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

+73
View File
@@ -720,6 +720,79 @@ describe('PromptCacheKeepWarmService', () => {
service.stop();
});
/**
* A settings.json left broken (a hand edit with a trailing comma) fails every read until it is
* fixed. Only a session that could nudge retries on its own, and the failure is logged once
* rather than once a minute per session.
*/
it.each([
{ label: 'keep-warm off', set: (s: FakeSession) => (s.keepWarmEnabled = false) },
{ label: 'no exporter on this launch', set: (s: FakeSession) => (s.statusLineExporter = 'remote' as never) },
{ label: 'no cache report', set: (s: FakeSession) => (s.promptCache = null) },
{ label: 'a cold cache', set: (s: FakeSession) => (s.promptCache = { ...warmCache(NOW + HOUR), warm: false }) },
])('does not retry an unreadable settings file for a session that cannot nudge ($label)', async ({ set }) => {
const session = new FakeSession();
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
set(session);
let reads = 0;
const logged: string[] = [];
const { ports, published } = makePorts(session, {
readSettings: async () => {
reads += 1;
throw new Error('Unexpected token } in JSON at position 812');
},
log: (m) => logged.push(m),
});
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(10 * KEEP_WARM_RETRY_MS);
await settle();
expect(reads).toBe(1);
expect(logged).toEqual([expect.stringContaining('Unexpected token')]);
expect(published).toHaveLength(0);
service.stop();
});
it('retries an unreadable settings file every minute for a session that can nudge, logging it once', async () => {
const session = new FakeSession();
session.promptCache = warmCache(NOW + 30 * 60_000);
let reads = 0;
let fail = true;
const logged: string[] = [];
const { ports, last } = makePorts(session, {
readSettings: async () => {
reads += 1;
if (fail) throw new Error('Unexpected token } in JSON at position 812');
return { keepWarmDefault: true, maxHours: 2 };
},
log: (m) => logged.push(m),
});
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(10 * KEEP_WARM_RETRY_MS);
await settle();
expect(reads).toBe(11);
expect(logged).toEqual([expect.stringContaining('Unexpected token')]);
// Fixed: the next retry reads it and the session is kept warm from there.
fail = false;
await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS);
await settle();
expect(reads).toBe(12);
expect(last()).toMatchObject({ enabled: true, state: 'armed' });
// Broken again later: logged again, once.
fail = true;
service.rescheduleAll();
await settle();
await vi.advanceTimersByTimeAsync(3 * KEEP_WARM_RETRY_MS);
await settle();
expect(logged).toHaveLength(2);
service.stop();
});
it('runs one follow-up for triggers that land during an evaluation, and resolves after it', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;