fix(native-chat): preserve background task ownership across restarts

This commit is contained in:
Brennan Benson
2026-09-13 23:41:12 -07:00
parent e39a25adc1
commit d3ad77d885
4 changed files with 121 additions and 5 deletions
@@ -80,6 +80,7 @@ export function taskName(frame: Record<string, unknown>): string | undefined {
export function classifyClaudeBackgroundTaskKind(taskType: unknown): ClaudeBackgroundTaskKind {
switch (taskType) {
case 'local_agent':
case 'local_subagent':
return 'agent'
case 'local_workflow':
return 'workflow'
@@ -183,6 +183,25 @@ describe('claude background task rows', () => {
expect(items).toEqual([])
})
it('leaves legacy local_subagent tasks to the subagent roster', () => {
const { rows, items } = harness()
rows.observe({
type: 'system',
subtype: 'task_started',
task_id: 'task-legacy-agent',
task_type: 'local_subagent',
subagent_type: 'explorer'
})
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'task-legacy-agent',
status: 'failed',
summary: 'the child failed'
})
expect(items).toEqual([])
})
it('writes nothing for ambient housekeeping the user never asked for', () => {
const { rows, items } = harness()
rows.observe({ ...START_BASH, task_id: 'ambient-1', ambient: true })
@@ -216,6 +235,25 @@ describe('claude background task rows', () => {
expect(items).toEqual([])
})
it('keeps terminal ownership after a tracked task reports foregrounded', () => {
const { rows, latest } = harness()
rows.observe(START_BASH)
rows.observe({
type: 'system',
subtype: 'task_updated',
task_id: START_BASH.task_id,
patch: { is_backgrounded: false, status: 'running' }
})
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: START_BASH.task_id,
status: 'failed',
summary: 'foreground transition failed'
})
expect(latest()).toMatchObject({ state: 'blocked', summary: 'foreground transition failed' })
})
it('revises in place rather than opening a row from a patch', () => {
const { rows, items, latest } = harness()
rows.observe({
@@ -454,6 +492,42 @@ describe('claude background task rows', () => {
})
})
it('reopens a settled task after its row was evicted when the parent alias changes', () => {
const { rows, keys, latest } = harness([FORWARDED_TOOL, 'toolu_second'])
rows.observe({ ...START_BASH, task_id: 'evicted-restart' })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: 'evicted-restart',
tool_use_id: FORWARDED_TOOL,
status: 'completed'
})
for (let index = 0; index < 63; index += 1) {
rows.observe({ ...START_BASH, task_id: `settled-${index}` })
rows.observe({
type: 'system',
subtype: 'task_notification',
task_id: `settled-${index}`,
status: 'completed'
})
}
// The first settled row is evicted to make room for this one.
rows.observe({ ...START_BASH, task_id: 'evictor' })
rows.observe({
...START_BASH,
task_id: 'evicted-restart',
tool_use_id: 'toolu_second',
description: 'second invocation'
})
expect([...new Set(keys())]).toContain('claude-background-task:evicted-restart#2')
expect(latest()).toMatchObject({
taskId: 'evicted-restart',
state: 'working',
label: 'second invocation'
})
})
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
+32 -5
View File
@@ -55,6 +55,10 @@ export class ClaudeBackgroundTaskRows {
private readonly generations = new Map<string, number>()
private readonly foreign = new Map<string, ForeignOwner>()
private readonly terminalTaskIds = new Set<string>()
/** The parent alias for the terminal run, when one was reported. Keeping it
* lets an evicted row distinguish a late duplicate start from a genuine
* restart under a fresh tool invocation. */
private readonly terminalToolUseIds = new Map<string, string | undefined>()
private readonly ids = new ClaudeSubagentIds()
private readonly now: () => number
@@ -106,6 +110,7 @@ export class ClaudeBackgroundTaskRows {
this.generations.clear()
this.foreign.clear()
this.terminalTaskIds.clear()
this.terminalToolUseIds.clear()
this.ids.clear()
}
@@ -152,8 +157,21 @@ export class ClaudeBackgroundTaskRows {
}
return true
}
let restartedTerminal = false
if (this.terminalTaskIds.has(id)) {
return true
const previousToolUseId = this.terminalToolUseIds.get(id)
const currentToolUseId = claudeBackgroundTaskToolUseId(message)
// A terminal edge that had no usable tool id cannot prove a later start
// is a new run, so keep the conservative orphan guard. When both runs
// name their parent, a different alias is the provider's restart signal.
if (
previousToolUseId === undefined ||
currentToolUseId === undefined ||
previousToolUseId === currentToolUseId
) {
return true
}
restartedTerminal = true
}
if (!this.admitsFirstRun(message)) {
return true
@@ -161,6 +179,10 @@ export class ClaudeBackgroundTaskRows {
if (!this.ensureRowSlot()) {
return false
}
if (restartedTerminal) {
this.terminalTaskIds.delete(id)
this.terminalToolUseIds.delete(id)
}
this.openRow(id, message, (this.generations.get(id) ?? 0) + 1)
return true
}
@@ -188,7 +210,7 @@ export class ClaudeBackgroundTaskRows {
// Remembered even for a task never admitted: Orca is deliberately stricter
// than the reference here, which keeps no trace of one. It stops a late
// announcement from opening a row for work already reported finished.
this.rememberTerminalId(id)
this.rememberTerminalId(id, claudeBackgroundTaskToolUseId(message))
if (!this.rows.has(id)) {
// Matched on `task_id` alone. A terminal frame for a task that was never
// admitted names nothing this transcript is tracking, so it yields no
@@ -202,13 +224,16 @@ export class ClaudeBackgroundTaskRows {
private observePatch(id: string, message: Record<string, unknown>): boolean {
const patch = record(message.patch)
if (patch?.is_backgrounded === false) {
// A tracked row remains this owner's responsibility even if a later patch
// reports foreground execution; its terminal notification still revises
// the durable row. Only an untracked task belongs to the foreground owner.
if (patch?.is_backgrounded === false && !this.rows.has(id)) {
this.rememberForeign(id, 'foreground')
return true
}
const change = claudeBackgroundTaskPatchChange(message)
if (change.state && isSettledBackgroundTaskState(change.state)) {
this.rememberTerminalId(id)
this.rememberTerminalId(id, claudeBackgroundTaskToolUseId(message))
}
// A patch is folded into the row it names and is never a row of its own, so
// an untracked task takes no row from it.
@@ -286,16 +311,18 @@ export class ClaudeBackgroundTaskRows {
}
}
private rememberTerminalId(id: string): void {
private rememberTerminalId(id: string, toolUseId?: string): void {
if (this.terminalTaskIds.has(id)) {
this.terminalTaskIds.delete(id)
}
this.terminalTaskIds.add(id)
this.terminalToolUseIds.set(id, toolUseId ?? this.rows.get(id)?.toolUseId)
while (this.terminalTaskIds.size > MAX_TERMINAL_TASK_IDS) {
const oldest = this.terminalTaskIds.values().next()
if (oldest.done || oldest.value === id) {
break
}
this.terminalToolUseIds.delete(oldest.value)
this.terminalTaskIds.delete(oldest.value)
}
}
@@ -33,6 +33,20 @@ describe('readClaudeSubagentTaskFrame', () => {
})
})
it('announces the legacy local_subagent task type', () => {
expect(
readClaudeSubagentTaskFrame(
system('task_started', {
task_id: 'task-legacy-subagent',
tool_use_id: 'toolu_legacy',
task_type: 'local_subagent',
subagent_type: 'code-reviewer',
description: 'Review the diff'
})
)
).toMatchObject({ announcesSubagent: true, excluded: false })
})
it('excludes a backgrounded shell command even though it carries a tool_use_id', () => {
const frame = readClaudeSubagentTaskFrame(
system('task_started', {