diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index fad227b81..7ec61073b 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1390,6 +1390,7 @@ "headed desktop remote-server pairing", "headless remote-server parity", "daemon and relay reconnect", + "remote completion classification across disconnect and reconnect", "terminal exit retirement and restart restore", "mixed-version fallback" ], @@ -1415,21 +1416,36 @@ "wsl", "remote-runtime" ], - "coverageNotes": "Deterministic macOS tests cover controller claims, daemon and SSH/relay operation replay, mixed-version selection, runtime ownership, exact provisional handoff, durable terminal retirement, two independent viewer mirrors, and guarded adoption of legacy live PTYs. The adoption harness models v1.4.150 agent/setup/shell tabs, current-generation restart and reconnect, exact handle/incarnation/worktree/host checks, topology CAS, competing clients, split-pane/group restoration, WSL ownership, and SSH owner rejection. The secondary parity repro runs independent clients against one headless remote Orca runtime over encrypted pairing and a real daemon-backed PTY. A headed Orca desktop server paired to a separate client is the primary live user topology and remains uncollected for this issue. SSH coverage is provider/relay contract and fault-injection coverage only; it does not substitute for paired-server coverage. Live Windows, WSL, and SSH hosts remain gaps.", + "coverageNotes": "Deterministic macOS tests cover controller claims, daemon and SSH/relay operation replay, mixed-version selection, runtime ownership, exact provisional handoff, durable terminal retirement, two independent viewer mirrors, guarded adoption of legacy live PTYs, and completion classification when either the outer remote transport or authoritative host/provider process inspection becomes unreachable. The adoption harness models v1.4.150 agent/setup/shell tabs, current-generation restart and reconnect, exact handle/incarnation/worktree/host checks, topology CAS, competing clients, split-pane/group restoration, WSL ownership, and SSH owner rejection. The secondary parity repro runs independent clients against one headless remote Orca runtime over encrypted pairing and a real daemon-backed PTY. The primary headed topology was exercised with an isolated macOS Orca desktop server, a separate paired Edge client, a real Codex process, injected WebSocket loss, reconnect, and explicit stop; host inspection and visible client state agreed throughout. SSH coverage is provider/relay contract and fault-injection coverage only; it does not substitute for paired-server coverage. Live Windows, Linux, WSL, and SSH hosts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8878", + "https://github.com/stablyai/orca/issues/9151", "https://github.com/stablyai/orca/issues/9352", "https://github.com/stablyai/orca/pull/9687" ], - "invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. A live orphan may be adopted only when the controller proves its exact handle and incarnation, its worktree and host owner match, no competing visual owner exists, and a host topology CAS wins. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.", - "oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, old daemon/relay capabilities, reused handles, stale incarnations, owner mismatch, and topology revision conflict; assert safe adoption or explicit failure without a second spawn or wrong-process attachment. Restore legacy split panes and groups beside a newer host-owned tab, preserving output, input, resize, titles, tab/leaf identity, active group, and multi-client convergence. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, and an exact provisional handoff is consumed even when exit wins before the next snapshot.", + "invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. A live orphan may be adopted only when the controller proves its exact handle and incarnation, its worktree and host owner match, no competing visual owner exists, and a host topology CAS wins. A viewer may classify completion only from successful host/provider inspection or explicit lifecycle evidence; transport, handle, or provider unavailability remains unknown and breaks any consecutive-idle proof. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.", + "oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, old daemon/relay capabilities, reused handles, stale incarnations, owner mismatch, and topology revision conflict; assert safe adoption or explicit failure without a second spawn or wrong-process attachment. Restore legacy split panes and groups beside a newer host-owned tab, preserving output, input, resize, titles, tab/leaf identity, active group, and multi-client convergence. For completion, drive a known running agent through outer transport loss, authoritative provider rejection, reconnect, explicit stop, real exit status, and successful hook completion; assert unavailable evidence never dispatches completion and two fresh authoritative idle samples are required after the gap. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, and an exact provisional handoff is consumed even when exit wins before the next snapshot.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts", "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts", "pnpm test:repro:remote-agent-session", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1" + "Manual headed paired-server journey: isolated Orca desktop host + separate paired web client + real Codex process + 20-second WebSocket fault + reconnect + explicit stop", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/lib/codex-session-restart.test.ts" ], "testFiles": [ + "src/main/providers/pty-process-inspection.test.ts", + "src/main/daemon/terminal-host.test.ts", + "src/main/daemon/daemon-pty-router.test.ts", + "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "src/relay/pty-handler.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "tests/e2e/remote-agent-completion-authority.unit.test.ts", + "src/renderer/src/runtime/runtime-terminal-inspection.test.ts", + "src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "src/renderer/src/lib/codex-session-restart.test.ts", "src/shared/claimed-agent-pty-owner.test.ts", "src/main/daemon/daemon-pty-adapter.test.ts", "src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts", @@ -1448,6 +1464,62 @@ "src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts" ], "assertionRefs": [ + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "completion-sensitive process inspection preserves authoritative host/provider failures" + ] + }, + { + "file": "src/main/providers/pty-process-inspection.test.ts", + "assertions": [ + "dedicated provider inspection preserves failures and rejects missing PTYs instead of returning idle evidence" + ] + }, + { + "file": "src/main/daemon/daemon-pty-router.test.ts", + "assertions": [ + "completion inspection rejects an unmapped session instead of borrowing the current daemon" + ] + }, + { + "file": "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "assertions": [ + "completion inspection rejects an unmapped session instead of borrowing the local fallback" + ] + }, + { + "file": "src/relay/pty-handler.test.ts", + "assertions": [ + "strict relay inspection rejects a missing PTY" + ] + }, + { + "file": "tests/e2e/remote-agent-completion-authority.unit.test.ts", + "assertions": [ + "transport loss remains unknown through reconnect and cannot dispatch completion", + "returned unavailability or a thrown transport failure interrupts consecutive-idle proof and requires two fresh authoritative idle samples", + "explicit stop, real exit status, and genuine successful completion remain distinct" + ] + }, + { + "file": "src/renderer/src/runtime/runtime-terminal-inspection.test.ts", + "assertions": [ + "direct SSH terminals use strict main-process inspection rather than lax split IPC evidence" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "completion polling uses the atomic process-inspection boundary without regressing established lifecycle behavior" + ] + }, + { + "file": "src/renderer/src/lib/codex-session-restart.test.ts", + "assertions": [ + "one unreachable pane cannot suppress restart notices for another authoritatively confirmed Codex pane" + ] + }, { "file": "src/shared/claimed-agent-pty-owner.test.ts", "assertions": [ @@ -1532,6 +1604,24 @@ } ], "evidenceRuns": [ + { + "date": "2026-07-22", + "runner": "manual", + "platform": "macos", + "command": "Manual headed paired-server journey: isolated Orca desktop host + separate paired web client + real Codex process + 20-second WebSocket fault + reconnect + explicit stop", + "result": "passed", + "durationSeconds": 549, + "summary": "The primary user topology used an isolated headed Orca desktop as the owning server and a separate paired Edge client. Host inspection reported Codex alive before, during, and after a page-scoped WebSocket fault; the client showed no completion toast, reconnected to the same live Codex TUI, and explicit stop restored the shell prompt with no child process." + }, + { + "date": "2026-07-23", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/lib/codex-session-restart.test.ts", + "result": "passed", + "durationSeconds": 11.96, + "summary": "Eleven focused files and 870 tests passed on the current-main candidate. The cross-boundary harness fails with the implementation reverted by dispatching process-exit from unavailable remote evidence. Direct SSH uses strict main-process inspection, daemon and relay inspection reject missing or unmapped sessions, the terminal lifecycle suite uses the atomic inspection boundary, and one stale pane cannot suppress restart notices for a separately confirmed Codex pane." + }, { "date": "2026-07-21", "runner": "local", @@ -1542,13 +1632,13 @@ "summary": "Ten focused files and 325 tests passed after the final review fixes, covering claim scope, mixed-version Pi/SSH fallback ordering, operation replay, terminal retirement, causal inventory fencing, exact concurrent handoff confirmation, daemon-generation integration, transport behavior, and remote host integration." }, { - "date": "2026-07-21", + "date": "2026-07-22", "runner": "local", "platform": "macos", "command": "pnpm test:repro:remote-agent-session", "result": "passed", - "durationSeconds": 48.47, - "summary": "The secondary build-backed headless parity harness passed over encrypted WebSocket pairing with independent clients, proving one spawn, retry adoption, durable exit retirement, stale-publication rejection, and no restart resurrection." + "durationSeconds": 48.63, + "summary": "The secondary build-backed headless parity harness passed post-rebase on main@72a2d7bc7 over encrypted WebSocket pairing with independent clients, proving one spawn, retry adoption, durable exit retirement, stale-publication rejection, and no restart resurrection." }, { "date": "2026-07-22", @@ -1588,22 +1678,23 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "The exact cross-boundary retirement oracle is red on pre-#9687 commit 2a32c5c9a and PR #9053 head d3a1d3047 because the pinned persisted surface remains in the host publication, and green on main@4fce2de49. Duplicate-resume red evidence remains encoded in lower-layer tests; saved CI artifacts are still needed." + "evidence": "Issue #9151 has local red/green evidence: current main dispatches process-exit from a stale remote handle, while the fix preserves unknown liveness and requires fresh consecutive idle evidence after reconnect. The exact retirement oracle is red on pre-#9687 commit 2a32c5c9a and PR #9053 head d3a1d3047 because the pinned persisted surface remains in the host publication, and green on main@4fce2de49. Duplicate-resume red evidence remains encoded in lower-layer tests; saved CI artifacts are still needed." }, "performanceBudget": { "required": true, - "evidence": "Agent-session reconciliation runs only at explicit claim admission, dedupes concurrent provider listing, and adds no polling or renderer output work. Create-operation ledgers are capped globally and per client, expire after 24 hours, and reject rather than evict live replay fences. Capability caches are bounded or connection-scoped, and exact handoffs are consumed by the next authoritative snapshot." + "evidence": "Agent-session reconciliation runs only at explicit claim admission, dedupes concurrent provider listing, and adds no polling or renderer output work. Completion inspection reuses the coordinator's per-pane in-flight guard, global concurrency/rate queue, and existing error backoff; the strict daemon path reduces two foreground RPCs to one. Create-operation ledgers are capped globally and per client, expire after 24 hours, and reject rather than evict live replay fences. Capability caches are bounded or connection-scoped, and exact handoffs are consumed by the next authoritative snapshot." }, "promotionCriteria": [ "Run the focused gate and remote-server repro for at least 100 consecutive passes or 14 days across required CI platforms.", "Attach saved red/green evidence for duplicate remote resume and exit-before-snapshot retirement.", - "Add the primary live journey with a headed Orca desktop server and a separate paired client; use a physical host when OS, ConPTY, update, sleep, firewall, or window lifecycle is causal.", + "Automate the headed Orca desktop-server journey with a separate paired client; add a physical host only when OS, ConPTY, update, sleep, firewall, or window lifecycle is causal.", "Add live SSH/WSL provider evidence before claiming full provider coverage; Docker SSH proves only the SSH provider/relay path." ], "knownGaps": [ - "The primary live topology—a headed Orca desktop server with an isolated profile paired to a separate persistent client—has not been run for this issue. The causal boundary is host membership rather than OS/window/ConPTY/update/sleep behavior, so a physical headed host was not required for the current disposition.", + "The primary headed macOS desktop-server journey is collected but not automated in CI; Windows and Linux window, ConPTY, update, sleep/wake, and firewall behavior remain uncollected.", + "Mixed-version pairings remain conservative only when the completion-aware client and strict-inspection host changes are both present; older peers retain their legacy classification behavior.", "The secondary headless parity harness runs on macOS with a local daemon-backed execution owner and independent short-lived encrypted RPC clients; two persistent viewer-store mirrors and reconnect ordering are joined deterministically in the cross-boundary unit test rather than mounted live.", - "SSH and relay failure ordering is deterministic provider-contract coverage, not a live SSH-host journey or paired-Orca-server proof; WSL has no provider-specific run.", + "SSH and relay failure ordering is deterministic provider-contract coverage, not a live SSH-host journey or paired-Orca-server proof; WSL has no provider-specific run, and Linux and Windows runs remain uncollected.", "Fresh-launch operation replay is memory-backed and intentionally does not survive runtime restart; a durable operation journal is a documented future extension.", "Automatic sleep checkpoints, verified nested-SSH execution namespaces, and multi-process profile coordination remain outside v1." ], diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 5e5e63e11..f50a2d4e0 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,11 +3,12 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(26) + expect(PROTOCOL_VERSION).toBe(27) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(24) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(25) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(26) }) }) diff --git a/src/main/daemon/daemon-foreground-process-protocol.ts b/src/main/daemon/daemon-foreground-process-protocol.ts index 01b050c58..c0d672ec1 100644 --- a/src/main/daemon/daemon-foreground-process-protocol.ts +++ b/src/main/daemon/daemon-foreground-process-protocol.ts @@ -9,3 +9,7 @@ export type GetForegroundProcessRequest = { export type ConfirmForegroundProcessRequest = Omit & { type: 'confirmForegroundProcess' } + +export type InspectProcessRequest = Omit & { + type: 'inspectProcess' +} diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index 9fb4784b6..e55726369 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -2,17 +2,19 @@ import { describe, expect, it } from 'vitest' import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './daemon-protocol-version' describe('daemon protocol version', () => { - it('ships claim and incarnation authority after startup-ingress generations', () => { - expect(PROTOCOL_VERSION).toBe(26) + it('ships strict completion inspection after claim authority', () => { + expect(PROTOCOL_VERSION).toBe(27) + expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27) expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 25 }, (_, index) => index + 1) + Array.from({ length: 26 }, (_, index) => index + 1) ) }) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index e8c88774f..8afcfdf3e 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,12 +1,13 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -export const PROTOCOL_VERSION = 26 +export const PROTOCOL_VERSION = 27 +export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27 export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25 export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 26 export const AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION = 26 export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22 export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index b31c9c6c5..a9754a1c4 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -10,6 +10,7 @@ import { supportsPtyStartupBarrier } from './shell-ready' import { CODEX_SHELL_READY_TIMEOUT_MS } from './session' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, @@ -822,6 +823,18 @@ export class DaemonPtyAdapter implements IPtyProvider { return foregroundProcess !== null && !isShellProcess(foregroundProcess) } + async inspectProcess( + id: string + ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) { + throw new Error('terminal_liveness_unavailable') + } + return this.client.request<{ + foregroundProcess: string | null + hasChildProcesses: boolean + }>('inspectProcess', { sessionId: id }) + } + async getForegroundProcess(id: string): Promise { try { const result = await this.client.request<{ foregroundProcess: string | null }>( diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index e003cbede..0c66cf38d 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -80,6 +80,7 @@ function createAdapter( acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(async () => false), getForegroundProcess: vi.fn(async () => null), + inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })), confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), serialize: vi.fn(async () => '{}'), revive: vi.fn(async () => {}), @@ -140,6 +141,15 @@ function createAdapter( } as unknown as AdapterMock } +it('rejects completion inspection when no daemon owns the session', async () => { + const router = new DaemonPtyRouter({ + current: createAdapter('current'), + legacy: [createAdapter('legacy')] + }) + + await expect(router.inspectProcess('unmapped-session')).rejects.toThrow('terminal_gone') +}) + describe('DaemonPtyRouter', () => { it('reports separate conservative resume and fresh-create boundaries', () => { const current = createAdapter( diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 4d1e3d723..e30021a6f 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -189,6 +189,12 @@ export class DaemonPtyRouter implements IPtyProvider { return this.adapterFor(id).getForegroundProcess(id) } + async inspectProcess( + id: string + ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + return this.adapterForInspection(id).inspectProcess(id) + } + async confirmForegroundProcess(id: string): Promise { return this.adapterFor(id).confirmForegroundProcess(id) } @@ -348,6 +354,17 @@ export class DaemonPtyRouter implements IPtyProvider { return this.sessionAdapters.get(sessionId) ?? this.current } + private adapterForInspection(sessionId: string): DaemonPtyAdapter { + const adapter = + this.sessionAdapters.get(sessionId) ?? + this.allAdapters().find((candidate) => candidate.hasPty(sessionId)) + if (!adapter) { + throw new Error('terminal_gone') + } + this.sessionAdapters.set(sessionId, adapter) + return adapter + } + private allAdapters(): DaemonPtyAdapter[] { return [this.current, ...this.legacy] } diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 814a27b26..95fb09715 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -909,6 +909,9 @@ export class DaemonServer { case 'getForegroundProcess': return { foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) } + case 'inspectProcess': + return this.host.inspectProcess(request.payload.sessionId) + case 'confirmForegroundProcess': return { foregroundProcess: await this.host.confirmForegroundProcess(request.payload.sessionId) diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index 87dc721db..a8211b361 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -113,6 +113,16 @@ function createDaemonAdapter( } as unknown as DaemonPtyAdapter & ProviderMock } +it('rejects completion inspection instead of borrowing the fallback provider', async () => { + const provider = new DegradedDaemonPtyProvider({ + current: createDaemonAdapter('daemon'), + legacy: [], + fallback: createProvider('fallback') + }) + + await expect(provider.inspectProcess('unmapped-session')).rejects.toThrow('terminal_gone') +}) + describe('DegradedDaemonPtyProvider', () => { it('only delegates owner-listing authority to the provider that owns the id', async () => { const current = createDaemonAdapter('daemon', ['daemon-session']) diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 0dd1577a6..e3bd38af6 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -1,14 +1,9 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shutdown' -import type { - IPtyProvider, - PtyBackgroundStreamEvent, - PtyDataEvent, - PtyProviderBufferSnapshot, - PtyProcessInfo, - PtySpawnOptions, - PtySpawnResult -} from '../providers/types' +import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' +import type { IPtyProvider, PtyBackgroundStreamEvent } from '../providers/types' +import type { PtyDataEvent, PtyProviderBufferSnapshot } from '../providers/types' +import type { PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from '../providers/types' export class DegradedDaemonPtyProvider implements IPtyProvider { readonly routesFreshSpawnsToLocalProvider = true @@ -158,7 +153,11 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { async getForegroundProcess(id: string): Promise { return this.providerFor(id).getForegroundProcess(id) } - + inspectProcess(id: string) { + return this.hasPty(id) + ? inspectPtyProviderProcess(this.providerFor(id), id) + : Promise.reject(new Error('terminal_gone')) + } async confirmForegroundProcess(id: string): Promise { return this.providerFor(id).confirmForegroundProcess?.(id) ?? null } diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index e7e83e31a..c1e9f09eb 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -84,6 +84,9 @@ describe('TerminalHost', () => { await host.dispose() }) + it('rejects missing strict inspection', () => + expect(() => host.inspectProcess('missing-session')).toThrow('not found')) + describe('createOrAttach', () => { it('creates a new session when none exists', async () => { const result = await host.createOrAttach({ diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 6be96d27d..ae348a385 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -16,6 +16,7 @@ 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' export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' export type { TerminalHostOptions } from './terminal-host-options' @@ -146,6 +147,17 @@ export class TerminalHost { return session.getForegroundProcess() } + inspectProcess(sessionId: string): { + foregroundProcess: string | null + hasChildProcesses: boolean + } { + const foregroundProcess = this.getAliveSession(sessionId).getForegroundProcess() + return { + foregroundProcess, + hasChildProcesses: foregroundProcess !== null && !isShellProcess(foregroundProcess) + } + } + async confirmForegroundProcess(sessionId: string): Promise { const session = this.sessions.get(sessionId) if (!session || !session.isAlive) { diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 082ae1e64..2ee1ebd95 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,11 +1,13 @@ import type { ConfirmForegroundProcessRequest, - GetForegroundProcessRequest + GetForegroundProcessRequest, + InspectProcessRequest } from './daemon-foreground-process-protocol' export type { ConfirmForegroundProcessRequest, - GetForegroundProcessRequest + GetForegroundProcessRequest, + InspectProcessRequest } from './daemon-foreground-process-protocol' // ─── Protocol Version ──────────────────────────────────────────────── @@ -24,6 +26,7 @@ export { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, CLEAN_DISCONNECT_PROTOCOL_VERSION, + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, @@ -302,6 +305,7 @@ export type DaemonRequest = | DetachRequest | GetCwdRequest | GetForegroundProcessRequest + | InspectProcessRequest | ConfirmForegroundProcessRequest | ClearScrollbackRequest | ShutdownRequest diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 931cf8f27..88000e699 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -58,6 +58,7 @@ import { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi- import { isPwshAvailable } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { SSH_SESSION_EXPIRED_ERROR, @@ -1586,6 +1587,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:hasPty') ipcMain.removeHandler('pty:hasChildProcesses') ipcMain.removeHandler('pty:getForegroundProcess') + ipcMain.removeHandler('pty:inspectProcess') ipcMain.removeHandler('pty:confirmForegroundProcess') ipcMain.removeHandler('pty:getCwd') ipcMain.removeHandler('pty:getSize') @@ -3752,6 +3754,7 @@ export function registerPtyHandlers( return null } }, + inspectProcess: async (ptyId) => inspectPtyProviderProcess(getProviderForPty(ptyId), ptyId), confirmForegroundProcess: async (ptyId) => { try { const provider = getProviderForPty(ptyId) @@ -5337,6 +5340,10 @@ export function registerPtyHandlers( } ) + ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) => + inspectPtyProviderProcess(getProviderForPty(args.id), args.id) + ) + ipcMain.handle( 'pty:confirmForegroundProcess', async (_event, args: { id: string }): Promise => { diff --git a/src/main/providers/pty-process-inspection.test.ts b/src/main/providers/pty-process-inspection.test.ts new file mode 100644 index 000000000..60ce53bc5 --- /dev/null +++ b/src/main/providers/pty-process-inspection.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' +import type { IPtyProvider } from './types' +import { inspectPtyProviderProcess } from './pty-process-inspection' + +describe('PTY provider process inspection', () => { + it('rejects a missing provider PTY instead of returning idle evidence', async () => { + const provider = { + hasPty: vi.fn(() => false), + getForegroundProcess: vi.fn().mockResolvedValue(null), + hasChildProcesses: vi.fn().mockResolvedValue(false) + } as unknown as IPtyProvider + + await expect(inspectPtyProviderProcess(provider, 'pty-missing')).rejects.toThrow( + 'terminal_gone' + ) + expect(provider.getForegroundProcess).not.toHaveBeenCalled() + }) + + it('preserves a completion-sensitive provider failure', async () => { + const failure = new Error('daemon unavailable') + const inspectProcess = vi.fn().mockRejectedValue(failure) + const provider = { inspectProcess } as unknown as IPtyProvider + + await expect(inspectPtyProviderProcess(provider, 'pty-1')).rejects.toBe(failure) + expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + }) + + it('falls back to the existing provider process APIs', async () => { + const getForegroundProcess = vi.fn().mockResolvedValue('codex') + const hasChildProcesses = vi.fn().mockResolvedValue(true) + const provider = { + getForegroundProcess, + hasChildProcesses + } as Pick as IPtyProvider + + await expect(inspectPtyProviderProcess(provider, 'pty-1')).resolves.toEqual({ + foregroundProcess: 'codex', + hasChildProcesses: true + }) + }) +}) diff --git a/src/main/providers/pty-process-inspection.ts b/src/main/providers/pty-process-inspection.ts new file mode 100644 index 000000000..68e174ff8 --- /dev/null +++ b/src/main/providers/pty-process-inspection.ts @@ -0,0 +1,26 @@ +import type { IPtyProvider } from './types' + +export type PtyProcessInspection = { + foregroundProcess: string | null + hasChildProcesses: boolean +} + +type CompletionSensitivePtyProvider = IPtyProvider & { + inspectProcess?: (id: string) => Promise +} + +export async function inspectPtyProviderProcess( + provider: IPtyProvider, + ptyId: string +): Promise { + if (provider.hasPty?.(ptyId) === false) { + throw new Error('terminal_gone') + } + const inspectProcess = (provider as CompletionSensitivePtyProvider).inspectProcess + if (inspectProcess) { + return inspectProcess.call(provider, ptyId) + } + const foregroundProcess = await provider.getForegroundProcess(ptyId) + const hasChildProcesses = await provider.hasChildProcesses(ptyId) + return { foregroundProcess, hasChildProcesses } +} diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 02611e79d..6dc0cb39a 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -281,6 +281,14 @@ export class SshPtyProvider implements IPtyProvider { return result as string | null } + async inspectProcess( + id: string + ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + return (await this.mux.request('pty.inspectProcess', { + id: this.toRelayPtyId(id) + })) as { foregroundProcess: string | null; hasChildProcesses: boolean } + } + async serialize(ids: string[]): Promise { const result = await this.mux.request('pty.serialize', { ids: ids.map((id) => this.toRelayPtyId(id)) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e9f6698e4..af1344d62 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -86,6 +86,8 @@ import { unregisterSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' +import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' +import type { IPtyProvider } from '../providers/types' import * as worktreePathComparison from '../ipc/worktree-path-comparison' import * as localWorktreeFilesystem from '../local-worktree-filesystem' import { @@ -1279,6 +1281,9 @@ function createRuntimeWithSshLease( async function createExplicitAgentStatusHarness(options: { getForegroundProcess: (ptyId: string) => Promise + inspectProcess?: ( + ptyId: string + ) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> confirmForegroundProcess?: (ptyId: string) => Promise title?: string }): Promise<{ @@ -1308,6 +1313,7 @@ async function createExplicitAgentStatusHarness(options: { write: () => true, kill: () => true, getForegroundProcess: options.getForegroundProcess, + inspectProcess: options.inspectProcess, confirmForegroundProcess: options.confirmForegroundProcess }) runtime.attachWindow(1) @@ -10368,6 +10374,23 @@ describe('OrcaRuntimeService', () => { expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1') }) + it('preserves provider failure during completion-sensitive process inspection', async () => { + const failure = new Error('daemon unavailable') + const providerInspectProcess = vi.fn().mockRejectedValue(failure) + const provider = { inspectProcess: providerInspectProcess } as unknown as IPtyProvider + const inspectProcess = vi.fn((ptyId: string) => inspectPtyProviderProcess(provider, ptyId)) + const getForegroundProcess = vi.fn(async () => null) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + inspectProcess + }) + + await expect(runtime.inspectTerminalProcess(handle)).rejects.toBe(failure) + expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(providerInspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(getForegroundProcess).not.toHaveBeenCalled() + }) + it('calls foreground confirmation with its controller receiver', async () => { const getForegroundProcess = vi.fn(async () => 'powershell.exe') const confirmForegroundProcess = vi.fn( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1a214ef44..05cc5770d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1427,6 +1427,9 @@ type RuntimePtyController = { markReversibleStops?(ptyIds: readonly string[]): () => void getCwd?(ptyId: string): Promise getForegroundProcess(ptyId: string): Promise + inspectProcess?( + ptyId: string + ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> confirmForegroundProcess?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise @@ -15723,13 +15726,15 @@ export class OrcaRuntimeService { async inspectTerminalProcess( terminalSelector: string ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { - const leaf = this.resolveLeafForHandle(terminalSelector) + const leaf = this.resolveLiveLeafForHandle(terminalSelector) if (!leaf?.ptyId || !this.ptyController) { - return { foregroundProcess: null, hasChildProcesses: false } + throw new Error('terminal_gone') + } + if (this.ptyController.inspectProcess) { + return this.ptyController.inspectProcess(leaf.ptyId) } const foregroundProcess = await this.ptyController.getForegroundProcess(leaf.ptyId) - const hasChildProcesses = - (await this.ptyController.hasChildProcesses?.(leaf.ptyId).catch(() => false)) ?? false + const hasChildProcesses = (await this.ptyController.hasChildProcesses?.(leaf.ptyId)) ?? false return { foregroundProcess, hasChildProcesses } } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b02f84749..be713ab04 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1373,6 +1373,9 @@ export type PreloadApi = { publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise + inspectProcess: ( + id: string + ) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> confirmForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> diff --git a/src/preload/index.ts b/src/preload/index.ts index 41d850a88..01e6c1879 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -981,6 +981,10 @@ const api = { /** Return the PTY foreground process basename when available (e.g. "codex"). */ getForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:getForegroundProcess', { id }), + inspectProcess: ( + id: string + ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> => + ipcRenderer.invoke('pty:inspectProcess', { id }), confirmForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 60f944456..3e3bbebd9 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -151,6 +151,7 @@ describe('PtyHandler', () => { expect(methods).toContain('pty.clearBuffer') expect(methods).toContain('pty.hasChildProcesses') expect(methods).toContain('pty.getForegroundProcess') + expect(methods).toContain('pty.inspectProcess') expect(methods).toContain('pty.listProcesses') expect(methods).toContain('pty.getDefaultShell') @@ -160,6 +161,12 @@ describe('PtyHandler', () => { expect(notifMethods).toContain('pty.ackData') }) + it('rejects strict process inspection for a missing relay PTY', async () => { + await expect(dispatcher.callRequest('pty.inspectProcess', { id: 'missing' })).rejects.toThrow( + 'terminal_gone' + ) + }) + it('allows callers to shorten a grace timer for empty startup relays', () => { const onExpire = vi.fn() handler.startGraceTimer(onExpire, 100) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 2bc9ead9c..ce2059e30 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -621,6 +621,7 @@ export class PtyHandler { this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p)) this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p)) this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p)) + this.dispatcher.onRequest('pty.inspectProcess', (p) => this.inspectProcess(p)) this.dispatcher.onRequest('pty.getCapabilities', async () => ({ startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION, @@ -1386,6 +1387,25 @@ export class PtyHandler { return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null) } + private async inspectProcess(params: Record): Promise<{ + foregroundProcess: string | null + hasChildProcesses: boolean + }> { + const id = params.id as string + const managed = this.ptys.get(id) + if (!managed || managed.disposed) { + throw new Error('terminal_gone') + } + const foregroundProcess = await getForegroundProcessName( + managed.pty.pid, + managed.pty.process || null + ) + return { + foregroundProcess, + hasChildProcesses: await processHasChildren(managed.pty.pid) + } + } + private async listProcesses(): Promise { const results: PtyProcessSummary[] = [] for (const [id, managed] of this.ptys) { diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index a57f4bb1f..b948cf43d 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -471,6 +471,13 @@ export function createAgentCompletionCoordinator( } function handleProcessInspectionResult(result: RuntimeTerminalProcessInspection): boolean { + if (result.unavailable === true) { + // Why: unknown liveness breaks the consecutive-idle proof without erasing known agent ownership. + pendingProcessExitAgent = null + consecutiveInspectionErrors += 1 + scheduleNextPoll() + return false + } consecutiveInspectionErrors = 0 const recognized = recognizeAgentProcess(result.foregroundProcess) if (recognized) { @@ -551,6 +558,8 @@ export function createAgentCompletionCoordinator( inspectionSucceeded = true } } catch { + // Why: a failed inspection breaks the consecutive-idle proof just like an unavailable result. + pendingProcessExitAgent = null consecutiveInspectionErrors += 1 } finally { inspectionInFlight = false diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 3020dab37..634f5f5b4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -874,6 +874,7 @@ describe('connectPanePty', () => { reportGeometry: vi.fn(), getMainBufferSnapshot: vi.fn().mockResolvedValue(null), getForegroundProcess: vi.fn().mockResolvedValue(null), + inspectProcess: vi.fn(), confirmForegroundProcess: vi.fn().mockResolvedValue(null), hasChildProcesses: vi.fn().mockResolvedValue(false), write: vi.fn(), @@ -910,6 +911,11 @@ describe('connectPanePty', () => { vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation((id) => window.api.pty.getForegroundProcess(id) ) + vi.mocked(window.api.pty.inspectProcess).mockImplementation(async (id) => { + const foregroundProcess = await window.api.pty.getForegroundProcess(id) + const hasChildProcesses = await window.api.pty.hasChildProcesses(id) + return { foregroundProcess, hasChildProcesses } + }) globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { callback(0) return 1 @@ -16508,6 +16514,7 @@ describe('connectPanePty', () => { terminalTitle: 'experimental-agent-observability', paneKey: makePaneKey('tab-1', LEAF_1) }) + expect(window.api.pty.inspectProcess).toHaveBeenCalledWith('pty-codex') }) it('does not dispatch generic spinner completions when process inspection finds no agent', async () => { diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts index d7074e8ed..b75a961c5 100644 --- a/src/renderer/src/lib/codex-session-restart.test.ts +++ b/src/renderer/src/lib/codex-session-restart.test.ts @@ -53,7 +53,8 @@ describe('markLiveCodexSessionsForRestart', () => { pty: { ...originalWindow?.api?.pty, getForegroundProcess: vi.fn(), - hasChildProcesses: vi.fn().mockResolvedValue(false) + hasChildProcesses: vi.fn().mockResolvedValue(false), + inspectProcess: vi.fn() }, runtimeEnvironments: { ...originalWindow?.api?.runtimeEnvironments, @@ -72,14 +73,17 @@ describe('markLiveCodexSessionsForRestart', () => { }) it('marks a live Codex PTY for restart', async () => { - vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'codex', + hasChildProcesses: false + }) await markLiveCodexSessionsForRestart({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) - expect(window.api.pty.getForegroundProcess).toHaveBeenCalledWith('pty-1') + expect(window.api.pty.inspectProcess).toHaveBeenCalledWith('pty-1') expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B @@ -117,14 +121,10 @@ describe('markLiveCodexSessionsForRestart', () => { 'tab-2': ['pty-3'] } }) - vi.mocked(window.api.pty.getForegroundProcess).mockImplementation((ptyId) => { - if (ptyId === 'pty-1') { - return Promise.resolve('codex') - } - if (ptyId === 'pty-3') { - return Promise.resolve('codex-aarch64-ap') - } - return Promise.resolve('zsh') + vi.mocked(window.api.pty.inspectProcess).mockImplementation(async (ptyId) => { + const foregroundProcess = + ptyId === 'pty-1' ? 'codex' : ptyId === 'pty-3' ? 'codex-aarch64-ap' : 'zsh' + return { foregroundProcess, hasChildProcesses: false } }) await markLiveCodexSessionsForRestart({ @@ -145,7 +145,10 @@ describe('markLiveCodexSessionsForRestart', () => { }) it('does not mark non-codex foreground processes', async () => { - vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('zsh') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) await markLiveCodexSessionsForRestart({ previousAccountLabel: ACCOUNT_A, @@ -155,8 +158,33 @@ describe('markLiveCodexSessionsForRestart', () => { expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) }) + it('still marks a confirmed Codex pane when another pane is unreachable', async () => { + useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-stale'] } }) + vi.mocked(window.api.pty.inspectProcess).mockImplementation(async (ptyId) => { + if (ptyId === 'pty-stale') { + throw new Error('terminal_gone') + } + return { foregroundProcess: 'codex', hasChildProcesses: true } + }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({ + 'pty-1': { + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + } + }) + }) + it('treats codex.exe as codex for Windows PTYs', async () => { - vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex.exe') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'codex.exe', + hasChildProcesses: false + }) await markLiveCodexSessionsForRestart({ previousAccountLabel: ACCOUNT_A, @@ -170,7 +198,10 @@ describe('markLiveCodexSessionsForRestart', () => { }) it('treats codex-prefixed packaged binaries as codex', async () => { - vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex-aarch64-ap') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'codex-aarch64-ap', + hasChildProcesses: false + }) await markLiveCodexSessionsForRestart({ previousAccountLabel: ACCOUNT_A, @@ -184,7 +215,10 @@ describe('markLiveCodexSessionsForRestart', () => { }) it('clears stale restart notices when the selected account switches back to the live pane account', async () => { - vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'codex', + hasChildProcesses: false + }) await markLiveCodexSessionsForRestart({ previousAccountLabel: ACCOUNT_A, @@ -202,7 +236,10 @@ describe('markLiveCodexSessionsForRestart', () => { }) it('preserves the pane original account across repeated switches until restart', async () => { - vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex') + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'codex', + hasChildProcesses: false + }) await markLiveCodexSessionsForRestart({ previousAccountLabel: ACCOUNT_A, @@ -255,7 +292,7 @@ describe('markLiveCodexSessionsForRestart', () => { nextAccountLabel: ACCOUNT_B }) - expect(window.api.pty.getForegroundProcess).not.toHaveBeenCalled() + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'terminal.inspectProcess', diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index 6e7a46bb1..36778b7be 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -37,7 +37,9 @@ async function getLiveCodexSessionPtyIds(state: AppState): Promise { const foregroundProcesses = await Promise.all( ptyIds.map((ptyId) => inspectRuntimeTerminalProcess(state.settings, ptyId).then( - (inspection) => inspection.foregroundProcess + (inspection) => inspection.foregroundProcess, + // Why: one stale remote pane must not hide restart notices for other confirmed Codex panes. + () => null ) ) ) diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts index 2782cb004..8202f5aa9 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts @@ -28,6 +28,7 @@ describe('runtime terminal owner routing', () => { const localWriteAccepted = vi.fn() const localForeground = vi.fn() const localHasChildren = vi.fn() + const localInspect = vi.fn() beforeEach(() => { clearRuntimeCompatibilityCacheForTests() @@ -47,7 +48,8 @@ describe('runtime terminal owner routing', () => { write: localWrite, writeAccepted: localWriteAccepted, getForegroundProcess: localForeground, - hasChildProcesses: localHasChildren + hasChildProcesses: localHasChildren, + inspectProcess: localInspect } } }) @@ -114,20 +116,35 @@ describe('runtime terminal owner routing', () => { expect(localHasChildren).not.toHaveBeenCalled() }) - it('treats stale remote terminal handles as gone during process inspection', async () => { - runtimeCall.mockResolvedValue({ - ok: false, - error: { code: 'terminal_handle_stale', message: 'terminal_handle_stale' } - }) + it('uses strict main-process inspection for a direct SSH PTY', async () => { + localInspect.mockResolvedValue({ foregroundProcess: 'codex', hasChildProcesses: true }) - await expect( - inspectRuntimeTerminalProcess( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-stale' - ) - ).resolves.toEqual({ foregroundProcess: null, hasChildProcesses: false }) + await expect(inspectRuntimeTerminalProcess(null, 'ssh:host@@pty-1')).resolves.toEqual({ + foregroundProcess: 'codex', + hasChildProcesses: true + }) + expect(localInspect).toHaveBeenCalledExactlyOnceWith('ssh:host@@pty-1') + expect(localForeground).not.toHaveBeenCalled() + expect(localHasChildren).not.toHaveBeenCalled() }) + it.each(['no_connected_pty', 'terminal_handle_stale', 'terminal_gone'])( + 'reports %s remote process inspection as unavailable', + async (code) => { + runtimeCall.mockResolvedValue({ + ok: false, + error: { code, message: code } + }) + + await expect( + inspectRuntimeTerminalProcess( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-stale' + ) + ).resolves.toEqual({ foregroundProcess: null, hasChildProcesses: false, unavailable: true }) + } + ) + it('records accepted fire-and-forget runtime input against the owning pane key', async () => { runtimeCall.mockResolvedValue({ ok: true, diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts index b9691f622..45bb77dd3 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -12,6 +12,8 @@ import { export type RuntimeTerminalProcessInspection = { foregroundProcess: string | null hasChildProcesses: boolean + // Why: callers must not treat a stale remote handle as authoritative idle evidence. + unavailable?: true } const REMOTE_PTY_ID_PREFIX = 'remote:' @@ -34,6 +36,7 @@ function isTerminalGoneError(error: unknown): boolean { ? String((error as { code?: unknown }).code) : '' return ( + code === 'no_connected_pty' || code === 'terminal_handle_stale' || code === 'terminal_exited' || code === 'terminal_gone' || @@ -74,11 +77,7 @@ export async function inspectRuntimeTerminalProcess( : getActiveRuntimeTarget(settings) const terminal = getRemoteRuntimeTerminalHandle(ptyId) if (target.kind !== 'environment' || !terminal) { - const [foregroundProcess, hasChildProcesses] = await Promise.all([ - window.api.pty.getForegroundProcess(ptyId), - window.api.pty.hasChildProcesses(ptyId) - ]) - return { foregroundProcess, hasChildProcesses } + return window.api.pty.inspectProcess(ptyId) } try { @@ -91,7 +90,7 @@ export async function inspectRuntimeTerminalProcess( return result.process } catch (error) { if (isTerminalGoneError(error)) { - return { foregroundProcess: null, hasChildProcesses: false } + return { foregroundProcess: null, hasChildProcesses: false, unavailable: true } } throw error } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 2e18333d8..d745b82ed 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2934,6 +2934,7 @@ function createPtyApi(): NonNullable['pty']> { publishTerminalViewAttributes: () => {}, hasChildProcesses: () => Promise.resolve(false), getForegroundProcess: () => Promise.resolve(null), + inspectProcess: () => Promise.reject(new Error('terminal_liveness_unavailable')), // Why: paired web panes cannot provide a local post-boundary process scan. confirmForegroundProcess: () => Promise.resolve(null), getCwd: () => Promise.resolve('~'), diff --git a/tests/e2e/remote-agent-completion-authority.unit.test.ts b/tests/e2e/remote-agent-completion-authority.unit.test.ts new file mode 100644 index 000000000..72e73ec0d --- /dev/null +++ b/tests/e2e/remote-agent-completion-authority.unit.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTerminalTitleTracker } from '../../src/shared/terminal-output-side-effects' +import { + createAgentCompletionCoordinator, + resetAgentCompletionCoordinatorIdentitiesForTest +} from '../../src/renderer/src/components/terminal-pane/agent-completion-coordinator' +import type { AgentCompletionDispatchMeta } from '../../src/renderer/src/components/terminal-pane/agent-completion-coordinator-types' +import { inspectRuntimeTerminalProcess } from '../../src/renderer/src/runtime/runtime-terminal-inspection' +import { clearRuntimeCompatibilityCacheForTests } from '../../src/renderer/src/runtime/runtime-rpc-client' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../src/renderer/src/runtime/runtime-compatibility-test-fixture' + +const REMOTE_PTY_ID = 'remote:remote-host@@term_remote_agent' + +describe('remote agent completion authority', () => { + const runtimeCall = vi.fn() + const runtimeTransportCall = vi.fn((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeCall(args) + }) + + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(Math, 'random').mockReturnValue(0.5) + clearRuntimeCompatibilityCacheForTests() + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { call: runtimeTransportCall }, + pty: { + getForegroundProcess: vi.fn(), + hasChildProcesses: vi.fn() + } + } + }) + }) + + afterEach(() => { + resetAgentCompletionCoordinatorIdentitiesForTest() + vi.useRealTimers() + vi.unstubAllGlobals() + vi.clearAllMocks() + vi.restoreAllMocks() + }) + + it('keeps transport loss unknown through reconnect and completes only after authoritative idle samples', async () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-remote:leaf-remote', + getPtyId: () => REMOTE_PTY_ID, + getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }), + inspectProcess: inspectRuntimeTerminalProcess, + dispatchCompletion, + isLive: () => true + }) + + runtimeCall.mockResolvedValue(remoteInspection('codex')) + coordinator.startProcessTracking() + await vi.advanceTimersByTimeAsync(2_000) + expect(runtimeCall).toHaveBeenCalledTimes(1) + + runtimeCall.mockResolvedValue({ + ok: false, + error: { code: 'terminal_handle_stale', message: 'remote transport is reconnecting' } + }) + await vi.advanceTimersByTimeAsync(20_000) + expect(runtimeCall.mock.calls.length).toBeGreaterThan(2) + expect(dispatchCompletion).not.toHaveBeenCalled() + + runtimeCall.mockResolvedValue(remoteInspection('codex')) + await vi.advanceTimersByTimeAsync(20_000) + expect(dispatchCompletion).not.toHaveBeenCalled() + + runtimeCall.mockResolvedValue(remoteInspection(null, false)) + await vi.advanceTimersByTimeAsync(20_000) + expect(dispatchCompletion).toHaveBeenCalledExactlyOnceWith('codex', { + source: 'process-exit', + quietedHookDone: false, + terminalIdleConfirmed: true + }) + + coordinator.dispose() + }) + + it.each([ + { + failure: { + ok: false, + error: { code: 'no_connected_pty', message: 'remote transport is unavailable' } + }, + kind: 'an unavailable response' + }, + { + failure: new Error('Runtime request timed out before terminal.inspectProcess completed'), + kind: 'a thrown transport failure' + } + ])( + 'requires two new idle samples when $kind interrupts exit confirmation', + async ({ failure }) => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-remote:leaf-partitioned-exit', + getPtyId: () => REMOTE_PTY_ID, + getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }), + inspectProcess: inspectRuntimeTerminalProcess, + dispatchCompletion, + isLive: () => true + }) + + runtimeCall.mockResolvedValue(remoteInspection('codex')) + coordinator.startProcessTracking() + await vi.advanceTimersByTimeAsync(2_000) + + runtimeCall.mockResolvedValue(remoteInspection(null, false)) + await vi.advanceTimersByTimeAsync(750) + expect(runtimeCall).toHaveBeenCalledTimes(2) + expect(dispatchCompletion).not.toHaveBeenCalled() + + if (failure instanceof Error) { + runtimeCall.mockRejectedValue(failure) + } else { + runtimeCall.mockResolvedValue(failure) + } + await vi.advanceTimersByTimeAsync(750) + expect(runtimeCall).toHaveBeenCalledTimes(3) + expect(dispatchCompletion).not.toHaveBeenCalled() + + runtimeCall.mockResolvedValue(remoteInspection(null, false)) + await vi.advanceTimersByTimeAsync(1_500) + expect(runtimeCall).toHaveBeenCalledTimes(4) + expect(dispatchCompletion).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(750) + expect(dispatchCompletion).toHaveBeenCalledExactlyOnceWith('codex', { + source: 'process-exit', + quietedHookDone: false, + terminalIdleConfirmed: true + }) + + coordinator.dispose() + } + ) + + it('preserves distinct stopped, exited, and successful completion evidence', async () => { + const outcomes: ( + | { kind: 'hook'; interrupted: boolean } + | { kind: 'process-exit'; exitCode: number | null } + )[] = [] + const createHookCoordinator = (paneKey: string) => + createAgentCompletionCoordinator({ + paneKey, + getPtyId: () => REMOTE_PTY_ID, + getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }), + inspectProcess: inspectRuntimeTerminalProcess, + dispatchCompletion: (_title: string, meta?: AgentCompletionDispatchMeta) => { + outcomes.push({ + kind: 'hook', + interrupted: meta?.agentStatus?.interrupted === true + }) + }, + isLive: () => true + }) + + const stopped = createHookCoordinator('tab-remote:leaf-stopped') + stopped.observeHookStatus({ state: 'working', prompt: 'stop me', agentType: 'codex' }) + stopped.observeHookStatus({ + state: 'done', + prompt: 'stop me', + agentType: 'codex', + interrupted: true + }) + await vi.advanceTimersByTimeAsync(1_500) + + const tracker = createTerminalTitleTracker({ + onCommandFinished: (exitCode) => outcomes.push({ kind: 'process-exit', exitCode }) + }) + tracker.handleChunk('\u001b]133;D;130\u0007') + + const succeeded = createHookCoordinator('tab-remote:leaf-succeeded') + succeeded.observeHookStatus({ state: 'working', prompt: 'finish me', agentType: 'codex' }) + succeeded.observeHookStatus({ state: 'done', prompt: 'finish me', agentType: 'codex' }) + await vi.advanceTimersByTimeAsync(1_500) + + expect(outcomes).toEqual([ + { kind: 'hook', interrupted: true }, + { kind: 'process-exit', exitCode: 130 }, + { kind: 'hook', interrupted: false } + ]) + + stopped.dispose() + succeeded.dispose() + tracker.dispose() + }) +}) + +function remoteInspection(foregroundProcess: string | null, hasChildProcesses = true) { + return { + ok: true, + result: { process: { foregroundProcess, hasChildProcesses } }, + _meta: { runtimeId: 'remote-host' } + } +}