diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index b776060dc0e..68c590427bb 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -3350,6 +3350,28 @@ describe('shared agent-hook-listener', () => { expect(stopped?.payload.subagents).toBeUndefined() }) + it('keeps a newer live start alive through a delayed stale stop with an empty inventory', () => { + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'a-old', + agent_type: 'general-purpose' + }) + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'a-new', + agent_type: 'general-purpose' + }) + // Why: a-old's delayed stop reports the empty inventory captured before + // a-new existed; clearing a-new from it would retire newer live work. + const staleStop = claudeEvent({ + hook_event_name: 'SubagentStop', + agent_id: 'a-old', + background_tasks: [] + }) + expect(staleStop?.payload.state).toBe('working') + expect(staleStop?.payload.subagents).toEqual([expect.objectContaining({ id: 'a-new' })]) + }) + it('clears restored phantom rows when a no-lead stop carries a complete empty inventory', () => { seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, [ { id: 'a1', state: 'working', startedAt: 1, agentType: 'general-purpose' }, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index c748d484ead..2b49dfac85c 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2471,7 +2471,16 @@ function normalizeClaudeSubagentLifecycleEvent( // Additive only — a complete-inventory reap here would kill a just-parked teammate row // before its TeammateIdle confirmation arrives. const inventory = readClaudeBackgroundAgentTasks(hookPayload) - if ( + if (inventory.present && inventory.tasks.length === 0 && roster) { + // Why: an empty complete inventory reaps only claims not backed by live lifecycle events + // (restored/inventory-derived phantoms). A delayed stale stop's empty list must not clear + // a newer live start's row — that is the uncorrelated-retirement race again. + for (const [id, tracked] of roster) { + if (tracked.restoredFromSnapshot || tracked.backgroundTasksAuthoritative) { + roster.delete(id) + } + } + } else if ( inventory.present && (roster || inventory.tasks.some( @@ -2484,11 +2493,9 @@ function normalizeClaudeSubagentLifecycleEvent( getOrCreateClaudeSubagentRoster(state, paneKey), inventory.tasks, Date.now(), - // Why: an empty inventory is complete by construction and proves nothing is left - // alive (lead-path rule), clearing restored phantoms; a non-empty one folds - // additively — its reap would kill a just-parked teammate row before its - // TeammateIdle confirmation arrives. - { inventoryComplete: inventory.tasks.length === 0 } + // Why: additive only — a complete-inventory reap here would kill a just-parked + // teammate row before its TeammateIdle confirmation arrives. + { inventoryComplete: false } ) } // Why: a blocked child that dies without another tool event would pin its permission/question wait on the pane forever — nothing else references that agent again.