fix(native-chat): transfer resumed background tasks to one live owner

This commit is contained in:
Merge Sim
2026-09-08 10:28:53 -07:00
parent e97ed961bd
commit f30fca2bf7
5 changed files with 155 additions and 31 deletions
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
const agent = {
task_id: 'a962f88aa82feb1c1',
task_type: 'local_agent',
description: 'Long proof writer'
}
const shell = { task_id: 'bcl6x3ixf', task_type: 'local_bash', description: 'sleep 150' }
const sibling = { task_id: 'sibling', task_type: 'local_agent' }
function system(subtype: string, fields: Record<string, unknown>) {
return { type: 'system', subtype, ...fields }
}
describe('Claude background task pause/resume ownership', () => {
it('moves a retained child back to live ownership across eviction, outcome, and auto-resume', () => {
let now = 100
const tracker = new ClaudeBackgroundTaskTracker(() => now)
const roster = (tasks: unknown[]) =>
tracker.observe(system('background_tasks_changed', { tasks }))
roster([agent, sibling, shell])
tracker.observe(
system('task_progress', { task_id: agent.task_id, usage: { total_tokens: 18000 } })
)
tracker.observe({ type: 'result' })
expect(tracker.state?.tasks).toHaveLength(3)
roster([sibling, shell])
expect(tracker.state?.settledTasks).toBeUndefined()
tracker.observe(
system('task_updated', { task_id: agent.task_id, patch: { status: 'completed' } })
)
tracker.observe(
system('task_notification', {
task_id: agent.task_id,
status: 'completed',
usage: { total_tokens: 19003 }
})
)
expect(tracker.state?.settledTasks).toEqual([
expect.objectContaining({
id: agent.task_id,
state: 'done',
startedAt: 100,
totalTokens: 19003
})
])
now = 150000
roster([agent, sibling, shell])
expect(tracker.state?.settledTasks).toBeUndefined()
expect(tracker.state?.tasks).toEqual([
expect.objectContaining({
id: agent.task_id,
state: 'working',
startedAt: 100,
totalTokens: 19003
}),
expect.objectContaining({ id: sibling.task_id }),
expect.objectContaining({ id: shell.task_id })
])
expect(tracker.stoppableTaskIds).toEqual([agent.task_id, sibling.task_id, shell.task_id])
roster([sibling, shell])
tracker.observe(
system('task_notification', {
task_id: agent.task_id,
status: 'completed',
usage: { total_tokens: 21000 }
})
)
expect(tracker.state?.settledTasks).toEqual([
expect.objectContaining({ id: agent.task_id, startedAt: 100, totalTokens: 21000 })
])
roster([])
expect(tracker.state).toBeNull()
})
it('reconciles an edge-only resume without keeping its earlier settled copy', () => {
const tracker = new ClaudeBackgroundTaskTracker(() => 100)
for (const task of [agent, sibling]) {
tracker.observe(system('task_started', { ...task, is_backgrounded: true }))
}
tracker.observe(system('task_notification', { task_id: agent.task_id, status: 'completed' }))
tracker.observe(
system('task_updated', {
task_id: agent.task_id,
patch: { status: 'running', is_backgrounded: true }
})
)
expect(tracker.state?.tasks).toHaveLength(2)
expect(tracker.state?.settledTasks).toBeUndefined()
})
})
@@ -196,8 +196,8 @@ export class ClaudeBackgroundTaskTracker {
continue
}
// An authoritative live roster supersedes an earlier terminal edge.
this.retention.forget(id)
const existing = prior.get(id)
const retained = this.retention.resume(id)
const existing = prior.get(id) ?? retained
const kind = classifyClaudeBackgroundTaskKind(task.task_type)
this.tasks.set(id, {
backgrounded: true,
@@ -217,20 +217,7 @@ export class ClaudeBackgroundTaskTracker {
}
private upsert(id: string, task: TrackedClaudeBackgroundTask): void {
const existing = this.tasks.get(id)
if (existing) {
this.tasks.set(id, {
backgrounded: existing.backgrounded || task.backgrounded,
kind: task.kind !== 'unknown' ? task.kind : existing.kind,
description: task.description ?? existing.description,
name: task.name ?? existing.name,
state: task.state ?? existing.state,
startedAt: existing.startedAt,
totalTokens: existing.totalTokens
})
return
}
if (this.tasks.size >= MAX_TRACKED_TASKS) {
if (!this.tasks.has(id) && this.tasks.size >= MAX_TRACKED_TASKS) {
let foregroundId: string | undefined
for (const [candidateId, candidate] of this.tasks) {
if (!candidate.backgrounded) {
@@ -243,6 +230,20 @@ export class ClaudeBackgroundTaskTracker {
}
this.tasks.delete(foregroundId)
}
const existing = this.tasks.get(id) ?? this.retention.resume(id)
this.terminalTaskIds.delete(id)
if (existing) {
this.tasks.set(id, {
backgrounded: existing.backgrounded || task.backgrounded,
kind: task.kind !== 'unknown' ? task.kind : existing.kind,
description: task.description ?? existing.description,
name: task.name ?? existing.name,
state: task.state ?? existing.state,
startedAt: existing.startedAt,
totalTokens: existing.totalTokens
})
return
}
this.tasks.set(id, task)
}
@@ -93,9 +93,17 @@ export class ClaudeSettledBackgroundTasks {
this.recentlyRemoved.delete(id)
}
forget(id: string): void {
/** Positive live evidence transfers identity back to the tracker, never the old outcome. */
resume(id: string): TrackedClaudeBackgroundTask | undefined {
const settled = this.settled.get(id)
const removed = this.recentlyRemoved.get(id)
this.settled.delete(id)
this.recentlyRemoved.delete(id)
const source = settled ?? removed
if (!source || source.startedAt === undefined) {
return undefined
}
return { ...source, backgrounded: true, state: undefined, startedAt: source.startedAt }
}
get hasSettled(): boolean {
@@ -200,3 +200,24 @@ describe('resolveBackgroundTaskName', () => {
).toBe('Background command')
})
})
describe('resumed tasks from mixed-version hosts', () => {
it('renders one live owner per id and counts only the two dispatched agents', () => {
const live = agent('resumed', { totalTokens: 20000 })
const settled = agent('resumed', { state: 'done', totalTokens: 19003 })
const shells = Array.from({ length: 4 }, (_, index) =>
agent(`shell-${index}`, { kind: 'command' })
)
const groups = buildBackgroundTaskGroups(
[live, ...shells],
[settled, agent('sibling', { state: 'done' })]
)
expect(
groups.flatMap((group) => group.tasks).filter((entry) => entry.task.id === live.id)
).toEqual([{ task: live, settled: false, state: 'working', name: 'Background agent' }])
expect(backgroundTasksHeaderContent(groups, { narrow: false, now: NOW }).segments).toEqual([
'2 agents',
'4 shells'
])
})
})
@@ -76,20 +76,22 @@ export function buildBackgroundTaskGroups(
tasks: readonly AgentSessionBackgroundTask[],
settledTasks: readonly AgentSessionBackgroundTask[]
): BackgroundTaskGroup[] {
const entries: BackgroundRosterTask[] = [
...tasks.map((task) => ({
task,
settled: false,
state: effectiveState(task, false),
name: resolveBackgroundTaskName(task)
})),
...settledTasks.map((task) => ({
task,
settled: true,
state: effectiveState(task, true),
name: resolveBackgroundTaskName(task)
}))
]
// Older hosts can retain a previous turn beside its resumed live task.
const owners = new Map<string, BackgroundRosterTask>()
for (const [roster, settled] of [
[settledTasks, true],
[tasks, false]
] as const) {
for (const task of roster) {
owners.set(task.id, {
task,
settled,
state: effectiveState(task, settled),
name: resolveBackgroundTaskName(task)
})
}
}
const entries = [...owners.values()]
entries.sort((left, right) => {
const startDelta = (left.task.startedAt ?? 0) - (right.task.startedAt ?? 0)
return startDelta !== 0 ? startDelta : left.task.id < right.task.id ? -1 : 1