fix(agent-status): stop start-less SubagentStop from minting phantom working

Claude compact emits PreCompact -> SubagentStop (never started) ->
SessionStart(compact) -> PostCompact. On a freshly resumed pane with no
cached lead state, the child-driven default minted a sticky empty-prompt
'working' that no Stop ever clears (STA-2915). Emit child-driven status
only on positive evidence: a lead record, a live working child, or an
actual roster change.
This commit is contained in:
Brennan Benson
2026-08-01 07:07:06 -07:00
parent 16c5526dfd
commit 01727afae0
2 changed files with 52 additions and 5 deletions
+36
View File
@@ -3096,6 +3096,42 @@ describe('shared agent-hook-listener', () => {
expect(stopped?.payload.subagents).toBeUndefined()
})
it('does not mint working from a start-less SubagentStop on a pane with no lead record', () => {
// Why: the live /compact flow on claude 2.1.186–2.1.220 is PreCompact → SubagentStop
// (never started) → SessionStart(compact) → PostCompact. On a resumed session in a
// fresh process this minted a sticky empty-prompt 'working' no Stop ever clears (STA-2915).
const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'compact-1' })
expect(stopped).toBeNull()
})
it('re-emits the lead done state on a start-less SubagentStop after a completed turn', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'go' })
claudeEvent({ hook_event_name: 'Stop', background_tasks: [] })
const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'compact-1' })
expect(stopped?.payload.state).toBe('done')
})
it('still emits working for a spawn on a pane with no lead record', () => {
const spawned = claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
})
expect(spawned?.payload.state).toBe('working')
expect(spawned?.payload.subagents).toEqual([expect.objectContaining({ id: 'a1' })])
})
it('keeps child-driven working when a live child survives an unknown stop with no lead record', () => {
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'a1',
agent_type: 'general-purpose'
})
const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'ghost' })
expect(stopped?.payload.state).toBe('working')
expect(stopped?.payload.subagents).toEqual([expect.objectContaining({ id: 'a1' })])
})
it('keeps gating on tracked children when background_tasks is absent (older Claude)', () => {
claudeEvent({
hook_event_name: 'SubagentStart',
+16 -5
View File
@@ -2433,10 +2433,11 @@ function normalizeClaudeSubagentLifecycleEvent(
return null
}
const roster = getOrCreateClaudeSubagentRoster(state, paneKey)
let rosterChanged = false
if (eventName === 'TeammateIdle') {
const teammateName = lifecycleId
// Why: on claude 2.1.21x teammates are turn-based — TeammateIdle means "turn over, awaiting mail", not finished. The row parks as idle (confirmed teammate) instead of leaving, so the sidebar keeps showing resumable children.
idleClaudeTeammateByName(roster, teammateName)
rosterChanged = idleClaudeTeammateByName(roster, teammateName)
clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) =>
claudeTeammateIdMatchesName(waitingAgentId, teammateName)
)
@@ -2449,14 +2450,17 @@ function normalizeClaudeSubagentLifecycleEvent(
{ agentType: readString(hookPayload, 'agent_type') },
Date.now()
)
rosterChanged = true
} else {
// Why: a stop of an untracked id (e.g. compact's start-less SubagentStop) is a roster no-op, not evidence.
rosterChanged = roster.has(agentId)
// Why: one-shot stops are true finishes (row removed); teammate-shaped stops are turn ends on 2.1.21x — the row parks idle and a later SubagentStart revives it.
stopClaudeSubagent(roster, agentId)
// 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)
}
}
return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload)
return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload, rosterChanged)
}
/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */
@@ -2553,12 +2557,19 @@ function buildClaudeChildDrivenStatusPayload(
state: HookListenerState,
eventName: unknown,
paneKey: string,
hookPayload: Record<string, unknown>
hookPayload: Record<string, unknown>,
rosterChanged = false
): ParsedAgentStatusPayload | null {
// Why: default 'working' — a spawn proves activity even before the lead's first state-bearing event (e.g. Orca restarted mid-session).
const lead = state.claudeLeadStateByPaneKey.get(paneKey)
const leadState = lead?.state ?? 'working'
const roster = state.claudeSubagentRosterByPaneKey.get(paneKey)
// Why: no lead record, no live child, and an unchanged roster mean the event proves nothing is
// running. Compact's start-less SubagentStop is exactly that; minting the default below left a
// sticky 'working' no Stop ever clears on freshly resumed panes (STA-2915).
if (!lead && !rosterChanged && !claudeRosterHasWorkingSubagent(roster)) {
return null
}
// Why: default 'working' — a spawn proves activity even before the lead's first state-bearing event (e.g. Orca restarted mid-session).
const leadState = lead?.state ?? 'working'
return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, {
stateName:
leadState === 'done' && claudeRosterHasWorkingSubagent(roster) ? 'working' : leadState,