resume shows stale messages: broken parentUuid chain after context compaction

Status Open
Reported on v2.1.96
Maintainer reply ✓ Yes — bcherny
Activity 5 comments · opened Apr 11, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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):

  1. Work normally for several hours after the last compaction
  2. Exit the session (Ctrl+C or accidental disconnect)
  3. claude --resume <session-id>
  4. Expected: see the most recent messages
  5. 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 parentUuid pointing to a UUID that does not exist anywhere in the JSONL as a uuid field
  • 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' parentUuid to 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

View original on GitHub ↗

5 Comments

github-actions[bot] · 4 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/21617
  2. https://github.com/anthropics/claude-code/issues/43941
  3. https://github.com/anthropics/claude-code/issues/35024

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

junaidtitan · 4 months ago

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 init

Feedback welcome.

ojura · 4 months ago

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 in logicalParentUuid while parentUuid is null (or points at the now-unreachable pre-compact tail). The chain-walking code in extension.js follows parentUuid only, so any path that reads back the conversation (--resume render, rewind UI, fork-action discoverability) stops at the boundary even though the pre-compact transcript is intact on disk.

Adding a logicalParentUuid fallback to the chain walker resurfaces those messages without changing the write side or the API context (which is bounded independently by getMessagesAfterCompactBoundary scanning for the boundary marker, not by walking parents).

There are two near-identical inline parentUuid walkers in extension.js. Both need the same bridge:

- <x> = <x>.parentUuid ? <map>.get(<x>.parentUuid) : void 0
+ <x> = <x>.parentUuid ? <map>.get(<x>.parentUuid)
+                       : (<x>.logicalParentUuid ? <map>.get(<x>.logicalParentUuid) : void 0)

Variable names differ between the two sites and across releases (e.g. A=A.parentUuid?K.get(...) and O=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 the K=!1 opt-in fallback used by forkSession. Only the inline walkers need bridging.

Empirical verification

Applied locally on 2.1.120 and 2.1.121. Confirmed:

  • claude --resume now renders the full post-compact and pre-compact transcript continuously
  • Forked sessions show pre-compact messages with their fork-action buttons
  • Continuation API behavior unchanged (the bounded slice getMessagesAfterCompactBoundary returns is unaffected)

This is a band-aid: existing corrupted sessions are reachable again, but the underlying write-side bug (boundary message persisted with parentUuid pointing 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-antigravity skill that re-applies a small set of surgical edits whenever the bundled extension updates; the two-walker structural locator survives the m1 to c1 / L to A / R1 to W1 / d5 to n5 symbol drift between 2.1.120 and 2.1.121 unchanged.

ojura · 3 months ago

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, inside compactConversation (covers both auto-compact and manual full /compact):

const boundaryMarker = createCompactBoundaryMessage(
  isAutoCompact ? 'auto' : 'manual',
  preCompactTokenCount ?? 0,
  messages.at(-1)?.uuid,    // no filter
)

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's uuid on disk: exactly the broken-chain pattern in OP's --resume JSONLs 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, inside partialCompactConversation (the /compact <selection> path), filters non-loggable types explicitly:

// Progress messages aren't loggable, so forkSessionImpl would null out
// a logicalParentUuid pointing at one. Both directions skip them.
const lastPreCompactUuid =
  direction === 'up_to'
    ? allMessages.slice(0, pivotIndex).findLast(m => m.type !== 'progress')?.uuid
    : messagesToKeep.at(-1)?.uuid

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, two compact_boundary stitches; 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 .bak snapshot |
| 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 /compact runs 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

-    messages.at(-1)?.uuid,
+    messages.findLast(m => m.type !== 'progress')?.uuid,

(isChainParticipant from messages.ts would 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 logicalParentUuid when parentUuid doesn'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 0727164e against 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.

bcherny collaborator · 9 days ago

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 --resume silently 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