mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
sim: merge PR #17003
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
type SessionInfo
|
||||
} from './types'
|
||||
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
import type { PtyProcessInspectionEvidence } from '../../shared/pty-process-inspection-evidence'
|
||||
|
||||
export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots {
|
||||
// Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells.
|
||||
@@ -42,6 +43,12 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot
|
||||
return this.client.request<{
|
||||
foregroundProcess: string | null
|
||||
hasChildProcesses: boolean
|
||||
// Why optional: daemons survive in-place app updates, so a pre-evidence
|
||||
// daemon omits the field and readers fall back to the legacy collapse
|
||||
// (readPtyProcessInspectionEvidence). The composed pre-v27 result above
|
||||
// stays evidence-less for the same reason: an old daemon's published
|
||||
// values are the only answer it can give.
|
||||
processEvidence?: PtyProcessInspectionEvidence
|
||||
}>('inspectProcess', { sessionId: id })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/* The confirmation scan's provenance. Everything below the `ps` fork is real: the shared
|
||||
* process-table snapshot cache (its TTL, its dedupe and its scan-start stamps), the agent
|
||||
* resolver that reads it, and the tracker that keeps or retires the pane's identity. Only the
|
||||
* OS scan itself is substituted, so the orderings under test are the ones production has. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => {
|
||||
const mock = vi.fn()
|
||||
// promisify() runs while the snapshot module is evaluated — before this file's body — so the
|
||||
// custom hook has to be attached here, or the reader awaits a bare stdout instead of
|
||||
// `{ stdout }` and every scan resolves to undefined.
|
||||
Object.defineProperty(mock, Symbol.for('nodejs.util.promisify.custom'), {
|
||||
value: mock,
|
||||
configurable: true,
|
||||
writable: true
|
||||
})
|
||||
return { execFileMock: mock }
|
||||
})
|
||||
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import { createPtyForegroundProcessTracker } from './pty-subprocess/foreground-process-tracker'
|
||||
import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot'
|
||||
import type * as pty from 'node-pty'
|
||||
|
||||
const SHELL_PID = 4242
|
||||
const SHELL_PATH = '/bin/zsh'
|
||||
// Why not 0: the refresh throttle compares `Date.now()` against a `lastRefreshStartedAt` that
|
||||
// initialises to 0, so a clock parked at the epoch throttles away the pane's first scan.
|
||||
const BASE = 1_700_000_000_000
|
||||
|
||||
/** `ps -axo pid=,ppid=,stat=,command=` rows for a pane with no agent under its shell. */
|
||||
function agentFreeTable(): string {
|
||||
return `${SHELL_PID} 1 Ss+ ${SHELL_PATH}\n`
|
||||
}
|
||||
|
||||
type Deferred = { resolve: (stdout: string) => void }
|
||||
|
||||
/** Queues one controllable `ps` answer per scan, so a scan can be held open across other work. */
|
||||
function queueScans(): { next: () => Deferred; settleAll: (stdout: string) => Promise<void> } {
|
||||
const pending: Deferred[] = []
|
||||
execFileMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<{ stdout: string }>((resolvePromise) => {
|
||||
pending.push({ resolve: (stdout) => resolvePromise({ stdout }) })
|
||||
})
|
||||
)
|
||||
return {
|
||||
next: () => {
|
||||
const deferred = pending.shift()
|
||||
if (!deferred) {
|
||||
throw new Error('no ps scan was started')
|
||||
}
|
||||
return deferred
|
||||
},
|
||||
settleAll: async (stdout) => {
|
||||
// Flush first: the scan is only registered once the resolver's own awaits have run.
|
||||
await flush()
|
||||
while (pending.length > 0) {
|
||||
pending.shift()!.resolve(stdout)
|
||||
await flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function trackerFor(ptyTitle: {
|
||||
value: string
|
||||
}): ReturnType<typeof createPtyForegroundProcessTracker> {
|
||||
const fakePty = {
|
||||
pid: SHELL_PID,
|
||||
get process() {
|
||||
return ptyTitle.value
|
||||
}
|
||||
} as unknown as pty.IPty
|
||||
return createPtyForegroundProcessTracker({
|
||||
process: fakePty,
|
||||
shellPath: SHELL_PATH,
|
||||
sessionId: 'wt-1::/repo@@abc',
|
||||
startupAgentRecognition: null,
|
||||
isDead: () => false
|
||||
})
|
||||
}
|
||||
|
||||
describe('a confirmation scan that resolves after an agent was recognized', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(BASE)
|
||||
resetProcessTableSnapshotForTests()
|
||||
execFileMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
resetProcessTableSnapshotForTests()
|
||||
})
|
||||
|
||||
it('does not retire the newer live-agent identity it never had a chance to see', async () => {
|
||||
const title = { value: 'zsh' }
|
||||
const tracker = trackerFor(title)
|
||||
const scans = queueScans()
|
||||
|
||||
// A settled, agent-free scan first: this is what later gives a shell-shaped title the
|
||||
// corroboration it needs, and it is only safe while no newer agent evidence outranks it.
|
||||
vi.setSystemTime(BASE)
|
||||
tracker.observeForegroundProcess()
|
||||
await scans.settleAll(agentFreeTable())
|
||||
|
||||
// The confirmation scan starts here, and its process table is sampled now.
|
||||
vi.setSystemTime(BASE + 10)
|
||||
const confirmation = tracker.confirmForegroundProcess()
|
||||
await flush()
|
||||
const confirmationScan = scans.next()
|
||||
|
||||
// While it is out, the agent starts and the pty title names it. The synchronous fast path
|
||||
// stamps it without a scan, so this identity is strictly newer than the table above.
|
||||
vi.setSystemTime(BASE + 20)
|
||||
title.value = 'claude'
|
||||
expect(tracker.observeForegroundProcess().processName).toBe('claude')
|
||||
|
||||
// Only now does the older scan answer, reporting a table taken before the agent existed.
|
||||
vi.setSystemTime(BASE + 25)
|
||||
title.value = 'zsh'
|
||||
confirmationScan.resolve(agentFreeTable())
|
||||
await confirmation
|
||||
await flush()
|
||||
|
||||
// Inside the identity's 1s TTL the cache answers directly, so this reads whether the
|
||||
// confirmation retired it at all.
|
||||
vi.setSystemTime(BASE + 100)
|
||||
expect(tracker.observeForegroundProcess().processName).toBe('claude')
|
||||
|
||||
// Past the TTL the fast path can no longer answer, which is where the retirement's real
|
||||
// cost lands: whether a shell-shaped title now counts as corroborated exit evidence.
|
||||
vi.setSystemTime(BASE + 1_500)
|
||||
const observation = tracker.observeForegroundProcess()
|
||||
|
||||
expect(
|
||||
observation.evidence.verdict,
|
||||
'a scan that sampled the table before the agent started cannot report it gone'
|
||||
).toBe('unverifiable')
|
||||
})
|
||||
|
||||
/** The caller's own clock is not the table's. A fresh scan is queued behind whatever is
|
||||
* already running, so an agent can be stamped after this call was made and still be older
|
||||
* than the process table the answer is built from — in which case the scan really did look
|
||||
* for it. Ordering on the request time instead would hold a dead agent name forever. */
|
||||
it("retires an identity the scan's own table was late enough to have seen", async () => {
|
||||
const title = { value: 'zsh' }
|
||||
const tracker = trackerFor(title)
|
||||
const scans = queueScans()
|
||||
|
||||
vi.setSystemTime(BASE)
|
||||
tracker.observeForegroundProcess()
|
||||
await scans.settleAll(agentFreeTable())
|
||||
|
||||
// The confirmation is requested here, but its scan has not started yet.
|
||||
vi.setSystemTime(BASE + 50)
|
||||
const confirmation = tracker.confirmForegroundProcess()
|
||||
|
||||
// The agent is stamped after the request...
|
||||
vi.setSystemTime(BASE + 60)
|
||||
title.value = 'claude'
|
||||
expect(tracker.observeForegroundProcess().processName).toBe('claude')
|
||||
|
||||
// ...but before the queued scan actually samples the table, so that table did see the pane
|
||||
// as it is now and its silence about an agent is a real observation.
|
||||
vi.setSystemTime(BASE + 70)
|
||||
title.value = 'zsh'
|
||||
await scans.settleAll(agentFreeTable())
|
||||
await confirmation
|
||||
await flush()
|
||||
|
||||
// Inside the TTL, so a surviving identity would answer here: it must not.
|
||||
vi.setSystemTime(BASE + 100)
|
||||
expect(tracker.observeForegroundProcess().processName).toBe('zsh')
|
||||
})
|
||||
|
||||
it('still retires an identity a later scan really did look for', async () => {
|
||||
const title = { value: 'claude' }
|
||||
const tracker = trackerFor(title)
|
||||
const scans = queueScans()
|
||||
|
||||
// The agent is recognized first...
|
||||
vi.setSystemTime(BASE)
|
||||
expect(tracker.observeForegroundProcess().processName).toBe('claude')
|
||||
|
||||
// ...and the confirmation scan starts strictly after it, so its table did see the pane
|
||||
// as the agent left it. This answer is entitled to retire the identity.
|
||||
vi.setSystemTime(BASE + 50)
|
||||
title.value = 'zsh'
|
||||
const confirmation = tracker.confirmForegroundProcess()
|
||||
await flush()
|
||||
await scans.settleAll(agentFreeTable())
|
||||
await confirmation
|
||||
await flush()
|
||||
|
||||
// Inside the TTL the cached identity would still answer if it had been kept.
|
||||
vi.setSystemTime(BASE + 100)
|
||||
|
||||
expect(tracker.observeForegroundProcess().processName).toBe('zsh')
|
||||
})
|
||||
})
|
||||
@@ -218,6 +218,9 @@ describe('daemon pty foreground degraded-scan handling', () => {
|
||||
|
||||
await readForegroundAt(handle, 0)
|
||||
expect(await readForegroundAt(handle, 120_000)).toBe('claude')
|
||||
// A read returns before the scan it schedules settles, so the hold has to
|
||||
// outlive that scan too -- an unreadable job stays unverifiable at any age.
|
||||
expect(await readForegroundAt(handle, 120_001)).toBe('claude')
|
||||
})
|
||||
|
||||
it('retires an anchored agent immediately when its pid leaves the job, despite a leftover', async () => {
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
// The daemon's synchronous foreground read cannot tell node-pty's silent
|
||||
// shell-title fallback from a real idle shell on its own, so a shell-shaped
|
||||
// title is only an observation while a completed scan corroborates it. Pins
|
||||
// how each scan outcome settles (docs/reference/ssh-execution-boundary.md).
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as pty from 'node-pty'
|
||||
|
||||
const resolveAgentForegroundProcessMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../providers/agent-foreground-process', () => ({
|
||||
resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) =>
|
||||
resolveAgentForegroundProcessMock(...args)
|
||||
}))
|
||||
|
||||
import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle'
|
||||
import { buildDaemonInspectProcessResult } from './terminal-host-process-evidence'
|
||||
import type { ForegroundProcessObservation, SubprocessHandle } from './session-subprocess-handle'
|
||||
|
||||
const SHELL_PID = 999_999_517
|
||||
// Above the idle-shell refresh throttle (5s) so each read starts a fresh scan.
|
||||
const PAST_THE_SCAN_THROTTLE_MS = 6_000
|
||||
// Bracket the 30s shell-title corroboration window from both sides.
|
||||
const INSIDE_CORROBORATION_WINDOW_MS = 25_000
|
||||
const PAST_CORROBORATION_WINDOW_MS = 35_000
|
||||
|
||||
describe('daemon foreground observation evidence', () => {
|
||||
let platformDescriptor: PropertyDescriptor | undefined
|
||||
let nodePty: pty.IPty & { process: string }
|
||||
let handle: SubprocessHandle
|
||||
let exitListeners: ((event: { exitCode: number; signal?: number }) => void)[]
|
||||
|
||||
function observe(): ForegroundProcessObservation {
|
||||
const observation = handle.observeForegroundProcess?.()
|
||||
if (!observation) {
|
||||
throw new Error('handle exposes no foreground evidence channel')
|
||||
}
|
||||
return observation
|
||||
}
|
||||
|
||||
/** The children verdict the completion monitor actually acts on. */
|
||||
function childrenVerdict(observation: ForegroundProcessObservation): string | undefined {
|
||||
return buildDaemonInspectProcessResult(observation).processEvidence?.children.verdict
|
||||
}
|
||||
|
||||
function exitPty(): void {
|
||||
for (const listener of exitListeners) {
|
||||
listener({ exitCode: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
async function readAfterSettledScan(): Promise<ReturnType<
|
||||
NonNullable<SubprocessHandle['observeForegroundProcess']>
|
||||
> | null> {
|
||||
// A read schedules the next scan rather than awaiting one, and the
|
||||
// throttle can swallow the read that follows a scan. Two cycles guarantee
|
||||
// one scan both started and settled under the behavior set by this test.
|
||||
for (let cycle = 0; cycle < 2; cycle++) {
|
||||
handle.observeForegroundProcess?.()
|
||||
await vi.advanceTimersByTimeAsync(PAST_THE_SCAN_THROTTLE_MS)
|
||||
}
|
||||
return handle.observeForegroundProcess?.() ?? null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
resolveAgentForegroundProcessMock.mockReset()
|
||||
// Default: a scan that runs and finds no agent. Individual tests override.
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({ available: true, processName: 'zsh' })
|
||||
exitListeners = []
|
||||
nodePty = {
|
||||
pid: SHELL_PID,
|
||||
process: 'zsh',
|
||||
onData: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
onExit: vi.fn((listener) => {
|
||||
exitListeners.push(listener)
|
||||
return { dispose: vi.fn() }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn()
|
||||
} as unknown as pty.IPty & { process: string }
|
||||
handle = createDaemonPtySubprocessHandle({
|
||||
process: nodePty,
|
||||
shellPath: '/bin/zsh',
|
||||
spawnCwd: '/tmp/wt',
|
||||
env: { PATH: '/usr/bin' },
|
||||
startupCommandDeliveredInShellArgs: false,
|
||||
reportsChildExitStatus: true,
|
||||
requestedCwd: '/tmp/wt',
|
||||
sessionId: 'repo-observe::/tmp/wt@@observe01',
|
||||
startupAgentRecognition: null
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', platformDescriptor)
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('withholds observation from a shell title no scan has corroborated', () => {
|
||||
const observation = handle.observeForegroundProcess?.()
|
||||
|
||||
expect(observation?.processName).toBe('zsh')
|
||||
expect(observation?.evidence.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('observes the shell once a completed scan agrees the pane is idle', async () => {
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({ available: true, processName: 'zsh' })
|
||||
|
||||
const observation = await readAfterSettledScan()
|
||||
|
||||
expect(observation?.processName).toBe('zsh')
|
||||
expect(observation?.evidence).toEqual({ verdict: 'observed', processName: 'zsh' })
|
||||
})
|
||||
|
||||
it('withholds observation when the scan ran but could not answer', async () => {
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({ available: false, processName: 'zsh' })
|
||||
|
||||
const observation = await readAfterSettledScan()
|
||||
|
||||
expect(observation?.evidence.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('withholds observation after a corroborating scan is followed by a thrown one', async () => {
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({ available: true, processName: 'zsh' })
|
||||
expect((await readAfterSettledScan())?.evidence.verdict).toBe('observed')
|
||||
|
||||
// A scan that rejects observed nothing; the corroboration it would have
|
||||
// refreshed must not be inherited from the last one that succeeded.
|
||||
resolveAgentForegroundProcessMock.mockRejectedValue(new Error('ps fork failed'))
|
||||
const observation = await readAfterSettledScan()
|
||||
|
||||
expect(observation?.evidence.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('stops observing a shell title once corroboration ages past the 30s bound', async () => {
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({ available: true, processName: 'zsh' })
|
||||
expect((await readAfterSettledScan())?.evidence.verdict).toBe('observed')
|
||||
|
||||
// A permanently wedged `ps`: the scan starts and never settles, so nothing
|
||||
// refreshes corroboration and it simply ages out. Brackets the 30s bound on
|
||||
// both sides so widening or removing it fails here.
|
||||
resolveAgentForegroundProcessMock.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
await vi.advanceTimersByTimeAsync(INSIDE_CORROBORATION_WINDOW_MS)
|
||||
expect(handle.observeForegroundProcess?.()?.evidence.verdict).toBe('observed')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(PAST_CORROBORATION_WINDOW_MS - INSIDE_CORROBORATION_WINDOW_MS)
|
||||
expect(handle.observeForegroundProcess?.()?.evidence.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('keeps a live agent title an observation without needing a scan', () => {
|
||||
nodePty.process = 'codex'
|
||||
|
||||
expect(handle.observeForegroundProcess?.()?.evidence).toEqual({
|
||||
verdict: 'observed',
|
||||
processName: 'codex'
|
||||
})
|
||||
})
|
||||
|
||||
it('stops corroborating with a settlement older than an agent the title fast path saw', async () => {
|
||||
// The sync fast path stamps a recognized title without running a scan, so the
|
||||
// last agent-free settlement can still be inside the 30s window while an agent
|
||||
// is running. Corroborating from it publishes a live agent as an idle shell.
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({ available: true, processName: 'zsh' })
|
||||
expect((await readAfterSettledScan())?.evidence.verdict).toBe('observed')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
nodePty.process = 'codex'
|
||||
expect(handle.observeForegroundProcess?.()?.evidence).toEqual({
|
||||
verdict: 'observed',
|
||||
processName: 'codex'
|
||||
})
|
||||
|
||||
// A wedged scan never refreshes the settlement, and node-pty's title read
|
||||
// falls back to the spawned shell while the agent is still running.
|
||||
resolveAgentForegroundProcessMock.mockReturnValue(new Promise(() => {}))
|
||||
nodePty.process = 'zsh'
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
|
||||
expect(handle.observeForegroundProcess?.()?.evidence.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('stops corroborating with a snapshot captured before an agent the scan could not see', async () => {
|
||||
// A scan stamps its settlement when it SETTLES, but its process snapshot was taken
|
||||
// when it STARTED. An agent that appears in between is invisible to that snapshot,
|
||||
// so settle-time ordering alone still lets a pre-agent scan corroborate the shell.
|
||||
let settleScan: (value: { available: boolean; processName: string }) => void = () => {}
|
||||
resolveAgentForegroundProcessMock.mockReturnValue(
|
||||
new Promise<{ available: boolean; processName: string }>((resolve) => {
|
||||
settleScan = resolve
|
||||
})
|
||||
)
|
||||
// Starts the agent-free scan; its snapshot is taken now.
|
||||
expect(handle.observeForegroundProcess?.()?.evidence.verdict).toBe('unverifiable')
|
||||
|
||||
// The agent starts while that scan is still in flight.
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
nodePty.process = 'codex'
|
||||
expect(handle.observeForegroundProcess?.()?.evidence).toEqual({
|
||||
verdict: 'observed',
|
||||
processName: 'codex'
|
||||
})
|
||||
|
||||
// Only now does the pre-agent scan settle, so its `at` is newer than the agent stamp.
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
settleScan({ available: true, processName: 'zsh' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// The title degrades back to the shell while the agent is still running.
|
||||
resolveAgentForegroundProcessMock.mockReturnValue(new Promise(() => {}))
|
||||
nodePty.process = 'zsh'
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
|
||||
expect(handle.observeForegroundProcess?.()?.evidence.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('does not let a pre-agent scan retire the agent it could not see', async () => {
|
||||
// The settling scan finds no agent and retires the cached identity — but its snapshot
|
||||
// predates that agent. Retiring it hands the same stale scan corroboration by way of
|
||||
// the no-evidence arm, so the guard has to hold on both sides.
|
||||
let settleScan: (value: { available: boolean; processName: string }) => void = () => {}
|
||||
resolveAgentForegroundProcessMock.mockReturnValue(
|
||||
new Promise<{ available: boolean; processName: string }>((resolve) => {
|
||||
settleScan = resolve
|
||||
})
|
||||
)
|
||||
expect(handle.observeForegroundProcess?.()?.evidence.verdict).toBe('unverifiable')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
nodePty.process = 'codex'
|
||||
expect(handle.observeForegroundProcess?.()?.evidence).toEqual({
|
||||
verdict: 'observed',
|
||||
processName: 'codex'
|
||||
})
|
||||
|
||||
// The title degrades back to the shell BEFORE the pre-agent scan settles, so
|
||||
// retirement sees a shell on both sides and clears the agent evidence.
|
||||
nodePty.process = 'zsh'
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
settleScan({ available: true, processName: 'zsh' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
resolveAgentForegroundProcessMock.mockReturnValue(new Promise(() => {}))
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
|
||||
expect(handle.observeForegroundProcess?.()?.evidence.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('leaves the legacy foreground read identical to the observed name', async () => {
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({ available: false, processName: 'zsh' })
|
||||
await readAfterSettledScan()
|
||||
|
||||
// The wire's legacy field must not change shape when evidence degrades.
|
||||
expect(handle.getForegroundProcess()).toBe('zsh')
|
||||
})
|
||||
|
||||
it('withholds observation when the title read names nothing usable', () => {
|
||||
// node-pty's POSIX title read reports an empty name when the native read
|
||||
// fails on a live pane, so "no name" is a failed read, not an observed exit.
|
||||
nodePty.process = ''
|
||||
|
||||
const observation = observe()
|
||||
|
||||
expect(observation.processName).toBeNull()
|
||||
expect(observation.evidence.verdict).toBe('unverifiable')
|
||||
expect(childrenVerdict(observation)).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('withholds observation when the title read throws', () => {
|
||||
Object.defineProperty(nodePty, 'process', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
throw new Error('foreground title read failed')
|
||||
}
|
||||
})
|
||||
|
||||
const observation = observe()
|
||||
|
||||
expect(observation.processName).toBeNull()
|
||||
expect(observation.evidence.verdict).toBe('unverifiable')
|
||||
expect(childrenVerdict(observation)).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('withholds observation after a corroborating scan is followed by an unavailable one', async () => {
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({ available: true, processName: 'zsh' })
|
||||
expect((await readAfterSettledScan())?.evidence.verdict).toBe('observed')
|
||||
|
||||
// A scan that ran but could not answer is a relay host's normal steady
|
||||
// state. It settles too, so the corroboration it failed to refresh must
|
||||
// retire rather than be inherited from the last scan that succeeded.
|
||||
resolveAgentForegroundProcessMock.mockResolvedValue({ available: false, processName: 'zsh' })
|
||||
const observation = await readAfterSettledScan()
|
||||
|
||||
expect(observation?.evidence.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('observes the exit node-pty itself reported', () => {
|
||||
// The conservative arms must not swallow a real completion: once the host
|
||||
// watched the pty exit, absence is something it observed happen.
|
||||
exitPty()
|
||||
|
||||
const observation = observe()
|
||||
|
||||
expect(observation.evidence).toEqual({ verdict: 'observed', processName: null })
|
||||
expect(childrenVerdict(observation)).toBe('exited')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* `getProcessTableSnapshot` is a process-wide 500ms cache shared by every pane
|
||||
* (process-table-snapshot.ts), so a refresh can be answered from a table another pane
|
||||
* captured before this pane's agent existed. The scan's own `startedAt` is the time it
|
||||
* ASKED, not the time the table was read, and both ordering guards in
|
||||
* foreground-identity-refresh compare against it. Driven through the real resolver and
|
||||
* the real cache — a resolver double cannot expose the gap, because the gap is the cache.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as pty from 'node-pty'
|
||||
import type * as childProcess from 'node:child_process'
|
||||
|
||||
const execFileMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof childProcess>()
|
||||
return { ...actual, execFile: execFileMock }
|
||||
})
|
||||
|
||||
import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle'
|
||||
import { buildDaemonInspectProcessResult } from './terminal-host-process-evidence'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
import {
|
||||
getProcessTableSnapshot,
|
||||
resetProcessTableSnapshotForTests
|
||||
} from '../../shared/process-table-snapshot'
|
||||
|
||||
const SHELL_PID = 999_999_611
|
||||
const AGENT_PID = 999_999_612
|
||||
const T0 = 1_700_000_000_000
|
||||
|
||||
/** A table with the pane's shell and no agent beneath it: available, and idle. */
|
||||
const NO_AGENT_TABLE = `${SHELL_PID} 1 Ss+ -zsh\n`
|
||||
const WITH_AGENT_TABLE = `${SHELL_PID} 1 Ss -zsh\n${AGENT_PID} ${SHELL_PID} S+ codex\n`
|
||||
|
||||
describe('foreground scan answered from another pane s cached snapshot', () => {
|
||||
let platformDescriptor: PropertyDescriptor | undefined
|
||||
let nodePty: pty.IPty & { process: string }
|
||||
let handle: SubprocessHandle
|
||||
let psOutput: string
|
||||
|
||||
/** The verdict the completion monitor acts on. */
|
||||
function childrenVerdict(): string | undefined {
|
||||
const observation = handle.observeForegroundProcess?.()
|
||||
if (!observation) {
|
||||
throw new Error('handle exposes no foreground evidence channel')
|
||||
}
|
||||
return buildDaemonInspectProcessResult(observation).processEvidence?.children.verdict
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(T0)
|
||||
resetProcessTableSnapshotForTests()
|
||||
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
psOutput = NO_AGENT_TABLE
|
||||
execFileMock.mockReset()
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
_command: string,
|
||||
_args: readonly string[],
|
||||
_options: unknown,
|
||||
callback: (error: null, result: { stdout: string }) => void
|
||||
) => {
|
||||
callback(null, { stdout: psOutput })
|
||||
}
|
||||
)
|
||||
nodePty = {
|
||||
pid: SHELL_PID,
|
||||
process: 'zsh',
|
||||
onData: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
onExit: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn()
|
||||
} as unknown as pty.IPty & { process: string }
|
||||
handle = createDaemonPtySubprocessHandle({
|
||||
process: nodePty,
|
||||
shellPath: '/bin/zsh',
|
||||
spawnCwd: '/tmp/wt',
|
||||
env: { PATH: '/usr/bin' },
|
||||
startupCommandDeliveredInShellArgs: false,
|
||||
reportsChildExitStatus: true,
|
||||
requestedCwd: '/tmp/wt',
|
||||
sessionId: 'repo-cache::/tmp/wt@@cache01',
|
||||
startupAgentRecognition: null
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessTableSnapshotForTests()
|
||||
vi.useRealTimers()
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', platformDescriptor)
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does not publish a live agent as an idle shell when the table predates it', async () => {
|
||||
// This pane's own first scan, so its refresh throttle is armed from T0.
|
||||
handle.observeForegroundProcess?.()
|
||||
await settle()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// T0+900: ANOTHER pane scans. Its snapshot — still agent-free — becomes the shared
|
||||
// cache entry, and it is the one this pane will be answered from.
|
||||
vi.setSystemTime(T0 + 900)
|
||||
await getProcessTableSnapshot()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
// T0+950: the agent starts. The sync title fast path stamps it without any scan.
|
||||
vi.setSystemTime(T0 + 950)
|
||||
psOutput = WITH_AGENT_TABLE
|
||||
nodePty.process = 'codex'
|
||||
expect(handle.observeForegroundProcess?.()?.evidence).toEqual({
|
||||
verdict: 'observed',
|
||||
processName: 'codex'
|
||||
})
|
||||
|
||||
// T0+1000: node-pty's title read degrades back to the spawned shell, and the pane's
|
||||
// refresh throttle (1s) is up, so a scan runs — and is served the T0+900 table,
|
||||
// which never saw the agent. No third `ps` fork proves the cache answered.
|
||||
vi.setSystemTime(T0 + 1000)
|
||||
nodePty.process = 'zsh'
|
||||
handle.observeForegroundProcess?.()
|
||||
await settle()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
// The agent is running. Reporting `exited` here is the completion monitor's cue to
|
||||
// publish the agent as finished.
|
||||
vi.setSystemTime(T0 + 1100)
|
||||
expect(childrenVerdict()).toBe('live')
|
||||
})
|
||||
|
||||
it('does not let a table captured in the agent s own millisecond retire it', async () => {
|
||||
// Same shape, with the cached scan and the agent stamped at the same millisecond.
|
||||
// Date.now() cannot order two events inside one tick, and both guards read that tie as
|
||||
// "the scan is at least as new as the agent" — the one reading that publishes an exit.
|
||||
handle.observeForegroundProcess?.()
|
||||
await settle()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// T0+900: another pane captures an agent-free table, and in that same millisecond the
|
||||
// agent starts and the title fast path stamps it. Neither observed the other.
|
||||
vi.setSystemTime(T0 + 900)
|
||||
await getProcessTableSnapshot()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
psOutput = WITH_AGENT_TABLE
|
||||
nodePty.process = 'codex'
|
||||
expect(handle.observeForegroundProcess?.()?.evidence).toEqual({
|
||||
verdict: 'observed',
|
||||
processName: 'codex'
|
||||
})
|
||||
|
||||
// T0+1000: the title read degrades to the shell and the throttle is up, so a scan runs
|
||||
// and is answered from that same-millisecond table. No third fork: the cache answered.
|
||||
vi.setSystemTime(T0 + 1000)
|
||||
nodePty.process = 'zsh'
|
||||
handle.observeForegroundProcess?.()
|
||||
await settle()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
vi.setSystemTime(T0 + 1100)
|
||||
expect(childrenVerdict()).toBe('live')
|
||||
})
|
||||
|
||||
it('does not corroborate a shell title with a table from the agent s own millisecond', async () => {
|
||||
// Past the identity's 1s TTL the corroboration guard is what answers, and it faces the
|
||||
// same tie: the only settled scan started in the millisecond the agent was stamped.
|
||||
handle.observeForegroundProcess?.()
|
||||
await settle()
|
||||
|
||||
vi.setSystemTime(T0 + 900)
|
||||
await getProcessTableSnapshot()
|
||||
psOutput = WITH_AGENT_TABLE
|
||||
nodePty.process = 'codex'
|
||||
handle.observeForegroundProcess?.()
|
||||
|
||||
vi.setSystemTime(T0 + 1000)
|
||||
nodePty.process = 'zsh'
|
||||
handle.observeForegroundProcess?.()
|
||||
await settle()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
// T0+2000: the identity is older than its TTL, so the shell title now needs the scan to
|
||||
// corroborate it. That scan cannot: it may never have sampled the agent.
|
||||
vi.setSystemTime(T0 + 2000)
|
||||
expect(childrenVerdict()).toBe('unverifiable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
import type * as pty from 'node-pty'
|
||||
import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process'
|
||||
import {
|
||||
judgeCachedAgentJobEvidence,
|
||||
WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS
|
||||
} from '../../providers/windows-cached-agent-revalidation'
|
||||
import {
|
||||
isWindowsPtyJobReadable,
|
||||
readWindowsPtyJobProcessIds
|
||||
} from '../../providers/windows-pty-job-membership'
|
||||
import {
|
||||
isAgentForegroundWrapperProcess,
|
||||
recognizeAgentProcess
|
||||
} from '../../../shared/agent-process-recognition'
|
||||
import { shouldInspectOuterWrapperForegroundProcess } from '../../../shared/foreground-wrapper-agent'
|
||||
import { isShellProcess } from '../../../shared/shell-process-detection'
|
||||
|
||||
export const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
|
||||
const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000
|
||||
const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000
|
||||
const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000
|
||||
// Why 30s: comfortably above the slowest idle refresh cadence (15s on Windows)
|
||||
// so a healthy idle pane always holds corroboration, while a pane whose scans
|
||||
// stopped settling loses shell-exit authority within seconds.
|
||||
const SHELL_TITLE_SCAN_CORROBORATION_MAX_AGE_MS = 30_000
|
||||
|
||||
// `pid` anchors the identity to the row that proved it (null when ambiguous).
|
||||
export type CachedAgentForeground = {
|
||||
processName: string
|
||||
pid: number | null
|
||||
refreshedAt: number
|
||||
}
|
||||
|
||||
/** Outcome of the most recently settled identity scan. `sawAgent` is only
|
||||
* meaningful when `available` — an unavailable scan observed nothing.
|
||||
* `startedAt` dates the process snapshot the scan read; `at` dates the answer. An
|
||||
* agent that appears between the two is invisible to the snapshot, so ordering
|
||||
* against other evidence must use `startedAt`. */
|
||||
export type ForegroundScanSettlement = {
|
||||
at: number
|
||||
startedAt: number
|
||||
available: boolean
|
||||
sawAgent: boolean
|
||||
}
|
||||
|
||||
export type ForegroundIdentityState = {
|
||||
cachedAgentForeground: CachedAgentForeground | null
|
||||
startupAgentForeground: { processName: string; expiresAt: number } | null
|
||||
lastScanSettlement: ForegroundScanSettlement | null
|
||||
refreshInFlight: boolean
|
||||
lastRefreshStartedAt: number
|
||||
lastOutputAt: number
|
||||
}
|
||||
|
||||
export function getActiveStartupAgent(
|
||||
state: ForegroundIdentityState,
|
||||
now = Date.now()
|
||||
): { processName: string; expiresAt: number } | null {
|
||||
if (!state.startupAgentForeground) {
|
||||
return null
|
||||
}
|
||||
if (now > state.startupAgentForeground.expiresAt) {
|
||||
state.startupAgentForeground = null
|
||||
return null
|
||||
}
|
||||
return state.startupAgentForeground
|
||||
}
|
||||
|
||||
/**
|
||||
* A shell-shaped title is exit evidence only when a completed scan agrees the
|
||||
* pane is idle. node-pty's POSIX title read silently falls back to the spawned
|
||||
* shell file when the native read fails, so under the same distress that
|
||||
* degrades the scan, "title == shell" observes nothing — and a scan that last
|
||||
* saw the agent cannot corroborate its absence either.
|
||||
*
|
||||
* `agentEvidence` outdates the settlement: the synchronous title fast path stamps a
|
||||
* recognized agent without running a scan, so an agent-free settlement can still be
|
||||
* inside the window while an agent that started after it is live. Compared against
|
||||
* `startedAt`, not `at` — a scan that settles after the agent appeared still read a
|
||||
* process table from before it.
|
||||
*
|
||||
* Strictly newer, because equal stamps are unordered rather than simultaneous: Date.now()
|
||||
* cannot separate two events inside one millisecond, so a table captured in the agent's own
|
||||
* millisecond may or may not have seen it. Only a scan that demonstrably started after the
|
||||
* agent was stamped can speak for its absence; the tie keeps the agent.
|
||||
*/
|
||||
export function isShellTitleCorroborated(
|
||||
settlement: ForegroundScanSettlement | null,
|
||||
now: number,
|
||||
agentEvidence: CachedAgentForeground | null
|
||||
): boolean {
|
||||
return (
|
||||
settlement !== null &&
|
||||
settlement.available &&
|
||||
!settlement.sawAgent &&
|
||||
now - settlement.at <= SHELL_TITLE_SCAN_CORROBORATION_MAX_AGE_MS &&
|
||||
!agentEvidenceOutdatesScan(agentEvidence, settlement.startedAt)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pane's agent identity postdates the process table a scan read. Such a scan cannot
|
||||
* speak for that agent's absence: the table it sampled at `scanStartedAt` may never have had the
|
||||
* chance to contain it, so its silence is not an observation.
|
||||
*
|
||||
* Strictly newer, because equal stamps are unordered rather than simultaneous: Date.now() cannot
|
||||
* separate two events inside one millisecond, so a table captured in the agent's own millisecond
|
||||
* may or may not have seen it. An unknown order is not proof of an exit; the tie keeps the agent.
|
||||
*/
|
||||
export function agentEvidenceOutdatesScan(
|
||||
agentEvidence: CachedAgentForeground | null,
|
||||
scanStartedAt: number
|
||||
): boolean {
|
||||
return agentEvidence !== null && agentEvidence.refreshedAt >= scanStartedAt
|
||||
}
|
||||
|
||||
/**
|
||||
* The throttled async identity scan behind the tracker's synchronous reads:
|
||||
* resolves the pane's real foreground agent through the process table, keeps
|
||||
* or retires the cached identity, and records how its last scan settled so
|
||||
* the sync read can tell an observed idle shell from a degraded read.
|
||||
*/
|
||||
export function createForegroundIdentityRefresh(args: {
|
||||
process: pty.IPty
|
||||
state: ForegroundIdentityState
|
||||
contextPaths: string[]
|
||||
isDead: () => boolean
|
||||
getFallbackProcess: () => string | null
|
||||
shouldInspectFallback: (fallbackProcess: string | null) => boolean
|
||||
}): (fallbackProcess: string | null) => void {
|
||||
const proc = args.process
|
||||
const state = args.state
|
||||
return (fallbackProcess) => {
|
||||
if (args.isDead() || !proc.pid) {
|
||||
return
|
||||
}
|
||||
const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess)
|
||||
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
|
||||
if (
|
||||
!fallbackProcess ||
|
||||
(fallbackRecognition !== null &&
|
||||
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
|
||||
!args.shouldInspectFallback(fallbackProcess)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const idleNoEvidenceShell =
|
||||
fallbackIsShell && !getActiveStartupAgent(state, now) && !state.cachedAgentForeground
|
||||
const retryMs = !idleNoEvidenceShell
|
||||
? FOREGROUND_AGENT_CACHE_TTL_MS
|
||||
: process.platform === 'win32' &&
|
||||
now - state.lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS
|
||||
? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS
|
||||
: SHELL_FOREGROUND_REFRESH_RETRY_MS
|
||||
if (state.refreshInFlight || now - state.lastRefreshStartedAt < retryMs) {
|
||||
return
|
||||
}
|
||||
state.refreshInFlight = true
|
||||
state.lastRefreshStartedAt = now
|
||||
// Why not `now` everywhere below: the POSIX process table is a process-wide cache
|
||||
// shared by every pane, so this scan can be answered from one another pane started
|
||||
// earlier. `now` is when this pane ASKED; the table can be a TTL older, and an agent
|
||||
// that began in between is simply missing from it. Replaced by the measured scan
|
||||
// start once the resolver reports one.
|
||||
let scanStartedAt = now
|
||||
const identityOlderThan = (ms: number): boolean =>
|
||||
state.cachedAgentForeground !== null &&
|
||||
Date.now() - state.cachedAgentForeground.refreshedAt > ms
|
||||
const retireStaleForegroundIdentity = ({ onlyWhenAged = false } = {}): void => {
|
||||
// This scan sampled the process table at `scanStartedAt`; an identity stamped at or
|
||||
// after that is evidence it may never have had a chance to see, so it cannot be retired
|
||||
// on this answer. Equal stamps are unordered, not simultaneous — Date.now() cannot say
|
||||
// which came first inside a millisecond — and an unknown order is not proof of an exit.
|
||||
if (agentEvidenceOutdatesScan(state.cachedAgentForeground, scanStartedAt)) {
|
||||
return
|
||||
}
|
||||
const currentFallbackProcess = args.getFallbackProcess()
|
||||
if (
|
||||
fallbackIsShell &&
|
||||
!getActiveStartupAgent(state) &&
|
||||
currentFallbackProcess !== null &&
|
||||
isShellProcess(currentFallbackProcess) &&
|
||||
(!onlyWhenAged || identityOlderThan(WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS))
|
||||
) {
|
||||
state.cachedAgentForeground = null
|
||||
state.startupAgentForeground = null
|
||||
} else if (
|
||||
identityOlderThan(FOREGROUND_AGENT_CACHE_TTL_MS) &&
|
||||
currentFallbackProcess !== null &&
|
||||
isAgentForegroundWrapperProcess(currentFallbackProcess)
|
||||
) {
|
||||
state.cachedAgentForeground = null
|
||||
}
|
||||
}
|
||||
const anchor = state.cachedAgentForeground
|
||||
void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, {
|
||||
contextPaths: args.contextPaths,
|
||||
...(anchor?.pid != null
|
||||
? { anchorProcessId: anchor.pid, anchorProcessName: anchor.processName }
|
||||
: {})
|
||||
})
|
||||
.then<string | void>(
|
||||
({ processName, processId, available, anchorPidForeign, tableScanStartedAtMs }) => {
|
||||
scanStartedAt = tableScanStartedAtMs ?? now
|
||||
state.lastScanSettlement = {
|
||||
at: Date.now(),
|
||||
startedAt: scanStartedAt,
|
||||
available,
|
||||
sawAgent: available && recognizeAgentProcess(processName) !== null
|
||||
}
|
||||
if (args.isDead() || !available) {
|
||||
return
|
||||
}
|
||||
if (!processName || !recognizeAgentProcess(processName)) {
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
fallbackIsShell &&
|
||||
state.cachedAgentForeground !== null
|
||||
) {
|
||||
// Job, not console: needs no console attachment, so no fork (#10857).
|
||||
const verdict = judgeCachedAgentJobEvidence({
|
||||
jobProcessIds: readWindowsPtyJobProcessIds(proc),
|
||||
jobSupported: isWindowsPtyJobReadable(),
|
||||
shellPid: proc.pid,
|
||||
anchorProcessId: state.cachedAgentForeground.pid,
|
||||
identityAgeMs: Date.now() - state.cachedAgentForeground.refreshedAt
|
||||
})
|
||||
// Unverifiable is never exit proof (ssh-execution-boundary.md): hold.
|
||||
if (verdict === 'unavailable') {
|
||||
return
|
||||
}
|
||||
if (verdict === 'unsupported') {
|
||||
// No job to consult on this build, and the scan that got here was
|
||||
// available and found no agent. Trust it, as every other platform
|
||||
// does, rather than holding a dead name forever (#16059).
|
||||
retireStaleForegroundIdentity()
|
||||
return
|
||||
}
|
||||
if (verdict === 'confirmed' || verdict === 'recheck') {
|
||||
if (anchorPidForeign === true) {
|
||||
// The scan proved the pid recycled to a non-agent: retire now.
|
||||
retireStaleForegroundIdentity()
|
||||
return
|
||||
}
|
||||
// The anchor pid is still in the job: the scan lost the row, not
|
||||
// the agent. Restamp so a live agent never ages out (#9258).
|
||||
state.cachedAgentForeground = {
|
||||
...state.cachedAgentForeground,
|
||||
refreshedAt: Date.now()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (verdict === 'exited' || verdict === 'anchor-exited') {
|
||||
// Safe mid-restart: an available scan already found no agent.
|
||||
retireStaleForegroundIdentity()
|
||||
return
|
||||
}
|
||||
// Unanchored superset evidence cannot tell a working agent from a
|
||||
// leftover; the age bound settles it.
|
||||
retireStaleForegroundIdentity({ onlyWhenAged: true })
|
||||
return
|
||||
}
|
||||
retireStaleForegroundIdentity()
|
||||
return
|
||||
}
|
||||
state.cachedAgentForeground = {
|
||||
processName,
|
||||
pid: processId ?? null,
|
||||
refreshedAt: Date.now()
|
||||
}
|
||||
state.startupAgentForeground = null
|
||||
return processName
|
||||
}
|
||||
)
|
||||
.catch(() => {
|
||||
// Best-effort only: foreground enrichment must never affect PTY health.
|
||||
state.lastScanSettlement = {
|
||||
at: Date.now(),
|
||||
startedAt: scanStartedAt,
|
||||
available: false,
|
||||
sawAgent: false
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
state.refreshInFlight = false
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,6 @@ import type * as pty from 'node-pty'
|
||||
import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths'
|
||||
import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process'
|
||||
import { confirmPtyShellForeground } from './pty-shell-foreground-confirmation'
|
||||
import {
|
||||
judgeCachedAgentJobEvidence,
|
||||
WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS
|
||||
} from '../../providers/windows-cached-agent-revalidation'
|
||||
import {
|
||||
isWindowsPtyJobReadable,
|
||||
readWindowsPtyJobProcessIds
|
||||
} from '../../providers/windows-pty-job-membership'
|
||||
|
||||
import { readWindowsConsoleAttachedProcessIds } from '../../providers/windows-console-attached-processes'
|
||||
import {
|
||||
isAgentForegroundWrapperProcess,
|
||||
@@ -24,19 +15,23 @@ import {
|
||||
import { isShellProcess } from '../../../shared/shell-process-detection'
|
||||
import { resolveFallbackForegroundProcess } from './foreground-fallback-process'
|
||||
import { parsePtySessionId } from '../pty-session-id'
|
||||
import type { ForegroundProcessObservation } from '../session-subprocess-handle'
|
||||
import {
|
||||
createForegroundIdentityRefresh,
|
||||
FOREGROUND_AGENT_CACHE_TTL_MS,
|
||||
agentEvidenceOutdatesScan,
|
||||
getActiveStartupAgent,
|
||||
isShellTitleCorroborated,
|
||||
type ForegroundIdentityState
|
||||
} from './foreground-identity-refresh'
|
||||
|
||||
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
|
||||
const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000
|
||||
const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000
|
||||
const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000
|
||||
const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000
|
||||
|
||||
type CachedAgentForeground = { processName: string; pid: number | null; refreshedAt: number }
|
||||
|
||||
export type PtyForegroundProcessTracker = {
|
||||
recordOutput(data: string): void
|
||||
markDead(): void
|
||||
getForegroundProcess(): string | null
|
||||
observeForegroundProcess(): ForegroundProcessObservation
|
||||
confirmForegroundProcess(): Promise<string | null>
|
||||
confirmShellForeground(): Promise<boolean>
|
||||
}
|
||||
@@ -50,214 +45,121 @@ export function createPtyForegroundProcessTracker(args: {
|
||||
isDead: () => boolean
|
||||
}): PtyForegroundProcessTracker {
|
||||
const proc = args.process
|
||||
let lastOutputAt = 0
|
||||
// `pid` anchors the identity to the row that proved it (null when ambiguous).
|
||||
let cachedAgentForeground: CachedAgentForeground | null = null
|
||||
const contextPaths = getAgentForegroundContextPaths({
|
||||
cwd: args.cwd,
|
||||
worktreeId: parsePtySessionId(args.sessionId).worktreeId
|
||||
})
|
||||
let startupAgentForeground: { processName: string; expiresAt: number } | null =
|
||||
args.startupAgentRecognition
|
||||
const state: ForegroundIdentityState = {
|
||||
cachedAgentForeground: null,
|
||||
startupAgentForeground: args.startupAgentRecognition
|
||||
? {
|
||||
processName: args.startupAgentRecognition.processName,
|
||||
expiresAt: Date.now() + STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS
|
||||
}
|
||||
: null
|
||||
let foregroundRefreshInFlight = false
|
||||
let lastForegroundRefreshStartedAt = 0
|
||||
: null,
|
||||
lastScanSettlement: null,
|
||||
refreshInFlight: false,
|
||||
lastRefreshStartedAt: 0,
|
||||
lastOutputAt: 0
|
||||
}
|
||||
const getFallbackProcess = (): string | null =>
|
||||
resolveFallbackForegroundProcess(proc.process, args.shellPath)
|
||||
const getActiveStartupAgent = (
|
||||
now = Date.now()
|
||||
): { processName: string; expiresAt: number } | null => {
|
||||
if (!startupAgentForeground) {
|
||||
return null
|
||||
}
|
||||
if (now > startupAgentForeground.expiresAt) {
|
||||
startupAgentForeground = null
|
||||
return null
|
||||
}
|
||||
return startupAgentForeground
|
||||
}
|
||||
const shouldInspectFallback = (fallbackProcess: string | null): boolean =>
|
||||
fallbackProcess !== null &&
|
||||
(isShellProcess(fallbackProcess) ||
|
||||
isAgentForegroundWrapperProcess(fallbackProcess) ||
|
||||
shouldInspectOuterWrapperForegroundName(fallbackProcess) ||
|
||||
process.platform !== 'win32')
|
||||
const scheduleRefresh = createForegroundIdentityRefresh({
|
||||
process: proc,
|
||||
state,
|
||||
contextPaths,
|
||||
isDead: args.isDead,
|
||||
getFallbackProcess,
|
||||
shouldInspectFallback
|
||||
})
|
||||
|
||||
const scheduleRefresh = (fallbackProcess: string | null): void => {
|
||||
if (args.isDead() || !proc.pid) {
|
||||
return
|
||||
const observed = (processName: string | null): ForegroundProcessObservation => ({
|
||||
processName,
|
||||
evidence: { verdict: 'observed', processName }
|
||||
})
|
||||
const unverifiable = (
|
||||
processName: string | null,
|
||||
reason: string
|
||||
): ForegroundProcessObservation => ({
|
||||
processName,
|
||||
evidence: { verdict: 'unverifiable', reason }
|
||||
})
|
||||
|
||||
const observeForegroundProcess = (): ForegroundProcessObservation => {
|
||||
if (args.isDead()) {
|
||||
// The pty's own exit marked death: a host observation, not a failed read.
|
||||
return observed(null)
|
||||
}
|
||||
const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess)
|
||||
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
|
||||
if (
|
||||
!fallbackProcess ||
|
||||
(fallbackRecognition !== null &&
|
||||
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
|
||||
!shouldInspectFallback(fallbackProcess)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const idleNoEvidenceShell =
|
||||
fallbackIsShell && !getActiveStartupAgent(now) && !cachedAgentForeground
|
||||
const retryMs = !idleNoEvidenceShell
|
||||
? FOREGROUND_AGENT_CACHE_TTL_MS
|
||||
: process.platform === 'win32' && now - lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS
|
||||
? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS
|
||||
: SHELL_FOREGROUND_REFRESH_RETRY_MS
|
||||
if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) {
|
||||
return
|
||||
}
|
||||
foregroundRefreshInFlight = true
|
||||
lastForegroundRefreshStartedAt = now
|
||||
const identityOlderThan = (ms: number): boolean =>
|
||||
cachedAgentForeground !== null && Date.now() - cachedAgentForeground.refreshedAt > ms
|
||||
const retireStaleForegroundIdentity = ({ onlyWhenAged = false } = {}): void => {
|
||||
const currentFallbackProcess = getFallbackProcess()
|
||||
if (
|
||||
fallbackIsShell &&
|
||||
!getActiveStartupAgent() &&
|
||||
currentFallbackProcess !== null &&
|
||||
isShellProcess(currentFallbackProcess) &&
|
||||
(!onlyWhenAged || identityOlderThan(WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS))
|
||||
) {
|
||||
cachedAgentForeground = null
|
||||
startupAgentForeground = null
|
||||
} else if (
|
||||
identityOlderThan(FOREGROUND_AGENT_CACHE_TTL_MS) &&
|
||||
currentFallbackProcess !== null &&
|
||||
isAgentForegroundWrapperProcess(currentFallbackProcess)
|
||||
) {
|
||||
cachedAgentForeground = null
|
||||
try {
|
||||
const fallbackProcess = getFallbackProcess()
|
||||
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
|
||||
const inspectOuterWrapper =
|
||||
fallbackRecognition !== null &&
|
||||
shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)
|
||||
if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) {
|
||||
state.cachedAgentForeground = {
|
||||
processName: fallbackProcess,
|
||||
pid: null,
|
||||
refreshedAt: Date.now()
|
||||
}
|
||||
state.startupAgentForeground = null
|
||||
return observed(fallbackProcess)
|
||||
}
|
||||
scheduleRefresh(fallbackProcess)
|
||||
const now = Date.now()
|
||||
if (
|
||||
state.cachedAgentForeground &&
|
||||
now - state.cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
|
||||
) {
|
||||
return observed(state.cachedAgentForeground.processName)
|
||||
}
|
||||
if (
|
||||
state.cachedAgentForeground &&
|
||||
fallbackProcess !== null &&
|
||||
(isAgentForegroundWrapperProcess(fallbackProcess) ||
|
||||
inspectOuterWrapper ||
|
||||
(process.platform === 'win32' && isShellProcess(fallbackProcess)))
|
||||
) {
|
||||
return observed(state.cachedAgentForeground.processName)
|
||||
}
|
||||
const activeStartupAgentForeground = getActiveStartupAgent(state, now)
|
||||
if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) {
|
||||
return observed(activeStartupAgentForeground.processName)
|
||||
}
|
||||
if (fallbackProcess === null) {
|
||||
// A successful read that named nothing usable observed nothing.
|
||||
return unverifiable(null, 'pty reported no usable foreground title')
|
||||
}
|
||||
if (
|
||||
isShellProcess(fallbackProcess) &&
|
||||
!isShellTitleCorroborated(state.lastScanSettlement, now, state.cachedAgentForeground)
|
||||
) {
|
||||
return unverifiable(fallbackProcess, 'shell title without a corroborating foreground scan')
|
||||
}
|
||||
return observed(fallbackProcess)
|
||||
} catch {
|
||||
return unverifiable(null, 'foreground title read threw')
|
||||
}
|
||||
const anchor = cachedAgentForeground
|
||||
void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, {
|
||||
contextPaths,
|
||||
...(anchor?.pid != null
|
||||
? { anchorProcessId: anchor.pid, anchorProcessName: anchor.processName }
|
||||
: {})
|
||||
})
|
||||
.then<string | void>(({ processName, processId, available, anchorPidForeign }) => {
|
||||
if (args.isDead() || !available) {
|
||||
return
|
||||
}
|
||||
if (!processName || !recognizeAgentProcess(processName)) {
|
||||
if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) {
|
||||
// Job, not console: needs no console attachment, so no fork (#10857).
|
||||
const verdict = judgeCachedAgentJobEvidence({
|
||||
jobProcessIds: readWindowsPtyJobProcessIds(proc),
|
||||
jobSupported: isWindowsPtyJobReadable(),
|
||||
shellPid: proc.pid,
|
||||
anchorProcessId: cachedAgentForeground.pid,
|
||||
identityAgeMs: Date.now() - cachedAgentForeground.refreshedAt
|
||||
})
|
||||
// Unverifiable is never exit proof (ssh-execution-boundary.md): hold.
|
||||
if (verdict === 'unavailable') {
|
||||
return
|
||||
}
|
||||
if (verdict === 'unsupported') {
|
||||
// No job to consult on this build, and the scan that got here was
|
||||
// available and found no agent. Trust it, as every other platform
|
||||
// does, rather than holding a dead name forever (#16059).
|
||||
retireStaleForegroundIdentity()
|
||||
return
|
||||
}
|
||||
if (verdict === 'confirmed' || verdict === 'recheck') {
|
||||
if (anchorPidForeign === true) {
|
||||
// The scan proved the pid recycled to a non-agent: retire now.
|
||||
retireStaleForegroundIdentity()
|
||||
return
|
||||
}
|
||||
// The anchor pid is still in the job: the scan lost the row, not
|
||||
// the agent. Restamp so a live agent never ages out (#9258).
|
||||
cachedAgentForeground = { ...cachedAgentForeground, refreshedAt: Date.now() }
|
||||
return
|
||||
}
|
||||
if (verdict === 'exited' || verdict === 'anchor-exited') {
|
||||
// Safe mid-restart: an available scan already found no agent.
|
||||
retireStaleForegroundIdentity()
|
||||
return
|
||||
}
|
||||
// Unanchored superset evidence cannot tell a working agent from a
|
||||
// leftover; the age bound settles it.
|
||||
retireStaleForegroundIdentity({ onlyWhenAged: true })
|
||||
return
|
||||
}
|
||||
retireStaleForegroundIdentity()
|
||||
return
|
||||
}
|
||||
cachedAgentForeground = { processName, pid: processId ?? null, refreshedAt: Date.now() }
|
||||
startupAgentForeground = null
|
||||
return processName
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort only: foreground enrichment must never affect PTY health.
|
||||
})
|
||||
.finally(() => {
|
||||
foregroundRefreshInFlight = false
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
recordOutput: (data) => {
|
||||
if (data.length > 0) {
|
||||
lastOutputAt = Date.now()
|
||||
state.lastOutputAt = Date.now()
|
||||
}
|
||||
},
|
||||
markDead: () => {
|
||||
cachedAgentForeground = null
|
||||
startupAgentForeground = null
|
||||
},
|
||||
getForegroundProcess: () => {
|
||||
if (args.isDead()) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const fallbackProcess = getFallbackProcess()
|
||||
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
|
||||
const inspectOuterWrapper =
|
||||
fallbackRecognition !== null &&
|
||||
shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)
|
||||
if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) {
|
||||
cachedAgentForeground = {
|
||||
processName: fallbackProcess,
|
||||
pid: null,
|
||||
refreshedAt: Date.now()
|
||||
}
|
||||
startupAgentForeground = null
|
||||
return fallbackProcess
|
||||
}
|
||||
scheduleRefresh(fallbackProcess)
|
||||
const now = Date.now()
|
||||
if (
|
||||
cachedAgentForeground &&
|
||||
now - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
|
||||
) {
|
||||
return cachedAgentForeground.processName
|
||||
}
|
||||
if (
|
||||
cachedAgentForeground &&
|
||||
fallbackProcess !== null &&
|
||||
(isAgentForegroundWrapperProcess(fallbackProcess) ||
|
||||
inspectOuterWrapper ||
|
||||
(process.platform === 'win32' && isShellProcess(fallbackProcess)))
|
||||
) {
|
||||
return cachedAgentForeground.processName
|
||||
}
|
||||
const activeStartupAgentForeground = getActiveStartupAgent(now)
|
||||
if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) {
|
||||
return activeStartupAgentForeground.processName
|
||||
}
|
||||
return fallbackProcess
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
state.cachedAgentForeground = null
|
||||
state.startupAgentForeground = null
|
||||
},
|
||||
getForegroundProcess: () => observeForegroundProcess().processName,
|
||||
observeForegroundProcess,
|
||||
confirmForegroundProcess: async () => {
|
||||
if (args.isDead() || !proc.pid) {
|
||||
return null
|
||||
@@ -274,6 +176,10 @@ export function createPtyForegroundProcessTracker(args: {
|
||||
) {
|
||||
return fallbackProcess
|
||||
}
|
||||
// Why stamped before the call: the POSIX table is a process-wide cache, so this answer
|
||||
// can come from a scan another pane started. Replaced by the measured scan start when
|
||||
// the resolver reports one; this is only the floor for the Windows path, which has none.
|
||||
const scanRequestedAt = Date.now()
|
||||
const resolution = await resolveAgentForegroundProcessWithAvailability(
|
||||
proc.pid,
|
||||
fallbackProcess,
|
||||
@@ -294,16 +200,26 @@ export function createPtyForegroundProcessTracker(args: {
|
||||
}
|
||||
const recognized = recognizeAgentProcess(resolution.processName)
|
||||
if (recognized) {
|
||||
cachedAgentForeground = {
|
||||
state.cachedAgentForeground = {
|
||||
processName: recognized.processName,
|
||||
pid: resolution.processId ?? null,
|
||||
refreshedAt: Date.now()
|
||||
}
|
||||
startupAgentForeground = null
|
||||
state.startupAgentForeground = null
|
||||
return recognized.processName
|
||||
}
|
||||
cachedAgentForeground = null
|
||||
startupAgentForeground = null
|
||||
// An agent recognized while this scan was in flight is evidence the scan's table could
|
||||
// not have held, so this answer may not retire it — and retiring it would also strip the
|
||||
// shell-title corroboration gate of the very evidence that holds it closed.
|
||||
if (
|
||||
!agentEvidenceOutdatesScan(
|
||||
state.cachedAgentForeground,
|
||||
resolution.tableScanStartedAtMs ?? scanRequestedAt
|
||||
)
|
||||
) {
|
||||
state.cachedAgentForeground = null
|
||||
state.startupAgentForeground = null
|
||||
}
|
||||
return resolution.processName
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -70,6 +70,7 @@ export function createDaemonPtySubprocessHandle(args: {
|
||||
? { startupCommandDeliveredInShellArgs: true }
|
||||
: {}),
|
||||
getForegroundProcess: foreground.getForegroundProcess,
|
||||
observeForegroundProcess: foreground.observeForegroundProcess,
|
||||
confirmForegroundProcess: foreground.confirmForegroundProcess,
|
||||
confirmShellForeground: foreground.confirmShellForeground,
|
||||
write: (data) => {
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
|
||||
import type { PtyForegroundProcessEvidence } from '../../shared/pty-process-inspection-evidence'
|
||||
|
||||
import type { JobTerminationOutcome } from '../windows/windows-pty-job'
|
||||
|
||||
/** A foreground read plus the evidence verdict behind it. `processName` keeps
|
||||
* the exact legacy collapse every existing caller sees; the evidence says
|
||||
* whether anything actually observed the pane, so a degraded read is never
|
||||
* exit evidence (docs/reference/ssh-execution-boundary.md). */
|
||||
export type ForegroundProcessObservation = {
|
||||
processName: string | null
|
||||
evidence: PtyForegroundProcessEvidence
|
||||
}
|
||||
|
||||
export type SubprocessHandle = {
|
||||
pid: number
|
||||
/** 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
|
||||
/** getForegroundProcess plus the evidence verdict behind the read. Optional so
|
||||
* a handle that cannot report evidence reads as `unverifiable` (Session's
|
||||
* fallback) rather than as an observation — the conservative arm. */
|
||||
observeForegroundProcess?(): ForegroundProcessObservation
|
||||
/** 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 +58,22 @@ export type SubprocessHandle = {
|
||||
/** Release the native PTY handle via node-pty's destroy(). Idempotent; safe to call after exit. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/** Read a handle's foreground observation. A handle with no evidence channel
|
||||
* proves nothing about the pane, so it reads as `unverifiable` rather than as
|
||||
* an observation (docs/reference/ssh-execution-boundary.md). */
|
||||
export function observeSubprocessForeground(
|
||||
subprocess: Pick<SubprocessHandle, 'getForegroundProcess' | 'observeForegroundProcess'>
|
||||
): ForegroundProcessObservation {
|
||||
const observe = subprocess.observeForegroundProcess
|
||||
if (observe) {
|
||||
return observe.call(subprocess)
|
||||
}
|
||||
return {
|
||||
processName: subprocess.getForegroundProcess(),
|
||||
evidence: {
|
||||
verdict: 'unverifiable',
|
||||
reason: 'subprocess handle reports no foreground evidence'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
SessionTerminationController,
|
||||
IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS
|
||||
} from './session-termination-controller'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
import { observeSubprocessForeground } from './session-subprocess-handle'
|
||||
import type { ForegroundProcessObservation, 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'
|
||||
@@ -252,8 +253,8 @@ export class Session {
|
||||
return this.output.getCwd()
|
||||
}
|
||||
|
||||
getForegroundProcess(): string | null {
|
||||
return this.subprocess.getForegroundProcess()
|
||||
observeForegroundProcess(): ForegroundProcessObservation {
|
||||
return observeSubprocessForeground(this.subprocess)
|
||||
}
|
||||
|
||||
async confirmForegroundProcess(): Promise<string | null> {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// A subprocess handle with no evidence channel proves nothing about the pane.
|
||||
// Session must read it as `unverifiable` rather than letting the legacy
|
||||
// foreground collapse reach the completion monitor as an observation
|
||||
// (docs/reference/ssh-execution-boundary.md).
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TerminalHost } from './terminal-host'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
|
||||
vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() }))
|
||||
|
||||
function createEvidencelessSubprocess(readForeground: () => string | null): SubprocessHandle {
|
||||
let onExitCb: ((code: number) => void) | null = null
|
||||
return {
|
||||
pid: 999_999_412,
|
||||
getForegroundProcess: readForeground,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
|
||||
terminateOwnedTree: () => 'unavailable' as const,
|
||||
forceKill: vi.fn(() => onExitCb?.(137)),
|
||||
signal: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onExit(cb) {
|
||||
onExitCb = cb
|
||||
},
|
||||
dispose: vi.fn()
|
||||
} as unknown as SubprocessHandle
|
||||
}
|
||||
|
||||
describe('daemon inspection of a handle that cannot report evidence', () => {
|
||||
const SESSION_ID = 'repo-degraded-handle::/tmp/wt@@degraded01'
|
||||
let host: TerminalHost
|
||||
let platformDescriptor: PropertyDescriptor | undefined
|
||||
let foreground: string | null
|
||||
|
||||
beforeEach(async () => {
|
||||
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
||||
foreground = 'zsh'
|
||||
host = new TerminalHost({
|
||||
spawnSubprocess: () => createEvidencelessSubprocess(() => foreground)
|
||||
})
|
||||
await host.createOrAttach({
|
||||
sessionId: SESSION_ID,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await host.dispose()
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', platformDescriptor)
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes unverifiable evidence beside the unchanged legacy fields', () => {
|
||||
const result = host.inspectProcess(SESSION_ID)
|
||||
|
||||
expect(result.foregroundProcess).toBe('zsh')
|
||||
expect(result.hasChildProcesses).toBe(false)
|
||||
expect(result.processEvidence?.foreground.verdict).toBe('unverifiable')
|
||||
expect(result.processEvidence?.children.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('does not upgrade a named agent to observed either', () => {
|
||||
// The conservative arm cuts both ways: an unverifiable read cannot prove
|
||||
// the agent is live any more than it can prove it exited.
|
||||
foreground = 'codex'
|
||||
const result = host.inspectProcess(SESSION_ID)
|
||||
|
||||
expect(result.hasChildProcesses).toBe(true)
|
||||
expect(result.processEvidence?.foreground.verdict).toBe('unverifiable')
|
||||
expect(result.processEvidence?.children.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('leaves the legacy foreground read untouched', () => {
|
||||
expect(host.getForegroundProcess(SESSION_ID)).toBe('zsh')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
// Pins the direction every arm of the daemon inspection collapse falls in: a
|
||||
// foreground read that observed nothing must reach the caller as
|
||||
// `unverifiable`, never as the `exited` the completion monitor acts on
|
||||
// (docs/reference/ssh-execution-boundary.md).
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildDaemonInspectProcessResult } from './terminal-host-process-evidence'
|
||||
import type { ForegroundProcessObservation } from './session-subprocess-handle'
|
||||
|
||||
function observed(processName: string | null): ForegroundProcessObservation {
|
||||
return { processName, evidence: { verdict: 'observed', processName } }
|
||||
}
|
||||
|
||||
function unverifiable(processName: string | null, reason: string): ForegroundProcessObservation {
|
||||
return { processName, evidence: { verdict: 'unverifiable', reason } }
|
||||
}
|
||||
|
||||
describe('buildDaemonInspectProcessResult', () => {
|
||||
it('reports a degraded read as unverifiable on both probes, never as an exit', () => {
|
||||
const result = buildDaemonInspectProcessResult(unverifiable('zsh', 'scan never settled'))
|
||||
|
||||
expect(result.processEvidence?.foreground).toEqual({
|
||||
verdict: 'unverifiable',
|
||||
reason: 'scan never settled'
|
||||
})
|
||||
expect(result.processEvidence?.children).toEqual({
|
||||
verdict: 'unverifiable',
|
||||
reason: 'scan never settled'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the legacy fields byte-identical to the pre-evidence collapse', () => {
|
||||
// The exact payload an old client still receives: a degraded read publishes
|
||||
// the shell title it fell back to and hasChildProcesses:false. Only the
|
||||
// additive evidence field distinguishes it from an observed idle shell.
|
||||
const degraded = buildDaemonInspectProcessResult(unverifiable('zsh', 'scan never settled'))
|
||||
expect(degraded.foregroundProcess).toBe('zsh')
|
||||
expect(degraded.hasChildProcesses).toBe(false)
|
||||
|
||||
const live = buildDaemonInspectProcessResult(observed('codex'))
|
||||
expect(live.foregroundProcess).toBe('codex')
|
||||
expect(live.hasChildProcesses).toBe(true)
|
||||
})
|
||||
|
||||
it('calls children live only when the observation named a non-shell process', () => {
|
||||
expect(buildDaemonInspectProcessResult(observed('codex')).processEvidence?.children).toEqual({
|
||||
verdict: 'live'
|
||||
})
|
||||
})
|
||||
|
||||
it('calls children exited only on a positive observation of an idle pane', () => {
|
||||
expect(buildDaemonInspectProcessResult(observed('zsh')).processEvidence?.children).toEqual({
|
||||
verdict: 'exited'
|
||||
})
|
||||
// The host watched the pty die: absence it observed itself.
|
||||
expect(buildDaemonInspectProcessResult(observed(null)).processEvidence?.children).toEqual({
|
||||
verdict: 'exited'
|
||||
})
|
||||
})
|
||||
|
||||
it('never lets the children verdict outrank an unverifiable foreground', () => {
|
||||
// An agent name carried by a read nothing corroborated is still not proof
|
||||
// the agent is live, and a shell name is still not proof it exited.
|
||||
for (const name of ['codex', 'zsh', null]) {
|
||||
const children = buildDaemonInspectProcessResult(unverifiable(name, 'title read threw'))
|
||||
.processEvidence?.children
|
||||
expect(children?.verdict).toBe('unverifiable')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { isShellProcess } from '../../shared/agent-detection'
|
||||
import type { PtyChildProcessesEvidence } from '../../shared/pty-process-inspection-evidence'
|
||||
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
import type { ForegroundProcessObservation } from './session-subprocess-handle'
|
||||
|
||||
/**
|
||||
* Wire result for the daemon 'inspectProcess' request. The legacy fields keep
|
||||
* the exact pre-evidence collapse (a degraded read still publishes the shell
|
||||
* title it fell back to); only the optional `processEvidence` distinguishes
|
||||
* "observed idle" from "could not ask", so a degraded read is never exit
|
||||
* evidence (docs/reference/ssh-execution-boundary.md). The field is additive:
|
||||
* older app clients ignore it and see byte-identical content.
|
||||
*/
|
||||
export function buildDaemonInspectProcessResult(
|
||||
observation: ForegroundProcessObservation
|
||||
): PtyProcessInspection {
|
||||
const foregroundProcess = observation.processName
|
||||
return {
|
||||
foregroundProcess,
|
||||
hasChildProcesses: foregroundProcess !== null && !isShellProcess(foregroundProcess),
|
||||
processEvidence: {
|
||||
foreground: observation.evidence,
|
||||
children: deriveChildProcessesEvidence(observation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A daemon pane's only child signal IS the foreground observation, so the
|
||||
* children verdict can never outrank the foreground's. Observed null means
|
||||
* the host itself watched the pane die — positive absence, not a failure. */
|
||||
function deriveChildProcessesEvidence(
|
||||
observation: ForegroundProcessObservation
|
||||
): PtyChildProcessesEvidence {
|
||||
if (observation.evidence.verdict !== 'observed') {
|
||||
return { verdict: 'unverifiable', reason: observation.evidence.reason }
|
||||
}
|
||||
const processName = observation.evidence.processName
|
||||
if (processName !== null && !isShellProcess(processName)) {
|
||||
return { verdict: 'live' }
|
||||
}
|
||||
return { verdict: 'exited' }
|
||||
}
|
||||
@@ -19,7 +19,8 @@ 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 { buildDaemonInspectProcessResult } from './terminal-host-process-evidence'
|
||||
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
import { TerminalAttachCanceledError } from './daemon-errors'
|
||||
|
||||
export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
|
||||
@@ -211,18 +212,13 @@ export class TerminalHost {
|
||||
if (!session || !session.isAlive) {
|
||||
return null
|
||||
}
|
||||
return session.getForegroundProcess()
|
||||
return session.observeForegroundProcess().processName
|
||||
}
|
||||
|
||||
inspectProcess(sessionId: string): {
|
||||
foregroundProcess: string | null
|
||||
hasChildProcesses: boolean
|
||||
} {
|
||||
const foregroundProcess = this.getAliveSession(sessionId).getForegroundProcess()
|
||||
return {
|
||||
foregroundProcess,
|
||||
hasChildProcesses: foregroundProcess !== null && !isShellProcess(foregroundProcess)
|
||||
}
|
||||
inspectProcess(sessionId: string): PtyProcessInspection {
|
||||
return buildDaemonInspectProcessResult(
|
||||
this.getAliveSession(sessionId).observeForegroundProcess()
|
||||
)
|
||||
}
|
||||
|
||||
async confirmForegroundProcess(sessionId: string): Promise<string | null> {
|
||||
|
||||
@@ -20,6 +20,8 @@ function mockPs(stdout: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
const SCAN_AT_MS = 1_700_000_000_000
|
||||
|
||||
/** A cached snapshot that never contained the pane observed nothing about it:
|
||||
* it must degrade (`available: false`) instead of reading as "no agent". */
|
||||
describe('cached snapshot pane availability', () => {
|
||||
@@ -50,11 +52,21 @@ describe('cached snapshot pane availability', () => {
|
||||
})
|
||||
|
||||
it('still scans a cached snapshot that has the pane children but lost the shell row', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(SCAN_AT_MS)
|
||||
mockPs('101 100 S+ node /Users/dev/.nvm/versions/node/bin/codex')
|
||||
|
||||
await expect(resolveAgentForegroundProcessWithAvailability(100, 'node')).resolves.toEqual({
|
||||
available: true,
|
||||
processName: 'codex'
|
||||
})
|
||||
try {
|
||||
await expect(resolveAgentForegroundProcessWithAvailability(100, 'node')).resolves.toEqual({
|
||||
available: true,
|
||||
processName: 'codex',
|
||||
// The scan's own start travels with the rows. A pane served this snapshot from
|
||||
// the shared cache asks later than this, so it must order the table by when the
|
||||
// table was read — its own clock would claim the rows are newer than they are.
|
||||
tableScanStartedAtMs: SCAN_AT_MS
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,8 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process
|
||||
import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent'
|
||||
import {
|
||||
getFreshProcessTableSnapshot,
|
||||
getProcessTableSnapshot,
|
||||
getFreshProcessTableSnapshotProvenance,
|
||||
getProcessTableSnapshotProvenance,
|
||||
type ProcessTableRow
|
||||
} from '../../shared/process-table-snapshot'
|
||||
import {
|
||||
@@ -25,6 +26,10 @@ export type AgentForegroundProcessResolution = {
|
||||
processId?: number
|
||||
/** Windows: the scan proved the caller's `anchorProcessId` is now a non-agent. */
|
||||
anchorPidForeign?: boolean
|
||||
/** When the scan behind this answer began. Present only for POSIX process-table
|
||||
* reads, which share a process-wide cache: the caller's own clock can be up to a
|
||||
* TTL newer than the table it was handed, so ordering must use this instead. */
|
||||
tableScanStartedAtMs?: number
|
||||
}
|
||||
|
||||
type ShellForegroundConfirmationOptions = {
|
||||
@@ -167,9 +172,9 @@ export async function resolveAgentForegroundProcessWithAvailability(
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = options.fresh
|
||||
? await getFreshProcessTableSnapshot()
|
||||
: await getProcessTableSnapshot()
|
||||
const { value: rows, scanStartedAtMs } = options.fresh
|
||||
? await getFreshProcessTableSnapshotProvenance()
|
||||
: await getProcessTableSnapshotProvenance()
|
||||
if (options.fresh && !rows.some((row) => row.pid === shellPid)) {
|
||||
return { available: false, processName: fallbackProcess }
|
||||
}
|
||||
@@ -181,7 +186,8 @@ export async function resolveAgentForegroundProcessWithAvailability(
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
processName: resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess
|
||||
processName: resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess,
|
||||
tableScanStartedAtMs: scanStartedAtMs
|
||||
}
|
||||
} catch {
|
||||
// Why: a failed scan cannot prove fallback ownership; callers retain the last recognized agent.
|
||||
|
||||
@@ -50,7 +50,7 @@ export function parseProcessTableRows(stdout: string): ProcessTableRow[] {
|
||||
return rows
|
||||
}
|
||||
|
||||
type Snapshot<T> = { value: T; capturedAtMs: number }
|
||||
type Snapshot<T> = { value: T; capturedAtMs: number; scanStartedAtMs: number }
|
||||
|
||||
type ProcessTableSnapshotReaderDeps<T> = {
|
||||
runPs: () => Promise<T>
|
||||
@@ -71,23 +71,30 @@ export function createProcessTableSnapshotReader<T = string>(
|
||||
): {
|
||||
getSnapshot: () => Promise<T>
|
||||
getFreshSnapshot: () => Promise<T>
|
||||
getSnapshotProvenance: () => Promise<Snapshot<T>>
|
||||
getFreshSnapshotProvenance: () => Promise<Snapshot<T>>
|
||||
reset: () => void
|
||||
} {
|
||||
const ttlMs = deps.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS
|
||||
let cached: Snapshot<T> | null = null
|
||||
let inFlight: Promise<T> | null = null
|
||||
let inFlight: Promise<Snapshot<T>> | null = null
|
||||
let sequence = 0
|
||||
let freshQueued: { promise: Promise<T>; startSequence: number | null } | null = null
|
||||
let freshQueued: { promise: Promise<Snapshot<T>>; startSequence: number | null } | null = null
|
||||
|
||||
async function runSnapshot(): Promise<T> {
|
||||
const promise = deps.runPs()
|
||||
async function runSnapshot(): Promise<Snapshot<T>> {
|
||||
// Why both stamps: TTL reuse must not hand back a snapshot already older than
|
||||
// its window, so freshness dates the answer. But ordering this table against
|
||||
// other evidence needs the earliest moment it could have been read — a process
|
||||
// that started after the scan began may be missing from it either way.
|
||||
const scanStartedAtMs = deps.now()
|
||||
const promise = deps.runPs().then((value) => {
|
||||
const snapshot: Snapshot<T> = { value, capturedAtMs: deps.now(), scanStartedAtMs }
|
||||
cached = snapshot
|
||||
return snapshot
|
||||
})
|
||||
inFlight = promise
|
||||
try {
|
||||
const value = await promise
|
||||
// Why: stamp capture time AFTER the scan returns so a slow scan can't
|
||||
// hand back a snapshot that is already older than its TTL.
|
||||
cached = { value, capturedAtMs: deps.now() }
|
||||
return value
|
||||
return await promise
|
||||
} finally {
|
||||
if (inFlight === promise) {
|
||||
inFlight = null
|
||||
@@ -95,9 +102,9 @@ export function createProcessTableSnapshotReader<T = string>(
|
||||
}
|
||||
}
|
||||
|
||||
async function getSnapshot(): Promise<T> {
|
||||
async function getSnapshot(): Promise<Snapshot<T>> {
|
||||
if (cached && deps.now() - cached.capturedAtMs < ttlMs) {
|
||||
return cached.value
|
||||
return cached
|
||||
}
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
@@ -110,14 +117,14 @@ export function createProcessTableSnapshotReader<T = string>(
|
||||
return runSnapshot()
|
||||
}
|
||||
|
||||
function getFreshSnapshot(): Promise<T> {
|
||||
function getFreshSnapshot(): Promise<Snapshot<T>> {
|
||||
const requestSequence = ++sequence
|
||||
if (freshQueued?.startSequence === null) {
|
||||
return freshQueued.promise
|
||||
}
|
||||
const priorFresh = freshQueued?.promise ?? null
|
||||
const priorScan = inFlight
|
||||
const entry: { promise: Promise<T>; startSequence: number | null } = {
|
||||
const entry: { promise: Promise<Snapshot<T>>; startSequence: number | null } = {
|
||||
promise: Promise.resolve(undefined as never),
|
||||
startSequence: null
|
||||
}
|
||||
@@ -152,8 +159,11 @@ export function createProcessTableSnapshotReader<T = string>(
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot,
|
||||
getFreshSnapshot,
|
||||
getSnapshot: async () => (await getSnapshot()).value,
|
||||
getFreshSnapshot: async () => (await getFreshSnapshot()).value,
|
||||
/** The same reads, with the provenance an ordering comparison needs. */
|
||||
getSnapshotProvenance: getSnapshot,
|
||||
getFreshSnapshotProvenance: getFreshSnapshot,
|
||||
// Why: lets tests that mock `ps` per case clear the cross-call cache so one
|
||||
// case's snapshot can't satisfy the next within the TTL window.
|
||||
reset: () => {
|
||||
@@ -193,6 +203,21 @@ export function getFreshProcessTableSnapshot(): Promise<ProcessTableRow[]> {
|
||||
return defaultReader.getFreshSnapshot()
|
||||
}
|
||||
|
||||
export type ProcessTableSnapshotProvenance = {
|
||||
value: ProcessTableRow[]
|
||||
/** When the scan behind these rows began. A pane reading a cached snapshot asked
|
||||
* later than this, so its own clock overstates how current the table is. */
|
||||
scanStartedAtMs: number
|
||||
}
|
||||
|
||||
export function getProcessTableSnapshotProvenance(): Promise<ProcessTableSnapshotProvenance> {
|
||||
return defaultReader.getSnapshotProvenance()
|
||||
}
|
||||
|
||||
export function getFreshProcessTableSnapshotProvenance(): Promise<ProcessTableSnapshotProvenance> {
|
||||
return defaultReader.getFreshSnapshotProvenance()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: clear the shared snapshot cache so suites that mock `ps` between
|
||||
* cases don't have one case's snapshot served to the next within the TTL.
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { rmSync } from 'node:fs'
|
||||
import type * as pty from 'node-pty'
|
||||
|
||||
const { execFileMock, execFileSyncMock, killWithDescendantSweepMock, forceKillPosixMock } =
|
||||
vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
killWithDescendantSweepMock: vi.fn(),
|
||||
forceKillPosixMock: vi.fn()
|
||||
}))
|
||||
|
||||
// The identity scan's `ps -axo` fork; scripted to fail so no scan can settle.
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
execFileSync: execFileSyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/pty-descendant-termination', () => ({
|
||||
killWithDescendantSweep: killWithDescendantSweepMock
|
||||
}))
|
||||
|
||||
// Teardown escalation must never signal a real process group from a test.
|
||||
vi.mock('../../../src/main/pty/posix-pty-process-groups', () => ({
|
||||
forceKillPosixPtyProcessGroups: forceKillPosixMock
|
||||
}))
|
||||
|
||||
import { createDaemonPtySubprocessHandle } from '../../../src/main/daemon/pty-subprocess/subprocess-handle'
|
||||
import {
|
||||
startDaemonAdapterHarness,
|
||||
type DaemonAdapterHarness
|
||||
} from '../../../src/main/daemon/daemon-pty-adapter-test-harness'
|
||||
import { inspectPtyProviderProcessForRenderer } from '../../../src/main/providers/pty-process-inspection'
|
||||
|
||||
/**
|
||||
* The daemon inspection evidence must survive the REAL wire: DaemonServer →
|
||||
* request router → JSON socket → DaemonPtyAdapter. The sibling suite proves
|
||||
* the monitor consequences in-process; this one proves the `processEvidence`
|
||||
* field actually crosses the daemon protocol, beside byte-identical legacy
|
||||
* fields, on the exact socket a macOS daemon pane uses.
|
||||
*/
|
||||
describe('daemon inspection evidence over the daemon socket', () => {
|
||||
const SHELL_PID = 999_999_311
|
||||
const AGENT_SESSION_ID = 'repo-daemon-wire::/tmp/wt@@wire0001'
|
||||
const SHELL_SESSION_ID = 'repo-daemon-wire::/tmp/wt@@wire0002'
|
||||
|
||||
type MockNodePty = pty.IPty & { process: string; _fireExit: (exitCode: number) => void }
|
||||
|
||||
function createMockNodePtyProcess(): MockNodePty {
|
||||
const exitListeners: ((e: { exitCode: number; signal?: number }) => void)[] = []
|
||||
const proc = {
|
||||
pid: SHELL_PID,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
process: 'codex',
|
||||
handleFlowControl: false,
|
||||
onData: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
onExit: vi.fn((cb: (e: { exitCode: number; signal?: number }) => void) => {
|
||||
exitListeners.push(cb)
|
||||
return { dispose: vi.fn() }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
kill: vi.fn(() => {
|
||||
setTimeout(() => proc._fireExit(0), 5)
|
||||
}),
|
||||
_fireExit: (exitCode: number) => {
|
||||
exitListeners.forEach((cb) => cb({ exitCode, signal: 0 }))
|
||||
}
|
||||
}
|
||||
return proc as unknown as MockNodePty
|
||||
}
|
||||
|
||||
let harness: DaemonAdapterHarness
|
||||
let procsBySession: Map<string, MockNodePty>
|
||||
|
||||
beforeEach(async () => {
|
||||
execFileMock.mockReset()
|
||||
execFileMock.mockImplementation(
|
||||
(_command: string, _args: string[], _opts: unknown, cb: unknown) => {
|
||||
;(cb as (err: Error | null, result: { stdout: string; stderr: string }) => void)(
|
||||
new Error('ps unavailable in wire test'),
|
||||
{ stdout: '', stderr: '' }
|
||||
)
|
||||
}
|
||||
)
|
||||
killWithDescendantSweepMock.mockReset()
|
||||
killWithDescendantSweepMock.mockResolvedValue(undefined)
|
||||
forceKillPosixMock.mockReset()
|
||||
procsBySession = new Map()
|
||||
forceKillPosixMock.mockImplementation(() => {
|
||||
setTimeout(() => procsBySession.forEach((proc) => proc._fireExit(137)), 1)
|
||||
})
|
||||
harness = await startDaemonAdapterHarness((opts) => {
|
||||
const proc = createMockNodePtyProcess()
|
||||
proc.process = opts.sessionId === AGENT_SESSION_ID ? 'codex' : 'zsh'
|
||||
procsBySession.set(opts.sessionId, proc)
|
||||
return createDaemonPtySubprocessHandle({
|
||||
process: proc,
|
||||
shellPath: '/bin/zsh',
|
||||
spawnCwd: '/tmp/wt',
|
||||
env: { PATH: '/usr/bin' },
|
||||
startupCommandDeliveredInShellArgs: false,
|
||||
reportsChildExitStatus: true,
|
||||
requestedCwd: '/tmp/wt',
|
||||
sessionId: opts.sessionId,
|
||||
startupAgentRecognition: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
harness.adapter?.dispose()
|
||||
await harness.server?.shutdown()
|
||||
rmSync(harness.dir, { recursive: true, force: true })
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('carries observed and unverifiable evidence across the socket, legacy fields intact', async () => {
|
||||
const agentPane = await harness.adapter.spawn({
|
||||
sessionId: AGENT_SESSION_ID,
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
const shellPane = await harness.adapter.spawn({
|
||||
sessionId: SHELL_SESSION_ID,
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
// A recognized agent title is a direct observation — no scan involved.
|
||||
expect(await inspectPtyProviderProcessForRenderer(harness.adapter, agentPane.id)).toEqual({
|
||||
foregroundProcess: 'codex',
|
||||
hasChildProcesses: true,
|
||||
processEvidence: {
|
||||
foreground: { verdict: 'observed', processName: 'codex' },
|
||||
children: { verdict: 'live' }
|
||||
}
|
||||
})
|
||||
|
||||
// A shell-shaped title with no settled scan: node-pty's silent shell
|
||||
// fallback makes it indistinguishable from a degraded read. The legacy
|
||||
// fields keep the exact pre-evidence collapse the monitor used to read as
|
||||
// exit proof; the evidence now says "could not ask".
|
||||
expect(await inspectPtyProviderProcessForRenderer(harness.adapter, shellPane.id)).toEqual({
|
||||
foregroundProcess: 'zsh',
|
||||
hasChildProcesses: false,
|
||||
processEvidence: {
|
||||
foreground: { verdict: 'unverifiable', reason: expect.any(String) },
|
||||
children: { verdict: 'unverifiable', reason: expect.any(String) }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as pty from 'node-pty'
|
||||
|
||||
const { execFileMock, execFileSyncMock, killWithDescendantSweepMock, forceKillPosixMock } =
|
||||
vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
killWithDescendantSweepMock: vi.fn(),
|
||||
forceKillPosixMock: vi.fn()
|
||||
}))
|
||||
|
||||
// The process-table snapshot (`ps -axo`) reads through node:child_process
|
||||
// execFile; the foreground resolver and tracker stay REAL so this suite
|
||||
// exercises the production degraded-read handling, not a stub of it.
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
execFileSync: execFileSyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/pty-descendant-termination', () => ({
|
||||
killWithDescendantSweep: killWithDescendantSweepMock
|
||||
}))
|
||||
|
||||
// Teardown escalation must never signal a real process group from a test.
|
||||
vi.mock('../../../src/main/pty/posix-pty-process-groups', () => ({
|
||||
forceKillPosixPtyProcessGroups: forceKillPosixMock
|
||||
}))
|
||||
|
||||
import { TerminalHost } from '../../../src/main/daemon/terminal-host'
|
||||
import { createDaemonPtySubprocessHandle } from '../../../src/main/daemon/pty-subprocess/subprocess-handle'
|
||||
import { DaemonPtyAdapter } from '../../../src/main/daemon/daemon-pty-adapter'
|
||||
import { inspectPtyProviderProcessForRenderer } from '../../../src/main/providers/pty-process-inspection'
|
||||
import { resetProcessTableSnapshotForTests } from '../../../src/shared/process-table-snapshot'
|
||||
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, DAEMON terminal-host inspection site — the
|
||||
* default macOS path: local panes are daemon-hosted, so the completion
|
||||
* monitor's inspectProcess lands in TerminalHost.inspectProcess via
|
||||
* DaemonPtyProcessInspection.
|
||||
*
|
||||
* The daemon's synchronous foreground read degrades in ways that used to be
|
||||
* indistinguishable from an observation: node-pty's POSIX title read silently
|
||||
* falls back to the spawned shell file when the native read fails, and the
|
||||
* async identity scan behind the 1s cache can be degraded (truncated table /
|
||||
* failed ps fork) so the cached agent identity goes stale without ever being
|
||||
* disproven. Both used to collapse into `{ foregroundProcess: <shell>,
|
||||
* hasChildProcesses: false }` — the exact payload the agent-completion monitor
|
||||
* reads as positive exit evidence. The truth is "could not ask", which must
|
||||
* stay `unverifiable` and never become a completion
|
||||
* (docs/reference/ssh-execution-boundary.md). Relay leg and local-provider leg
|
||||
* fixed by the two parent changes; this is the daemon leg.
|
||||
*/
|
||||
describe('daemon terminal-host inspection under degraded reads', () => {
|
||||
// Implausibly high so a leaked signal can never hit a real process.
|
||||
const SHELL_PID = 999_999_242
|
||||
// Minted worktree ids are `${repoId}::${path}`; the parser rejects anything else.
|
||||
const WORKTREE_ID = 'repo-daemon-evidence::/tmp/wt'
|
||||
const SESSION_ID = `${WORKTREE_ID}@@repro001`
|
||||
|
||||
type PsBehavior = 'healthy' | 'table-missing-pane' | 'ps-failure' | 'agent-exited'
|
||||
let psBehavior: PsBehavior = 'healthy'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 (command !== 'ps' || args[0] !== '-axo') {
|
||||
callback(new Error(`unexpected command ${command}`), { stdout: '', stderr: '' })
|
||||
return
|
||||
}
|
||||
switch (psBehavior) {
|
||||
case 'healthy':
|
||||
callback(null, {
|
||||
stdout: [
|
||||
`${SHELL_PID} 1 Ss -zsh`,
|
||||
`999999555 ${SHELL_PID} S+ node /home/dev/.local/bin/codex`
|
||||
].join('\n'),
|
||||
stderr: ''
|
||||
})
|
||||
return
|
||||
case 'table-missing-pane':
|
||||
// The scan "succeeded" but the table never contained the pane's
|
||||
// shell or its descendants.
|
||||
callback(null, { stdout: '1 0 Ss /sbin/launchd', stderr: '' })
|
||||
return
|
||||
case 'ps-failure':
|
||||
callback(timeoutKilledError(command), { stdout: '', stderr: '' })
|
||||
return
|
||||
case 'agent-exited':
|
||||
callback(null, { stdout: `${SHELL_PID} 1 Ss+ -zsh`, stderr: '' })
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
type MockNodePty = pty.IPty & {
|
||||
process: string
|
||||
_fireExit: (exitCode: number) => void
|
||||
}
|
||||
|
||||
function createMockNodePtyProcess(): MockNodePty {
|
||||
const exitListeners: ((e: { exitCode: number; signal?: number }) => void)[] = []
|
||||
const proc = {
|
||||
pid: SHELL_PID,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
// node-pty reports the wrapper entrypoint for node-launched agents; the
|
||||
// daemon's descendant scan resolves it to the real agent.
|
||||
process: 'node',
|
||||
handleFlowControl: false,
|
||||
onData: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
onExit: vi.fn((cb: (e: { exitCode: number; signal?: number }) => void) => {
|
||||
exitListeners.push(cb)
|
||||
return { dispose: vi.fn() }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
kill: vi.fn(() => {
|
||||
setTimeout(() => proc._fireExit(0), 5)
|
||||
}),
|
||||
_fireExit: (exitCode: number) => {
|
||||
exitListeners.forEach((cb) => cb({ exitCode, signal: 0 }))
|
||||
}
|
||||
}
|
||||
return proc as unknown as MockNodePty
|
||||
}
|
||||
|
||||
let platformDescriptor: PropertyDescriptor | undefined
|
||||
let host: TerminalHost
|
||||
let adapter: DaemonPtyAdapter
|
||||
let mockProc: MockNodePty
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
||||
// The default macOS topology: a daemon-hosted local pane.
|
||||
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
psBehavior = 'healthy'
|
||||
execFileMock.mockReset()
|
||||
installExecFile()
|
||||
killWithDescendantSweepMock.mockReset()
|
||||
killWithDescendantSweepMock.mockResolvedValue(undefined)
|
||||
forceKillPosixMock.mockReset()
|
||||
resetProcessTableSnapshotForTests()
|
||||
|
||||
mockProc = createMockNodePtyProcess()
|
||||
// Teardown escalation must still produce a physical exit for the session.
|
||||
forceKillPosixMock.mockImplementation(() => {
|
||||
setTimeout(() => mockProc._fireExit(137), 1)
|
||||
})
|
||||
host = new TerminalHost({
|
||||
// The REAL daemon subprocess handle (and with it the real foreground
|
||||
// tracker) over a scripted node-pty process.
|
||||
spawnSubprocess: (opts) =>
|
||||
createDaemonPtySubprocessHandle({
|
||||
process: mockProc,
|
||||
shellPath: '/bin/zsh',
|
||||
spawnCwd: '/tmp/wt',
|
||||
env: { PATH: '/usr/bin' },
|
||||
startupCommandDeliveredInShellArgs: false,
|
||||
reportsChildExitStatus: true,
|
||||
requestedCwd: '/tmp/wt',
|
||||
sessionId: opts.sessionId,
|
||||
startupAgentRecognition: null
|
||||
})
|
||||
})
|
||||
await host.createOrAttach({
|
||||
sessionId: SESSION_ID,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
|
||||
// The REAL app-side client leg: DaemonPtyAdapter.inspectProcess with its
|
||||
// socket call routed straight to the host. The daemon-request-router's
|
||||
// 'inspectProcess' / 'listSessions' cases are one-line delegations to
|
||||
// TerminalHost, so this preserves every production code path around the
|
||||
// wire itself (which the sibling wire test covers over a real socket).
|
||||
adapter = new DaemonPtyAdapter({
|
||||
socketPath: join(tmpdir(), 'daemon-evidence-unused.sock'),
|
||||
tokenPath: join(tmpdir(), 'daemon-evidence-unused.token')
|
||||
})
|
||||
type ClientInternals = {
|
||||
client: {
|
||||
request: (type: string, payload: unknown) => Promise<unknown>
|
||||
disconnect: () => void
|
||||
ensureConnected: () => Promise<void>
|
||||
ensureConnectedWithin: (ms: number) => Promise<void>
|
||||
getDaemonIdentity: () => null
|
||||
onEvent: (cb: (raw: unknown) => void) => () => void
|
||||
}
|
||||
}
|
||||
;(adapter as unknown as ClientInternals).client = {
|
||||
request: async (type: string, payload: unknown) => {
|
||||
if (type === 'inspectProcess') {
|
||||
return host.inspectProcess((payload as { sessionId: string }).sessionId)
|
||||
}
|
||||
if (type === 'listSessions') {
|
||||
return { sessions: host.listSessions() }
|
||||
}
|
||||
throw new Error(`unexpected daemon request: ${type}`)
|
||||
},
|
||||
disconnect: () => {},
|
||||
ensureConnected: async () => {},
|
||||
ensureConnectedWithin: async () => {},
|
||||
getDaemonIdentity: () => null,
|
||||
onEvent: () => () => {}
|
||||
}
|
||||
// The production adoption flow that teaches the adapter about
|
||||
// daemon-surviving sessions (and makes hasPty answer for them).
|
||||
await adapter.reconcileOnStartup(new Set([WORKTREE_ID]))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetAgentProcessInspectionQueueForTests()
|
||||
resetAgentCompletionCoordinatorIdentitiesForTest()
|
||||
resetProcessTableSnapshotForTests()
|
||||
adapter?.dispose()
|
||||
vi.useRealTimers()
|
||||
await host?.dispose()
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', platformDescriptor)
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function createCoordinator(paneKey: string, dispatchCompletion: ReturnType<typeof vi.fn>) {
|
||||
return createAgentCompletionCoordinator({
|
||||
paneKey,
|
||||
getPtyId: () => SESSION_ID,
|
||||
getSettings: () => null,
|
||||
// Real production adapter chain for a daemon-hosted pane: the renderer's
|
||||
// window.api.pty.inspectProcess lands in the pty IPC handler, which
|
||||
// calls exactly this.
|
||||
inspectProcess: async (_settings, ptyId) =>
|
||||
(await inspectPtyProviderProcessForRenderer(
|
||||
adapter,
|
||||
ptyId
|
||||
)) as RuntimeTerminalProcessInspection,
|
||||
dispatchCompletion,
|
||||
isLive: () => true
|
||||
})
|
||||
}
|
||||
|
||||
it('does not conclude an agent finished when the daemon reads degrade', async () => {
|
||||
const dispatchCompletion = vi.fn()
|
||||
const coordinator = createCoordinator('tab-1:leaf-1', dispatchCompletion)
|
||||
|
||||
coordinator.startProcessTracking()
|
||||
coordinator.observeTitle('Codex working')
|
||||
|
||||
// Healthy polls: the first poll's identity scan proves codex is live in
|
||||
// this pane; the next poll surfaces it through the wrapper-hold.
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(dispatchCompletion).not.toHaveBeenCalled()
|
||||
|
||||
// Distress: the cached table stops containing the pane's subtree, and
|
||||
// node-pty's native title read degrades to the spawn file (the shell) —
|
||||
// its documented fallback. Codex is still running; nothing observed it.
|
||||
psBehavior = 'table-missing-pane'
|
||||
mockProc.process = 'zsh'
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
|
||||
// Full distress: the ps fork now fails outright.
|
||||
psBehavior = 'ps-failure'
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
|
||||
expect(dispatchCompletion).not.toHaveBeenCalled()
|
||||
|
||||
coordinator.dispose()
|
||||
})
|
||||
|
||||
it('still confirms a real exit once the table answers again', async () => {
|
||||
const dispatchCompletion = vi.fn()
|
||||
const coordinator = createCoordinator('tab-2:leaf-1', dispatchCompletion)
|
||||
|
||||
coordinator.startProcessTracking()
|
||||
coordinator.observeTitle('Codex working')
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
|
||||
psBehavior = 'ps-failure'
|
||||
mockProc.process = 'zsh'
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(dispatchCompletion).not.toHaveBeenCalled()
|
||||
|
||||
// Recovery, and the agent has genuinely exited: the shell holds the
|
||||
// foreground again and the table positively shows no descendants.
|
||||
psBehavior = 'agent-exited'
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchCompletion).toHaveBeenCalledWith('codex', {
|
||||
source: 'process-exit',
|
||||
quietedHookDone: false,
|
||||
terminalIdleConfirmed: true
|
||||
})
|
||||
|
||||
coordinator.dispose()
|
||||
})
|
||||
|
||||
it('publishes unchanged legacy fields beside the evidence', async () => {
|
||||
// Warm the identity: a healthy scan resolves the node wrapper to codex.
|
||||
await inspectPtyProviderProcessForRenderer(adapter, SESSION_ID)
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
const healthy = await inspectPtyProviderProcessForRenderer(adapter, SESSION_ID)
|
||||
expect(healthy).toEqual({
|
||||
foregroundProcess: 'codex',
|
||||
hasChildProcesses: true,
|
||||
processEvidence: {
|
||||
foreground: { verdict: 'observed', processName: 'codex' },
|
||||
children: { verdict: 'live' }
|
||||
}
|
||||
})
|
||||
|
||||
// Degraded scan with a shell-fallback title: the legacy fields keep the
|
||||
// exact pre-evidence collapse; the evidence says "could not ask".
|
||||
psBehavior = 'ps-failure'
|
||||
mockProc.process = 'zsh'
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
const degraded = await inspectPtyProviderProcessForRenderer(adapter, SESSION_ID)
|
||||
expect(degraded).toEqual({
|
||||
foregroundProcess: 'zsh',
|
||||
hasChildProcesses: false,
|
||||
processEvidence: {
|
||||
foreground: { verdict: 'unverifiable', reason: expect.any(String) },
|
||||
children: { verdict: 'unverifiable', reason: expect.any(String) }
|
||||
}
|
||||
})
|
||||
|
||||
// A real exit is a real observation: legacy and evidence agree. The first
|
||||
// read schedules the corroborating scan; the second observes its verdict.
|
||||
psBehavior = 'agent-exited'
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await inspectPtyProviderProcessForRenderer(adapter, SESSION_ID)
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
const exited = await inspectPtyProviderProcessForRenderer(adapter, SESSION_ID)
|
||||
expect(exited).toEqual({
|
||||
foregroundProcess: 'zsh',
|
||||
hasChildProcesses: false,
|
||||
processEvidence: {
|
||||
foreground: { verdict: 'observed', processName: 'zsh' },
|
||||
children: { verdict: 'exited' }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user