[BUG] Prompt cache lookup fails intermittently mid-session — cache_read pinned to the stable-prefix boundary, 89 full-context rewrites across 9 days (~59M excess cache_creation tokens)

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 7 comments · opened Aug 19, 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?

What's wrong

In long-running Claude Code sessions, the prompt cache intermittently fails to match anything past the stable prefix (system prompt + project context). When it happens, cache_read_input_tokens collapses to a fixed value (29,542 on 2026-08-19) and cache_creation_input_tokens jumps to the entire remaining conversation (600k–750k tokens).

The failures are:

  • Intermittent — they interleave with successful full-prefix hits seconds apart
  • Self-healing — the session returns to normal hit rates on its own, then relapses
  • Not correlated with idle time — gaps between failing requests are typically 0.3–1.0 minutes, while 26-minute and 32-minute idle gaps in the same session hit the cache perfectly
  • Persistent — 89 occurrences across 9 separate days (2026-08-08 → 2026-08-19)

Environment

  • Claude Code: 2.1.220 (native), commit 4073f59596e2
  • Platform: win32-x64 (Windows 11)
  • Auth: Claude subscription (Max 5x) → 1-hour TTL requested automatically
  • Model: claude-opus-5, effort xhigh — both constant across all 3,653 calls in the dataset
  • Feedback ID: d1b1e7aa-01f0-41bd-a082-05c48d0e7aba
  • Auto-updates: disabled via DISABLE_AUTOUPDATER; last update attempt failed (install_failed) 2026-08-16
  • MCP servers:
  • Google Drive (HTTP) — connected
  • blender (uvx blender-mcp, stdio) — connected
  • mobile-mcp (npx -y @mobilenext/mobile-mcp@latest, stdio) — Failed to connect: -32000: MCP error -32000: Connection closed

Method

Parsed from the session JSONL and deduplicated by requestId — a single API response produces 2–5 JSONL records (one per content block) that each repeat the same usage object, so a naive parse inflates token totals 2–3×.

Real token counts live in message.usage.iterations[], not in the top-level message.usage counters, which are zero.

Data

| | count |
|---|---|
| Total billed calls (deduplicated) | 3,653 |
| Calls with cache_creation > 100,000 | 131 |
| — of those, gap ≥ 60 min (legitimate TTL expiry) | 41 — excluded |
| — of those, a compaction event (2026-08-17 09:52:06, context Δ −804,681) | 1 — excluded |
| Unexplained failures | 89 |

cache_read on failures is not random. It sits on one of two stable-prefix boundaries, drifting slowly as the system prompt changes between days:

| date | cache_read on failure |
|---|---|
| 2026-08-08 | 29,194 (25,942 once) |
| 2026-08-11 → 08-17 | 29,499 |
| 2026-08-18 | 29,517 / 29,522 (26,239 once) |
| 2026-08-19 | 29,542 |

Representative window — 2026-08-19, local time

| time | gap (min) | cache_read | cache_creation | Δ context |
|---|---|---|---|---|
| 18:45:29 | — | 666,986 | 928 | normal |
| 18:45:53 | 0.4 | 29,542 | 604,467 | −33,905 |
| 18:46:18 | 0.4 | 29,542 | 605,916 | +1,449 |
| 18:47:24 | 1.1 | 635,458 | 1,079 | normal |
| 18:47:45 | 0.4 | 29,542 | 608,163 | +1,168 |
| 18:48:22 | 0.6 | 637,705 | 1,103 | normal |
| 18:48:45 | 0.4 | 29,542 | 608,707 | −559 |
| 18:49:07 | 0.4 | 29,542 | 610,534 | +1,827 |
| 19:13:37 → 19:39:45 | 26.1 | 688,358 | 2,341 | full hit after 26 min idle |
| 19:53:27 | 0.6 | 29,542 | 712,410 | +1,815 |
| 20:11:31 | 0.7 | 29,542 | 731,499 | +145 |
| 20:11:51 → 20:43:35 | 31.7 | 761,041 | 1,218 | full hit after 32 min idle |
| 20:52:27 | 0.4 | 29,542 | 749,260 | −736 |

Two points worth highlighting:

  1. The 26-minute and 32-minute idle gaps hit the cache completely, while sub-minute gaps miss entirely. This is the inverse of what TTL expiry would produce.
  2. cache_read + cache_creation on a failing request exactly equals the cache_read of the next successful request (e.g. 29,542 + 608,163 = 637,705, which is the next request's cache_read). The conversation is the same linear chain at the expected size — the content is not diverging, the lookup is failing.

Affected request IDs (89) and the full per-request CSV are attached.

Documented causes ruled out

Per https://code.claude.com/docs/en/prompt-caching, none of the listed invalidating actions apply:

  • Model switchmodel is claude-opus-5 on all 3,653 calls
  • Effort changeeffort is xhigh on all 3,653 calls
  • Subagents / forksisSidechain is false on all 3,653 calls
  • Compaction — the single compaction event in the dataset is excluded above
  • Claude Code upgrade — auto-update disabled; version constant at 2.1.220
  • Fast mode, tool deny rules, manual MCP config edits — none performed
  • TTL expiry — subscription auth requests the 1-hour TTL, and this is confirmed in the data: 3,652 of 3,653 records have cache_creation.ephemeral_1h_input_tokens populated and ephemeral_5m_input_tokens = 0 on every single record. No request in this dataset used the 5-minute tier.

Two observations that may point at the cause

1. A stdio MCP server in a failure state.

mobile-mcp is currently reporting -32000: Connection closed. The documentation states that a stdio server's process exiting, or a server reconnecting automatically after a transient failure, changes the tool definition set and invalidates the cache without any user action. A server in a crash/respawn loop would produce exactly this intermittent, self-healing pattern.

If this is the mechanism, it seems worth treating as a bug in its own right: a single non-essential MCP server failing to connect should not be able to invalidate an entire 750k-token conversation cache, repeatedly, silently.

2. Content being removed from the middle of the conversation.

The first failure in each cluster carries a large negative context delta:

| date | first failure of cluster | Δ context |
|---|---|---|
| 2026-08-08 | 15:15:33 | −60,110 |
| 2026-08-11 | 15:30:58 | −30,660 |
| 2026-08-12 | 18:20:25 | −57,272 |
| 2026-08-19 | 18:45:53 | −33,905 |

Smaller negative deltas continue through each cluster (−559, −2,014, −2,794, −2,438, −996). Something is pruning earlier content mid-session. Under exact-prefix matching, a removal near the front of the conversation invalidates everything after it — which is precisely where cache_read lands.

Impact

  • 89 requests (2.4% of all calls) generated 58,930,689 cache_creation tokens
  • Expected for the same 89 turns at the observed normal-turn average of 1,745: 155,305
  • Excess: 58,775,348 cache_creation tokens
  • At the 1-hour cache write rate (2× base input) versus the cache reads that should have occurred (0.1×), net excess ≈ 111,657,626 base-input-equivalent tokens, roughly $558 of API-equivalent consumption
  • On a Max 5x subscription this repeatedly exhausted 5-hour windows. The failing requests return the same responses a cache hit would have produced — the excess buys nothing.

Reproduction

Not deterministically reproducible on demand, but reliably observed in long-lived sessions (this one has been resumed since 2026-08-02) carrying 500k+ token contexts. It has recurred on 9 of the last 12 days.

Attached

  • cache-usage-dedup.csv — all 3,653 calls with timestamp, requestId, gap, cache_read, cache_creation, total context, delta, TTL tier, output tokens
  • failed-request-ids.txt — the affected request IDs

What Should Happen?

Create prompt cache again only when prompted to expire

Error Messages/Logs

Steps to Reproduce

Environment

  • Claude Code: 2.1.220 (native), commit 4073f59596e2
  • Platform: win32-x64 (Windows 11)
  • Auth: Claude subscription (Max 5x) → 1-hour TTL requested automatically
  • Model: claude-opus-5, effort xhigh — both constant across all 3,653 calls in the dataset
  • Feedback ID: d1b1e7aa-01f0-41bd-a082-05c48d0e7aba
  • Auto-updates: disabled via DISABLE_AUTOUPDATER; last update attempt failed (install_failed) 2026-08-16
  • MCP servers:
  • Google Drive (HTTP) — connected
  • blender (uvx blender-mcp, stdio) — connected
  • mobile-mcp (npx -y @mobilenext/mobile-mcp@latest, stdio) — Failed to connect: -32000: MCP error -32000: Connection closed

Method

Parsed from the session JSONL and deduplicated by requestId — a single API response produces 2–5 JSONL records (one per content block) that each repeat the same usage object, so a naive parse inflates token totals 2–3×.

Real token counts live in message.usage.iterations[], not in the top-level message.usage counters, which are zero.

Data

| | count |
|---|---|
| Total billed calls (deduplicated) | 3,653 |
| Calls with cache_creation > 100,000 | 131 |
| — of those, gap ≥ 60 min (legitimate TTL expiry) | 41 — excluded |
| — of those, a compaction event (2026-08-17 09:52:06, context Δ −804,681) | 1 — excluded |
| Unexplained failures | 89 |

cache_read on failures is not random. It sits on one of two stable-prefix boundaries, drifting slowly as the system prompt changes between days:

| date | cache_read on failure |
|---|---|
| 2026-08-08 | 29,194 (25,942 once) |
| 2026-08-11 → 08-17 | 29,499 |
| 2026-08-18 | 29,517 / 29,522 (26,239 once) |
| 2026-08-19 | 29,542 |

Representative window — 2026-08-19, local time

| time | gap (min) | cache_read | cache_creation | Δ context |
|---|---|---|---|---|
| 18:45:29 | — | 666,986 | 928 | normal |
| 18:45:53 | 0.4 | 29,542 | 604,467 | −33,905 |
| 18:46:18 | 0.4 | 29,542 | 605,916 | +1,449 |
| 18:47:24 | 1.1 | 635,458 | 1,079 | normal |
| 18:47:45 | 0.4 | 29,542 | 608,163 | +1,168 |
| 18:48:22 | 0.6 | 637,705 | 1,103 | normal |
| 18:48:45 | 0.4 | 29,542 | 608,707 | −559 |
| 18:49:07 | 0.4 | 29,542 | 610,534 | +1,827 |
| 19:13:37 → 19:39:45 | 26.1 | 688,358 | 2,341 | full hit after 26 min idle |
| 19:53:27 | 0.6 | 29,542 | 712,410 | +1,815 |
| 20:11:31 | 0.7 | 29,542 | 731,499 | +145 |
| 20:11:51 → 20:43:35 | 31.7 | 761,041 | 1,218 | full hit after 32 min idle |
| 20:52:27 | 0.4 | 29,542 | 749,260 | −736 |

Two points worth highlighting:

  1. The 26-minute and 32-minute idle gaps hit the cache completely, while sub-minute gaps miss entirely. This is the inverse of what TTL expiry would produce.
  2. cache_read + cache_creation on a failing request exactly equals the cache_read of the next successful request (e.g. 29,542 + 608,163 = 637,705, which is the next request's cache_read). The conversation is the same linear chain at the expected size — the content is not diverging, the lookup is failing.

Affected request IDs (89) and the full per-request CSV are attached.

Documented causes ruled out

Per https://code.claude.com/docs/en/prompt-caching, none of the listed invalidating actions apply:

  • Model switchmodel is claude-opus-5 on all 3,653 calls
  • Effort changeeffort is xhigh on all 3,653 calls
  • Subagents / forksisSidechain is false on all 3,653 calls
  • Compaction — the single compaction event in the dataset is excluded above
  • Claude Code upgrade — auto-update disabled; version constant at 2.1.220
  • Fast mode, tool deny rules, manual MCP config edits — none performed
  • TTL expiry — subscription auth requests the 1-hour TTL, and this is confirmed in the data: 3,652 of 3,653 records have cache_creation.ephemeral_1h_input_tokens populated and ephemeral_5m_input_tokens = 0 on every single record. No request in this dataset used the 5-minute tier.

Two observations that may point at the cause

1. A stdio MCP server in a failure state.

mobile-mcp is currently reporting -32000: Connection closed. The documentation states that a stdio server's process exiting, or a server reconnecting automatically after a transient failure, changes the tool definition set and invalidates the cache without any user action. A server in a crash/respawn loop would produce exactly this intermittent, self-healing pattern.

If this is the mechanism, it seems worth treating as a bug in its own right: a single non-essential MCP server failing to connect should not be able to invalidate an entire 750k-token conversation cache, repeatedly, silently.

2. Content being removed from the middle of the conversation.

The first failure in each cluster carries a large negative context delta:

| date | first failure of cluster | Δ context |
|---|---|---|
| 2026-08-08 | 15:15:33 | −60,110 |
| 2026-08-11 | 15:30:58 | −30,660 |
| 2026-08-12 | 18:20:25 | −57,272 |
| 2026-08-19 | 18:45:53 | −33,905 |

Smaller negative deltas continue through each cluster (−559, −2,014, −2,794, −2,438, −996). Something is pruning earlier content mid-session. Under exact-prefix matching, a removal near the front of the conversation invalidates everything after it — which is precisely where cache_read lands.

Impact

  • 89 requests (2.4% of all calls) generated 58,930,689 cache_creation tokens
  • Expected for the same 89 turns at the observed normal-turn average of 1,745: 155,305
  • Excess: 58,775,348 cache_creation tokens
  • At the 1-hour cache write rate (2× base input) versus the cache reads that should have occurred (0.1×), net excess ≈ 111,657,626 base-input-equivalent tokens, roughly $558 of API-equivalent consumption
  • On a Max 5x subscription this repeatedly exhausted 5-hour windows. The failing requests return the same responses a cache hit would have produced — the excess buys nothing.

Reproduction

Not deterministically reproducible on demand, but reliably observed in long-lived sessions (this one has been resumed since 2026-08-02) carrying 500k+ token contexts. It has recurred on 9 of the last 12 days.

Attached

  • cache-usage-dedup.csv — all 3,653 calls with timestamp, requestId, gap, cache_read, cache_creation, total context, delta, TTL tier, output tokens
  • failed-request-ids.txt — the affected request IDs

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.220

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

Windows Terminal

Additional Information

cache-usage-dedup.csv

failed-request-ids.txt

View original on GitHub ↗

7 Comments

Gunther-Schulz · 10 days ago

We run an HTTP proxy in front of Claude Code that captures the raw request
bodies before they leave the machine, so we can see the messages[] array as
CC actually serializes it. The proxy is open source —
https://github.com/cnighswonger/claude-code-cache-fix — and these particular
measurements were taken on a fork of it
(https://github.com/Gunther-Schulz/claude-code-cache-fix), so the
instrumentation behind every number below is inspectable. We hit a failure with your exact signature this
week and have a mechanism for it, measured on the wire. It may or may not be
the same root cause as yours, but it reproduces every observable you list.

The signature we measured. One request billed cache_creation 110,022 with
cache_read collapsing from 126,671 to 20,623. The requests immediately before
it billed 758–2,410 creation against 120k–126k read. The requests immediately
after billed 5,857 / 1,652 / 275 with read back above 130,000. Median creation
across the other 545 requests in that capture: 1,196. So: intermittent,
self-healing within one request, not idle-correlated (69 seconds after the
previous request), and the read pins low rather than to zero — which is the
part that matches your "pinned to the stable-prefix boundary" most exactly.

The mechanism. Comparing that request's messages[] against the previous
one's, CC had inserted a single 374-byte role: "system" message at index 82
of 107
— 23 messages behind the last user turn. The other three new entries
were ordinary tail growth. The inserted message carried a Stop-hook
notification (a hook returning {"decision": "block", "reason": ...}, which is
the documented way for a Stop hook to decline to stop). The hook behaved to
spec; what cost the tokens is that its output was placed mid-history rather
than at the tail, where CC's other injected content (<system-reminder>
blocks) rides.

Why a 374-byte insert costs 110k tokens. Cache entries exist at
breakpoints, not at arbitrary indices. An insertion at index 82 invalidates
every entry above it, so the read falls back to the highest surviving
breakpoint below the splice — which can sit near the start of the
conversation. That is why the cost is set by the insertion's DEPTH rather than
its size, and it explains a cache_read that collapses to a low, roughly
constant value rather than degrading smoothly. Your fixed 29,542 is what this
would look like if the surviving breakpoint is the system/project prefix.

Why it self-heals. We tracked the inserted message by content hash across
the following requests: CC keeps it at index 82 permanently while the array
grows around it (110, 113, 116, 119, 122, 125, 128, 131, 134 messages). Once it
is in place it is just history, so the next request is append-only and the
cache behaves normally. One insertion, one bust, then recovery — which is your
"self-healing, then relapses": each relapse would be a new insertion.

A check you can run on your own data, which is what would confirm or kill
this for your case. For each failing request, diff its messages[] against the
preceding request's:

  1. Is there an entry present in the failing request and absent from the

previous one, at an index below the last surviving entry? (An insertion
mid-array, as opposed to growth at the end.)

  1. If so, what is its role, and how far is it behind the last user-authored

message?

  1. Does cache_read on the failing request correspond to a prefix that ends

before that index?

If (1) is empty on your failures, our mechanism is not your bug and this
comment is noise — worth knowing either way. If (1) is consistently non-empty,
the question upstream is why injected content lands mid-array instead of at the
tail.

Scale, honestly stated. We see this rarely: across 49 captures spanning two
days, 2,569 request pairs carried a mid-array insertion, but only one sat
deep enough to be expensive. The other 96% are CC's trailing reminder block
being pushed one slot later by each new turn — structurally an insertion,
costing almost nothing because it happens at the tail. So the class is common
and the expensive case is rare, which fits a bug that shows up 89 times across
9 days rather than constantly.

We are not proposing a client-side workaround here — we looked at fixing it in
our proxy and concluded we should not, because relocating a message changes the
order the model sees and any such fix takes on a per-request obligation whose
failure costs more than the bug. This looks like it wants fixing where the
insertion happens.

Happy to provide the sanitized before/after messages[] index maps (roles,
sizes, positions — no message content) if that would help.

— Posted by an AI agent (Claude) working on this analysis, on behalf of the
repository owner.

garaz1983-rgb · 9 days ago

Same signature on Windows / Max 5x / v2.1.233. I have one thing I haven't seen in this thread yet: the server's own cache_miss_reason, which is persisted into the session JSONL and names the failure directly.

The server says previous_message_not_found

message.diagnostics.cache_miss_reason over a 165-minute period on 2026-08-20:

| cache_miss_reason.type | count |
|---|---|
| previous_message_not_found | 19 |
| messages_changed | 4 |
| model_changed | 2 |
| tools_changed | 2 |

There were 25 full re-caches (cache_creation_input_tokens >= 50k) in that period. 9 are legitimately explained — TTL genuinely exceeded, or a model switch (cache keys are per-model). 5 were attributed client-side by the server (messages_changed, tools_changed).

The remaining 11 came back previous_message_not_found, which is mutually exclusive with system_changed / tools_changed / messages_changed. The server did not claim my prompt diverged — it says it could not locate the entry. That the same classifier attributed 6 other events in the same period to client-side causes suggests it was live and discriminating, not defaulting.

This is the server stating what the raw-body diffs earlier in this thread inferred.

The 11 events

gap is start-of-request to start-of-request, per the documented TTL basis ("measured from the start of the request … time spent generating a response counts against the lifetime"). TTL is the tier each request itself declared in cache_creation.

| # | UTC | gap | declared TTL | prefix discarded | cache_creation | cache_read | model | requestId |
|---|---|---|---|---|---|---|---|---|
| 1 | 14:56:46 | 78s | 5m | 124,667 | 132,875 | 0 | fable-5 | req_011CeECHYR3dM3zvvvg1TeKc |
| 2 | 14:58:17 | 363s | 1h | 289,091 | 267,823 | 22,272 | opus-5 | req_011CeECNmSmGUUjvxh2uonSh |
| 3 | 14:58:45 | 226s | 1h | 167,069 | 145,786 | 22,272 | opus-5 | req_011CeECReDX7ofohiBvq2r74 |
| 4 | 14:59:55 | 278s | 1h | 116,010 | 95,071 | 22,276 | fable-5 | req_011CeECXeVof2HRpS5RtUVwB |
| 5 | 15:02:40 | 799s | 1h | 364,414 | 346,104 | 22,401 | fable-5 | req_011CeECj1gacswigUH1tHWDq |
| 6 | 15:13:57 | 174s | 5m | 162,229 | 162,484 | 0 | fable-5 | req_011CeEDbdNpMMconcYM6ULtf |
| 7 | 15:14:42 | 538s | 1h | 376,000 | 355,126 | 22,401 | fable-5 | req_011CeEDehGoWn9wVZ8xakTd6 |
| 8 | 15:14:54 | 192s | 1h | 201,358 | 180,462 | 22,272 | opus-5 | req_011CeEDfe4r2zcydLEVg3Sfh |
| 9 | 15:21:00 | 2307s | 1h | 228,632 | 208,973 | 22,272 | opus-5 | req_011CeEE8BSTgrFHDg95ZGaF2 |
| 10 | 15:30:09 | 90s | 5m | 186,691 | 194,165 | 0 | fable-5 | req_011CeEEqKG9osk1MkihAUZZQ |
| 11 | 15:36:21 | 458s | 1h | 389,432 | 365,383 | 25,567 | fable-5 | req_011CeEFJULcBHWYkLgHJGCEs |

Every one is inside its own declared lifetime — the shortest at 78s against a 5m TTL.

cache_read collapses to 22,272 / 22,276 / 22,401 / 25,567 / 11,115 / 0 — the stable system+tools boundary, the same pinning described in the OP. (The 5m-tier rows are Task subagent requests, consistent with the documented "subagents use the five-minute TTL even on a subscription"; the 1h rows are main sessions.)

Control

Same machine, same build, same plan, a few hours later:

| | control | incident period |
|---|---|---|
| requests | 65 | 547 |
| max context | 287,094 | 449,565 |
| longest idle gap | 1101s (18.3 min) | — |
| cache_creation on that gap | 772 (cache_read 157,733 — clean hit) | — |
| full re-caches | 0 | 25 |
| previous_message_not_found | 0 | 19 |

The control sat idle 18.3 minutes on a 158,505-token prefix and came back to a clean hit, re-writing 772 tokens. It later grew to 287,094 tokens of context with still zero re-caches.

So on this machine the 1h TTL does hold across long idles when it works. That is the inversion the OP describes — short gaps missing while long idles hit — isolated as an A/B rather than observed inside one run.

Also ruled out

  • 20-block lookback window — the docs note lookback stops after 20 blocks per breakpoint. The preceding turns here carried 1-2 content blocks.
  • Context size on its own — the control reached 287,094 tokens with zero occurrences.
  • Platform incident — status.anthropic.com shows a major incident that day at 19:16-19:42Z. This period is 13:00-15:45Z. No overlap.
  • Compaction / /clear / plugin or MCP changes — none occurred in these sessions during the period.

Impact

These 11 events wrote 2,454,252 cache-creation tokens and produced no additional output — about 15% of the period's weighted spend (output x5 / cache-write x1.25 / cache-read x0.1 by list-price ratio). Actual output generation was 14%.

Downstream, a freshly reset 5-hour window was exhausted 22 minutes after it opened:

You've hit your session limit · resets 4:50am (Asia/Seoul)     ← window opened 23:50, hit at 00:12

Environment

Claude Code   v2.1.233
OS            Windows 11 Pro 26200
Plan          Max, rateLimitTier default_claude_max_5x, subscription auth
Models        claude-opus-5, claude-fable-5
Context       150k-450k per session, long-running sessions with Task subagents

Method note, since it has tripped people up in this thread: all figures are de-duplicated by requestId. One API response is written to the JSONL once per content block, each copy repeating the same usage object, so a naive sum inflates totals 2-3x.

I still have the raw transcripts for both the incident period and the control run. Happy to attach the full per-request CSV, or to test a specific variable on request if it would help narrow the trigger.

garaz1983-rgb · 7 days ago

Follow-up to my Aug 20 comment. Since then I ran a controlled experiment on one machine, audited 4 months of logs with a TTL-aware method, and caught four clean server-side eviction events in 24 hours. Self-check script and minimal repro below so anyone can verify on their own account.

Context: I first reported cache-related billing collapse on May 29 as #63282 ($630 equivalent in one session). Zero replies; the stale bot auto-closed it as not_planned.

1. Four eviction events in 24h — every live session on the account loses its entry in the same minute

Setup (one Windows 11 machine, Max plan, Claude Code 2.1.239, all cache writes on the ephemeral_1h tier):

  • A: normal IDE-driven work sessions (entrypoint: sdk-cli)
  • B: a bare headless probe — claude -p --resume <sid> heartbeats ("reply ack N") every 2–5 min plus a 12-min idle every 7th beat, ~90k context
  • C: the interactive terminal REPL session (entrypoint: cli, ~250k context) I was working in

Every row below is a request whose previous request in the same session was minutes earlier — far inside the 1h TTL — and whose message.diagnostics.cache_miss_reason was stamped previous_message_not_found by the server. Long-idle expirations are excluded.

| event (KST) | sessions lost in the same minute | age of lost entries | tokens re-billed |
|---|---|---|---|
| Aug 22 13:38 | IDE ×2 + terminal REPL + bare headless probe | 58 s, 6.6, 13.6, 12.1 min | 800k |
| Aug 22 20:38 | IDE ×3 | 4.6, 4.6, 8.2 min | 708k |
| Aug 23 08:18 | IDE ×4 | 1.2, 3.4, 3.7, 13.9 min | 1,275k |
| Aug 23 09:54 | IDE ×4 + terminal REPL | 1.3, 1.6, 1.3, 3.3, 1.9 min | 1,536k |

Zero output was produced for any of it. In the 13:38 event I checked all 62 requests in the surrounding 40 minutes one by one: after the cutoff no session hit an old entry; every later hit is on the fresh rewrite. The headless probe had held its entry through 34 consecutive beats (2h55m, including 12-min idles) before being evicted together with everything else.

A 58-second-old entry on a 1-hour TTL is not an expiry. Three different client paths losing their entries in the same minute is not a client change. The server says so itself in the reason field.

Usage shape (read this before comparing your numbers to mine): this machine habitually runs 4-8 concurrent long-lived Claude Code sessions, most of them driven through an SDK-based IDE (entrypoint: sdk-cli), often with 100k-450k contexts, all day. That maximizes exposure: one eviction event hits every live entry at once, and the re-bill scales with each session's context. A single-session terminal user has almost no surface for this - which matches the near-zero historical rate on my own cli entrypoint and probably explains why many users never notice it. If you run one session at a time, a zero result below is expected and still worth posting.

Correction to my own earlier working theory: from historical rates I suspected the terminal REPL was immune (0 in-TTL losses in 2,366 requests). The controlled run falsified that — the REPL lost its entry in two of the four events. Historical rate differences between client paths look like differences in exposure (how many sessions were live when an eviction hit), not immunity.

2. Four months of logs, TTL-aware

A naive count of previous_message_not_found over my logs gives 1,076 events / 286M tokens. Don't quote that — the server stamps the same reason on legitimate expirations after long idle, and 78% of those events were over the 1h TTL. Classifying every event by idle gap to the previous request in the same session:

  • 199 events under the TTL (margin: <30 min main / <5 min subagent) — 46.0M tokens re-billed for context the server was still contractually holding (through Aug 22 morning; this week has added ~30 more since)
  • 145 of them under 5 minutes idle — impossible under any TTL tier

Daily in-TTL losses / share of that day's cache-write tokens, this week:

| day | in-TTL losses | tokens | share of day's cache writes |
|---|---|---|---|
| Aug 18 | 1 | 0.07M | 1.3% |
| Aug 19 | 0 | 0 | 0% |
| Aug 20 | 14 | 2.42M | 15.1% |
| Aug 21 | 26 | 5.84M | 26.1% |
| Aug 22 | 13 | 3.24M | 19.3% |
| Aug 23 (to 10:00) | 11 | 3.43M | 47.4% |

Week total: 71 in-TTL losses, 16.95M tokens, 17.3% of all cache writes. Longer view: near-clean until late June (weekly share 0–3%), appears early July, worst ever this week.

(Methodology: requestId dedup is mandatory — one response is logged once per content block, naive counting inflates 2–3x. TTL runs from request start per the docs. The diagnostics field first appears in my logs on Apr 26; earlier weeks are unmeasurable, not clean.)

3. Minimal repro

#!/usr/bin/env bash
# Minimal prompt-cache loss probe for Claude Code.
# Keeps ONE headless session alive with heartbeats spaced 2-5 min apart (plus an
# occasional 12-min idle) - all far inside the 1h cache TTL that subscription
# Claude Code requests for main sessions. A healthy run costs a few cents per
# beat (cache hits). A beat whose cost jumps ~15x is a full context rewrite;
# the session JSONL then carries the server's own reason:
#   message.diagnostics.cache_miss_reason = {"type": "previous_message_not_found"}
# On 2026-08-22 this exact loop held its entry for 34 beats (2h55m) and lost it
# at beat 35 (12.1 min after the previous request), in the same minute-wide
# window in which every other active session on the account lost theirs.
set -u
PROMPT='This is a prompt-cache probe session. Reply with exactly: READY'
SID=$(claude -p "$PROMPT" --output-format json --max-turns 1 \
      | python3 -c 'import json,sys;print(json.load(sys.stdin)["session_id"])')
echo "probe session: $SID"
for i in $(seq 1 60); do
  gap=$(( (RANDOM % 180) + 120 ))          # 120-300 s
  [ $((i % 7)) -eq 0 ] && gap=720            # every 7th beat: 12-min idle
  sleep "$gap"
  out=$(claude -p --resume "$SID" "heartbeat $i - reply with exactly: ack $i" \
        --output-format json --max-turns 1)
  cost=$(echo "$out" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("total_cost_usd"))')
  echo "$(date +%H:%M:%S) beat $i gap=${gap}s cost_usd=$cost"
done
echo "Check the server's verdict:"
echo "  grep -o '\"cache_miss_reason\":{[^}]*}' ~/.claude/projects/*/$SID.jsonl | sort | uniq -c"
# Optional: inflate the probe's context first (e.g. ask it to Read a few large
# files with --allowedTools Read) so a loss costs enough to be unmistakable.

This is the exact loop that was evicted in the 13:38 event. Healthy beats cost ~$0.09 each at 90k context; the loss beat cost $1.48.

4. Check your own account — and don't take my word for it

Stdlib-only Python. Reads only local ~/.claude/projects metadata, prints only aggregate numbers (no content, no session ids). Dedups by requestId and classifies by idle gap so legitimate expirations are not counted — only the inTTL_loss and iron<5m columns matter.

I'd prefer you don't trust me: paste it into your own AI first, ask it to confirm it's safe and does what I claim, then have it run and sanity-check your numbers.

#!/usr/bin/env python3
"""Claude Code prompt-cache loss self-check (v2.1 - TTL-aware).

Your Claude Code session logs (~/.claude/projects/**/*.jsonl) contain a
server-generated diagnostic: message.diagnostics.cache_miss_reason.
type == "previous_message_not_found" (PMNF) means the server could not find
the cache entry for your previous turn and re-billed your entire context as
cache_creation tokens.

IMPORTANT: PMNF alone is NOT proof of a bug. The server also stamps it when
an entry legitimately expired (idle longer than the TTL). This script
therefore classifies every PMNF event by the idle gap to the previous
request in the same session:

  - in-TTL  : gap < 30 min for main sessions (1h TTL, with margin for
              response-duration skew), gap < 5 min for subagent sessions
              (5m TTL). These should be impossible per Anthropic's docs.
  - iron    : gap < 5 min. Impossible under ANY TTL tier. The headline.
  - expired : gap over the TTL - legitimate, not counted as loss.

It reads ONLY local metadata and prints ONLY aggregate numbers - no
conversation content, no session ids. Safe to paste publicly.
Dedups by requestId (one response is logged once per content block; naive
counting inflates totals 2-3x).

Requires Python 3.8+, stdlib only. Run:  python check_cache_loss.py
"""
import json, glob, os
from collections import defaultdict
from datetime import datetime

ROOT = os.path.join(os.path.expanduser("~"), ".claude", "projects")

months = defaultdict(lambda: defaultdict(int))
seen = set()
nfiles = 0

def parse_ts(ts):
    try:
        return datetime.fromisoformat(ts.replace("Z", "+00:00"))
    except Exception:
        return None

for f in glob.glob(os.path.join(ROOT, "**", "*.jsonl"), recursive=True):
    nfiles += 1
    is_sub = (os.sep + "subagents" + os.sep) in f
    reqs = []
    byrid = {}
    try:
        fh = open(f, encoding="utf-8", errors="replace")
    except OSError:
        continue
    with fh:
        for line in fh:
            if '"usage"' not in line:
                continue
            try:
                o = json.loads(line)
            except Exception:
                continue
            msg = o.get("message") or {}
            u = msg.get("usage")
            if not (u and isinstance(u, dict)):
                continue
            rid = o.get("requestId") or msg.get("id")
            diag = (msg.get("diagnostics") or {}).get("cache_miss_reason")
            rtype = diag.get("type") if isinstance(diag, dict) else diag
            if rid in seen:
                # v2.1: the reason may sit on a later record of the same
                # requestId - keep the first record, fill in a missing reason.
                prev = byrid.get(rid)
                if prev and prev[2] is None and rtype:
                    byrid[rid] = (prev[0], prev[1], rtype)
                continue
            seen.add(rid)
            dt = parse_ts(o.get("timestamp") or "")
            if not dt:
                continue
            byrid[rid] = (dt, u.get("cache_creation_input_tokens", 0) or 0, rtype)
    reqs = sorted(byrid.values())
    for i, (dt, cc, rtype) in enumerate(reqs):
        mo = dt.strftime("%Y-%m")
        d = months[mo]
        d["req"] += 1
        d["cc"] += cc
        if rtype != "previous_message_not_found":
            continue
        d["pmnf_raw"] += 1
        gap = (dt - reqs[i - 1][0]).total_seconds() if i > 0 else None
        if gap is None:
            continue
        in_ttl = gap < (300 if is_sub else 1800)
        if in_ttl:
            d["loss"] += 1
            d["loss_cc"] += cc
            if gap < 300:
                d["iron"] += 1
                d["iron_cc"] += cc

print("Claude Code prompt-cache loss self-check (TTL-aware)")
print(f"scanned {nfiles} session files, {len(seen)} unique API requests\n")
hdr = (f"{'month':7} {'requests':>9} {'cache_write_tok':>16} {'PMNF_raw':>8} "
       f"{'inTTL_loss':>10} {'inTTL_wasted':>13} {'share':>6} {'iron<5m':>8} {'iron_wasted':>12}")
print(hdr)
print("-" * len(hdr))
tot = defaultdict(int)
for m in sorted(months):
    d = months[m]
    share = 100 * d["loss_cc"] / d["cc"] if d["cc"] else 0
    print(f"{m:7} {d['req']:>9,} {d['cc']:>16,} {d['pmnf_raw']:>8} "
          f"{d['loss']:>10} {d['loss_cc']:>13,} {share:>5.1f}% {d['iron']:>8} {d['iron_cc']:>12,}")
    for k in d:
        tot[k] += d[k]
share = 100 * tot["loss_cc"] / tot["cc"] if tot["cc"] else 0
print("-" * len(hdr))
print(f"{'TOTAL':7} {tot['req']:>9,} {tot['cc']:>16,} {tot['pmnf_raw']:>8} "
      f"{tot['loss']:>10} {tot['loss_cc']:>13,} {share:>5.1f}% {tot['iron']:>8} {tot['iron_cc']:>12,}")
print("""
inTTL_loss = server-stamped 'previous_message_not_found' with idle gap
UNDER the cache TTL (30min margin for main sessions / 5min for subagents).
Per Anthropic's docs these should not happen. iron<5m = idle under 5 minutes,
impossible under ANY TTL tier. PMNF_raw includes legitimate expirations after
long idle - do NOT quote that column as a bug figure.
If inTTL_loss is non-zero, consider posting this output at:
https://github.com/anthropics/claude-code/issues/87966
If it is zero, that is valuable data too.""")

If inTTL_loss is non-zero, please post the output here. If it's zero, please post that too, with your typical client and plan — the events above suggest exposure scales with how many sessions you have live when an eviction hits, so clean accounts are informative.

---
Edit (Aug 23): self-check bumped v2 -> v2.1 - per @eason-chengzi's method note below, cache_miss_reason can sit on a later JSONL record of the same requestId; v2.1 back-fills the reason from later records instead of reading only the first. On my own logs this changes nothing (0 of ~152k requestIds carried the reason only on a later record), but on yours it may.

intermap74 · 7 days ago

Another Windows data point, using @garaz1983-rgb's cache_miss_reason method. Posting it mainly because most of my large re-caches turned out to be legitimate TTL expiry — which I think is a useful negative result for sizing this bug — plus two events that do match the thread's signature, and independent server-side confirmation of the tools_changed mechanism from #81967.

Environment

  • Windows 11 Pro 26200, Claude subscription
  • Single long-lived session JSONL; CC versions present in the file: 2.1.229 (bulk), 2.1.167, 2.1.234, 2.1.237
  • Models used within the one session: claude-opus-5 (495), claude-sonnet-5 (271), claude-opus-4-8 (150), claude-sonnet-4-6 (56), claude-fable-5 (27)

Method: parsed the session JSONL, deduplicated by requestId, and read real counts from message.usage.iterations[] — both caveats from the original report apply and materially change the totals. 468 deduplicated billed calls.

Whole-session totals

| | tokens |
|---|---:|
| cache_read_input_tokens | 151,061,111 |
| cache_creation_input_tokens | 19,498,040 |
| output | 417,493 |

Hit rate 88.6%. Cache creation is 11% of input tokens but, at the 2x write multiplier vs 0.1x read, roughly 69% of input spend.

cache_miss_reason distribution (all 169 records carrying diagnostics)

| type | count |
|---|---:|
| previous_message_not_found | 133 |
| tools_changed | 12 |
| messages_changed | 9 |
| model_changed | 8 |
| system_changed | 6 |
| unavailable | 1 |

Restricted to expensive events (cache_creation >= 50k) — 61 events

| reason | events | creation tokens |
|---|---:|---:|
| previous_message_not_found | 49 | 15,396,858 |
| tools_changed | 3 | 1,232,264 |
| system_changed | 3 | 1,156,392 |
| messages_changed | 2 | 672,235 |
| model_changed | 3 | 256,973 |
| (no diagnostics) | 1 | 259,302 |

The part that argues against over-counting this bug

Of the 49 expensive previous_message_not_found events, 47 had a preceding-request gap of >= 1 hour. At a 1h TTL those are ordinary expiry, and "the server could not locate the entry" is the expected reason string for an expired entry — not evidence of the defect. Attributing them to this bug would inflate it by ~30x.

That leaves 2 genuinely short-gap events:

| gap | cache_creation | cache_read | model |
|---:|---:|---:|---|
| 33 s | 330,849 | 33,958 | claude-opus-5 |
| 14 s | 128,777 | 0 | claude-opus-4-8 |

The first matches the reported signature closely: seconds-scale gap, and cache_read pinned low (33,958) rather than degrading smoothly — the same "pinned to the stable-prefix boundary" shape as the original 29,542 and @Gunther-Schulz's 20,623. Different absolute value, same behavior.

So on this machine the defect is real but rare and expensive, not routine — consistent with @Gunther-Schulz's finding that only 1 of 2,569 mid-array insertions sat deep enough to cost anything.

Independent confirmation of the tools_changed mechanism (#81967)

3 expensive events (1,232,264 creation tokens) are attributed by the server to tools_changed. #81967 inferred tools-array mutation from proxied wire captures; this is the server-side classifier naming the same cause, which I don't think has been posted in this thread yet.

Relevant to that: this machine runs a Desktop build whose MCP connectors attach and detach during a session (idle-timeout driven, per WarmLifecycle in main.log). If connector lifecycle can mutate the tools array mid-session the way #81967 describes for LSP, that is a second, independent trigger for the same defect — and one that fires on a timer rather than on a process failure.

system_changed (3 events, 1,156,392 tokens)

Not discussed upthread as far as I can see. I have no mechanism for it and am not claiming one; flagging it in case it is a distinct third cause worth splitting out.

Note on model_changed

8 events. Cache keys are per-model, so these are correct behavior, but worth stating plainly for anyone reading this thread for cost advice: switching models inside one conversation discards the prefix. Five different model IDs appear in this single session.

Check I can run

If the messages[] index-diff from @Gunther-Schulz's comment would be useful on the 2 short-gap events specifically, I can run it and post the sanitized index maps (roles, sizes, positions — no content). Say the word.

Environment note: Desktop MSIX, engine 2.1.234 at time of writing.

eason-chengzi · 7 days ago

Follow-up comment for issue #87966

OP here. I ran the message.diagnostics.cache_miss_reason check that @garaz1983-rgb described, against my 89 failures. The server attributes 87 of them to messages_changed — not to a lost entry. So my case is @Gunther-Schulz's mechanism, not server-side eviction, and I want to correct my own original framing: I wrote that "the content is not diverging, the lookup is failing." The server says otherwise, and it is right to. My inference was based on cache_read + cache_creation on a failing request exactly matching the next request's cache_read — that only shows the total prompt size was as expected, which a mid-array splice preserves.

Reason distribution

Session 9573dbe5, 3,660 requests, 2026-08-02 → 08-19, deduplicated by requestId. 161 requests carry a cache_miss_reason.

| cache_miss_reason.type | whole session | of my 89 failures |
|---|---|---|
| messages_changed | 101 | 87 |
| previous_message_not_found | 45 | 1 |
| unavailable | 11 | 0 |
| system_changed | 4 | 2 |

I have not seen unavailable mentioned in this thread. Flagging it in case it is a distinct class.

The reason maps exactly onto the boundary cache_read pins to

This is the part I think is new. In my original report I noted cache_read pinned to two different values and could not explain the split. The reason field explains it:

| reason | cache_read on those requests | layer that survived |
|---|---|---|
| system_changed | 25,942 / 26,239 | system prompt + tool definitions |
| messages_changed | 29,194 / 29,499 / 29,517 / 29,542 | the above + project context |

The two system_changed events among my 89 (req_011CdpyBfHXAyV5gFFN69eF9, req_011Ce9KGdfL9kXAiytpvCT7v) are precisely the two outliers that pinned at ~26k. Every ~29.5k pin is messages_changed or previous_message_not_found. The ~3,300-token difference between the two bands is the project context layer.

So the fallback boundary is not arbitrary — it is determined by which cache layer the mutation landed in. That is direct confirmation of @Gunther-Schulz's point that cost is set by the depth of the change rather than its size.

What is changing in messages[] — likely removal, not insertion

@Gunther-Schulz measured an insertion (a Stop-hook notification landing at index 82 of 107). My data points at removal, or at least at something that shrinks the array.

Tracking total prompt size (cache_read + cache_creation) request over request, the first failure of every cluster carries a large negative delta:

| date | first failure of cluster | Δ total context |
|---|---|---|
| 2026-08-08 15:15:33 | | −60,110 |
| 2026-08-11 15:30:58 | | −30,660 |
| 2026-08-12 18:20:25 | | −57,272 |
| 2026-08-19 18:45:53 | | −33,905 |

Smaller negative deltas continue through each cluster (−559, −2,014, −2,794, −2,438, −996, −736). Content is being dropped from the conversation mid-session, in bursts, and each burst starts a run of full re-caches.

The magnitudes (30k–60k tokens) are consistent with old tool results being cleared for context management. If that is what this is, the defect would be that the pruning is not cache-aware: dropping content from near the front of the array invalidates every breakpoint above it, so each prune costs a full-context rewrite.

That would also explain the correlation with context size — all 89 of my failures occurred above ~300k context, and none below.

One previous_message_not_found at a 0.3-minute gap

req_011Ce9HRBkNY1geHc57CzuoG, 2026-08-18 08:41:17, 0.3 minutes after the preceding request in the same session, 1-hour TTL tier, cache_read 29,517, cache_creation 537,736. Server reason: previous_message_not_found.

One event, so not a pattern on my account, but it is @garaz1983-rgb's exact signature and it is well inside any TTL tier. Adding it as an independent data point.

I am classifying my other 44 previous_message_not_found events by idle gap now and will post the in-TTL count.

Restating the ask

The excess remains 58.9M cache_creation tokens across the 89 events, ~111.7M base-input-equivalent, for output identical to what a cache hit would have produced.

With the reason field in hand, the question narrows usefully: why is Claude Code mutating the middle of its own messages[] array mid-session, and why is that mutation not aligned to cache breakpoints? messages_changed is an accurate description of what the server observed, but nothing on my side changed — no /clear, no /compact, no config edit, no model or effort change (both constant across all 3,660 requests), no manual MCP change.

Method note, matching @garaz1983-rgb's: dedup by requestId is mandatory, and cache_miss_reason may appear on only one of the several JSONL records sharing a requestId, so scan every line and take the first non-null rather than only the first record per request.

Environment unchanged from the OP: Claude Code 2.1.220 native, Windows 11, Max 5x, claude-opus-5, effort xhigh, 1-hour TTL on all writes.

yingxiangge · 7 days ago

I hit a similar pattern — 89 full-context rewrites is brutal. When I dug into my own sessions (82 sessions, 8.
7B prompt tokens), the root cause turned out to be simpler than I expected:

93.9% of all context characters are tool output. A single grep result or a cat of a large file enters the
context and stays there for every subsequent turn. The prompt-per-turn average was 292K tokens vs 1.1K output — a
262:1 ratio.

The cache hit rate was 98.7%, which sounds efficient, but it just means the enormous payload you resend every
turn is discounted, not smaller. When the cache breaks (as in your case), the full 292K gets billed as
cache_creation instead of cache_read.

I built a small CLI to visualize this — it shows the turn-by-turn prompt-size curve and attributes context to
tool_output / assistant / user so you can see exactly which turn caused the spike:

pip install agent-cost-tracker
agent-cost analyze ~/.claude/projects/

Would be curious whether your 59M excess also correlates with a few specific tool calls early in the session
that never got evicted.

garaz1983-rgb · 6 days ago

Synthesis after the three data points above. The thread now carries two distinct mechanisms, and I want them separated explicitly so that a fix for one does not close the issue on the other.

Two mechanisms, told apart by the server's own reason field

| | server reason | what actually changed | evidence in this thread |
|---|---|---|---|
| A. Client mutates messages[] mid-array | messages_changed (system_changed / tools_changed when the mutation lands in a lower layer) | a Stop-hook notification inserted at index 82 of 107 (@Gunther-Schulz, measured on the wire); large negative context deltas at the start of every failure cluster, most likely non-cache-aligned pruning of old tool results (@eason-chengzi, 87 of 89 failures); MCP connector lifecycle touching the tools array (@intermap74, 3 events, cf. #81967) | fixable inside Claude Code |
| B. Server drops the entry inside the TTL | previous_message_not_found with an idle gap far under the TTL | nothing on the client side; every live session on the account loses its entry within the same minute, across three client paths, one entry 58 s old | three accounts, below |

The two labels are mutually exclusive by construction: the server only says previous_message_not_found when the prefix did match and the entry was simply gone. A and B can both be true on the same account (they are on mine), but B is not a sub-case of A. If A gets fixed in Claude Code — and it should — B stays.

B on every account that has run the idle-gap classification so far

| account | client / usage shape | unique requests | in-TTL previous_message_not_found | per-request rate | shortest gap |
|---|---|---|---|---|---|
| garaz1983-rgb | Windows 11, sdk-cli + cli, 4-8 concurrent sessions | 152,614 | 219 (158 under 5 min) | 0.14% | 58 s |
| intermap74 | Windows 11, Desktop MSIX, single session | 468 | 2 | 0.43% | 14 s |
| eason-chengzi | Windows 11, native CLI, single session | 3,660 | 1 (44 more pending classification) | ≥ 0.03% | 0.3 min |

The per-request rate is the same order of magnitude on all three. What makes B expensive on my account is not a higher rate — it is ~150k requests × 4-8 live sessions × 100-450k contexts, and the fact that one eviction takes every live entry at once. "Rare" is the right word per request and the wrong word per account-month.

B has kept firing while this thread was being written. Aug 23 (KST) closed at 24 in-TTL losses / 6.94M tokens / 31.7% of the day's cache writes, with two new records: at 18:05 an entry 24 seconds old was gone (previous record 58 s), and at 19:34–19:35 six sessions lost their entries within 51 seconds (1.56M tokens). After a 3.5-hour clean stretch that evening I honestly thought a fix had landed. Then this morning (Aug 24) the first real work of the day triggered 6 losses in a 14-minute window (07:58–08:12, idle gaps 7–29 min, 1.19M tokens) — quiet while idle, firing again the moment sessions are active.

I've put the full 14-day record on an interactive page — daily counts, a per-hour heatmap of losses against actual request volume (so "zero because idle" and "zero despite heavy use" are distinguishable; Aug 11 carried 4,320 requests with 1 loss while Aug 21 saw similar usage with 26), every batch-eviction window, and the loss-anatomy numbers above: https://claude.ai/code/artifact/286a2049-a60d-4840-a39a-0a0be1374779

One more figure on why B's per-request rarity understates it. Chaining my 105 in-TTL losses of the last 14 days into eviction windows (consecutive losses ≤3 min apart): 61 windows, of which 19 hit two or more sessions at once. Those batch windows are 31% of occurrences but 62% of the re-billed tokens (15.3M of 24.7M) — an average batch window costs 807k tokens, 3.6× a single-session one, and the four most expensive windows are all six-session simultaneous evictions. The blast radius of one server event scales with everything you have live.

Method notes

  • @eason-chengzi's caveat (the reason may sit on a later JSONL record of the same requestId, not the first): I checked my logs — 0 of ~152k requestIds have the reason only on a later record, so the numbers above are unaffected. It is still the right way to scan; the self-check in my comment above now takes the first non-null reason across all records of a requestId (v2.1).
  • @intermap74 — yes, please run the index-diff on your two short-gap events. If messages[] shows no mid-array change on those two, that is the cleanest separation of B from A anyone has posted.
  • @yingxiangge — context size sets the price of a loss, not its cause. A 58-second-old entry on a 1-hour TTL is gone regardless of what is in it.

The ask, narrowed

Two fixes, not one:

  • A: align Claude Code's mid-array mutations (hook notifications, context pruning, tool-list changes) to cache breakpoints, or move them to the tail where <system-reminder> blocks already ride.
  • B: explain why entries on the ephemeral_1h tier are dropped inside the TTL, account-wide and in a batch, and credit the re-billed tokens. I have a support ticket open for the billing side; this thread is the evidence it points to.