diff --git a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts index 8d0d6a3975c..fa5cd7457fa 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from './server' -import { buildBody, PANE, RUNNING_SHELL } from './server.test-fixtures' +import { buildBody, PANE } from './server.test-fixtures' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), @@ -222,12 +222,20 @@ describe('AgentHookServer listener replay', () => { await expect( postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' }) ).resolves.toMatchObject({ status: 204 }) + // Why: a recognised shell now retires the pane to 'done', which short-circuits + // inference before this guard runs — so it must be unclassifiable work that keeps + // the pane 'working' here, or the assertion below proves nothing (STA-4119). await expect( - postHook({ hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) + postHook({ + hook_event_name: 'Stop', + background_tasks: [{ id: 'task-1', type: 'workflow', status: 'running' }] + }) ).resolves.toMatchObject({ status: 204 }) const baseline = server.getStatusSnapshot()[0] + expect(baseline).toMatchObject({ state: 'working' }) expect(baseline).not.toHaveProperty('claudeRunningNonAgentTask') + expect(baseline).not.toHaveProperty('claudeUnclassifiedBackgroundTask') expect( server.inferInterrupt({ paneKey: PANE, @@ -263,6 +271,88 @@ describe('AgentHookServer listener replay', () => { } }) + // Why: STA-4119. The set survives into the NEXT turn, so while a recognised shell was + // still in it this guard could only fire on a genuinely active turn the user just + // cancelled — the opposite of what its comment promises. Only unclassifiable work + // (which can still hold an idle pane at 'working') may block inference now. + it('lets a recognised background shell be interrupted but not unclassifiable work', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const ingest = (envelope: Record): void => { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + ...envelope, + payload: { state: 'working', prompt: 'second turn', agentType: 'claude' } + } as Parameters[0], + 'conn-1' + ) + } + const infer = (): boolean => { + const baseline = server.getStatusSnapshot()[0] + vi.setSystemTime(Number(baseline.receivedAt) + 500) + return server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'second turn', + baselineAgentType: 'claude', + intent: 'plain-escape' + }) + } + + ingest({ claudeRunningNonAgentTask: true, claudeUnclassifiedBackgroundTask: true }) + expect(infer()).toBe(false) + + vi.setSystemTime(3_000) + ingest({ claudeRunningNonAgentTask: true, claudeUnclassifiedBackgroundTask: false }) + expect(infer()).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + // Why: a host older than STA-4119 sends only the broad field, and there it gated pane + // state. Reading it as the fail-active signal reproduces that host exactly. + it('falls back to the legacy field when an older host omits the unclassified flag', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + claudeRunningNonAgentTask: true, + payload: { state: 'working', prompt: 'legacy host', agentType: 'claude' } + }, + 'conn-1' + ) + expect(server._getStateForTests().claudeUnclassifiedBackgroundTaskPaneKeys.has(PANE)).toBe( + true + ) + const baseline = server.getStatusSnapshot()[0] + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'legacy host', + baselineAgentType: 'claude', + intent: 'plain-escape' + }) + ).toBe(false) + } finally { + vi.useRealTimers() + } + }) + it('uses replayed Claude background metadata only before a live observation', () => { vi.useFakeTimers() vi.setSystemTime(1_000) diff --git a/src/main/agent-hooks/server-last-status-write.test.ts b/src/main/agent-hooks/server-last-status-write.test.ts index 6a88e162316..d829801a74a 100644 --- a/src/main/agent-hooks/server-last-status-write.test.ts +++ b/src/main/agent-hooks/server-last-status-write.test.ts @@ -84,7 +84,11 @@ describe('Last-status persistence', () => { createHash('sha256').update('launch-bearer-must-not-persist').digest('hex') ) expect(file.entries[PANE].claudeRunningNonAgentTask).toBeUndefined() + expect(file.entries[PANE].claudeUnclassifiedBackgroundTask).toBeUndefined() expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('claudeRunningNonAgentTask') + expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain( + 'claudeUnclassifiedBackgroundTask' + ) expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('launch-bearer-must-not-persist') } finally { server.stop() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index b301e99d6df..7bc17033877 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -106,6 +106,12 @@ type EnrichedAgentHookEventPayload = AgentHookEventPayload & { claudeLeadBoundaryChildOnly?: true } +type ClaudeBackgroundEvidence = { + hasRunningTask: boolean + hasUnclassifiedTask: boolean + hasActiveCron: boolean +} + type NormalizedLocalHook = { event: AgentHookEventPayload | null onAccepted?: () => void @@ -113,7 +119,11 @@ type NormalizedLocalHook = { type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, - 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' + | 'claudeRunningNonAgentTask' + | 'claudeUnclassifiedBackgroundTask' + | 'launchToken' + | 'promptInteractionKey' + | 'restoredUnconfirmed' > & { launchTokenHash?: string } @@ -934,10 +944,13 @@ export class AgentHookServer { if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) { return false } - // Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons. + // Why: Escape/Ctrl+C does not stop unclassified background work or session crons, and + // those are the only inventory that can still hold an idle pane at 'working'. A recognised + // shell no longer gates, so keeping it here would only block inference on a REAL turn the + // user just cancelled — the set survives into the next turn (STA-4119). if ( agentType === 'claude' && - (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || + (this.state.claudeUnclassifiedBackgroundTaskPaneKeys.has(existing.paneKey) || this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey)) ) { return false @@ -1982,32 +1995,42 @@ export class AgentHookServer { if (!paneKey) { return { event: normalizeHookPayload(this.state, source, body, this.env) } } - const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) - const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) + const previous = this.readClaudeBackgroundEvidence(paneKey) const event = normalizeHookPayload(this.state, source, body, this.env) - const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) - const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) - this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron) + const next = this.readClaudeBackgroundEvidence(paneKey) + this.setClaudeBackgroundEvidence(paneKey, previous) if (!event || event.paneKey !== paneKey) { return { event } } // Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate. return { event, - onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron) + onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, next) + } + } + + private readClaudeBackgroundEvidence(paneKey: string): ClaudeBackgroundEvidence { + return { + hasRunningTask: this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey), + hasUnclassifiedTask: this.state.claudeUnclassifiedBackgroundTaskPaneKeys.has(paneKey), + hasActiveCron: this.state.claudeActiveSessionCronPaneKeys.has(paneKey) } } private setClaudeBackgroundEvidence( paneKey: string, - hasRunningTask: boolean, - hasActiveCron: boolean + { hasRunningTask, hasUnclassifiedTask, hasActiveCron }: ClaudeBackgroundEvidence ): void { if (hasRunningTask) { this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) } else { this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) } + if (hasUnclassifiedTask) { + this.state.claudeUnclassifiedBackgroundTaskPaneKeys.add(paneKey) + } else { + this.state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey) + } if (hasActiveCron) { this.state.claudeActiveSessionCronPaneKeys.add(paneKey) } else { @@ -2130,6 +2153,7 @@ export class AgentHookServer { /** Payload fields the relay dropped to fit an oversized frame; validated below. */ shedFields?: unknown claudeRunningNonAgentTask?: unknown + claudeUnclassifiedBackgroundTask?: unknown payload: unknown }, connectionId: string @@ -2279,6 +2303,12 @@ export class AgentHookServer { ) { return } + // Why: a pre-STA-4119 host sends only the broad field. It gated pane state there, so + // reading it as the fail-active signal reproduces that host's behaviour exactly. + const unclassifiedBackgroundTask = + typeof envelope.claudeUnclassifiedBackgroundTask === 'boolean' + ? envelope.claudeUnclassifiedBackgroundTask + : envelope.claudeRunningNonAgentTask const applyClaudeBackgroundWork = normalizedPayload.agentType === 'claude' && typeof envelope.claudeRunningNonAgentTask === 'boolean' && @@ -2313,6 +2343,8 @@ export class AgentHookServer { typeof envelope.claudeRunningNonAgentTask === 'boolean' ? envelope.claudeRunningNonAgentTask : undefined, + claudeUnclassifiedBackgroundTask: + typeof unclassifiedBackgroundTask === 'boolean' ? unclassifiedBackgroundTask : undefined, payload: normalizedPayload } this.recordCurrentAuthorityObservation(event) @@ -2325,6 +2357,11 @@ export class AgentHookServer { } else { this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) } + if (unclassifiedBackgroundTask) { + this.state.claudeUnclassifiedBackgroundTaskPaneKeys.add(paneKey) + } else { + this.state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey) + } } : undefined ) @@ -2551,6 +2588,7 @@ export class AgentHookServer { this.state.claudeSubagentRosterByPaneKey.delete(paneKey) this.state.claudeLeadStateByPaneKey.delete(paneKey) this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + this.state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey) this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) } } @@ -3050,6 +3088,7 @@ export class AgentHookServer { const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true const { claudeRunningNonAgentTask: _claudeRunningNonAgentTask, + claudeUnclassifiedBackgroundTask: _claudeUnclassifiedBackgroundTask, promptInteractionKey: _promptInteractionKey, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. restoredUnconfirmed: _restoredUnconfirmed, diff --git a/src/main/index.ts b/src/main/index.ts index 90aed9840ef..560278b125f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1582,6 +1582,7 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow providerSessionOnly, promptInteractionKey, restoredUnconfirmed, + claudeRunningNonAgentTask, isReplay }) => { if (mainWindow?.isDestroyed()) { @@ -1630,6 +1631,9 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + // Why: the hibernation planner needs "is provider background work still live", which + // is not derivable from `state` once a leftover shell stops gating it (STA-4119). + ...(claudeRunningNonAgentTask ? { providerBackgroundWorkActive: true } : {}), ...(orchestration ? { orchestration } : {}) } mainWindow?.webContents.send('agentStatus:set', statusEvent) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index eb59d0a56fa..c6baafb5be0 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1509,6 +1509,7 @@ export class SshRelaySession { providerSessionOnly?: unknown shedFields?: unknown claudeRunningNonAgentTask?: unknown + claudeUnclassifiedBackgroundTask?: unknown payload?: unknown } if (typeof envelope.paneKey !== 'string') { @@ -1548,6 +1549,10 @@ export class SshRelaySession { typeof envelope.claudeRunningNonAgentTask === 'boolean' ? envelope.claudeRunningNonAgentTask : undefined, + claudeUnclassifiedBackgroundTask: + typeof envelope.claudeUnclassifiedBackgroundTask === 'boolean' + ? envelope.claudeUnclassifiedBackgroundTask + : undefined, payload: envelope.payload }, this.targetId diff --git a/src/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index 34b4cbbdec6..ea6dee2be97 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -31,6 +31,7 @@ export function buildRelayHookEnvelope( teammateName: event.teammateName, toolAgentType: event.toolAgentType, claudeRunningNonAgentTask: event.claudeRunningNonAgentTask, + claudeUnclassifiedBackgroundTask: event.claudeUnclassifiedBackgroundTask, ...(event.providerSession ? { providerSession: event.providerSession } : {}), ...(event.providerSessionOnly ? { providerSessionOnly: true } : {}), isReplay: options.isReplay === true ? true : undefined, diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 103db5fdb05..40e43fd2fc0 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -97,10 +97,34 @@ describe('RelayAgentHookServer', () => { }) }) + // Why: the host still reports live provider work (hibernation reads it) while the + // fail-active gate stays clear, so the pane is free to settle to 'done'. expect(forward.mock.calls[0][0]).toMatchObject({ claudeRunningNonAgentTask: true, + claudeUnclassifiedBackgroundTask: false, payload: { state: 'done', agentType: 'claude' } }) + + await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + payload: { + hook_event_name: 'Stop', + background_tasks: [{ id: 'task-1', type: 'agent', status: 'running' }] + } + }) + }) + + expect(forward.mock.calls[1][0]).toMatchObject({ + claudeRunningNonAgentTask: true, + claudeUnclassifiedBackgroundTask: true, + payload: { state: 'working', agentType: 'claude' } + }) } finally { server.stop() } diff --git a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts index 609b3fb562c..aa06c8026da 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts @@ -32,6 +32,7 @@ export type AgentStatusSetData = { interrupted?: boolean sessionBoundary?: boolean turnCompletedAt?: number + providerBackgroundWorkActive?: boolean terminalHandle?: string launchToken?: string providerSession?: { key: 'session_id'; id: string } diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts index 0a9cb1a6c30..0c960c24bcb 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts @@ -89,4 +89,76 @@ describe('useIpcEvents agent status turn completion', () => { }) ) }) + + // Why: envelope-level fields are dropped by the payload rebuild in applyAgentStatus, and + // this one is what stops hibernation from killing a live dev server (STA-4119). The + // whitelist has silently eaten fields before, so pin it here rather than in the slice. + it('forwards providerBackgroundWorkActive through the agent-status IPC whitelist', async () => { + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Claude' }] + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: vi.fn(), + resetAgentHookCompletionNotificationCoordinators: vi.fn(), + syncAgentHookCompletionNotificationSettings: vi.fn(), + syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + await Promise.resolve() + + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + const base = { + paneKey: FUTURE_PANE_KEY, + tabId: 'tab-future', + worktreeId: 'wt-1', + state: 'done', + prompt: 'start the dev server', + agentType: 'claude', + receivedAt: 1_700_000_005_000, + stateStartedAt: 1_700_000_000_000 + } as const + + onSetListenerRef.current({ ...base, providerBackgroundWorkActive: true }) + expect(setAgentStatus).toHaveBeenLastCalledWith( + FUTURE_PANE_KEY, + expect.objectContaining({ state: 'done', providerBackgroundWorkActive: true }), + expect.anything(), + expect.anything(), + expect.anything(), + undefined + ) + + expect(setAgentStatus).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 55705a8384e..6dc1fe5ab20 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3357,6 +3357,12 @@ export function useIpcEvents(): void { data.restoredUnconfirmed === true ? { ...statusPayloadWithTurnBoundary, restoredUnconfirmed: true } : statusPayloadWithTurnBoundary + // Why: same whitelist trap — envelope-level background-work evidence the payload + // rebuild drops, and pane hibernation reads it to avoid killing a live dev server. + const statusPayloadWithBackgroundWork = + data.providerBackgroundWorkActive === true + ? { ...statusPayloadWithProvenance, providerBackgroundWorkActive: true } + : statusPayloadWithProvenance const identity = resolveAgentStatusIdentity({ existing: existingStatus ? { @@ -3396,7 +3402,7 @@ export function useIpcEvents(): void { const statusWorktreeId = data.worktreeId ?? owningWorktreeId const update: AgentStatusUpdate = { paneKey, - payload: statusPayloadWithProvenance, + payload: statusPayloadWithBackgroundWork, terminalTitle, timing: { updatedAt: data.receivedAt, diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index f825fc9758d..e5c0db138f0 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -102,6 +102,26 @@ describe('agent sleep planner', () => { expect(plannedWorktrees(snapshot({ foregroundTerminalTabIds: ['tab-1'] }))).toEqual([]) }) + // Why: STA-4119 let a pane reach `done` while a provider-owned shell keeps running, so + // `done` alone stopped implying "safe to kill this PTY". Hibernation tears the PTY down, + // which would take the user's dev server with it. + it('refuses a done pane whose provider still has live background work', () => { + const busy = entry({ providerBackgroundWorkActive: true }) + expect(plannedWorktrees(snapshot({ agentStatusByPaneKey: { [busy.paneKey]: busy } }))).toEqual( + [] + ) + + const settled = entry({ providerBackgroundWorkActive: false }) + expect( + plannedWorktrees(snapshot({ agentStatusByPaneKey: { [settled.paneKey]: settled } })) + ).toEqual(['wt-bg']) + + const unknown = entry() + expect( + plannedWorktrees(snapshot({ agentStatusByPaneKey: { [unknown.paneKey]: unknown } })) + ).toEqual(['wt-bg']) + }) + it('requires done resumable provider-session entries', () => { for (const state of ['working', 'waiting', 'blocked'] as const) { const e = entry({ state }) diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index c350296d9b3..e7840d2a183 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -148,6 +148,11 @@ function getEligiblePane(args: { entry.state !== 'done' || entry.interrupted === true || Boolean(entry.subagents?.length) || + // Why: `done` means the agent is idle, not that the pane is disposable. A provider-owned + // background shell or session cron is still running under this PTY, and hibernation kills + // it — that is the user's dev server. Idle and safe-to-tear-down are different questions + // and STA-4119 is exactly what pulled them apart. + entry.providerBackgroundWorkActive === true || 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 d64b99d74f8..67117eb5c28 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -198,3 +198,33 @@ describe('agent status stateStartedAt', () => { expect(entry.updatedAt).toBe(2_000) }) }) + +describe('provider background-work evidence', () => { + // Why: the pane's own hook stream restates this on every accepted status, so the entry must + // track it exactly — a value carried over from the previous row would keep a settled pane + // permanently ineligible for hibernation, and a dropped one would let hibernation kill a + // live dev server (STA-4119). + it('writes through on every accepted status instead of carrying the previous value', () => { + const store = createTestStore() + + store.getState().setAgentStatus('tab-1:1', { + state: 'done', + prompt: 'start the dev server', + agentType: 'claude', + providerBackgroundWorkActive: true + }) + expect(store.getState().agentStatusByPaneKey['tab-1:1']).toMatchObject({ + state: 'done', + providerBackgroundWorkActive: true + }) + + store.getState().setAgentStatus('tab-1:1', { + state: 'done', + prompt: 'dev server stopped', + agentType: 'claude' + }) + expect( + store.getState().agentStatusByPaneKey['tab-1:1']?.providerBackgroundWorkActive + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index e3b27077bf4..17c1ba964cc 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -112,6 +112,7 @@ export type AgentStatusPayload = ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext promptInteractionKey?: string restoredUnconfirmed?: boolean + providerBackgroundWorkActive?: boolean } export type AgentStatusTiming = { updatedAt?: number; stateStartedAt?: number } @@ -2194,6 +2195,9 @@ export const createAgentStatusSlice: StateCreator /** One-normalization provenance marker for a status backed only by restored child state. */ claudeUnconfirmedRestoredStatusPaneKeys: Set - /** Panes whose latest authoritative Claude task inventory still has running non-agent work. */ + /** Panes whose latest authoritative Claude task inventory still has running non-agent work. + * Broad on purpose: it includes a recognised background shell, so consumers that must not + * destroy live provider work (pane hibernation) can see it. It does NOT gate pane state. */ claudeRunningNonAgentTaskPaneKeys: Set + /** Panes whose latest authoritative Claude task inventory still has running work Orca could + * not positively classify as a non-agent shell. Fail-active: this DOES gate pane state. */ + claudeUnclassifiedBackgroundTaskPaneKeys: Set /** Panes whose latest authoritative Claude cron inventory still has a scheduled job. */ claudeActiveSessionCronPaneKeys: Set /** Live thread-spawn children per Codex pane. */ @@ -181,6 +186,7 @@ export function createHookListenerState(): HookListenerState { claudeLeadStateByPaneKey: new Map(), claudeUnconfirmedRestoredStatusPaneKeys: new Set(), claudeRunningNonAgentTaskPaneKeys: new Set(), + claudeUnclassifiedBackgroundTaskPaneKeys: new Set(), claudeActiveSessionCronPaneKeys: new Set(), codexSubagentRosterByPaneKey: new Map(), codexSubagentTranscriptByPaneKey: new Map(), @@ -198,6 +204,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.claudeLeadStateByPaneKey.delete(paneKey) state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) state.codexSubagentRosterByPaneKey.delete(paneKey) state.codexSubagentTranscriptByPaneKey.delete(paneKey) @@ -245,6 +252,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.claudeLeadStateByPaneKey, fromPaneKey, toPaneKey) movePaneScopedSetEntries(state.claudeUnconfirmedRestoredStatusPaneKeys, fromPaneKey, toPaneKey) movePaneScopedSetEntries(state.claudeRunningNonAgentTaskPaneKeys, fromPaneKey, toPaneKey) + movePaneScopedSetEntries(state.claudeUnclassifiedBackgroundTaskPaneKeys, fromPaneKey, toPaneKey) movePaneScopedSetEntries(state.claudeActiveSessionCronPaneKeys, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) @@ -290,6 +298,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.claudeLeadStateByPaneKey.clear() state.claudeUnconfirmedRestoredStatusPaneKeys.clear() state.claudeRunningNonAgentTaskPaneKeys.clear() + state.claudeUnclassifiedBackgroundTaskPaneKeys.clear() state.claudeActiveSessionCronPaneKeys.clear() state.codexSubagentRosterByPaneKey.clear() state.codexSubagentTranscriptByPaneKey.clear() @@ -365,6 +374,8 @@ export type AgentHookEventPayload = { isReplay?: boolean /** Transport-only Claude background-work evidence used to reject false input-based interrupts. */ claudeRunningNonAgentTask?: boolean + /** See claudeUnclassifiedBackgroundTaskPaneKeys. Absent from pre-STA-4119 hosts. */ + claudeUnclassifiedBackgroundTask?: boolean payload: ParsedAgentStatusPayload } @@ -2584,6 +2595,7 @@ function updateClaudeRunningNonAgentTask( state: HookListenerState, paneKey: string, hasRunningNonAgentTask: boolean, + hasRunningUnclassifiedTask: boolean, interrupted: boolean ): void { if (hasRunningNonAgentTask && !interrupted) { @@ -2591,6 +2603,11 @@ function updateClaudeRunningNonAgentTask( } else { state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) } + if (hasRunningUnclassifiedTask && !interrupted) { + state.claudeUnclassifiedBackgroundTaskPaneKeys.add(paneKey) + } else { + state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey) + } } function resolveClaudePaneState( @@ -2602,11 +2619,14 @@ 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). + // Why: a recognised leftover shell is session inventory, not lead-turn work. Gating + // done→working on it latches the pane forever, because an idle parent never emits + // another background_tasks list (STA-4119). Work Orca could NOT classify still gates: + // it may be a child agent, and silently retiring live agent work is the worse failure. return claudeRosterHasWorkingSubagent(roster) || - (!lead.interrupted && state.claudeActiveSessionCronPaneKeys.has(paneKey)) + (!lead.interrupted && + (state.claudeUnclassifiedBackgroundTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey))) ? 'working' : 'done' } @@ -2680,7 +2700,8 @@ function normalizeClaudeSubagentLifecycleEvent( const hasConfirmedDoneGate = cachedLead?.state === 'done' && cachedLead.interrupted !== true && - state.claudeActiveSessionCronPaneKeys.has(paneKey) + (state.claudeUnclassifiedBackgroundTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey)) const restoredOnlyDoneGate = cachedLead?.state === 'done' && !hasConfirmedDoneGate && hasUnconfirmedChild if (roster?.size === 0) { @@ -2705,6 +2726,7 @@ function normalizeClaudeSubagentLifecycleEvent( export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done', interrupted: true }) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) } @@ -2903,6 +2925,7 @@ function normalizeClaudeEvent( // fresh session's idle row back up to 'working' (same reset Codex does on SessionStart). state.claudeSubagentRosterByPaneKey.delete(paneKey) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' }) return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { @@ -2960,6 +2983,7 @@ function normalizeClaudeEvent( state, paneKey, backgroundTasks.hasRunningNonAgentTask, + backgroundTasks.hasRunningUnclassifiedTask, interrupted === true ) } @@ -3074,6 +3098,7 @@ function normalizeClaudeEvent( if (interrupted && eventAgentId === undefined) { state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) } @@ -3107,6 +3132,7 @@ function normalizeClaudeEvent( effectiveState === 'working' && claudeRosterHasRestoredSnapshotSubagent(effectiveRoster) && !claudeRosterHasRuntimeWorkingSubagent(effectiveRoster) && + !state.claudeUnclassifiedBackgroundTaskPaneKeys.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. @@ -4585,8 +4611,15 @@ export function normalizeHookPayload( toolAgentType: readString(hookPayloadRecord, 'agent_type'), ...(source === 'claude' ? { + // Why: unchanged meaning — ANY live provider-owned background work, shell + // included. Consumers that must not tear the pane down read this one. claudeRunningNonAgentTask: state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey), + // Why: the narrower fail-active signal that gates pane state. Optional on the + // wire: an older host omits it and the client falls back to the broad field. + claudeUnclassifiedBackgroundTask: + state.claudeUnclassifiedBackgroundTaskPaneKeys.has(paneKey) || state.claudeActiveSessionCronPaneKeys.has(paneKey) } : {}), diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 0ea80999766..8c56e446a9d 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -103,8 +103,13 @@ export type AgentHookRelayEnvelope = { providerSessionOnly?: boolean /** True when the relay is replaying its cache after Orca reconnects. */ isReplay?: boolean - /** Claude background-work evidence for input-interrupt inference on the receiving host. */ + /** Claude background-work evidence for input-interrupt inference on the receiving host. + * Broad: any live provider-owned background work, recognised shells included. */ claudeRunningNonAgentTask?: boolean + /** The fail-active subset that still gates pane state: running background work the host + * could not positively classify as a non-agent shell. Optional — hosts older than + * STA-4119 omit it and the receiver falls back to `claudeRunningNonAgentTask`. */ + claudeUnclassifiedBackgroundTask?: boolean /** Forwarded from the agent CLI POST body. The relay default is `remote`, * which marks transport location rather than dev/prod build env. */ env?: string diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index c1e3db6e606..fce88a278d0 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -151,6 +151,12 @@ export type AgentStatusEntry = { * the transition may have been missed while no receiver was up, so freshness gates * treat the row as stale immediately. Cleared by any accepted live event. */ restoredUnconfirmed?: boolean + /** True while the provider still owns live background work on this pane — a running + * shell it launched, or a scheduled session cron — even though the turn itself is done. + * Why: `done` answers "is the agent idle", not "is this pane safe to tear down". Anything + * that kills the PTY (agent hibernation) must consult this or it silently kills the + * user's dev server. Live-only: a restarted listener cannot re-derive it. */ + providerBackgroundWorkActive?: boolean } export type MigrationUnsupportedPtyEntry = { @@ -252,16 +258,14 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { promptInteractionKey?: string /** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */ restoredUnconfirmed?: boolean + /** See AgentStatusEntry.providerBackgroundWorkActive. */ + providerBackgroundWorkActive?: boolean } /** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */ export type AgentStatusClearIpcPayload = | { paneKey: string } - | { - transient: true - connectionId: string - clearedAt: number - } + | { transient: true; connectionId: string; clearedAt: number } /** Maximum character length for the toolName field. */ export const AGENT_STATUS_TOOL_NAME_MAX_LENGTH = 60 diff --git a/src/shared/claude-background-task-inventory.ts b/src/shared/claude-background-task-inventory.ts index 33e38f64dfd..8c8f20e9b12 100644 --- a/src/shared/claude-background-task-inventory.ts +++ b/src/shared/claude-background-task-inventory.ts @@ -22,6 +22,12 @@ const CLAUDE_TERMINAL_BACKGROUND_TASK_STATUSES = new Set([ 'timed_out' ]) +/** Types Claude uses for a plain provider-owned background shell. Only a type on + * this list is positively identified as "not agent work" and therefore allowed to + * stop gating the pane; anything else fails active. Keep it a whitelist — an + * unrecognised type must never be assumed harmless (STA-4119). */ +const CLAUDE_NON_AGENT_SHELL_TASK_TYPES = new Set(['shell', 'background_shell']) + /** One agent entry from the `background_tasks` array Claude attaches to Stop * (and SubagentStop) hook payloads. Non-agent tasks do not become rows. */ export type ClaudeBackgroundAgentTask = { @@ -42,19 +48,35 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record @@ -65,12 +87,17 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record { expect(result).toMatchObject({ truncated: true, hasRunningNonAgentTask: true }) expect(result.tasks).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS) + // Why: with a full roster present, `working` is carried by the child rows, so this + // asserts the monitor ALONE — otherwise the monitor half of the gate is untested. + const monitorOnly = createHookListenerState() + claudeEvent(monitorOnly, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'watch' }) + expect( + claudeEvent(monitorOnly, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'monitor-1', type: 'monitor', status: 'pending' }] + }) + ).toMatchObject({ state: 'working', subagents: undefined }) + const state = createHookListenerState() expect( claudeEvent(state, SOURCE_PANE, { @@ -150,6 +161,79 @@ describe('Claude background task status', () => { ).toBe('working') }) + // Why: the whole point of STA-4119's narrowing. Only a POSITIVELY identified non-agent + // shell may stop gating; anything Orca cannot classify may still be agent work, and + // retiring live agent work is the worse failure. Guards the fail-active property in + // readClaudeBackgroundAgentTasks, which had no end-to-end coverage before. + it.each([ + { label: 'a future agent type', task: { id: 't', type: 'agent', status: 'running' } }, + { label: 'an unrecognised type', task: { id: 't', type: 'workflow', status: 'running' } }, + { label: 'a monitor', task: { id: 't', type: 'monitor', status: 'running' } }, + { label: 'a blank type', task: { id: 't', type: ' ', status: 'running' } }, + { label: 'an absent type', task: { id: 't', status: 'running' } } + ])('holds a finished lead at working for $label', ({ task }) => { + const state = createHookListenerState() + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + + expect( + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [task] }) + ).toMatchObject({ state: 'working', subagents: undefined }) + expect(state.claudeUnclassifiedBackgroundTaskPaneKeys.has(SOURCE_PANE)).toBe(true) + + // Why: it must still clear once the same inventory reports the entry finished — + // failing active may not become a second one-way latch. + expect( + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [{ ...task, status: 'completed' }] + })?.state + ).toBe('done') + expect(state.claudeUnclassifiedBackgroundTaskPaneKeys.has(SOURCE_PANE)).toBe(false) + }) + + it('holds a finished lead at working for a malformed inventory entry', () => { + const state = createHookListenerState() + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + + expect( + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: ['garbled'] }) + ?.state + ).toBe('working') + expect(state.claudeUnclassifiedBackgroundTaskPaneKeys.has(SOURCE_PANE)).toBe(true) + }) + + it.each([ + { label: 'shell', type: 'shell' }, + { label: 'background_shell', type: 'background_shell' }, + { label: 'SHELL cased and padded', type: ' Shell ' } + ])('does not hold a finished lead at working for a recognised $label', ({ type }) => { + const state = createHookListenerState() + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + + expect( + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'sh-1', type, status: 'running' }] + })?.state + ).toBe('done') + // Why: it stops gating pane state, but stays visible as live provider work so pane + // teardown (hibernation) can still refuse to kill it. + expect(state.claudeUnclassifiedBackgroundTaskPaneKeys.has(SOURCE_PANE)).toBe(false) + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) + }) + + it('keeps a recognised shell from masking an unclassified sibling', () => { + const state = createHookListenerState() + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + + expect( + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [RUNNING_SHELL, { id: 'x-1', type: 'agent', status: 'running' }] + })?.state + ).toBe('working') + }) + it('keeps pending task state when pane authority moves', () => { const state = createHookListenerState() claudeEvent(state, SOURCE_PANE, {