fix(claude): bound task generation history

This commit is contained in:
Brennan Benson
2026-09-14 17:45:21 -07:00
parent af72858ba0
commit a9665f5212
3 changed files with 78 additions and 10 deletions
@@ -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<string, number>()
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<string>, id: string, maxSize: number): void {
ids.delete(id)
ids.add(id)
@@ -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
+7 -10
View File
@@ -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<string, ClaudeBackgroundTaskRow>()
/** 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<string, number>()
private readonly generations = new ClaudeBackgroundTaskGenerationLedger()
private readonly foreign = new Map<string, ForeignOwner>()
/** 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<string, unknown>, generation: number): void {
this.generations.set(id, generation)
private openRow(id: string, message: Record<string, unknown>): 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,