From 5023debf7cae272b5cda21f43b409232aaebe890 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:02:14 -0700 Subject: [PATCH] fix(agent-sleep): harden background work detection --- src/main/runtime/orca-runtime.test.ts | 55 +++++++++++++++++++ src/main/runtime/orca-runtime.ts | 17 ++++-- src/main/runtime/orchestration/db.test.ts | 1 + src/main/runtime/orchestration/db.ts | 1 + .../src/lib/agent-hibernation-planner.test.ts | 52 ++++++++++++++---- .../src/lib/agent-hibernation-planner.ts | 8 +-- .../src/store/slices/agent-status.test.ts | 27 +++++++++ src/shared/agent-hook-listener.test.ts | 21 +++++++ src/shared/agent-hook-listener.ts | 11 +++- src/shared/claude-subagent-roster.test.ts | 22 ++++++++ src/shared/claude-subagent-roster.ts | 21 +++++++ 11 files changed, 213 insertions(+), 23 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 1c29aaecf0b..0599b3d5d2e 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -36177,6 +36177,61 @@ describe('OrcaRuntimeService', () => { }) }) + 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' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 581c0c8ccd6..3d4fa9c4d39 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -29169,10 +29169,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 } @@ -29185,7 +29185,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 @@ -29208,12 +29211,16 @@ export class OrcaRuntimeService { } } - private getRecentCompletedDispatchForTerminal( + private getRecentSettledDispatchForTerminal( handle: string, db = this.getOrchestrationDbIfAvailable() ): ReturnType { 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( diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 4c89c2c10a3..04985a9ee0e 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -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') }) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 369032012e4..ddaeb172a72 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -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 = ?` ) diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index ac726c287ae..4c3a550642f 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -134,6 +134,29 @@ 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) => { @@ -153,19 +176,24 @@ describe('agent sleep planner', () => { } ) - it('allows orchestration panes after authoritative dispatch settlement', () => { - const orchestrated = entry({ - orchestration: { - taskId: 'task-1', - dispatchId: 'ctx-1', - dispatchStatus: 'completed' - } - }) + 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]) - }) + 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 }) diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index cedc179751b..6920d18ed1e 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -110,12 +110,11 @@ 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 +// 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 @@ -147,6 +146,7 @@ function getEligiblePane(args: { if ( entry.state !== 'done' || entry.interrupted === true || + Boolean(entry.subagents?.length) || hasUnsettledOrUnknownDispatch(entry) || (sleepingRecord && !hasOnlyLivePiCompatibleRecoveryIdentity) ) { diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 431337f4543..6954fcc7034 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -295,6 +295,33 @@ describe('agent status runtime orchestration metadata', () => { }) }) + 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() diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index f6bcc0c383f..098ceeff9a2 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -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({ diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 2294639df2f..e64b1941e4e 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -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, diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts index 977d96508ed..58bf9c6d99b 100644 --- a/src/shared/claude-subagent-roster.test.ts +++ b/src/shared/claude-subagent-roster.test.ts @@ -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) diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index 128b49e8010..50be0ab5ab9 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -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 +): 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 + 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. */