From 82dae61562532c234b36e3785bc044b32b840e8a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:25:26 -0700 Subject: [PATCH] fix(agent-status): reap only unbacked claims from an empty stop inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding round 11: the complete-empty fold cleared live-tracked rows, so a delayed stale stop's empty list could retire a newer live start — the uncorrelated-retirement race again. The empty inventory now reaps only restored/inventory-derived claims not backed by live lifecycle events. --- src/shared/agent-hook-listener.test.ts | 22 ++++++++++++++++++++++ src/shared/agent-hook-listener.ts | 19 +++++++++++++------ 2 files changed, 35 insertions(+), 6 deletions(-) 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.