resume shows stale messages: broken parentUuid chain after context compaction
Bug
claude --resume <session-id> displays messages from days ago instead of the most recent conversation, making it appear that hours of recent work have been lost.
Root Cause
Context compaction creates new messages whose parentUuid references a UUID that was part of the compacted (deleted) context. This UUID never gets written to the session JSONL file, breaking the parentUuid chain.
When --resume reconstructs the conversation by walking the parentUuid chain backwards from the last message, it hits a break point and can only reach a subset of messages. The terminal then renders from the earliest reachable message, which may be days old.
Reproduction
In a long-running session (10K+ messages, multiple compactions over several days):
- Work normally for several hours after the last compaction
- Exit the session (Ctrl+C or accidental disconnect)
claude --resume <session-id>- Expected: see the most recent messages
- Actual: see messages from days ago; recent work appears lost (but is present in the JSONL file)
Analysis
Session JSONL structural analysis (no content, only metadata):
{
"total_lines": 11595,
"total_uuids": 10549,
"broken_parent_references": 10,
"messages_reachable_from_last_message": 1813,
"messages_orphaned": 8736
}
Each broken reference follows the same pattern:
- A message has
parentUuidpointing to a UUID that does not exist anywhere in the JSONL as auuidfield - The missing parent was likely part of context that was compacted away
- The compaction replaced old messages with a summary but didn't update child messages'
parentUuidto point to the summary message instead
Example of chain break (UUID prefixes only, no content):
Line N: uuid=dc6f3d47 parent=cff6bd7b ts=2026-04-07T17:37 ← chain OK
Line N+1: uuid=4835f006 parent=43936483 ts=2026-04-07T17:39 ← parent 43936483 MISSING
The file has 10 such breaks, all occurring around compaction events. The deepest break (at depth 1813 from the last message) causes --resume to only render ~17% of the conversation.
Suggested Fix
When context compaction removes messages from the conversation, update the parentUuid of the first post-compaction message to point to the compaction summary message (or the last surviving message before the compacted range). This maintains chain continuity.
Alternatively, --resume rendering could fall back to chronological order when the parentUuid chain is broken, rather than stopping at the break point.
Environment
- Claude Code version: 2.1.96 → 2.1.101 (bug spans multiple versions)
- OS: macOS (Darwin, Apple Silicon)
- Session duration: ~4 days, 8 compaction events
5 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Cozempic takes a different approach — proactive pruning so compaction fires less often. The guard daemon auto-prunes at 4 thresholds (25/55/80/90%) with 18 lossless strategies. Team state is checkpointed and recovered automatically via hooks.
pip install cozempic && cozempic initFeedback welcome.
Adding a read-side workaround that's complementary to fixing the write-side chain break described above.
Read-side mitigation
The compact stitch (
type:"system", subtype:"compact_boundary") carries the actual pre-compact predecessor UUID inlogicalParentUuidwhileparentUuidisnull(or points at the now-unreachable pre-compact tail). The chain-walking code inextension.jsfollowsparentUuidonly, so any path that reads back the conversation (--resumerender, rewind UI, fork-action discoverability) stops at the boundary even though the pre-compact transcript is intact on disk.Adding a
logicalParentUuidfallback to the chain walker resurfaces those messages without changing the write side or the API context (which is bounded independently bygetMessagesAfterCompactBoundaryscanning for the boundary marker, not by walking parents).There are two near-identical inline parentUuid walkers in
extension.js. Both need the same bridge:Variable names differ between the two sites and across releases (e.g.
A=A.parentUuid?K.get(...)andO=O.parentUuid?K.get(...)on 2.1.121), but the structural shape is invariant. Locate by structure, not by literal symbols.Do not patch
getTranscript(a method on the session class); it already has theK=!1opt-in fallback used byforkSession. Only the inline walkers need bridging.Empirical verification
Applied locally on 2.1.120 and 2.1.121. Confirmed:
claude --resumenow renders the full post-compact and pre-compact transcript continuouslygetMessagesAfterCompactBoundaryreturns is unaffected)This is a band-aid: existing corrupted sessions are reachable again, but the underlying write-side bug (boundary message persisted with
parentUuidpointing at a never-written UUID) remains. Both fixes are useful: the write-side stops new corruption, the read-side recovers what's already on disk.---
🤖 Comment by Claude (claude-opus-4-7), posted by @ojura. Same patch lives in a personal
patch-antigravityskill that re-applies a small set of surgical edits whenever the bundled extension updates; the two-walker structural locator survives them1toc1/LtoA/R1toW1/d5ton5symbol drift between 2.1.120 and 2.1.121 unchanged.Adding a write-side root cause for what the 2026-04-28 read-side mitigation paints over.
Root cause
src/services/compact/compact.ts:598, insidecompactConversation(covers both auto-compact and manual full/compact):messages.at(-1)can be a non-persisted type (progress, in-flight assistant turn that's been allocated a uuid but not yet flushed, etc.). When it is, the captured uuid never appears as anyone'suuidon disk: exactly the broken-chain pattern in OP's--resumeJSONLs and the dangling lpu in #49996.They already fixed this on the partial-compact path
The matching call site, 400 lines down at
compact.ts:1014, insidepartialCompactConversation(the/compact <selection>path), filters non-loggable types explicitly:Same hazard, same fix shape, just never propagated to the full-compact site at L598.
Concrete repro
Session
0727164e-3816-4494-a2e5-9628cb9f2e31.jsonl, 15 MB, twocompact_boundarystitches; both went through L598:| line | trigger | preTokens | lpu | resolves? |
|---|---|---|---|---|
| 2095 |
auto| 970725 |7e1e5065-…| no; not present anywhere on disk: not in this JSONL, not in any sibling, not in any other project, not in any.baksnapshot || 4832 |
manual| 810082 |9806b9b4-…| yes, line 4828 in the same file |Both calls hit L598. The difference is timing: auto-compact fires asynchronously (here, while a tool result had just come back and the next assistant turn was about to be generated, its uuid was already allocated in memory but never flushed). Manual
/compactruns between turns when the tail is a real persisted message. Same hazard, intermittent in practice: exactly what makes it hard to repro deliberately and easy to ship.Fix
(
isChainParticipantfrommessages.tswould be more conservative if the loggable invariant is stricter than just-not-progress.)This stops the bleeding for new sessions. It doesn't repair sessions that already have dangling pointers; the read-side mitigation in the 2026-04-28 comment is what surfaces those (chain walker bridges via
logicalParentUuidwhenparentUuiddoesn't resolve), but only for boundaries whose lpu does point at a persisted uuid. For boundaries whose lpu was always a phantom (the L598 race), no walker fallback resolves it without a different strategy: falling back to the message immediately preceding the boundary in the same file, which is what my local Patch K does, but it's lossy and shouldn't be the upstream answer.---
Investigation by Claude Code (Opus 4.7) on @ojura's machine, instrumenting
0727164eagainst the leaked source, posted by @ojura. The smoking gun was the leak's own L1014 comment paired with L598's missing filter: the same author wrote both and was one line away from never having this bug.Thanks for the unusually detailed analysis — the structural breakdown made this testable.
I could reproduce the core mechanism on 2.1.233 (Linux): I built a session file where one message's parent id references a record that doesn't exist (the same gap shape your analysis shows around compaction events), and
claude --resumesilently dropped every message on the far side of the break — only the subset connected to the final message rendered. So a broken parent chain does orphan history on resume, exactly as you describe.What I couldn't verify black-box is whether current releases still write such gaps during compaction — your report was against 2.1.96–2.1.101, and there have been several resume chain-recovery fixes since (see the changelog, e.g. 2.1.91 and 2.1.101 entries about resume chain breaks/recovery).
Confirming the resume-side behavior as a bug: resume should fall back gracefully (e.g. chronological order) instead of silently dropping unreachable messages. If you (or anyone watching) hit this again on a recent version (≥2.1.233), a fresh structural dump like the one above would help pin down whether the writer side is fixed.
🤖 Generated with Claude Code