From fc66ab6eea4ea1a01c6605706d135e4f78c35638 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:37:10 -0700 Subject: [PATCH] fix(agent-status): count empty-inventory reaps as removal evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding round 12: reaping the last inventory-derived rows emitted nothing, stranding the previously published working until the sweeper. The reap is same-source-correlated removal evidence — the authority that minted those rows now reports empty — so it rides the existing done veto chain. --- src/shared/agent-hook-listener.test.ts | 20 ++++++++++++++++++++ src/shared/agent-hook-listener.ts | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 68c590427bb..59623f8636b 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -3372,6 +3372,26 @@ describe('shared agent-hook-listener', () => { expect(staleStop?.payload.subagents).toEqual([expect.objectContaining({ id: 'a-new' })]) }) + it('resolves to done when an empty inventory reaps the last inventory-derived rows', () => { + // Why: the authority that minted a1's row now reports empty; a silent + // drain would strand the previously published working until the sweeper. + const first = claudeEvent({ + hook_event_name: 'SubagentStop', + agent_id: 'ghost-1', + background_tasks: [ + { id: 'a1', type: 'subagent', status: 'running', agent_type: 'general-purpose' } + ] + }) + expect(first?.payload.state).toBe('working') + const second = claudeEvent({ + hook_event_name: 'SubagentStop', + agent_id: 'ghost-2', + background_tasks: [] + }) + expect(second?.payload.state).toBe('done') + expect(second?.payload.subagents).toBeUndefined() + }) + 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 2b49dfac85c..82744b39dc8 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2474,10 +2474,13 @@ function normalizeClaudeSubagentLifecycleEvent( 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. + // a newer live start's row — that is the uncorrelated-retirement race again. The reap is + // same-source-correlated removal evidence: the authority that minted those rows now + // reports empty, so a silent drain must not strand the published working. for (const [id, tracked] of roster) { if (tracked.restoredFromSnapshot || tracked.backgroundTasksAuthoritative) { roster.delete(id) + removedRow = true } } } else if (