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:
parent
676964b099
commit
9bf05b0a9c
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = ?`
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue