mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
fix(telemetry): stamp a compaction on entering the null window
Claude Code's tracker nulls recache_tokens_if_cold at a compaction or a tool-result clearing and only counts the expected rebuild at the next request, which also closes the window, so no report carried both and the old rule never fired. Stamp on the first warm null report after one that carried a value, carry it through idle re-reports and drop it at the next request; the readout now says "Context compacted at HH:MM" since a clearing opens the same window. Also persist a changed report so a crash restores the latest one, rename the session field to statusLineExporter (statusLineTelemetry is the registry capability) and document its values, word the stake as re-cached, move the readout block above the SSE staleness heading, and drop keep-warm mentions until it lands.
This commit is contained in:
1 parent
551a7f2792
commit
ecb2e3d8f3
16 files changed
+110
-125
No files matched your search
@@ -224,7 +224,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
||||
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-read stake, the hit rate, the last miss's causes, and a `compacted at HH:MM` note for Claude Code's own idle compaction (`recache_tokens_if_cold` null while `expected_rebuilds` rose — NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
|
||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||
|
||||
|
||||
@@ -645,7 +645,7 @@ change-only `session:promptCache` event (`{ sessionId, ...promptCache }`):
|
||||
| `expectedRebuilds` | Cache rebuilds that followed a compaction or a clearing of old tool results. |
|
||||
| `hitRatio` | Cache read tokens as a fraction of all input tokens this session, 0 to 1. |
|
||||
| `lastMissCauses` | Claude Code's diagnosis of the last miss (e.g. `ttl_expired_1h`, `tools_changed`); untrusted text. |
|
||||
| `compactedAt` | Epoch MILLISECONDS when Codeman first saw Claude Code's own idle compaction of the current prefix (derived, not reported). |
|
||||
| `compactedAt` | Epoch MILLISECONDS when the context was compacted or its old tool results cleared: the first warm report without `recacheTokensIfCold` after one that had it. Derived, not reported; dropped at the next request. |
|
||||
|
||||
The whole object is absent until the first report, and restored after a server restart
|
||||
(so the readout is not blank until the pane's next render) rather than recomputed; a value
|
||||
@@ -654,6 +654,12 @@ whose `expiresAt` has passed reads as cold. The statusline re-runs when a warm c
|
||||
it. Shown in Session Options → Respawn and, for sessions without a statusline of their own,
|
||||
as a `cache:until HH:MM` / `cache:cold` group in the terminal footer.
|
||||
|
||||
Beside it, `statusLineExporter` says whether this launch carries the statusline exporter
|
||||
that sends the report, decided when the pane starts: `injected` (it does), `collection-off`
|
||||
(Plan Usage was off at launch), `workspace-statusline` (the workspace's
|
||||
`.claude/settings.local.json` sets its own `statusLine`, which Codeman never replaces) or
|
||||
`remote` (remote and Docker sessions). Absent when unknown.
|
||||
|
||||
## Approvals Inbox
|
||||
|
||||
Cross-session queue of prompts waiting on a human (permission dialogs,
|
||||
|
||||
@@ -157,7 +157,7 @@ Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgent
|
||||
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, printing NOTHING on failure, `curl -sfk` plus `|| true`, since a bare brand word on the statusline is what discussion #405 opened with; backgrounded — `>/dev/null 2>&1 </dev/null &`, closing stdin too — only in the wrap branch, where the user's own command owns the footer; `curl --max-time 5` bounds a hung, not just refused, Codeman). Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. `plan-usage-latest.ts` merges both process-wide sources and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox and chip visibility (DISPLAY only) — the SAME `showPlanUsageLimits` setting also doubles as the server-side telemetry COLLECTION switch, read FRESH from `settings.json` by `readPlanUsageTelemetryEnabled()` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), never cached, with no per-session field and no per-request wire field — applies uniformly across every claude-creation path (interactive Run, cron, Ralph Loop API, quick-start) and survives a Codeman restart by construction (nothing per-session to lose). ⚠️ An ABSENT key reads as ON, the same way an absent `workspaceHooksEnabled` does: the desktop chip already shows as on for an install that never touched the setting, and the exporter posts only to this Codeman over loopback. Resolving the default in the reader is what keeps `GET /api/settings` a plain read. It briefly reconciled the key on first read (persisting `true` when absent), but `readJsonConfig()` answers `{}` for ANY read failure, not only ENOENT, and every page load hits that route, so one unlucky read replaced the whole settings file with a one-key file; pinned by `test/routes/system-routes-settings-get-plan-usage-default.test.ts`. ⚠️ The client sends `showPlanUsageLimits` in a settings save ONLY when that save FLIPS the chip relative to what the device had (`planUsageCollectionFlip()` in settings-ui.js): the chip defaults OFF on handhelds, so sending it on every save let a phone saving its font size persist `false` and switch collection off for every desktop, whose chip then went stale with no error anywhere. An explicit toggle on any device still writes the switch. Injection covers LOCAL tmux-spawned claude sessions only: the non-tmux direct-PTY fallback (`Session.startInteractive` when tmux is unavailable) and the remote/docker pane builders do not carry the flag. Registry-gated on `getCli(mode)?.capabilities.statusLineTelemetry` rather than a hardcoded mode string. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`, `test/hooks-config.test.ts` (statusline exporter script + `readPlanUsageTelemetryEnabled`), `test/statusline-cli-flag.test.ts`.
|
||||
|
||||
Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`. The same statusline payload carries a `prompt_cache` object since Claude Code v2.1.251 (`warm`, `ttl`, `expires_at` in epoch seconds, `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at`, `last_miss_cause`); `StatusTelemetrySchema` lists every documented scalar field null-tolerant and keeps `causes` as `unknown` (the object gains fields across CLI versions and several are documented as possibly null, so a stricter shape would 400 the whole post and blank the footer — the recurring `.optional()`/null trap), while `parsePromptCache()` (usage-telemetry.ts) normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. The footer shows it as `cache:until HH:MM` (server-local clock, never a countdown: the footer only re-renders on statusline triggers) while warm and `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, so the footer flips to cold on its own. ⚠️ A user's OWN statusline wins the footer (the exporter then posts in the background and prints theirs), so the state ALSO lives on `Session.promptCache` (`setPromptCache()`, in `toState()` for the Session Options → Respawn readout `#promptCacheStatus`, worded by the pure `CodemanPromptCache.format` in constants.js, which also shows the hit rate, the last miss's `causes`, and — for Claude Code's own idle compaction, `recache_tokens_if_cold` null while `expected_rebuilds` rose — a `compacted at HH:MM` note that is NEVER an alert) and the route broadcasts `session:promptCache` (the STORED object, so the compaction stamp rides along) only when the report CHANGES (the statusline fires per assistant message). ⚠️ `setPromptCache()` returns a NEW object when it stamps `compactedAt`, never mutating the parser's output, which is what keeps broadcasting the stored object meaningful; a rise counts only against a KNOWN prior `expected_rebuilds` (an absent prior is not 0, or the first report of a mid-session restart would invent a compaction), and `adoptClaudeSessionId()` drops the whole report on a conversation switch so a post-`/clear` pane never shows the old prefix's warm-until. The last report is restored on a Codeman restart (the constructor's `promptCache`, set directly so it is not re-stamped, like `displayModel`) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose `expiresAt` has passed reads as cold rather than as a stale warm (and `parsePromptCache` validates the converted-to-ms expiry against the Date range, so a huge `expires_at` cannot render `NaN:NaN`). ⚠️ A launch without the exporter never reports, so `TmuxManager._resolveStatusLine()` records why on the mux record at every create and respawn (`MuxSession.statusLine`, published as `SessionState.statusLineTelemetry`: `injected`, `collection-off`, `workspace-statusline`, `remote`) and the readout names the cause instead of "not reported yet" forever. It is a spawn-time fact: flipping Plan Usage on later changes nothing until the next launch. ⚠️ This object, not the plan tier, is the source of truth for the TTL: Claude Code requests the hour only for a subscription within plan usage, and drops to five minutes on an API key, a cloud provider or extra-usage credits.
|
||||
Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`. The same statusline payload carries a `prompt_cache` object since Claude Code v2.1.251 (`warm`, `ttl`, `expires_at` in epoch seconds, `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at`, `last_miss_cause`); `StatusTelemetrySchema` lists every documented scalar field null-tolerant and keeps `causes` as `unknown` (the object gains fields across CLI versions and several are documented as possibly null, so a stricter shape would 400 the whole post and blank the footer — the recurring `.optional()`/null trap), while `parsePromptCache()` (usage-telemetry.ts) normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. The footer shows it as `cache:until HH:MM` (server-local clock, never a countdown: the footer only re-renders on statusline triggers) while warm and `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, so the footer flips to cold on its own. ⚠️ A user's OWN statusline wins the footer (the exporter then posts in the background and prints theirs), so the state ALSO lives on `Session.promptCache` (`setPromptCache()`, in `toState()` for the Session Options → Respawn readout `#promptCacheStatus`, worded by the pure `CodemanPromptCache.format` in constants.js, which also shows the hit rate, the last miss's `causes`, and — after a compaction (`/compact` or Claude Code's own idle compaction) or a tool-result clearing, the first warm report whose `recache_tokens_if_cold` is null after one that carried it — a `Context compacted at HH:MM` note that is NEVER an alert) and the route broadcasts `session:promptCache` (the STORED object, so the compaction stamp rides along) only when the report CHANGES (the statusline fires per assistant message). ⚠️ `setPromptCache()` returns a NEW object when it stamps `compactedAt`, never mutating the parser's output, which is what keeps broadcasting the stored object meaningful; the stamp needs a KNOWN prior `recache_tokens_if_cold` (a first report already inside the null window, such as a mid-session restart's, gets none), is carried while the window holds and dropped at the next request, and never reads `expected_rebuilds`: Claude Code's tracker clears the window and counts the expected rebuild in that same next request, so no single report carries both (2.1.294-2.1.296), and `adoptClaudeSessionId()` drops the whole report on a conversation switch so a post-`/clear` pane never shows the old prefix's warm-until. The last report is restored on a Codeman restart (the constructor's `promptCache`, set directly so it is not re-stamped, like `displayModel`) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose `expiresAt` has passed reads as cold rather than as a stale warm (and `parsePromptCache` validates the converted-to-ms expiry against the Date range, so a huge `expires_at` cannot render `NaN:NaN`). ⚠️ A launch without the exporter never reports, so `TmuxManager._resolveStatusLine()` records why on the mux record at every create and respawn (`MuxSession.statusLine`, published as `SessionState.statusLineExporter`: `injected`, `collection-off`, `workspace-statusline`, `remote`) and the readout names the cause instead of "not reported yet" forever. It is a spawn-time fact: flipping Plan Usage on later changes nothing until the next launch. ⚠️ This object, not the plan tier, is the source of truth for the TTL: Claude Code requests the hour only for a subscription within plan usage, and drops to five minutes on an API key, a cloud provider or extra-usage credits.
|
||||
|
||||
### Cron jobs
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Claude Code (**v2.1.80+**; prod box runs **2.1.177**) pipes a JSON blob to a con
|
||||
|
||||
The same stdin object also carries `model.display_name`, `context_window.{used_percentage, total_input_tokens, total_output_tokens, …}`, `cost.total_cost_usd`, `effort.level`, etc. The shipped feature uses **model + token totals + context %** to build the in-terminal footer (so the statusline stays useful even though we own it). The endpoint also broadcasts `contextUsedPercentage`/`costUsd`/`modelDisplayName` alongside the limits for future chip tooltips.
|
||||
|
||||
Since Claude Code v2.1.251 the payload also carries a `prompt_cache` object for the main conversation: `warm`, `ttl` (`5m` or `1h`), `expires_at` (epoch seconds), `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at` and `last_miss_cause`. `StatusTelemetrySchema` lists every documented scalar field null-tolerant (several are documented as possibly null, and the object gains fields across CLI versions, so a strict shape would 400 the post and blank the footer); `parsePromptCache()` normalizes only the subset Codeman stores. The footer appends `cache:until HH:MM` while warm or `cache:cold` otherwise, and Claude re-runs the statusline when `expires_at` passes, so the cold flip arrives without a turn. The Session Options readout adds the hit rate, the last miss's causes, and a `compacted at HH:MM` note for Claude Code's own idle compaction (`recache_tokens_if_cold` null while `expected_rebuilds` rose, never raised as an alert). The last report is restored on a Codeman restart and the readout is time-aware, so a value whose expiry has passed shows as cold rather than stale-warm.
|
||||
Since Claude Code v2.1.251 the payload also carries a `prompt_cache` object for the main conversation: `warm`, `ttl` (`5m` or `1h`), `expires_at` (epoch seconds), `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at` and `last_miss_cause`. `StatusTelemetrySchema` lists every documented scalar field null-tolerant (several are documented as possibly null, and the object gains fields across CLI versions, so a strict shape would 400 the post and blank the footer); `parsePromptCache()` normalizes only the subset Codeman stores. The footer appends `cache:until HH:MM` while warm or `cache:cold` otherwise, and Claude re-runs the statusline when `expires_at` passes, so the cold flip arrives without a turn. The Session Options readout adds the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (the first warm report whose `recache_tokens_if_cold` is null after one that carried it; `expected_rebuilds` rises only at the next request, so it is not read; never raised as an alert). The last report is restored on a Codeman restart and the readout is time-aware, so a value whose expiry has passed shows as cold rather than stale-warm.
|
||||
|
||||
### Alternatives considered & rejected
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ subscription plan.
|
||||
|
||||
Claude Code bills a turn against the cached conversation while that cache is warm, and lets the cache expire after a fixed idle time: one hour on a subscription within plan usage, five minutes on an API key, a cloud provider or extra-usage credits. The first turn after a longer break re-processes the whole context at the cache-write rate, which is why coming back to a big session after lunch costs more than the turns around it.
|
||||
|
||||
Claude Code 2.1.251 and newer reports the cache state on the statusline after every response, and Codeman's statusline exporter keeps it per session. The row shows **Warm until HH:MM** with the lifetime and the tokens that would be re-read if it went cold, or **Cold** with the size of the re-read the next turn pays. When Claude reports them it also shows the session's cache hit rate and the cause of the last cache miss (for example `ttl_expired_1h` or `tools_changed`), so a surprise miss has a reason next to it. If the conversation is compacted while it stays warm (Claude Code now does this on its own on large idle sessions, and a manual `/compact` does it too), the row notes **Compacted at HH:MM**: the cache holds a smaller prefix, so there is nothing to re-read. None of this raises an alert. The row refreshes live and flips to cold on its own when the lifetime passes, and the last state is kept across a Codeman restart so it is not blank until the session's next turn. Sessions without a statusline of their own also get a short `cache:until HH:MM` or `cache:cold` group in the terminal footer. The readout rides the plan usage chip's telemetry collection, which is on by default on desktop.
|
||||
Claude Code 2.1.251 and newer reports the cache state on the statusline after every response, and Codeman's statusline exporter keeps it per session. The row shows **Warm until HH:MM** with the lifetime and the tokens that would be re-cached if it went cold, or **Cold** with the size of the re-cache the next turn pays. When Claude reports them it also shows the session's cache hit rate and the cause of the last cache miss (for example `ttl_expired_1h` or `tools_changed`), so a surprise miss has a reason next to it. If the conversation is compacted while it stays warm (Claude Code now does this on its own on large idle sessions, a manual `/compact` does it too, and so does a clearing of old tool results), the row notes **Context compacted at HH:MM** until the next turn: Claude Code reports no re-cache size until then. None of this raises an alert. The row refreshes live and flips to cold on its own when the lifetime passes, and the last state is kept across a Codeman restart so it is not blank until the session's next turn. Sessions without a statusline of their own also get a short `cache:until HH:MM` or `cache:cold` group in the terminal footer. The readout rides the plan usage chip's telemetry collection, which is on by default on desktop.
|
||||
|
||||
## The plan usage chip
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
PaneExit,
|
||||
StatusLineTelemetry,
|
||||
StatusLineExporter,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -59,7 +59,7 @@ export interface MuxSession {
|
||||
/** Whether Ralph / Todo tracking is enabled */
|
||||
ralphEnabled?: boolean;
|
||||
/** Whether the current launch carries the statusLine exporter; set on every create and respawn. */
|
||||
statusLine?: StatusLineTelemetry;
|
||||
statusLine?: StatusLineExporter;
|
||||
/**
|
||||
* This record was rebuilt from the tmux socket rather than from Codeman's own
|
||||
* bookkeeping, so everything on it but the name and the pid is a guess. Its
|
||||
|
||||
+11
-18
@@ -1443,28 +1443,21 @@ export class Session extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Replace the prompt-cache report; true when it differs from the previous one. Stamps
|
||||
* `compactedAt` when this report shows Claude Code's own idle compaction of the current
|
||||
* prefix: `recacheTokensIfCold` is null (the transient post-compaction window the docs
|
||||
* describe) while `expectedRebuilds` rose above the last report. The stamp is carried
|
||||
* forward while that window holds, so the readout can say "compacted at HH:MM"; it is
|
||||
* never an alert. A tool-result clearing bumps `expectedRebuilds` the same way, which the
|
||||
* readout wording tolerates.
|
||||
* `compactedAt` when this report ENTERS the window in which Claude Code reads
|
||||
* `recacheTokensIfCold` as null: a compaction (`/compact` or its own idle compaction) or
|
||||
* a clearing of old tool results opened it, and the previous report still had a known
|
||||
* value. Claude Code closes the window on the next request (that request is the one that
|
||||
* counts as an expected rebuild), so `expectedRebuilds` never rises inside it and is not
|
||||
* read here. The stamp is carried while the window holds and dropped once it closes; a
|
||||
* first report already inside the window gets none. It is never an alert.
|
||||
*/
|
||||
setPromptCache(status: PromptCacheStatus): boolean {
|
||||
const prev = this._promptCache;
|
||||
const inPostCompactWindow = status.recacheTokensIfCold == null && status.warm;
|
||||
const inWindow = status.warm && status.recacheTokensIfCold == null;
|
||||
let compactedAt: number | undefined;
|
||||
// A rise is only a rise against a KNOWN prior counter. Treating an absent prior as 0
|
||||
// would invent a compaction out of the first report that already carries a high count
|
||||
// (a restart mid-session, or `4 -> omitted -> 4`), so both counters must be present.
|
||||
if (
|
||||
inPostCompactWindow &&
|
||||
prev?.expectedRebuilds != null &&
|
||||
status.expectedRebuilds != null &&
|
||||
status.expectedRebuilds > prev.expectedRebuilds
|
||||
) {
|
||||
if (inWindow && prev?.recacheTokensIfCold != null) {
|
||||
compactedAt = Date.now();
|
||||
} else if (prev?.compactedAt != null && inPostCompactWindow && status.expectedRebuilds === prev.expectedRebuilds) {
|
||||
} else if (inWindow && prev?.compactedAt != null) {
|
||||
compactedAt = prev.compactedAt;
|
||||
}
|
||||
// A NEW object when a stamp applies, never a mutation of the caller's parsed report: the
|
||||
@@ -1956,7 +1949,7 @@ export class Session extends EventEmitter {
|
||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||
promptCache: this._promptCache ?? undefined,
|
||||
statusLineTelemetry: this._muxSession?.statusLine,
|
||||
statusLineExporter: this._muxSession?.statusLine,
|
||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||
pinned: this._pinned || undefined,
|
||||
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
|
||||
|
||||
+3
-3
@@ -59,7 +59,7 @@ import {
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
type PaneExit,
|
||||
type StatusLineTelemetry,
|
||||
type StatusLineExporter,
|
||||
} from './types.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
|
||||
@@ -2046,14 +2046,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* only (remote/docker have their own separate command builders). Resolving still runs
|
||||
* with collection off, because it also self-heals: it strips any legacy disk-written
|
||||
* exporter from an older Codeman build the first time a session starts in that
|
||||
* workspace again. `state` lands on the mux record so the Status row and keep-warm can
|
||||
* workspace again. `state` lands on the mux record so the Status row can
|
||||
* say why no cache report will come, instead of "not reported yet" forever.
|
||||
*/
|
||||
private async _resolveStatusLine(
|
||||
mode: SessionMode,
|
||||
workingDir: string,
|
||||
remoteOrDocker: boolean
|
||||
): Promise<{ command?: string; state?: StatusLineTelemetry }> {
|
||||
): Promise<{ command?: string; state?: StatusLineExporter }> {
|
||||
if (!getCli(mode)?.capabilities.statusLineTelemetry) return {};
|
||||
if (remoteOrDocker) return { state: 'remote' };
|
||||
const enabled = await readPlanUsageTelemetryEnabled();
|
||||
|
||||
@@ -747,10 +747,12 @@ export interface PromptCacheStatus {
|
||||
/** Claude Code's diagnosis of the last miss, e.g. `ttl_expired_1h` or `tools_changed`. */
|
||||
lastMissCauses?: string[];
|
||||
/**
|
||||
* Epoch MS when Codeman first saw Claude Code's own idle compaction for the current prefix
|
||||
* (`recacheTokensIfCold` null while `expectedRebuilds` rose). Derived in `Session.setPromptCache`,
|
||||
* not reported by the statusline, so it is never set by `parsePromptCache`. Lets the readout say
|
||||
* "compacted at HH:MM"; it is never an alert (CC's idle compaction is benign).
|
||||
* Epoch MS when Codeman saw the context compacted (`/compact`, Claude Code's own idle
|
||||
* compaction, or a clearing of old tool results): the first warm report whose
|
||||
* `recacheTokensIfCold` is null after one that carried it. Carried while the null window holds,
|
||||
* dropped at the next request. Derived in `Session.setPromptCache`, not reported by the
|
||||
* statusline, so it is never set by `parsePromptCache`. Lets the readout say "Context compacted
|
||||
* at HH:MM"; it is never an alert.
|
||||
*/
|
||||
compactedAt?: number;
|
||||
}
|
||||
@@ -765,7 +767,7 @@ export interface PromptCacheStatus {
|
||||
* statusLine (or could not be read), and Codeman never replaces one there.
|
||||
* - `remote`: remote and Docker launches never carry the exporter.
|
||||
*/
|
||||
export type StatusLineTelemetry = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote';
|
||||
export type StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote';
|
||||
|
||||
export interface SessionState {
|
||||
/** Unique session identifier */
|
||||
@@ -826,8 +828,8 @@ export interface SessionState {
|
||||
* `expiresAt` has passed reads as cold rather than as a stale warm.
|
||||
*/
|
||||
promptCache?: PromptCacheStatus;
|
||||
/** Why a cache report will or will not arrive (see `StatusLineTelemetry`); absent when unknown. */
|
||||
statusLineTelemetry?: StatusLineTelemetry;
|
||||
/** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */
|
||||
statusLineExporter?: StatusLineExporter;
|
||||
/** Pinned to the top of the session manager list (COD-139) */
|
||||
pinned?: boolean;
|
||||
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
|
||||
|
||||
@@ -709,14 +709,12 @@ function computeConnectionLossUi(input) {
|
||||
};
|
||||
}
|
||||
|
||||
// SSE staleness policy: is this stream a zombie?
|
||||
//
|
||||
// Session Options → Respawn → "Prompt cache": one sentence of the main
|
||||
// conversation's cache state as Claude Code reports it on the statusline
|
||||
// (`session.promptCache`, v2.1.251+). A clock time rather than a countdown
|
||||
// because the row refreshes on SSE, not on a timer, and a countdown would sit
|
||||
// stale between turns. Pure: no DOM, `now` is passed in. `statusLine` is the
|
||||
// session's `statusLineTelemetry`: a launch without the exporter never reports, so
|
||||
// session's `statusLineExporter`: a launch without the exporter never reports, so
|
||||
// it says why instead of "not reported yet" forever.
|
||||
const PROMPT_CACHE_NOT_COLLECTED = {
|
||||
'collection-off':
|
||||
@@ -738,18 +736,20 @@ function formatPromptCacheStatus(cache, now, statusLine) {
|
||||
if (cache.warm && Number.isFinite(cache.expiresAt) && cache.expiresAt > now) {
|
||||
const ttl = cache.ttl ? ` (${cache.ttl} cache)` : '';
|
||||
parts.push(`Warm until ${clock(cache.expiresAt)}${ttl}.`);
|
||||
if (tokens) parts.push(`About ${tokens} tokens would be re-read if it goes cold.`);
|
||||
if (tokens) parts.push(`About ${tokens} tokens would be re-cached if it goes cold.`);
|
||||
} else {
|
||||
parts.push(tokens ? `Cold. The next turn re-reads about ${tokens} tokens at the cache-write rate.` : 'Cold.');
|
||||
parts.push(tokens ? `Cold. The next turn re-caches about ${tokens} tokens.` : 'Cold.');
|
||||
}
|
||||
// Claude Code's own idle compaction kept the prefix warm but smaller, so there is no
|
||||
// re-read stake to show; naming when it happened explains the shrink. Never an alert.
|
||||
if (Number.isFinite(cache.compactedAt)) parts.push(`Compacted at ${clock(cache.compactedAt)}.`);
|
||||
// A compaction or a tool-result clearing leaves no re-cache stake to show until the next
|
||||
// request; naming when it happened explains the gap. Never an alert.
|
||||
if (Number.isFinite(cache.compactedAt)) parts.push(`Context compacted at ${clock(cache.compactedAt)}.`);
|
||||
if (Number.isFinite(cache.hitRatio)) parts.push(`${Math.round(cache.hitRatio * 100)}% cache hit rate.`);
|
||||
if (cache.lastMissCauses && cache.lastMissCauses.length) parts.push(`Last miss: ${cache.lastMissCauses.join(', ')}.`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// SSE staleness policy: is this stream a zombie?
|
||||
//
|
||||
// An EventSource that stops delivering does not always error. A proxy that
|
||||
// idle-closed the connection, a laptop resumed from sleep, a tailnet
|
||||
// reconnect: `onerror` never fires, the header dot stays green, and every
|
||||
|
||||
@@ -2795,7 +2795,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const el = document.getElementById('promptCacheStatus');
|
||||
if (!el || this.editingSessionId !== sessionId) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
el.textContent = window.CodemanPromptCache.format(session?.promptCache, Date.now(), session?.statusLineTelemetry);
|
||||
el.textContent = window.CodemanPromptCache.format(session?.promptCache, Date.now(), session?.statusLineExporter);
|
||||
},
|
||||
|
||||
_onSessionPromptCache(data) {
|
||||
|
||||
@@ -71,6 +71,8 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
|
||||
const promptCache = parsePromptCache(payload);
|
||||
if (promptCache && session?.setPromptCache(promptCache)) {
|
||||
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
|
||||
// Persisted on change, like a model change, so a crash restores the latest report.
|
||||
ctx.persistSessionState(session);
|
||||
}
|
||||
|
||||
// Plan-usage limits (account-wide) → broadcast to the header chip, when
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The Session Options "Prompt cache" readout (`CodemanPromptCache.format`, constants.js):
|
||||
* the one sentence that tells the user whether the next turn re-reads the conversation
|
||||
* the one sentence that tells the user whether the next turn reads the conversation
|
||||
* from cache or re-processes it cold. Loaded the way the other constants.js policies are.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
@@ -49,7 +49,7 @@ describe('Prompt cache readout', () => {
|
||||
const format = loadFormat();
|
||||
const expiresAt = NOW + 42 * 60_000;
|
||||
expect(format({ warm: true, ttl: '1h', expiresAt, recacheTokensIfCold: 109833 }, NOW)).toBe(
|
||||
`Warm until ${clock(expiresAt)} (1h cache). About 110k tokens would be re-read if it goes cold.`
|
||||
`Warm until ${clock(expiresAt)} (1h cache). About 110k tokens would be re-cached if it goes cold.`
|
||||
);
|
||||
expect(format({ warm: true, expiresAt }, NOW)).toBe(`Warm until ${clock(expiresAt)}.`);
|
||||
});
|
||||
@@ -57,10 +57,10 @@ describe('Prompt cache readout', () => {
|
||||
it('cold once reported cold, past the expiry, or warm with no expiry to promise', () => {
|
||||
const format = loadFormat();
|
||||
expect(format({ warm: false, recacheTokensIfCold: 89882 }, NOW)).toBe(
|
||||
'Cold. The next turn re-reads about 90k tokens at the cache-write rate.'
|
||||
'Cold. The next turn re-caches about 90k tokens.'
|
||||
);
|
||||
expect(format({ warm: true, expiresAt: NOW - 1, recacheTokensIfCold: 500 }, NOW)).toBe(
|
||||
'Cold. The next turn re-reads about 500 tokens at the cache-write rate.'
|
||||
'Cold. The next turn re-caches about 500 tokens.'
|
||||
);
|
||||
expect(format({ warm: true }, NOW)).toBe('Cold.');
|
||||
expect(format({ warm: false }, NOW)).toBe('Cold.');
|
||||
@@ -70,22 +70,20 @@ describe('Prompt cache readout', () => {
|
||||
const format = loadFormat();
|
||||
const expiresAt = NOW + 42 * 60_000;
|
||||
expect(format({ warm: true, ttl: '1h', expiresAt, recacheTokensIfCold: 50000, hitRatio: 0.91 }, NOW)).toBe(
|
||||
`Warm until ${clock(expiresAt)} (1h cache). About 50k tokens would be re-read if it goes cold. 91% cache hit rate.`
|
||||
`Warm until ${clock(expiresAt)} (1h cache). About 50k tokens would be re-cached if it goes cold. 91% cache hit rate.`
|
||||
);
|
||||
expect(
|
||||
format({ warm: false, recacheTokensIfCold: 90000, hitRatio: 0.6, lastMissCauses: ['ttl_expired_1h'] }, NOW)
|
||||
).toBe(
|
||||
'Cold. The next turn re-reads about 90k tokens at the cache-write rate. 60% cache hit rate. Last miss: ttl_expired_1h.'
|
||||
);
|
||||
).toBe('Cold. The next turn re-caches about 90k tokens. 60% cache hit rate. Last miss: ttl_expired_1h.');
|
||||
});
|
||||
|
||||
it("names Claude Code's own idle compaction, with no re-read stake while the prefix is still warm", () => {
|
||||
it('names a compaction, with no re-cache stake while the prefix is still warm', () => {
|
||||
const format = loadFormat();
|
||||
const expiresAt = NOW + 42 * 60_000;
|
||||
const compactedAt = NOW - 6 * 60_000;
|
||||
// recacheTokensIfCold is null right after a compaction, so no "would be re-read" clause.
|
||||
// recacheTokensIfCold is null right after a compaction, so no "would be re-cached" clause.
|
||||
expect(format({ warm: true, ttl: '1h', expiresAt, compactedAt, hitRatio: 0.88 }, NOW)).toBe(
|
||||
`Warm until ${clock(expiresAt)} (1h cache). Compacted at ${clock(compactedAt)}. 88% cache hit rate.`
|
||||
`Warm until ${clock(expiresAt)} (1h cache). Context compacted at ${clock(compactedAt)}. 88% cache hit rate.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -215,9 +215,9 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => {
|
||||
it('broadcasts the STORED prompt-cache object, so a compaction stamp reaches the live readout', async () => {
|
||||
const session = realSession('claude', 'claude-compact');
|
||||
const expiresAtS = Math.floor(Date.now() / 1000) + 3600;
|
||||
// A normal warm report (rebuild baseline 0), then Claude Code's idle compaction: recache
|
||||
// goes null (absent), expected_rebuilds rises above the known baseline, still warm. The
|
||||
// session stamps compactedAt in setPromptCache.
|
||||
// The order Claude Code really reports in: a warm report with 500k at stake, then
|
||||
// `/compact` finishes (recache null, expected_rebuilds unchanged), and the session stamps
|
||||
// compactedAt in setPromptCache on entering that window.
|
||||
await post({
|
||||
sessionId: 'claude-compact',
|
||||
data: {
|
||||
@@ -232,7 +232,7 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => {
|
||||
});
|
||||
await post({
|
||||
sessionId: 'claude-compact',
|
||||
data: { prompt_cache: { warm: true, ttl: '1h', expires_at: expiresAtS, expected_rebuilds: 1 } },
|
||||
data: { prompt_cache: { warm: true, ttl: '1h', expires_at: expiresAtS, expected_rebuilds: 0 } },
|
||||
});
|
||||
expect(typeof session.toState().promptCache?.compactedAt).toBe('number');
|
||||
// The broadcast must carry the stored object, not just the freshly parsed one, or the
|
||||
@@ -243,4 +243,14 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => {
|
||||
expect(last).toMatchObject({ sessionId: 'claude-compact', warm: true });
|
||||
expect(typeof last?.compactedAt).toBe('number');
|
||||
});
|
||||
|
||||
it('persists a changed prompt-cache report, and only a changed one', async () => {
|
||||
const session = realSession('claude', 'claude-persist');
|
||||
const data = { prompt_cache: { warm: true, ttl: '1h', expires_at: Math.floor(Date.now() / 1000) + 3600 } };
|
||||
await post({ sessionId: 'claude-persist', data });
|
||||
expect(h.ctx.persistSessionState).toHaveBeenCalledWith(session);
|
||||
h.ctx.persistSessionState.mockClear();
|
||||
await post({ sessionId: 'claude-persist', data });
|
||||
expect(h.ctx.persistSessionState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* Session.setPromptCache's compaction detection and the restore of the last report.
|
||||
*
|
||||
* The statusline never reports WHEN Claude Code's own idle compaction happened, so the
|
||||
* session stamps it: `recacheTokensIfCold` null (the transient post-compaction window the
|
||||
* CC statusline docs describe) while `expectedRebuilds` rose above the last report. The
|
||||
* stamp drives the "compacted at HH:MM" readout; it is never an alert. A rise is only a rise
|
||||
* against a KNOWN prior counter, so a first high report never invents one. The last report is
|
||||
* restored through the constructor (keeping an old compaction stamp, never re-stamping at
|
||||
* restart time), and a conversation switch drops it.
|
||||
* The statusline never reports WHEN the context was compacted, so the session stamps it.
|
||||
* Claude Code's tracker (2.1.294-2.1.296) opens a window at a compaction or a tool-result
|
||||
* clearing in which `recacheTokensIfCold` reads null, and closes it on the next request,
|
||||
* which is also the one that counts as an expected rebuild. So `expectedRebuilds` never rises
|
||||
* inside the window, and the stamp fires on ENTERING it: a warm null report after one that
|
||||
* carried a value. It is carried through idle re-reports, dropped at the next request, and a
|
||||
* first report already inside the window never invents one. The fixtures follow that real
|
||||
* order. The last report is restored through the constructor (keeping an old stamp, never
|
||||
* re-stamping at restart time), and a conversation switch drops it.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
@@ -20,78 +22,50 @@ function claudeSession(extra: Record<string, unknown> = {}): Session {
|
||||
describe('Session.setPromptCache compaction detection', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('stamps compactedAt only when recache went null and expected_rebuilds rose', () => {
|
||||
it('stamps when /compact finishes, keeps the stamp through idle re-reports, drops it at the next request', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-10-06T22:05:00Z'));
|
||||
const s = claudeSession();
|
||||
const expiresAt = Date.now() + 3600_000;
|
||||
|
||||
// A normal warm report establishes the rebuild baseline (known 0): recache known, no
|
||||
// compaction yet. Claude reports expected_rebuilds from the first response, so a real
|
||||
// baseline always carries it.
|
||||
s.setPromptCache({
|
||||
warm: true,
|
||||
ttl: '1h',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
expectedRebuilds: 0,
|
||||
recacheTokensIfCold: 50000,
|
||||
});
|
||||
// 1. Warm, 500k at stake.
|
||||
s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 0, recacheTokensIfCold: 500000 });
|
||||
expect(s.promptCache?.compactedAt).toBeUndefined();
|
||||
|
||||
// Claude Code compacts: recache goes null (absent after parsing), expected_rebuilds rises,
|
||||
// the prefix stays warm. This is the one transition that stamps.
|
||||
// 2. `/compact` finishes: recache null (absent after parsing), expected_rebuilds unchanged.
|
||||
vi.setSystemTime(new Date('2026-10-06T22:31:00Z'));
|
||||
s.setPromptCache({ warm: true, ttl: '1h', expiresAt: Date.now() + 3600_000, expectedRebuilds: 1 });
|
||||
s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 0 });
|
||||
expect(s.promptCache?.compactedAt).toBe(Date.parse('2026-10-06T22:31:00Z'));
|
||||
});
|
||||
|
||||
it('carries the stamp forward through the transient window, drops it once a real request re-caches', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-10-06T22:30:00Z'));
|
||||
const s = claudeSession();
|
||||
// Baseline (known rebuild count) then the compaction, so the rise is against a known prior.
|
||||
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 0, recacheTokensIfCold: 70000 });
|
||||
vi.setSystemTime(new Date('2026-10-06T22:31:00Z'));
|
||||
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 1 });
|
||||
const stampedAt = s.promptCache?.compactedAt;
|
||||
expect(stampedAt).toBe(Date.parse('2026-10-06T22:31:00Z'));
|
||||
|
||||
// A later idle report, still no request since the compaction: same rebuild count, recache
|
||||
// still null. The stamp is kept (not re-stamped), so the clock in the readout does not move.
|
||||
// An idle re-report inside the same window: the stamp is carried, not re-stamped.
|
||||
vi.setSystemTime(new Date('2026-10-06T22:45:00Z'));
|
||||
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 1 });
|
||||
expect(s.promptCache?.compactedAt).toBe(stampedAt);
|
||||
s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 0 });
|
||||
expect(s.promptCache?.compactedAt).toBe(Date.parse('2026-10-06T22:31:00Z'));
|
||||
|
||||
// The next real request records the rewritten size (recache non-null): the window is over.
|
||||
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 1, recacheTokensIfCold: 42000 });
|
||||
// 3. The next request closes the window: expected_rebuilds 1, recache 40k.
|
||||
s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 1, recacheTokensIfCold: 40000 });
|
||||
expect(s.promptCache?.compactedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never stamps a plain miss (recache present) or an unchanged rebuild count', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-10-06T22:31:00Z'));
|
||||
it('never stamps a first report that is already inside the window', () => {
|
||||
// The first report Codeman sees (a restart mid-window) has no prior value to leave.
|
||||
const s = claudeSession();
|
||||
// expected_rebuilds rose but recache is present: the transient window was already missed.
|
||||
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 });
|
||||
expect(s.promptCache?.compactedAt).toBeUndefined();
|
||||
// Nor does a later report in the same window: the stamp needs a known value before it.
|
||||
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 });
|
||||
expect(s.promptCache?.compactedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never stamps a cold report or a report that still carries the stake', () => {
|
||||
const s = claudeSession();
|
||||
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, recacheTokensIfCold: 60000 });
|
||||
// Cold with no stake: an expired cache, not a compaction.
|
||||
s.setPromptCache({ warm: false, expectedRebuilds: 0 });
|
||||
expect(s.promptCache?.compactedAt).toBeUndefined();
|
||||
// A rebuild count that rose with the stake present is the request after a window, not one.
|
||||
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 2, recacheTokensIfCold: 60000 });
|
||||
expect(s.promptCache?.compactedAt).toBeUndefined();
|
||||
// recache null but the rebuild count did not rise: not a fresh compaction.
|
||||
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 2 });
|
||||
expect(s.promptCache?.compactedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never invents a stamp without a KNOWN prior rebuild count (a rise needs two known reports)', () => {
|
||||
// The very first report Codeman sees already carries a high count with recache null (a
|
||||
// restart mid-session). Treating an absent prior as 0 would fabricate "compacted now".
|
||||
const s1 = claudeSession();
|
||||
s1.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 });
|
||||
expect(s1.promptCache?.compactedAt).toBeUndefined();
|
||||
|
||||
// `4 -> omitted -> 4`: a report that drops the counter clears the known prior, so the next
|
||||
// report carrying 4 again is not a provable rise.
|
||||
const s2 = claudeSession();
|
||||
s2.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4, recacheTokensIfCold: 10 });
|
||||
s2.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000 }); // counter omitted
|
||||
s2.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 });
|
||||
expect(s2.promptCache?.compactedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports changed only when the stored object differs', () => {
|
||||
@@ -120,9 +94,9 @@ describe('prompt-cache report survives a restart', () => {
|
||||
});
|
||||
|
||||
it('restores a post-compaction value WITHOUT re-stamping it at restart time', () => {
|
||||
// A direct restore, not a setPromptCache call: an already-compacted report (recache absent,
|
||||
// expectedRebuilds > 0, an OLD compactedAt) must keep its original timestamp through
|
||||
// construction, never be re-stamped with "now" as a fresh compaction.
|
||||
// A direct restore, not a setPromptCache call: a report from inside the window (recache
|
||||
// absent, an OLD compactedAt) must keep its original timestamp through construction,
|
||||
// never be re-stamped with "now" as a fresh compaction.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-10-06T23:59:00Z'));
|
||||
const oldStamp = Date.parse('2026-10-06T22:31:00Z');
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('buildSpawnCommand statusLineCommand (claude mode)', () => {
|
||||
});
|
||||
|
||||
// Why a launch does or does not carry the exporter. Recorded on the mux record and
|
||||
// published as `statusLineTelemetry`, so the Status row and keep-warm can say why no
|
||||
// published as `statusLineExporter`, so the Status row can say why no
|
||||
// cache report will come instead of "not reported yet" forever.
|
||||
describe('TmuxManager statusLine classification', () => {
|
||||
type Resolve = (
|
||||
|
||||
Reference in new issue
Block a user