Prevent Agent sleep while orchestration dispatch is active (#11808)

* fix(agent-sleep): keep active dispatch workers awake

* fix(agent-sleep): harden background work detection
This commit is contained in:
Brennan Benson 2026-07-31 16:35:33 -07:00 committed by GitHub
parent 676964b099
commit 9bf05b0a9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 305 additions and 8 deletions

View File

@ -36465,6 +36465,7 @@ describe('OrcaRuntimeService', () => {
expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toMatchObject({
taskId: 'task-1',
dispatchId: 'ctx-1',
dispatchStatus: 'dispatched',
taskTitle: 'Dispatch prompt work',
displayName: 'Review dispatch prompts and make worker labels distinct',
parentPaneKey: coordinatorPaneKey,
@ -36542,6 +36543,7 @@ describe('OrcaRuntimeService', () => {
expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toMatchObject({
taskId: 'task-done',
dispatchId: 'ctx-done',
dispatchStatus: 'completed',
parentPaneKey: coordinatorPaneKey,
parentTerminalHandle: coordinatorHandle
})
@ -36599,10 +36601,66 @@ describe('OrcaRuntimeService', () => {
expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toEqual({
taskId: 'task-done',
dispatchId: 'ctx-done'
dispatchId: 'ctx-done',
dispatchStatus: 'completed'
})
})
it.each(['failed', 'circuit_broken'] as const)(
'returns recent %s orchestration context without an active coordinator',
(dispatchStatus) => {
const runtime = new OrcaRuntimeService(store)
const workerLeafId = '66666666-6666-4666-8666-666666666666'
const workerPaneKey = makePaneKey('tab-worker', workerLeafId)
const workerHandle = runtime.preAllocateHandleForPty('pty-worker')
const getActiveCoordinatorRun = vi.fn(() => ({
id: 'run-unrelated',
coordinator_handle: 'term_unrelated'
}))
runtime.setOrchestrationDb({
getActiveDispatchForTerminal: vi.fn(() => undefined),
getLatestDispatchForTerminal: vi.fn(() => ({
id: 'ctx-settled',
task_id: 'task-settled',
assignee_handle: workerHandle,
status: dispatchStatus,
completed_at: new Date(Date.now()).toISOString()
})),
getActiveCoordinatorRun
} as never)
runtime.attachWindow(1)
const result = runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-worker',
worktreeId: TEST_WORKTREE_ID,
title: 'Claude Code',
activeLeafId: workerLeafId,
layout: null
}
],
leaves: [
{
tabId: 'tab-worker',
worktreeId: TEST_WORKTREE_ID,
leafId: workerLeafId,
paneRuntimeId: 1,
ptyId: 'pty-worker',
paneTitle: null
}
]
})
expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toEqual({
taskId: 'task-settled',
dispatchId: 'ctx-settled',
dispatchStatus
})
expect(getActiveCoordinatorRun).not.toHaveBeenCalled()
}
)
it('does not return stale completed orchestration context for renderer-synced terminal leaves', () => {
const runtime = new OrcaRuntimeService(store)
const workerLeafId = '77777777-7777-4777-8777-777777777777'

View File

@ -29307,10 +29307,10 @@ export class OrcaRuntimeService {
handle: string,
db = this.getOrchestrationDbIfAvailable()
): AgentStatusOrchestrationContext | undefined {
// Why: active dispatch is authoritative for reused terminals; completed context stale-groups future work once its done row is gone.
// Why: active dispatch is authoritative for reused terminals; settled context stale-groups later work once its row is gone.
const dispatch =
db?.getActiveDispatchForTerminal?.(handle) ??
this.getRecentCompletedDispatchForTerminal(handle, db)
this.getRecentSettledDispatchForTerminal(handle, db)
if (!dispatch) {
return undefined
}
@ -29323,7 +29323,10 @@ export class OrcaRuntimeService {
displayName: task.display_name
})
: { taskTitle: '', displayName: '' }
const activeRun = dispatch.status === 'completed' ? undefined : db?.getActiveCoordinatorRun?.()
const activeRun =
dispatch.status === 'pending' || dispatch.status === 'dispatched'
? db?.getActiveCoordinatorRun?.()
: undefined
const parentTerminalHandle =
task?.created_by_terminal_handle ??
(activeRun?.coordinator_handle && activeRun.coordinator_handle !== handle
@ -29336,6 +29339,7 @@ export class OrcaRuntimeService {
return {
taskId: dispatch.task_id,
dispatchId: dispatch.id,
dispatchStatus: dispatch.status,
...(display.taskTitle ? { taskTitle: display.taskTitle } : {}),
...(display.displayName ? { displayName: display.displayName } : {}),
...(parentTerminalHandle ? { parentTerminalHandle } : {}),
@ -29345,12 +29349,16 @@ export class OrcaRuntimeService {
}
}
private getRecentCompletedDispatchForTerminal(
private getRecentSettledDispatchForTerminal(
handle: string,
db = this.getOrchestrationDbIfAvailable()
): ReturnType<OrchestrationDb['getLatestDispatchForTerminal']> {
const dispatch = db?.getLatestDispatchForTerminal?.(handle)
if (dispatch?.status !== 'completed' || !dispatch.completed_at) {
if (
!dispatch?.completed_at ||
dispatch.status === 'pending' ||
dispatch.status === 'dispatched'
) {
return undefined
}
const completedAtMs = Date.parse(

View File

@ -473,6 +473,7 @@ describe('OrchestrationDb', () => {
const after3 = d.failDispatch(ctx3.id, 'timeout')
expect(after3?.failure_count).toBe(3)
expect(after3?.status).toBe('circuit_broken')
expect([after1, after2, after3].every((dispatch) => dispatch?.completed_at)).toBe(true)
expect(d.getTask(task.id)?.status).toBe('failed')
})

View File

@ -5826,6 +5826,7 @@ export class OrchestrationDb {
.prepare(
`UPDATE dispatch_contexts
SET status = ?, failure_count = ?, last_failure = ?,
completed_at = COALESCE(completed_at, datetime('now')),
capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
WHERE id = ?`
)

View File

@ -356,6 +356,35 @@ describe('agent sleep coordinator', () => {
expect(shutdown).not.toHaveBeenCalled()
})
it('rechecks dispatch settlement before shutdown', async () => {
vi.useFakeTimers()
const completed = {
...entry(),
orchestration: {
taskId: 'task-1',
dispatchId: 'ctx-1',
dispatchStatus: 'completed' as const
}
}
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
agentStatusByPaneKey: { [completed.paneKey]: completed }
})
startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW })
await vi.advanceTimersByTimeAsync(1000)
useAppStore.setState({
agentStatusByPaneKey: {
[completed.paneKey]: {
...completed,
orchestration: { ...completed.orchestration, dispatchStatus: 'dispatched' }
}
}
})
await vi.advanceTimersByTimeAsync(1000)
expect(shutdown).not.toHaveBeenCalled()
})
it('restarts confirmation when a foreground terminal visit refreshes idle state', async () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)

View File

@ -134,6 +134,67 @@ describe('agent sleep planner', () => {
).toEqual([])
})
it('blocks done panes until their live subagent roster clears', () => {
const withIdleTeammate = entry({
subagents: [
{
id: 'reviewer-1',
agentType: 'reviewer',
state: 'idle',
startedAt: OLD
}
]
})
expect(
plannedPaneKeys(
snapshot({ agentStatusByPaneKey: { [withIdleTeammate.paneKey]: withIdleTeammate } })
)
).toEqual([])
const cleared = { ...withIdleTeammate, subagents: undefined }
expect(
plannedPaneKeys(snapshot({ agentStatusByPaneKey: { [cleared.paneKey]: cleared } }))
).toEqual([cleared.paneKey])
})
it.each([undefined, 'pending', 'dispatched'] as const)(
'blocks orchestration panes while dispatch status is %s',
(dispatchStatus) => {
const orchestrated = entry({
orchestration: {
taskId: 'task-1',
dispatchId: 'ctx-1',
...(dispatchStatus ? { dispatchStatus } : {})
}
})
expect(
plannedPaneKeys(
snapshot({ agentStatusByPaneKey: { [orchestrated.paneKey]: orchestrated } })
)
).toEqual([])
}
)
it.each(['completed', 'failed', 'circuit_broken'] as const)(
'allows orchestration panes after authoritative %s settlement',
(dispatchStatus) => {
const orchestrated = entry({
orchestration: {
taskId: 'task-1',
dispatchId: 'ctx-1',
dispatchStatus
}
})
expect(
plannedPaneKeys(
snapshot({ agentStatusByPaneKey: { [orchestrated.paneKey]: orchestrated } })
)
).toEqual([orchestrated.paneKey])
}
)
it('requires the idle threshold and blocks input after done', () => {
const fresh = entry({ updatedAt: NOW - 1_000 })
expect(

View File

@ -110,6 +110,12 @@ function getEntryTabId(entry: AgentStatusEntry): string | null {
return parsePaneKey(entry.paneKey)?.tabId ?? null
}
// Why: provider done hooks can fire mid-Dispatch; only runtime-confirmed settlement makes sleep safe.
const hasUnsettledOrUnknownDispatch = ({ orchestration }: AgentStatusEntry): boolean =>
orchestration
? !['completed', 'failed', 'circuit_broken'].includes(orchestration.dispatchStatus ?? '')
: false
function getEligiblePane(args: {
entry: AgentStatusEntry
tab: TerminalTab
@ -140,6 +146,8 @@ function getEligiblePane(args: {
if (
entry.state !== 'done' ||
entry.interrupted === true ||
Boolean(entry.subagents?.length) ||
hasUnsettledOrUnknownDispatch(entry) ||
(sleepingRecord && !hasOnlyLivePiCompatibleRecoveryIdentity)
) {
return null

View File

@ -265,6 +265,63 @@ describe('agent status runtime orchestration metadata', () => {
})
})
it('updates runtime status for the same dispatch', () => {
vi.useFakeTimers()
const store = createTestStore()
const childPaneKey = 'tab-child:11111111-1111-4111-8111-111111111111'
store.getState().setAgentStatus(childPaneKey, {
state: 'done',
prompt: 'child agent',
agentType: 'claude',
orchestration: {
taskId: 'task-1',
dispatchId: 'ctx-1',
dispatchStatus: 'dispatched'
}
})
store.getState().setRuntimeAgentOrchestrationByPaneKey({
[childPaneKey]: {
taskId: 'task-1',
dispatchId: 'ctx-1',
dispatchStatus: 'completed'
}
})
expect(store.getState().agentStatusByPaneKey[childPaneKey].orchestration).toMatchObject({
taskId: 'task-1',
dispatchId: 'ctx-1',
dispatchStatus: 'completed'
})
})
it.each(['failed', 'circuit_broken'] as const)(
'updates runtime status to %s for the same dispatch',
(dispatchStatus) => {
vi.useFakeTimers()
const store = createTestStore()
const childPaneKey = 'tab-child:11111111-1111-4111-8111-111111111111'
store.getState().setAgentStatus(childPaneKey, {
state: 'done',
prompt: 'child agent',
agentType: 'claude',
orchestration: {
taskId: 'task-1',
dispatchId: 'ctx-1',
dispatchStatus: 'dispatched'
}
})
store.getState().setRuntimeAgentOrchestrationByPaneKey({
[childPaneKey]: { taskId: 'task-1', dispatchId: 'ctx-1', dispatchStatus }
})
expect(
store.getState().agentStatusByPaneKey[childPaneKey].orchestration?.dispatchStatus
).toBe(dispatchStatus)
}
)
it('keeps current payload orchestration ahead of a stale runtime map entry', () => {
vi.useFakeTimers()
const store = createTestStore()

View File

@ -1090,6 +1090,7 @@ function orchestrationContextsEqual(
return (
a.taskId === b.taskId &&
a.dispatchId === b.dispatchId &&
a.dispatchStatus === b.dispatchStatus &&
a.taskTitle === b.taskTitle &&
a.displayName === b.displayName &&
a.parentTerminalHandle === b.parentTerminalHandle &&

View File

@ -3008,6 +3008,27 @@ describe('shared agent-hook-listener', () => {
expect(stop?.payload.subagents).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 working while a background subagent is still running', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' })
claudeEvent({

View File

@ -31,6 +31,7 @@ import {
claudeRosterToSnapshots,
claudeTeammateIdMatchesName,
foldClaudeBackgroundTasksIntoRoster,
hasActiveClaudeNonAgentBackgroundWork,
idleClaudeTeammateByName,
readClaudeBackgroundAgentTasks,
reapRestoredClaudeSubagentsWithoutLiveAgent,
@ -2600,6 +2601,9 @@ function normalizeClaudeEvent(
if (!stateName) {
return null
}
const hasActiveNonAgentBackgroundWork =
(eventName === 'Stop' || eventName === 'StopFailure') &&
hasActiveClaudeNonAgentBackgroundWork(hookPayload)
const eventAgentId = readString(hookPayload, 'agent_id')
// Why: subagent/teammate events carry `agent_id` (lead's don't); child tool activity keeps its row live but must not become the lead's state or overwrite its tool/prompt caches (a live card would vanish).
@ -2682,10 +2686,13 @@ function normalizeClaudeEvent(
...(stateBeforeWait ? { stateBeforeWait } : {})
})
// Why: a lead Stop isn't "done" while subagents/teammates run (would show a finished row mid-flight); Claude re-wakes the lead, so a later empty-roster Stop resolves to done.
// Why: a lead Stop isn't done while children, shells, or crons remain live; a later drained Stop resolves it.
const roster = state.claudeSubagentRosterByPaneKey.get(paneKey)
const effectiveState =
stateName === 'done' && claudeRosterHasWorkingSubagent(roster) ? 'working' : stateName
stateName === 'done' &&
(hasActiveNonAgentBackgroundWork || claudeRosterHasWorkingSubagent(roster))
? 'working'
: stateName
return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, {
stateName: effectiveState,

View File

@ -60,6 +60,8 @@ export const AGENT_STATE_HISTORY_MAX = 20
export type AgentStatusOrchestrationContext = {
taskId: string
dispatchId: string
/** Runtime-authoritative lifecycle state. Hook-only contexts may omit it. */
dispatchStatus?: 'pending' | 'dispatched' | 'completed' | 'failed' | 'circuit_broken'
taskTitle?: string
displayName?: string
parentTerminalHandle?: string

View File

@ -5,6 +5,7 @@ import {
claudeRosterToSnapshots,
claudeTeammateIdMatchesName,
foldClaudeBackgroundTasksIntoRoster,
hasActiveClaudeNonAgentBackgroundWork,
idleClaudeTeammateByName,
readClaudeBackgroundAgentTasks,
reapRestoredClaudeSubagentsWithoutLiveAgent,
@ -202,6 +203,27 @@ describe('claude-subagent-roster', () => {
])
})
it('detects live non-agent background work without treating agent tasks as shell work', () => {
expect(
hasActiveClaudeNonAgentBackgroundWork({
background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }]
})
).toBe(true)
expect(hasActiveClaudeNonAgentBackgroundWork({ session_crons: [{ id: 'cron-1' }] })).toBe(true)
expect(
hasActiveClaudeNonAgentBackgroundWork({
background_tasks: [
{ id: 'agent-1', type: 'subagent', status: 'running' },
{ id: 'team-1', type: 'teammate', status: 'running' }
]
})
).toBe(false)
expect(hasActiveClaudeNonAgentBackgroundWork({ background_tasks: [], session_crons: [] })).toBe(
false
)
expect(hasActiveClaudeNonAgentBackgroundWork({})).toBe(false)
})
it('reports background_tasks as absent when missing or malformed', () => {
expect(readClaudeBackgroundAgentTasks({}).present).toBe(false)
expect(readClaudeBackgroundAgentTasks({ background_tasks: 'nope' }).present).toBe(false)

View File

@ -142,6 +142,27 @@ export function stopClaudeSubagent(roster: ClaudeSubagentRoster, id: string): vo
tracked.state = 'idle'
}
/** Shell tasks and session crons outlive a lead Stop but do not belong in the agent roster. */
export function hasActiveClaudeNonAgentBackgroundWork(
hookPayload: Record<string, unknown>
): boolean {
const sessionCrons = hookPayload['session_crons']
if (Array.isArray(sessionCrons) && sessionCrons.length > 0) {
return true
}
const backgroundTasks = hookPayload['background_tasks']
return (
Array.isArray(backgroundTasks) &&
backgroundTasks.some((item) => {
if (typeof item !== 'object' || item === null) {
return false
}
const task = item as Record<string, unknown>
return task.status === 'running' && task.type !== 'subagent' && task.type !== 'teammate'
})
)
}
/** Read the agent-typed entries of a hook payload's `background_tasks` field.
* `present: false` means the field was absent/malformed (older Claude builds),
* so callers must keep their tracked roster instead of clearing it. */