From f30fca2bf7ee0ce88319e32c164ac7a31dd2f299 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Tue, 8 Sep 2026 10:28:53 -0700 Subject: [PATCH] fix(native-chat): transfer resumed background tasks to one live owner --- .../claude-background-task-resume.test.ts | 92 +++++++++++++++++++ .../claude/claude-background-task-tracker.ts | 33 +++---- .../claude/claude-settled-background-tasks.ts | 10 +- .../background-task-roster.test.ts | 21 +++++ .../native-chat/background-task-roster.ts | 30 +++--- 5 files changed, 155 insertions(+), 31 deletions(-) create mode 100644 src/main/claude/claude-background-task-resume.test.ts diff --git a/src/main/claude/claude-background-task-resume.test.ts b/src/main/claude/claude-background-task-resume.test.ts new file mode 100644 index 00000000000..c043d485304 --- /dev/null +++ b/src/main/claude/claude-background-task-resume.test.ts @@ -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) { + 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() + }) +}) diff --git a/src/main/claude/claude-background-task-tracker.ts b/src/main/claude/claude-background-task-tracker.ts index 9bde7902b1b..9d4a2134d84 100644 --- a/src/main/claude/claude-background-task-tracker.ts +++ b/src/main/claude/claude-background-task-tracker.ts @@ -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) } diff --git a/src/main/claude/claude-settled-background-tasks.ts b/src/main/claude/claude-settled-background-tasks.ts index 2320c6151d8..91e975d07bb 100644 --- a/src/main/claude/claude-settled-background-tasks.ts +++ b/src/main/claude/claude-settled-background-tasks.ts @@ -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 { diff --git a/src/renderer/src/components/native-chat/background-task-roster.test.ts b/src/renderer/src/components/native-chat/background-task-roster.test.ts index 0f7c9f31d34..541123747e8 100644 --- a/src/renderer/src/components/native-chat/background-task-roster.test.ts +++ b/src/renderer/src/components/native-chat/background-task-roster.test.ts @@ -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' + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/background-task-roster.ts b/src/renderer/src/components/native-chat/background-task-roster.ts index 38f1f9a9bc5..170c7d5a1a4 100644 --- a/src/renderer/src/components/native-chat/background-task-roster.ts +++ b/src/renderer/src/components/native-chat/background-task-roster.ts @@ -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() + 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