anthropics / anthropics/claude-code

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

Open
#94,815 1 comment 0 reactions 1 assignee Assigned to @bogini View on GitHub
area:agent-view area:agents area:core area:cost bug has repro
Dominant language
Python
Stars
145k
Forks
23.1k
PR merge metrics
PR metrics pending

Description

### Preflight Checklist

- [x] I have searched [existing issues](https://github.com/anthropics/claude-code/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug) 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.**

Image

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

```shell
**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//*.jsonl`.

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

4. 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.

```js
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 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**

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

That interpolated call resolves to `~/.claude/jobs//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.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with repro/cache-repro.py, then compare the first assistant message usage in ~/.claude/projects//*.jsonl and the prompt_snapshot attachments from two background sessions. The shipped claude.exe bundle identifies system-prompt assembly near byte offset 202051363 and the background-session template near 199423023. Done means a second session with unchanged project inputs reads the shared prompt from cache and reports approximately zero cache creation.

Written by the indexing model from the issue text.

Assessment

Tech stack
bun, javascript, python
Domain
cli, performance, tooling
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.