mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* 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>
791 lines
33 KiB
Go
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)
|
|
}
|
|
}
|