Files
multica/server/internal/daemon/execenv/codex_home_sessions_test.go
Bohan Jiang 6cc553e5a3 fix(daemon): isolate Codex sessions per task to unblock initialize (MUL-4424) (#5360)
* fix(daemon): isolate Codex sessions per task to unblock initialize (MUL-4424)

Codex 0.143+ backfills a per-home session-state DB by enumerating every
rollout visible under sessions/ during `initialize`. The per-task
CODEX_HOME symlinked the shared ~/.codex/sessions in, so a machine with a
large accumulated history (one reporter: ~2000 rollouts / ~22 GiB) stalled
`initialize` for tens of seconds — the app-server started but the task
produced no output before it was cancelled (github #5273).

Give each task its own local sessions/ directory instead:

- Fresh task: create an empty local sessions/ so backfill is trivial.
- Reused task with a real sessions/ dir: it is authoritative — leave it.
- Reused task still holding a legacy symlink (older build): migrate in
  place. Replace the symlink with a real dir; when resuming, symlink only
  the single rollout being resumed (never copy — a rollout can be GiB and
  this is on initialize's critical path); and drop the stale, rebuildable
  session-state DB (state_*.sqlite*, session_index.jsonl) so Codex
  re-indexes the task-local sessions. Unrelated per-task DBs (goals_*,
  logs_*, memories_*) are left intact.

Also point the token-usage fallback scan at the backend's per-task
CODEX_HOME instead of the daemon-global home, so usage isn't lost now that
sessions are isolated there.

Complements the #5319 handshake watchdog (which turns a silent stall into a
loud, phased timeout); this removes the underlying cause.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): address Codex session isolation review (MUL-4424)

Resolves the three blockers from Elon's review of #5360:

1. local_directory context loss. local_directory tasks get a fresh
   codex-home per task ID (the daemon never reuses their workdir), so
   task-local isolation stranded every follow-up run with an empty
   sessions dir and silently restarted the conversation. Their only
   stable, GC-safe cross-task store is the user's own ~/.codex/sessions
   (a persistent store under WorkspacesRoot would be orphan-GC'd), so keep
   the shared-sessions symlink for them (IsLocalDirectory). Managed tasks
   stay isolated.

2. Migration resume robustness.
   - Rollout lookup now covers the flat layout and background-compressed
     .jsonl.zst rollouts, not just nested YYYY/MM/DD *.jsonl — both are
     legitimate Codex 0.144 history that were previously judged "not
     found", silently dropping resume.
   - Exposure hard-links first, then symlinks, never copies — hard links
     need no privilege and work on Windows within a volume, so the
     zero-copy path is exercised identically on CI.
   - The daemon now verifies the rollout is actually present in the task
     CODEX_HOME (execenv.CodexResumeRolloutPresent) before the brief is
     generated; if absent it clears the resume from both the backend and
     the brief instead of telling the agent it is continuing a lost thread.

3. session_index.jsonl is no longer deleted during migration — Codex uses
   it as the authoritative thread-id -> name store (not rebuildable from
   rollouts). Only the rebuildable state_*.sqlite* is reset.

Tests: 2-round local_directory resume across task IDs; compressed/flat
lookup; hard-link zero-copy (os.SameFile); session_index preserved;
CodexResumeRolloutPresent + the daemon gate helper (present keeps /
absent drops / non-codex + empty no-op).

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): scope Codex sessions to a per-issue store; disclose lost resumes (MUL-4424)

Addresses the three blockers from Elon's second review of #5360.

1. local_directory still enumerated the whole machine history. The prior
   fix re-linked the entire ~/.codex/sessions into every fresh local_directory
   codex-home, so Codex still backfilled from thousands of unrelated rollouts on
   `initialize` (measured ~8.3s with 3450 rollouts; the reporter's 22 GiB could
   exceed the 30s watchdog). Point sessions/ at a persistent, per-(agent, issue)
   store under the shared Codex home (multica-sessions/<agent>/<issue>) that holds
   only this issue's rollouts. It is keyed stably across task IDs and lives
   outside the task-scoped envRoot the GC reclaims, so follow-up runs resume it
   while `initialize` only ever sees this issue's history.

2. Windows cross-volume resume was lost. Exposing a single rollout by hard-link
   (same-volume only) then file symlink (needs Windows privilege) can't cross a
   volume boundary. The store now lives on the shared Codex volume, so the resume
   rollout is hard-linked there zero-copy, and sessions/ is exposed to the task
   home via a directory link — a symlink on Unix, a junction on Windows — which
   crosses volumes without privilege and never copies a (possibly GiB) rollout on
   initialize's critical path. There is no remaining per-file cross-volume link.

3. An unavailable resume was a silent downgrade. Both resume gates
   (gateResumeToReusedWorkdir, gateCodexResumeToRolloutPresence) now set
   PriorSessionResumeUnavailable, and the runtime brief renders a Session
   Continuity Notice telling the agent to disclose to the user, up front in its
   reply, that the previous conversation context could not be restored and this
   run starts fresh — turning a silent restart into a user-visible one. The task
   is not failed: it can still do useful work without the prior context.

Managed fresh / reused-real-dir tasks keep their task-local, GC-collected
sessions dir unchanged; only the legacy-symlink migration with a resume routes
through the store (cross-volume-safe), and a home already linked to the store is
treated as authoritative on reuse.

Tests: local_directory per-issue store (only this issue's history, no whole-
machine leak); no-key fallback to an empty dir; two-round resume across task IDs
through the store; legacy migration routed through the store with a zero-copy
hard link; reused store link stays authoritative; both gates set the
resume-unavailable flag; brief renders the continuity notice only when a resume
was lost. execenv + daemon + pkg/agent packages, go vet, and gofmt all pass.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): disclose live resume-RPC loss; bound Codex session store lifecycle (MUL-4424)

Addresses the two blockers from Elon's third review of #5360.

1. A real thread/resume failure was still a silent new session. The brief's
   Session Continuity Notice only covers losses the daemon detects before launch
   (workdir not reused, rollout absent). But when the rollout is present yet
   Codex rejects the live thread/resume (corrupt/incompatible rollout, server-side
   thread GC, schema drift), startOrResumeThread falls back to thread/start and
   the run succeeds on a fresh thread with no user-facing signal. Carry the
   original resume intent into the backend as ExecOptions.ResumeExpected (set from
   the post-gate PriorSessionID, so a pre-flight drop still routes through the
   brief and never double-notifies), and when a resume was expected but the
   backend landed on a fresh thread, prepend the same continuity notice to the
   first turn/start input. This also covers the daemon's transport-error
   fresh-session retry, which clears ResumeSessionID but not ResumeExpected.

2. The persistent per-issue store had no data lifecycle. multica-sessions stores
   live outside the task-scoped envRoot the GC reclaims (so resume survives across
   task IDs), which meant a done/abandoned issue's prompts and full rollouts (one
   reporter: a single 1.5 GiB rollout) accumulated forever and were never freed on
   issue/agent/workspace deletion. Add PruneCodexSessionStores: the daemon GC loop
   reclaims any store untouched for GCCodexSessionTTL (default 14 days, configurable
   via MULTICA_GC_CODEX_SESSION_TTL, 0 disables). A store's newest rollout mtime is
   its last activity, so an active or recently-resumed task keeps its store fresh
   and is never reclaimed, while a deleted issue's store ages out — an eventual
   reclamation guarantee without needing deletion events.

Tests: codexTurnInput discloses on resume fallback and stays silent on success /
fresh start (paired with the existing live-RPC fallback test); store pruning
reclaims aged stores, keeps recent ones, isolates issues, cleans empty agent
dirs, and is disable-able. execenv / daemon / pkg/agent, go vet, gofmt all pass.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): protect a reopened Codex session store from GC mid-mount (MUL-4424)

Addresses Elon's fourth-review blocker: reopening an issue idle past
GCCodexSessionTTL could lose context, because mounting its per-issue session
store (MkdirAll + rollout lookup + task-home link) never refreshed the store's
mtime, so a GC cycle firing before the resumed turn wrote its first rollout saw
a >TTL-old store and reclaimed it — a stat->remove race with no in-use guard.

Two complementary defenses:

- Activity refresh: linkCodexSessionsToStore now os.Chtimes the store to now
  after linking, so codexStoreStat (which reads the newest mtime as last
  activity) sees a just-used store. This fixes the sequential repro — a mount
  immediately followed by a prune keeps the store.

- In-process active-store guard: the daemon marks the per-issue store in-use
  (execenv.CodexSessionStorePath) from before Prepare/Reuse mounts it until the
  task ends, and PruneCodexSessionStores now takes an isActive predicate and
  skips any store a live task holds. Because prepare and prune run in the same
  process, this closes the remaining concurrent stat->remove window the mtime
  refresh alone cannot. Reference-counted, mirroring the env-root guard.

Tests: a reopened >TTL store survives a GC cycle after remount and stays
resumable; an idle-on-disk store marked active is skipped, then reclaimed once
inactive; the existing idle-reclaim / isolation / disable / empty-agent-dir
cases still pass. execenv + daemon, go vet, gofmt all pass.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): make Codex store delete atomic with mark-active (MUL-4424)

Addresses Elon's fifth-review blocker: the active-store guard's check and delete
were not one atomic step. PruneCodexSessionStores called isActive (which locked,
read, and unlocked) and only then RemoveAll'd, leaving a window where a task
could markActiveCodexStore between the check and the removal and still lose its
store — the exact mark-then-delete interleaving Elon reproduced.

Replace the point-in-time isActive predicate with a reserve-for-deletion
protocol that shares one lock with mark-active:

- reserveCodexStoreForDeletion(store) atomically refuses when a live task holds
  the store (or another delete already reserved it) and otherwise marks it
  reserved, all under one activeCodexStoresMu acquisition. PruneCodexSessionStores
  reserves before RemoveAll and commits after, so confirm-inactive and remove are
  effectively atomic against a concurrent mark.
- markActiveCodexStore now waits (on a sync.Cond) while a store is reserved, so a
  task never mounts a store mid-removal; committing the removal wakes it and the
  store is recreated fresh by Prepare (with the continuity notice).

So mark-before-reserve keeps the store (reserve refused); reserve-before-mark
removes it and blocks the late mark until the removal commits. The genuinely
idle case still reclaims.

Tests (daemon, run under -race): mark-then-reserve is refused; reserve blocks a
concurrent mark until commit then the store reads active; a second reserve is
refused mid-flight. The execenv prune tests move to the reserve seam; the
activity-refresh / reopen-then-prune / isolation / disable cases still pass.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): namespace Codex session stores per profile for cross-daemon safety (MUL-4424)

Addresses Elon's sixth-review blocker: the in-process reservation guard cannot
span processes, but Multica supports multiple profile daemons on one machine
(e.g. production + staging) that share the same ~/.codex. Each daemon's GC
scanned the whole multica-sessions root, so a staging daemon could reclaim a
store a production task was actively resuming — its reservation lived only in the
other process's memory.

Isolate by profile instead of trying to lock across processes:

- Store path is now <shared>/multica-sessions/<namespace>/<agent>/<issue>, where
  namespace is the daemon's profile (empty -> "default"). PrepareParams/ReuseParams
  carry Profile; codexSessionStoreKey and CodexSessionStorePath fold it in.
- PruneCodexSessionStores takes the profile and scans ONLY that namespace, so a
  daemon never even sees another profile's stores, let alone deletes them. The
  per-profile trees are disjoint, so the in-process guard is sufficient within a
  namespace (profiles get separate daemon state, so no two daemons share one).

Test: a "staging"-owned idle store is untouched by a default-profile prune and
reclaimed only by staging's own prune. Existing prune/guard/reopen tests move
under the namespace. execenv + daemon under -race, go vet, gofmt all pass.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): make the Codex store profile→namespace map injective (MUL-4424)

Addresses Elon's seventh-review blocker: the per-profile namespace was derived by
dropping unsafe characters, which is not injective. The CLI treats the empty
(default) profile and a profile literally named "default" as separate daemons,
yet both mapped to namespace "default"; likewise "staging.prod" and "stagingprod"
both mapped to "stagingprod". Two distinct daemons then shared one store tree, so
one could again reclaim the other's live session — the cross-process blocker
reopened for those profile names.

Make codexSessionStoreNamespace injective: the empty profile gets a reserved
bare literal "default", and every named profile is hex-encoded (bijective,
filesystem-safe) under a "p_" prefix a bare literal can never collide with. So
"" -> "default" while "default" -> "p_64656661756c74", and "staging.prod" /
"stagingprod" get distinct hex segments. sanitizeCodexPathSegment stays for the
UUID agent/issue segments (injective for real UUIDs); only the user-controlled
profile needed the encoding.

Tests: codexSessionStoreNamespace is distinct for "" vs "default", punctuation
variants, case variants, and an encoded-looking name; and end-to-end, pruning one
profile never reclaims the other's store for the "" vs "default" and
"staging.prod" vs "stagingprod" pairs. execenv + daemon under -race, go vet,
gofmt all pass.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): fixed-length Codex store namespace so long profiles fit (MUL-4424)

Addresses Elon's eighth-review blocker: hex-encoding the full profile doubled the
namespace segment length. A profile can be as long as a filesystem segment allows
(~255 bytes) and the CLI persists it as its own config dir, but the store
namespace "p_" + hex(profile) reached 2 + 127*2 = 256 bytes at 127 chars,
overflowing the 255-byte single-segment limit — the profile's own dir created
fine, then the session store failed with "file name too long".

Derive the namespace from a fixed-length hash instead: a named profile is now
"p_" + hex(sha256(profile)) — a constant 64 hex chars (66 with the prefix),
filesystem-safe and collision-resistant. The empty (default) profile keeps its
reserved bare literal "default", which the "p_"-prefixed 66-char segment can
never equal. Still injective across the CLI's distinct-daemon cases; just no
longer length-expanding.

Test: the namespace stays <=255 bytes and creatable for profiles up to the
255-byte segment limit (127- and 255-char cases that overflowed under hex); the
prior injectivity and cross-profile prune-isolation tests still hold. execenv +
daemon under -race, go vet, gofmt all pass.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 00:48:30 +08:00

791 lines
33 KiB
Go

package execenv
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// seedFakeRollout writes a fake Codex rollout for sessionID under
// sharedSessions/YYYY/MM/DD, mirroring Codex's real layout, and returns its path.
func seedFakeRollout(t *testing.T, sharedSessions, y, m, d, sessionID string, size int) string {
t.Helper()
dir := filepath.Join(sharedSessions, y, m, d)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir rollout dir: %v", err)
}
path := filepath.Join(dir, "rollout-"+y+"-"+m+"-"+d+"T00-00-00-"+sessionID+".jsonl")
body := make([]byte, size)
for i := range body {
body[i] = 'x'
}
if err := os.WriteFile(path, body, 0o644); err != nil {
t.Fatalf("write rollout: %v", err)
}
return path
}
// seedLegacySessionsSymlink recreates the pre-MUL-4424 layout: codex-home's
// sessions is a symlink into the shared ~/.codex/sessions. Skips on Windows
// sessions where symlink creation is unavailable.
func seedLegacySessionsSymlink(t *testing.T, codexHome, sharedSessions string) {
t.Helper()
if err := os.MkdirAll(codexHome, 0o755); err != nil {
t.Fatalf("mkdir codex-home: %v", err)
}
if err := os.MkdirAll(sharedSessions, 0o755); err != nil {
t.Fatalf("mkdir shared sessions: %v", err)
}
if err := os.Symlink(sharedSessions, filepath.Join(codexHome, "sessions")); err != nil {
if runtime.GOOS == "windows" {
t.Skipf("directory symlink unavailable on this Windows session: %v", err)
}
t.Fatalf("seed legacy sessions symlink: %v", err)
}
}
func TestPrepareCodexSessionsDir_FreshCreatesEmptyLocalDir(t *testing.T) {
t.Parallel()
codexHome := filepath.Join(t.TempDir(), "codex-home")
if err := os.MkdirAll(codexHome, 0o755); err != nil {
t.Fatalf("mkdir codex-home: %v", err)
}
sharedHome := t.TempDir()
if err := prepareCodexSessionsDir(codexHome, sharedHome, CodexHomeOptions{}, testLogger()); err != nil {
t.Fatalf("prepareCodexSessionsDir: %v", err)
}
sessions := filepath.Join(codexHome, "sessions")
fi, err := os.Lstat(sessions)
if err != nil {
t.Fatalf("sessions not created: %v", err)
}
if fi.Mode()&os.ModeSymlink != 0 {
t.Error("fresh sessions must be a real dir, not a symlink")
}
if !fi.IsDir() {
t.Error("fresh sessions must be a directory")
}
entries, _ := os.ReadDir(sessions)
if len(entries) != 0 {
t.Errorf("fresh sessions must be empty, has %d entries", len(entries))
}
}
func TestPrepareCodexSessionsDir_RealDirIsAuthoritative(t *testing.T) {
t.Parallel()
codexHome := filepath.Join(t.TempDir(), "codex-home")
sessions := filepath.Join(codexHome, "sessions", "2026", "07", "13")
if err := os.MkdirAll(sessions, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
// A rollout the prior (isolated) run already wrote into the task-local dir.
own := filepath.Join(sessions, "rollout-2026-07-13T00-00-00-own-session.jsonl")
if err := os.WriteFile(own, []byte("keep me"), 0o644); err != nil {
t.Fatalf("write own rollout: %v", err)
}
if err := prepareCodexSessionsDir(codexHome, t.TempDir(), CodexHomeOptions{}, testLogger()); err != nil {
t.Fatalf("prepareCodexSessionsDir: %v", err)
}
// The task-local dir is authoritative — its contents must survive untouched.
if data, err := os.ReadFile(own); err != nil || string(data) != "keep me" {
t.Errorf("task-local rollout must be preserved, got err=%v data=%q", err, data)
}
fi, _ := os.Lstat(filepath.Join(codexHome, "sessions"))
if fi.Mode()&os.ModeSymlink != 0 {
t.Error("authoritative sessions must remain a real dir")
}
}
func TestPrepareCodexSessionsDir_MigratesLegacySymlinkNoResume(t *testing.T) {
t.Parallel()
root := t.TempDir()
codexHome := filepath.Join(root, "codex-home")
sharedSessions := filepath.Join(root, "shared", "sessions")
// Simulate a machine with accumulated global history.
seedFakeRollout(t, sharedSessions, "2026", "07", "13", "other-session-a", 16)
seedFakeRollout(t, sharedSessions, "2026", "07", "12", "other-session-b", 16)
seedLegacySessionsSymlink(t, codexHome, sharedSessions)
// Session-derived state that indexed the whole global history, plus an
// unrelated per-task DB that must NOT be touched.
writeFile(t, filepath.Join(codexHome, "state_5.sqlite"), "stale")
writeFile(t, filepath.Join(codexHome, "state_5.sqlite-wal"), "stale")
writeFile(t, filepath.Join(codexHome, "session_index.jsonl"), "names")
writeFile(t, filepath.Join(codexHome, "goals_1.sqlite"), "keep")
if err := prepareCodexSessionsDir(codexHome, filepath.Dir(sharedSessions), CodexHomeOptions{}, testLogger()); err != nil {
t.Fatalf("prepareCodexSessionsDir: %v", err)
}
sessions := filepath.Join(codexHome, "sessions")
fi, err := os.Lstat(sessions)
if err != nil {
t.Fatalf("sessions missing after migration: %v", err)
}
if fi.Mode()&os.ModeSymlink != 0 {
t.Error("legacy symlink must be replaced with a real dir")
}
// No resume requested -> none of the global history is pulled in.
entries, _ := os.ReadDir(sessions)
if len(entries) != 0 {
t.Errorf("non-resume migration must yield an empty sessions dir, has %d entries", len(entries))
}
// The global history itself must be left intact.
if _, err := os.Stat(filepath.Join(sharedSessions, "2026", "07", "13", "rollout-2026-07-13T00-00-00-other-session-a.jsonl")); err != nil {
t.Errorf("shared history must not be deleted: %v", err)
}
// Rebuildable SQLite state dropped; the thread-name index and unrelated DBs
// are preserved.
assertAbsent(t, filepath.Join(codexHome, "state_5.sqlite"))
assertAbsent(t, filepath.Join(codexHome, "state_5.sqlite-wal"))
assertPresent(t, filepath.Join(codexHome, "session_index.jsonl"))
assertPresent(t, filepath.Join(codexHome, "goals_1.sqlite"))
}
func TestPrepareCodexSessionsDir_MigrateWithResumeRoutesThroughStore(t *testing.T) {
t.Parallel()
root := t.TempDir()
sharedHome := filepath.Join(root, "shared")
codexHome := filepath.Join(root, "codex-home")
sharedSessions := filepath.Join(sharedHome, "sessions")
resumeID := "019f59d9-a6aa-7a53-b173-1eccc4b4c873"
resumeSrc := seedFakeRollout(t, sharedSessions, "2026", "07", "13", resumeID, 32)
seedFakeRollout(t, sharedSessions, "2026", "07", "11", "unrelated-session", 32)
seedLegacySessionsSymlink(t, codexHome, sharedSessions)
writeFile(t, filepath.Join(codexHome, "state_5.sqlite"), "stale")
key := filepath.Join("agent-1", "issue-1")
err := prepareCodexSessionsDir(codexHome, sharedHome, CodexHomeOptions{ResumeSessionID: resumeID, SessionStoreKey: key}, testLogger())
if err != nil {
t.Fatalf("prepareCodexSessionsDir: %v", err)
}
// sessions/ is now a directory link to the per-issue store on the shared
// volume — the cross-volume-safe exposure (a same-volume hard link into the
// store + a directory link into the task home), never a task-local file copy.
sessions := filepath.Join(codexHome, "sessions")
assertSessionsLinkedToStore(t, sessions, codexSessionStoreDir(sharedHome, key))
// The resume rollout is present in the task home (through the link) and
// materialised as a zero-copy hard link from the shared history.
if !CodexResumeRolloutPresent(codexHome, resumeID) {
t.Fatal("resume rollout must be visible in the task CODEX_HOME after migration")
}
stored := filepath.Join(codexSessionStoreDir(sharedHome, key), "2026", "07", "13", filepath.Base(resumeSrc))
assertZeroCopyLink(t, resumeSrc, stored)
// The unrelated session must NOT be pulled in.
if CodexResumeRolloutPresent(codexHome, "unrelated-session") {
t.Error("unrelated shared history must not be exposed to the task")
}
// Stale state dropped so Codex rebuilds from the scoped store.
assertAbsent(t, filepath.Join(codexHome, "state_5.sqlite"))
}
func TestExposeResumeRollout_NoMatchReturnsError(t *testing.T) {
t.Parallel()
shared := filepath.Join(t.TempDir(), "sessions")
if err := os.MkdirAll(shared, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
local := filepath.Join(t.TempDir(), "sessions")
if err := os.MkdirAll(local, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := exposeResumeRollout(shared, local, "missing-session", testLogger()); err == nil {
t.Error("expected error when no rollout matches the resume session ID")
}
}
func TestExposeResumeRollout_LinksLargeFileWithoutCopying(t *testing.T) {
t.Parallel()
root := t.TempDir()
shared := filepath.Join(root, "shared", "sessions")
local := filepath.Join(root, "local", "sessions")
if err := os.MkdirAll(local, 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
// A deliberately large rollout — copying it onto the critical path is
// exactly what MUL-4424 forbids.
const big = 4 << 20 // 4 MiB
src := seedFakeRollout(t, shared, "2026", "07", "13", "big-session", big)
if err := exposeResumeRollout(shared, local, "big-session", testLogger()); err != nil {
t.Fatalf("exposeResumeRollout: %v", err)
}
// Must share an inode with the source — a hard link (or symlink) — never a
// 4 MiB copy. A hard link needs no special privilege and works on Windows
// within a volume, so this path is exercised identically on CI.
dst := filepath.Join(local, "2026", "07", "13", "rollout-2026-07-13T00-00-00-big-session.jsonl")
if _, err := os.Lstat(dst); err != nil {
t.Fatalf("exposed rollout missing: %v", err)
}
assertZeroCopyLink(t, src, dst)
}
func TestResetCodexSessionState_OnlyRemovesSessionDerived(t *testing.T) {
t.Parallel()
home := t.TempDir()
sessionDerived := []string{
"state_5.sqlite", "state_5.sqlite-shm", "state_5.sqlite-wal",
"state_6.sqlite",
}
preserved := []string{
// session_index.jsonl holds thread-name mappings not rebuildable from
// rollouts, so it must survive the SQLite-only reset.
"session_index.jsonl",
"goals_1.sqlite", "logs_2.sqlite", "memories_1.sqlite",
"config.toml", "auth.json", "models_cache.json",
}
for _, n := range append(append([]string{}, sessionDerived...), preserved...) {
writeFile(t, filepath.Join(home, n), "x")
}
resetCodexSessionState(home, testLogger())
for _, n := range sessionDerived {
assertAbsent(t, filepath.Join(home, n))
}
for _, n := range preserved {
assertPresent(t, filepath.Join(home, n))
}
}
func TestPrepareCodexSessionsDir_MigratePreservesSessionIndex(t *testing.T) {
t.Parallel()
root := t.TempDir()
codexHome := filepath.Join(root, "codex-home")
sharedSessions := filepath.Join(root, "shared", "sessions")
seedFakeRollout(t, sharedSessions, "2026", "07", "13", "some-session", 16)
seedLegacySessionsSymlink(t, codexHome, sharedSessions)
writeFile(t, filepath.Join(codexHome, "state_5.sqlite"), "stale")
writeFile(t, filepath.Join(codexHome, "session_index.jsonl"), `{"id":"x","thread_name":"My thread"}`)
if err := prepareCodexSessionsDir(codexHome, filepath.Dir(sharedSessions), CodexHomeOptions{}, testLogger()); err != nil {
t.Fatalf("prepareCodexSessionsDir: %v", err)
}
// SQLite state is rebuilt; the thread-name index (not rebuildable from
// rollouts) is preserved.
assertAbsent(t, filepath.Join(codexHome, "state_5.sqlite"))
assertPresent(t, filepath.Join(codexHome, "session_index.jsonl"))
if data, _ := os.ReadFile(filepath.Join(codexHome, "session_index.jsonl")); string(data) != `{"id":"x","thread_name":"My thread"}` {
t.Errorf("session_index.jsonl content changed: %q", data)
}
}
func TestExposeResumeRollout_FindsCompressedAndFlatLayouts(t *testing.T) {
t.Parallel()
root := t.TempDir()
shared := filepath.Join(root, "shared", "sessions")
local := filepath.Join(root, "local", "sessions")
if err := os.MkdirAll(local, 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
// Codex background-compresses cold rollouts to .jsonl.zst and also supports
// a flat layout (rollout directly under sessions/). Both are legit history.
compressed := seedRolloutAt(t, filepath.Join(shared, "2026", "07", "13", "rollout-2026-07-13T00-00-00-zst-session.jsonl.zst"), 8)
flat := seedRolloutAt(t, filepath.Join(shared, "rollout-2026-07-13T00-00-00-flat-session.jsonl"), 8)
for _, tc := range []struct {
id, src, rel string
}{
{"zst-session", compressed, filepath.Join("2026", "07", "13", "rollout-2026-07-13T00-00-00-zst-session.jsonl.zst")},
{"flat-session", flat, "rollout-2026-07-13T00-00-00-flat-session.jsonl"},
} {
if err := exposeResumeRollout(shared, local, tc.id, testLogger()); err != nil {
t.Fatalf("expose %s: %v", tc.id, err)
}
assertZeroCopyLink(t, tc.src, filepath.Join(local, tc.rel))
}
}
func TestCodexResumeRolloutPresent(t *testing.T) {
t.Parallel()
root := t.TempDir()
codexHome := filepath.Join(root, "codex-home")
sessions := filepath.Join(codexHome, "sessions")
seedRolloutAt(t, filepath.Join(sessions, "2026", "07", "13", "rollout-2026-07-13T00-00-00-nested.jsonl"), 8)
seedRolloutAt(t, filepath.Join(sessions, "rollout-2026-07-13T00-00-00-flat.jsonl.zst"), 8)
for _, id := range []string{"nested", "flat"} {
if !CodexResumeRolloutPresent(codexHome, id) {
t.Errorf("expected rollout %q to be found", id)
}
}
if CodexResumeRolloutPresent(codexHome, "absent") {
t.Error("absent session must not be reported present")
}
if CodexResumeRolloutPresent("", "nested") || CodexResumeRolloutPresent(codexHome, "") {
t.Error("empty codexHome/sessionID must be reported absent")
}
}
func TestPrepareCodexSessionsDir_LocalDirectoryUsesPerIssueStore(t *testing.T) {
t.Parallel()
root := t.TempDir()
codexHome := filepath.Join(root, "codex-home")
if err := os.MkdirAll(codexHome, 0o755); err != nil {
t.Fatalf("mkdir codex-home: %v", err)
}
sharedHome := filepath.Join(root, "shared")
// A machine with accumulated global history that must NOT be exposed to the task.
seedFakeRollout(t, filepath.Join(sharedHome, "sessions"), "2026", "07", "13", "other-a", 16)
seedFakeRollout(t, filepath.Join(sharedHome, "sessions"), "2026", "07", "12", "other-b", 16)
key := filepath.Join("agent-1", "issue-1")
if err := prepareCodexSessionsDir(codexHome, sharedHome, CodexHomeOptions{IsLocalDirectory: true, SessionStoreKey: key}, testLogger()); err != nil {
t.Fatalf("prepareCodexSessionsDir: %v", err)
}
// sessions/ links the per-issue store, not the shared ~/.codex/sessions.
sessions := filepath.Join(codexHome, "sessions")
assertSessionsLinkedToStore(t, sessions, codexSessionStoreDir(sharedHome, key))
// The store holds only this issue's history — the machine's global rollouts
// are invisible, so `initialize` never enumerates them (the MUL-4424 stall).
entries, _ := os.ReadDir(sessions)
if len(entries) != 0 {
t.Errorf("per-issue store must start empty, has %d entries (whole-history leak?)", len(entries))
}
if CodexResumeRolloutPresent(codexHome, "other-a") {
t.Error("machine-global history must not be visible to a local_directory task")
}
}
// With no stable per-issue key (e.g. a non-issue task), a local_directory task
// must NOT collapse back to exposing the whole shared history — it falls back to
// an empty local dir.
func TestPrepareCodexSessionsDir_LocalDirectoryNoKeyFallsBackToEmptyDir(t *testing.T) {
t.Parallel()
root := t.TempDir()
codexHome := filepath.Join(root, "codex-home")
if err := os.MkdirAll(codexHome, 0o755); err != nil {
t.Fatalf("mkdir codex-home: %v", err)
}
sharedHome := filepath.Join(root, "shared")
seedFakeRollout(t, filepath.Join(sharedHome, "sessions"), "2026", "07", "13", "other", 16)
if err := prepareCodexSessionsDir(codexHome, sharedHome, CodexHomeOptions{IsLocalDirectory: true}, testLogger()); err != nil {
t.Fatalf("prepareCodexSessionsDir: %v", err)
}
sessions := filepath.Join(codexHome, "sessions")
fi, err := os.Lstat(sessions)
if err != nil {
t.Fatalf("sessions not created: %v", err)
}
if fi.Mode()&os.ModeSymlink != 0 {
t.Error("no-key local_directory must not link the shared history")
}
if CodexResumeRolloutPresent(codexHome, "other") {
t.Error("machine-global history must not be visible")
}
}
// Two consecutive local_directory tasks share one project dir but get a fresh
// codex-home each (the daemon never reuses their workdir). The second run must
// still see the first run's rollout via the per-issue store — this is the
// regression Elon's review flagged.
func TestPrepareCodexSessionsDir_LocalDirectoryResumeAcrossTaskIDs(t *testing.T) {
t.Parallel()
root := t.TempDir()
sharedHome := filepath.Join(root, "shared")
sessionID := "019f59d9-a6aa-7a53-b173-1eccc4b4c873"
key := filepath.Join("agent-1", "issue-1")
// Round 1: a fresh per-task codex-home links the per-issue store.
home1 := filepath.Join(root, "task-1", "codex-home")
if err := os.MkdirAll(home1, 0o755); err != nil {
t.Fatalf("mkdir home1: %v", err)
}
if err := prepareCodexSessionsDir(home1, sharedHome, CodexHomeOptions{IsLocalDirectory: true, SessionStoreKey: key}, testLogger()); err != nil {
t.Fatalf("round 1 prepare: %v", err)
}
// Codex writes the round-1 rollout through the link into the store.
seedRolloutAt(t, filepath.Join(home1, "sessions", "2026", "07", "13", "rollout-2026-07-13T00-00-00-"+sessionID+".jsonl"), 32)
// Round 2: a brand-new task ID → brand-new codex-home, same issue key.
home2 := filepath.Join(root, "task-2", "codex-home")
if err := os.MkdirAll(home2, 0o755); err != nil {
t.Fatalf("mkdir home2: %v", err)
}
if err := prepareCodexSessionsDir(home2, sharedHome, CodexHomeOptions{IsLocalDirectory: true, SessionStoreKey: key, ResumeSessionID: sessionID}, testLogger()); err != nil {
t.Fatalf("round 2 prepare: %v", err)
}
// The round-2 home must resolve the round-1 rollout through the shared store —
// otherwise the daemon would silently drop the conversation.
if !CodexResumeRolloutPresent(home2, sessionID) {
t.Fatal("round-2 local_directory task cannot see round-1 rollout — context would be silently lost")
}
}
// A managed home migrated on a prior reuse already links the per-issue store; a
// subsequent reuse must treat that link as authoritative and not re-migrate it.
func TestPrepareCodexSessionsDir_ReusedStoreLinkIsAuthoritative(t *testing.T) {
t.Parallel()
root := t.TempDir()
sharedHome := filepath.Join(root, "shared")
codexHome := filepath.Join(root, "codex-home")
if err := os.MkdirAll(codexHome, 0o755); err != nil {
t.Fatalf("mkdir codex-home: %v", err)
}
key := filepath.Join("agent-1", "issue-1")
storeDir := codexSessionStoreDir(sharedHome, key)
if err := os.MkdirAll(storeDir, 0o755); err != nil {
t.Fatalf("mkdir store: %v", err)
}
if err := os.Symlink(storeDir, filepath.Join(codexHome, "sessions")); err != nil {
if runtime.GOOS == "windows" {
t.Skipf("directory symlink unavailable on this Windows session: %v", err)
}
t.Fatalf("seed store link: %v", err)
}
sessionID := "019f59d9-a6aa-7a53-b173-1eccc4b4c873"
seedRolloutAt(t, filepath.Join(storeDir, "2026", "07", "13", "rollout-2026-07-13T00-00-00-"+sessionID+".jsonl"), 16)
if err := prepareCodexSessionsDir(codexHome, sharedHome, CodexHomeOptions{SessionStoreKey: key, ResumeSessionID: sessionID}, testLogger()); err != nil {
t.Fatalf("prepareCodexSessionsDir: %v", err)
}
assertSessionsLinkedToStore(t, filepath.Join(codexHome, "sessions"), storeDir)
if !CodexResumeRolloutPresent(codexHome, sessionID) {
t.Error("existing store rollout must remain resumable after reuse")
}
}
// PruneCodexSessionStores must reclaim per-issue stores idle past retention,
// keep recently-touched ones (active/resumable tasks), isolate issues from one
// another, and be disable-able — the data lifecycle Elon's review required so
// the persistent store can't grow forever (MUL-4424).
func TestPruneCodexSessionStores(t *testing.T) {
home := t.TempDir()
t.Setenv("CODEX_HOME", home)
// Stores live under the profile namespace; the default profile is "".
storeRoot := filepath.Join(home, codexSessionStoreRoot, codexSessionStoreNamespace(""))
freshStore := filepath.Join(storeRoot, "agent-1", "issue-fresh")
staleStore := filepath.Join(storeRoot, "agent-1", "issue-stale")
otherStore := filepath.Join(storeRoot, "agent-2", "issue-x")
seedRolloutAt(t, filepath.Join(freshStore, "2026", "07", "14", "rollout-2026-07-14T00-00-00-a.jsonl"), 16)
seedRolloutAt(t, filepath.Join(staleStore, "2026", "06", "01", "rollout-2026-06-01T00-00-00-b.jsonl"), 16)
seedRolloutAt(t, filepath.Join(otherStore, "2026", "07", "14", "rollout-2026-07-14T00-00-00-c.jsonl"), 16)
now := time.Now()
retention := 14 * 24 * time.Hour
// Age only the stale store's whole tree well past retention.
chtimesTree(t, staleStore, now.Add(-30*24*time.Hour))
removed, bytes := PruneCodexSessionStores("", retention, now, nil, testLogger())
if removed != 1 {
t.Fatalf("removed = %d, want 1 (only the store idle past retention)", removed)
}
if bytes <= 0 {
t.Errorf("bytesFreed = %d, want > 0", bytes)
}
// The stale store is gone; the fresh one and the other agent's store survive
// (per-issue isolation), and agent-1 stays because issue-fresh remains.
assertAbsent(t, staleStore)
assertPresent(t, freshStore)
assertPresent(t, otherStore)
assertPresent(t, filepath.Join(storeRoot, "agent-1"))
// retention <= 0 disables pruning even for an aged store.
chtimesTree(t, freshStore, now.Add(-30*24*time.Hour))
if removed, _ := PruneCodexSessionStores("", 0, now, nil, testLogger()); removed != 0 {
t.Errorf("retention<=0 must disable pruning, removed=%d", removed)
}
assertPresent(t, freshStore)
}
// TestPruneCodexSessionStores_ReopenedStoreNotReclaimed is Elon's blocker repro:
// a store idle past the TTL that a user reopens must NOT be reclaimed by a GC
// cycle that fires before the resumed turn writes its first rollout — mounting
// the store refreshes its activity (MUL-4424).
func TestPruneCodexSessionStores_ReopenedStoreNotReclaimed(t *testing.T) {
home := t.TempDir()
t.Setenv("CODEX_HOME", home)
storeDir := codexSessionStoreDir(home, codexSessionStoreKey("", "agent-1", "issue-old"))
sessionID := "019f59d9-a6aa-7a53-b173-1eccc4b4c873"
// A long-idle (30-day) store that still holds a resumable rollout.
seedRolloutAt(t, filepath.Join(storeDir, "2026", "06", "01", "rollout-2026-06-01T00-00-00-"+sessionID+".jsonl"), 16)
chtimesTree(t, storeDir, time.Now().Add(-30*24*time.Hour))
// The user reopens the issue: a fresh task home remounts the store to resume.
codexHome := filepath.Join(home, "task", "codex-home")
if err := os.MkdirAll(codexHome, 0o755); err != nil {
t.Fatalf("mkdir codex-home: %v", err)
}
if err := linkCodexSessionsToStore(filepath.Join(codexHome, "sessions"), storeDir, filepath.Join(home, "sessions"), sessionID, testLogger()); err != nil {
t.Fatalf("linkCodexSessionsToStore: %v", err)
}
if !CodexResumeRolloutPresent(codexHome, sessionID) {
t.Fatal("resume rollout must be visible after remount")
}
// A GC cycle now — before the resumed turn writes anything — must keep it.
if removed, _ := PruneCodexSessionStores("", 14*24*time.Hour, time.Now(), nil, testLogger()); removed != 0 {
t.Fatalf("removed = %d, want 0 (a just-reopened store must survive GC)", removed)
}
assertPresent(t, storeDir)
if !CodexResumeRolloutPresent(codexHome, sessionID) {
t.Error("resume rollout must still be present after GC")
}
}
// TestPruneCodexSessionStores_ActiveStoreNotReclaimed proves the reservation
// guard closes the stat->remove race: a store idle on disk that reserve refuses
// (a live task holds it) is skipped, then reclaimed once reservable. The
// daemon's atomic reserve-vs-mark protocol is unit-tested separately.
func TestPruneCodexSessionStores_ActiveStoreNotReclaimed(t *testing.T) {
home := t.TempDir()
t.Setenv("CODEX_HOME", home)
storeDir := codexSessionStoreDir(home, codexSessionStoreKey("", "agent-1", "issue-active"))
seedRolloutAt(t, filepath.Join(storeDir, "2026", "06", "01", "rollout-2026-06-01T00-00-00-x.jsonl"), 16)
// Idle past retention on disk (no remount refresh), but currently in use.
chtimesTree(t, storeDir, time.Now().Add(-30*24*time.Hour))
// reserve refuses the in-use store (ok=false) and allows anything else.
held := func(p string) (func(), bool) {
if sameCodexPath(p, storeDir) {
return nil, false
}
return func() {}, true
}
if removed, _ := PruneCodexSessionStores("", 14*24*time.Hour, time.Now(), held, testLogger()); removed != 0 {
t.Fatalf("removed = %d, want 0 (a reserved/in-use store must never be reclaimed)", removed)
}
assertPresent(t, storeDir)
// Once reservable, the same idle store is reclaimed.
if removed, _ := PruneCodexSessionStores("", 14*24*time.Hour, time.Now(), nil, testLogger()); removed != 1 {
t.Fatalf("removed = %d, want 1 (idle store reclaimed when reservable)", removed)
}
assertAbsent(t, storeDir)
}
// TestPruneCodexSessionStores_IsolatesProfiles is Elon's cross-profile blocker:
// two profile-daemons share one ~/.codex, so one daemon's GC must never reclaim
// another profile's store — the in-process reservation guard cannot span
// processes, but the per-profile namespace makes their store trees disjoint so a
// GC only ever sees, and reclaims, its own (MUL-4424).
func TestPruneCodexSessionStores_IsolatesProfiles(t *testing.T) {
home := t.TempDir()
t.Setenv("CODEX_HOME", home)
// A store owned by the "staging" profile daemon, idle past retention.
stagingStore := codexSessionStoreDir(home, codexSessionStoreKey("staging", "agent-1", "issue-1"))
seedRolloutAt(t, filepath.Join(stagingStore, "2026", "06", "01", "rollout-2026-06-01T00-00-00-s.jsonl"), 16)
chtimesTree(t, stagingStore, time.Now().Add(-30*24*time.Hour))
// The production (default) daemon prunes — it must NOT touch staging's store,
// even with no guard, because staging is a different namespace it never scans.
if removed, _ := PruneCodexSessionStores("", 14*24*time.Hour, time.Now(), nil, testLogger()); removed != 0 {
t.Fatalf("removed = %d, want 0 — another profile's store must be out of scope", removed)
}
assertPresent(t, stagingStore)
// The staging daemon prunes its own namespace → reclaims its idle store.
if removed, _ := PruneCodexSessionStores("staging", 14*24*time.Hour, time.Now(), nil, testLogger()); removed != 1 {
t.Fatalf("removed = %d, want 1 — the owning profile reclaims its idle store", removed)
}
assertAbsent(t, stagingStore)
}
// TestCodexSessionStoreNamespaceInjective guards the profile->namespace map
// against the collisions Elon flagged: the CLI treats these profile pairs as
// distinct daemons, so they must never share a store namespace (a lossy
// character-dropping scheme merged them). MUL-4424.
func TestCodexSessionStoreNamespaceInjective(t *testing.T) {
t.Parallel()
pairs := [][2]string{
{"", "default"}, // empty (default) vs a profile literally named "default"
{"staging.prod", "stagingprod"}, // punctuation must not be dropped into a collision
{"a-b", "a_b"},
{"prod", "Prod"},
{"p_default", "default"}, // an encoded-looking name must not alias another
}
for _, p := range pairs {
if a, b := codexSessionStoreNamespace(p[0]), codexSessionStoreNamespace(p[1]); a == b {
t.Errorf("profiles %q and %q collide in namespace %q", p[0], p[1], a)
}
}
// Deterministic for a fixed profile.
if codexSessionStoreNamespace("staging") != codexSessionStoreNamespace("staging") {
t.Error("namespace must be deterministic for a fixed profile")
}
}
// TestCodexSessionStoreNamespace_FitsDirectorySegment guards Elon's round-8
// blocker: a profile the CLI can persist as its own config-dir segment (up to
// the ~255-byte filesystem limit) must yield a namespace that also fits one
// segment and is creatable — a length-expanding encoding (full hex) overflowed
// at 127 bytes. MUL-4424.
func TestCodexSessionStoreNamespace_FitsDirectorySegment(t *testing.T) {
t.Parallel()
base := t.TempDir()
for _, profile := range []string{"", "staging", strings.Repeat("a", 127), strings.Repeat("z", 255)} {
ns := codexSessionStoreNamespace(profile)
if len(ns) > 255 {
t.Errorf("profile (len %d) -> namespace (len %d) exceeds the 255-byte single-segment limit", len(profile), len(ns))
}
if err := os.MkdirAll(filepath.Join(base, ns), 0o755); err != nil {
t.Errorf("namespace for profile (len %d) could not be created: %v", len(profile), err)
}
}
}
// TestPruneCodexSessionStores_NoCrossProfileCollision proves the injective
// namespace holds end-to-end: profiles the CLI treats as distinct never reclaim
// each other's stores, including the "" vs "default" and punctuation cases that
// a lossy sanitizer collapsed (Elon's round-7 repro). MUL-4424.
func TestPruneCodexSessionStores_NoCrossProfileCollision(t *testing.T) {
for _, pair := range [][2]string{{"", "default"}, {"staging.prod", "stagingprod"}} {
home := t.TempDir()
t.Setenv("CODEX_HOME", home)
a, b := pair[0], pair[1]
storeA := codexSessionStoreDir(home, codexSessionStoreKey(a, "agent", "issue"))
storeB := codexSessionStoreDir(home, codexSessionStoreKey(b, "agent", "issue"))
seedRolloutAt(t, filepath.Join(storeA, "2026", "06", "01", "rollout-2026-06-01T00-00-00-a.jsonl"), 16)
seedRolloutAt(t, filepath.Join(storeB, "2026", "06", "01", "rollout-2026-06-01T00-00-00-b.jsonl"), 16)
chtimesTree(t, storeA, time.Now().Add(-30*24*time.Hour))
chtimesTree(t, storeB, time.Now().Add(-30*24*time.Hour))
// Pruning profile a reclaims a's store only — b's must survive.
if removed, _ := PruneCodexSessionStores(a, 14*24*time.Hour, time.Now(), nil, testLogger()); removed != 1 {
t.Fatalf("prune %q removed=%d, want 1", a, removed)
}
assertAbsent(t, storeA)
assertPresent(t, storeB)
// Then pruning profile b reclaims b's store.
if removed, _ := PruneCodexSessionStores(b, 14*24*time.Hour, time.Now(), nil, testLogger()); removed != 1 {
t.Fatalf("prune %q removed=%d, want 1", b, removed)
}
assertAbsent(t, storeB)
}
}
// TestPruneCodexSessionStores_RemovesEmptyAgentDir confirms an agent directory
// is cleaned up once its last issue store ages out, so the tree doesn't leave
// empty <agent>/ shells behind.
func TestPruneCodexSessionStores_RemovesEmptyAgentDir(t *testing.T) {
home := t.TempDir()
t.Setenv("CODEX_HOME", home)
storeRoot := filepath.Join(home, codexSessionStoreRoot, codexSessionStoreNamespace(""))
onlyStore := filepath.Join(storeRoot, "agent-lonely", "issue-1")
seedRolloutAt(t, filepath.Join(onlyStore, "2026", "06", "01", "rollout-2026-06-01T00-00-00-x.jsonl"), 16)
now := time.Now()
chtimesTree(t, onlyStore, now.Add(-30*24*time.Hour))
if removed, _ := PruneCodexSessionStores("", 14*24*time.Hour, now, nil, testLogger()); removed != 1 {
t.Fatalf("removed = %d, want 1", removed)
}
assertAbsent(t, filepath.Join(storeRoot, "agent-lonely"))
}
// chtimesTree sets the atime/mtime of every entry under root to ts.
func chtimesTree(t *testing.T, root string, ts time.Time) {
t.Helper()
err := filepath.WalkDir(root, func(path string, _ os.DirEntry, err error) error {
if err != nil {
return err
}
return os.Chtimes(path, ts, ts)
})
if err != nil {
t.Fatalf("chtimes tree %s: %v", root, err)
}
}
func seedRolloutAt(t *testing.T, path string, size int) string {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir rollout dir: %v", err)
}
body := make([]byte, size)
for i := range body {
body[i] = 'x'
}
if err := os.WriteFile(path, body, 0o644); err != nil {
t.Fatalf("write rollout: %v", err)
}
return path
}
// assertSessionsLinkedToStore verifies codex-home/sessions is a directory link
// that resolves to the per-issue store (storeDir), i.e. the task home reaches
// only that issue's history and not the machine-global ~/.codex/sessions.
func assertSessionsLinkedToStore(t *testing.T, sessions, storeDir string) {
t.Helper()
fi, err := os.Lstat(sessions)
if err != nil {
t.Fatalf("sessions link missing: %v", err)
}
if runtime.GOOS != "windows" {
if fi.Mode()&os.ModeSymlink == 0 {
t.Fatalf("sessions must link the per-issue store, got mode %v", fi.Mode())
}
if target, _ := os.Readlink(sessions); filepath.Clean(target) != filepath.Clean(storeDir) {
t.Errorf("sessions link target = %q, want store %q", target, storeDir)
}
}
realSessions, err := filepath.EvalSymlinks(sessions)
if err != nil {
t.Fatalf("eval sessions link: %v", err)
}
realStore, err := filepath.EvalSymlinks(storeDir)
if err != nil {
t.Fatalf("eval store: %v", err)
}
if realSessions != realStore {
t.Errorf("sessions resolves to %q, want store %q", realSessions, realStore)
}
}
// assertZeroCopyLink verifies dst resolves to the same inode as src — a hard
// link or symlink, never a byte copy.
func assertZeroCopyLink(t *testing.T, src, dst string) {
t.Helper()
si, err := os.Stat(src)
if err != nil {
t.Fatalf("stat src %s: %v", src, err)
}
di, err := os.Stat(dst)
if err != nil {
t.Fatalf("stat dst %s: %v", dst, err)
}
if !os.SameFile(si, di) {
t.Errorf("%s is a copy of %s, expected a zero-copy hard/sym link", dst, src)
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func assertAbsent(t *testing.T, path string) {
t.Helper()
if _, err := os.Lstat(path); !os.IsNotExist(err) {
t.Errorf("expected %s to be removed, err=%v", filepath.Base(path), err)
}
}
func assertPresent(t *testing.T, path string) {
t.Helper()
if _, err := os.Lstat(path); err != nil {
t.Errorf("expected %s to be preserved: %v", filepath.Base(path), err)
}
}