diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index f77a4ebae3c..1e3728eaef7 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2412,7 +2412,14 @@ function normalizeClaudeSubagentLifecycleEvent( Date.now() ) } else { - finishClaudeSubagent(roster, agentId) + // Why: SubagentStop carries the session's task inventory; a stopping + // agent listed id-exact as a subagent task is a workflow/named one-shot + // (teammate lifecycle ids never appear there), not a resumable teammate. + const stopTasks = readClaudeBackgroundAgentTasks(hookPayload) + finishClaudeSubagent(roster, agentId, { + listedAsSubagentTask: + stopTasks.present && stopTasks.tasks.some((task) => !task.teammate && task.id === agentId) + }) // Why: a blocked child that dies (killed, errored) without another tool // event would otherwise pin its permission/question wait on the pane // forever — nothing else references that agent again. diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts index 5e226ca38ab..99cd9bbb8dc 100644 --- a/src/shared/claude-subagent-roster.test.ts +++ b/src/shared/claude-subagent-roster.test.ts @@ -32,6 +32,83 @@ describe('claude-subagent-roster', () => { expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle', teammate: true }) }) + it('removes a finished workflow lane despite its teammate-shaped id when task-listed', () => { + const roster: ClaudeSubagentRoster = new Map() + // Why: workflow lanes get name-embedding ids (afinder-C-) like + // teammates, but their SubagentStop payload lists them id-exact as a + // subagent task — real teammate lifecycle ids never appear there. + upsertWorkingClaudeSubagent( + roster, + 'afinder-C-5d713c0781b7f8d2', + { agentType: 'finder-C' }, + 100 + ) + finishClaudeSubagent(roster, 'afinder-C-5d713c0781b7f8d2', { listedAsSubagentTask: true }) + expect(roster.size).toBe(0) + }) + + it('reclassifies a task-listed teammate-shaped entry as a one-shot', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent( + roster, + 'av1-streaming-0b1c2d3e', + { agentType: 'v1-streaming' }, + 100 + ) + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { + id: 'av1-streaming-0b1c2d3e', + agentType: 'v1-streaming', + description: undefined, + running: true, + teammate: false + }, + { + id: 'tteam1', + agentType: undefined, + description: undefined, + running: true, + teammate: true + } + ], + 200 + ) + // A later stop without its own inventory still removes it: the fold + // already proved the id is a task id, not a teammate. + finishClaudeSubagent(roster, 'av1-streaming-0b1c2d3e') + expect(roster.has('av1-streaming-0b1c2d3e')).toBe(false) + }) + + it('removes teammate-shaped leftovers when a complete inventory lists no teammates', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'acr-triage-1-c5a0588e', { agentType: 'cr-triage-1' }, 100) + finishClaudeSubagent(roster, 'acr-triage-1-c5a0588e') + expect(roster.get('acr-triage-1-c5a0588e')).toMatchObject({ state: 'idle' }) + upsertWorkingClaudeSubagent(roster, 'afix-main-11223344', { agentType: 'fix-main' }, 150) + + // Why: a teams session lists its teammates (even idle) as teammate-typed + // tasks; an inventory with none proves these name-shaped rows are dead + // workflow lanes, not resumable teammates. + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { + id: 'aunrelated0000001', + agentType: 'general-purpose', + description: undefined, + running: true, + teammate: false + } + ], + 200 + ) + expect(roster.has('acr-triage-1-c5a0588e')).toBe(false) + expect(roster.has('afix-main-11223344')).toBe(false) + expect(roster.has('aunrelated0000001')).toBe(true) + }) + it('re-marks an idle teammate working without resetting startedAt', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index f4fb1bc6429..b0b945a76e2 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -105,13 +105,24 @@ function evictOldestIdleClaudeSubagent(roster: ClaudeSubagentRoster): boolean { /** SubagentStop: a finished one-shot subagent leaves the sidebar immediately — * retaining it as an idle row made long workflow/ultracode sessions pile up * dozens of dead rows. Teammates only idle (alive + resumable): SubagentStop - * fires each time a teammate finishes a task, not just at shutdown. */ -export function finishClaudeSubagent(roster: ClaudeSubagentRoster, id: string): void { + * fires each time a teammate finishes a task, not just at shutdown. + * + * Workflow/named one-shots share the teammate id shape (`a