From 8cb32cd86bb535d980e28583d772a38f920042f4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:18:53 -0700 Subject: [PATCH] fix(agent-status): track inventory children additively and fail closed on truncated inventories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/shared/agent-hook-listener.test.ts | 31 +++++++++++++++++++-- src/shared/agent-hook-listener.ts | 38 ++++++++++++++++++++------ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 0e327a886d2..09ce99f33a3 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -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', () => { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index f2bc13795fa..d1b82f395c8 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -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 }) }