fix(agent-status): close the remaining no-lead sticky/phantom paths

Review findings round 7: (1) a child permission wait that is a fresh
pane's first lead record fabricated 'working' from the no-stash clear
fallback when the child died — drop the record instead and let evidence
rules decide. (2) A complete empty stop inventory now clears restored
phantom rows (it is complete by construction; the non-empty additive fold
is unchanged). (3) An uncapped raw scan sees running subagents hidden
past the 32-entry parser cap, so a capped inventory with live work emits
truthful working instead of retaining stale state.
This commit is contained in:
Brennan Benson
2026-08-01 08:32:54 -07:00
parent 8cb32cd86b
commit 1bc62cf4dc
2 changed files with 87 additions and 7 deletions
+51
View File
@@ -3221,9 +3221,60 @@ describe('shared agent-hook-listener', () => {
agent_id: 'a1',
background_tasks: cappedTasks
})
// Why: the uncapped raw scan still sees hidden-live running — the pane is
// truthfully working, never done.
expect(stopped?.payload.state).toBe('working')
})
it('claims nothing from a no-lead removal when a truncated inventory shows no live work', () => {
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
})
const teammateOnly = Array.from({ length: 33 }, (_, i) => ({
id: `t${i}`,
type: 'teammate',
status: 'running'
}))
const stopped = claudeEvent({
hook_event_name: 'SubagentStop',
agent_id: 'a1',
background_tasks: teammateOnly
})
expect(stopped).toBeNull()
})
it('resolves a fresh-pane child permission wait whose child dies to done, not phantom working', () => {
// Why: the wait is this pane's FIRST lead record; the old no-stash fallback
// fabricated 'working' when the waiting child stopped — a stuck card.
claudeEvent({
hook_event_name: 'PermissionRequest',
agent_id: 'w1',
agent_type: 'general-purpose',
tool_name: 'Bash'
})
const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'w1' })
expect(stopped?.payload.state).toBe('done')
expect(stopped?.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' },
{ id: 'a2', state: 'working', startedAt: 1, agentType: 'general-purpose' }
])
// Why: a2's finish hook was lost while Orca was down; a1's stop reports an
// authoritative empty inventory, which must not leave a2 spinning forever.
const stopped = claudeEvent({
hook_event_name: 'SubagentStop',
agent_id: 'a1',
background_tasks: []
})
expect(stopped?.payload.state).toBe('done')
expect(stopped?.payload.subagents).toBeUndefined()
})
it('keeps working when a no-lead drain leaves a live shell in the inventory', () => {
claudeEvent({
hook_event_name: 'SubagentStart',
+36 -7
View File
@@ -2478,7 +2478,11 @@ function normalizeClaudeSubagentLifecycleEvent(
getOrCreateClaudeSubagentRoster(state, paneKey),
inventory.tasks,
Date.now(),
{ inventoryComplete: false }
// 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: 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.
@@ -2552,7 +2556,14 @@ function clearClaudePendingWaitForAgent(
if (lead?.state !== 'waiting' || !lead.waitingAgentId || !ownsWait(lead.waitingAgentId)) {
return
}
state.claudeLeadStateByPaneKey.set(paneKey, lead.stateBeforeWait ?? { state: 'working' })
if (lead.stateBeforeWait) {
state.claudeLeadStateByPaneKey.set(paneKey, lead.stateBeforeWait)
return
}
// Why: no stash means the wait was this pane's FIRST lead record (child wait on a fresh
// process); fabricating 'working' here becomes a stuck card when the waiting child dies —
// drop the record and let the evidence rules decide downstream (STA-2915 review).
state.claudeLeadStateByPaneKey.delete(paneKey)
}
/** Clear an AskUserQuestion wait after the answer is typed (answering emits no hook event; the caller infers it from the submit keystroke). Restores the stashed pre-wait lead state or 'working', drops the cached card, and returns the pane state to emit (gated up to 'working' while children run). */
@@ -2580,6 +2591,22 @@ export function clearClaudeAnsweredQuestionWait(
}
/** Re-emit the lead's cached state on child activity — gated up to 'working' while a child works — without touching the lead's tool/prompt caches, so a live card or permission wait survives child churn. */
// Why: the roster parser bounds TRACKED rows at the wire cap; this boolean liveness scan of the
// raw array must still see a running subagent-typed entry hidden past it.
function rawInventoryHasRunningSubagentTask(hookPayload: Record<string, unknown>): boolean {
const raw = hookPayload['background_tasks']
return (
Array.isArray(raw) &&
raw.some((item) => {
if (typeof item !== 'object' || item === null) {
return false
}
const task = item as Record<string, unknown>
return task.type === 'subagent' && task.status === 'running'
})
)
}
function buildClaudeChildDrivenStatusPayload(
state: HookListenerState,
eventName: unknown,
@@ -2610,16 +2637,18 @@ function buildClaudeChildDrivenStatusPayload(
})
}
if (evidence.removedRow) {
// 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)) {
// Why: live shells/crons veto done, and the uncapped raw scan sees a running subagent hidden
// past the parser cap (tracked folding surfaces the listed ones through the working branch).
if (
hasActiveClaudeNonAgentBackgroundWork(hookPayload) ||
rawInventoryHasRunningSubagentTask(hookPayload)
) {
return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, {
stateName: 'working',
updateToolSnapshot: false
})
}
// Why: a truncated inventory with no visible live work is incomplete evidence — claim nothing.
const inventory = readClaudeBackgroundAgentTasks(hookPayload)
if (inventory.present && inventory.truncated) {
return null