Recovered API-error retries orphan the turn's persisted output: late-flushed api_error records take over the parentUuid chain, so --resume drops the content
Summary
A turn that recovers from API-error retries loses its entire post-error output on the next --resume, --continue, or process restart. The output was streamed to the user and fully persisted to the transcript; the loss comes from how the buffered api_error records are flushed afterward.
The mechanics: when the internal backoff recovers from an error burst (a run of 429s mid-turn, for example), the turn completes normally and its records persist. The buffered api_error records are flushed later with a stale parent: the first is parented on the conversation leaf as it was at error time, before the recovered output; each subsequent error is parented on the previous one; stop_hook_summary is parented on the last error; and the next user message is parented on the stop hook. The recovered output is left as a sibling fork that is on disk but no longer an ancestor of the leaf. Conversation reconstruction walks parentUuid ancestry, so every reload drops it: the model then denies or confabulates its own prior work, and the error records are permanently spliced into the conversation spine.
The live process keeps the conversation in memory, so the session behaves normally until it next reloads the transcript. That masking makes the loss easy to miss and easy to misattribute.
In my transcript corpus, every recovered-retry episode shows this shape: 55 of 55 episodes across 15 sessions over about 2.5 weeks, orphaning 612 assistant messages. The worst single episode orphans a 266-record segment spanning about 58 minutes of one long turn (everything after the first error, not just one message).
Environment
- Confirmed in the wild on 2.1.224 and 2.1.247. The relevant writer and loader code is identical in the installed 2.1.223, 2.1.224, and 2.1.247 binaries, so presumably 2.1.223 or earlier onward.
- Errors observed: 429
rate_limit_errorbursts (source: "request_retry"and"connection_retry"), with occasional 5xx mid-burst. The bug is the flush shape, not the error type.
On-disk shape
Clean turns chain stop_hook_summary onto the final assistant record. Errored-but-recovered turns instead look like this (synthetic, annotated):
{"parentUuid":null, "type":"user", "uuid":"…0001", "timestamp":"T+0s", …} // the prompt
{"parentUuid":"…0001", "type":"assistant", "uuid":"…0002", "timestamp":"T+5s", …} // recovered reply, fully persisted
{"parentUuid":"…0001", "type":"system", "subtype":"api_error", "uuid":"…0003", "timestamp":"T+2s", …} // flushed late; parented at the error-time leaf; note the older timestamp
{"parentUuid":"…0003", "type":"system", "subtype":"stop_hook_summary", "uuid":"…0004", …} // chains onto the error, not the reply
// the next user record then parents on …0004, so the reply (…0002) is an unreachable sibling fork
The late flush is visible directly in real transcripts: the api_error records appear after the recovered output in file order while carrying timestamps many minutes older (they were buffered from mid-turn), and the first is parented on whatever the leaf was when the error struck.
Why the content is lost on replay
The loader's own embedded spec string in the shipped binary states the contract:
"Conversation reconstruction walks parentUuid from the last record, so unlinked records are dropped — the file's producer must chain records (parentUuid null on the first, the previous record's uuid on each subsequent one)."
Both reconstruction implementations in the binary (the resume leaf-walk and the SDK/worker-side DAG-leaf variant) select a leaf at or after the flushed error tail and walk up parentUuid; the recovered branch is excluded by construction. The three repair mechanisms that exist do not apply to this shape:
- the timestamp fallback fires only when a parent uuid is missing from the file (all parents exist here);
- the same-
message.idduplicate merge only recovers records matching an assistant already in the walked chain; logicalParentUuidrelinking applies tocompact_boundaryrecords only (no record in these episodes carries a side-link).
Observed live: a session with six such episodes was later resumed, and the rebuilt chain contained all ten api_error records and zero of the 103 orphaned records (66 assistant messages).
Reproduction
Real-world recipe: during org-level TPM pressure, catch a turn that shows a brief retry notice and then completes. Finish the turn, exit, run claude --resume, and ask the model what it just did. The recovered segment is absent, and on disk the last api_error record has stop_hook_summary (or the next user record) as its child.
Deterministic demonstration with a synthetic transcript (fully invented content):
{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/tmp/demo","sessionId":"11111111-2222-3333-4444-555555555555","version":"2.1.247","type":"user","message":{"role":"user","content":"Pick a random animal and tell me what it eats."},"uuid":"aaaaaaaa-0000-0000-0000-000000000001","timestamp":"2026-09-01T00:00:00.000Z"}
{"parentUuid":"aaaaaaaa-0000-0000-0000-000000000001","isSidechain":false,"userType":"external","cwd":"/tmp/demo","sessionId":"11111111-2222-3333-4444-555555555555","version":"2.1.247","type":"assistant","message":{"id":"msg_synthetic01","type":"message","role":"assistant","model":"<model id>","content":[{"type":"text","text":"The otter. It eats shellfish and small fish."}],"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":15}},"uuid":"aaaaaaaa-0000-0000-0000-000000000002","timestamp":"2026-09-01T00:00:05.000Z"}
{"parentUuid":"aaaaaaaa-0000-0000-0000-000000000001","isSidechain":false,"userType":"external","cwd":"/tmp/demo","sessionId":"11111111-2222-3333-4444-555555555555","version":"2.1.247","type":"system","subtype":"api_error","level":"error","error":{"status":429,"message":"429 rate_limit_error (synthetic)"},"retryInMs":2000,"retryAttempt":1,"maxRetries":10,"source":"request_retry","uuid":"aaaaaaaa-0000-0000-0000-000000000003","timestamp":"2026-09-01T00:00:02.000Z"}
{"parentUuid":"aaaaaaaa-0000-0000-0000-000000000003","isSidechain":false,"userType":"external","cwd":"/tmp/demo","sessionId":"11111111-2222-3333-4444-555555555555","version":"2.1.247","type":"system","subtype":"stop_hook_summary","hookCount":0,"hookInfos":[],"hookErrors":[],"hookAdditionalContext":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","uuid":"aaaaaaaa-0000-0000-0000-000000000004","timestamp":"2026-09-01T00:00:05.500Z"}
Save as ~/.claude/projects/-tmp-demo/11111111-2222-3333-4444-555555555555.jsonl, then:
cd /tmp/demo
claude --resume 11111111-2222-3333-4444-555555555555 --print "What animal did you pick? Answer only from our conversation."
With the bug shape, the model cannot see its own reply: the leaf walk lands on the user record through the system chain, and the assistant record is a dropped sibling fork. Control: re-parent the stop_hook_summary record onto …0002 (the clean-turn shape) and the model answers from the reply.
Detection one-liner for affected transcripts: any api_error uuid that appears as another record's parentUuid.
Suggested fix direction
At flush time, parent the first buffered api_error on the current leaf rather than the leaf captured at error time. Alternatively, make the error chain a dead-end side branch and chain stop_hook_summary onto the real leaf, matching clean-turn behavior. Either preserves the error records without splicing them into the spine over the recovered content.
Adjacent existing issues (not duplicates)
Linked for triage context: #43764 (wrong leaf after power loss), #22566 (responses never written after cd), #21617 (chain break from a missing record), #36583 (messageId collisions break traversal), #59520 (unrecoverable session when retries fail), #22898 (error sessions hidden from /resume), #47931 (stop_hook_summary as terminal record). This report differs from all of them: the retries succeed, the output is persisted, and the late-flushed error chain orphans it.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗