diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 153f7840760..1c29aaecf0b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -36036,6 +36036,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, @@ -36113,6 +36114,7 @@ describe('OrcaRuntimeService', () => { expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toMatchObject({ taskId: 'task-done', dispatchId: 'ctx-done', + dispatchStatus: 'completed', parentPaneKey: coordinatorPaneKey, parentTerminalHandle: coordinatorHandle }) @@ -36170,7 +36172,8 @@ describe('OrcaRuntimeService', () => { expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toEqual({ taskId: 'task-done', - dispatchId: 'ctx-done' + dispatchId: 'ctx-done', + dispatchStatus: 'completed' }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index abc82437657..581c0c8ccd6 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -29198,6 +29198,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 } : {}), diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts index 5f7f71ca94a..e6b7d41951f 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts @@ -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) diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index d29eaa8aab4..ac726c287ae 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -134,6 +134,39 @@ describe('agent sleep planner', () => { ).toEqual([]) }) + 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('allows orchestration panes after authoritative dispatch settlement', () => { + const orchestrated = entry({ + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'completed' + } + }) + + 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( diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index 8f33daed474..cedc179751b 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -110,6 +110,13 @@ function getEntryTabId(entry: AgentStatusEntry): string | null { return parsePaneKey(entry.paneKey)?.tabId ?? null } +function hasUnsettledOrUnknownDispatch({ orchestration }: AgentStatusEntry): boolean { + // Why: provider done hooks can fire mid-Dispatch; only runtime-confirmed settlement makes sleep safe. + return orchestration + ? !['completed', 'failed', 'circuit_broken'].includes(orchestration.dispatchStatus ?? '') + : false +} + function getEligiblePane(args: { entry: AgentStatusEntry tab: TerminalTab @@ -140,6 +147,7 @@ function getEligiblePane(args: { if ( entry.state !== 'done' || entry.interrupted === true || + hasUnsettledOrUnknownDispatch(entry) || (sleepingRecord && !hasOnlyLivePiCompatibleRecoveryIdentity) ) { return null diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index ca9bb491218..431337f4543 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -265,6 +265,36 @@ 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('keeps current payload orchestration ahead of a stale runtime map entry', () => { vi.useFakeTimers() const store = createTestStore() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 578b3ca5e67..dc2fde9f603 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -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 && diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index be22ce6e68d..157fc95a050 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -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