fix(terminal): close process inspection contract

This commit is contained in:
Brennan Benson
2026-08-27 17:47:40 -07:00
parent bc72ee5219
commit ebd61242c2
28 changed files with 815 additions and 147 deletions
@@ -3,6 +3,7 @@ import {
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION,
AUTHORITATIVE_COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION,
@@ -19,7 +20,8 @@ import {
describe('daemon protocol version', () => {
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
expect(PROTOCOL_VERSION).toBe(36)
expect(PROTOCOL_VERSION).toBe(37)
expect(AUTHORITATIVE_COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(37)
expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36)
expect(ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION).toBe(35)
expect(CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION).toBe(34)
@@ -33,7 +35,7 @@ describe('daemon protocol version', () => {
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
Array.from({ length: 35 }, (_, index) => index + 1)
Array.from({ length: 36 }, (_, index) => index + 1)
)
})
+4 -3
View File
@@ -1,6 +1,7 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
// v36 launches shells from content-addressed wrapper trees; older owners stay attachable.
export const PROTOCOL_VERSION = 36
// v37 makes completion-sensitive process inspection fail closed; older owners stay attachable.
export const PROTOCOL_VERSION = 37
export const AUTHORITATIVE_COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 37
export const CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION = 36
export const ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION = 35
export const CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION = 34
@@ -30,7 +31,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28, 29, 30, 31, 32, 33, 34, 35
28, 29, 30, 31, 32, 33, 34, 35, 36
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
@@ -5,6 +5,7 @@ import { DaemonClient } from './client'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-session-control'
import {
AUTHORITATIVE_COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
GET_SIZE_PROTOCOL_VERSION,
@@ -623,7 +624,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(await legacy.inspectProcess('sess-a')).toEqual({
foregroundProcess: 'codex',
hasChildProcesses: true
hasChildProcesses: true,
unavailable: true
})
expect(request).toHaveBeenCalledWith('getForegroundProcess', { sessionId: 'sess-a' })
expect(request).not.toHaveBeenCalledWith('inspectProcess', expect.anything())
@@ -639,7 +641,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(await legacy.inspectProcess('sess-a')).toEqual({
foregroundProcess: 'bash',
hasChildProcesses: false
hasChildProcesses: false,
unavailable: true
})
legacy.dispose()
@@ -653,7 +656,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(await legacy.inspectProcess('sess-a')).toEqual({
foregroundProcess: null,
hasChildProcesses: false
hasChildProcesses: false,
unavailable: true
})
legacy.dispose()
@@ -674,8 +678,25 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
legacy.dispose()
})
it.each([COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, PROTOCOL_VERSION])(
'delegates protocol %s inspection to inspectProcess',
it.each([
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
AUTHORITATIVE_COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1
])('marks protocol %s inspectProcess results unavailable', async (protocolVersion) => {
const request = vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false }))
const legacy = createInspectionAdapter(protocolVersion, request)
await expect(legacy.inspectProcess('sess-a')).resolves.toEqual({
foregroundProcess: null,
hasChildProcesses: false,
unavailable: true
})
expect(request).toHaveBeenCalledWith('inspectProcess', { sessionId: 'sess-a' })
legacy.dispose()
})
it.each([AUTHORITATIVE_COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, PROTOCOL_VERSION])(
'delegates authoritative protocol %s inspection to inspectProcess',
async (protocolVersion) => {
const request = vi.fn(async () => ({ foregroundProcess: 'codex', hasChildProcesses: true }))
const current = createInspectionAdapter(protocolVersion, request)
@@ -2,6 +2,7 @@ import { isShellProcess } from '../../shared/agent-detection'
import { DaemonPtyBufferSnapshots } from './daemon-pty-buffer-snapshots'
import { parsePtySessionId } from './pty-session-id'
import {
AUTHORITATIVE_COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
type ListSessionsResult,
@@ -27,22 +28,27 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot
return { foregroundProcess: null, hasChildProcesses: true, unavailable: true }
}
if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) {
// Why: pre-v27 daemons survive an in-place app update; compose the inspection client-side from the
// one call they do support instead of throwing, or completion detection stays dead until recreate.
// Requests directly (not via getForegroundProcess) so a dead socket still rejects rather than
// reading as an idle foreground and dispatching a false completion.
// Why: pre-v27 daemons expose only a best-effort read that swallows scan failures into null.
// Preserve positive evidence, but never turn its shell/null result into completion authority.
const { foregroundProcess } = await this.client.request<{
foregroundProcess: string | null
}>('getForegroundProcess', { sessionId: id })
return {
foregroundProcess,
hasChildProcesses: this.hasChildProcessesFromForeground(foregroundProcess)
hasChildProcesses: this.hasChildProcessesFromForeground(foregroundProcess),
unavailable: true
}
}
return this.client.request<{
const inspection = await this.client.request<{
foregroundProcess: string | null
hasChildProcesses: boolean
}>('inspectProcess', { sessionId: id })
return {
...inspection,
...(this.protocolVersion < AUTHORITATIVE_COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION
? { unavailable: true as const }
: {})
}
}
async getForegroundProcess(id: string): Promise<string | null> {
@@ -0,0 +1,104 @@
import type * as pty from 'node-pty'
import { recognizeAgentProcess } from '../../../shared/agent-process-recognition'
import { shouldInspectOuterWrapperForegroundProcess } from '../../../shared/foreground-wrapper-agent'
import {
inspectAgentPtyProcess,
resolveAgentForegroundProcessWithAvailability
} from '../../providers/agent-foreground-process'
import { readWindowsConsoleAttachedProcessIds } from '../../providers/windows-console-attached-processes'
import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership'
type ForegroundInspectionArgs = {
process: pty.IPty
contextPaths: readonly string[]
isDead: () => boolean
getFallbackProcess: () => string | null
shouldInspectFallback: (fallbackProcess: string | null) => boolean
cachedAgentName: () => string | null
setRecognizedAgent: (processName: string, processId: number | null) => void
clearAgentEvidence: () => void
}
export async function inspectTrackedForegroundProcess(
args: ForegroundInspectionArgs
): Promise<{ processName: string | null; available: boolean }> {
const proc = args.process
if (args.isDead() || !proc.pid) {
return { processName: null, available: false }
}
try {
const fallbackProcess = args.getFallbackProcess()
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
if (
fallbackProcess &&
((fallbackRecognition !== null &&
process.platform !== 'win32' &&
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
(process.platform !== 'win32' && !args.shouldInspectFallback(fallbackProcess)))
) {
return { processName: fallbackProcess, available: true }
}
const resolution = await resolveAgentForegroundProcessWithAvailability(
proc.pid,
fallbackProcess,
{
contextPaths: args.contextPaths,
fresh: true,
...(process.platform === 'win32'
? {
forceProcessScan: true,
readWindowsConsoleAttachedProcessIds: () =>
readWindowsConsoleAttachedProcessIds(proc.pid)
}
: {})
}
)
if (args.isDead() || !resolution.available) {
return { processName: args.cachedAgentName() ?? fallbackProcess, available: false }
}
const recognized = recognizeAgentProcess(resolution.processName)
if (recognized) {
args.setRecognizedAgent(recognized.processName, resolution.processId ?? null)
return { processName: recognized.processName, available: true }
}
args.clearAgentEvidence()
return { processName: resolution.processName, available: true }
} catch {
return { processName: args.cachedAgentName(), available: false }
}
}
export async function inspectTrackedPtyProcess(args: ForegroundInspectionArgs): Promise<{
foregroundProcess: string | null
hasChildProcesses: boolean
unavailable?: true
}> {
const proc = args.process
if (args.isDead() || !proc.pid) {
return { foregroundProcess: null, hasChildProcesses: false, unavailable: true }
}
const inspection = await inspectAgentPtyProcess(proc.pid, args.getFallbackProcess(), {
contextPaths: args.contextPaths,
forceProcessScan: true,
...(process.platform === 'win32'
? {
readWindowsConsoleAttachedProcessIds: () =>
readWindowsConsoleAttachedProcessIds(proc.pid),
readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(proc)
}
: {})
})
if (inspection.available) {
const recognized = recognizeAgentProcess(inspection.processName)
if (recognized) {
args.setRecognizedAgent(recognized.processName, inspection.processId ?? null)
} else {
args.clearAgentEvidence()
}
}
return {
foregroundProcess: inspection.processName,
hasChildProcesses: inspection.hasChildProcesses,
...(inspection.available ? {} : { unavailable: true })
}
}
@@ -11,7 +11,6 @@ import {
readWindowsPtyJobProcessIds
} from '../../providers/windows-pty-job-membership'
import { readWindowsConsoleAttachedProcessIds } from '../../providers/windows-console-attached-processes'
import {
isAgentForegroundWrapperProcess,
recognizeAgentProcess,
@@ -24,6 +23,10 @@ import {
import { isShellProcess } from '../../../shared/shell-process-detection'
import { resolveFallbackForegroundProcess } from './foreground-fallback-process'
import { parsePtySessionId } from '../pty-session-id'
import {
inspectTrackedForegroundProcess,
inspectTrackedPtyProcess
} from './foreground-process-inspection'
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000
@@ -37,6 +40,11 @@ export type PtyForegroundProcessTracker = {
recordOutput(data: string): void
markDead(): void
getForegroundProcess(): string | null
inspectProcess(): Promise<{
foregroundProcess: string | null
hasChildProcesses: boolean
unavailable?: true
}>
confirmForegroundProcess(): Promise<string | null>
confirmShellForeground(): Promise<boolean>
}
@@ -203,6 +211,24 @@ export function createPtyForegroundProcessTracker(args: {
})
}
const inspectionArgs = {
process: proc,
contextPaths,
isDead: args.isDead,
getFallbackProcess,
shouldInspectFallback,
cachedAgentName: () => cachedAgentForeground?.processName ?? null,
setRecognizedAgent: (processName: string, pid: number | null) => {
cachedAgentForeground = { processName, pid, refreshedAt: Date.now() }
startupAgentForeground = null
},
clearAgentEvidence: () => {
cachedAgentForeground = null
startupAgentForeground = null
}
}
const inspectForegroundProcess = () => inspectTrackedForegroundProcess(inspectionArgs)
return {
recordOutput: (data) => {
if (data.length > 0) {
@@ -258,56 +284,10 @@ export function createPtyForegroundProcessTracker(args: {
return null
}
},
inspectProcess: () => inspectTrackedPtyProcess(inspectionArgs),
confirmForegroundProcess: async () => {
if (args.isDead() || !proc.pid) {
return null
}
try {
const fallbackProcess = getFallbackProcess()
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
if (
!fallbackProcess ||
(fallbackRecognition !== null &&
process.platform !== 'win32' &&
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
(process.platform !== 'win32' && !shouldInspectFallback(fallbackProcess))
) {
return fallbackProcess
}
const resolution = await resolveAgentForegroundProcessWithAvailability(
proc.pid,
fallbackProcess,
{
contextPaths,
fresh: true,
...(process.platform === 'win32'
? {
forceProcessScan: true,
readWindowsConsoleAttachedProcessIds: () =>
readWindowsConsoleAttachedProcessIds(proc.pid)
}
: {})
}
)
if (args.isDead() || !resolution.available) {
return null
}
const recognized = recognizeAgentProcess(resolution.processName)
if (recognized) {
cachedAgentForeground = {
processName: recognized.processName,
pid: resolution.processId ?? null,
refreshedAt: Date.now()
}
startupAgentForeground = null
return recognized.processName
}
cachedAgentForeground = null
startupAgentForeground = null
return resolution.processName
} catch {
return null
}
const inspection = await inspectForegroundProcess()
return inspection.available ? inspection.processName : null
},
confirmShellForeground: () =>
confirmPtyShellForeground({ process: proc, shellPath: args.shellPath, isDead: args.isDead })
@@ -70,6 +70,7 @@ export function createDaemonPtySubprocessHandle(args: {
? { startupCommandDeliveredInShellArgs: true }
: {}),
getForegroundProcess: foreground.getForegroundProcess,
inspectProcess: foreground.inspectProcess,
confirmForegroundProcess: foreground.confirmForegroundProcess,
confirmShellForeground: foreground.confirmShellForeground,
write: (data) => {
@@ -0,0 +1,32 @@
import type { TakePendingOutputResult, TerminalSnapshot } from './types'
export function takeSessionPendingOutput(args: {
disposed: boolean
includeSnapshot: boolean
teardownSnapshot: boolean
prepareForFinalSnapshot: () => string
takePendingOutput: (
includeSnapshot: boolean,
releasedHeldBytes: string,
getSnapshot: () => TerminalSnapshot | null
) => TakePendingOutputResult | null
getSnapshot: () => TerminalSnapshot | null
}): TakePendingOutputResult | null {
if (args.disposed) {
return null
}
const releasedHeldBytes =
args.includeSnapshot && args.teardownSnapshot ? args.prepareForFinalSnapshot() : ''
return args.takePendingOutput(args.includeSnapshot, releasedHeldBytes, args.getSnapshot)
}
export function prepareSessionFinalSnapshot(args: {
releaseHeldBytes: () => string
snapshotIngressBarrier: () => void
flushRecoveryBarrier: () => void
}): string {
const held = args.releaseHeldBytes()
args.snapshotIngressBarrier()
args.flushRecoveryBarrier()
return held
}
@@ -7,6 +7,11 @@ export type SubprocessHandle = {
/** Live foreground process name of the PTY (node-pty's `.process`), e.g.
* 'claude' / 'codex' / 'zsh'. Null once the child has exited. */
getForegroundProcess(): string | null
inspectProcess?(): Promise<{
foregroundProcess: string | null
hasChildProcesses: boolean
unavailable?: true
}>
/** Await process-table evidence captured after this confirmation request. */
confirmForegroundProcess?(): Promise<string | null>
/** Proves a fresh post-boundary PTY process tree contains only the shell. */
@@ -44,3 +49,15 @@ export type SubprocessHandle = {
/** Release the native PTY handle via node-pty's destroy(). Idempotent; safe to call after exit. */
dispose(): void
}
export function inspectSessionSubprocess(handle: SubprocessHandle) {
const foregroundProcess = handle.getForegroundProcess()
return (
handle.inspectProcess?.() ??
Promise.resolve({
foregroundProcess,
hasChildProcesses: foregroundProcess !== null,
unavailable: true as const
})
)
}
+18 -18
View File
@@ -8,12 +8,13 @@ import {
SessionTerminationController,
IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS
} from './session-termination-controller'
import type { SubprocessHandle } from './session-subprocess-handle'
import { inspectSessionSubprocess, type SubprocessHandle } from './session-subprocess-handle'
import type { JobTerminationOutcome } from '../windows/windows-pty-job'
import type { SessionOptions } from './session-options'
import type { TuiAgent } from '../../shared/tui-agent'
import { randomUUID } from 'node:crypto'
import { PtyStartupIngress } from '../../shared/pty-startup-ingress'
import { prepareSessionFinalSnapshot, takeSessionPendingOutput } from './session-snapshot-output'
import type {
SessionState,
@@ -238,23 +239,23 @@ export class Session {
includeSnapshot: boolean,
opts: { teardownSnapshot?: boolean } = {}
): TakePendingOutputResult | null {
if (this._disposed) {
return null
}
const releasedHeldBytes =
includeSnapshot && opts.teardownSnapshot === true ? this.prepareForFinalSnapshot() : ''
return this.output.takePendingOutput(includeSnapshot, releasedHeldBytes, () =>
this.getSnapshot()
)
return takeSessionPendingOutput({
disposed: this._disposed,
includeSnapshot,
teardownSnapshot: opts.teardownSnapshot === true,
prepareForFinalSnapshot: () => this.prepareForFinalSnapshot(),
takePendingOutput: (...args) => this.output.takePendingOutput(...args),
getSnapshot: () => this.getSnapshot()
})
}
getCwd(): string | null {
return this.output.getCwd()
}
getForegroundProcess(): string | null {
return this.subprocess.getForegroundProcess()
}
getForegroundProcess = (): string | null => this.subprocess.getForegroundProcess()
inspectProcess = () => inspectSessionSubprocess(this.subprocess)
async confirmForegroundProcess(): Promise<string | null> {
return this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess()
@@ -275,12 +276,11 @@ export class Session {
}
prepareForFinalSnapshot(): string {
const held = this.shellReady.releaseHeldBytes()
this.startupIngress.snapshotBarrier()
// Why last: snapshotBarrier can emit held spans into the barrier, and a
// teardown checkpoint mid-episode must not lose the barrier's queued bytes.
this.recoveryBarrier.flushPending()
return held
return prepareSessionFinalSnapshot({
releaseHeldBytes: () => this.shellReady.releaseHeldBytes(),
snapshotIngressBarrier: () => this.startupIngress.snapshotBarrier(),
flushRecoveryBarrier: () => this.recoveryBarrier.flushPending()
})
}
dispose(): void {
+2 -2
View File
@@ -93,8 +93,8 @@ describe('TerminalHost', () => {
}
})
it('rejects missing strict inspection', () =>
expect(() => host.inspectProcess('missing-session')).toThrow('not found'))
it('rejects missing strict inspection', async () =>
await expect(host.inspectProcess('missing-session')).rejects.toThrow('not found'))
describe('createOrAttach', () => {
it('creates a new session when none exists', async () => {
+4 -8
View File
@@ -19,7 +19,6 @@ import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd'
import { TerminalHostTombstones } from './terminal-host-tombstones'
import { listLiveTerminalHostSessions } from './terminal-host-session-listing'
import { createOrAttachTerminalSession } from './terminal-host-session-create'
import { isShellProcess } from '../../shared/agent-detection'
import { TerminalAttachCanceledError } from './daemon-errors'
export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
@@ -214,15 +213,12 @@ export class TerminalHost {
return session.getForegroundProcess()
}
inspectProcess(sessionId: string): {
async inspectProcess(sessionId: string): Promise<{
foregroundProcess: string | null
hasChildProcesses: boolean
} {
const foregroundProcess = this.getAliveSession(sessionId).getForegroundProcess()
return {
foregroundProcess,
hasChildProcesses: foregroundProcess !== null && !isShellProcess(foregroundProcess)
}
unavailable?: true
}> {
return this.getAliveSession(sessionId).inspectProcess()
}
async confirmForegroundProcess(sessionId: string): Promise<string | null> {
+1
View File
@@ -29,6 +29,7 @@ export {
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION,
AUTHORITATIVE_COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
CLEAN_DISCONNECT_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
@@ -27,6 +27,10 @@ export type AgentForegroundProcessResolution = {
anchorPidForeign?: boolean
}
export type AgentPtyProcessInspection = AgentForegroundProcessResolution & {
hasChildProcesses: boolean
}
type ShellForegroundConfirmationOptions = {
readWindowsPtyJobProcessIds?: () =>
| ReadonlySet<number>
@@ -34,6 +38,72 @@ type ShellForegroundConfirmationOptions = {
| Promise<ReadonlySet<number> | null>
}
export async function inspectPtyChildProcesses(
shellPid: number | null | undefined,
options: ShellForegroundConfirmationOptions = {}
): Promise<{ hasChildProcesses: boolean; available: boolean }> {
if (!shellPid) {
return { hasChildProcesses: false, available: false }
}
if (process.platform === 'win32') {
try {
const processIds = await options.readWindowsPtyJobProcessIds?.()
return processIds
? { hasChildProcesses: processIds.size > 1, available: processIds.has(shellPid) }
: { hasChildProcesses: false, available: false }
} catch {
return { hasChildProcesses: false, available: false }
}
}
try {
const rows = await getFreshProcessTableSnapshot()
if (!rows.some((row) => row.pid === shellPid)) {
return { hasChildProcesses: false, available: false }
}
return { hasChildProcesses: collectDescendants(rows, shellPid).length > 0, available: true }
} catch {
return { hasChildProcesses: false, available: false }
}
}
export async function inspectAgentPtyProcess(
shellPid: number | null | undefined,
fallbackProcess: string | null,
options: AgentForegroundResolutionOptions & ShellForegroundConfirmationOptions = {}
): Promise<AgentPtyProcessInspection> {
if (!shellPid) {
return { available: false, processName: fallbackProcess, hasChildProcesses: false }
}
if (process.platform === 'win32') {
const [foreground, processIds] = await Promise.all([
resolveAgentForegroundProcessWithAvailability(shellPid, fallbackProcess, {
...options,
fresh: true
}),
Promise.resolve(options.readWindowsPtyJobProcessIds?.() ?? null).catch(() => null)
])
const childInspectionAvailable = processIds?.has(shellPid) === true
return {
...foreground,
available: foreground.available && childInspectionAvailable,
hasChildProcesses: childInspectionAvailable && processIds!.size > 1
}
}
try {
const rows = await getFreshProcessTableSnapshot()
if (!rows.some((row) => row.pid === shellPid)) {
return { available: false, processName: fallbackProcess, hasChildProcesses: false }
}
return {
available: true,
processName: resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess,
hasChildProcesses: collectDescendants(rows, shellPid).length > 0
}
} catch {
return { available: false, processName: fallbackProcess, hasChildProcesses: false }
}
}
function collectDescendants<Row extends { pid: number; ppid: number }>(
rows: Row[],
rootPid: number
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const execFileMock = vi.hoisted(() => vi.fn())
vi.mock('child_process', () => ({ execFile: execFileMock }))
import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot'
import { inspectAgentPtyProcess, inspectPtyChildProcesses } from './agent-foreground-process'
function mockPs(stdout: string): void {
execFileMock.mockImplementation((_cmd, _args, _opts, callback) => {
callback(null, { stdout, stderr: '' })
})
}
describe('agent PTY process inspection', () => {
let platform: PropertyDescriptor | undefined
beforeEach(() => {
execFileMock.mockReset()
resetProcessTableSnapshotForTests()
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
})
afterEach(() => {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})
it('resolves POSIX identity and children from one fresh snapshot', async () => {
mockPs(['100 99 Ss+ /bin/zsh -l', '101 100 S+ sleep 100'].join('\n'))
await expect(inspectAgentPtyProcess(100, 'zsh')).resolves.toEqual({
available: true,
processName: 'zsh',
hasChildProcesses: true
})
expect(execFileMock).toHaveBeenCalledTimes(1)
})
it('reports an available child-free POSIX shell', async () => {
mockPs('100 99 Ss+ /bin/zsh -l')
await expect(inspectAgentPtyProcess(100, 'zsh')).resolves.toEqual({
available: true,
processName: 'zsh',
hasChildProcesses: false
})
})
it('treats a missing POSIX root and failed scan as unavailable', async () => {
mockPs('101 999 S+ sleep 100')
await expect(inspectPtyChildProcesses(100)).resolves.toEqual({
available: false,
hasChildProcesses: false
})
resetProcessTableSnapshotForTests()
execFileMock.mockImplementation((_cmd, _args, _opts, callback) => {
callback(new Error('ps unavailable'), { stdout: '', stderr: '' })
})
await expect(inspectPtyChildProcesses(100)).resolves.toEqual({
available: false,
hasChildProcesses: false
})
})
it('reads Windows child evidence from ConPTY membership', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
await expect(
inspectPtyChildProcesses(100, {
readWindowsPtyJobProcessIds: async () => new Set([100, 101])
})
).resolves.toEqual({ available: true, hasChildProcesses: true })
await expect(
inspectPtyChildProcesses(100, { readWindowsPtyJobProcessIds: async () => new Set([100]) })
).resolves.toEqual({ available: true, hasChildProcesses: false })
await expect(
inspectPtyChildProcesses(100, { readWindowsPtyJobProcessIds: async () => null })
).resolves.toEqual({ available: false, hasChildProcesses: false })
})
})
@@ -9,6 +9,7 @@ const {
writeFileSyncMock,
spawnMock,
prepareMacosTccLoginShellMock,
inspectAgentPtyProcessMock,
resolveAgentForegroundProcessMock,
readWindowsPtyJobProcessIdsMock,
killWithDescendantSweepMock,
@@ -23,6 +24,7 @@ const {
writeFileSyncMock: vi.fn(),
spawnMock: vi.fn(),
prepareMacosTccLoginShellMock: vi.fn(),
inspectAgentPtyProcessMock: vi.fn(),
resolveAgentForegroundProcessMock: vi.fn(),
readWindowsPtyJobProcessIdsMock: vi.fn(),
killWithDescendantSweepMock: vi.fn(),
@@ -80,6 +82,7 @@ vi.mock('./windows-powershell-executable', () => ({
}))
vi.mock('./agent-foreground-process', () => ({
inspectAgentPtyProcess: (...args: unknown[]) => inspectAgentPtyProcessMock(...args),
resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) =>
resolveAgentForegroundProcessMock(...args)
}))
@@ -153,6 +156,14 @@ describe('LocalPtyProvider', () => {
}
})
spawnMock.mockReturnValue(mockProc)
inspectAgentPtyProcessMock.mockReset()
inspectAgentPtyProcessMock.mockImplementation(
async (_pid: number, fallbackProcess: string | null) => ({
available: true,
processName: fallbackProcess,
hasChildProcesses: false
})
)
provider = new LocalPtyProvider()
})
@@ -207,9 +218,10 @@ describe('LocalPtyProvider', () => {
})
it('marks a degraded local scan unavailable for completion-sensitive inspection', async () => {
resolveAgentForegroundProcessMock.mockResolvedValue({
inspectAgentPtyProcessMock.mockResolvedValue({
available: false,
processName: 'zsh'
processName: 'zsh',
hasChildProcesses: false
})
const { id } = await provider.spawn({ cols: 80, rows: 24 })
@@ -222,13 +234,33 @@ describe('LocalPtyProvider', () => {
it('reports live child evidence in completion-sensitive inspection', async () => {
const { id } = await provider.spawn({ cols: 80, rows: 24 })
mockProc.process = 'node'
resolveAgentForegroundProcessMock.mockResolvedValue({ available: true, processName: null })
await expect(provider.inspectProcess(id)).resolves.toEqual({
foregroundProcess: null,
inspectAgentPtyProcessMock.mockResolvedValue({
available: true,
processName: 'zsh',
hasChildProcesses: true
})
await expect(provider.inspectProcess(id)).resolves.toEqual({
foregroundProcess: 'zsh',
hasChildProcesses: true
})
})
it('leaves WSL completion inspection unavailable without distro-side evidence', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const { id } = await provider.spawn({
cols: 80,
rows: 24,
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Ubuntu'
})
await expect(provider.inspectProcess(id)).resolves.toEqual({
foregroundProcess: 'zsh',
hasChildProcesses: false,
unavailable: true
})
expect(inspectAgentPtyProcessMock).not.toHaveBeenCalled()
})
it('keeps a recognized agent across an unavailable scan without adding probes', async () => {
+34 -15
View File
@@ -61,6 +61,7 @@ import {
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
import {
confirmShellForegroundProcess,
inspectAgentPtyProcess,
resolveAgentForegroundProcessWithAvailability
} from './agent-foreground-process'
import { resolveStableForegroundProcess } from './stable-foreground-process'
@@ -1549,26 +1550,44 @@ export class LocalPtyProvider implements IPtyProvider {
if (!proc) {
return { foregroundProcess: null, hasChildProcesses: false, unavailable: true }
}
const foreground = await this.inspectForegroundProcessResolution(id)
if (ptyProcesses.get(id) !== proc) {
return { foregroundProcess: null, hasChildProcesses: false, unavailable: true }
}
let hasChildProcesses: boolean
try {
const shell = ptyShellName.get(id)
const currentProcess = resolveForegroundFallbackProcess(proc.process || null, shell)
hasChildProcesses = !shell || (currentProcess !== null && currentProcess !== shell)
} catch {
if (ptyWslDistroById.get(id) !== null && ptyWslDistroById.has(id)) {
return {
foregroundProcess: foreground.processName,
foregroundProcess: (await this.inspectForegroundProcessResolution(id)).processName,
hasChildProcesses: false,
unavailable: true
}
}
return {
foregroundProcess: foreground.processName,
hasChildProcesses,
...(foreground.available ? {} : { unavailable: true as const })
try {
const shell = ptyShellName.get(id)
const inspection = await inspectAgentPtyProcess(
proc.pid,
resolveForegroundFallbackProcess(proc.process || null, shell),
{
contextPaths: ptyAgentForegroundContextPaths.get(id),
forceProcessScan: true,
...(process.platform === 'win32'
? {
readWindowsConsoleAttachedProcessIds: () =>
readWindowsConsoleAttachedProcessIds(proc.pid),
readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(proc)
}
: {})
}
)
if (ptyProcesses.get(id) !== proc) {
return { foregroundProcess: null, hasChildProcesses: false, unavailable: true }
}
return {
foregroundProcess: inspection.processName,
hasChildProcesses: inspection.hasChildProcesses,
...(inspection.available ? {} : { unavailable: true as const })
}
} catch {
return {
foregroundProcess: null,
hasChildProcesses: false,
unavailable: true
}
}
}
@@ -110,6 +110,7 @@ describe('PTY provider dispatch', () => {
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
inspectProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
listProcesses: vi.fn(),
@@ -61,7 +61,7 @@ describe('PTY provider process inspection', () => {
await expect(inspectPtyProviderProcess(provider, 'pty-1')).resolves.toEqual(inspection)
})
it('falls back to the existing provider process APIs', async () => {
it('keeps providers without completion-sensitive inspection unavailable', async () => {
const getForegroundProcess = vi.fn().mockResolvedValue('codex')
const hasChildProcesses = vi.fn().mockResolvedValue(true)
const provider = {
@@ -70,8 +70,57 @@ describe('PTY provider process inspection', () => {
} as Pick<IPtyProvider, 'getForegroundProcess' | 'hasChildProcesses'> as IPtyProvider
await expect(inspectPtyProviderProcess(provider, 'pty-1')).resolves.toEqual({
foregroundProcess: 'codex',
hasChildProcesses: true
foregroundProcess: null,
hasChildProcesses: false,
unavailable: true
})
expect(getForegroundProcess).not.toHaveBeenCalled()
expect(hasChildProcesses).not.toHaveBeenCalled()
})
it('keeps a partial inspection unavailable', async () => {
const provider = {
inspectProcess: vi.fn(async () => ({ hasChildProcesses: false }))
} as unknown as IPtyProvider
await expect(inspectPtyProviderProcess(provider, 'pty-1')).resolves.toEqual({
foregroundProcess: null,
hasChildProcesses: false,
unavailable: true
})
})
it('preserves positive live evidence from a partial inspection', async () => {
const provider = {
inspectProcess: vi.fn(async () => ({
foregroundProcess: 'codex',
hasChildProcesses: 'unknown'
}))
} as unknown as IPtyProvider
await expect(inspectPtyProviderProcess(provider, 'pty-1')).resolves.toEqual({
foregroundProcess: 'codex',
hasChildProcesses: false,
unavailable: true
})
})
it('snapshots adapter fields once before validating them', async () => {
let foregroundReads = 0
const provider = {
inspectProcess: vi.fn(async () => ({
get foregroundProcess() {
foregroundReads += 1
return foregroundReads === 1 ? null : undefined
},
hasChildProcesses: false
}))
} as unknown as IPtyProvider
await expect(inspectPtyProviderProcess(provider, 'pty-1')).resolves.toEqual({
foregroundProcess: null,
hasChildProcesses: false
})
expect(foregroundReads).toBe(1)
})
})
+34 -8
View File
@@ -6,8 +6,37 @@ export type PtyProcessInspection = {
unavailable?: true
}
type CompletionSensitivePtyProvider = IPtyProvider & {
inspectProcess?: (id: string) => Promise<PtyProcessInspection>
function unavailablePtyProcessInspection(
value?: Partial<PtyProcessInspection>
): PtyProcessInspection {
return {
foregroundProcess:
typeof value?.foregroundProcess === 'string' ? value.foregroundProcess : null,
hasChildProcesses: value?.hasChildProcesses === true,
unavailable: true
}
}
export function normalizePtyProcessInspection(value: unknown): PtyProcessInspection {
if (typeof value !== 'object' || value === null) {
return unavailablePtyProcessInspection()
}
const inspection = value as Partial<PtyProcessInspection>
const foregroundProcess = inspection.foregroundProcess
const hasChildProcesses = inspection.hasChildProcesses
const unavailable = inspection.unavailable
if (
(foregroundProcess !== null && typeof foregroundProcess !== 'string') ||
typeof hasChildProcesses !== 'boolean' ||
(unavailable !== undefined && unavailable !== true)
) {
return unavailablePtyProcessInspection({ foregroundProcess, hasChildProcesses })
}
return {
foregroundProcess,
hasChildProcesses,
...(unavailable === true ? { unavailable: true } : {})
}
}
export async function inspectPtyProviderProcess(
@@ -17,13 +46,10 @@ export async function inspectPtyProviderProcess(
if (provider.hasPty?.(ptyId) === false) {
throw new Error('terminal_gone')
}
const inspectProcess = (provider as CompletionSensitivePtyProvider).inspectProcess
if (inspectProcess) {
return inspectProcess.call(provider, ptyId)
if (typeof provider.inspectProcess !== 'function') {
return unavailablePtyProcessInspection()
}
const foregroundProcess = await provider.getForegroundProcess(ptyId)
const hasChildProcesses = await provider.hasChildProcesses(ptyId)
return { foregroundProcess, hasChildProcesses }
return normalizePtyProcessInspection(await provider.inspectProcess(ptyId))
}
export async function inspectPtyProviderProcessForRenderer(
@@ -210,6 +210,12 @@ export type IPtyProvider = {
acknowledgeDataEvent(id: string, charCount: number): void
hasChildProcesses(id: string): Promise<boolean>
getForegroundProcess(id: string): Promise<string | null>
/** Atomic completion-sensitive inspection; failures must reject or return unavailable. */
inspectProcess(id: string): Promise<{
foregroundProcess: string | null
hasChildProcesses: boolean
unavailable?: true
}>
/** Strong process evidence captured after the caller's command boundary. */
confirmForegroundProcess?: (id: string) => Promise<string | null>
/** Fresh execution-host proof that the spawned shell owns the PTY foreground. */
+181 -2
View File
@@ -61,6 +61,7 @@ import { OrchestrationDb } from './orchestration/db'
import type { MessagePriority, MessageRow, MessageType } from './orchestration/types'
import {
AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS,
PTY_FOREGROUND_PROCESS_READ_TIMEOUT_MS,
appendNormalizedToTailBuffer,
buildPreview,
OrcaRuntimeService,
@@ -10608,7 +10609,13 @@ describe('OrcaRuntimeService', () => {
const providerQuery = vi.fn(async () => {
throw new Error('socket_closed')
})
setLocalPtyProvider({ getForegroundProcess: providerQuery } as unknown as IPtyProvider)
setLocalPtyProvider({
getForegroundProcess: providerQuery,
inspectProcess: async () => ({
foregroundProcess: await providerQuery(),
hasChildProcesses: false
})
} as unknown as IPtyProvider)
onTestFinished(() => setLocalPtyProvider(originalProvider))
runtime.setPtyController({
write: () => true,
@@ -10636,6 +10643,147 @@ describe('OrcaRuntimeService', () => {
])
})
it('does not certify an adapter that lacks completion-sensitive inspection', async () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtySpawned('pty-1', 'inc-legacy-inspection', { awaitsRegistration: false })
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07')
batches.length = 0
const originalProvider = getLocalPtyProvider()
setLocalPtyProvider({
getForegroundProcess: async () => null,
hasChildProcesses: async () => false
} as unknown as IPtyProvider)
onTestFinished(() => setLocalPtyProvider(originalProvider))
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: getForegroundProcessFromRuntimeController,
inspectProcess: inspectProcessFromRuntimeController
})
runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100)
await vi.waitFor(() =>
expect(
(
runtime as unknown as {
ptyForegroundProcessReads: Map<string, unknown>
}
).ptyForegroundProcessReads.size
).toBe(0)
)
expect(batches.flatMap((batch) => batch.facts)).toEqual([
{ kind: 'title', normalizedTitle: 'bichir', rawTitle: 'bichir' },
{ kind: 'agent-exited' }
])
})
it.each([
['resolved null', async () => null],
['resolved undefined', async () => undefined],
['missing child evidence', async () => ({ foregroundProcess: null })],
['missing foreground evidence', async () => ({ hasChildProcesses: false })],
['rejection', () => Promise.reject(new Error('socket_closed'))],
[
'synchronous throw',
() => {
throw new Error('socket_closed')
}
]
])('does not certify a %s inspection result', async (_shape, inspectProcess) => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtySpawned('pty-1', 'inc-malformed-inspection', { awaitsRegistration: false })
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07')
batches.length = 0
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
inspectProcess: inspectProcess as never
})
runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100)
await vi.waitFor(() =>
expect(
(
runtime as unknown as {
ptyForegroundProcessReads: Map<string, unknown>
}
).ptyForegroundProcessReads.size
).toBe(0)
)
expect(batches.flatMap((batch) => batch.facts)).toEqual([
{ kind: 'title', normalizedTitle: 'bichir', rawTitle: 'bichir' },
{ kind: 'agent-exited' }
])
})
it('times out a stalled completion-sensitive inspection as unavailable', async () => {
vi.useFakeTimers()
try {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtySpawned('pty-1', 'inc-stalled-inspection', { awaitsRegistration: false })
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07')
batches.length = 0
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
inspectProcess: () => new Promise<never>(() => {})
})
runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100)
await vi.advanceTimersByTimeAsync(PTY_FOREGROUND_PROCESS_READ_TIMEOUT_MS)
expect(
(
runtime as unknown as {
ptyForegroundProcessReads: Map<string, unknown>
}
).ptyForegroundProcessReads.size
).toBe(0)
expect(batches.flatMap((batch) => batch.facts)).not.toContainEqual(
expect.objectContaining({ executionHostConfirmed: true })
)
} finally {
vi.useRealTimers()
}
})
it('keeps recognized foreground evidence from a partial inspection live', async () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtySpawned('pty-1', 'inc-partial-live-inspection', { awaitsRegistration: false })
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07')
batches.length = 0
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
inspectProcess: async () => ({ foregroundProcess: 'codex' }) as never
})
runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100)
await vi.waitFor(() =>
expect(
(
runtime as unknown as {
ptyForegroundProcessReads: Map<string, unknown>
}
).ptyForegroundProcessReads.size
).toBe(0)
)
expect(batches.flatMap((batch) => batch.facts)).toEqual([
{ kind: 'title', normalizedTitle: 'bichir', rawTitle: 'bichir' }
])
})
it('still certifies an agent exit when process inspection confirms no foreground agent', async () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
@@ -10647,7 +10795,11 @@ describe('OrcaRuntimeService', () => {
const providerQuery = vi.fn(async () => null)
setLocalPtyProvider({
getForegroundProcess: providerQuery,
hasChildProcesses: async () => false
hasChildProcesses: async () => false,
inspectProcess: async () => ({
foregroundProcess: await providerQuery(),
hasChildProcesses: false
})
} as unknown as IPtyProvider)
onTestFinished(() => setLocalPtyProvider(originalProvider))
runtime.setPtyController({
@@ -10808,6 +10960,33 @@ describe('OrcaRuntimeService', () => {
)
})
it('does not certify a synthetic local disconnect while inspection is pending', async () => {
const { runtime, batches } = createSideEffectRuntime()
runtime.registerPty('pty-1', TEST_WORKTREE_ID)
syncSinglePty(runtime)
runtime.onPtySpawned('pty-1', 'inc-local-unverified-stop', { awaitsRegistration: false })
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07')
let rejectInspection!: (error: Error) => void
const inspection = new Promise<never>((_resolve, reject) => {
rejectInspection = reject
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
inspectProcess: () => inspection
})
runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100)
runtime.onPtyExit('pty-1', -1, 'inc-local-unverified-stop')
rejectInspection(new Error('shutdown failed'))
await Promise.resolve()
expect(batches.flatMap((batch) => batch.facts)).not.toContainEqual(
expect.objectContaining({ executionHostConfirmed: true })
)
})
it('drops a confirmed exit from a replaced PTY incarnation', async () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
+17 -8
View File
@@ -768,6 +768,7 @@ import type {
PtySpawnResult,
PtyTransientFact
} from '../providers/types'
import { normalizePtyProcessInspection } from '../providers/pty-process-inspection'
import { ClaudeAgentTeamsService } from './claude-agent-teams-service'
import type {
AgentTeamsTmuxCompatRequest,
@@ -2235,6 +2236,7 @@ async function waitForAgentPromptDelay(delayMs: number, signal?: AbortSignal): P
}
const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000
export const PTY_FOREGROUND_PROCESS_READ_TIMEOUT_MS = 10_000
// Why: the split already failed; the caller waits on this teardown only to learn whether the
// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection.
const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000
@@ -15951,7 +15953,7 @@ export class OrcaRuntimeService {
this.remoteDesktopViewerRevisions.delete(ptyId)
this.disposeHeadlessTerminal(ptyId)
if (pty) {
if (options?.hostExitConfirmed === true) {
if (processDeathCertified) {
pty.hostExitConfirmed = true
}
pty.connected = false
@@ -19685,11 +19687,14 @@ export class OrcaRuntimeService {
try {
processRead = verifiedAbsence
? controller.inspectProcess
? Promise.resolve(controller.inspectProcess(ptyId)).then((inspection) => ({
process: inspection.foregroundProcess,
hasChildProcesses: inspection.hasChildProcesses,
available: inspection.unavailable !== true
}))
? Promise.resolve(controller.inspectProcess(ptyId)).then((value) => {
const inspection = normalizePtyProcessInspection(value)
return {
process: inspection.foregroundProcess,
hasChildProcesses: inspection.hasChildProcesses,
available: inspection.unavailable !== true
}
})
: Promise.resolve({ process: null, hasChildProcesses: null, available: false })
: Promise.resolve(controller.getForegroundProcess(ptyId)).then((process) => ({
process,
@@ -19712,7 +19717,11 @@ export class OrcaRuntimeService {
return entry.promise
}
let entry: PtyForegroundProcessReadEntry
const promise = processRead
const promise = withTimeout(processRead, PTY_FOREGROUND_PROCESS_READ_TIMEOUT_MS, {
process: null,
hasChildProcesses: null,
available: false
})
.then((result) => ({ controller, ...result }))
.catch(() => unavailable)
.finally(() => {
@@ -19737,7 +19746,7 @@ export class OrcaRuntimeService {
if (this.getPtyLivenessVerdict(ptyId)?.status === 'unverifiable') {
return true
}
return Boolean(pty?.connectionId && !pty.connected && !pty.hostExitConfirmed)
return Boolean(pty && !pty.connected && !pty.hostExitConfirmed)
}
private settlePendingPtyAgentExitOnCommandFinished(ptyId: string): void {
+22
View File
@@ -0,0 +1,22 @@
import { execFile as execFileCb } from 'node:child_process'
import { promisify } from 'node:util'
const execFile = promisify(execFileCb)
export async function inspectProcessChildren(pid: number): Promise<{
hasChildProcesses: boolean
unavailable?: true
}> {
try {
const { stdout } = await execFile('pgrep', ['-P', String(pid)], {
encoding: 'utf-8',
timeout: 3000
})
return { hasChildProcesses: stdout.trim().length > 0 }
} catch (error) {
if ((error as { code?: string | number }).code === 1) {
return { hasChildProcesses: false }
}
return { hasChildProcesses: false, unavailable: true }
}
}
@@ -91,6 +91,18 @@ describe('PtyHandler', () => {
)
})
it('preserves an unavailable relay child inspection', async () => {
await spawnPty({ cols: 80, rows: 24 })
vi.mocked(ptyShellUtils.inspectProcessChildren).mockResolvedValueOnce({
hasChildProcesses: false,
unavailable: true
})
await expect(dispatcher.callRequest('pty.inspectProcess', { id: 'pty-1' })).resolves.toEqual(
expect.objectContaining({ hasChildProcesses: false, unavailable: true })
)
})
it('spawns a PTY and returns an id', async () => {
const result = await spawnPty({ cols: 80, rows: 24 })
expect(result).toEqual({ id: 'pty-1', incarnationId: expect.any(String) })
+3
View File
@@ -104,6 +104,9 @@ export function beginPtyHandlerTest(mocks: PtyHandlerTestMocks): {
dispose: vi.fn()
})
vi.spyOn(ptyShellUtils, 'processHasChildren').mockResolvedValue(false)
vi.spyOn(ptyShellUtils, 'inspectProcessChildren').mockResolvedValue({
hasChildProcesses: false
})
mockPtySpawn.mockReturnValue({ ...mockPtyInstance })
+4 -1
View File
@@ -12,6 +12,7 @@ import {
resolveDefaultCwd,
resolveProcessCwd,
processHasChildren,
inspectProcessChildren,
getForegroundProcessName,
isProcessAlive,
listShellProfiles
@@ -2083,6 +2084,7 @@ export class PtyHandler {
private async inspectProcess(params: Record<string, unknown>): Promise<{
foregroundProcess: string | null
hasChildProcesses: boolean
unavailable?: true
}> {
const id = params.id as string
const managed = this.ptys.get(id)
@@ -2093,9 +2095,10 @@ export class PtyHandler {
managed.pty.pid,
managed.pty.process || null
)
const childInspection = await inspectProcessChildren(managed.pty.pid)
return {
foregroundProcess,
hasChildProcesses: await processHasChildren(managed.pty.pid)
...childInspection
}
}
+3 -9
View File
@@ -3,6 +3,8 @@ import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { win32 as pathWin32 } from 'node:path'
import { promisify } from 'node:util'
import { inspectProcessChildren } from './pty-child-process-inspection'
export { inspectProcessChildren } from './pty-child-process-inspection'
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess,
@@ -169,15 +171,7 @@ export async function resolveProcessCwd(pid: number, fallbackCwd: string): Promi
* 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
}
return (await inspectProcessChildren(pid)).hasChildProcesses
}
// Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no