Skip to content

Relay texts miss the prompt cache on every message: TimeContext minute line and ARCHITECTURE_SUMMARY header-mirror race #2468

Open

Description

@yziks

Version

LifeOS 8.761.0 · attestation: 10 system file(s) differ from danielmiessler/LifeOS@v8.761.0:

  • hooks/TimeContext.hook.ts
  • hooks/SatisfactionCapture.hook.ts
  • LIFEOS/TOOLS/ArchitectureSummaryGenerator.ts
  • LIFEOS/TOOLS/models.ts
  • LIFEOS/TOOLS/model-pricing.json
  • LIFEOS/DOCUMENTATION/ARCHITECTURE_SUMMARY.md
  • LIFEOS/RELAY/bridge/session.ts
  • LIFEOS/RELAY/bridge/index.ts
  • LIFEOS/RELAY/worker/CHANGELOG.md
  • LIFEOS/RELAY/worker/package.json

What is broken

Every Tex (Relay) text runs as a fresh claude --print session, so its ~161k-token prompt is only cheap when it matches the previous text's prompt byte for byte and is read back from the 1h prompt cache. Two per-turn writers break that match. (1) TimeContext.hook.ts writes 🕐 Current time: … HH:MM into the large reminder block (skills list, agents) whose cache breakpoint sits right after it, so a one-digit change rewrites ~47k cached tokens on every text, even though the bridge already passes Now: in the turn block. (2) ArchitectureSummaryGenerator.ts generate runs at the end of every session and writes ARCHITECTURE_SUMMARY.md without its header-mirror block; DerivedSync adds the block back ~30 s later, so a text sent within that window sees a different CLAUDE.md import near the top of the prompt and rewrites ~114k tokens. Measured on two health-check turns 20 s apart: before the fix the second turn wrote 114,330 and read 46,797 cache tokens; after it, wrote 0 and read 161,149. On the subscription plan this is what drains the 5h window: with the bridge on an opus carrier, idle hourly self-checks alone spent about a quarter of a window overnight.
The probe in Repro is also red when pointed at an unmodified v8.761.0 tree, so the extra modified files listed under Version are local forks unrelated to this bug; the fix touches only the two files in the patch.

Where (file:line)

hooks/TimeContext.hook.ts:58 · LIFEOS/TOOLS/ArchitectureSummaryGenerator.ts:274

Repro on a clean tree

# probe.sh <install root>: exits 1 while either writer is still present
ROOT="$1"; fail=0
if echo '{}' | LIFEOS_TEXTING_SESSION=probe bun "$ROOT/hooks/TimeContext.hook.ts" | grep -q '<time-now>'; then
  echo "RED: TimeContext prints the minute line in a texting session (LIFEOS_TEXTING_SESSION set)"; fail=1
else echo "GREEN: TimeContext silent in texting session"; fi
bun "$ROOT/LIFEOS/TOOLS/ArchitectureSummaryGenerator.ts" generate >/dev/null
if bun "$ROOT/LIFEOS/TOOLS/HeaderMirror.ts" --check >/dev/null 2>&1; then
  echo "GREEN: ARCHITECTURE_SUMMARY.md has its header-mirror block right after generate"
else echo "RED: ARCHITECTURE_SUMMARY.md written without its header-mirror block (HeaderMirror --check fails)"; fail=1; fi
exit $fail
# Cache check: two texts (or two askDA health-prompt calls) 20 s apart; compare cache_creation_input_tokens / cache_read_input_tokens
# of the first assistant message in each transcript. Diffing their api-request-blob entries shows only the two differences above.

Negative control

Probe: bash /private/tmp/claude-502/-Users-<user>--claude/75de89f8-a7cc-4e07-bd61-a0ba93c3a55f/scratchpad/cache-probe.sh $HOME/.claude · exit 1 · captured 2026-10-30T08:14:41.790Z

Red looks like: RED: TimeContext prints the minute line in a texting session

RED: TimeContext prints the minute line in a texting session (LIFEOS_TEXTING_SESSION set)
RED: ARCHITECTURE_SUMMARY.md written without its header-mirror block (HeaderMirror --check fails)

Suggested fix

TimeContext stays silent when LIFEOS_TEXTING_SESSION is set (the texting turn already carries Now:), and ArchitectureSummaryGenerator passes its output through renderMirror() from HeaderMirror.ts, so the file is written once with its block and the existing "unchanged, mtime bumped" path stops the rewrite altogether. Patch below; the probe goes red on 8.761.0 and green with it.

diff --git a/LIFEOS/TOOLS/ArchitectureSummaryGenerator.ts b/LIFEOS/TOOLS/ArchitectureSummaryGenerator.ts
index 224ee599..4e5430b8 100755
--- a/LIFEOS/TOOLS/ArchitectureSummaryGenerator.ts
+++ b/LIFEOS/TOOLS/ArchitectureSummaryGenerator.ts
@@ -26,6 +26,7 @@ import * as fs from "fs";
 import * as path from "path";
 import { homedir } from "node:os";
 import { getClaudeDir, getLifeosDir } from "./Paths"; // public issue #1974 @sabo961, PR #1918 @PigLardLord
+import { renderMirror } from "./HeaderMirror";
 
 // Normalize env path vars that Claude Code injects without shell expansion (LifeOS#1404)
 for (const k of ["LIFEOS_DIR", "LIFEOS_CONFIG_DIR", "PROJECTS_DIR"]) {
@@ -271,7 +272,9 @@ function generate(): string {
 const withoutStamp = (s: string): string => s.replace(/^last_updated: .*$/m, "");
 
 function cmdGenerate(): void {
-  const summary = generate();
+  // Write the header-mirror block in the same write. Without it the file sat mirror-less
+  // until DerivedSync re-added the block ~30 s later, and a session starting in that gap missed the prompt cache.
+  const summary = renderMirror(generate());
   // Stamp-only rewrites dirtied git after most sessions; keep the bytes and bump
   // mtime so RebuildArchSummary's mtime check settles (public issue #2200, @corobis).
   if (fs.existsSync(SUMMARY_OUTPUT) && withoutStamp(fs.readFileSync(SUMMARY_OUTPUT, "utf-8")) === withoutStamp(summary)) {
diff --git a/hooks/TimeContext.hook.ts b/hooks/TimeContext.hook.ts
index 23d1c170..bdf7368f 100755
--- a/hooks/TimeContext.hook.ts
+++ b/hooks/TimeContext.hook.ts
@@ -55,7 +55,10 @@ if (import.meta.main) {
   // Armed session: the LifeOSHooks plugin writes the time line from $.clock itself.
   // The presence block (hooks/lib/presence-context.ts) rides the same cap: HookBridge prompt-context composes it when armed.
   const covered = hookCovers(input.session_id ?? "", "prompt-context");
-  const out = covered ? null : run();
+  // A texting turn already carries `Now:` in its turn block. The minute here changed every
+  // text and sat just before the prompt-cache breakpoint, so each text rewrote ~47k cached tokens.
+  const texting = !!process.env.LIFEOS_TEXTING_SESSION;
+  const out = covered || texting ? null : run();
   const reach = covered ? null : (await import("./lib/presence-context")).presenceContext();
   if (out || reach || shape) process.stdout.write((out ?? "") + (reach ?? "") + (shape ? `${shape}\n` : ""));
 }

Verified after fix

Probe: bash /private/tmp/claude-502/-Users-<user>--claude/75de89f8-a7cc-4e07-bd61-a0ba93c3a55f/scratchpad/cache-probe.sh $HOME/.claude · exit 0 · captured 2026-10-30T08:14:42.029Z

GREEN: TimeContext silent in texting session
GREEN: ARCHITECTURE_SUMMARY.md has its header-mirror block right after generate

Environment

  • OS: macOS 27.0 (arm64)
  • Harness: claude-code
  • bun: 1.3.11 · git: 2.50.1
  • Doctor broken: fabric

Activity

  1. xg-gh-25 commented on Oct 30, 2026

    @xg-gh-25

    Great write-up — the 114,330 → 0 cache-write delta is the clearest measurement of this failure mode I've seen. We hit the exact same HH:MM trap in our own agent runtime and the fix generalized well beyond the clock, so sharing in case it's useful for keeping this from regressing.

    What we found: prompt caching matches on a byte-exact prefix, so the question isn't "is this content volatile?" but "does anything volatile sit before the big stable block?" Our datetime section (minute precision) was in the framing ahead of ~76K of context files, so the whole constitution re-cache_creation'd every minute — same shape as your TimeContext line sitting above the skills/agents breakpoint.

    The structural fix was to split the builder into two outputs and fix the byte order, rather than patch each writer:

    # stable framing ONLY — no clock, no runtime state
    builder_text  = prompt_builder.build(include_volatile=False)
    volatile_tail = prompt_builder.build_volatile_tail()   # datetime + runtime
    
    # final order (cache-optimal):
    #   [stable framing] + [~76K context files] + [volatile datetime/runtime]

    Two follow-ons that mattered more than the initial fix:

    1. Per-turn blocks ride the user turn, not the system prompt. Our ~150K session-resume block was appended to the system prompt, which poisoned the cacheable segment the same way your header-mirror race does. Moving it onto the query (prefixed to the user message) kept the system prompt input-independent. Your bridge already passing Now: in the turn block is exactly this pattern — the TimeContext line is just a second copy in the wrong place.

    2. Make cache misses loud in production. Probes catch known writers; they don't catch the next one. We log every turn where the cache was fully missed on a large prompt:

      if cache_read == 0 and cache_create > 50_000:
          logger.info("cache_miss session=%s cache_creation=%d cache_read=0", sid, cache_create)

      That one line is how we'd have found your race Can PAI be used with Warp #2 — a derived file rewritten ~30s after a session ends looks like random, intermittent misses that only show up as a pattern in logs.

    For race #2 specifically, a cheap invariant that would cover any future "generate then fix up later" writer: build the prompt twice with a different clock and assert everything before the volatile tail is byte-identical. Anything that fails that test is, by definition, a cache-buster.


    Same byte-order rule (stable prefix, volatile tail, per-turn content on the query) runs our prompt assembly in SwarmAI. Discussion: Recall Architecture — the READ path

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions