[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
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 echoin 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_controlbreakpoint carriesttl: "1h"— 136/136 requests across three sessions, zero exceptions (37/37, 65/65, 34/34). Every post-spawn fork breakpoint carries nottl(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 writesephemeral_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_reasonisnullthroughout. 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_1hspawn block. Refused match, not expiry. - Parent activity is not the cause: one fork's parent issued zero requests between the fork's
end_turnand 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_turnpaid 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:
- 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.
- Fork requests must carry
ttl: "1h"like the parent's (Problem 2). Claude Code already writesttl: "1h"on every parentcache_controlbreakpoint and omits it on fork breakpoints — the fix surface is a serializer literal. - 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):
- Spawn a named fork (
subagent_type: "fork"+name) from a parent with substantial history. SendMessage: have it run one trivial tool call (Bash echo) and end its turn.SendMessageagain (any gap; 71 s sufficed).- Wake request shows
cache_miss_reason: {"type": "messages_changed"}. Wakes without a preceding tool loop staynull(control).
P2:
- Spawn a named fork, note spawn read +
ephemeral_1hwrite (= boundary). - Idle >5 min after its turn ends.
- Wake:
cache_read= boundary exactly, tail re-written atephemeral_5m, no diagnostic. - Parent writes are
ephemeral_1hthroughout.
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_turnand 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_readpins to the system+tools prefix and a 600–750k tail rewrites, server-reported asprevious_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_changeddivergence. - #44724
- About: the first
SendMessageresume of a fresh subagent readscache_read0 —system[1]swaps identity strings betweenAgent()andSendMessage()— then self-heals. - Not a duplicate because: their swap zeroes the entire read once; ours keeps the system+tools floor hitting while
messagesis reported changed, recurring and growing every wake.
🤖 Generated with Claude Code
4 Comments
I found a related deterministic
messages_changedmechanism 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:
2.1.241(@anthropic-ai/claude-code-linux-x64)0.2.141Minimal sequence:
PreToolUsehook 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.
tool_result.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-25653266f613f1230fb73f7ae88a77bb64fe4ceadb3dbdbab6b85450d9fb37a9702|| 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: trueon eachresumeSessionAtproduces a new session ID each turn and preserves the historical result payload byte-for-byte (apart from expectedcache_controlmovement). The 209-byte marker does not appear in subsequent API request bodies.Interpretation: in this reproducible case,
resumeSessionAtreplaces 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.@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!
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.241executable, 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:
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:
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: truecontrol preserves the supplied result and changes the returned session ID at each checkpoint fork. Normalized system and tools are asserted unchanged; normalization removes onlycache_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_reasonagainst the live API.Another related instance of cache invalidation caused by client-side tool pool mutation (
tools_changed):Environment & Context
2.1.239(Fable / standard tier)Order of Events
ToolSearchdynamically loaded coordination tools into the active tool set (SendMessage,TaskStop,TaskList, etc.).TaskStop, leaving no active teammates in the team.ToolSearchwhich pruned the teammate-coordination tools fromtoolsarray.toolsarray 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
ToolSearchshould remain stable in the requesttoolsarray for the remainder of the session rather than being evicted when teammates are. In this instance the team wastemporarilyempty. Not pruning the tools would prevent the severetools_changedcache thrashing in long-running sessions.