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:
Brennan Benson
2026-08-17 16:50:36 -07:00
parent 8cd338357e
commit bc6c53877c
10 changed files with 384 additions and 44 deletions
@@ -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(
+1 -1
View File
@@ -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' })
+5 -6
View File
@@ -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)