mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 00:02:54 +00:00
fix(agent-status): stop leftover Claude shells from holding a finished lead at working
A lead Stop with a still-running background shell published working, and a later start-less SubagentStop re-emitted that latch forever because an idle parent never sends another inventory. Leftover non-agent inventory no longer participates in pane state; live child agents and session crons still gate working. STA-4119
This commit is contained in:
@@ -77,7 +77,7 @@ describe('Last-status persistence', () => {
|
||||
worktreeId: 'wt-1',
|
||||
receivedAt: expect.any(Number),
|
||||
stateStartedAt: expect.any(Number),
|
||||
payload: expect.objectContaining({ state: 'working', prompt: 'persist me' })
|
||||
payload: expect.objectContaining({ state: 'done', prompt: 'persist me' })
|
||||
})
|
||||
expect(file.entries[PANE].launchToken).toBeUndefined()
|
||||
expect(file.entries[PANE].launchTokenHash).toBe(
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('RelayAgentHookServer', () => {
|
||||
|
||||
expect(forward.mock.calls[0][0]).toMatchObject({
|
||||
claudeRunningNonAgentTask: true,
|
||||
payload: { state: 'working', agentType: 'claude' }
|
||||
payload: { state: 'done', agentType: 'claude' }
|
||||
})
|
||||
} finally {
|
||||
server.stop()
|
||||
|
||||
@@ -205,14 +205,16 @@ describe('Claude background-turn completion notifications', () => {
|
||||
}
|
||||
])
|
||||
|
||||
// Why: a leftover shell no longer gates Stop to working, so this turn
|
||||
// announces on the done quiet edge instead of turnCompletedAt.
|
||||
expect(banners).toHaveLength(2)
|
||||
expect(banners[0]).toMatchObject({
|
||||
at: 1_700_000_001_000,
|
||||
at: 1_700_000_001_000 + HOOK_DONE_QUIET_MS,
|
||||
body: 'Dev server is up.',
|
||||
stateStartedAt: 1_700_000_001_000
|
||||
})
|
||||
expect(banners[1]).toMatchObject({
|
||||
at: 1_700_000_011_000,
|
||||
at: 1_700_000_011_000 + HOOK_DONE_QUIET_MS,
|
||||
body: 'Route added.',
|
||||
stateStartedAt: 1_700_000_011_000
|
||||
})
|
||||
|
||||
@@ -97,26 +97,25 @@ describe('shared agent-hook-listener', () => {
|
||||
expect(stop?.payload.turnCompletedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'a running shell task',
|
||||
eventName: 'Stop',
|
||||
payload: { background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }] }
|
||||
},
|
||||
{
|
||||
label: 'a pending session cron',
|
||||
eventName: 'StopFailure',
|
||||
payload: { session_crons: [{ id: 'cron-1' }] }
|
||||
}
|
||||
])(
|
||||
'reports Stop as working for $label without adding a subagent row',
|
||||
({ eventName, payload }) => {
|
||||
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' })
|
||||
const stop = claudeEvent({ hook_event_name: eventName, ...payload })
|
||||
expect(stop?.payload.state).toBe('working')
|
||||
expect(stop?.payload.subagents).toBeUndefined()
|
||||
}
|
||||
)
|
||||
it('reports Stop as done for a leftover shell without adding a subagent row', () => {
|
||||
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' })
|
||||
const stop = claudeEvent({
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }]
|
||||
})
|
||||
expect(stop?.payload.state).toBe('done')
|
||||
expect(stop?.payload.subagents).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports StopFailure as working for a pending session cron without adding a subagent row', () => {
|
||||
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' })
|
||||
const stop = claudeEvent({
|
||||
hook_event_name: 'StopFailure',
|
||||
session_crons: [{ id: 'cron-1' }]
|
||||
})
|
||||
expect(stop?.payload.state).toBe('working')
|
||||
expect(stop?.payload.subagents).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports Stop as working while a background subagent is still running', () => {
|
||||
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' })
|
||||
|
||||
@@ -2602,10 +2602,11 @@ function resolveClaudePaneState(
|
||||
return lead.state
|
||||
}
|
||||
const roster = state.claudeSubagentRosterByPaneKey.get(paneKey)
|
||||
// Why: leftover provider shells/monitors are session inventory, not lead-turn
|
||||
// work. Gating done→working on them latches the pane forever because an idle
|
||||
// parent never emits another background_tasks list (STA-4119).
|
||||
return claudeRosterHasWorkingSubagent(roster) ||
|
||||
(!lead.interrupted &&
|
||||
(state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
|
||||
state.claudeActiveSessionCronPaneKeys.has(paneKey)))
|
||||
(!lead.interrupted && state.claudeActiveSessionCronPaneKeys.has(paneKey))
|
||||
? 'working'
|
||||
: 'done'
|
||||
}
|
||||
@@ -2679,8 +2680,7 @@ function normalizeClaudeSubagentLifecycleEvent(
|
||||
const hasConfirmedDoneGate =
|
||||
cachedLead?.state === 'done' &&
|
||||
cachedLead.interrupted !== true &&
|
||||
(state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
|
||||
state.claudeActiveSessionCronPaneKeys.has(paneKey))
|
||||
state.claudeActiveSessionCronPaneKeys.has(paneKey)
|
||||
const restoredOnlyDoneGate =
|
||||
cachedLead?.state === 'done' && !hasConfirmedDoneGate && hasUnconfirmedChild
|
||||
if (roster?.size === 0) {
|
||||
@@ -3107,7 +3107,6 @@ function normalizeClaudeEvent(
|
||||
effectiveState === 'working' &&
|
||||
claudeRosterHasRestoredSnapshotSubagent(effectiveRoster) &&
|
||||
!claudeRosterHasRuntimeWorkingSubagent(effectiveRoster) &&
|
||||
!state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) &&
|
||||
!state.claudeActiveSessionCronPaneKeys.has(paneKey)
|
||||
) {
|
||||
// Why: a legacy or partial Stop confirms the lead boundary, not a child restored from disk; keep the child-only gate eligible for reconciliation.
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('Claude background task status', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('stays working through Claude 2.1.220 Stop and SubagentStop until the shell finishes', () => {
|
||||
it('does not hold a finished lead at working for a leftover background shell', () => {
|
||||
const state = createHookListenerState()
|
||||
|
||||
expect(
|
||||
@@ -117,29 +117,37 @@ describe('Claude background task status', () => {
|
||||
expect(
|
||||
claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'Stop',
|
||||
last_assistant_message: 'STA-4119-TURN-DONE',
|
||||
background_tasks: [RUNNING_SHELL]
|
||||
})?.state
|
||||
).toBe('working')
|
||||
})
|
||||
).toMatchObject({ state: 'done', lastAssistantMessage: 'STA-4119-TURN-DONE' })
|
||||
expect(
|
||||
claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'SubagentStop',
|
||||
agent_id: 'a70fdf2986e38302b',
|
||||
background_tasks: [RUNNING_SHELL]
|
||||
})?.state
|
||||
).toBe('working')
|
||||
).toBe('done')
|
||||
expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true)
|
||||
|
||||
expect(
|
||||
claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: '<task-notification><status>completed</status></task-notification>'
|
||||
prompt: 'next turn'
|
||||
})?.state
|
||||
).toBe('working')
|
||||
|
||||
claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'child-1',
|
||||
agent_type: 'general-purpose'
|
||||
})
|
||||
expect(
|
||||
claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: []
|
||||
background_tasks: [RUNNING_SHELL, { id: 'child-1', type: 'subagent', status: 'running' }]
|
||||
})?.state
|
||||
).toBe('done')
|
||||
).toBe('working')
|
||||
})
|
||||
|
||||
it('keeps pending task state when pane authority moves', () => {
|
||||
@@ -156,7 +164,8 @@ describe('Claude background task status', () => {
|
||||
hook_event_name: 'SubagentStop',
|
||||
agent_id: 'a70fdf2986e38302b'
|
||||
})?.state
|
||||
).toBe('working')
|
||||
).toBe('done')
|
||||
expect(state.claudeRunningNonAgentTaskPaneKeys.has(TARGET_PANE)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an interrupted Stop terminal even when its task inventory is still running', () => {
|
||||
@@ -287,7 +296,7 @@ describe('Claude background task status', () => {
|
||||
expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a failed turn working while its background shell runs', () => {
|
||||
it('does not hold a failed turn at working for a leftover background shell', () => {
|
||||
const state = createHookListenerState()
|
||||
|
||||
expect(
|
||||
@@ -295,7 +304,8 @@ describe('Claude background task status', () => {
|
||||
hook_event_name: 'StopFailure',
|
||||
background_tasks: [RUNNING_SHELL]
|
||||
})?.state
|
||||
).toBe('working')
|
||||
).toBe('done')
|
||||
expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true)
|
||||
expect(
|
||||
claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'StopFailure',
|
||||
@@ -317,7 +327,7 @@ describe('Claude background task status', () => {
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [RUNNING_SHELL]
|
||||
})
|
||||
).toMatchObject({ state: 'working', interrupted: undefined })
|
||||
).toMatchObject({ state: 'done', interrupted: undefined })
|
||||
})
|
||||
|
||||
it('resumes on task notifications and user slash commands after interruption', () => {
|
||||
@@ -497,7 +507,8 @@ describe('Claude background task status', () => {
|
||||
agent_id: 'child-1',
|
||||
background_tasks: []
|
||||
})?.state
|
||||
).toBe('working')
|
||||
).toBe('done')
|
||||
expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores TeammateIdle inventories for lead-owned background work', () => {
|
||||
@@ -513,7 +524,7 @@ describe('Claude background task status', () => {
|
||||
teammate_name: 'reviewer',
|
||||
background_tasks: []
|
||||
})?.state
|
||||
).toBe('working')
|
||||
).toBe('done')
|
||||
expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true)
|
||||
|
||||
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [] })
|
||||
@@ -550,7 +561,8 @@ describe('Claude background task status', () => {
|
||||
background_tasks: [RUNNING_SHELL]
|
||||
})
|
||||
|
||||
expect(claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop' })?.state).toBe('working')
|
||||
expect(claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop' })?.state).toBe('done')
|
||||
expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true)
|
||||
clearPaneCacheState(state, SOURCE_PANE)
|
||||
expect(state.claudeRunningNonAgentTaskPaneKeys.size).toBe(0)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user