fix(sessions): log an unreadable settings.json once and retry only sessions that can nudge

Merge-time fix for #666. The strict settings reader plus the retry after a
throw is right for a read that lands mid-write, but a settings.json that
stays broken (a hand edit with a trailing comma) made every session, keep-warm
off included, re-read the file and log a line every 60 seconds until it was
fixed (11 reads and 11 log lines in ten simulated minutes for one session).

The settings read now fails inside `evaluate()`: a given failure is logged
once until a read succeeds, and the retry is armed only for a session that
could nudge (its choice not `false`, the exporter `injected`, a warm report).
Other throws keep the generic retry in `schedule()`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:10:26 +02:00
1 parent e408fa21bc
commit f334b59dd3
3 files changed
+108 -4

No files matched your search

+1 -1
View File
@@ -161,7 +161,7 @@ Further detail: the handheld OFF default comes from the mobile block in `getDefa
### Prompt-cache keep-warm
**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its Line truncated
**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its Line truncated
### Cron jobs