fix(acp): skip unreplayable transcript entries instead of failing the load - #4040
Conversation
… load A session whose transport dropped could never be recovered: `session/prompt` answered `Unknown session, not found`, and the documented recovery through `session/load` then died on `ACP cannot replay a transcript entry without its production body`. One malformed row revoked `loadSession` for the entire session, and the agent ended `closed` with its work unreachable — reproduced across a broker restart and a fresh binary. Replay now decides per entry: an entry without its production body is skipped and reported through the same `_meta` boundary channel that already reports unavailable historical images, so a session with zero replayable rows still loads. Lore-id: c4e7b285 Constraint: never fabricate an empty body -- that replays a message that never existed Constraint: gjc advertises loadSession: true, so load must not be revocable by one bad row Rejected: keep throwing and let the client retry | the failure is deterministic, every retry dies identically Rejected: substitute a placeholder body | silently invents transcript content the user never wrote Confidence: high Scope-risk: narrow Reversibility: easy Tested: one bad entry among good ones -- good ones replay, the skip is reported with count and reason Tested: every entry unreplayable -- load still succeeds with zero replayed messages Tested: healthy transcripts replay with an unchanged update sequence Not-tested: recovery against a live host that dropped mid-turn
|
Self-review: REQUEST_CHANGES on my own PR. A red-team pass found that skipping is the wrong answer for the case that actually produces body-less entries, and that it creates two new broken states. I verified both in source. Blocker 1: the entries this PR skips are usually RECOVERABLE, not corrupt
[{ id: item?.itemId, error: { code: "item_too_large" }, continuations }]That row has no Blocker 2: skipping breaks tool-call pairing
const replayTool = replayTools.get(message.toolCallId);
const toolName = typeof message.toolName === "string" ? message.toolName : replayTool?.name;
if (!toolName) continue; // :2273Skip the entry that owned the
The replayed session then renders as permanently mid-flight. What the fix has to beSkipping must be the last resort, not the first. Replay should:
The core premise of the PR still stands: one bad entry must not fail the whole Not merging until continuation recovery is in and both broken states have regressions. |
…tions The replay treated any entry without a production body as unreplayable and skipped it — but the common producer of a body-less entry is the host's own item_too_large envelope, which ships continuations describing exactly how to fetch the content. Skipping traded a loud total failure for the silent loss of the largest message in the session, and a skipped entry that owned a toolCall left an orphan update and a call stuck at pending forever. Replay now follows continuations and reassembles the body through the existing query path; only a genuinely unrecoverable entry (no body, no usable continuation) is reported-and-skipped, and a skip keeps tool pairing coherent by dropping the paired result instead of leaving it pending. Lore-id: 7f1e9c53 Constraint: one bad entry still never fails the whole session/load Constraint: a session whose entries are all unrecoverable still loads with zero replayed messages Rejected: fabricating an empty body | replays a message that never existed Rejected: leaving pairing to the client | the client cannot know a start it never saw was skipped Confidence: high Scope-risk: narrow Reversibility: easy Tested: an item_too_large entry with continuations is recovered and replayed Tested: a genuinely unrecoverable entry is skipped with count and stable reason Tested: a skipped toolCall owner leaves no orphan update and nothing pending Tested: healthy transcripts replay with an unchanged update sequence Not-tested: multi-page continuation chains against a live host
|
Both blockers fixed in |
Continuation recovery discarded a partial result: if `role`, `toolCallId` or `toolName` failed to recover, the entry was skipped after its tool call had already been published, leaving a call stuck at pending forever and an oversized successful result reported as failed whenever its `isError` continuation was absent. Recovery is now all-or-nothing per entry, and a skip after publication synthesises the terminal state instead of abandoning it, so a replayed session never renders permanently mid-flight. Lore-id: 8f3c1d64 Constraint: one unrecoverable entry still never fails the whole session/load Constraint: absence of an isError continuation is not evidence of failure Rejected: publishing the partial entry | a tool call with no name cannot be rendered Confidence: high Scope-risk: narrow Reversibility: easy Tested: a partial continuation failure leaves nothing pending Tested: an oversized successful result is not reported as failed Tested: healthy transcripts replay unchanged Not-tested: multi-page continuation chains against a live host
|
Local codex-pro review gate: APPROVE, no blockers, after Continuation recovery was discarding partial results: if 200 pass / 0 fail across |
…ing it pending Yeachan-Heo#4040 made transcript replay skip entries it could not reconstruct, so a broken row stopped failing the whole session load. One skip was in the wrong place: a `toolResult` that resolved no tool name hit `continue`, which also skipped the `tool_execution_end` notification and the `replayTools` bookkeeping. The start had already been published, so the client kept a tool call at `pending` for the life of the session -- the opposite of the all-or-nothing recovery the change claimed. A result row that lost its own name now falls back to the name its start carried, which is a result the client can still place. A call nothing can name is closed as failed with an honest reason instead of being abandoned mid-flight. Found by the post-merge audit in Yeachan-Heo#4063. Constraint: a published tool call MUST reach a terminal status or the load MUST fail Rejected: failing the whole continuation | reintroduces the Yeachan-Heo#4019 symptom Confidence: high Scope-risk: narrow Reversibility: easy Tested: result row missing a name, call nothing can name, call never resolved Not-tested: a start row that is itself unparseable
…4082) * fix(acp): give a replayed tool call a terminal status instead of leaving it pending #4040 made transcript replay skip entries it could not reconstruct, so a broken row stopped failing the whole session load. One skip was in the wrong place: a `toolResult` that resolved no tool name hit `continue`, which also skipped the `tool_execution_end` notification and the `replayTools` bookkeeping. The start had already been published, so the client kept a tool call at `pending` for the life of the session -- the opposite of the all-or-nothing recovery the change claimed. A result row that lost its own name now falls back to the name its start carried, which is a result the client can still place. A call nothing can name is closed as failed with an honest reason instead of being abandoned mid-flight. Found by the post-merge audit in #4063. Constraint: a published tool call MUST reach a terminal status or the load MUST fail Rejected: failing the whole continuation | reintroduces the #4019 symptom Confidence: high Scope-risk: narrow Reversibility: easy Tested: result row missing a name, call nothing can name, call never resolved Not-tested: a start row that is itself unparseable * fix(acp): close every published tool call on any exit from transcript replay Round one gave a nameless tool result a terminal status instead of skipping it, which fixed the `continue` path and left the throw path open: a later `transcript.list` page that failed exited the replay before any cleanup, so every call already published as `pending` stayed pending for the life of the session. Guarding that one site would have been the same mistake a third time. The replay body now runs inside a single boundary, and whatever leaves it -- normal return, early exit, or a page that throws -- closes every start still open. A call abandoned because the replay itself stopped reads differently from one abandoned because nothing could name it, so the two carry different reasons; the client can tell "this call failed" from "the replay could not finish". Constraint: a published tool call MUST reach a terminal status on every exit path Rejected: guarding the throw site | a future early exit reopens the same hole Confidence: high Scope-risk: narrow Reversibility: easy Tested: second transcript page throws, unnameable call, call the transcript never resolved Not-tested: a failure inside the cleanup loop itself * fix(acp): finish the replay cleanup and report what it could not close Round two put the replay body inside one boundary so every exit closes still-open tool calls. The cleanup itself could still fail: `#publishSessionUpdate` fails the session on error, so the first failing publication tore down the record and every later close became a silent no-op. The boundary ran and accomplished nothing -- the calls queued behind that first failure stayed `pending` for the life of the session, which is the defect this branch exists to remove. Every close is now attempted, and the ones that could not be published are reported rather than dropped. A cleanup that cannot report its own failure is not a cleanup; that silence is what kept the original defect invisible for a whole session. Constraint: one failed close MUST NOT prevent the remaining ones Constraint: a close that could not happen MUST surface, never be swallowed Rejected: wrapping the loop in another try | the third variation of guarding one site Confidence: high Scope-risk: narrow Reversibility: easy Tested: cleanup publication fails partway; remaining calls still close and the failures are reported Not-tested: a session torn down concurrently with the cleanup * fix(acp): stop the replay cleanup from destroying what the rest of it needs Round three made the cleanup report its failures, and a review on a different model showed the report was worth nothing. Two reasons, both structural. The cleanup published through `#publishSessionUpdate`, which calls `#failSession` and deletes the session record, so the first failing close tore down the state every later close depended on -- the boundary ran and accomplished nothing. And `replayTools.delete(...)` ran before the close was attempted, so a call whose close was refused had already left the map and never appeared in the report. The cleanup could fail, take the session with it, and name none of the calls it abandoned. Entries now leave the map only after their close succeeds, and the cleanup does not publish through the path that fails the session. What still cannot be closed is named in a report that production reads. Four rounds in one invariant is what a single model's blind spot looks like; this round came from reviewing on a different one. Constraint: a published tool call reaches a terminal status on every exit path Constraint: cleanup MUST NOT depend on state its own failure destroys Rejected: remove-on-attempt | drops exactly the calls the report exists to name Confidence: high Scope-risk: narrow Reversibility: easy Tested: page throws mid-replay, cleanup publication fails partway, unnameable call, call the transcript never resolved Not-tested: a session torn down concurrently with the cleanup * fix(acp): end the terminal obligation where the session ends Five rounds patched five routes to the same invariant, so this round settled the question the patching kept deferring: when a concurrent `session/close`, a failed session, or a connection teardown removes the session mid-replay, is a pending tool call still a defect? It is not. The removal takes the client's view of those calls with it -- there is nobody left to observe a `pending` one, and a frame carrying a closed session id is one the client asked to stop receiving. The obligation ends where the session ends, and the next `session/load` replays the same transcript rows from scratch. That is now stated at the exit instead of left as a silent return that reads like a bug. The session is read once at the cleanup boundary rather than at each exit, because `#closeReplayToolCall` deliberately bypasses `#publishSessionUpdate` -- so its own failures cannot silence the calls behind it -- and therefore cannot notice the session is gone on its own. Constraint: a published tool call reaches a terminal status while a client can observe one Constraint: cleanup does not publish through the path that fails the session Rejected: a fifth guard | four rounds of guards produced a fifth route Confidence: high Scope-risk: narrow Reversibility: easy Tested: page throws mid-replay, cleanup publication fails partway, unnameable call, call the transcript never resolved, session closed concurrently with the cleanup Not-tested: a session removed between the state read and the first close * fix(acp): stop emitting terminals to a session that is already closed Round 5 argued the terminal obligation ends where the session ends, and that argument only holds if nothing is emitted afterwards. The review measured otherwise: expected no post-close terminal, received `tool-race`; expected only `tool-before-close`, also received `tool-after-close`. The comment asserted a boundary the code kept writing past. `#closeReplayToolCall` bypasses `#publishSessionUpdate` on purpose, so a failure closing one call cannot silence the calls behind it. That bypass also skipped the check that the session still exists, and the two are separate concerns -- only the publish path needed avoiding. The session check is back, and the round-4 property survives with it. Constraint: no terminal frame is addressed to a session the client has closed Constraint: one failed close still does not silence the rest Rejected: keeping the bypass wholesale | it conflated the publish path with session state Confidence: high Scope-risk: narrow Reversibility: easy Tested: close during an unreplayable result, close between cleanup entries, page throws mid-replay, cleanup publication fails partway, unnameable call, call the transcript never resolved Not-tested: a close that lands between the session read and the first publish
Scoped replacement for the transcript-replay portion of the closed #4021. One commit, 2 files.
Fixes #4019.
The problem
One transcript entry without a production body makes
session/loadfail for the whole session, permanently. Since that is the documented recovery path after a transport drop, a session whose transport dropped can never be recovered — it endsclosedwith its work unreachable.Captured while running a batch of agents:
SDK WebSocket reconnect attempts exhausted).Unknown session, not found: ad9903df-…(not_found).session/load:closed.Step 2 alone is fine — gjc advertises
loadSession: true, so a client is entitled to recover viasession/load. Step 3 is what removes that guarantee.Mechanism
transcriptReplayContentthrewtranscript_body_unavailablewhenrecord.bodywas not a string, and#replaySessioncalls it for every item on every transcript page. A single bad entry aborted the entire replay, so the failure was total and permanent rather than partial — and deterministic, so every retry died identically.The fix
Replay decides per entry.
transcriptReplayContentreturns a discriminated{ replayable: true, content } | { replayable: false, reason }instead of throwing, and the replay walk skips unreplayable entries, reporting a count and a stable machine-readable reason through the samesession_info_update_metachannel already used for unavailable historical images:No fabricated body — that would replay a message that never existed. A session whose entries are all unreplayable still loads, reporting zero replayed messages.
Verification
Load-bearing proof — restoring
packages/coding-agent/srcfromorigin/devand re-running:Coverage: one bad entry among good ones — the good ones replay, the bad one is skipped and reported with count and reason; every entry unreplayable — load still succeeds with zero replayed messages; healthy transcripts replay with an unchanged update sequence.
Relationship to #4021
#4021 bundled twelve unrelated defects into 53 files and was closed with the instruction to open fresh, scoped PRs. This is one of those, alongside #4031, #4033, #4035, #4036, #4037, #4038 and #4039.
Closes #4019