[BUG] Prompt cache misses on every background session: per-session job-dir path contaminates the `cacheScope:"org"` system-prompt block

Status Open
Reported on v2.1.273
Maintainer reply None cached
Activity 1 comment · opened Sep 16, 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?

(did my best to direct Opus to confirm and check the root cause, not confirmed manually )
Q.

TL;DR — Every background session writes its own temp-folder path into a system-prompt chunk that is meant to be cached and shared across sessions. That one unique string breaks the cache prefix, so ~23–28k tokens of unchanged text (CLAUDE.md, skills, MCP) get re-uploaded every time. Sessions without that line hit ~99% cache. Cost and latency only — answers are unaffected.

Spotted in the statusline on a fresh session spawned from agents view — 12.8k read from cache, 22.8k written. Any session started that way, same thing. ~23k token cache writes.

<!-- paste statusline screenshot / stats here -->

<img width="1285" height="972" alt="Image" src="https://github.com/user-attachments/assets/50f2bb46-c3bc-456d-9315-770c757e64bd" />

Claude Code splits its system prompt into two cached chunks: a small one shared everywhere, and a larger one meant to be shared across everyone in an organisation.

Background sessions add a line to that larger chunk containing the session's own temp-folder path — a string that is different for every single session. Prompt caching matches on exact prefixes, so that one unique string makes the whole chunk unmatchable, and everything after it in the request (CLAUDE.md, the skill list, MCP instructions) misses too.

Every new background session therefore re-uploads roughly 23,000–28,000 tokens of text that never changed, instead of reading it from cache. Sessions without that line reach ~99% cache hits.

Costs tokens and latency only — answers are unaffected.

In more detail: when the system prompt is assembled, every block positioned after an internal boundary marker is concatenated into a single cache unit, and that unit is tagged as shareable across an organisation. The # Background Session block lands inside it, carrying the resolved per-session temp-folder path. One unique id therefore invalidates the entire org-shared unit — and because caching is prefix-based, everything downstream of it (the first user message: CLAUDE.md, skill listing, MCP instructions, agent listing) misses as well.

The unit is explicitly marked for org-wide sharing, yet contains a value that is unique per session. Behaviour contradicts the declared scope.

Who is affected

A session is affected iff its system prompt carries the # Background Session block and its job id is not already cached. Session kind alone is not the determinant.

Measured over 45 sessions in 2 projects:

  • Affected — block present, fresh job id: 20 of 20. (17 had a warm cache available and still fell to the floor; 3 were cold starts.)
  • Not affected — no such block: 9 of 9, including one recorded as sessionKind=bg on an earlier build that did not emit the block.
  • Not affected — inheriting an already-cached job id: 16 of 16; these reach full reuse.

In-process subagents: not measurable from this corpus. It contains zero sidechain turns, so no subagent request was ever issued in these sessions. Separately, the agent-spawn path removes the boundary marker before assembling the prompt, so the static/dynamic split does not run for subagents at all — that branch was never exercised here and its behaviour is unknown.

Impact

45 sessions sampled across 2 projects; 36 carry the # Background Session block.

| cohort (block present) | n | mean cache_read | mean cache_creation |
|---|---|---|---|
| fresh job id | 20 | 14,349 | 27,780 |
| reused job id | 16 | 39,307 | 8,101 |

Total cache_creation across the 20 fresh-id sessions: 555,607 tokens.

17 of those 20 land on exactly one of two cache_read values — 12,804 or 21,468, depending on how many blocks the build emits. The other 3 were true cold starts with nothing cached to hit. Per-session avoidable write across the 17 warm cases: 22,835–28,032 tokens.

Breakdown of one measured 22,837-token write, apportioned by character share — estimated, not separately metered:

  • org-shared system block: ~4,600 tok (11,817 chars)
  • first user message: ~18,200 tok (46,863 chars), blocked as prefix fallout

Waste attributable to this contamination alone:

  • Repeated/templated first message — agent spawns, -p pipelines, scripted runs: the full ~23–28k/session.
  • Novel first prompt: the org-shared system block only (~4.6k). The remainder is independently blocked by #94417 / #93499.

Every spawned background session gets a fresh job id, so this scales linearly with the number started.

What Should Happen?

A second background session in the same project, with unchanged CLAUDE.md, skills and MCP config, should read the org-shared system block from cache rather than rewrite it.

Concretely, on that second session's first request:

  • cache_read_input_tokens ≈ the full prompt
  • cache_creation_input_tokens ≈ 0

This is not hypothetical — it is already the observed behaviour for sessions that do not carry the # Background Session block, and for background sessions that inherit an already-cached job id. Both reach it consistently.

More generally: a cache unit declared shareable across an organisation should not contain values that are unique per session.

Error Messages/Logs

**No error is emitted.** The failure is silent and visible only in usage counters.

### Controlled pair — the isolating test

Identical first user message (`"just say hi to me"`), same tools, same CLAUDE.md, same skills. The messages layer is therefore identical, leaving the system layer as the only variable. Session ids are anonymised labels; each is a distinct local session.

| session | job id | `cache_read` | `cache_creation` |
|---|---|---|---|
| A | job-A | 21,468 | 28,011 |
| B | job-B | 21,468 | 28,010 |

Full system-prompt comparison: **1 differing block out of 17** — block 12, the `# Background Session` block, 1,978 characters in both. The sole difference is the 8-character job id.

### Positive control

Background sessions that *inherit* an existing job id reach full reuse. Two sessions launched under one shared job id: `cache_read=49,607`, `cache_creation=0` for both.

### Negative control

Five consecutive sessions in one project carrying no `# Background Session` block:


session 1   cache_read=46,516   cache_creation=0
session 2   cache_read=46,648   cache_creation=0
session 3   cache_read=46,648   cache_creation=0
session 4   cache_read=46,648   cache_creation=0
session 5   cache_read=46,648   cache_creation=0


These also shared a repeated first message, so they show the end-to-end machinery is healthy; they are not on their own an isolating test. The controlled pair above is.

### Natural control

The determinant is the presence of the block, not the session kind. One session recorded as `sessionKind=bg`, running an earlier build that did not emit the block, reached `cache_read=46,648`, `cache_creation=0`. Background sessions are not inherently uncacheable; only the contaminated block makes them so.

`cache_read` is pinned to a constant floor per build — 12,804 or 21,468 — consistent with falling back to the smaller, globally shared chunk.

Steps to Reproduce

  1. Start a background session in a project with a non-trivial CLAUDE.md. Send any prompt.
  2. Start a second background session in the same project. Send the same prompt.
  3. Inspect the first assistant message usage in ~/.claude/projects/<proj>/*.jsonl.

Expected: cache_read ≈ full prompt, cache_creation ≈ 0.
Actual: cache_read pinned at the floor; cache_creation ≈ 23–28k.

  1. Diff the prompt_snapshot attachments of both sessions — the only system-prompt difference is the job id.

Script: repro/cache-repro.py (standard library only) prints per-session cache reads/writes, isolates the differing block, and tallies cohorts.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.273

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

Windows Terminal

Additional Information

Root cause

Traced in the shipped claude.exe v2.1.273 (Bun bundle). The bundle is minified, so the snippets below carry machine-generated names; byte offsets are included so the same code can be located directly.

1. System-prompt assembly — byte offset ≈202051363

Blocks before the boundary marker go into a static chunk shared globally. Blocks at or after it go into a dynamic chunk shared org-wide — as one concatenated unit, with no internal cache breakpoints.

function ITt(e,n){
let r=ske(),s=e.findIndex((B)=>B===OW);   // OW = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"
...
if(r)if(s!==-1){
let B,U,fe,ge=[],ve=[];
for(let De=0;De<e.length;De++){
let Ne=e[De];
if(!Ne||Ne===OW)continue;
if(Ne.startsWith(bDe))B=Ne;
else if(PHt.has(Ne))U=Ne;
else if(Ne===nve)fe=Ne;
else if(De<s)ge.push(Ne);      // before boundary -> static
else ve.push(Ne)}              // at/after boundary -> dynamic
let Ee=[];
if(B)Ee.push({text:B,cacheScope:null});
if(U)Ee.push({text:U,cacheScope:null});
if(fe&&B&&U)Ee.push({text:fe,cacheScope:null});
let Re=ge.join(`\n\n`);
if(Re)Ee.push({text:Re,cacheScope:"global"});
let Pe=ve.join(`\n\n`);
if(Pe)Ee.push({text:Pe,cacheScope:"org"});   // <-- ONE block, no internal breakpoints
return i("tengu_sysprompt_boundary_found",{
blockCount:Ee.length,staticBlockLength:Re.length,dynamicBlockLength:Pe.length}),Ee}

The two trailing // comments were added for readability; everything else is verbatim.

2. The contaminating template — byte offset 199423023

return`# Background Session
...
Use \`$CLAUDE_JOB_DIR/tmp\` (\`${nPo(e,"tmp")}\`) for any temporary files ...

That interpolated call resolves to ~/.claude/jobs/<session-id>/tmp — unique per session, and it lands inside the org-shared chunk built above.

Related issues

Read in full and confirmed non-overlapping — neither mentions CLAUDE_JOB_DIR, background sessions, or the system-prompt boundary:

  • #94417 — per-session UUID in the second message. Same class of defect (per-session value inside a shared unit), different layer.
  • #93499 — CLAUDE.md re-written at every session start (messages layer).

Listed from titles only, not individually verified:

  • #66966 — first-message envelope never reuses across sessions.
  • #86244 — background auto-update changes the system prompt mid-flight.
  • #82563 — full conversation prefix re-written mid-session.
  • #94400, #94728 — background/resume cache misses from tool-array and message divergence.

This report does not supersede any of the above; it identifies a blocker that sits before them in the prefix.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗