diff --git a/src/main/providers/local-pty-provider-foreground-process.test.ts b/src/main/providers/local-pty-provider-foreground-process.test.ts index b9e65af4e92..a10fd9a0579 100644 --- a/src/main/providers/local-pty-provider-foreground-process.test.ts +++ b/src/main/providers/local-pty-provider-foreground-process.test.ts @@ -196,12 +196,41 @@ describe('LocalPtyProvider', () => { 'powershell.exe', expect.any(Object) ) + await expect(provider.inspectProcess(id)).resolves.toEqual({ + foregroundProcess: 'powershell.exe', + hasChildProcesses: false + }) }) it('returns null for unknown PTY ids', async () => { expect(await provider.getForegroundProcess('nonexistent')).toBeNull() }) + it('marks a degraded local scan unavailable for completion-sensitive inspection', async () => { + resolveAgentForegroundProcessMock.mockResolvedValue({ + available: false, + processName: 'zsh' + }) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.inspectProcess(id)).resolves.toEqual({ + foregroundProcess: 'zsh', + hasChildProcesses: false, + unavailable: true + }) + }) + + it('reports live child evidence in completion-sensitive inspection', async () => { + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + mockProc.process = 'node' + resolveAgentForegroundProcessMock.mockResolvedValue({ available: true, processName: null }) + + await expect(provider.inspectProcess(id)).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: true + }) + }) + it('keeps a recognized agent across an unavailable scan without adding probes', async () => { resolveAgentForegroundProcessMock .mockResolvedValueOnce({ available: true, processName: 'claude' }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 223168b65c0..8a753bfc492 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -99,6 +99,7 @@ import { expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' import { resolveProcessExitCause, type TerminalExitCause } from '../../shared/terminal-exit-cause' +import type { PtyProcessInspection } from './pty-process-inspection' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -1419,11 +1420,13 @@ export class LocalPtyProvider implements IPtyProvider { } } - async getForegroundProcess(id: string): Promise { + private async inspectForegroundProcessResolution( + id: string + ): Promise<{ processName: string | null; available: boolean }> { const proc = ptyProcesses.get(id) if (!proc) { ptyLastRecognizedForeground.delete(id) - return null + return { processName: null, available: false } } const fallbackProcess = resolveForegroundFallbackProcess( proc.process || null, @@ -1444,7 +1447,7 @@ export class LocalPtyProvider implements IPtyProvider { try { const paneProcessIds = readWindowsPtyJobProcessIds(proc) if (ptyProcesses.get(id) !== proc) { - return null + return { processName: null, available: false } } const verdict = judgeCachedAgentJobEvidence({ jobProcessIds: paneProcessIds, @@ -1454,7 +1457,7 @@ export class LocalPtyProvider implements IPtyProvider { identityAgeMs: Date.now() - (cachedEntry?.at ?? 0) }) if (verdict === 'confirmed' || verdict === 'unproven') { - return cachedAgent + return { processName: cachedAgent, available: verdict === 'confirmed' } } if (verdict === 'exited') { // The shell stands alone in a complete, inescapable job list: no @@ -1486,7 +1489,7 @@ export class LocalPtyProvider implements IPtyProvider { ) // Why: the scan can outlive PTY teardown/id reuse; stale results must not resurrect cache for a foreign id. if (ptyProcesses.get(id) !== proc) { - return null + return { processName: null, available: false } } // Why: a degraded scan reporting shell-as-foreground fires a false "agent done"; keep last recognized agent instead. const lastRecognizedAgent = ptyLastRecognizedForeground.get(id)?.name ?? null @@ -1525,13 +1528,47 @@ export class LocalPtyProvider implements IPtyProvider { } else if (!stable.lastRecognizedAgent) { ptyLastRecognizedForeground.delete(id) } - return stable.processName + return { processName: stable.processName, available: stableResolution.available } } catch { if (ptyProcesses.get(id) !== proc) { - return null + return { processName: null, available: false } } - // Why: an inspection error is a degraded read; fall back to last recognized agent (null reads as an exit). - return ptyLastRecognizedForeground.get(id)?.name ?? null + return { + processName: ptyLastRecognizedForeground.get(id)?.name ?? null, + available: false + } + } + } + + async getForegroundProcess(id: string): Promise { + return (await this.inspectForegroundProcessResolution(id)).processName + } + + async inspectProcess(id: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return { foregroundProcess: null, hasChildProcesses: false, unavailable: true } + } + const foreground = await this.inspectForegroundProcessResolution(id) + if (ptyProcesses.get(id) !== proc) { + return { foregroundProcess: null, hasChildProcesses: false, unavailable: true } + } + let hasChildProcesses: boolean + try { + const shell = ptyShellName.get(id) + const currentProcess = resolveForegroundFallbackProcess(proc.process || null, shell) + hasChildProcesses = !shell || (currentProcess !== null && currentProcess !== shell) + } catch { + return { + foregroundProcess: foreground.processName, + hasChildProcesses: false, + unavailable: true + } + } + return { + foregroundProcess: foreground.processName, + hasChildProcesses, + ...(foreground.available ? {} : { unavailable: true as const }) } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 2e089c44f07..b9bd685ab66 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -114,6 +114,11 @@ import { } from '../memory/pty-registry' import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' import type { IPtyProvider } from '../providers/types' +import { + getForegroundProcessFromRuntimeController, + inspectProcessFromRuntimeController +} from '../ipc/pty/runtime/operations' +import { getLocalPtyProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry' import * as worktreePathComparison from '../ipc/worktree-path-comparison' import * as localWorktreeFilesystem from '../local-worktree-filesystem' import { @@ -984,6 +989,15 @@ function syncSinglePty( }) } +function inspectForegroundProcessWith( + readForegroundProcess: (ptyId: string) => string | null | Promise +): (ptyId: string) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + return async (ptyId) => ({ + foregroundProcess: await readForegroundProcess(ptyId), + hasChildProcesses: false + }) +} + function makeDeferred(): { promise: Promise; resolve: () => void } { let resolve!: () => void const promise = new Promise((next) => { @@ -10365,11 +10379,12 @@ describe('OrcaRuntimeService', () => { runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') batches.length = 0 - const getForegroundProcess = vi.fn().mockResolvedValueOnce('codex') + const getForegroundProcess = vi.fn().mockResolvedValueOnce('codex').mockResolvedValue('zsh') runtime.setPtyController({ write: () => true, kill: () => true, - getForegroundProcess + getForegroundProcess, + inspectProcess: inspectForegroundProcessWith(getForegroundProcess) }) runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 101) @@ -10400,7 +10415,7 @@ describe('OrcaRuntimeService', () => { incarnationId: 'inc-agent-exit' }) ) - expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(getForegroundProcess).toHaveBeenCalledTimes(3) }) it('settles a pending title exit when command completion proves the agent returned to shell', async () => { @@ -10416,7 +10431,8 @@ describe('OrcaRuntimeService', () => { runtime.setPtyController({ write: () => true, kill: () => true, - getForegroundProcess + getForegroundProcess, + inspectProcess: inspectForegroundProcessWith(getForegroundProcess) }) runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 101) @@ -10543,7 +10559,8 @@ describe('OrcaRuntimeService', () => { runtime.setPtyController({ write: () => true, kill: () => true, - getForegroundProcess + getForegroundProcess, + inspectProcess: inspectForegroundProcessWith(getForegroundProcess) }) runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') @@ -10568,7 +10585,8 @@ describe('OrcaRuntimeService', () => { runtime.setPtyController({ write: () => true, kill: () => true, - getForegroundProcess + getForegroundProcess, + inspectProcess: inspectForegroundProcessWith(getForegroundProcess) }) runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) @@ -10579,6 +10597,108 @@ describe('OrcaRuntimeService', () => { expect(getForegroundProcess).toHaveBeenCalledOnce() }) + it('does not certify an adapter-swallowed foreground query failure as an agent exit', async () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + runtime.onPtySpawned('pty-1', 'inc-unverifiable-exit', { awaitsRegistration: false }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + batches.length = 0 + + const originalProvider = getLocalPtyProvider() + const providerQuery = vi.fn(async () => { + throw new Error('socket_closed') + }) + setLocalPtyProvider({ getForegroundProcess: providerQuery } as unknown as IPtyProvider) + onTestFinished(() => setLocalPtyProvider(originalProvider)) + runtime.setPtyController({ + write: () => true, + kill: () => true, + // Production's legacy read swallows this failure while strong inspection preserves it. + getForegroundProcess: getForegroundProcessFromRuntimeController, + inspectProcess: inspectProcessFromRuntimeController + }) + + runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) + + await vi.waitFor(() => expect(providerQuery).toHaveBeenCalled()) + await vi.waitFor(() => + expect( + ( + runtime as unknown as { + ptyForegroundProcessReads: Map + } + ).ptyForegroundProcessReads.size + ).toBe(0) + ) + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { kind: 'title', normalizedTitle: 'bichir', rawTitle: 'bichir' }, + { kind: 'agent-exited' } + ]) + }) + + it('still certifies an agent exit when process inspection confirms no foreground agent', async () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + runtime.onPtySpawned('pty-1', 'inc-confirmed-exit', { awaitsRegistration: false }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + batches.length = 0 + + const originalProvider = getLocalPtyProvider() + const providerQuery = vi.fn(async () => null) + setLocalPtyProvider({ + getForegroundProcess: providerQuery, + hasChildProcesses: async () => false + } as unknown as IPtyProvider) + onTestFinished(() => setLocalPtyProvider(originalProvider)) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: getForegroundProcessFromRuntimeController, + inspectProcess: inspectProcessFromRuntimeController + }) + + runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) + + await vi.waitFor(() => + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'agent-exited', + executionHostConfirmed: true, + incarnationId: 'inc-confirmed-exit' + }) + ) + expect(providerQuery).toHaveBeenCalledOnce() + }) + + it('does not certify an agent exit while process inspection still sees children', async () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + runtime.onPtySpawned('pty-1', 'inc-live-child', { awaitsRegistration: false }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + batches.length = 0 + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + inspectProcess: async () => ({ foregroundProcess: null, hasChildProcesses: true }) + }) + + runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) + + await vi.waitFor(() => + expect( + ( + runtime as unknown as { + ptyForegroundProcessReads: Map + } + ).ptyForegroundProcessReads.size + ).toBe(0) + ) + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { kind: 'title', normalizedTitle: 'bichir', rawTitle: 'bichir' }, + { kind: 'agent-exited' } + ]) + }) + it('does not confirm an agent exit when the SSH host is unverifiable', async () => { const { runtime, batches } = createSideEffectRuntime() runtime.registerPty('pty-1', TEST_WORKTREE_ID, 'conn-1') @@ -10590,7 +10710,8 @@ describe('OrcaRuntimeService', () => { runtime.setPtyController({ write: () => true, kill: () => true, - getForegroundProcess + getForegroundProcess, + inspectProcess: inspectForegroundProcessWith(getForegroundProcess) }) runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) @@ -10613,7 +10734,8 @@ describe('OrcaRuntimeService', () => { runtime.setPtyController({ write: () => true, kill: () => true, - getForegroundProcess + getForegroundProcess, + inspectProcess: inspectForegroundProcessWith(getForegroundProcess) }) runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) @@ -10639,7 +10761,8 @@ describe('OrcaRuntimeService', () => { runtime.setPtyController({ write: () => true, kill: () => true, - getForegroundProcess + getForegroundProcess, + inspectProcess: inspectForegroundProcessWith(getForegroundProcess) }) runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) @@ -36907,11 +37030,13 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(store) const db = new InMemoryOrchestrationMessages() const write = vi.fn().mockReturnValue(true) + const getForegroundProcess = vi.fn(async () => 'codex') setInMemoryOrchestrationMessages(runtime, db) runtime.setPtyController({ write, kill: vi.fn(), - getForegroundProcess: async () => 'codex' + getForegroundProcess, + inspectProcess: inspectForegroundProcessWith(getForegroundProcess) }) syncSinglePty(runtime) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 4a64ed896fe..0f36dbdf878 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1703,12 +1703,14 @@ type PtyForegroundAgentRefresh = { type PtyForegroundProcessRead = { controller: RuntimePtyController process: string | null + hasChildProcesses: boolean | null available: boolean } type PtyForegroundProcessReadEntry = { controller: RuntimePtyController startedAfterTitleObservation: number + verifiedAbsence: boolean promise: Promise } @@ -19634,7 +19636,8 @@ export class OrcaRuntimeService { private readPtyForegroundProcessFromController( ptyId: string, - afterTitleObservation = 0 + afterTitleObservation = 0, + verifiedAbsence = false ): Promise | null { const controller = this.ptyController if (!controller) { @@ -19643,32 +19646,61 @@ export class OrcaRuntimeService { const pending = this.ptyForegroundProcessReads.get(ptyId) if ( pending?.controller === controller && - pending.startedAfterTitleObservation >= afterTitleObservation + pending.startedAfterTitleObservation >= afterTitleObservation && + (!verifiedAbsence || pending.verifiedAbsence) ) { return pending.promise } if (pending?.controller === controller) { - return pending.promise.then( - () => - this.readPtyForegroundProcessFromController(ptyId, afterTitleObservation) ?? { - controller, - process: null, - available: false - } - ) + return pending.promise.then((result) => { + // A recognized agent disproves exit; every absence claim needs strong inspection. + if ( + (!verifiedAbsence || + pending.verifiedAbsence || + recognizeAgentProcess(result.process) !== null) && + pending.startedAfterTitleObservation >= afterTitleObservation + ) { + return result + } + return ( + this.readPtyForegroundProcessFromController( + ptyId, + afterTitleObservation, + verifiedAbsence + ) ?? { controller, process: null, hasChildProcesses: null, available: false } + ) + }) } const unavailable: PtyForegroundProcessRead = { controller, process: null, + hasChildProcesses: null, available: false } - let processRead: Promise + let processRead: Promise<{ + process: string | null + hasChildProcesses: boolean | null + available: boolean + }> try { - processRead = Promise.resolve(controller.getForegroundProcess(ptyId)) + processRead = verifiedAbsence + ? controller.inspectProcess + ? Promise.resolve(controller.inspectProcess(ptyId)).then((inspection) => ({ + process: inspection.foregroundProcess, + hasChildProcesses: inspection.hasChildProcesses, + available: inspection.unavailable !== true + })) + : Promise.resolve({ process: null, hasChildProcesses: null, available: false }) + : Promise.resolve(controller.getForegroundProcess(ptyId)).then((process) => ({ + process, + hasChildProcesses: null, + available: true + })) } catch { const entry: PtyForegroundProcessReadEntry = { controller, startedAfterTitleObservation: afterTitleObservation, + verifiedAbsence, promise: Promise.resolve(unavailable) } entry.promise = entry.promise.finally(() => { @@ -19681,7 +19713,7 @@ export class OrcaRuntimeService { } let entry: PtyForegroundProcessReadEntry const promise = processRead - .then((process) => ({ controller, process, available: true })) + .then((result) => ({ controller, ...result })) .catch(() => unavailable) .finally(() => { if (this.ptyForegroundProcessReads.get(ptyId) === entry) { @@ -19691,6 +19723,7 @@ export class OrcaRuntimeService { entry = { controller, startedAfterTitleObservation: afterTitleObservation, + verifiedAbsence, promise } this.ptyForegroundProcessReads.set(ptyId, entry) @@ -19797,7 +19830,11 @@ export class OrcaRuntimeService { : { kind: 'agent-exited' } ) } - const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, titleObservedAt ?? 0) + const foregroundRead = this.readPtyForegroundProcessFromController( + ptyId, + titleObservedAt ?? 0, + true + ) if (!pty?.connected || !foregroundRead) { // Why: a local PTY that is already gone after a shell-title exit is // `exited`. A remote PTY that we cannot observe is `unverifiable`. @@ -19831,7 +19868,6 @@ export class OrcaRuntimeService { } if ( result.controller === this.ptyController && - result.available && recognizeAgentProcess(result.process) !== null ) { const restoredStatus = this.ptyTitleTrackersByPtyId @@ -19852,10 +19888,10 @@ export class OrcaRuntimeService { } return } - if (!result.available && current.connectionId) { + if ((!result.available || result.hasChildProcesses !== false) && current.connectionId) { return } - recordExit(result.available) + recordExit(result.available && result.hasChildProcesses === false) }) }