fix(agent-status): count empty-inventory reaps as removal evidence

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.
This commit is contained in:
Brennan Benson
2026-08-01 09:37:10 -07:00
parent 82dae61562
commit fc66ab6eea
2 changed files with 24 additions and 1 deletions
+20
View File
@@ -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' },
+4 -1
View File
@@ -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 (