From a9665f52121ee20a0e98afbf64d2b379a607b407 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:45:21 -0700 Subject: [PATCH] fix(claude): bound task generation history --- .../claude/claude-background-task-memory.ts | 38 +++++++++++++++++++ .../claude-background-task-rows.test.ts | 33 ++++++++++++++++ .../claude/claude-background-task-rows.ts | 17 ++++----- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/main/claude/claude-background-task-memory.ts b/src/main/claude/claude-background-task-memory.ts index a221fd7ea22..b2330dae414 100644 --- a/src/main/claude/claude-background-task-memory.ts +++ b/src/main/claude/claude-background-task-memory.ts @@ -1,6 +1,44 @@ import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row' import type { ClaudeBackgroundTaskRow } from './claude-background-task-row-lifecycle' +const MAX_GENERATION_ENTRIES = 512 + +/** Bounded run identity ledger. Once old ids fall out, a monotonic sequence + * keeps a reused id from colliding with a durable row already in the journal. */ +export class ClaudeBackgroundTaskGenerationLedger { + private readonly entries = new Map() + private nextUniqueGeneration = 1 + private evicted = false + + next(id: string): number { + const previous = this.entries.get(id) + const generation = + previous === undefined ? (this.evicted ? this.nextUniqueGeneration++ : 1) : previous + 1 + this.entries.delete(id) + this.entries.set(id, generation) + this.nextUniqueGeneration = Math.max(this.nextUniqueGeneration, generation + 1) + while (this.entries.size > MAX_GENERATION_ENTRIES) { + const oldest = this.entries.keys().next() + if (oldest.done || oldest.value === id) { + break + } + this.entries.delete(oldest.value) + this.evicted = true + } + return generation + } + + get size(): number { + return this.entries.size + } + + clear(): void { + this.entries.clear() + this.nextUniqueGeneration = 1 + this.evicted = false + } +} + export function rememberBoundedClaudeTaskSet(ids: Set, id: string, maxSize: number): void { ids.delete(id) ids.add(id) diff --git a/src/main/claude/claude-background-task-rows.test.ts b/src/main/claude/claude-background-task-rows.test.ts index c94b3369d46..aa9c26e8ee1 100644 --- a/src/main/claude/claude-background-task-rows.test.ts +++ b/src/main/claude/claude-background-task-rows.test.ts @@ -586,6 +586,39 @@ describe('claude background task rows', () => { }) }) + it('bounds generation history without reusing an evicted durable identity', () => { + const { rows, keys, latest } = harness([FORWARDED_TOOL, 'toolu_second']) + for (let index = 0; index < 513; index += 1) { + const taskId = `generation-${index}` + rows.observe({ ...START_BASH, task_id: taskId }) + rows.observe({ + type: 'system', + subtype: 'task_notification', + task_id: taskId, + status: 'completed' + }) + } + + rows.observe({ + ...START_BASH, + task_id: 'generation-0', + tool_use_id: 'toolu_second', + description: 'reused after ledger eviction' + }) + + const identities = new Set(keys()) + expect(identities).toContain('claude-background-task:generation-0') + expect(identities).toContain('claude-background-task:generation-0#2') + expect(latest()).toMatchObject({ + taskId: 'generation-0', + state: 'working', + label: 'reused after ledger eviction' + }) + + const generations = Reflect.get(rows, 'generations') + expect(generations.size).toBeLessThanOrEqual(512) + }) + it('declines coverage so the fallback still reports when every row slot is live', () => { // The row map is bounded. A task that cannot be admitted for lack of a slot // is not silently swallowed: coverage is declined so the generic fallback diff --git a/src/main/claude/claude-background-task-rows.ts b/src/main/claude/claude-background-task-rows.ts index 960e7b32a66..db7c8736264 100644 --- a/src/main/claude/claude-background-task-rows.ts +++ b/src/main/claude/claude-background-task-rows.ts @@ -23,6 +23,7 @@ import { type ClaudeBackgroundTaskRow } from './claude-background-task-row-lifecycle' import { + ClaudeBackgroundTaskGenerationLedger, ensureClaudeBackgroundTaskRowSlot, rememberBoundedClaudeTaskMap, rememberBoundedClaudeTaskSet, @@ -64,7 +65,7 @@ export class ClaudeBackgroundTaskRows { private readonly rows = new Map() /** Runs seen per task id, so a reused id opens a new row instead of * overwriting the finished one. Survives the row being evicted. */ - private readonly generations = new Map() + private readonly generations = new ClaudeBackgroundTaskGenerationLedger() private readonly foreign = new Map() /** Tasks that were declined because every typed row slot was live. Their * later frames must remain visible through the generic fallback. */ @@ -184,7 +185,7 @@ export class ClaudeBackgroundTaskRows { return true } if (shouldRestartClaudeBackgroundTaskRow(existing, message)) { - this.openRow(id, message, existing.generation + 1) + this.openRow(id, message) } return true } @@ -208,14 +209,14 @@ export class ClaudeBackgroundTaskRows { return true } if (!ensureClaudeBackgroundTaskRowSlot(this.rows, MAX_TASK_ROWS)) { - this.rememberFallbackTaskId(id) + rememberBoundedClaudeTaskSet(this.fallbackTaskIds, id, MAX_FALLBACK_TASK_IDS) return false } if (restartedTerminal) { this.terminalTaskIds.delete(id) this.terminalToolUseIds.delete(id) } - this.openRow(id, message, (this.generations.get(id) ?? 0) + 1) + this.openRow(id, message) return true } @@ -232,8 +233,8 @@ export class ClaudeBackgroundTaskRows { return toolUseId === undefined || this.deps.isForwardedParentTool(toolUseId) } - private openRow(id: string, message: Record, generation: number): void { - this.generations.set(id, generation) + private openRow(id: string, message: Record): void { + const generation = this.generations.next(id) this.rows.set(id, newClaudeBackgroundTaskRow(id, message, this.now(), generation)) this.write(id) } @@ -349,10 +350,6 @@ export class ClaudeBackgroundTaskRows { rememberBoundedClaudeTaskMap(this.foreign, id, owner, MAX_FOREIGN_TASK_ROWS) } - private rememberFallbackTaskId(id: string): void { - rememberBoundedClaudeTaskSet(this.fallbackTaskIds, id, MAX_FALLBACK_TASK_IDS) - } - private rememberTerminal(id: string, toolUseId?: string): void { rememberClaudeBackgroundTaskTerminal( this.terminalTaskIds,