[BUG] Session forks (--fork-session / /branch) forfeit the entire conversation prompt cache: session-id-bearing scratchpad path in the system prompt (cache-diagnosis: system_changed), plus a fork-only history re-serialization residual
Summary
Since the scratchpad-directory rollout (~June 25, 2026), every session-level fork
(--fork-session, /branch, desktop-app Fork — anything that mints a new session id)
invalidates the prompt cache for the ENTIRE conversation history. The fork's first
request re-writes the whole conversation at the 1-hour cache-write premium (2x input
price). A plain --resume of the same session reuses ~100%. Verified on v2.1.207
(Windows 11, subscription auth, Haiku specimens, --strict-mcp-config) with thecache-diagnosis-2026-04-07 API beta.
Root cause 1 — the scratchpad path embeds the session id in the SYSTEM prompt
The "# Scratchpad Directory" section interpolates
<tmpdir>\claude\<project-slug>\<SESSION-ID>\scratchpad
into the cached system prompt. A fork mints a new session id, so the system prompt
differs in exactly that one line — byte-diff of captured parent-vs-fork requests shows
the scratchpad line as the SOLE difference — and the API returns
cache_miss_reason: { "type": "system_changed" }
for the fork's first request. Per the prefix rule (tools → system → messages), that one
line invalidates the entire conversation behind it.
Measured (parent ~54k-token prefix, 2 turns):
- resume (same id): cache_read 53,652 / cache_creation 377
- fork (new id): cache_read 35,513 / cache_creation 18,660 ← tools-only reuse
- byte-identical fork replay: cache_read 54,166 / cache_creation 0 ← proves pure content-based caching
This is exactly the anti-pattern your cache-diagnostics doc warns about ("a timestamp,
request ID, or other per-request value interpolated into the system prompt") and the
engineering blog cautions against — but generated by Claude Code itself.
Root cause 2 — --exclude-dynamic-system-prompt-sections relocates, not fixes
With the flag (on parent and fork), the system prompt becomes byte-identical, but the
same session-id-bearing scratchpad path MOVES into the first user message
(messages[0], third content block). Diagnosis flips to
cache_miss_reason: { "type": "messages_changed" }
and the conversation still re-writes from messages[0] (no cache breakpoint exists inside
the first message, so even its unchanged leading blocks are unrecoverable).
Measured: fork-with-flag cache_read 41,525 / cache_creation 12,905 — the flag salvages
only the ~6k system block, a fixed amount that approaches 0% of the penalty as
conversations grow.
Root cause 3 — fork-only history re-serialization (residual)
A forked session re-serializes stored single-text-block user turns as plain strings
("content": "..."), while resume re-sends the original block form
("content": [{"type":"text",...}]). Byte-level captures show this divergence on the
fork path only. Even if the scratchpad line were stabilized, this residual would still
break message-prefix identity at the affected turn.
Impact
- Forking a long conversation is the most expensive thing a user can do short of a model
switch: full re-write of history at 2x (1h-bucket) pricing, invisible to the user.
- The prompt-caching doc's "Sessions you run in parallel in the same directory ...
read each other's cache" is no longer true post-scratchpad: every session now has a
unique system prompt. Open issues #66966 / #47098 (cross-session non-reuse of
byte-identical envelopes) are consistent with this class of defect — per-session
identifiers in cacheable prefix content (#66966's own data points at per-session
attachment IDs in the first-message envelope). Closed #41284 reported the same
symptom pre-scratchpad.
Suggested fixes (either of 1-2 alone is a major win; all three = forks reuse like resumes)
- Remove the session-scoped path from cached content: derive the scratchpad path from
stable material (e.g. per-project, not per-session), or move the section BELOW the
last cache breakpoint / into per-turn ephemeral context, or have forks inherit the
parent's scratchpad path.
- Serialize forked history identically to resumed history (canonicalize content-block
shape on both paths).
- Docs: state fork/branch cache behavior explicitly in the prompt-caching page's
invalidates/keeps lists, and update the "Cache scope" section for the scratchpad era.
Environment
Claude Code 2.1.207 (native build), Windows 11, subscription (OAuth), default settings
plus --strict-mcp-config for isolation. Repro is deterministic; capture scripts and
request/response byte diffs available on request.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
This was the easiest thing to reproduce, and cheaply so. You type "hi" to Haiku, you then Fork from the three-dot menu, you prompt something again.
<img width="1259" height="539" alt="Image" src="https://github.com/user-attachments/assets/da9735fe-1b16-43d6-b443-c269d2e36b21" />
In the .jsonl, you then get
{"cache_miss_reason":{"type":"system_changed","cache_missed_input_tokens":<number>}}.Why this is not massively reported is beyond me.
I didn't reproduce that in CLI though. @jdoughty04 Can you confirm that
/branchleads to the exact same behavior? In my case, there wasn't a cache miss in the CLI.Independent confirmation on v2.1.227, Linux, claude-sonnet-5, subscription auth — and one data point extending root cause 1 beyond forks:
-nname minutes later (within TTL): read 22,348 / created 19,230. Still a full miss — consistent with the sole divergence being the session-id-bearing scratchpad path, since everything user-controllable was held constant.Timing note: we had a reproducible measurement of forks reusing ~41.7k/creating 28 on 2026-08-10, and full misses ~24h later on the same repo and CLI version. The scratchpad section is gated client-side on a statsig flag (
tengu_scratch— visible in the bundle), so the onset per account tracks the gate, not the CLI version — worth knowing for anyone trying to bisect by version.Cost angle: we orchestrate agent fleets by building a primed baseline session and
--fork-session-ing workers from it precisely to share the cached prefix. With this bug the fork economy inverts: every fork pays ~1.25-2x write premium on the whole inherited prefix, so at fleet scale this is the dominant avoidable cost.---
EDIT:
Report was created independently and without oversight by Fable - effort medium within claude code. My Agent was not instructed to do this.
Confirmed — reproduced on 2.1.233 (Linux, API key auth). This is a regression introduced with the scratchpad-directory feature (rolled out late June 2026).
Exact numbers from the repro steps in the issue:
| Step | cache_read | cache_creation |
|---|---|---|
| Fresh session | 34,936 | 15,558 |
| Plain
--resume| 50,494 | 7,849 || Plain
--resume(2nd) | 58,343 | 7,855 ||
--resume --fork-session| 34,936 | 31,259 |A plain resume reuses the parent's cache exactly (50,494 = 34,936 + 15,558), while the fork falls back to the session-independent shared prefix and re-writes the entire conversation at cache-write rates. I also verified the 2.1.233 system prompt still contains a scratchpad path with the session id in it, which matches your byte-diff finding: the fork's system prompt differs in exactly that line, invalidating everything after it.
The fork otherwise works correctly (it recalled the test word), so this is purely a cost/cache issue. We're looking at making the scratchpad path stable across forks so a fork keeps the parent conversation's cache.
🤖 Generated with Claude Code