mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
wip(daemon): terminal-host inspectProcess coercion — in-progress evidence plumbing
Snapshot of a stalled lane so the work survives. Not reviewed, not complete.
This commit is contained in:
@@ -37,6 +37,10 @@ function createMockSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 55555,
|
||||
getForegroundProcess: () => null,
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write() {},
|
||||
resize() {},
|
||||
kill() {},
|
||||
|
||||
@@ -29,6 +29,10 @@ function createMockSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 55555,
|
||||
getForegroundProcess: () => null,
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write() {},
|
||||
resize() {},
|
||||
kill() {},
|
||||
|
||||
@@ -80,6 +80,10 @@ function createMockSubprocess(): SubprocessHandle & { exit(code: number): void }
|
||||
return {
|
||||
pid: 9345,
|
||||
getForegroundProcess: () => null,
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
|
||||
@@ -95,6 +95,10 @@ function createMockSubprocess() {
|
||||
return {
|
||||
pid: 99999,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
|
||||
|
||||
@@ -19,6 +19,10 @@ function createMockSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 55555,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
confirmForegroundProcess: vi.fn(async () => null),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
|
||||
@@ -39,6 +39,10 @@ export function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle
|
||||
// Why: getCwd falls back to OS pid lookup; an implausibly-high fake pid can't collide with a real process' cwd.
|
||||
pid: 999_999_999,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
confirmShellForeground: vi.fn(async () => false),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ function createMockSubprocess(): SubprocessHandle & {
|
||||
return {
|
||||
pid: 999_999_999,
|
||||
getForegroundProcess: () => null,
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
|
||||
@@ -18,6 +18,10 @@ function createFixtureSubprocess(pid: number): FixtureSubprocess {
|
||||
return {
|
||||
pid,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
|
||||
@@ -22,6 +22,10 @@ function createMockSubprocess(): SubprocessHandle & { emitData: (data: string) =
|
||||
return {
|
||||
pid: 4242,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => setTimeout(() => onExit?.(0), 1)),
|
||||
|
||||
@@ -44,6 +44,10 @@ function createMockSubprocess(): SubprocessHandle & {
|
||||
return {
|
||||
pid: 4242,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => setTimeout(() => onExit?.(0), 1)),
|
||||
|
||||
@@ -12,6 +12,10 @@ function fixtureSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: process.pid,
|
||||
getForegroundProcess: () => null,
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: () => {},
|
||||
resize: () => {},
|
||||
kill: () => queueMicrotask(() => onExit?.(0)),
|
||||
|
||||
@@ -12,6 +12,10 @@ function createMockSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 55555,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => onExit?.(0)),
|
||||
|
||||
@@ -14,6 +14,10 @@ function createMockSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 55_555,
|
||||
getForegroundProcess: () => null,
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => onExit?.(0)),
|
||||
|
||||
@@ -22,6 +22,10 @@ function createMockSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 44444,
|
||||
getForegroundProcess: () => null,
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write() {},
|
||||
resize() {},
|
||||
kill: exit,
|
||||
|
||||
@@ -27,6 +27,10 @@ function createMockSubprocess(): SubprocessHandle & {
|
||||
return {
|
||||
pid: 55555,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
confirmForegroundProcess: confirmForegroundProcessMock,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
|
||||
@@ -42,6 +42,10 @@ describe('daemon session scrollback window', () => {
|
||||
return {
|
||||
pid: 4242,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => {
|
||||
|
||||
@@ -24,6 +24,10 @@ function createMockSubprocess(): SubprocessHandle & { emitData: (data: string) =
|
||||
return {
|
||||
pid: 4243,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => setTimeout(() => onExit?.(0), 1)),
|
||||
|
||||
@@ -34,6 +34,10 @@ function createMockSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 88888,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
|
||||
|
||||
@@ -33,6 +33,10 @@ function createMockSubprocess(): MockSubprocess {
|
||||
return {
|
||||
pid: 55_555,
|
||||
getForegroundProcess: () => null,
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => onExit?.(0)),
|
||||
|
||||
@@ -19,6 +19,10 @@ function fixtureSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 1234,
|
||||
getForegroundProcess: () => null,
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: () => {},
|
||||
resize: () => {},
|
||||
kill: () => {
|
||||
|
||||
@@ -22,6 +22,10 @@ function createMockSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 55555,
|
||||
getForegroundProcess: () => null,
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write() {},
|
||||
resize() {},
|
||||
kill() {},
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
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. */
|
||||
export type ForegroundScanSettlement = { at: 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.
|
||||
*/
|
||||
export function isShellTitleCorroborated(
|
||||
settlement: ForegroundScanSettlement | null,
|
||||
now: number
|
||||
): boolean {
|
||||
return (
|
||||
settlement !== null &&
|
||||
settlement.available &&
|
||||
!settlement.sawAgent &&
|
||||
now - settlement.at <= SHELL_TITLE_SCAN_CORROBORATION_MAX_AGE_MS
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
const identityOlderThan = (ms: number): boolean =>
|
||||
state.cachedAgentForeground !== null &&
|
||||
Date.now() - state.cachedAgentForeground.refreshedAt > ms
|
||||
const retireStaleForegroundIdentity = ({ onlyWhenAged = false } = {}): void => {
|
||||
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 }) => {
|
||||
state.lastScanSettlement = {
|
||||
at: Date.now(),
|
||||
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(), 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,22 @@ 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,
|
||||
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 +44,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)
|
||||
) {
|
||||
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
|
||||
@@ -294,16 +195,16 @@ 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
|
||||
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) => {
|
||||
|
||||
@@ -12,6 +12,10 @@ function createMockSubprocess(): SubprocessHandle & {
|
||||
return {
|
||||
pid: 42,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
|
||||
|
||||
@@ -50,6 +50,10 @@ function createFakeSubprocess(foregroundProcess: string) {
|
||||
forceKilled: false,
|
||||
pid: 4242,
|
||||
getForegroundProcess: () => foregroundProcess,
|
||||
observeForegroundProcess: () => ({
|
||||
processName: foregroundProcess,
|
||||
evidence: { verdict: 'observed' as const, processName: foregroundProcess }
|
||||
}),
|
||||
write: (data: string) => void written.push(data),
|
||||
resize: () => {},
|
||||
kill: () => {},
|
||||
|
||||
@@ -66,6 +66,10 @@ function makeSubprocess(): SubprocessHandle & { emit: (data: string) => void } {
|
||||
return {
|
||||
pid: 4242,
|
||||
getForegroundProcess: () => 'bench',
|
||||
observeForegroundProcess: () => ({
|
||||
processName: 'bench',
|
||||
evidence: { verdict: 'observed' as const, processName: 'bench' }
|
||||
}),
|
||||
write: () => {},
|
||||
resize: () => {},
|
||||
kill: () => {},
|
||||
|
||||
@@ -10,6 +10,10 @@ function createMockSubprocess() {
|
||||
return {
|
||||
pid: 12345,
|
||||
getForegroundProcess: (): string | null => null,
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write(_data: string) {},
|
||||
resize(_cols: number, _rows: number) {},
|
||||
kill() {},
|
||||
|
||||
@@ -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. Required,
|
||||
* not optional: an omitting handle would silently recreate the coercion of
|
||||
* degraded reads into exit evidence. */
|
||||
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. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Session } from './session'
|
||||
import type { ForegroundProcessObservation } from './session-subprocess-handle'
|
||||
import { PRODUCER_PAUSE_FAILSAFE_MS } from './session-producer-pause'
|
||||
|
||||
function createRecordingSubprocess() {
|
||||
@@ -30,6 +31,10 @@ function createRecordingSubprocess() {
|
||||
getForegroundProcess(): string | null {
|
||||
return this.foregroundProcess
|
||||
},
|
||||
observeForegroundProcess(): ForegroundProcessObservation {
|
||||
const processName = this.foregroundProcess
|
||||
return { processName, evidence: { verdict: 'observed', processName } }
|
||||
},
|
||||
write(data: string) {
|
||||
written.push(data)
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Session } from './session'
|
||||
import type { ForegroundProcessObservation } from './session-subprocess-handle'
|
||||
import { SESSION_FORCE_KILL_RETRY_MS } from './session-termination-controller'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import type { SessionState, ShellReadyState } from './types'
|
||||
@@ -41,6 +42,10 @@ function createMockSubprocess() {
|
||||
getForegroundProcess(): string | null {
|
||||
return this.foregroundProcess
|
||||
},
|
||||
observeForegroundProcess(): ForegroundProcessObservation {
|
||||
const processName = this.foregroundProcess
|
||||
return { processName, evidence: { verdict: 'observed', processName } }
|
||||
},
|
||||
confirmShellForeground: vi.fn(async () => true),
|
||||
write(data: string) {
|
||||
written.push(data)
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
SessionTerminationController,
|
||||
IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS
|
||||
} from './session-termination-controller'
|
||||
import type { SubprocessHandle } 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 +252,8 @@ export class Session {
|
||||
return this.output.getCwd()
|
||||
}
|
||||
|
||||
getForegroundProcess(): string | null {
|
||||
return this.subprocess.getForegroundProcess()
|
||||
observeForegroundProcess(): ForegroundProcessObservation {
|
||||
return this.subprocess.observeForegroundProcess()
|
||||
}
|
||||
|
||||
async confirmForegroundProcess(): Promise<string | null> {
|
||||
|
||||
@@ -25,6 +25,10 @@ function createMockSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 55555,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
|
||||
|
||||
@@ -11,6 +11,10 @@ function createClaimedSubprocess(): SubprocessHandle & {
|
||||
return {
|
||||
pid: 99_999,
|
||||
getForegroundProcess: () => 'codex',
|
||||
observeForegroundProcess: () => ({
|
||||
processName: 'codex',
|
||||
evidence: { verdict: 'observed' as const, processName: 'codex' }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
|
||||
@@ -14,6 +14,10 @@ describe('TerminalHost attach-only sessions', () => {
|
||||
return {
|
||||
pid: 99999,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => onExit?.(0)),
|
||||
|
||||
@@ -17,6 +17,10 @@ function mockSubprocess(
|
||||
return {
|
||||
pid: 4242,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
...(options.confirmShellForeground
|
||||
? { confirmShellForeground: options.confirmShellForeground }
|
||||
: {}),
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -18,6 +18,10 @@ function createSubprocess(shellPath: string): TestSubprocess {
|
||||
pid: 99_999,
|
||||
shellPath,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => onExit?.(0)),
|
||||
|
||||
@@ -15,6 +15,10 @@ function createSubprocess(): SubprocessHandle & { exit: () => void } {
|
||||
pid: 4242,
|
||||
shellPath: '/opt/homebrew/bin/zsh',
|
||||
getForegroundProcess: () => 'zsh',
|
||||
observeForegroundProcess: () => ({
|
||||
processName: 'zsh',
|
||||
evidence: { verdict: 'observed' as const, processName: 'zsh' }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
|
||||
@@ -28,6 +28,10 @@ function createMockSubprocess(): SubprocessHandle & {
|
||||
return {
|
||||
pid: 99999,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => {
|
||||
|
||||
@@ -6,6 +6,10 @@ function mockSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 1,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
|
||||
@@ -15,6 +15,10 @@ function createSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 123,
|
||||
getForegroundProcess: () => null,
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => onExit?.(0)),
|
||||
|
||||
@@ -25,6 +25,10 @@ function createMockSubprocess(
|
||||
: {}),
|
||||
...(options.shellPath ? { shellPath: options.shellPath } : {}),
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: null,
|
||||
evidence: { verdict: 'observed' as const, processName: null }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -33,6 +33,10 @@ function createControlledSubprocess(): ControlledSubprocess {
|
||||
return {
|
||||
pid: 41_000,
|
||||
getForegroundProcess: () => (exited ? null : 'claude'),
|
||||
observeForegroundProcess: () => ({
|
||||
processName: exited ? null : 'claude',
|
||||
evidence: { verdict: 'observed' as const, processName: exited ? null : 'claude' }
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: () => exit(0),
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user