fix(agent-status): reap only unbacked claims from an empty stop inventory

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.
This commit is contained in:
Brennan Benson
2026-08-01 09:25:26 -07:00
parent d9f485b491
commit 82dae61562
2 changed files with 35 additions and 6 deletions
+22
View File
@@ -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' },
+13 -6
View File
@@ -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.