mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user