mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
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:
@@ -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 & {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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[]> {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
+148
@@ -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' }
|
||||
}
|
||||
+274
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user