[BUG] task_reminder nudge fired mid-tool-loop rewrites cached history (near-total rebuild per firing in autonomous sessions); 29% of sessions double-write the opening context — 3-week measured audit

Status Open
Reported on v2.1.162
Maintainer reply None cached
Activity 3 comments · opened Jul 17, 2026

Preflight — relationship to existing issues

I ran a 3-week forensic audit of my local transcripts (2026-06-26 → 07-17: 200 main-session files / 11,889 requests + 513 subagent transcripts / 25,971 requests) classifying every prompt-cache rebuild by what happened between the two requests. Several rebuild classes are already tracked, so this report files only the two classes I could not find issues for, plus one reliability observation — with corroborating numbers for the known ones rather than duplicates: #75142 (mid-session tool loading invalidates cache — confirmed, 82 front-load events / 4.3M tokens even when done as early as possible), #64901 (async subagent completion rebuilds warm cache — confirmed still live on 2.1.2xx: 10 events / 2.35M tokens, median ~230K, collapse to the ~17–25K tool-defs/first-message floor), #63930 (collapse to system+tools floor), #76606 (old system-reminder blocks rewritten in long sessions — Finding 1 below appears to be a named, deterministic trigger for exactly that class), #74318 (subagent 5-minute cache tier — corroborated from the child side: 13 events where the child's own >5-minute tool call, e.g. a remote test suite, expired the child's cache, 91K–554K per event, 3.6M total; survival in my data is binary: 0/256 losses under 300s gaps, 35% at 300–420s, 100% at 600s+), #74149 (TTL docs).

Finding 1 — the "task tools haven't been used recently" nudge, fired mid-tool-loop, mutates cached history

What's wrong?

When the task-reminder nudge (transcript attachment entry, type: "task_reminder") fires while the assistant is mid-tool-loop, the next request's cache_read_input_tokens collapses to the byte offset of the last human text message, and cache_creation rewrites everything after it. Everything is visible in the local transcript's usage fields.

Evidence from the window:

  • 1,597 task_reminder firings total; the ones that fired between a tool_result and the next request of the same turn are the ones followed by rebuilds (22 of 24 rebuild-adjacent firings were mid-loop). Firings at ordinary turn boundaries are benign (tail-append).
  • The collapse offset is byte-identical across consecutive occurrences. Worst case (a single-prompt scheduled session on 2026-07-10): three consecutive requests collapsed to exactly cache_read=24,997 while rewriting 217K → 231K → 238K, within 90 seconds — ~650K tokens re-written, one full rebuild per nudge firing during an Edit loop. A second session (2026-07-16) shows the identical pattern at cache_read=26,366 with a 311K rewrite.
  • The blast radius scales with how far back the last human text message is: in interactive sessions it is recent (small rebuild); in single-prompt autonomous/scheduled sessions it is the first message, so every firing is a near-total rebuild.
  • Cost in my window: 1.77M excess cache-write tokens attributed directly, plus a ~1.8M residual family with the same collapse-to-last-human-turn signature at other turn-entry paths (queued-message delivery, return-from-away).

This looks like a deterministic trigger for the class reported in #76606 (old <system-reminder> blocks changing shape/position in long sessions): the injection appears to be anchored to the most recent human message rather than appended after the pending tool_result, so it edits a message deep inside the cached prefix.

What should happen

Reminder/nudge injections must be append-only relative to the cached prefix — attached to the newest message, never to an earlier one. (And arguably the nudge should be suppressed entirely during autonomous stretches with no human present to see it.)

Steps to reproduce

  1. Start a session with a single prompt that triggers a long autonomous tool loop (many Edit/Bash calls, no TaskCreate/TaskWrite usage so the nudge fires).
  2. Watch the session JSONL: when an attachment entry with type: "task_reminder" lands between a tool_result and the next assistant request, compare that request's usage.cache_read_input_tokens with the previous request's cache_read + cache_creation.
  3. Observed: cache_read collapses to the offset of the last human text message and cache_creation rewrites the rest; repeat firings collapse to the byte-identical offset.

Finding 2 — 29% of sessions double-write the opening context (two fully cold requests at session start)

What's wrong?

In 49 of 169 sessions, request #1 cold-writes the opening context (cache_read=0, e.g. cache_creation=66K) and request #2, issued seconds later, is also fully cold (cache_read=0, cache_creation=93K — a superset of request #1's content). The opening 60–90K is paid twice; 2.71M wasted cache-write tokens in my 3-week window. The rate is similar for CLI-local and web-bridged sessions (27/131 vs 22/69).

Likely either request #2 is issued before request #1's cache entry is reusable, or session-start initialization (SessionStart hook additionalContext, deferred-tool listing, skill listing) lands between the two requests and changes the prompt head so nothing before the first breakpoint matches.

What should happen

Request #2 should read request #1's write (or the harness should defer the first request until the head is settled). Expected loss at session start is one cold write, not two.

Steps to reproduce

Scan any transcript corpus: take the first two distinct requestIds per session file and count sessions where both have cache_read_input_tokens == 0 with cache_creation_input_tokens > 10K. My data: 49/169 sessions (29%).

Finding 3 (reliability observation) — total cache miss seconds after a healthy 300K+ hit

Roughly once a day under parallel-subagent load, a subagent request reads 300K+ from cache and the next request 6–11 seconds later gets cache_read=0 — not even the system prompt matches — rewrites the full prompt (up to 395K), and subsequent requests then hit normally. Context sizes are scattered (100K–395K), so it does not look like a threshold crossing (e.g. into a long-context pool); it looks like server-side eviction or routing on the 5-minute tier. 8 clean occurrences / ~2.1M tokens in the window. Mentioning it here since it surfaces in the same audit; happy to move it to a separate report if that's more useful.

Impact summary (one user, 3 weeks, subscription)

| Population | Requests | Cache writes | Excess rewrite tokens |
|---|---|---|---|
| Main sessions (169) | 11,889 | 60.7M | 10.56M (17% of writes) |
| Subagents (513 transcripts) | 25,971 | 102.0M | 10.09M (10% of writes) |
| Session-start double cold write | 49 sessions | — | 2.71M |

23.4M excess cache-write tokens in 3 weeks (~1.1M/day) — roughly $300 API-equivalent, i.e. material subscription quota burn. Overall caching is otherwise healthy (2.8B main-chain cache-read tokens vs 60.7M writes).

Method (reproducible from local transcripts)

Every assistant record in ~/.claude/projects/**/*.jsonl carries message.usage. Dedupe streamed chunks by requestId; a rebuild is cur.cache_read < prev.cache_read + prev.cache_creation − ε. One accounting note that matters: input_tokens is the uncached tail after the final cache breakpoint and was never written to cache — including it in the baseline (prev.cache_read + prev.cache_creation + prev.input) fabricates hundreds of phantom "tail rebuilds" (~18K each, matching the injected-reminder tail). Attribution comes from transcript entries between the two requests: attachment entries (incl. type: task_reminder), ToolSearch tool_use, queue-operation, compact boundaries, human turns, and file boundaries. I'm happy to share the analysis script and redacted per-event data.

Environment

  • Claude Code 2.1.162 → 2.1.212 across the window (current: 2.1.212); macOS (darwin 25.5); CLI, plus a minority of web-bridged sessions (both affected)
  • Subscription (Max); main loop on the 1h cache tier, subagents on the 5m tier
  • Models: claude-fable-5 (main), claude-opus-4-8 (subagents); API provider: Anthropic

View original on GitHub ↗

3 Comments

carrotRakko · 1 month ago

Adding an independent replication with a different user profile and a cross-model comparison that isolates the Anthropic-side factor.

Environment (deconfounds the VS Code / autosave path)

  • Terminal-only (iTerm2, no VS Code extension) — decouples from the claudeCode.autosave trigger identified in the comment thread on #76606
  • Claude Code 2.1.198 → 2.1.215 across ~1 month
  • Anthropic API (subscription Max), Fable 5 main + Opus 4.7 fallback
  • Corpus: 203 primary transcripts, 90 Fable-5-started sessions; analysis under ~/.claude/projects/**/*.jsonl with same accounting rules you described (dedupe streamed chunks per requestId; drop = cur.cache_read < prev.cache_read after dedupe of consecutive same-value records)

Independently reproduces Finding 2 at a similar rate. Filtering to Fable-5 pure sessions (single-model, no fallback):

| Corpus | Sessions | With short-gap drop (< 5min, same-model) |
|---|---|---|
| Yours | 169 | 49 (29%) |
| Mine | 80 (Fable 5 pure) | 29 (36%) |

Same order of magnitude on a different user profile — strengthens the deterministic-trigger interpretation.

Additional observation (a) — cross-model comparison isolates the Anthropic-side factor

I ran the same drop detection over 28 GLM-5.2 single-model sessions in the same corpus (same harness, different backend API) as a controlled comparison:

| Metric | Fable 5 Group A (80 sessions) | GLM-5.2 (28 sessions) |
|---|---|---|
| Drops/session | 0.75 | 2.82 |
| Gap distribution — 0-1min | 50% | 9% |
| Gap distribution — 5-30min | 8% | 51% |
| Gap distribution — 60-120min | 25% | 6% |
| after value: zero | 47% | 1% |
| after value: 7,300–8,500 | 40% | 0% |

The after ≈ 7,300–8,500 cluster (24 of 60 same-model Fable-5 drops) is completely absent on GLM-5.2. That value is consistent with the size of "Claude Code system prompt + tool definitions in Anthropic's tokenizer" — a stable frozen prefix that survives even when the entire conversation history is invalidated.

The 2-mode after distribution (0 = full loss, ~7,500 = frozen-prefix-only, minimal middle) is direct behavioural evidence for a 2-breakpoint / 2-TTL layout: a deep breakpoint that survives most rewrites, and a tail breakpoint that goes down whenever a mid-conversation edit lands. This aligns with @hksbindra's observation on #76606 that there is exactly one cache_control breakpoint in the messages array — invalidation there always collapses back to the system-side floor.

Additional observation (b) — mid-turn drops are catastrophic writes, not incremental ones

For every drop, comparing cache_creation at the drop record against the mean of the 5 preceding records:

| Metric | Fable 5 |
|---|---|
| cache_creation before drop (median of 5-record window) | 1,468 tokens |
| cache_creation at drop (median) | 321,322 tokens |
| Ratio | 219× |

A drop request writes ~219× the normal per-turn write on average. This quantifies "near-total rebuild per firing" from a different angle: the drop turn is not an incremental append, it's a full-prefix rebuild — matching the "blast radius scales with how far back the last human text message is" mechanism you described. Median at_drop = 321k is close to the median before (cache prefix at drop time) of ~320k, so the rewrite covers approximately the entire cached prefix except the frozen head.

Additional observation (c) — economic-loss ranking

Bucketed by root-cause pattern (100 drops total: 60 same-model Fable-5 + 40 involving Fable-5→Opus-4.7 safeguard switches + drops on the fallback):

| Rank | Pattern | Drops | Sessions | delta sum |
|---|---|---|---|---|
| 1 | ShortGap-WithoutSafeguard (< 5min, same-model, no safeguard event in session) | 32 | 29 | 9.68M |
| 2 | ModelSwitch (safeguard-triggered fallback) | 21 | 10 | 9.27M |
| 3 | TTL-1hour-ish (≥ 60min gap, same-model) | 28 | 24 | 8.13M |
| 4 | ShortGap-WithSafeguard (< 5min, same-model, safeguard fired somewhere in session) | 12 | 7 | 3.40M |
| 5 | TTL-5min-ish (5-30min gap, same-model) | 7 | 6 | 2.00M |

Total: 32.5M cache-creation tokens across 1 month for one subscription user. Rank 1 (ShortGap-WithoutSafeguard) alone is 30% of total loss — mid-turn injections into cached history plausibly account for a large fraction of it, per the mechanism you documented. The gap distribution inside Rank 1 (5–15 s bucket dominant, after value in the 2-mode split) matches what you'd expect from the task_reminder mechanism firing mid-tool-loop.

Distribution of drops around 60-minute boundary

For same-model drops in the 60–120min range (15 of my 60 same-model Fable drops), clustering at 67–72 min:

67-72min: 6 drops
75-90min: 4 drops
90-120min: 5 drops

Consistent with a 1h TTL boundary + typical time between adjacent requests: the deep breakpoint expires around 60 min, next request lands somewhere in the following minutes and shows the drop. This is a separate root cause from Finding 1 (time-independent, not mid-loop) but the two are additive in the ranking.

Ask, echoing yours

+1 to "reminder / nudge injections must be append-only relative to the cached prefix — attached to the newest message, never to an earlier one." From the user side, this is the class with the least visibility and no config lever: unlike claudeCode.autosave (workaround: disable it, per #76606), a CLI user has no knob to turn task_reminder off.

Beyond the fix in Finding 1, a second breakpoint mid-way through the messages array — as also suggested on #76606 — would reduce the blast radius of any mid-conversation edit that does slip through: today a mutation at message 3 and a mutation at message 93 are equally catastrophic, because both drop everything past the single tail-anchored breakpoint back to the system-side floor.

Happy to share the analysis scripts (Python + jq + Bash) and the per-drop TSVs if useful.

✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved)

Gunther-Schulz · 1 month ago

Quantitative confirmation of the anchoring mechanism, from an independent capture corpus (CC 2.1.220, linux, every /v1/messages body recorded through a local proxy).

We classified every consecutive same-conversation request pair in a 1,731-request corpus and extracted the mid-history in-place edits (message rewritten at an index well before the tail — the expensive class, since everything after the index is re-billed). Then we matched each edit position against the last human-typed message of that request (excluding tool_results and injected <system-reminder>/notification blocks):

  • 33 mid-history edit pairs total; 22 have a human-typed anchor (the other 11 are subagent conversations).
  • 20 of those 22 sit within ±2 of the last human message — 11 exactly on it, 8 at −2. In histories of 500–800 messages, that clustering is not chance.
  • The two remaining deep edits sit 3 and 11 messages past the then-current last human message — i.e. in the zone where injected reminder/hook blocks land, consistent with an anchor that aged as the tool loop appended past it. The worst of these preceded a 484k-token full cache rewrite by 15 seconds.

So on our data, mid-history rewrites are not diffuse: they are concentrated precisely at the reminder-anchoring position this issue describes. Fixing the anchoring (append after the pending tool_result instead of attaching to the last human message, and never re-stamp an already-sent message) would eliminate the entire measured population.

Methodology is open: capture cnighswonger/claude-code-cache-fix#275, census/replay cnighswonger/claude-code-cache-fix#276.

🤖 Generated with Claude Code

Gunther-Schulz · 1 month ago

One addition to the edit-class inventory from our corpus above: a new in-place edit class from the same injected-content family. CC merged a message's two hook reminders — wrapper-stripped, "\n\n"-joined — into one standalone system message (627 B), and separately oscillated a reminder among already-seen forms. Both present as mid-history splices with the near-total rebuild this thread prices.

Both are absorbed by content-identity pinning once the matcher also hashes the JOIN of a message's blocks, not only each block individually (implementation + sanitized fixture: Gunther-Schulz/claude-code-cache-fix 78940a0, test/fixtures/harvested/oscillation-s-633915a8-863.json; upstreamed in cnighswonger/claude-code-cache-fix#272). Relevant here because any dedupe/anchoring fix has the same requirement: a merged standalone must compare equal to its constituent parts, or the fix trades one rebuild class for another.

🤖 Generated with Claude Code