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

+34 -3
View File
@@ -153,6 +153,20 @@ export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision {
return { action: 'nudge' };
}
/**
* Whether a session could be nudged once the settings are known: its own switch is not off, its
* launch reports the cache, and the last report is warm. Only such a session has anything to
* lose while the settings cannot be read, so only it retries on its own.
*/
function couldNudge(s: KeepWarmSessionView): boolean {
return (
s.keepWarmEnabled !== false &&
s.statusLineExporter === 'injected' &&
!!s.promptCache?.warm &&
!!s.promptCache.expiresAt
);
}
/** What the service needs from a session; `Session` satisfies it structurally. */
export interface KeepWarmSessionView {
readonly id: string;
@@ -226,6 +240,8 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
* `rescheduleAll()` drops it, and the settings PUT calls that whenever either key changes.
*/
private settings: Promise<KeepWarmSettings> | undefined;
/** The last settings read failure logged, so one that persists is logged once; cleared by a good read. */
private settingsFailure: string | undefined;
private stopped = false;
constructor(private readonly ports: KeepWarmPorts) {}
@@ -252,8 +268,8 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
const run = this.evaluate(sessionId)
.catch((err) => {
this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`);
// A failed settings read or write is transient, and an idle session gets no other
// trigger until its next turn.
// A failed write or pane read is transient, and an idle session gets no other trigger
// until its next turn. (A failed settings read is handled in evaluate().)
// Not after `remove()`, which drops this run: the session is being deleted.
if (!this.stopped && this.running.get(sessionId) === run) this.arm(sessionId, Date.now() + KEEP_WARM_RETRY_MS);
})
@@ -318,7 +334,22 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
this.clearTimer(id);
const session = this.ports.getSession(id);
if (!session) return;
const settings = await this.readSettings();
let settings: KeepWarmSettings;
try {
settings = await this.readSettings();
} catch (err) {
// A read that lands mid-write is retried. A settings.json that stays broken (a hand edit
// with a trailing comma) fails every read until it is fixed, so it is logged once rather
// than once a minute per session, and only a session that could nudge retries.
const message = err instanceof Error ? err.message : String(err);
if (message !== this.settingsFailure) {
this.settingsFailure = message;
this.ports.log?.(`[KeepWarm] ${id.slice(0, 8)}: cannot read the settings: ${message}`);
}
if (!cancelled() && couldNudge(session)) this.arm(id, Date.now() + KEEP_WARM_RETRY_MS);
return;
}
this.settingsFailure = undefined;
if (cancelled()) return;
const tally = this.tallies.get(id) ?? { nudges: 0 };
const facts: KeepWarmFacts = {