Skip to content

fix(codex): correct rollout tail ownership so the committed feed keeps growing - #9

Merged
Juliusolsson05 merged 3 commits into
mainfrom
fix/codex-rollout-tail-ownership
May 16, 2026
Merged

Juliusolsson05 merged 3 commits into
mainfrom
fix/codex-rollout-tail-ownership

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented May 16, 2026 •

Copy link
Copy Markdown
Owner

Problem

During MCP / client-tool agentic runs the Agent Code feed appears to "clear" every couple of turns and stops growing — runtime.entries stays frozen at the bootstrap snapshot while the proxy semantic stream keeps producing turns. Traced in Juliusolsson05/agent-code#159.

Root cause is here, not in the renderer: the committed-transcript channel goes permanently silent after bootstrap.

  1. CodexHeadless.start() pins tailFile() to a single rollout JSONL. When Codex writes the resumed conversation into a different (reconstructed / forked) rollout file, the tailer keeps reading the dead one — so no live rollout-entry events ever land.
  2. For client-executed tools (MCP), the proxy adapter held activeFlowId until the socket's response-end. Codex issues the follow-up /v1/responses (carrying function_call_output) before that end is observed — and on long-lived transports it may never arrive — so the legitimate follow-up flow was demoted to secondary and never published.

Fix

CodexResponsesAdapter — release the flow slot on response.completed. New 'completed' attribution state: the upstream semantic terminal releases activeFlowId so the next model turn can claim the active slot. A 'completed' flow drops late chunks instead of growing its buffer / refreshing lastEventAt, so it is reaped rather than leaked.

CodexHeadless — correct rollout tail ownership.

  • Resume: a single sessions-dir watcher detects a forked rollout and switches the committed tail to it; freshness is decided by an mtime guard so a fork created before/during/after attach is all evaluated.
  • A candidate must match both the cwd (compared canonically — symlink/case/..-normalised) and share copied-history item ids with the resumed file. cwd alone is unsafe — orchestration branches spawn sibling Codex agents in the same directory. Lineage is matched by opaque item id value (msg_/fc_/rs_/call_/turn ids), which survives re-serialisation.
  • A same-cwd candidate with no shared lineage is rejected with a diagnostic; an empty-lineage resume fails closed rather than switching on cwd alone.
  • Fresh start: tailNewRolloutFile emits a diagnostic when additional new rollout files appear during startup, surfacing wrong-session-attach ambiguity.

Verification

  • npm run build (tsc) passes clean.
  • Not yet exercised against a live Codex resume/fork. Recommended smoke matrix: append-resume (no fork), immediate fork during startup, same-cwd sibling agent in the watch window, MCP follow-up turn promotion after response.completed.

Resolves

Root cause of Juliusolsson05/agent-code#159 (paired with the submodule-pointer PR Juliusolsson05/agent-code#160).

Follow-up issues (bookkeeping — not blocking)

🤖 Generated with Claude Code

Juliusolsson05 and others added 2 commits May 16, 2026 18:09
For client-executed tools (MCP especially), Codex issues the
follow-up /v1/responses request carrying the function_call_output
before the proxy observes the previous socket's response-end — which
on long-lived / broken transports may never arrive. Holding
activeFlowId until response-end demoted the legitimate follow-up flow
to 'secondary', so the renderer saw only phase changes and no
block/text events for it.

Add a 'completed' attribution state: response.completed (the upstream
semantic terminal of a Responses turn) releases activeFlowId and marks
the flow completed, so the next model turn can claim the active slot
while stray tail chunks from the finished flow are still suppressed.

Refs Juliusolsson05/agent-code#159

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The committed-transcript channel could go permanently silent after
bootstrap. CodexHeadless.start() pinned tailFile() to a single rollout
JSONL; when Codex wrote the resumed conversation into a different
(reconstructed / forked) file, the tailer kept reading the dead one.
The renderer still received live proxy semantics, so the feed appeared
to "clear" every couple of turns while runtime.entries stayed frozen
at the bootstrap snapshot. See Juliusolsson05/agent-code#159.

Resume path (tailResumeRolloutFile / watchResumeForkRollout):
- After attaching to the resume file, watch the sessions dir for a
  bounded window and switch the committed tail to a forked rollout if
  one appears.
- A candidate must match BOTH our cwd AND share copied-history item
  ids with the resumed file (isResumeForkCandidate +
  readRolloutLineageIds / collectRolloutLineageIds). cwd alone is not
  enough: orchestration branches spawn sibling Codex agents in the
  same directory, so a cwd-only match would latch a sibling agent's
  transcript. Lineage is matched by opaque item id VALUE (msg_/fc_/
  rs_/call_/turn ids), which survives any re-serialisation a copied
  history might introduce.
- A same-cwd candidate with no shared history is rejected with a
  diagnostic rather than silently adopted.

Fresh path (tailNewRolloutFile):
- Keeps "first new rollout wins", but now emits a diagnostic when
  additional new rollout files appear during startup, surfacing the
  wrong-session-attach ambiguity instead of failing silently.

Refs Juliusolsson05/agent-code#159

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review feedback on the rollout-tail-ownership fix:

- watchResumeForkRollout: replace the prime-then-watch design with a
  single ignoreInitial:false watcher. The two-phase version recorded
  a fork created during the priming scan as "pre-existing" and never
  evaluated it — defeating the fix in exactly its target case.
  Freshness is now decided by the isResumeForkCandidate mtime guard,
  not by watch ordering.
- isResumeForkCandidate: compare cwd canonically (normalizeCwd —
  realpath resolves macOS symlink/case, resolve handles `..`/trailing
  slash) so a symlinked or non-canonical cwd no longer rejects a real
  fork.
- Drop the empty-lineage cwd-only fallback: if the resumed file has
  no fingerprintable history we can no longer prove a same-cwd
  candidate is ours, so fail closed rather than risk adopting a
  sibling agent's transcript.
- Widen the candidate read cap 256 KiB -> 4 MiB so large copied
  bootstrap entries cannot push shared item ids out of range.
- Document the intentional open-before-close tail overlap in switchTo.
- CodexResponsesAdapter: a 'completed' flow now drops late chunks
  instead of growing flow.buffer and refreshing lastEventAt — the
  latter previously kept the watchdog from ever reaping a finished
  flow on a keepalive-heavy transport.

Refs Juliusolsson05/agent-code#159

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Thanks — review addressed in 07e197b.

Blocking

  • Priming-scan race — fixed. watchResumeForkRollout no longer primes an existing set then watches for "new" files (which recorded a fork created mid-scan as pre-existing and skipped it forever). It's now a single ignoreInitial:false watcher; freshness is decided solely by the isResumeForkCandidate mtime guard, so a fork created at any point — before, during, or after attach — is evaluated.
  • Raw cwd equality — fixed. New normalizeCwd canonicalises both sides via realpathSync.native (resolves macOS symlinks like /var→/private/var and case) with a resolve fallback. payload.cwd is also now type-guarded.

Should Fix

  • Empty-lineage cwd-only fallback — removed. If the resumed file has no fingerprintable history we now fail closed (no switch) with a diagnostic, instead of latching on cwd alone. The #159 failure had 53 bootstrap entries → non-empty lineage → unaffected; an empty resumed session has no accumulated conversation to lose.
  • 256 KiB candidate read — widened to 4 MiB so large copied bootstrap entries (AGENTS.md / environment_context) can't push shared item ids out of range. Reads only happen on fresh-file events inside the bounded window.
  • Completed-flow buffer/lastEventAt leak — fixed. A 'completed' flow now drops late chunks entirely (early return in the response-chunk handler) rather than appending to flow.buffer and refreshing lastEventAt. lastEventAt is frozen at completion, so the watchdog reaps the flow if response-end never arrives.
  • switchTo open-before-close overlap — documented inline: the overlap is intentional (closing first would drop in-between entries) and safe because a fork starts with copied history and downstream ingest dedupes by deterministic per-entry uuid.

Nits

  • Unit coverage: the fork detector is the most testable part, but the repo convention is no new test files in feature/fix PRs — flagged as a follow-up. A live smoke matrix (append-resume, immediate-fork-during-startup, same-cwd sibling in the window, MCP follow-up promotion) is noted on the PR.
  • 'rollout-error' for a successful tail switch: left as-is for now — a dedicated diagnostic event would need a new CodexHeadlessEvents member wired through the parent's IPC, out of scope for this PR. Tracked as follow-up.

@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Follow-up work from review tracked as bookkeeping issues (not blocking this PR):

@Juliusolsson05
Juliusolsson05 merged commit b416ede into main May 16, 2026
Juliusolsson05 added a commit to Juliusolsson05/agent-code that referenced this pull request May 16, 2026
Bumps packages/codex-headless to include the rollout-tail-ownership
fix (Juliusolsson05/codex-headless#9).

The committed-transcript tailer could go permanently silent after
bootstrap: it pinned to a single rollout JSONL and, when a resumed
Codex session's conversation was written into a forked rollout file,
kept reading the dead one. The renderer still received live proxy
semantics, so the feed appeared to "clear" every couple of turns
during MCP / tool-call runs while runtime.entries stayed frozen.

The submodule fix makes the tailer follow a forked rollout — gated by
cwd plus copied-history lineage so sibling orchestration agents in the
same directory are not latched — and releases the proxy flow slot on
response.completed so MCP follow-up turns are not starved.

Root-cause analysis: #159.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Juliusolsson05 added a commit to Juliusolsson05/agent-code that referenced this pull request May 16, 2026
Bumps packages/codex-headless to include the rollout-tail-ownership
fix (Juliusolsson05/codex-headless#9).

The committed-transcript tailer could go permanently silent after
bootstrap: it pinned to a single rollout JSONL and, when a resumed
Codex session's conversation was written into a forked rollout file,
kept reading the dead one. The renderer still received live proxy
semantics, so the feed appeared to "clear" every couple of turns
during MCP / tool-call runs while runtime.entries stayed frozen.

The submodule fix makes the tailer follow a forked rollout — gated by
cwd plus copied-history lineage so sibling orchestration agents in the
same directory are not latched — and releases the proxy flow slot on
response.completed so MCP follow-up turns are not starved.

Root-cause analysis: #159.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant