Keep relay completion evidence live/unverifiable/exited, never coerced

A failed pgrep/ps probe on a relay host resolved as
{foregroundProcess: null|fallback, hasChildProcesses: false}, which the
agent-completion monitor read as positive exit evidence and turned into
a process-exit completion. Probes now return live/unverifiable/exited
verdicts, the relay publishes them in a new optional processEvidence
field beside byte-identical legacy fields, and the monitor refuses to
treat an unverifiable probe as exit evidence.
This commit is contained in:
Brennan Benson
2026-08-27 18:42:52 -07:00
parent ebe6b559ef
commit 7b32eed4b6
17 changed files with 1300 additions and 481 deletions
@@ -1,9 +1,13 @@
import type { IPtyProvider } from './types'
import type { PtyProcessInspectionEvidence } from '../../shared/pty-process-inspection-evidence'
export type PtyProcessInspection = {
foregroundProcess: string | null
hasChildProcesses: boolean
unavailable?: true
// Why optional: hosts that predate the evidence contract omit it; readers
// fall back to the legacy fields (see readPtyProcessInspectionEvidence).
processEvidence?: PtyProcessInspectionEvidence
}
type CompletionSensitivePtyProvider = IPtyProvider & {
+13 -6
View File
@@ -577,6 +577,7 @@ import {
type TerminalQuickCommandMutation
} from '../../shared/terminal-quick-commands'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { PtyProcessInspectionEvidence } from '../../shared/pty-process-inspection-evidence'
import {
buildAgentDraftLaunchPlan,
buildAgentResumeStartupPlan,
@@ -2042,9 +2043,12 @@ type RuntimePtyController = {
markReversibleStops?(ptyIds: readonly string[]): () => void
getCwd?(ptyId: string): Promise<string | null>
getForegroundProcess(ptyId: string): Promise<string | null>
inspectProcess?(
ptyId: string
): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }>
inspectProcess?(ptyId: string): Promise<{
foregroundProcess: string | null
hasChildProcesses: boolean
unavailable?: true
processEvidence?: PtyProcessInspectionEvidence
}>
confirmForegroundProcess?(ptyId: string): Promise<string | null>
confirmShellForeground?(ptyId: string): Promise<boolean>
hasChildProcesses?(ptyId: string): Promise<boolean>
@@ -22459,9 +22463,12 @@ export class OrcaRuntimeService {
return { removed: true }
}
async inspectTerminalProcess(
terminalSelector: string
): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> {
async inspectTerminalProcess(terminalSelector: string): Promise<{
foregroundProcess: string | null
hasChildProcesses: boolean
unavailable?: true
processEvidence?: PtyProcessInspectionEvidence
}> {
const leaf = this.resolveLiveLeafForHandle(terminalSelector)
if (!leaf?.ptyId || !this.ptyController) {
throw new Error('terminal_gone')
+2
View File
@@ -9,6 +9,7 @@ import type {
SkillDeleteResult
} from '../shared/skill-delete-contract'
import type { AppIdentity } from '../shared/app-identity'
import type { PtyProcessInspectionEvidence } from '../shared/pty-process-inspection-evidence'
import type { MacCapturedDigitRowChord } from '../shared/macos-symbolic-hotkeys'
import type { ComputerAwakeStatus } from '../shared/computer-awake-mode'
import type {
@@ -1227,6 +1228,7 @@ const api = {
foregroundProcess: string | null
hasChildProcesses: boolean
unavailable?: true
processEvidence?: PtyProcessInspectionEvidence
}> => ipcRenderer.invoke('pty:inspectProcess', { id }),
confirmForegroundProcess: (id: string): Promise<string | null> =>
ipcRenderer.invoke('pty:confirmForegroundProcess', { id }),
+4 -4
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import * as ptyShellUtils from './pty-shell-utils'
import * as ptyProcessProbes from './pty-process-probes'
const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
@@ -80,7 +80,7 @@ describe('PtyHandler', () => {
expect(handler.activePtyCount).toBe(1)
expect(onExitCb).toBeDefined()
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
const aliveSpy = vi.spyOn(ptyProcessProbes, 'isProcessAlive').mockReturnValue(false)
try {
await expect(
dispatcher.callRequest('pty.attach', { id: 'pty-1', suppressReplayNotification: true })
@@ -109,7 +109,7 @@ describe('PtyHandler', () => {
await dispatcher.callRequest('pty.spawn', {})
let shutdown: Promise<unknown> | undefined
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockImplementation(() => {
const aliveSpy = vi.spyOn(ptyProcessProbes, 'isProcessAlive').mockImplementation(() => {
shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
return false
})
@@ -141,7 +141,7 @@ describe('PtyHandler', () => {
const spawn = await spawnPty()
dataCallback?.('prompt$ ')
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true)
const aliveSpy = vi.spyOn(ptyProcessProbes, 'isProcessAlive').mockReturnValue(true)
try {
const result = await attachPty({
id: 'pty-1',
@@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import * as ptyShellUtils from './pty-shell-utils'
import * as ptyProcessProbes from './pty-process-probes'
const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
@@ -368,7 +368,7 @@ describe('PtyHandler', () => {
handler.onPtyPoolEmpty(poolEmpty)
await spawnPty()
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
const aliveSpy = vi.spyOn(ptyProcessProbes, 'isProcessAlive').mockReturnValue(false)
try {
await expect(attachPty({ id: 'pty-1', suppressReplayNotification: true })).rejects.toThrow(
'PTY "pty-1" not found'
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../shared/setup-agent-sequencing'
import * as ptyShellUtils from './pty-shell-utils'
import * as ptyProcessProbes from './pty-process-probes'
const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
@@ -607,7 +607,7 @@ describe('PtyHandler', () => {
}
expect(handler.retainedStartupCommandCount).toBe(1)
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
const aliveSpy = vi.spyOn(ptyProcessProbes, 'isProcessAlive').mockReturnValue(false)
try {
await expect(dispatcher.callRequest('pty.attach', { id: 'pty-1' })).rejects.toThrow(
'PTY "pty-1" not found'
+3 -2
View File
@@ -1,6 +1,6 @@
import { vi } from 'vitest'
import type { Mock } from 'vitest'
import * as ptyShellUtils from './pty-shell-utils'
import * as ptyProcessProbes from './pty-process-probes'
import { PtyHandler } from './pty-handler'
import type { RelayDispatcher } from './dispatcher'
@@ -103,7 +103,8 @@ export function beginPtyHandlerTest(mocks: PtyHandlerTestMocks): {
notifyOutput: vi.fn(),
dispose: vi.fn()
})
vi.spyOn(ptyShellUtils, 'processHasChildren').mockResolvedValue(false)
vi.spyOn(ptyProcessProbes, 'processHasChildren').mockResolvedValue(false)
vi.spyOn(ptyProcessProbes, 'probeProcessChildren').mockResolvedValue({ verdict: 'exited' })
mockPtySpawn.mockReturnValue({ ...mockPtyInstance })
+20 -15
View File
@@ -11,11 +11,19 @@ import {
resolveDefaultShell,
resolveDefaultCwd,
resolveProcessCwd,
processHasChildren,
getForegroundProcessName,
isProcessAlive,
listShellProfiles
} from './pty-shell-utils'
import {
processHasChildren,
probeProcessChildren,
getForegroundProcessName,
observeForegroundProcess,
isProcessAlive
} from './pty-process-probes'
import {
buildPtyProcessInspectionWireResult,
type PtyProcessInspectionWireResult
} from '../shared/pty-process-inspection-evidence'
import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch'
import { addWslEnvKeys } from '../shared/wsl-env'
import { SHELL_STARTUP_FEATURE_ENV } from '../main/shell-startup-features'
@@ -2080,23 +2088,20 @@ export class PtyHandler {
return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)
}
private async inspectProcess(params: Record<string, unknown>): Promise<{
foregroundProcess: string | null
hasChildProcesses: boolean
}> {
private async inspectProcess(
params: Record<string, unknown>
): Promise<PtyProcessInspectionWireResult> {
const id = params.id as string
const managed = this.ptys.get(id)
if (!managed || managed.disposed) {
throw new Error('terminal_gone')
}
const foregroundProcess = await getForegroundProcessName(
managed.pty.pid,
managed.pty.process || null
)
return {
foregroundProcess,
hasChildProcesses: await processHasChildren(managed.pty.pid)
}
// Why evidence, not booleans: completion consumers must be able to tell
// "no children observed" from "the probe could not run". The builder keeps
// the legacy fields collapsing exactly as before for pre-evidence clients.
const foreground = await observeForegroundProcess(managed.pty.pid, managed.pty.process || null)
const children = await probeProcessChildren(managed.pty.pid)
return buildPtyProcessInspectionWireResult(foreground, children)
}
private async listProcesses(): Promise<PtyProcessSummary[]> {
+417
View File
@@ -0,0 +1,417 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, execFileSyncMock, getAllProcessesMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
getAllProcessesMock: vi.fn(),
execFileSyncMock: vi.fn()
}))
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock
}))
import { resetWindowsProcessRowsSnapshotForTests } from '../main/providers/windows-foreground-process-rows'
import { __setWindowsProcessTreeLoaderForTests } from '../main/windows/windows-process-table'
import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot'
import {
getForegroundProcessName,
isProcessAlive,
observeForegroundProcess,
probeProcessChildren,
processHasChildren
} from './pty-process-probes'
function mockExecFile(
implementation: (command: string, args: string[]) => { stdout: string; stderr?: string } | Error
): void {
execFileMock.mockImplementation(
(command: string, args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
const result = implementation(command, args)
if (result instanceof Error) {
callback(result, { stdout: '', stderr: '' })
return
}
callback(null, { stdout: result.stdout, stderr: result.stderr ?? '' })
}
)
}
/**
* Feed the native Windows snapshot. A real snapshot always contains the
* querying process, and the reader rejects a table without it.
*/
function mockWindowsProcessTable(
rows: { pid: number; ppid: number; name: string; commandLine?: string }[]
): void {
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses: (cb: (value: typeof rows | undefined) => void) =>
cb([{ pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }, ...rows])
}))
getAllProcessesMock.mockClear()
}
async function withProcessPlatform<T>(
platform: NodeJS.Platform,
run: () => T | Promise<T>
): Promise<T> {
const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return await run()
} finally {
if (descriptor) {
Object.defineProperty(process, 'platform', descriptor)
}
}
}
beforeEach(() => {
vi.resetModules()
execFileMock.mockReset()
execFileSyncMock.mockReset()
resetProcessTableSnapshotForTests()
resetWindowsProcessRowsSnapshotForTests()
__setWindowsProcessTreeLoaderForTests()
})
describe('isProcessAlive', () => {
it('reports the test runner process as alive', () => {
expect(isProcessAlive(process.pid)).toBe(true)
})
it('reports a process as dead ONLY on ESRCH', () => {
// Why: attach() reaps a lingering managed PTY based on this, so a false
// positive would kill a live remote shell. Only "no such process" counts.
const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
const err = new Error('no such process') as NodeJS.ErrnoException
err.code = 'ESRCH'
throw err
})
try {
expect(isProcessAlive(2147483646)).toBe(false)
} finally {
spy.mockRestore()
}
})
it('treats an unsignalable process (EPERM) as alive', () => {
const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
const err = new Error('operation not permitted') as NodeJS.ErrnoException
err.code = 'EPERM'
throw err
})
try {
expect(isProcessAlive(1)).toBe(true)
} finally {
spy.mockRestore()
}
})
})
describe('getForegroundProcessName', () => {
it('returns clear non-wrapper foregrounds without process-table enrichment', async () => {
await expect(getForegroundProcessName(100, 'vim')).resolves.toBe('vim')
expect(execFileMock).not.toHaveBeenCalled()
})
it('recognizes SSH relay node-wrapped agents from descendant command lines', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: ['100 99 Ss bash -l', '101 100 S+ node /home/dev/.local/bin/codex'].join(
'\n'
)
}
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('codex')
})
})
it('recognizes Windows SSH relay shell-rooted agent descendants', async () => {
await withProcessPlatform('win32', async () => {
mockWindowsProcessTable([
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{
pid: 101,
ppid: 100,
name: 'node.exe',
commandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd'
}
])
await expect(getForegroundProcessName(100, 'powershell.exe')).resolves.toBe('codex')
})
})
it('recognizes SSH relay wrapped agents when no foreground marker is available', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: [
'100 99 Ss bash -l',
'101 100 S node /home/dev/.local/bin/node_modules/@google/gemini-cli/bundle/gemini.mjs'
].join('\n')
}
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('gemini')
})
})
it('does not guess when SSH relay wrapper descendants are ambiguous', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: [
'100 99 Ss bash -l',
'101 100 S node /home/dev/project/server.js',
'102 100 S node /home/dev/.local/bin/node_modules/@openai/codex/bin/codex.js'
].join('\n')
}
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node')
})
})
it('does not report a stopped SSH relay agent when another process has foreground', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: [
'100 99 Ss bash -l',
'101 100 T node /home/dev/.local/bin/codex',
'102 100 S+ vim notes.txt'
].join('\n')
}
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node')
})
})
// Why: OMP embeds Pi, but the outer process is the user-visible identity (#6364).
it('reports the outer omp wrapper over the wrapped pi child from a shell fallback', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n')
}
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'bash')).resolves.toBe('omp')
})
})
it('rescans for the omp wrapper when node-pty reports the wrapped pi as foreground', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n')
}
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp')
})
})
it('returns an outer omp fallback without a process-table scan', async () => {
mockExecFile(() => new Error('unexpected process-table scan'))
await expect(getForegroundProcessName(100, 'omp')).resolves.toBe('omp')
expect(execFileMock).not.toHaveBeenCalled()
})
it('rescans a Windows pi fallback for its outer omp wrapper', async () => {
await withProcessPlatform('win32', async () => {
mockWindowsProcessTable([
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 101, ppid: 100, name: 'omp.exe', commandLine: 'omp.exe' },
{ pid: 102, ppid: 101, name: 'pi.exe', commandLine: 'pi.exe' }
])
await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp')
})
})
it('keeps a pi fallback as pi when no omp wrapper is in the tree', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return { stdout: ['100 99 Ss bash -l', '101 100 S+ pi'].join('\n') }
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('pi')
})
})
it('falls back to the root process command when descendant inspection fails', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return new Error('ps table unavailable')
}
return { stdout: 'bash\n' }
})
await expect(getForegroundProcessName(100)).resolves.toBe('bash')
})
})
function subprocessExitError(
message: string,
overrides: { code?: number | string; killed?: boolean; signal?: string | null } = {}
): Error {
const error = new Error(message) as Error & {
code?: number | string
killed?: boolean
signal?: string | null
}
error.code = overrides.code
error.killed = overrides.killed ?? false
error.signal = overrides.signal ?? null
return error
}
describe('probeProcessChildren', () => {
it('reports live children from pgrep output', async () => {
mockExecFile(() => ({ stdout: '4242\n' }))
await expect(probeProcessChildren(100)).resolves.toEqual({ verdict: 'live' })
})
it('treats pgrep exit 1 as positive absence', async () => {
// pgrep exits 1 with no output when it ran and matched nothing.
mockExecFile(() => subprocessExitError('no match', { code: 1 }))
await expect(probeProcessChildren(100)).resolves.toEqual({ verdict: 'exited' })
})
it('keeps a missing pgrep binary unverifiable, never exited', async () => {
mockExecFile(() => subprocessExitError('spawn pgrep ENOENT', { code: 'ENOENT' }))
await expect(probeProcessChildren(100)).resolves.toMatchObject({ verdict: 'unverifiable' })
})
it('keeps a timed-out pgrep unverifiable, never exited', async () => {
mockExecFile(() => subprocessExitError('killed', { killed: true, signal: 'SIGTERM' }))
await expect(probeProcessChildren(100)).resolves.toMatchObject({ verdict: 'unverifiable' })
})
it('keeps a pgrep fatal error (exit 3) unverifiable', async () => {
mockExecFile(() => subprocessExitError('fatal', { code: 3 }))
await expect(probeProcessChildren(100)).resolves.toMatchObject({ verdict: 'unverifiable' })
})
it('answers live from the Windows process table', async () => {
await withProcessPlatform('win32', async () => {
mockWindowsProcessTable([
{ pid: 100, ppid: 99, name: 'powershell.exe' },
{ pid: 101, ppid: 100, name: 'codex.exe' }
])
await expect(probeProcessChildren(100)).resolves.toEqual({ verdict: 'live' })
})
})
it('answers exited when the Windows table observes a childless root', async () => {
await withProcessPlatform('win32', async () => {
mockWindowsProcessTable([{ pid: 100, ppid: 99, name: 'powershell.exe' }])
await expect(probeProcessChildren(100)).resolves.toEqual({ verdict: 'exited' })
})
})
it('keeps a Windows table that misses the PTY root unverifiable', async () => {
await withProcessPlatform('win32', async () => {
mockWindowsProcessTable([])
await expect(probeProcessChildren(100)).resolves.toMatchObject({ verdict: 'unverifiable' })
})
})
it('collapses every probe failure to false for the legacy boolean', async () => {
mockExecFile(() => subprocessExitError('spawn pgrep ENOENT', { code: 'ENOENT' }))
await expect(processHasChildren(100)).resolves.toBe(false)
})
})
describe('observeForegroundProcess', () => {
it('reports a successful ps read as observed', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return { stdout: '' }
}
return { stdout: 'vim\n' }
})
await expect(observeForegroundProcess(100)).resolves.toEqual({
verdict: 'observed',
processName: 'vim'
})
})
it('reports ps exit 1 as an observed absence of the process', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return new Error('ps table unavailable')
}
return subprocessExitError('no such pid', { code: 1 })
})
await expect(observeForegroundProcess(100)).resolves.toEqual({
verdict: 'observed',
processName: null
})
})
it('keeps a timed-out ps read unverifiable', async () => {
mockExecFile(() => subprocessExitError('killed', { killed: true, signal: 'SIGTERM' }))
await expect(observeForegroundProcess(100)).resolves.toMatchObject({
verdict: 'unverifiable'
})
})
it('keeps the node-pty fallback observed when descendant enrichment fails', async () => {
mockExecFile(() => subprocessExitError('killed', { killed: true, signal: 'SIGTERM' }))
await expect(observeForegroundProcess(100, 'node')).resolves.toEqual({
verdict: 'observed',
processName: 'node'
})
})
it('collapses an unverifiable observation to null for the legacy reader', async () => {
mockExecFile(() => subprocessExitError('killed', { killed: true, signal: 'SIGTERM' }))
await expect(getForegroundProcessName(100)).resolves.toBeNull()
})
})
+280
View File
@@ -0,0 +1,280 @@
import { execFile as execFileCb } from 'node:child_process'
import { promisify } from 'node:util'
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess,
recognizeAgentProcess,
recognizeAgentProcessFromCommandLine
} from '../shared/agent-process-recognition'
import { getFirstCommandToken } from '../shared/command-token-scanner'
import { getProcessTableSnapshot, type ProcessTableRow } from '../shared/process-table-snapshot'
import {
resolveOuterWrapperForegroundProcess,
shouldInspectOuterWrapperForegroundProcess
} from '../shared/foreground-wrapper-agent'
import { isShellProcess } from '../shared/shell-process-detection'
import type {
PtyChildProcessesEvidence,
PtyForegroundProcessEvidence
} from '../shared/pty-process-inspection-evidence'
import {
resolveWindowsAgentForegroundProcess,
shouldInspectWindowsAgentForeground
} from '../main/providers/windows-agent-foreground-process'
import { queryWindowsPaneProcessInventory } from '../main/providers/windows-foreground-process-rows'
const execFile = promisify(execFileCb)
/**
* Check whether a process has child processes (via pgrep).
*
* Legacy collapse kept for the standalone `pty.hasChildProcesses` RPC: every
* probe failure reads as `false`. Completion-sensitive callers must use
* `probeProcessChildren`, whose verdict keeps "could not ask" distinct.
*/
export async function processHasChildren(pid: number): Promise<boolean> {
try {
const { stdout } = await execFile('pgrep', ['-P', String(pid)], {
encoding: 'utf-8',
timeout: 3000
})
return stdout.trim().length > 0
} catch {
return false
}
}
// Why not NodeJS.ErrnoException: execFile stamps the numeric exit status into
// `code`, which the lib types declare as string-only.
type SubprocessProbeError = {
code?: number | string
killed?: boolean
signal?: NodeJS.Signals | null
message?: string
}
// Exit code 1 without a kill signal: pgrep/ps RAN and matched nothing — that
// is positive absence, not a failed probe.
function subprocessRanAndMatchedNothing(error: unknown): boolean {
const failure = error as SubprocessProbeError
return failure?.code === 1 && failure.killed !== true && !failure.signal
}
function describeProcessProbeFailure(command: string, error: unknown): string {
const failure = error as SubprocessProbeError
if (failure?.killed === true || failure?.signal) {
return `${command} did not answer before its deadline`
}
return `${command} could not run: ${failure?.code ?? failure?.message ?? 'unknown failure'}`
}
/**
* Probe whether the PTY's root process has live children, keeping the
* live / unverifiable / exited contract: a probe that could not run (pgrep
* missing, fork pressure, timeout) is `unverifiable`, never `exited`.
*/
export async function probeProcessChildren(pid: number): Promise<PtyChildProcessesEvidence> {
if (process.platform === 'win32') {
// pgrep does not exist on Windows hosts; ask the native process table the
// relay already reads for foreground resolution (TTL-cached snapshot).
const inventory = await queryWindowsPaneProcessInventory(pid)
if (!inventory) {
return {
verdict: 'unverifiable',
reason: 'the Windows process table could not observe the PTY root'
}
}
return inventory.candidates.length > 0 ? { verdict: 'live' } : { verdict: 'exited' }
}
try {
const { stdout } = await execFile('pgrep', ['-P', String(pid)], {
encoding: 'utf-8',
timeout: 3000
})
return stdout.trim().length > 0 ? { verdict: 'live' } : { verdict: 'exited' }
} catch (error) {
if (subprocessRanAndMatchedNothing(error)) {
return { verdict: 'exited' }
}
return { verdict: 'unverifiable', reason: describeProcessProbeFailure('pgrep', error) }
}
}
// Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no
// such process") proves the pid is gone; EPERM means it exists but is
// unsignalable, so treat every non-ESRCH outcome as alive. Kept conservative so
// a liveness check can only ever declare a *provably* dead process dead.
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (err) {
return (err as NodeJS.ErrnoException).code !== 'ESRCH'
}
}
function collectDescendants(
rows: ProcessTableRow[],
rootPid: number
): (ProcessTableRow & { depth: number })[] {
const childrenByParent = new Map<number, ProcessTableRow[]>()
for (const row of rows) {
const children = childrenByParent.get(row.ppid) ?? []
children.push(row)
childrenByParent.set(row.ppid, children)
}
const descendants: (ProcessTableRow & { depth: number })[] = []
const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
while (stack.length > 0) {
const { row, depth } = stack.pop()!
descendants.push({ ...row, depth })
for (const child of childrenByParent.get(row.pid) ?? []) {
stack.push({ row: child, depth: depth + 1 })
}
}
return descendants
}
function candidateScore(row: ProcessTableRow & { depth: number }): number {
return (row.stat.includes('+') ? 10_000 : 0) + row.depth
}
function processCommandToken(command: string): string {
return getFirstCommandToken(command)
}
function candidateMatchesFallbackWrapper(
candidate: ProcessTableRow,
fallbackProcess: string
): boolean {
return isExpectedAgentProcess(processCommandToken(candidate.command), fallbackProcess)
}
async function getRecognizedForegroundDescendant(
pid: number,
fallbackProcess?: string | null
): Promise<string | null> {
try {
const rows = await getProcessTableSnapshot()
const root = rows.find((row) => row.pid === pid)
const candidates = collectDescendants(rows, pid).sort(
(a, b) => candidateScore(b) - candidateScore(a)
)
// Why: SSH relays do not have the daemon's async wrapper cache. Inspect the
// remote process tree so node/python agent entrypoints become real agents.
const foregroundIsKnown =
root?.stat.includes('+') === true ||
candidates.some((candidate) => candidate.stat.includes('+'))
const foregroundCandidates = foregroundIsKnown
? candidates.filter((candidate) => candidate.stat.includes('+'))
: candidates
const inspectionCandidates =
fallbackProcess && isAgentForegroundWrapperProcess(fallbackProcess)
? foregroundCandidates.filter((candidate) =>
candidateMatchesFallbackWrapper(candidate, fallbackProcess)
)
: foregroundCandidates
if (
fallbackProcess &&
isAgentForegroundWrapperProcess(fallbackProcess) &&
inspectionCandidates.length !== 1
) {
return null
}
for (const candidate of inspectionCandidates) {
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
if (recognized) {
// Why: return the outer wrapper (omp) rather than the deeper wrapped child
// (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates)
}
}
} catch {
// Fall through to node-pty's process name or the root command name.
}
return null
}
/**
* Get the foreground process name of a given pid (via ps).
*
* Legacy collapse kept for the standalone `pty.getForegroundProcess` RPC and
* pane titles: an unverifiable observation reads as `null`. Completion-
* sensitive callers must use `observeForegroundProcess`.
*/
export async function getForegroundProcessName(
pid: number,
fallbackProcess?: string | null
): Promise<string | null> {
const evidence = await observeForegroundProcess(pid, fallbackProcess)
return evidence.verdict === 'observed' ? evidence.processName : null
}
function observedForeground(processName: string | null): PtyForegroundProcessEvidence {
return { verdict: 'observed', processName }
}
/**
* Observe the foreground process, keeping "could not ask" distinct from
* "observed nothing". Fallback names come from node-pty's own record of the
* terminal's foreground process — a genuine observation even when descendant
* enrichment fails. Only the last-resort `ps` read can be unverifiable: exit 1
* proves the pid is gone (`observed` + null), while a probe that could not run
* proves nothing.
*/
export async function observeForegroundProcess(
pid: number,
fallbackProcess?: string | null
): Promise<PtyForegroundProcessEvidence> {
if (fallbackProcess) {
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
if (fallbackRecognition) {
// Why: node-pty can report OMP's wrapped Pi; enrich only that ambiguous
// fallback so authoritative OMP reads keep the zero-subprocess fast path.
if (shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) {
if (process.platform === 'win32') {
return observedForeground(
(await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ??
fallbackRecognition.processName
)
}
return observedForeground(
(await getRecognizedForegroundDescendant(pid, fallbackProcess)) ??
fallbackRecognition.processName
)
}
return observedForeground(fallbackRecognition.processName)
}
if (process.platform === 'win32') {
if (!shouldInspectWindowsAgentForeground(fallbackProcess)) {
return observedForeground(fallbackProcess)
}
return observedForeground(
(await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? fallbackProcess
)
}
if (!isShellProcess(fallbackProcess) && !isAgentForegroundWrapperProcess(fallbackProcess)) {
return observedForeground(fallbackProcess)
}
}
const recognized = await getRecognizedForegroundDescendant(pid, fallbackProcess)
if (recognized) {
return observedForeground(recognized)
}
if (fallbackProcess) {
return observedForeground(fallbackProcess)
}
try {
const { stdout } = await execFile('ps', ['-o', 'comm=', '-p', String(pid)], {
encoding: 'utf-8',
timeout: 3000
})
return observedForeground(stdout.trim() || null)
} catch (error) {
if (subprocessRanAndMatchedNothing(error)) {
return observedForeground(null)
}
return { verdict: 'unverifiable', reason: describeProcessProbeFailure('ps', error) }
}
}
+2 -259
View File
@@ -1,8 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, execFileSyncMock, getAllProcessesMock } = vi.hoisted(() => ({
const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
getAllProcessesMock: vi.fn(),
execFileSyncMock: vi.fn()
}))
@@ -14,58 +13,7 @@ vi.mock('child_process', () => ({
import { resetWindowsProcessRowsSnapshotForTests } from '../main/providers/windows-foreground-process-rows'
import { __setWindowsProcessTreeLoaderForTests } from '../main/windows/windows-process-table'
import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot'
import {
getForegroundProcessName,
isProcessAlive,
resolveDefaultCwd,
resolveWindowsDefaultShell
} from './pty-shell-utils'
function mockExecFile(
implementation: (command: string, args: string[]) => { stdout: string; stderr?: string } | Error
): void {
execFileMock.mockImplementation(
(command: string, args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
const result = implementation(command, args)
if (result instanceof Error) {
callback(result, { stdout: '', stderr: '' })
return
}
callback(null, { stdout: result.stdout, stderr: result.stderr ?? '' })
}
)
}
/**
* Feed the native Windows snapshot. A real snapshot always contains the
* querying process, and the reader rejects a table without it.
*/
function mockWindowsProcessTable(
rows: { pid: number; ppid: number; name: string; commandLine?: string }[]
): void {
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
getAllProcesses: (cb: (value: typeof rows | undefined) => void) =>
cb([{ pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }, ...rows])
}))
getAllProcessesMock.mockClear()
}
async function withProcessPlatform<T>(
platform: NodeJS.Platform,
run: () => T | Promise<T>
): Promise<T> {
const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return await run()
} finally {
if (descriptor) {
Object.defineProperty(process, 'platform', descriptor)
}
}
}
import { resolveDefaultCwd, resolveWindowsDefaultShell } from './pty-shell-utils'
beforeEach(() => {
vi.resetModules()
@@ -76,40 +24,6 @@ beforeEach(() => {
__setWindowsProcessTreeLoaderForTests()
})
describe('isProcessAlive', () => {
it('reports the test runner process as alive', () => {
expect(isProcessAlive(process.pid)).toBe(true)
})
it('reports a process as dead ONLY on ESRCH', () => {
// Why: attach() reaps a lingering managed PTY based on this, so a false
// positive would kill a live remote shell. Only "no such process" counts.
const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
const err = new Error('no such process') as NodeJS.ErrnoException
err.code = 'ESRCH'
throw err
})
try {
expect(isProcessAlive(2147483646)).toBe(false)
} finally {
spy.mockRestore()
}
})
it('treats an unsignalable process (EPERM) as alive', () => {
const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
const err = new Error('operation not permitted') as NodeJS.ErrnoException
err.code = 'EPERM'
throw err
})
try {
expect(isProcessAlive(1)).toBe(true)
} finally {
spy.mockRestore()
}
})
})
describe('resolveWindowsDefaultShell', () => {
it('uses an existing SHELL override when one is provided', () => {
expect(
@@ -297,174 +211,3 @@ describe('resolveDefaultCwd', () => {
expect(resolveDefaultCwd({ HOME: '/home/alice' }, 'linux', '/fallback')).toBe('/home/alice')
})
})
describe('getForegroundProcessName', () => {
it('returns clear non-wrapper foregrounds without process-table enrichment', async () => {
await expect(getForegroundProcessName(100, 'vim')).resolves.toBe('vim')
expect(execFileMock).not.toHaveBeenCalled()
})
it('recognizes SSH relay node-wrapped agents from descendant command lines', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: ['100 99 Ss bash -l', '101 100 S+ node /home/dev/.local/bin/codex'].join(
'\n'
)
}
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('codex')
})
})
it('recognizes Windows SSH relay shell-rooted agent descendants', async () => {
await withProcessPlatform('win32', async () => {
mockWindowsProcessTable([
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{
pid: 101,
ppid: 100,
name: 'node.exe',
commandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd'
}
])
await expect(getForegroundProcessName(100, 'powershell.exe')).resolves.toBe('codex')
})
})
it('recognizes SSH relay wrapped agents when no foreground marker is available', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: [
'100 99 Ss bash -l',
'101 100 S node /home/dev/.local/bin/node_modules/@google/gemini-cli/bundle/gemini.mjs'
].join('\n')
}
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('gemini')
})
})
it('does not guess when SSH relay wrapper descendants are ambiguous', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: [
'100 99 Ss bash -l',
'101 100 S node /home/dev/project/server.js',
'102 100 S node /home/dev/.local/bin/node_modules/@openai/codex/bin/codex.js'
].join('\n')
}
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node')
})
})
it('does not report a stopped SSH relay agent when another process has foreground', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: [
'100 99 Ss bash -l',
'101 100 T node /home/dev/.local/bin/codex',
'102 100 S+ vim notes.txt'
].join('\n')
}
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node')
})
})
// Why: OMP embeds Pi, but the outer process is the user-visible identity (#6364).
it('reports the outer omp wrapper over the wrapped pi child from a shell fallback', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n')
}
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'bash')).resolves.toBe('omp')
})
})
it('rescans for the omp wrapper when node-pty reports the wrapped pi as foreground', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return {
stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n')
}
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp')
})
})
it('returns an outer omp fallback without a process-table scan', async () => {
mockExecFile(() => new Error('unexpected process-table scan'))
await expect(getForegroundProcessName(100, 'omp')).resolves.toBe('omp')
expect(execFileMock).not.toHaveBeenCalled()
})
it('rescans a Windows pi fallback for its outer omp wrapper', async () => {
await withProcessPlatform('win32', async () => {
mockWindowsProcessTable([
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 101, ppid: 100, name: 'omp.exe', commandLine: 'omp.exe' },
{ pid: 102, ppid: 101, name: 'pi.exe', commandLine: 'pi.exe' }
])
await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp')
})
})
it('keeps a pi fallback as pi when no omp wrapper is in the tree', async () => {
await withProcessPlatform('linux', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return { stdout: ['100 99 Ss bash -l', '101 100 S+ pi'].join('\n') }
}
return new Error('unexpected command')
})
await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('pi')
})
})
it('falls back to the root process command when descendant inspection fails', async () => {
mockExecFile((_command, args) => {
if (args[0] === '-axo') {
return new Error('ps table unavailable')
}
return { stdout: 'bash\n' }
})
await expect(getForegroundProcessName(100)).resolves.toBe('bash')
})
})
-185
View File
@@ -3,24 +3,6 @@ import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { win32 as pathWin32 } from 'node:path'
import { promisify } from 'node:util'
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess,
recognizeAgentProcess,
recognizeAgentProcessFromCommandLine
} from '../shared/agent-process-recognition'
import { getFirstCommandToken } from '../shared/command-token-scanner'
import { getProcessTableSnapshot, type ProcessTableRow } from '../shared/process-table-snapshot'
import {
resolveOuterWrapperForegroundProcess,
shouldInspectOuterWrapperForegroundProcess
} from '../shared/foreground-wrapper-agent'
import { isShellProcess } from '../shared/shell-process-detection'
import {
resolveWindowsAgentForegroundProcess,
shouldInspectWindowsAgentForeground
} from '../main/providers/windows-agent-foreground-process'
const execFile = promisify(execFileCb)
const OPENSSH_REGISTRY_KEY = 'HKLM\\SOFTWARE\\OpenSSH'
@@ -165,173 +147,6 @@ export async function resolveProcessCwd(pid: number, fallbackCwd: string): Promi
return fallbackCwd
}
/**
* Check whether a process has child processes (via pgrep).
*/
export async function processHasChildren(pid: number): Promise<boolean> {
try {
const { stdout } = await execFile('pgrep', ['-P', String(pid)], {
encoding: 'utf-8',
timeout: 3000
})
return stdout.trim().length > 0
} catch {
return false
}
}
// Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no
// such process") proves the pid is gone; EPERM means it exists but is
// unsignalable, so treat every non-ESRCH outcome as alive. Kept conservative so
// a liveness check can only ever declare a *provably* dead process dead.
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (err) {
return (err as NodeJS.ErrnoException).code !== 'ESRCH'
}
}
function collectDescendants(
rows: ProcessTableRow[],
rootPid: number
): (ProcessTableRow & { depth: number })[] {
const childrenByParent = new Map<number, ProcessTableRow[]>()
for (const row of rows) {
const children = childrenByParent.get(row.ppid) ?? []
children.push(row)
childrenByParent.set(row.ppid, children)
}
const descendants: (ProcessTableRow & { depth: number })[] = []
const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
while (stack.length > 0) {
const { row, depth } = stack.pop()!
descendants.push({ ...row, depth })
for (const child of childrenByParent.get(row.pid) ?? []) {
stack.push({ row: child, depth: depth + 1 })
}
}
return descendants
}
function candidateScore(row: ProcessTableRow & { depth: number }): number {
return (row.stat.includes('+') ? 10_000 : 0) + row.depth
}
function processCommandToken(command: string): string {
return getFirstCommandToken(command)
}
function candidateMatchesFallbackWrapper(
candidate: ProcessTableRow,
fallbackProcess: string
): boolean {
return isExpectedAgentProcess(processCommandToken(candidate.command), fallbackProcess)
}
async function getRecognizedForegroundDescendant(
pid: number,
fallbackProcess?: string | null
): Promise<string | null> {
try {
const rows = await getProcessTableSnapshot()
const root = rows.find((row) => row.pid === pid)
const candidates = collectDescendants(rows, pid).sort(
(a, b) => candidateScore(b) - candidateScore(a)
)
// Why: SSH relays do not have the daemon's async wrapper cache. Inspect the
// remote process tree so node/python agent entrypoints become real agents.
const foregroundIsKnown =
root?.stat.includes('+') === true ||
candidates.some((candidate) => candidate.stat.includes('+'))
const foregroundCandidates = foregroundIsKnown
? candidates.filter((candidate) => candidate.stat.includes('+'))
: candidates
const inspectionCandidates =
fallbackProcess && isAgentForegroundWrapperProcess(fallbackProcess)
? foregroundCandidates.filter((candidate) =>
candidateMatchesFallbackWrapper(candidate, fallbackProcess)
)
: foregroundCandidates
if (
fallbackProcess &&
isAgentForegroundWrapperProcess(fallbackProcess) &&
inspectionCandidates.length !== 1
) {
return null
}
for (const candidate of inspectionCandidates) {
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
if (recognized) {
// Why: return the outer wrapper (omp) rather than the deeper wrapped child
// (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates)
}
}
} catch {
// Fall through to node-pty's process name or the root command name.
}
return null
}
/**
* Get the foreground process name of a given pid (via ps).
*/
export async function getForegroundProcessName(
pid: number,
fallbackProcess?: string | null
): Promise<string | null> {
if (fallbackProcess) {
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
if (fallbackRecognition) {
// Why: node-pty can report OMP's wrapped Pi; enrich only that ambiguous
// fallback so authoritative OMP reads keep the zero-subprocess fast path.
if (shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) {
if (process.platform === 'win32') {
return (
(await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ??
fallbackRecognition.processName
)
}
return (
(await getRecognizedForegroundDescendant(pid, fallbackProcess)) ??
fallbackRecognition.processName
)
}
return fallbackRecognition.processName
}
if (process.platform === 'win32') {
if (!shouldInspectWindowsAgentForeground(fallbackProcess)) {
return fallbackProcess
}
return (
(await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? fallbackProcess
)
}
if (!isShellProcess(fallbackProcess) && !isAgentForegroundWrapperProcess(fallbackProcess)) {
return fallbackProcess
}
}
const recognized = await getRecognizedForegroundDescendant(pid, fallbackProcess)
if (recognized) {
return recognized
}
if (fallbackProcess) {
return fallbackProcess
}
try {
const { stdout } = await execFile('ps', ['-o', 'comm=', '-p', String(pid)], {
encoding: 'utf-8',
timeout: 3000
})
return stdout.trim() || null
} catch {
return null
}
}
/**
* List available shell profiles from /etc/shells (or known fallbacks).
*/
@@ -5,6 +5,7 @@ import {
import type { RecognizedAgentProcess } from '../../../../shared/agent-process-recognition'
import { recognizeAgentProcess } from '../../../../shared/agent-process-recognition'
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
import { readPtyProcessInspectionEvidence } from '../../../../shared/pty-process-inspection-evidence'
import {
NO_EVIDENCE_ACTIVITY_HOT_WINDOW_MS,
POLL_TIER_INTERVAL_MS,
@@ -74,18 +75,33 @@ export function createAgentCompletionProcessMonitor({
scheduleNextPoll()
return false
}
state.consecutiveInspectionErrors = 0
const recognized = recognizeAgentProcess(result.foregroundProcess)
if (recognized) {
handleRecognizedProcess(recognized)
return true
const evidence = readPtyProcessInspectionEvidence(result)
if (evidence.foreground.verdict === 'observed') {
const recognized = recognizeAgentProcess(evidence.foreground.processName)
if (recognized) {
state.consecutiveInspectionErrors = 0
handleRecognizedProcess(recognized)
return true
}
}
if (
evidence.foreground.verdict === 'unverifiable' ||
evidence.children.verdict === 'unverifiable'
) {
// The host could not ask; that is never exit evidence. Re-arm the
// two-sample confirmation like any other failed inspection.
state.pendingProcessExitAgent = null
state.consecutiveInspectionErrors += 1
scheduleNextPoll()
return false
}
state.consecutiveInspectionErrors = 0
if (hasPendingHookDone() || hasPendingCodexAttention()) {
scheduleNextPoll()
return false
}
if (state.lastForegroundAgent && state.hasAgentRunEvidence) {
if (result.hasChildProcesses) {
if (evidence.children.verdict === 'live') {
state.pendingProcessExitAgent = null
scheduleNextPoll()
return false
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from 'vitest'
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
import {
processResult,
useAgentCompletionCoordinatorLifecycle
} from './agent-completion-coordinator-test-harness'
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
function unverifiableChildren(foregroundProcess: string | null): RuntimeTerminalProcessInspection {
return {
foregroundProcess,
hasChildProcesses: false,
processEvidence: {
foreground: { verdict: 'observed', processName: foregroundProcess },
children: { verdict: 'unverifiable', reason: 'pgrep did not answer before its deadline' }
}
}
}
function unverifiableForeground(): RuntimeTerminalProcessInspection {
return {
foregroundProcess: null,
hasChildProcesses: false,
processEvidence: {
foreground: { verdict: 'unverifiable', reason: 'ps did not answer before its deadline' },
children: { verdict: 'exited' }
}
}
}
function confirmedExit(foregroundProcess: string | null): RuntimeTerminalProcessInspection {
return {
foregroundProcess,
hasChildProcesses: false,
processEvidence: {
foreground: { verdict: 'observed', processName: foregroundProcess },
children: { verdict: 'exited' }
}
}
}
describe('agent completion with inspection evidence', () => {
useAgentCompletionCoordinatorLifecycle()
function startCoordinator(results: () => RuntimeTerminalProcessInspection) {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(async () => results()),
dispatchCompletion,
isLive: () => true
})
coordinator.startProcessTracking()
return { coordinator, dispatchCompletion }
}
it('never turns unverifiable child evidence into a process-exit completion', async () => {
let result: RuntimeTerminalProcessInspection = processResult('codex')
const { dispatchCompletion } = startCoordinator(() => result)
await vi.advanceTimersByTimeAsync(2_000)
// The host stops being able to answer pgrep; codex may still be running.
result = unverifiableChildren('zsh')
await vi.advanceTimersByTimeAsync(60_000)
expect(dispatchCompletion).not.toHaveBeenCalled()
})
it('never turns unverifiable foreground evidence into a process-exit completion', async () => {
let result: RuntimeTerminalProcessInspection = processResult('codex')
const { dispatchCompletion } = startCoordinator(() => result)
await vi.advanceTimersByTimeAsync(2_000)
result = unverifiableForeground()
await vi.advanceTimersByTimeAsync(60_000)
expect(dispatchCompletion).not.toHaveBeenCalled()
})
it('keeps tracking a recognized agent whose child probe is unverifiable', async () => {
let result: RuntimeTerminalProcessInspection = {
foregroundProcess: 'codex',
hasChildProcesses: false,
processEvidence: {
foreground: { verdict: 'observed', processName: 'codex' },
children: { verdict: 'unverifiable', reason: 'pgrep could not run: ENOENT' }
}
}
const { dispatchCompletion } = startCoordinator(() => result)
await vi.advanceTimersByTimeAsync(4_000)
expect(dispatchCompletion).not.toHaveBeenCalled()
// Once the probes answer and positively observe the exit, the completion
// still fires — recognition was not lost to the unverifiable child probe.
result = confirmedExit('zsh')
await vi.advanceTimersByTimeAsync(4_000)
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
expect(dispatchCompletion).toHaveBeenCalledWith('codex', {
source: 'process-exit',
quietedHookDone: false,
terminalIdleConfirmed: true
})
})
it('treats malformed evidence from a foreign host as unverifiable', async () => {
let result: RuntimeTerminalProcessInspection = processResult('codex')
const { dispatchCompletion } = startCoordinator(() => result)
await vi.advanceTimersByTimeAsync(2_000)
result = {
foregroundProcess: null,
hasChildProcesses: false,
processEvidence: {
foreground: { verdict: 'observed', processName: null },
children: { verdict: 'someday-new-verdict' } as never
}
}
await vi.advanceTimersByTimeAsync(60_000)
expect(dispatchCompletion).not.toHaveBeenCalled()
})
it('still completes from legacy fields when the host predates evidence', async () => {
// An old host publishes no processEvidence; its legacy answer remains the
// only available reading and completion detection must keep working.
let result: RuntimeTerminalProcessInspection = processResult('codex')
const { dispatchCompletion } = startCoordinator(() => result)
await vi.advanceTimersByTimeAsync(2_000)
result = processResult(null, false)
await vi.advanceTimersByTimeAsync(2_000)
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
expect(dispatchCompletion).toHaveBeenCalledWith('codex', {
source: 'process-exit',
quietedHookDone: false,
terminalIdleConfirmed: true
})
})
})
@@ -1,4 +1,5 @@
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { PtyProcessInspectionEvidence } from '../../../shared/pty-process-inspection-evidence'
import type { RuntimeTerminalSend } from '../../../shared/runtime-types'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { isTerminalInputTooLargeWithDeferredMeasurement } from '../../../shared/terminal-input'
@@ -14,6 +15,9 @@ export type RuntimeTerminalProcessInspection = {
hasChildProcesses: boolean
// Why: callers must not treat a stale remote handle as authoritative idle evidence.
unavailable?: true
// Why: relay hosts publish per-probe live/unverifiable/exited evidence so a
// failed host probe cannot be read as exit evidence; absent on older hosts.
processEvidence?: PtyProcessInspectionEvidence
}
const REMOTE_PTY_ID_PREFIX = 'remote:'
@@ -0,0 +1,103 @@
/**
* Evidence contract for PTY agent-process inspection.
*
* The completion monitor concludes "the agent finished" from two observations:
* the foreground process is no longer a recognized agent, and the shell has no
* child processes. Both probes run on the execution host, and on a relay host
* "could not ask" is a normal steady state — probes time out under load, and
* minimal hosts lack `pgrep`. Per docs/reference/ssh-execution-boundary.md the
* vocabulary is `live` / `unverifiable` / `exited`: a probe that failed to run
* is `unverifiable` and must never be read as exit evidence; only a probe that
* ran and positively observed absence may say `exited`.
*
* Wire compatibility (docs/reference/remote-wire-compatibility.md): the
* evidence rides in a NEW OPTIONAL FIELD (`processEvidence`) beside the legacy
* `foregroundProcess` / `hasChildProcesses` fields, whose published values keep
* the exact legacy collapse. An old client ignores the field and sees identical
* content; a new client against an old host reads the legacy fields as the only
* available answer.
*/
export type PtyForegroundProcessEvidence =
| { verdict: 'observed'; processName: string | null }
| { verdict: 'unverifiable'; reason: string }
export type PtyChildProcessesEvidence =
| { verdict: 'live' }
| { verdict: 'exited' }
| { verdict: 'unverifiable'; reason: string }
export type PtyProcessInspectionEvidence = {
foreground: PtyForegroundProcessEvidence
children: PtyChildProcessesEvidence
}
export type PtyProcessInspectionWireResult = {
foregroundProcess: string | null
hasChildProcesses: boolean
processEvidence: PtyProcessInspectionEvidence
}
/**
* Collapse probe evidence into the wire result. The legacy fields reproduce the
* pre-evidence behavior exactly (`unverifiable` collapses to `null` / `false`)
* so clients that predate `processEvidence` observe unchanged host content.
*/
export function buildPtyProcessInspectionWireResult(
foreground: PtyForegroundProcessEvidence,
children: PtyChildProcessesEvidence
): PtyProcessInspectionWireResult {
return {
foregroundProcess: foreground.verdict === 'observed' ? foreground.processName : null,
hasChildProcesses: children.verdict === 'live',
processEvidence: { foreground, children }
}
}
/**
* Read the evidence off a wire result, tolerating peers this client cannot
* vouch for. A host that predates the field gets the legacy interpretation —
* its published values are the only answer it can give. A malformed shape from
* a foreign host reads as `unverifiable`, never as an observation.
*/
export function readPtyProcessInspectionEvidence(result: {
foregroundProcess: string | null
hasChildProcesses: boolean
processEvidence?: PtyProcessInspectionEvidence
}): PtyProcessInspectionEvidence {
const evidence = result.processEvidence
if (evidence === undefined) {
return {
foreground: { verdict: 'observed', processName: result.foregroundProcess },
children: result.hasChildProcesses ? { verdict: 'live' } : { verdict: 'exited' }
}
}
return {
foreground: normalizeForegroundEvidence(evidence?.foreground),
children: normalizeChildrenEvidence(evidence?.children)
}
}
function normalizeForegroundEvidence(
evidence: PtyForegroundProcessEvidence | undefined
): PtyForegroundProcessEvidence {
if (evidence?.verdict === 'observed') {
return { verdict: 'observed', processName: evidence.processName ?? null }
}
if (evidence?.verdict === 'unverifiable') {
return { verdict: 'unverifiable', reason: evidence.reason ?? 'unspecified' }
}
return { verdict: 'unverifiable', reason: 'malformed foreground inspection evidence' }
}
function normalizeChildrenEvidence(
evidence: PtyChildProcessesEvidence | undefined
): PtyChildProcessesEvidence {
if (evidence?.verdict === 'live' || evidence?.verdict === 'exited') {
return { verdict: evidence.verdict }
}
if (evidence?.verdict === 'unverifiable') {
return { verdict: 'unverifiable', reason: evidence.reason ?? 'unspecified' }
}
return { verdict: 'unverifiable', reason: 'malformed child-process inspection evidence' }
}
@@ -0,0 +1,274 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Mock } from 'vitest'
import * as ptyProcessProbes from '../../../src/relay/pty-process-probes'
const { execFileMock, execFileSyncMock, mockPtySpawn, mockPtyInstance, mockReadinessProbe } =
vi.hoisted(() => ({
execFileMock: vi.fn(),
execFileSyncMock: vi.fn(),
mockPtySpawn: vi.fn(),
mockReadinessProbe: vi.fn(),
mockPtyInstance: {
pid: process.pid,
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
clear: vi.fn(),
pause: vi.fn(),
resume: vi.fn()
}
}))
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock
}))
vi.mock('node-pty', () => ({
spawn: mockPtySpawn
}))
vi.mock('../../../src/main/pty/posix-pty-process-groups', () => ({
forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback())
}))
vi.mock('../../../src/main/shell-prompt-readiness-probe', () => ({
createShellPromptReadinessProbe: mockReadinessProbe
}))
import { resetProcessTableSnapshotForTests } from '../../../src/shared/process-table-snapshot'
import type { PtyHandler } from '../../../src/relay/pty-handler'
import {
beginPtyHandlerTest,
createPtyRequestHelpers,
endPtyHandlerTest,
type MockDispatcher
} from '../../../src/relay/pty-handler-test-harness'
import {
createAgentCompletionCoordinator,
resetAgentCompletionCoordinatorIdentitiesForTest
} from '../../../src/renderer/src/components/terminal-pane/agent-completion-coordinator'
import { resetAgentProcessInspectionQueueForTests } from '../../../src/renderer/src/components/terminal-pane/agent-process-inspection-queue'
import type { RuntimeTerminalProcessInspection } from '../../../src/renderer/src/runtime/runtime-terminal-inspection'
/**
* "Failure becomes fact" coercion, SSH relay completion site.
*
* The relay adapter (pty-shell-utils) swallows `pgrep`/`ps` failures and
* resolves `{ foregroundProcess: <fallback>, hasChildProcesses: false }` — the
* exact payload the agent-completion monitor reads as positive exit evidence.
* On a distressed or minimal relay host (probe timeouts under load, procps
* missing) the truth is "could not ask", which must stay `unverifiable` and
* must never become a completion. Loss of contact is never evidence of death
* (docs/reference/ssh-execution-boundary.md).
*/
describe('relay inspection under probe failure', () => {
let dispatcher: MockDispatcher
let handler: PtyHandler
let originalPlatform: PropertyDescriptor | undefined
const { spawnPty } = createPtyRequestHelpers(() => dispatcher)
// Timeout-killed subprocess: what execFile yields when the host is too
// loaded to answer `ps`/`pgrep` inside the 3s probe deadline.
function timeoutKilledError(command: string): Error {
const error = new Error(`spawn ${command} ETIMEDOUT`) as Error & {
killed: boolean
signal: string
code: null
}
error.killed = true
error.signal = 'SIGTERM'
error.code = null
return error
}
type ExecBehavior = 'healthy' | 'probe-failure'
let execBehavior: ExecBehavior = 'healthy'
function installExecFile(): void {
execFileMock.mockImplementation(
(command: string, args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (
err: Error | null,
result: { stdout: string; stderr: string }
) => void
if (execBehavior === 'probe-failure') {
callback(timeoutKilledError(command), { stdout: '', stderr: '' })
return
}
if (command === 'ps' && args[0] === '-axo') {
callback(null, {
stdout: [
`${process.pid} 1 Ss bash -l`,
`99999 ${process.pid} S+ node /home/dev/.local/bin/codex`
].join('\n'),
stderr: ''
})
return
}
if (command === 'pgrep') {
callback(null, { stdout: '99999\n', stderr: '' })
return
}
callback(new Error(`unexpected command ${command}`), { stdout: '', stderr: '' })
}
)
}
beforeEach(() => {
execBehavior = 'healthy'
execFileMock.mockReset()
installExecFile()
resetProcessTableSnapshotForTests()
;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({
mockPtySpawn,
mockPtyInstance,
mockCreateShellPromptReadinessProbe: mockReadinessProbe
}))
// The harness stubs processHasChildren for unrelated PTY tests; this suite
// exercises the real relay adapter, including its failure handling.
;(ptyProcessProbes.processHasChildren as unknown as Mock).mockRestore()
;(ptyProcessProbes.probeProcessChildren as unknown as Mock).mockRestore()
// node-pty reports the wrapper entrypoint as the foreground process for
// node-launched agents; descendant scans resolve it to the real agent.
mockPtySpawn.mockReturnValue({ ...mockPtyInstance, process: 'node' })
vi.spyOn(Math, 'random').mockReturnValue(0.5)
})
afterEach(async () => {
await endPtyHandlerTest(handler, originalPlatform)
resetAgentProcessInspectionQueueForTests()
resetAgentCompletionCoordinatorIdentitiesForTest()
resetProcessTableSnapshotForTests()
})
it('does not conclude an agent finished when the host process probes fail', async () => {
const { id } = await spawnPty({})
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => id,
getSettings: () => null,
// Real transport shape: the renderer awaits the relay RPC result and
// treats whatever resolves as the inspection answer.
inspectProcess: async (_settings, ptyId) =>
(await dispatcher.callRequest('pty.inspectProcess', {
id: ptyId
})) as RuntimeTerminalProcessInspection,
dispatchCompletion,
isLive: () => true
})
coordinator.startProcessTracking()
coordinator.observeTitle('Codex working')
// Healthy poll: the descendant scan proves codex is live in this pane.
await vi.advanceTimersByTimeAsync(2_000)
expect(dispatchCompletion).not.toHaveBeenCalled()
// Host distress: every process probe now times out. Codex is still
// running; the host merely cannot answer.
execBehavior = 'probe-failure'
await vi.advanceTimersByTimeAsync(20_000)
expect(dispatchCompletion).not.toHaveBeenCalled()
coordinator.dispose()
})
// Old-client behavior proof (remote-wire-compatibility Rule 1/Rule 3): the
// legacy fields keep the exact pre-evidence collapse, so a client that
// predates `processEvidence` observes byte-identical host content.
it('publishes unchanged legacy fields beside the evidence', async () => {
const { id } = await spawnPty({})
const healthy = await dispatcher.callRequest('pty.inspectProcess', { id })
expect(healthy).toEqual({
foregroundProcess: 'codex',
hasChildProcesses: true,
processEvidence: {
foreground: { verdict: 'observed', processName: 'codex' },
children: { verdict: 'live' }
}
})
execBehavior = 'probe-failure'
await vi.advanceTimersByTimeAsync(600)
const failed = await dispatcher.callRequest('pty.inspectProcess', { id })
expect(failed).toEqual({
// node-pty's own record still answers the foreground question.
foregroundProcess: 'node',
// The pre-evidence collapse old clients have always received.
hasChildProcesses: false,
processEvidence: {
foreground: { verdict: 'observed', processName: 'node' },
children: { verdict: 'unverifiable', reason: expect.any(String) }
}
})
})
it('still confirms a real exit once the probes answer again', async () => {
const { id } = await spawnPty({})
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-2:leaf-1',
getPtyId: () => id,
getSettings: () => null,
inspectProcess: async (_settings, ptyId) =>
(await dispatcher.callRequest('pty.inspectProcess', {
id: ptyId
})) as RuntimeTerminalProcessInspection,
dispatchCompletion,
isLive: () => true
})
coordinator.startProcessTracking()
coordinator.observeTitle('Codex working')
await vi.advanceTimersByTimeAsync(2_000)
execBehavior = 'probe-failure'
await vi.advanceTimersByTimeAsync(10_000)
expect(dispatchCompletion).not.toHaveBeenCalled()
// Recovery, and the agent has genuinely exited: bash has the foreground
// again and pgrep positively reports no children.
execBehavior = 'healthy'
execFileMock.mockImplementation(
(command: string, args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (
err: Error | null,
result: { stdout: string; stderr: string }
) => void
if (command === 'ps' && args[0] === '-axo') {
callback(null, { stdout: `${process.pid} 1 Ss+ bash -l`, stderr: '' })
return
}
if (command === 'pgrep') {
// pgrep ran and matched nothing: exits 1 with empty output.
const noMatch = new Error('pgrep exited 1') as Error & { code: number; killed: boolean }
noMatch.code = 1
noMatch.killed = false
callback(noMatch, { stdout: '', stderr: '' })
return
}
callback(new Error(`unexpected command ${command}`), { stdout: '', stderr: '' })
}
)
await vi.advanceTimersByTimeAsync(30_000)
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
expect(dispatchCompletion).toHaveBeenCalledWith('codex', {
source: 'process-exit',
quietedHookDone: false,
terminalIdleConfirmed: true
})
coordinator.dispose()
})
})