[BUG] Resuming a fork forfeits its prompt cache: deterministic miss after any tool loop, fork 5m vs parent 1h TTL, full-context rewrites while the prefix is live

Status Open
Reported on v2.1.237
Maintainer reply None cached
Activity 4 comments · opened Aug 21, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Claude Code's own system prompt steers agents into resuming spawned agents (verbatim quotes under Additional Information).

Resuming a fork is silently cache-hostile, in three distinct ways sharing one fix surface (fork wake serialization/caching):

Problem 1 (P1) — deterministic: one tool call in a fork's turn guarantees the next wake misses with messages_changed

  • One trivial Bash echo in the fork's turn → the very next wake misses. Text-only turns never trigger it.
  • Idle duration is irrelevant: misses at 63 ms, 71 s, 166 s, 21.7 min.

Predicted in advance and confirmed at 3 scales / 2 models (controlled probes, synthetic sessions), zero failed predictions — each after consecutive clean wakes, each induced by one tool call:

| Fork context | Model | Tokens missed |
| :----------- | :--------- | ------------: |
| ~35k | Opus | 21,991 |
| ~81k | Opus | 67,249 |
| ~110k | Sonnet 4.6 | 80,440 |

At scale the same trigger escalates from a diagnostic to a full context re-buy — see Problem 3.

What the client serializes differently is unidentified. Byte-level /v1/messages captures (Sonnet 4.6 fork, reverse proxy) prove a byte-stable mode exists: system/tools/history byte-identical across spawn + tool loop + two wakes, breakpoint only advancing, thinking preserved verbatim. That fork never fired messages_changed. No capture straddles an expensive wake yet.

Problem 2 (P2) — forks are second-class in the client's own cache policy: 5m TTL where the parent gets 1h, same shared prefix

  • Every parent cache_control breakpoint carries ttl: "1h"136/136 requests across three sessions, zero exceptions (37/37, 65/65, 34/34). Every post-spawn fork breakpoint carries no ttl (5m default). Not sampled inference: these are Claude Code's own request bytes.
  • Only the fork's spawn request writes ephemeral_1h; every later fork request writes ephemeral_5m.
  • So any wake >5m idle lands back on the spawn boundary: cache_read = spawn read + spawn 1h write, verified to the token (32,381 = 27,667 + 4,714; 108,591 = 96,961 + 11,630), and the whole post-spawn tail is re-bought at write prices, growing every cycle (2,908 → 4,039 over five wakes).
  • cache_miss_reason is null throughout. No expiry diagnostic exists: a 386,393-request scan shows all six observed reason values diagnose _changes_; nothing ever reports "aged out". The cost is structurally invisible.

Problem 3 (P3) — catastrophic full-floor collapse: the wake loses even the prefix the fork matched at spawn

70 occurrences across 54 forks in 41 sessions — a census of all 1,449 fork transcripts among 5,397 subagent files, every occurrence firing messages_changed. The original report documented 5 of them as detailed case studies (three forks, two sessions, ~120–157k inherited context); the census shows the phenomenon is far more common and spans every model family tested: claude-fable-5 (36), claude-opus-5 (22), claude-opus-4-8 (4), claude-sonnet-5 (4), claude-sonnet-4-5 (2). The cache-read drop ranges from ~600 to ~191,500 tokens below the fork's spawn read; two occurrences dropped to cache_read = 0.

The largest organic events — ordinary working sessions, no probing involved; both verified against their transcript records, both messages_changed:

| organic collapse (fork wake) | model | spawn read | wake read | created on wake | reported missed |
| :--------------------------- | :------ | ----------: | --------: | --------------: | --------------: |
| 2026-08-16, Fable 5 fork | fable-5 | 218,867 | 27,324 | 199,544 | 178,792 |
| 2026-08-20, Opus 5 fork | opus-5 | 204,759 | 14,474 | 230,770 | 212,937 |

On every collapse cache_read falls to the session's system+tools floor — a per-session constant (exactly 14,479 on every event in one case-study session, 14,834 in the other; the floor tracks the session, not the fork) — _below the fork's own spawn read_ (88,802 in the three-wake fork below). Problem 2 lands _on_ the spawn boundary; Problem 3 lands _beneath_ it. The whole context is re-bought at 5m write prices: one event cost 142,746 created / 14,479 read = 91.5¢, cent-exact at Opus 5 list prices (a 5m write is 12.5× a hit).

One organic fork (2.1.237, Fable 5), woken three times after end_turn (every turn contained tool calls) — read pinned at the floor, the re-written span growing every wake:

| | create | read | cache_miss_reason |
| :----- | ------: | ---------: | :-------------------------------- |
| spawn | 1,973 | 88,802 | — (inherited) |
| wake 1 | 99,758 | 14,834 | messages_changed, 86,406 missed |
| wake 2 | 105,416 | 14,834 | messages_changed, 90,973 missed |
| wake 3 | 107,213 | 14,834 | messages_changed, 92,796 missed |

  • The prefix was provably live: the parent hit it cleanly 4 s before one collapse (parent read 134,035; the fork read 14,834), and another collapse fell inside the fork's own still-live ephemeral_1h spawn block. Refused match, not expiry.
  • Parent activity is not the cause: one fork's parent issued zero requests between the fork's end_turn and its wake — the fork still collapsed (102,898 created / 14,479 read).
  • Same trigger class as P1; not deterministically reproduced — controlled runs to ~110k yield only P1's diagnostic form with the read holding. Mechanism unidentified. Related silent class: one controlled rebuild (24,276 created / 12,455 read) carried cache_miss_reason: null — the costliest controlled event had no diagnostic at all.
  • Also: the sender cannot observe the turn boundary that separates a cheap mid-turn delivery from an expensive wake — a message landing 63 ms after end_turn paid a full rewrite (102,898 created).

Census methodology. For each of the 1,449 fork transcripts (identified by .meta.json): deduplicate assistant records by requestId, then flag any post-spawn record whose cache_read_input_tokens falls below the spawn record's while cache_miss_reason contains messages_changed. Per-occurrence evidence (session UUIDs, line numbers, token figures) available on request.

What Should Happen?

Resuming an idle fork must do one of two things: return an error, or resume with a cache hit on the inherited prefix. What it must never do is what it does now: accept the wake and silently re-buy the entire context at write-tier prices.

Concretely:

  1. A woken fork must read the cache at least as deep as its spawn read. The prefix is live — the parent is actively warming it, and the fork matched it at spawn. A wake reading below the spawn read (Problem 3) is a refused match on a live prefix, not expiry, and should be impossible.
  2. Fork requests must carry ttl: "1h" like the parent's (Problem 2). Claude Code already writes ttl: "1h" on every parent cache_control breakpoint and omits it on fork breakpoints — the fix surface is a serializer literal.
  3. Any cache rebuild must be diagnosable. Today both the 5m-expiry re-buy and the costliest controlled rebuild carried cache_miss_reason: null — the cost is structurally invisible.

Error Messages/Logs

N/A — no error is emitted anywhere; the failure is silent billing (see What Should Happen, point 3). Measured wake-by-wake figures are under Additional Information.

Steps to Reproduce

P1 (magnitude scales with inherited context):

  1. Spawn a named fork (subagent_type: "fork" + name) from a parent with substantial history.
  2. SendMessage: have it run one trivial tool call (Bash echo) and end its turn.
  3. SendMessage again (any gap; 71 s sufficed).
  4. Wake request shows cache_miss_reason: {"type": "messages_changed"}. Wakes without a preceding tool loop stay null (control).

P2:

  1. Spawn a named fork, note spawn read + ephemeral_1h write (= boundary).
  2. Idle >5 min after its turn ends.
  3. Wake: cache_read = boundary exactly, tail re-written at ephemeral_5m, no diagnostic.
  4. Parent writes are ephemeral_1h throughout.

Claude Model

Other — claude-opus-5, claude-fable-5, claude-sonnet-4-6. P2 is model-independent (client request bodies). P1's diagnostic form reproduces on Opus and Sonnet. P3 (full-floor collapse) occurs across all model families: fable-5, opus-5, opus-4-8, sonnet-5, sonnet-4-5 (census of 1,449 fork transcripts); only claude-sonnet-4-6 showed no full-floor events (P1 diagnostic form only).

Claude Code Version

2.1.238 (also observed on 2.1.237)

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Additional Information

Wake-by-wake figures. Every event below is read straight from the session transcripts (assistant records deduplicated by requestId); the diagnostic column is the API's own message.diagnostics.cache_miss_reason. Note where it reads null: the recurring 5m-expiry re-buys carry no diagnostic at all — the silent billing that What Should Happen, point 3 is about.

ORGANIC (ordinary working sessions, no probing):

P3 organic — largest observed (Fable 5, 2026-08-16; spawn read 218,867):
  wake:                         create 199,544 (5m) | read 27,324 | messages_changed, 178,792 missed
P3 organic (Opus 5, 2026-08-20; spawn read 204,759):
  wake:                         create 230,770 (5m) | read 14,474 | messages_changed, 212,937 missed
P3 organic (Opus, 21.7 min idle; spawn: read 142,687 / wrote 2,407 @1h):
  wake:                         create 142,746 (5m) | read 14,479 | messages_changed, 131,501 missed (~91.5¢)

CONTROLLED / SYNTHETIC (deliberate probe sessions, reproduction detail):

P1 controlled (Opus, ~35k fork):
  wake 6 (text-only baseline):  create 4,039 (5m) | read 32,381 | null
  wake 7 (one Bash echo):       create 103+242    | read 36,420 | null
  wake 8 (71 s later):          create 126        | read 36,523 | messages_changed, 21,991 missed

P2 controlled (Opus, ~35k fork; spawn boundary 27,667+4,714 = 32,381):
  5 wakes, 8.7–31.2 min idle:   read pinned at 32,381 every time; tail re-written @5m,
                                growing 2,908 → 4,039; cache_miss_reason null every time

System-prompt guidance steering agents into fork resumption (Claude Code 2.1.238, Opus 5 system prompt, verbatim):

  • Agent tool:

> Use SendMessage with the agent's ID or name to continue a previously spawned agent with its context intact; a new Agent call starts fresh.

  • SendMessage:

> Refer to agents by name — names keep working after an agent completes (a send resumes it from its transcript).

  • Agent listing:

> **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage.

Related, but not duplicate:

  • #77306
  • About: session-level forks (--fork-session, /branch) lose the whole cache on their first request — the scratchpad section interpolates the new session id into the system prompt (system_changed; fix accepted upstream).
  • Not a duplicate because: theirs is a one-shot system-prefix divergence at fork creation; ours starts after a successful spawn and recurs on every wake with messages_changed, in forks that share the parent's system prompt.
  • #63930
  • About: main-thread floor collapse, attributed at 128k-call scale to late mutation of an already-sent message — dominantly right after a typed user turn.
  • Not a duplicate because: their mechanism needs a live thread mutating its own next request; our fork is byte-frozen between end_turn and wake, collapses with no writer present, and carries the fork-only TTL omission (P2), which has no counterpart there.
  • #87966
  • About: intermittent collapse in one very long session — cache_read pins to the system+tools prefix and a 600–750k tail rewrites, server-reported as previous_message_not_found.
  • Not a duplicate because: its corpus is entirely non-sidechain with zero 5m-tier writes, so our fork-specific mechanisms cannot occur there; a lookup failure vs our messages_changed divergence.
  • #44724
  • About: the first SendMessage resume of a fresh subagent reads cache_read 0 — system[1] swaps identity strings between Agent() and SendMessage() — then self-heals.
  • Not a duplicate because: their swap zeroes the entire read once; ours keeps the system+tools floor hitting while messages is reported changed, recurring and growing every wake.

🤖 Generated with Claude Code

View original on GitHub ↗

4 Comments

justprosh · 6 days ago

I found a related deterministic messages_changed mechanism at the Agent SDK boundary. It may help narrow Problem 1's serializer class, although I am not claiming it is necessarily the same cause as a normal Agent fork whose tool executes successfully.

Tested offline with:

  • Claude Code 2.1.241 (@anthropic-ai/claude-code-linux-x64)
  • Agent SDK 0.2.141
  • Linux x64
  • isolated network namespace, loopback request interceptor, dummy API key

Minimal sequence:

  1. Start a session whose assistant emits one tool call.
  2. A PreToolUse hook blocks server-side execution and returns this reason:

> This tool call has been forwarded to the client for execution. The result will be delivered in a future turn. Do not retry, do not call additional tools, and do not generate further text — end your turn now.

  1. Resume at the assistant UUID and supply the real structured client tool_result.
  2. Let that turn emit another tool call, then resume once more without forkSession.

The intercepted API requests show a retroactive historical mutation between steps 3 and 4:

| surface | earlier request | next request |
|---|---|---|
| historical result payload | 22 bytes, SHA-256 97235e69147f91f45214ee2a19638258a1552fcdcdc9985ea064038d48f8c34d | 209-byte hook reason, SHA-256 53266f613f1230fb73f7ae88a77bb64fe4ceadb3dbdbab6b85450d9fb37a9702 |
| tool-use ID | unchanged | unchanged |
| system | unchanged | unchanged |
| tools | unchanged | unchanged |

The mutation remains after ignoring cache_control. There is no idle interval or TTL boundary: it happens on the immediately following resume. Checkpoint UUIDs resolve successfully, and the session ID remains the same throughout the no-fork chain.

Control: repeating the same sequence with forkSession: true on each resumeSessionAt produces a new session ID each turn and preserves the historical result payload byte-for-byte (apart from expected cache_control movement). The 209-byte marker does not appear in subsequent API request bodies.

Interpretation: in this reproducible case, resumeSessionAt replaces the denial tail only for the current query; the source transcript keeps the blocked-tool record, and a later resume serializes that old record instead of the real result accepted on the previous turn. A fork makes the replacement tail durable.

This is still present in 2.1.241. If useful, I can provide a content-free interceptor harness or structural request diffs.

NubeBuster · 6 days ago
[...] If useful, I can provide a content-free interceptor harness or structural request diffs.

@justprosh Yes, I would be very happy if you were able to provide a guest or repo with a minimal interceptor that I presume is a gateway proxy for analyzing the activity. I would be very happy with this for various other analysis and triaging as well. Greatly appreciated!

justprosh · 6 days ago

Here is the standalone reproducer: immutable Gist revision.

It uses only @anthropic-ai/claude-agent-sdk@0.2.141, an explicitly selected @anthropic-ai/claude-code@2.1.241 executable, Node built-ins, and an in-process loopback mock Messages endpoint. It contains no captured sessions or request bodies and needs no credentials.

The canonical run disables external networking:

docker build -t claude-resume-repro .
docker run --rm --network=none claude-resume-repro

The mock emits the same fixed response sequence in both cases and does not know whether the SDK is forking. Assertions inspect the request histories emitted by the actual Claude Code process:

no-fork diff: /messages/2/content/0 tool-result-content-replaced
no-fork: historical tool_result replaced PASS
fork diff: /messages/2/content/0 unchanged-tool-result
fork: historical tool_result preserved PASS

The no-fork chain first serializes the supplied structured result, then restores the earlier synthetic hook denial at the same historical tool-use ID on the following resume. The forkSession: true control preserves the supplied result and changes the returned session ID at each checkpoint fork. Normalized system and tools are asserted unchanged; normalization removes only cache_control.

This is deliberately scoped as a deterministic client-serialization reproducer. It does not claim to reproduce server-side cache accounting, billing, or cache_miss_reason against the live API.

NubeBuster · 6 days ago

Another related instance of cache invalidation caused by client-side tool pool mutation (tools_changed):

Environment & Context

  • Claude Code: 2.1.239 (Fable / standard tier)
  • State: Post-compaction session with deferred tools active (~84k cached tokens).

Order of Events

  1. Dynamic Tool Selection: During teammate orchestration, ToolSearch dynamically loaded coordination tools into the active tool set (SendMessage, TaskStop, TaskList, etc.).
  2. All Teammates Exit: The teammate completed and was stopped with TaskStop, leaving no active teammates in the team.
  3. Tool Pool Pruned: Once the team had no teammates left, Claude Code for some reason ran ToolSearch which pruned the teammate-coordination tools from tools array.
  4. Cache Prefix Breakdown: On the prompt turn, the modified tools array altered the prompt cache prefix, resulting in:

``json
"diagnostics": {
"cache_miss_reason": {
"type": "tools_changed",
"cache_missed_input_tokens": 77817
}
}
``
Only the first 9,049 tokens (base tool schemas) hit cache; the remaining 75,368 tokens (system instructions + entire conversation history) had to be rewritten at full cache-creation cost.

Impact

Automatically pruning dynamically loaded tools when the active teammate pool becomes empty mutates the tool schema prefix, breaking prompt caching for the rest of the session.

Tools loaded via ToolSearch should remain stable in the request tools array for the remainder of the session rather than being evicted when teammates are. In this instance the team was temporarily empty. Not pruning the tools would prevent the severe tools_changed cache thrashing in long-running sessions.