Repository navigation
Codex agents cross-render transcripts: new empty Codex agent prints another agent's JSONL #173
Description
Severity: CRITICAL
A new, empty Codex agent renders the JSONL transcript belonging to a different Codex agent in the same project. Codex panes cannot reliably differentiate their own transcript from those of sibling Codex agents.
Symptom
- Open a project that already has a running/active Codex agent (Agent A).
- Create a second, brand-new Codex agent (Agent B) in the same project. Agent B has sent no messages — its feed should be empty.
- Agent B's feed starts printing Agent A's JSONL rollout transcript instead of staying empty.
The two agents bleed into each other: a fresh pane shows another pane's history.
Why this matters
- The user sees another conversation's content in a pane that should be blank — confusing and a trust/correctness problem.
- Likely related to the broader feed-ownership work in Fix feed render ownership across committed transcript, semantic live/history, ghosts, and optimistic input #172 / Codex resume feed broken: proxy streaming turn dropped + duplicate semantic-history keys #168, but distinct: this is transcript-file → pane mis-association, not in-feed render-ownership.
Suspected root cause
Codex rollout files live in a global, date-bucketed tree (not per-cwd like Claude):
~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl
So every Codex agent across every project shares the same day directory. Transcript lookup must therefore be exact-keyed on the session's own rollout id. Two spots look unsafe:
src/main/providerSwitch/shared.ts—findCodexRolloutPathBySessionId()collects suffix matches and sorts by mtime, returning the newest. If the lookup key is missing/ambiguous, it hands back whichever rollout was touched most recently — i.e. another active agent's file.src/main/sessions/historyLoader.ts—findCodexRolloutPathByThreadId()matches withf.includes(threadId)(partial substring, not an exact-<uuid>.jsonlsuffix match) and returns the first filesystem-order hit.
A fresh Codex session also has no resumeSessionId and is allocated a timestamp-based key rather than a stable session id (src/providers/codex/runtime/codexSession.ts, the shell-<timestamp> branch). A new empty agent has no rollout id of its own yet, so any history load for it can fall through to "the latest rollout in this project/day" — which is the sibling agent.
(Root cause is suspected, not confirmed — needs a debugging pass to pin which path fires.)
Acceptance criteria
- A newly created, empty Codex agent shows an empty feed, full stop — regardless of how many other Codex agents exist in the same project or were active that day.
- Codex transcript lookup is keyed on an exact, unique rollout/session identity for the pane — never a "newest in directory" or substring fallback.
- Switching between sibling Codex agents in one project never cross-renders transcripts.
Notes
- Claude does not hit this because it stores per-cwd (
~/.claude/projects/<cwd-hash>/<sessionId>.jsonl); the bug is specific to Codex's global session tree.
Activity
Juliusolsson05 commentedon May 17, 2026
Investigation update: upstream Codex already has a stable thread id, but Agent Code's TUI/filesystem path cannot see it early enough
Fresh Codex rollout ownership is tricky because upstream Codex creates the real identity before it creates the durable rollout file.
Findings from
vendor/codex-src:ThreadIdbefore any rollout file exists.Session::newusesThreadId::default()forInitialHistory::New | Forked, andThreadId::new()is a UUID v7 (protocol/src/thread_id.rs).RolloutRecorderParams::new(core/src/codex.rs).RolloutRecorder::newbuildsSessionMetaand returns withfile = Nonefor new sessions (rollout/src/recorder.rs).session_metais written only when the deferred writer is materialized by persisted rollout items/flush, not at process/session startup. Upstream tests assert the rollout file does not exist after recorder creation and only appears after a persisted item plus flush.SessionConfiguredEventcontains both thesession_idand precomputedrollout_path, and core emits it as the first event. ButSessionConfiguredis filtered out of rollout persistence, so watching rollout files cannot see this early identity.Conclusion:
A stable provider identity exists inside Codex before the first transcript line, but Agent Code's current TUI/filesystem observer does not consume that internal event. From the outside, before the first rollout write, simultaneous same-cwd TUI sessions are not distinguishable by filesystem state. This is the original reason Agent Code fell back to filesystem timing for fresh sessions.
Implication for this issue:
The strongest long-term fix would consume an explicit Codex event/API that gives us
thread_id/rollout_pathfor the PTY we spawned. Without that, fresh-session ownership has to be inferred from local signals such as the prompt submitted through this exact headless instance or proxy-observed response ids.Juliusolsson05 commentedon May 17, 2026
Investigation update: why
first new rollout winsexists and why it breaksfirst new rollout winswas used because fresh Codex sessions do not have a provider id when Agent Code spawns them.Current implementation path:
SessionManager.spawn()creates an Agent Code-localsessionId, but that is not Codex's provider/thread id.session_meta.payload.id.session_metaand persists it asproviderSessionIdso later resume/history loading can work.CodexHeadless.tailNewRolloutFile()currently snapshots existing files and latches the first laterrollout-*.jsonl.That relies on these assumptions:
session_metalands, the captured provider id is correct.Parallel orchestration breaks those assumptions. If N fresh Codex panes start around the same time, all N watchers observe the same global sessions tree. The first child rollout can be latched by every watcher. After
latchedPathis set, later rollout files are ignored and only reported as ambiguity.Once the wrong file is tailed, the bad association is durable:
tailFile()emits entries from the sibling rollout;CodexSessionforwards them asjsonl-entry;SessionManagerstamps them with this pane's Agent CodesessionId;session_meta.payload.idas this pane'sproviderSessionId;providerSessionIdlater.Conclusion:
The fresh attach path is identity-by-timing. It is acceptable only for serialized launches. In parallel same-cwd orchestration, it can cross-wire committed transcripts and persist the wrong Codex provider id.
Juliusolsson05 commentedon May 17, 2026
Investigation update: why the rollout-tail ownership logic changed in #159/#160
The large rollout-tail ownership switch came from #159/#160.
Old resume model:
findRolloutByThreadId -> tailFile(existing)was treated as authoritative.codex resume <id>appended to the original rollout.In the captured #159 case, 67+ Responses turns produced only the initial JSONL bootstrap. The renderer symptoms looked like feed clearing, missing user rows, and MCP/tool-call rendering bugs, but the real root cause was lower:
runtime.entrieswas frozen because the committed rollout tail was dead.The #160 fix moved resume from:
into:
The important design choice was cwd + lineage, not cwd alone. Cwd alone is unsafe because Codex rollouts are global and orchestration can run sibling agents in the same cwd. A blind cwd-only switch would steal a child agent's file and paint it into the parent pane.
Tradeoffs from that change:
rollout-error, creating falsetranscript unavailablebanners; that was fixed by not treating successful switches as errors.Why fresh sessions stayed weaker:
A fresh Codex pane has no original rollout and no prior transcript ids to fingerprint. Before the first durable
session_meta, the only filesystem signal is "a new rollout appeared." That is why the fresh path keptfirst new rollout winsplus ambiguity diagnostics. This issue shows that was an unfinished bridge, not a valid ownership rule for parallel orchestration.Juliusolsson05 commentedon May 17, 2026
Investigation update: orchestration makes the race expected, not exotic
Four child Codex agents can spawn concurrently because the orchestration path intentionally has no serialization point.
Path shape:
orchestration_create_agentsends each MCP call throughOrchestrationBridge.createAgent()with an independentrequestId.createOrchestrationAgent()for each request.SessionManager.spawn()mints independent Agent CodesessionIds and starts independent Codex PTYs.Renderer-side isolation is mostly correct:
orchestrationParentId/orchestrationRootId;The isolation failure is below Dispatch: Codex rollout ownership.
Fresh Codex sessions call
tailNewRolloutFile(). That watcher snapshots the global~/.codex/sessions/YYYY/MM/DDtree and then latches the first newrollout-*.jsonlit sees. If several same-cwd Codex sessions start together, every watcher can see the same first rollout and attach to it.Local transcript timing matches this risk: multiple Codex subagent rollouts were created in the same second, in the same cwd, with the same parent thread id but different child ids/nicknames. That is exactly the shape where per-session
first new rolloutwatchers can collapse onto one file.What the renderer can fix:
What the renderer cannot fix:
If main/headless emits the same rollout entries under four different Agent Code
sessionIds, the renderer has no authoritative way to recover the true owner. It can only render the already-misattributed committed stream.Conclusion:
Do not serialize orchestration just to hide this. Parallel child agents are a valid product requirement. The durable fix is to make fresh Codex rollout claiming prove ownership or fail closed.
Juliusolsson05 commentedon May 17, 2026
Investigation update: proposed ownership proof hierarchy
Fresh Codex rollout ownership should not be
cwdorfirst new rollout wins.Recommended proof hierarchy:
Exact provider id
If the pane already has
providerSessionIdor is resuming, attach only to a matchingsession_meta.id/ rollout filename suffix. This is authoritative, but unavailable for brand-new sessions until after a rollout is chosen.Spawn-scoped proxy response lineage
When proxy streaming is enabled, each Agent Code Codex session has its own
ResponsesProxy. Correlate that private proxy's observedresponse.id, item ids, and call ids to candidate rollout fields such asturn_context.turn_id, payloadturn_id,payload.id, orpayload.call_id.This is the strongest fresh-session proof because proxy scope is per Agent Code session and separates same-cwd siblings. Ignore non-turn endpoints such as
responses/compact,memories/trace_summarize,models, etc.Prompt lineage
For orchestration-created agents, record the submitted prompt ownership key at
submitPrompt()time and match it against the candidate rollout's first real user message (event_msg:user_message, falling back to non-bootstrapresponse_itemuser text). Normalize like Codex optimistic reconciliation does. This is useful, but not perfectly unique when workers share templated prompts.Item-id lineage
Keep the resume/fork strategy already present: same cwd plus overlap of opaque rollout ids. For fresh sessions this becomes useful only after proxy or committed response items exist; it is not available before the first model/request items.
Timestamps
Use spawn time, submit time,
session_meta.timestamp, file mtime, and first request time only as bounds/tie-breakers. They should reject impossible candidates, not prove ownership.Cwd
Cwd is a necessary filter only. Same-cwd orchestration siblings are expected, so cwd alone must never attach or switch a pane.
Request/proxy metadata
Proxy
requestIdis local to a proxy process and useful inside proxy logs, but it is not written into rollout. Headers/body are forensic signals unless we add a deliberate correlation field.Failure rule:
If multiple candidate rollouts reach the same best proof level, do not attach by recency. Keep the optimistic/proxy live surface, mark transcript ownership ambiguous, and wait for a stronger signal. A wrong attach is worse than a delayed committed transcript because it paints another agent's conversation into the pane and then persists the wrong
providerSessionId.Implementation shape:
Replace fresh first-new latch with a bounded candidate scorer. Buffer same-window rollout candidates, parse early
session_meta/turn_context/ first-user lines, ingest proxy-observed ids when available, and accept only the unique highest-confidence candidate. The existing resume fork detector has the right shape: cwd is a gate, lineage is the proof.Juliusolsson05 commentedon May 17, 2026
Investigation update: smallest safe implementation boundary and tests
This symptom is most likely the fresh-session live rollout tail, not initial-history loading and not feed rendering.
Key facts:
providerSessionIddoes not load initial history. Fresh Codex runtimes start withtranscriptStatus: ready; initial history is loaded only when a provider session id exists.CodexHeadless.tailNewRolloutFile().session_metais captured and forwarded as this pane'sproviderSessionId, poisoning ownership for that pane.Smallest safe implementation boundary:
packages/codex-headless/src/CodexHeadless.ts, specificallytailNewRolloutFile()plus the local input/write boundary.Feedor renderer state. By the time the renderer seesjsonl-entry, the file has already been associated with the wrong pane.tailFile():src/main/sessions/historyLoader.tsstill usesf.includes(threadId). That is probably not the empty-fresh-pane root cause, but it should become an exact-${providerSessionId}.jsonl/ UUID match like the provider-switch helper.Proposed tests:
CODEX_HOME; create a same-cwd sibling rollout before any local write; assert norollout-entry, nosession_meta, and no active tail.sendPrompt("own prompt")or equivalent bracketed paste, create a rollout whose first user item matches; assert entries emit andgetSessionMeta()?.idis that file's id.B prompt, create sibling rollout withA prompt; assert it is rejected/held, then create matching rollout and assert it is accepted.session_metaburst through the bulk jsonl path and assertproviderSessionIdis set only on the routedsessionId.createOrchestrationAgent()creates a fresh Codex child with noresumeSessionId/providerSessionId; assert no initial-history load is requested until the child's own rolloutsession_metaarrives.Current coverage gap:
There are rendering/session-ownership scripts, but no focused test for fresh Codex rollout attach ownership. That is the gap #173 needs covered.
Juliusolsson05 commentedon May 17, 2026
SQLite/state DB investigation: can it replace
first new rollout wins?Short version: SQLite helps with session listing and
thread_id -> rollout_pathlookup, but it is not enough by itself to reliably map a fresh Agent Code Codex pane/PTY to the correct fresh Codex thread under parallel same-cwd starts. The DB has the right durable metadata once a thread is materialized, but it does not carry any per-process, PTY, pane, or launch-token identity we can correlate back to the child process we spawned.Relevant upstream findings:
state_5.sqliteundersqlite_home, defaulting to$CODEX_HOMEunlesssqlite_home/CODEX_SQLITE_HOMEoverrides it:vendor/codex-src/codex-rs/state/src/runtime.rsvendor/codex-src/codex-rs/core/src/config/mod.rsthreadstable storesid,rollout_path,created_at,updated_at,source,model_provider,cwd, title/sandbox/approval/git metadata, plus later agent metadata (agent_nickname,agent_role,agent_path). It does not store pid, pty, terminal id, parent process id, launch nonce, or Agent Code pane id.vendor/codex-src/codex-rs/state/migrations/0001_threads.sqlvendor/codex-src/codex-rs/state/migrations/0013_threads_agent_nickname.sqlvendor/codex-src/codex-rs/state/migrations/0021_thread_spawn_edges.sqlvendor/codex-src/codex-rs/state/migrations/0022_threads_agent_path.sqllogs.process_uuid), not inthreads, and it is not a durable mapping from our PTY/pane to the thread row.vendor/codex-src/codex-rs/state/migrations/0010_logs_process_id.sqlvendor/codex-src/codex-rs/state/src/log_db.rsRolloutRecorder::newprecomputes the rollout path andSessionMeta, but explicitly defers file creation/open until a persist/flush path.vendor/codex-src/codex-rs/rollout/src/recorder.rssession_metais written/materialized, viawrite_session_meta -> sync_thread_state_after_write -> state_db::apply_rollout_items.vendor/codex-src/codex-rs/rollout/src/recorder.rsvendor/codex-src/codex-rs/rollout/src/state_db.rsSessionConfiguredEvent.rollout_path. Internally, upstreamThreadManagerconsumesSessionConfiguredas the first event and stores the rollout path on the thread.vendor/codex-src/codex-rs/core/src/codex.rsvendor/codex-src/codex-rs/protocol/src/protocol.rsvendor/codex-src/codex-rs/core/src/thread_manager.rsCurrent Agent Code /
codex-headlessbehavior:~/.codex/sessionsand attach to the first newrollout-*.jsonl:packages/codex-headless/src/CodexHeadless.tssession_meta, not SQLite.packages/codex-headless/src/transcript/SessionList.tssrc/providers/registry.main.tsLocal proof from the current DB:
~/.codex/state_5.sqlitedoes record spawned subagent rows withsource = {"subagent":{"thread_spawn":...}},agent_nickname,agent_role, cwd,first_user_message, andthread_spawn_edgeslinking them to the parent thread.Can SQLite solve #173?
Not completely. A DB-backed fresh attach could improve over raw filesystem watching by querying new
threadsrows wherecwd == spawned cwdandcreated_at/updated_at >= spawn time, then taking the new row'srollout_path. That would avoid accidentally attaching to a different cwd. But under parallel same-cwd sessions, SQLite has the same fundamental ambiguity as the filesystem: multiple new rows can appear with the same cwd/source/time window, and neither row says which OS process or PTY produced it.Also, row timing is not earlier than the current durable rollout signal. For fresh sessions,
SessionConfiguredis emitted before initial history recording and before rollout materialization; the DB row is written when the rollout writer writessession_meta. So SQLite cannot give us an earlier fresh identity than the file unless Agent Code can observe upstream's structuredSessionConfiguredEvent.Implementation path if we still want SQLite as a better fallback:
codex-headlessthat opens${CODEX_SQLITE_HOME || CODEX_HOME}/state_5.sqlite.threads.idbefore spawning/tailing.threadsrows with matching normalizedcwd, existing rollout path, andcreated_at/updated_atafter the spawn window.threads.rollout_path.The real fix is still to stop inferring identity from shared storage. Best options:
SessionConfiguredEvent.rollout_pathis already the authoritative mapping.--print-session-configured-jsonstyle channel.session_meta/threads, e.g.AGENT_CODE_PANE_IDor--session-source agent-code:<pane-id>, so DB/filesystem lookup can filter by a unique value instead of cwd/time.