fix(terminal): preserve unverifiable exit evidence

This commit is contained in:
Brennan Benson
2026-08-27 16:37:17 -07:00
parent 858bed00e3
commit bc72ee5219
4 changed files with 263 additions and 36 deletions
@@ -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' })
+46 -9
View File
@@ -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<string | null> {
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<string | null> {
return (await this.inspectForegroundProcessResolution(id)).processName
}
async inspectProcess(id: string): Promise<PtyProcessInspection> {
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 })
}
}
+135 -10
View File
@@ -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<string | null>
): (ptyId: string) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> {
return async (ptyId) => ({
foregroundProcess: await readForegroundProcess(ptyId),
hasChildProcesses: false
})
}
function makeDeferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void
const promise = new Promise<void>((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<string, unknown>
}
).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<string, unknown>
}
).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)
+53 -17
View File
@@ -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<PtyForegroundProcessRead>
}
@@ -19634,7 +19636,8 @@ export class OrcaRuntimeService {
private readPtyForegroundProcessFromController(
ptyId: string,
afterTitleObservation = 0
afterTitleObservation = 0,
verifiedAbsence = false
): Promise<PtyForegroundProcessRead> | 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<string | null>
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)
})
}