From fe09a29d0ecf520deef13c41d57049c323b9d5de Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 04:38:41 -0700 Subject: [PATCH 1/7] fix(agent-activity): cache a context id only after its line is written (#1303) Co-Authored-By: Claude Opus 5.5 --- .../agentActivity/AgentActivityStore.test.ts | 22 +++++++++++++++++++ src/main/agentActivity/AgentActivityStore.ts | 11 +++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/main/agentActivity/AgentActivityStore.test.ts b/src/main/agentActivity/AgentActivityStore.test.ts index a4c1eab99..1ce886102 100644 --- a/src/main/agentActivity/AgentActivityStore.test.ts +++ b/src/main/agentActivity/AgentActivityStore.test.ts @@ -124,3 +124,25 @@ describe('AgentActivityStore', () => { expect(keys.size).toBe(1) }) }) + +// #1303: the context id was cached BEFORE its context line was written. One +// failed append (ENOSPC, EIO) then left every later interval for that agent +// this month pointing at a context line that never reached disk, and +// readIntervals dropped each one silently. +describe('a failed context write', () => { + it('does not orphan the agent\'s later intervals', async () => { + const store = new AgentActivityStore(dir) + const internal = store as unknown as { appendLines: (file: string, lines: string[]) => Promise } + const realAppend = internal.appendLines.bind(store) + let failNext = true + internal.appendLines = async (file, lines) => { + if (failNext) { failNext = false; throw Object.assign(new Error('no space left'), { code: 'ENOSPC' }) } + return realAppend(file, lines) + } + const start = Date.parse('2026-09-01T09:00:00Z') + await expect(store.appendInterval({ context, startedAt: start, endedAt: start + HOUR })).rejects.toThrow('no space left') + await store.appendInterval({ context, startedAt: start + 2 * HOUR, endedAt: start + 3 * HOUR }) + const read = await new AgentActivityStore(dir).readIntervals(start, start + 4 * HOUR) + expect(read.map(interval => interval.startedAt)).toEqual([start + 2 * HOUR]) + }) +}) diff --git a/src/main/agentActivity/AgentActivityStore.ts b/src/main/agentActivity/AgentActivityStore.ts index 49d69572d..f73a41857 100644 --- a/src/main/agentActivity/AgentActivityStore.ts +++ b/src/main/agentActivity/AgentActivityStore.ts @@ -154,15 +154,24 @@ export class AgentActivityStore { const key = contextKey(interval.context) const lines: string[] = [] let id = ids.get(key) + const isNewContext = id === undefined if (id === undefined) { id = ids.size + 1 - ids.set(key, id) const contextLine: ContextLine = { t: 'c', c: id, ...interval.context } lines.push(JSON.stringify(contextLine)) } const intervalLine: IntervalLine = { t: 'i', c: id, s: interval.startedAt, e: interval.endedAt } lines.push(JSON.stringify(intervalLine)) await this.appendLines(join(this.dir, `${month}.jsonl`), lines) + // Cache the id only once its context line is on disk (#1303). Caching it + // first meant one failed append (ENOSPC, EIO) left every later interval + // for this agent this month pointing at a context line that never + // landed, and readIntervals drops an interval with no context. Not + // caching on failure means the next interval re-mints the same id + // (`ids.size + 1` is unchanged) and writes the context line again. If + // the failed append landed partially, a duplicate context line with the + // same id and content is harmless on read. + if (isNewContext) ids.set(key, id) }) } From 8755ce23f7f18dc52a0c2bf6b9dbe0adf702fdd5 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 05:44:57 -0700 Subject: [PATCH 2/7] fix(agent-activity): never reissue a context id after a failed or partial write (#1414 review a+b) Co-Authored-By: Claude Opus 5.5 --- .../agentActivity/AgentActivityStore.test.ts | 30 +++++++++++++++++++ src/main/agentActivity/AgentActivityStore.ts | 23 ++++++++++---- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/main/agentActivity/AgentActivityStore.test.ts b/src/main/agentActivity/AgentActivityStore.test.ts index 1ce886102..2b629ba14 100644 --- a/src/main/agentActivity/AgentActivityStore.test.ts +++ b/src/main/agentActivity/AgentActivityStore.test.ts @@ -146,3 +146,33 @@ describe('a failed context write', () => { expect(read.map(interval => interval.startedAt)).toEqual([start + 2 * HOUR]) }) }) + +// #1414 review a+b: a PARTIAL write (A's context line lands, then the append +// fails before its interval line) left id 1 on disk for A. A was not cached, so +// B's next context also took id 1; after a restart a later A interval reused +// id 1 and read back as B's time. +describe('a partially written context', () => { + it('never lets another agent reuse its id', async () => { + const store = new AgentActivityStore(dir) + const internal = store as unknown as { appendLines: (file: string, lines: string[]) => Promise } + const realAppend = internal.appendLines.bind(store) + let partial = true + internal.appendLines = async (file, lines) => { + if (partial) { + partial = false + await appendFile(file, lines[0] + '\n') + throw Object.assign(new Error('no space left'), { code: 'ENOSPC' }) + } + return realAppend(file, lines) + } + const start = Date.parse('2026-09-01T09:00:00Z') + const a = { ...context, agentKey: 'A', label: 'A' } + const b = { ...context, agentKey: 'B', label: 'B' } + await expect(store.appendInterval({ context: a, startedAt: start, endedAt: start + HOUR })).rejects.toThrow('no space left') + await store.appendInterval({ context: b, startedAt: start + HOUR, endedAt: start + 2 * HOUR }) + const restarted = new AgentActivityStore(dir) + await restarted.appendInterval({ context: a, startedAt: start + 2 * HOUR, endedAt: start + 3 * HOUR }) + const read = await new AgentActivityStore(dir).readIntervals(start, start + 4 * HOUR) + expect(read.map(interval => interval.context.agentKey)).toEqual(['B', 'A']) + }) +}) diff --git a/src/main/agentActivity/AgentActivityStore.ts b/src/main/agentActivity/AgentActivityStore.ts index f73a41857..1584fea02 100644 --- a/src/main/agentActivity/AgentActivityStore.ts +++ b/src/main/agentActivity/AgentActivityStore.ts @@ -115,6 +115,15 @@ export class AgentActivityStore { private readonly cleanTails = new Set() /** Context ids already written to each month file this process has touched. */ private readonly monthContexts = new Map>() + /** + * The next context id to mint per month (#1414 review). Minting always + * advances it, even when the write then fails, so an id is never issued + * twice: a PARTIAL write can leave a context line on disk for an id whose + * mapping was never cached, and reusing that id for another agent made the + * later reads attribute one agent's time to the other. Loaded as the + * file's highest id + 1. + */ + private readonly monthNextId = new Map() constructor(private readonly dir: string) {} @@ -132,9 +141,11 @@ export class AgentActivityStore { const known = this.monthContexts.get(month) if (known) return known const ids = new Map() + let highest = 0 try { for (const line of parseJsonLines(await readFile(join(this.dir, `${month}.jsonl`), 'utf8'))) { if (line.t !== 'c' || !isNumber(line.c)) continue + highest = Math.max(highest, line.c) const context = parseContext(line) if (context) ids.set(contextKey(context), line.c) } @@ -142,6 +153,7 @@ export class AgentActivityStore { // No file yet for this month. } this.monthContexts.set(month, ids) + this.monthNextId.set(month, highest + 1) return ids } @@ -156,7 +168,8 @@ export class AgentActivityStore { let id = ids.get(key) const isNewContext = id === undefined if (id === undefined) { - id = ids.size + 1 + id = this.monthNextId.get(month) ?? ids.size + 1 + this.monthNextId.set(month, id + 1) const contextLine: ContextLine = { t: 'c', c: id, ...interval.context } lines.push(JSON.stringify(contextLine)) } @@ -167,10 +180,10 @@ export class AgentActivityStore { // first meant one failed append (ENOSPC, EIO) left every later interval // for this agent this month pointing at a context line that never // landed, and readIntervals drops an interval with no context. Not - // caching on failure means the next interval re-mints the same id - // (`ids.size + 1` is unchanged) and writes the context line again. If - // the failed append landed partially, a duplicate context line with the - // same id and content is harmless on read. + // caching on failure means the next interval for this agent mints a + // NEW id (monthNextId already advanced) and writes its context line + // again. The failed id is burned: if its line landed partially, it + // still names this agent, and no other agent is ever given that id. if (isNewContext) ids.set(key, id) }) } From 4c64d3d1355b790c7a221e0bce3742de9f497287 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 11:59:01 -0700 Subject: [PATCH 3/7] fix(agent-activity): an unreadable month file refuses the append instead of restarting ids (#1303, #1414 review a round 2) Co-Authored-By: Claude Opus 5.5 --- .../agentActivity/AgentActivityStore.test.ts | 58 ++++++++++++++++++- src/main/agentActivity/AgentActivityStore.ts | 11 +++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/main/agentActivity/AgentActivityStore.test.ts b/src/main/agentActivity/AgentActivityStore.test.ts index 2b629ba14..8ad550480 100644 --- a/src/main/agentActivity/AgentActivityStore.test.ts +++ b/src/main/agentActivity/AgentActivityStore.test.ts @@ -1,4 +1,4 @@ -import { appendFile, mkdtemp, readFile, rm } from 'node:fs/promises' +import { appendFile, chmod, mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -176,3 +176,59 @@ describe('a partially written context', () => { expect(read.map(interval => interval.context.agentKey)).toEqual(['B', 'A']) }) }) + +// #1414 review a round 2 (q115, "unknown is never empty"): an existing month +// file that cannot be READ was treated as absent, so a restarted store started +// ids at 1 and gave a second agent the id the first agent's lines already use. +// Once readable again, the first agent's later hours read back as the second +// agent's. Only ENOENT means "no file yet"; any other failure refuses the +// append, and the bytes stay as they were. +describe('an unreadable month file', () => { + it('refuses the append instead of restarting ids, and ids continue once it is readable', async () => { + const start = Date.parse('2026-09-01T09:00:00Z') + const a = { ...context, agentKey: 'A', label: 'A' } + const b = { ...context, agentKey: 'B', label: 'B' } + await new AgentActivityStore(dir).appendInterval({ context: a, startedAt: start, endedAt: start + HOUR }) + const file = join(dir, '2026-09.jsonl') + const before = await readFile(file) + await chmod(file, 0o200) + try { + await expect(new AgentActivityStore(dir).appendInterval({ context: b, startedAt: start + HOUR, endedAt: start + 2 * HOUR })).rejects.toThrow() + } finally { + await chmod(file, 0o600) + } + expect(await readFile(file)).toEqual(before) + const restarted = new AgentActivityStore(dir) + await restarted.appendInterval({ context: b, startedAt: start + HOUR, endedAt: start + 2 * HOUR }) + await restarted.appendInterval({ context: a, startedAt: start + 2 * HOUR, endedAt: start + 3 * HOUR }) + const read = await new AgentActivityStore(dir).readIntervals(start, start + 4 * HOUR) + expect(read.map(interval => interval.context.agentKey)).toEqual(['A', 'B', 'A']) + }) +}) + +// #1414 review b round 2 (test gap): a failed write that wrote NOTHING still +// consumed its id, and a restart must continue from the highest id on disk, +// not from the count of contexts (which would reissue a live id). +describe('an id gap left by a failed write', () => { + it('is never filled by a later context after a restart', async () => { + const store = new AgentActivityStore(dir) + const internal = store as unknown as { appendLines: (file: string, lines: string[]) => Promise } + const realAppend = internal.appendLines.bind(store) + let failNext = true + internal.appendLines = async (file, lines) => { + if (failNext) { failNext = false; throw Object.assign(new Error('no space left'), { code: 'ENOSPC' }) } + return realAppend(file, lines) + } + const start = Date.parse('2026-09-01T09:00:00Z') + const a = { ...context, agentKey: 'A', label: 'A' } + const b = { ...context, agentKey: 'B', label: 'B' } + const c = { ...context, agentKey: 'C', label: 'C' } + await expect(store.appendInterval({ context: a, startedAt: start, endedAt: start + HOUR })).rejects.toThrow('no space left') + await store.appendInterval({ context: b, startedAt: start + HOUR, endedAt: start + 2 * HOUR }) + const restarted = new AgentActivityStore(dir) + await restarted.appendInterval({ context: c, startedAt: start + 2 * HOUR, endedAt: start + 3 * HOUR }) + await restarted.appendInterval({ context: b, startedAt: start + 3 * HOUR, endedAt: start + 4 * HOUR }) + const read = await new AgentActivityStore(dir).readIntervals(start, start + 5 * HOUR) + expect(read.map(interval => interval.context.agentKey)).toEqual(['B', 'C', 'B']) + }) +}) diff --git a/src/main/agentActivity/AgentActivityStore.ts b/src/main/agentActivity/AgentActivityStore.ts index 1584fea02..9e4ffc722 100644 --- a/src/main/agentActivity/AgentActivityStore.ts +++ b/src/main/agentActivity/AgentActivityStore.ts @@ -149,8 +149,15 @@ export class AgentActivityStore { const context = parseContext(line) if (context) ids.set(contextKey(context), line.c) } - } catch { - // No file yet for this month. + } catch (error) { + // Only a missing file means "no contexts yet" (#1414 review a round 2, + // q115 "unknown is never empty"). A file that exists but cannot be read + // was treated as empty: ids restarted at 1, a second agent got the id + // the first agent's lines already use, and once readable the first + // agent's later hours read back as the second's. Refuse the append + // instead (the interval is lost, as for any failed write); nothing is + // cached, so the next append reads again. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } this.monthContexts.set(month, ids) this.monthNextId.set(month, highest + 1) From 995e993c1f779d82ddb003acbd97ca824953bee9 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 12:03:11 -0700 Subject: [PATCH 4/7] fix(agent-activity): an unreadable open-interval snapshot is set aside and retried, never overwritten (#1414 review a round 3) Recovery treated any failure as 'no open file' and overwrote open.json with an empty snapshot, losing the pending interval. Only ENOENT is empty now; anything else is moved to open.json.unrecovered-