mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
* fix(relay): observe Windows PTY child processes instead of answering false `processHasChildren` returned a hardcoded `false` on Windows, and a hardcoded negative is indistinguishable from a measurement. Every close guard reads it as "nothing is running in this pane", so an SSH-to-Windows tab running a build closed with no prompt. Measured on a real Windows SSH host: a live `PING.EXE` under the pane's `cmd.exe` still reported `hasChildProcesses: false`, while the identical harness on Linux reported `sleep` / `true`. Windows has no `ps`, but it does have a process table, and the pane walk over it already existed for the foreground reader. The answer now comes from `queryWindowsPaneProcessInventory`; a table it could not read reports `unverifiable` rather than a fabricated negative. `hasChildProcesses` is a boolean, which cannot hold the third answer, and it is read both as "busy, do not close" and as "the agent took the PTY, safe to type into" — so no single mapping of `unverifiable` is safe for both. The verdict moves to a new optional `childProcessEvidence` member that the close paths read; the boolean keeps its exact meaning for every client that cannot. Cost: `pty.inspectProcess` is the polled path and a relay host has no `@vscode/windows-process-tree`, so its table read falls back to the 1.36s CIM scan. Polling that would reinstate the fork storm the shared table exists to prevent, so only a caller whose answer decides something asks for the scan. * fix(runtime): forward scanChildProcesses through the environment inspection RPC `guardRunningTerminalClose` asks the host to pay for a real child-process read, but the environment path dropped the option before it reached the wire: the renderer sent only `expectedIncarnationId`, and the RPC schema — the shared `TerminalHandle` — silently stripped anything else. A host routing that pane through an SSH relay then declined to scan and answered `unverifiable`, which `inspectionReportsRunningWork` reads as running work. The result was a close confirmation on an idle pane, which is the nag this PR exists to avoid. Forwarded through all four layers: renderer payload, RPC schema, method handler, and the runtime/controller signatures. The schema is a dedicated extension rather than a field on `TerminalHandle`, so `clearBuffer`/`agentStatus`/`isRunningAgent` keep refusing an option they have no use for. The silent strip is not itself the defect — it is what makes a new optional member safe to send to an old host, per docs/reference/remote-wire-compatibility.md. The defect was the schema and its caller drifting inside one version, so the tests pin the registered method rather than the schema alone: pointing it back at `TerminalHandle` compiles, parses, and drops the option. Found by review on #18591. * fix(terminal): teach the shared running-work probe the third child-process answer Rebasing onto main landed `probePtyRunningWork`, which is a better home for this than the close guard: it already speaks `live` / `unverifiable` / `exited`, and it exists so the tab-close and window-close guards cannot drift. The child-process verdict belongs there, not in a parallel predicate beside it. So the mapping moves into the probe and `inspectionReportsRunningWork` is deleted rather than kept alongside. The probe now asks for the scan, and a host that could not observe the pane reports `unverifiable` instead of collapsing onto `exited` -- which is what `hasChildProcesses: false` meant on every Windows relay. The pane-close path is routed through the same probe for the same reason; it was the third caller asking this question through a direct inspect of its own.
129 lines
4.1 KiB
TypeScript
129 lines
4.1 KiB
TypeScript
import type { RemoteForegroundEvidence } from './foreground-process-evidence'
|
|
|
|
/**
|
|
* What the execution host observed about processes running under a PTY's shell.
|
|
*
|
|
* Separate from `hasChildProcesses` because a boolean cannot hold the third answer. The host that
|
|
* could not read its own process table and the host that read it and found nothing both had to
|
|
* spell themselves `false`, and every close guard reads `false` as "nothing is running here".
|
|
* Windows relays spelled it `false` unconditionally.
|
|
*/
|
|
export type PtyChildProcessVerdict = 'children' | 'no-children' | 'unverifiable'
|
|
|
|
/** Reasons the renderer could not observe the execution host. */
|
|
export type ClientOnlyUnverifiableReason =
|
|
| 'transport_loss'
|
|
| 'timeout'
|
|
| 'terminal_gone'
|
|
| 'old_host'
|
|
|
|
/**
|
|
* A renderer-only verdict. Host identity fields are explicitly forbidden so a
|
|
* transport failure cannot be promoted into a synthetic host observation.
|
|
*/
|
|
export type ClientOnlyUnverifiableInspection = {
|
|
foregroundProcess: null
|
|
hasChildProcesses: false
|
|
verdict: 'unverifiable'
|
|
reason: string
|
|
foregroundProcessEvidence?: never
|
|
childProcessEvidence?: never
|
|
authorityGeneration?: never
|
|
observationEpoch?: never
|
|
capturedAgeMs?: never
|
|
ptyId?: never
|
|
ptyIncarnationId?: never
|
|
}
|
|
|
|
/** Compatibility-shaped host/local inspection returned by the inspect RPC. */
|
|
export type HostProcessInspection = {
|
|
foregroundProcess: string | null
|
|
hasChildProcesses: boolean
|
|
/** Optional on old hosts; the renderer treats an omitted field as old-host unverifiable. */
|
|
foregroundProcessEvidence?: RemoteForegroundEvidence
|
|
/** Absent on hosts that predate the member, and on answers the host did not pay to observe. */
|
|
childProcessEvidence?: PtyChildProcessVerdict
|
|
verdict?: never
|
|
reason?: never
|
|
}
|
|
|
|
export type TerminalProcessInspection = HostProcessInspection | ClientOnlyUnverifiableInspection
|
|
|
|
export function clientOnlyUnverifiableInspection(reason: string): ClientOnlyUnverifiableInspection {
|
|
return {
|
|
foregroundProcess: null,
|
|
hasChildProcesses: false,
|
|
verdict: 'unverifiable',
|
|
reason
|
|
}
|
|
}
|
|
|
|
export function isClientOnlyUnverifiableInspection(
|
|
value: unknown
|
|
): value is ClientOnlyUnverifiableInspection {
|
|
return (
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
(value as { verdict?: unknown }).verdict === 'unverifiable'
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Classify only failures that mean the execution host could not be observed.
|
|
* Unexpected programming errors deliberately return null and remain throws.
|
|
*/
|
|
export function classifyTerminalProcessInspectionFailure(
|
|
error: unknown
|
|
): ClientOnlyUnverifiableReason | null {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
const code =
|
|
error && typeof error === 'object' && 'code' in error
|
|
? String((error as { code?: unknown }).code)
|
|
: ''
|
|
if (
|
|
code === 'terminal_handle_stale' ||
|
|
code === 'terminal_exited' ||
|
|
code === 'terminal_gone' ||
|
|
code === 'no_connected_pty' ||
|
|
message.includes('terminal_handle_stale') ||
|
|
message.includes('terminal_exited') ||
|
|
message.includes('terminal_gone') ||
|
|
message.includes('no_connected_pty') ||
|
|
/PTY\s+"[^"]+"\s+not found/i.test(message)
|
|
) {
|
|
return 'terminal_gone'
|
|
}
|
|
if (
|
|
code === 'SSH_MUX_REQUEST_TIMEOUT' ||
|
|
code === 'request_timeout' ||
|
|
code === 'rpc_timeout' ||
|
|
code === 'deadline_exceeded' ||
|
|
/\b(?:timed?\s*out|timeout)\b/i.test(message)
|
|
) {
|
|
return 'timeout'
|
|
}
|
|
if (
|
|
code === 'method_not_found' ||
|
|
code === 'rpc_method_not_found' ||
|
|
code === 'unsupported_method' ||
|
|
/(?:method|inspectProcess).*not found|unsupported.*(?:method|inspect)/i.test(message)
|
|
) {
|
|
return 'old_host'
|
|
}
|
|
if (
|
|
code === 'CONNECTION_LOST' ||
|
|
code === 'DISPOSED' ||
|
|
code === 'socket_closed' ||
|
|
code === 'connection_closed' ||
|
|
code === 'transport_closed' ||
|
|
code === 'runtime_unavailable' ||
|
|
code === 'remote_runtime_unavailable' ||
|
|
/(?:connection\s+(?:lost|closed)|socket\s+(?:closed|lost)|terminal\s+closed|runtime\s+unavailable|reconnecting|multiplexer\s+disposed|request\s+closed)/i.test(
|
|
message
|
|
)
|
|
) {
|
|
return 'transport_loss'
|
|
}
|
|
return null
|
|
}
|