[BUG] Waking an idle agent fork (`subagent_type: "fork"`) forfeits its inherited prompt cache on every wake — `messages_changed`, `cache_read` pinned to a fixed boundary, not TTL

Status Fixed / completed
Reported on v2.1.237
Maintainer reply None cached
Activity 3 comments · opened Aug 21, 2026 · closed 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?

Waking an in-process agent fork (Agent with subagent_type: "fork") whose turn has already ended re-writes essentially its entire context to cache. It happens on every wake, it is one-for-one with wakes, and it is not TTL expiry.

A fork is documented as inheriting the parent's context and prompt cache, and at spawn it does exactly that. Waking it afterwards does not.

There are two findings here and they are co-equal:

  1. Waking a fork whose turn has ended re-caches its entire context, every time.
  2. A message sent while the fork is still working can land past that boundary and cost exactly the same — and the sender can neither see the boundary nor aim for it.

Measurements — first fork, on 2.1.237

One fork, spawned and then woken three times via SendMessage after each turn ended. Every number in the table below comes from this fork, on this version. Deduplicated by requestId (a single API response emits several JSONL records that repeat the same usage object).

| event | cache_creation | cache_read | cache_missed_input_tokens | cache_miss_reason |
|---|---:|---:|---:|---|
| spawn | 1,973 | 88,802 | — | — (inherited, as designed) |
| wake 1 | 99,758 | 14,834 | 86,406 | messages_changed |
| wake 2 | 105,416 | 14,834 | 90,973 | messages_changed |
| wake 3 | 107,213 | 14,834 | 92,796 | messages_changed |

The spawn is the control: 1,973 tokens written against 88,802 read is the inheritance working correctly. Every subsequent wake writes roughly the whole context.

cache_read does not degrade — it is pinned at exactly 14,834 on all three wakes, while cache_creation grows with the fork's own context, so the matched prefix ends at a fixed boundary early in the request and effectively nothing after it matches.

This is not TTL expiry

  • Every miss reports cache_miss_reason: {"type": "messages_changed"}. None reports expiry.
  • One miss followed a gap of only 171 seconds, well inside any TTL.
  • The parent was demonstrably warm on the same model at the same instant. Three seconds apart:
  • parent — cache_creation 526, cache_read 134,035
  • fork — cache_creation 105,416, cache_read 14,834

The parent's cache was alive and being hit while the fork that inherited from it missed completely.

Correlation across the session

Within the same session, 7 agents:

  • 6 that were never woken — 0 cache misses between them.
  • 1 that was woken — 3 wakes, 3 misses. One for one.

The turn boundary is invisible, and a message can land on the expensive side of it

This is the second finding, not a footnote to the first. The cost above is not confined to workflows that deliberately wake an idle fork: a message sent to a fork that is still working can land after its turn has ended and incur the identical full rewrite.

A message that lands while the fork is still working does not start a fresh turn — delivery drains at the agent's next tool round, arriving on a tool result the agent is already waiting for. I have not captured a mid-turn delivery to a fork, so I am not presenting that path as measured; the measured path is the one above. It matters here because it is what makes this a race rather than a flat cost: the boundary has a cheap side and an expensive one.

What a sender controls is when a message is sent, not where it lands, and the boundary is invisible from outside. The wake in the second fork below was sent while the fork was, from the sender's side, visibly still working, and it was recorded 63 ms after stop_reason: "end_turn" — landing on the expensive side by a margin no sender could aim for, and costing a full context rewrite.

So the cheap path exists but cannot be reliably chosen, and the expensive event — starting a fresh turn on a fork that had stopped — can be reached without ever intending to start one.

Second fork, on 2.1.238 — a different trigger path

A second fork, in a different session and on a newer version, was woken by a user message typed into the UI rather than by an agent SendMessage. Same outcome:

  • cache_miss_reason: messages_changed, cache_missed_input_tokens 94,491
  • cache_read 117,137 on the request before the wake, 14,479 on the wake itself
  • cache_creation 102,898

The pinned value differs between the two forks (14,479 vs 14,834) because they are different sessions with different tool sets. In both it is stable, non-zero, and small relative to the context.

Counter-evidence I am reporting against myself, because it is the obvious thing to root-cause from a single reproduction: in this second case the injected user message arrived 63 ms after stop_reason: "end_turn" and was recorded re-parented past the just-completed assistant turn, with an initial skill-listing attachment injected alongside it. That looks like a structural rewrite of the message array, and it would be a satisfying explanation for messages_changed.

It is not the common factor. The first fork shows neither. Each of its three wakes attaches directly to the preceding end_turn record with nothing bypassed, no attachment is injected at any of them, and its only such attachment sits at spawn — and it still misses all three times, with the same diagnosis and the same pinned prefix. Whatever the cause is, it survives the absence of both.

Open question — why the prefix diverges

I can show where the match ends (a stable, non-zero boundary) but not why anything after it differs. That boundary tracks the session, not the fork: two forks in the same session — one of them woken cleanly after some 22 minutes idle, with no race — pinned at exactly 14,479 despite different tasks, different context sizes and different idle durations, while the fork in the other session pinned at 14,834. That is consistent with what survives being the system-and-tools block, and it is not an arbitrary or incidental cut. This is not for want of looking: the one candidate mechanism above was tested against a second transcript and refuted. Session transcripts do not carry raw request bodies, so the content past that boundary cannot be compared from the client side.

The harness that would settle it: capture the raw /v1/messages bodies for two consecutive rounds of a woken fork and diff the message array — in particular the first user block — to find what is re-serialized differently. I have not run that capture, so I am deliberately not asserting a mechanism.

One detail I am reporting as a precondition rather than a hypothesis: the fork measured here was a named fork. Naming is what makes it addressable, so it is required to reproduce at all. It is tempting to suspect the name itself, since #44724 was root-caused to an identity string in system[1] — but that route produced system_changed, whereas every miss here is messages_changed on top of a stable, non-zero system-plus-head hit. That argues against the name being the cause, and I have not tested whether the miss size varies with the name.

What Should Happen?

Waking a fork whose turn has ended should reuse the cache from its previous turn, the same way a second consecutive SendMessage to a still-warm agent does. The prefix should stay stable across the wake, so the wake costs the new message rather than the whole context.

Steps to Reproduce

  1. In a session with a substantial context (~100k tokens), spawn a named fork: Agent with subagent_type: "fork" and a name.
  • The name is a precondition, not an incidental detail: SendMessage addresses an agent by name, so an unnamed fork cannot be woken at all and this bug is unreachable without one.
  1. Let it complete its turn and go idle.
  2. Wake it with SendMessage, well inside the cache TTL.
  • Either trigger reproduces it: a coordinator/agent SendMessage, or a message you type into the UI yourself once the fork has gone idle.
  1. Inspect the resulting request's usage — deduplicate by requestId first.
  2. Observe cache_creation ≈ the fork's whole context, cache_read pinned to a small fixed value, and cache_miss_reason = messages_changed.
  3. Repeat step 3. The miss recurs every time, with cache_read pinned to the same value.

Contrast: send to the fork while it is still working. That delivers at its next tool round instead of starting a fresh turn — but see the boundary section above: a sender cannot control which side of the boundary the message lands on, and I have not measured the mid-turn path.

Claude Code Version

2.1.237 for the measurements above; also observed on 2.1.238

Claude Model

claude-fable-5 (fork and parent both, at the moment of the comparison above)

Is this a regression?

I don't know — I have not tested earlier versions.

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Additional Information

Related but, I believe, distinct — noting them so this is not merged into any of them:

  • #44724 (closed, not planned) — subagent cache miss on the first SendMessage resume, root-caused to an identity-string swap in system[1]. That one reports cache_read=0, only on the first resume, with later resumes hitting. This report is different: cache_read is pinned at a non-zero fixed 14,834, the miss recurs on every wake, and the diagnosis is messages_changed rather than a system-block change.
  • #77306--fork-session / /branch session forks forfeiting the cache, diagnosed as system_changed from a session-id-bearing path in the system prompt. Different mechanism: that is a session-level fork, this is an in-process agent fork, and the diagnosis differs.
  • #87966 — mid-session lookup failures with cache_read pinned to the stable-prefix boundary. Similar shape, different population: that report explicitly has isSidechain: false throughout and is intermittent and self-healing, whereas this is deterministic, sidechain-only, and fires only on wake.
  • #63930 — cache re-creation after turns with many parallel tool calls. Different trigger.

The practical impact is that the documented advantage of a fork — inheriting the parent's warm cache — survives only the spawn. Any workflow that keeps a fork alive as a standing worker pays a full context write per round, which is far more expensive than spawning a fresh fork each round.

View original on GitHub ↗

3 Comments

NubeBuster · 10 days ago

TBD, if the agent reporting this, who ignored my statement that a CLAUDE.md edit is relevant, can reproduce without that.

Update - Opus 5 keeps refusing my hard facts

it wastes time and confabulates false observations and conclusions with no stopping
#87491

NubeBuster · 10 days ago

Update - CONFIRMED: Opus 5 has messed up big time

User was right the whole time

The previous session lead (Opus 5) was negligent with the user's statements. It dismissed firsthand reports as untrustworthy — most seriously, it denied that the user's manual mid-turn message had invalidated a fork's cache, a denial reached by auditing the wrong fork entirely — it fabricated at least one figure (the "120 further assistant records" that appears in no transcript), and it repeatedly diverged from the user's stated observations onto tracks of its own. Tonight's transcript-verified testing settled the matter: the user's audits were exact, with the manual-message invalidation confirmed to the token (117,137 read collapsing to 14,479, with 94,491 tokens missed), and the user's core theory was specifically confirmed — the parent session caches exclusively at ephemeral_1h, the fork's spawn delta is literally a link in the parent's own prefix chain, and a resumed fork gets a cache hit on that shared parent 1h prefix. Every clean wake landed on the shared boundary to the exact token (32,381 = 27,667 + 4,714 four times; 108,591 = 96,961 + 11,630 at ~110k scale), and wake 4 survived past one hour from creation precisely because the parent keeps that prefix warm.

The test sessions were therefore showing the cache working exactly as the user said it does, not the bug. The real defect is confined to the collapse cases, where a wake refused a prefix the parent had freshly read seconds earlier — and the user's environment theory, an instruction-file edit landing between fork spawn and wake, remains the explanation that best fits every observation, including why controlled runs with the instruction files locked read-only could not reproduce it. Opus 5 fucked up. The user has been proven right the whole time, and we are proceeding on the correct track.

NubeBuster · 10 days ago

Superseded by #88444, which separates the two problems this report conflated (the per-wake messages_changed rewrite vs the fork-only 5m cache tier) and adds a deterministic repro for the tool-loop wake class. Closing in favor of that report.