fix(agent-status): track inventory children additively and fail closed on truncated inventories

Review findings round 6: (1) veto-only left the vetoing child untracked,
so its own later drain was start-less-suppressed and the pane stuck on
working — fold running non-teammate inventory entries additively
(inventoryComplete: false; no reap, so a just-parked unconfirmed teammate
row survives). (2) The agent-task parser caps at 32 entries; a running
child past the cap is invisible, so a truncated inventory now claims
neither done nor working. The raw-array shell/cron veto stays uncapped.
This commit is contained in:
Brennan Benson
2026-08-01 08:18:53 -07:00
parent 3c6c1f80b2
commit 8cb32cd86b
2 changed files with 59 additions and 10 deletions
+29 -2
View File
@@ -3192,9 +3192,36 @@ describe('shared agent-hook-listener', () => {
{ id: 'a2', type: 'subagent', status: 'running', agent_type: 'general-purpose' }
]
})
// Why: veto only — a2's row materializes from its own events or the next
// lead Stop's fold; the stop must merely not retire the pane.
expect(stopped?.payload.state).toBe('working')
// Why: the additive fold must TRACK a2 — otherwise its own later stop is
// start-less-suppressed and the pane sticks on working forever.
expect(stopped?.payload.subagents).toEqual([expect.objectContaining({ id: 'a2' })])
const drained = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a2' })
expect(drained?.payload.state).toBe('done')
expect(drained?.payload.subagents).toBeUndefined()
})
it('claims nothing from a no-lead removal whose agent inventory is truncated', () => {
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
})
// Why: 32 teammate rows fill the parser cap; the running subagent behind
// them is invisible, so the inventory can prove neither done nor working.
const cappedTasks = Array.from({ length: 32 }, (_, i) => ({
id: `t${i}`,
type: 'teammate',
status: 'running'
}))
cappedTasks.push({ id: 'hidden-live', type: 'subagent', status: 'running' })
const stopped = claudeEvent({
hook_event_name: 'SubagentStop',
agent_id: 'a1',
background_tasks: cappedTasks
})
expect(stopped).toBeNull()
})
it('keeps working when a no-lead drain leaves a live shell in the inventory', () => {
+30 -8
View File
@@ -2465,6 +2465,22 @@ function normalizeClaudeSubagentLifecycleEvent(
stopClaudeSubagent(roster, agentId)
removedRow = hadRow && !roster.has(agentId)
}
// Why: the stop's inventory can list children this listener never saw start (post-restart);
// tracking them keeps their own later drains resolvable instead of start-less-suppressed.
// Additive only — a complete-inventory reap here would kill a just-parked teammate row
// before its TeammateIdle confirmation arrives.
const inventory = readClaudeBackgroundAgentTasks(hookPayload)
if (
inventory.present &&
(roster || inventory.tasks.some((task) => task.running && !task.teammate))
) {
foldClaudeBackgroundTasksIntoRoster(
getOrCreateClaudeSubagentRoster(state, paneKey),
inventory.tasks,
Date.now(),
{ 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.
clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId)
}
@@ -2594,16 +2610,22 @@ function buildClaudeChildDrivenStatusPayload(
})
}
if (evidence.removedRow) {
// Why: the stop's own inventory can list work this listener never saw start (post-restart);
// a running non-teammate task or live shell/cron vetoes done. Teammate-typed entries prove
// nothing ("running" even while idle), and full folding here would reap a just-parked
// teammate row before its TeammateIdle confirmation arrives — veto only, no reconcile.
// Why: live shells/crons veto done (the raw-array check is uncapped). A truncated agent-task
// inventory is incomplete evidence — a running child past the parser cap would be invisible —
// so it claims neither done nor working. Running non-teammate tasks were already folded into
// the roster above and surface through the working branch.
if (hasActiveClaudeNonAgentBackgroundWork(hookPayload)) {
return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, {
stateName: 'working',
updateToolSnapshot: false
})
}
const inventory = readClaudeBackgroundAgentTasks(hookPayload)
const inventoryShowsLiveWork =
inventory.tasks.some((task) => task.running && !task.teammate) ||
hasActiveClaudeNonAgentBackgroundWork(hookPayload)
if (inventory.present && inventory.truncated) {
return null
}
return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, {
stateName: inventoryShowsLiveWork ? 'working' : 'done',
stateName: 'done',
updateToolSnapshot: false
})
}