Bug: --resume loads 0% context on v2.1.91 — three regressions in session loading pipeline (source code verified)

Status Fixed / completed
Maintainer reply None cached
Activity 10 comments · opened Apr 3, 2026 · closed Apr 9, 2026

Summary

Session resume (both /resume in the REPL and --resume CLI flag) on v2.1.91 silently loads 0% of conversation history. The model has zero context — not a rendering issue, but actual context loss. Each failed resume attempt corrupts the session JSONL further by forking the parentUuid DAG. Source code analysis reveals three v2.1.91-specific regressions in the session loading pipeline.

We built a Go tool (ccdiag) to recover session context directly from JSONL files.

Environment

  • Claude Code v2.1.91 (npm/Node installation, Windows 10)
  • Opus 4.6, 1M context
  • Session: 20 MB, 5,027 lines, 444 user messages

Important distinction: this is NOT the scrollback rendering bug

Since v2.1.89, terminal scrollback doesn't render properly (#41814, #42024). But on v2.1.89-90 resumes, the context window was still fully loaded — the model remembered the conversation, you just couldn't scroll back to see it visually.

On v2.1.91, resume fails to load context entirely. We used /resume inside the REPL (showed session list, loaded it), then tried claude --resume <id> from CLI — same result. /context confirms:

Messages: 644 tokens (0.1%)
37k/1000k tokens (4%)

The model responds: "context of previous conversation was lost (new session)". This is a context loading regression, not a rendering issue.

Downgrading to v2.1.84 produces the same result — the JSONL was already corrupted by prior resume attempts on v2.1.91.

Root cause analysis (source code verified)

We analyzed the full source code (v2.1.88 TypeScript from the leaked source map) and compared with the v2.1.91 minified cli.js. Three changes in v2.1.91 contribute to the regression:

1. New synchronous reader BxY skips fork pruning (files >5 MB)

v2.1.88 (sessionStorage.ts:3520-3578):

// For files >5MB:
const scan = await readTranscriptForLoad(filePath, size)  // async
buf = walkChainBeforeParse(buf)  // PRUNES dead fork branches
const entries = parseJSONL(buf)

v2.1.91 (cli.js offset ~11454550):

// For files >5MB:
let b = BxY(q, R, V, ()=>{_.clear(), W.clear(), k.clear()})  // SYNC, inline processing
return JQK(_), MQK(_), y()  // EARLY RETURN — walkChainBeforeParse SKIPPED entirely

Verified: walkChainBeforeParse appears 2 times in v2.1.88 source, 0 times in v2.1.91 cli.js — the function was removed. Our 20 MB file triggers this >5 MB path, and fork branches are no longer pruned.

2. New timestamp fallback ExY can bridge fork boundaries

v2.1.88 (sessionStorage.ts:2088-2090):

currentMsg = currentMsg.parentUuid
  ? messages.get(currentMsg.parentUuid)
  : undefined
// Parent not found → walk terminates cleanly

v2.1.91 (cli.js offset ~11439744):

let O = q.get($)           // lookup parent by UUID
if(!O) {
  if(O = ExY(q,Y,z), O)   // NEW: timestamp fallback (5-second window)
    d("tengu_chain_timestamp_fallback",{})
}

Verified: tengu_chain_timestamp_fallback appears 1 time in v2.1.91 cli.js, 0 times in v2.1.88 source — this is entirely new. The ExY function searches for any message within 5 seconds of the current timestamp, regardless of session branch. This can incorrectly bridge across fork boundaries, connecting unrelated messages.

3. Missing leafUuids check in getLastSessionLog (pre-existing)

loadFullLog (sessionStorage.ts:2988) — correct:

findLatestMessage(messages.values(), 
  msg => leafUuids.has(msg.uuid) && (msg.type === 'user' || msg.type === 'assistant'))

getLastSessionLog (sessionStorage.ts:3900) — missing check:

findLatestMessage(messages.values(), m => !m.isSidechain)
// ↑ No leafUuids check! Can pick a mid-chain synthetic message from a failed resume

This pre-existing bug becomes critical with DAG forks: findLatestMessage picks a synthetic message from the most recent resume attempt (by timestamp) instead of the actual conversation leaf.

How the fork corrupts the JSONL

Each resume appends synthetic messages via recordTranscript (called by useLogMessages hook on first REPL render). Multiple messages end up sharing parentUuid = msg444.uuid:

root → msg1 → ... → msg444 (original: 444 messages, 20 MB)
                     ├→ synthetic_1 → hook_1  (resume #1: 2 messages)
                     ├→ synthetic_2 → hook_2  (resume #2: 2 messages)
                     └→ synthetic_3 → hook_3  (resume #3: 2 messages)

getLastSessionLog without leafUuids → picks synthetic_3 (newest timestamp) → buildConversationChain walks: synthetic_3 → msg444 → ... → root. But with walkChainBeforeParse removed in v2.1.91, the reader loads ALL entries including forks, and the chain walk may terminate early or bridge incorrectly via ExY.

Additional findings

  • Multiple resume attempts make it worse — each attempt adds another fork branch to the JSONL. This is not documented anywhere. Resume should be a read-only operation.
  • /export shows full context while API receives truncated version — see ArkNill's cache analysis documenting silent microcompact (327 events) and tool result budget cap (200K chars).
  • Financial impact: the combination of broken resume + cache invalidation + silent microcompact cost us $364.87 in extra usage in a single day (April 2) on top of a $200/month Max subscription — a $10,920/month rate.

Related issues

  • #40319 — Session resume loads zero history (regression v2.1.85)
  • #39856 — Context drops from 67% to 2.5% on resume
  • #40487 — Duplicate UUID fork, 1,232 messages dropped
  • #42376 — --continue v2.1.90 silently drops context
  • #42338 — Resume invalidates prompt cache (cache_read = 0)
  • #42542 — 3 silent microcompact mechanisms, DISABLE_AUTO_COMPACT doesn't disable all
  • #34629 — deferred_tools_delta causes resume cache miss (v2.1.69+)
  • #40524 — Cache invalidation on subsequent turns (74 comments, assigned to qing-ant)
  • #41666 — v2.1.88 yanked release + token loss analysis

Workaround: ccdiag recover

Since Claude Code's resume is broken, we built a Go tool that reads JSONL directly:

# Install
go install github.com/kolkov/ccdiag@latest

# Recover session context
ccdiag recover session.jsonl

# Find and recover latest session for a project
ccdiag recover --latest D:\projects\myproject

# Full dump (handoff summary + all messages)
ccdiag recover --output full -o recovery.md session.jsonl

Extracts: all user/assistant messages, files modified (Write/Edit with counts), GitHub commands (classified), git commands, web searches, issue references — everything needed to reconstruct session context for a new conversation. Zero dependencies, pure Go.

Source: https://github.com/kolkov/ccdiag

Suggested fixes

Fix 1: Restore fork pruning for large files

The walkChainBeforeParse optimization was removed in v2.1.91 when BxY replaced the async reader. Restore it or integrate equivalent pruning into BxY:

// After BxY loads all entries, prune fork branches before chain walk
const leafUuids = computeLeafUuids(messages)
// Remove messages not reachable from the longest leaf chain

Fix 2: Add leafUuids check to getLastSessionLog

sessionStorage.ts:3900 — align with loadFullLog at line 2988:

// Before (broken):
const lastMessage = findLatestMessage(messages.values(), m => !m.isSidechain)

// After (correct):
const lastMessage = findLatestMessage(
  messages.values(),
  msg => leafUuids.has(msg.uuid) && !msg.isSidechain,
)

Fix 3: Guard ExY timestamp fallback

The 5-second window bridge should not cross session/fork boundaries:

function timestampFallback(messages, currentMsg, seen) {
  for (const m of messages.values()) {
    if (seen.has(m.uuid)) continue
    if (m.sessionId !== currentMsg.sessionId) continue  // Don't bridge forks
    const delta = currentTs - ts
    if (delta >= 0 && delta <= 5000 && delta < bestDelta) { bestDelta = delta; best = m }
  }
  return best
}

Fix 4: Make resume read-only

Resume should NEVER append to the JSONL it's loading. Synthetic messages from the resume session should go to a NEW file, not the original.

Prompts for Claude Code to fix itself

Prompt 1: Fix getLastSessionLog leaf selection

Read src/utils/sessionStorage.ts. Find the function `getLastSessionLog` (around line 3869). 
On line 3900, `findLatestMessage` uses predicate `m => !m.isSidechain` WITHOUT checking 
`leafUuids`. Compare with `loadFullLog` (around line 2988) which correctly uses 
`msg => leafUuids.has(msg.uuid) && (msg.type === 'user' || msg.type === 'assistant')`.

Fix: compute `leafUuids` in `getLastSessionLog` (same as `loadFullLog` does) and add the 
`leafUuids.has(msg.uuid)` check to the `findLatestMessage` predicate on line 3900. 
This prevents the chain walker from starting at a synthetic resume message instead of the 
actual conversation leaf.

Prompt 2: Restore fork pruning in the new BxY reader

In v2.1.91, the function `walkChainBeforeParse` was removed when the new synchronous 
inline reader (BxY) replaced `readTranscriptForLoad` for files >5MB. This means fork 
branches from failed resume attempts are no longer pruned before chain walking.

Find the BxY reader (sync file reader using openSync/readSync for large JSONL files). 
After it finishes loading all entries, add a pruning step that removes messages not 
reachable from the longest parentUuid chain. This restores the behavior of the removed 
`walkChainBeforeParse` function.

Prompt 3: Guard timestamp fallback against fork bridging

In `buildConversationChain` (sessionStorage.ts), v2.1.91 added a timestamp-based fallback 
(ExY function, telemetry event "tengu_chain_timestamp_fallback") that finds the nearest 
message within a 5-second window when parentUuid lookup fails.

This fallback can incorrectly bridge across fork boundaries — connecting a message from 
one resume attempt to a message from a different branch. Add a guard: the fallback should 
only consider messages with the same sessionId as the current message. Also skip messages 
already in the `seen` set.

Prompt 4: Make resume append to a new file

When resuming a session, `useLogMessages` hook calls `recordTranscript` which appends 
synthetic messages (NO_RESPONSE_REQUESTED, hook outputs) to the ORIGINAL session JSONL. 
This creates forks in the parentUuid DAG that break subsequent resumes.

Fix: on resume, create a NEW continuation JSONL file (e.g., {sessionId}-{timestamp}.jsonl) 
for new messages. The original file stays read-only. The session loader should scan for 
continuation files and merge them in order when loading.

Prompt 5: Add resume diagnostics

Add a diagnostic check before resume: after loading the JSONL and building the conversation 
chain, compare chain length with total non-sidechain messages. If the chain contains less 
than 50% of messages, warn the user:

"Warning: resume loaded {chain_length} of {total_messages} messages ({percentage}%). 
The session may be corrupted by fork branches. Use `ccdiag recover` to extract full context."

Log the discrepancy to telemetry as "tengu_resume_chain_truncation".

Workaround: ccdiag — session analyzer, recovery & proxy

Until these bugs are fixed, we built a Go CLI tool that bypasses the broken resume logic:

go install github.com/kolkov/ccdiag@latest

# Recover lost session context (reads JSONL directly, not via broken walker)
ccdiag recover --latest D:\projects\myproject
ccdiag recover --output full -o recovery.md session.jsonl

# Real-time API traffic proxy — detect cache invalidation, token burn, silent fallback
ccdiag proxy --verbose
# .claude/settings.local.json: { "env": { "ANTHROPIC_BASE_URL": "http://localhost:9119" } }
ccdiag proxy stats --last 1h --cost

The proxy logs per-session JSONL files (session UUID = file name) with token usage, cache ratio, cost, and latency for every API request. Makes it trivial to detect watchdog false positives (99% cache ratio = model was thinking, not hung), cache invalidation after resume (#42338), and silent 529 fallback.

Zero dependencies, Go stdlib only: https://github.com/kolkov/ccdiag

Expected behavior

  1. Warn when context is truncated — don't silently drop 99.9% of history
  2. Don't corrupt the JSONL on failed resume — read-only operation
  3. Follow the longest chain — not the most recent fork
  4. Don't bridge forks via timestamp fallback
  5. Provide a built-in recovery tool — users shouldn't need third-party tools to access their own session data

---

cc: @bcherny @ant-kurt @fvolcic @bogini @ashwin-ant @OctavianGuzu @whyuan-cc @hackyon-anthropic @chrislloyd @catherinewu @ThariqS @rboyce-ant @dicksontsai @dhollman @qing-ant @ddworken @km-anthropic @sid374

View original on GitHub ↗

10 Comments

github-actions[bot] · 4 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/35024
  2. https://github.com/anthropics/claude-code/issues/40319
  3. https://github.com/anthropics/claude-code/issues/37437

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

kolkov · 4 months ago

This is not a duplicate of the listed issues.

  • #35024 and #37437 are about session discovery/visibility — sessions not showing in the picker. Our sessions ARE discovered and shown. The problem is that the loaded context = 0%.
  • #40319 is the closest match (we reference it), but our analysis goes significantly further: we identified three specific v2.1.91 code changes causing the regression, verified against both the v2.1.88 TypeScript source and v2.1.91 minified cli.js:
  1. walkChainBeforeParse removed (2 occurrences in v2.1.88 → 0 in v2.1.91)
  2. New ExY timestamp fallback (tengu_chain_timestamp_fallback: 0 in v2.1.88 → 1 in v2.1.91) bridges fork boundaries
  3. Missing leafUuids check in getLastSessionLog (line 3900)

We also provide 5 ready-made prompts for Claude Code to fix itself, a Go recovery tool (ccdiag), and document how each failed resume corrupts the JSONL by forking the parentUuid DAG.

This is a root cause analysis issue, not a "me too" report.

jmarianski · 4 months ago

Great summary. And your tool sounds like a great addition to the collection of things everyone else had built. @ArkNill have you seen this issue?

ArkNill · 4 months ago

@jmarianski Yes, just caught up on this.

@kolkov Solid root cause analysis — appreciate the effort and sharing this publicly. The three regressions you identified (walkChainBeforeParse removal, ExY timestamp fallback bridging forks, missing leafUuids in getLastSessionLog) are well-documented against the source.

My primary testing environment is Linux CLI, Max 20, v2.1.91 (both npm and standalone) with a MITM proxy (cc-relay) capturing every request/response. I haven't hit this specific resume regression because my workflow is session-per-task rather than long-running 400+ message sessions, but the underlying mechanisms you describe (DAG fork corruption on resume append, silent context truncation) are consistent with what I've observed on the context mutation side — specifically the 327 microcompact events and 261 budget cap events in my proxy data.

I also did a quick test on Windows with the latest CLI version — token caching has noticeably improved compared to earlier versions. The cache regression (B1-B2 in my analysis) does appear to be genuinely fixed across platforms.

That said, the fact that resume writes to the original JSONL (your Fix 4) is particularly concerning — that's a design-level issue, not just a code bug.

I'll add a reference to your resume regression analysis in the cache analysis repo — different layers of the same broader problem.

kolkov · 4 months ago

Updated the issue body with a Workaround section — ccdiag now includes a transparent API proxy (ccdiag proxy) that logs per-session traffic with token usage, cache ratio, cost estimation, and latency. Makes it easy to catch cache invalidation, watchdog false positives, and silent 529 fallback in real-time.

bnnuybnnuy · 4 months ago

I've been struggling with the same issue - I've experienced two separate corrupted sessions across two of my machines so far.

Environment:

  • Claude Code v2.1.90/v2.1.91
  • NixOS (Linux 6.19.6/Linux 6.19.10)
  • Opus 4.6 (1M context)

Session 1 - 24MB, 4,786 lines

I had 2,540 null bytes (\x00) prepended to a file-history-snapshot entry at line 1,242. Claude Code's CLI couldn't parse past it, so only the first 1,241 lines were visible, causing 75%~ of the conversation to just be gone. It resumed from whatever the last user message was before the corruption, ignoring 3,544 lines of real conversation after it. I also had 72 lines of fork artifacts tacked onto the tail, These artifacts also somehow had updated timestamps.

Session 2 - 24MB+, 47,433 lines

Same null byte issue at line 13,802 (523 bytes this time), but this one was much worse. the CLI showed 0 tokens and treated it as a completely new chat. On top of that, 232 lines at the tail were duplicated content from ~10 days earlier. Messages originally from March 23-24 showed up again with April 2nd timestamps. Different UUIDs but the same conversation flow, looked exactly like the ExY timestamp fallback pulling content across fork boundaries.

Lines up with all three regressions in your report

  • Both files are well over 5MB, so they hit the BxY sync path that skips walkChainBeforeParse - no fork pruning
  • Session 2's duplicate tail is a clear case of the timestamp fallback bridging forks - old content replayed with fresh timestamps
  • Session 1 resumed from the wrong point, session 2 showed 0 tokens - both consistent with getLastSessionLog picking a synthetic message instead of the real leaf
  • Trying to resume multiple times just made both files worse, exactly as you described
  • The null bytes are a separate bug (#40906) - both files had exactly one corrupted file-history-snapshot line each

Can also confirm the /export behavior you mentioned

/export shows the conversation looking perfectly fine, no sign of the duplicate tail or corruption. Same thing with my own custom JSONL parser, it renders the chat correctly and doesn't show the forked content. So the data for the real conversation is all there in the file, the resume logic just can't find it.

What I personally did to fix them - Manually

  1. Stripped the leading \x00 bytes from the corrupted line
  2. Truncated the file at the last real message to remove the duplicate/fork tail

Both sessions worked again after that - I'm choosing to stay on v2.1.86 until further notice.

I strongly agree with your fix 4

Resume should 100% be read-only. The worst part of this bug is that just trying to resume appends fork branches that make the corruption harder to recover from. A read type operation shouldn't be modifying the session file.

kolkov · 4 months ago

@bnnuybnnuy Excellent confirmation — your Session 2 with duplicated March 23-24 content showing April 2nd timestamps is the clearest evidence yet of the ExY timestamp fallback bridging across fork boundaries. Different UUIDs but same conversation flow = exactly what the code does when leafUuids check is missing.

As I mentioned above, we've built tooling around this — both for recovery and real-time monitoring.

The null bytes corruption (#40906) is a separate bug but compounds the problem — once the walker hits a parse error, it falls back to timestamp-based ordering which triggers the fork bridging.

For future sessions — instead of manually stripping \x00 and truncating, you can use ccdiag to extract full context directly:

go install github.com/kolkov/ccdiag@latest
ccdiag recover --output full -o recovery.md your-session.jsonl

It reads the JSONL directly, bypasses the broken walker entirely, and outputs a structured handoff document with all messages, files modified, GitHub commands, etc. We built it after hitting the same wall.

Also: staying on v2.1.86 is smart — v2.1.92 (just released) still has the same three regressions, watchdog timing bug is also still unfixed.

kolkov · 4 months ago

One correction to my previous comment: staying on v2.1.86 avoids the three resume regressions, but keeps three other bugs that were fixed in v2.1.90:

  • Resume prompt cache miss (regression since v2.1.69) — every --resume = full cache miss, burns tokens fast
  • Quadratic SSE parsing — streaming frames processed in O(n²)
  • Quadratic transcript writes — long sessions slow down exponentially

So v2.1.86 trades resume corruption for token drain. There's no clean version right now — every release has something broken. We patch v2.1.92 manually (watchdog timing fix) and accept the resume risk by never resuming old sessions + maintaining handoff files.

ArkNill · 4 months ago

@kolkov FYI — this was just closed by an Anthropic member with no comment. You may want to check if it's been addressed or consider reopening.

github-actions[bot] · 4 months ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.