fix(remote): don't classify a stale/gone remote handle as agent completion (#9263)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
parent
1648251fb8
commit
7ab601487c
|
|
@ -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."
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,3 +9,7 @@ export type GetForegroundProcessRequest = {
|
|||
export type ConfirmForegroundProcessRequest = Omit<GetForegroundProcessRequest, 'type'> & {
|
||||
type: 'confirmForegroundProcess'
|
||||
}
|
||||
|
||||
export type InspectProcessRequest = Omit<GetForegroundProcessRequest, 'type'> & {
|
||||
type: 'inspectProcess'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
try {
|
||||
const result = await this.client.request<{ foregroundProcess: string | null }>(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
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<string | null> {
|
||||
return this.providerFor(id).confirmForegroundProcess?.(id) ?? null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string | null> => {
|
||||
|
|
|
|||
|
|
@ -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<IPtyProvider, 'getForegroundProcess' | 'hasChildProcesses'> as IPtyProvider
|
||||
|
||||
await expect(inspectPtyProviderProcess(provider, 'pty-1')).resolves.toEqual({
|
||||
foregroundProcess: 'codex',
|
||||
hasChildProcesses: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { IPtyProvider } from './types'
|
||||
|
||||
export type PtyProcessInspection = {
|
||||
foregroundProcess: string | null
|
||||
hasChildProcesses: boolean
|
||||
}
|
||||
|
||||
type CompletionSensitivePtyProvider = IPtyProvider & {
|
||||
inspectProcess?: (id: string) => Promise<PtyProcessInspection>
|
||||
}
|
||||
|
||||
export async function inspectPtyProviderProcess(
|
||||
provider: IPtyProvider,
|
||||
ptyId: string
|
||||
): Promise<PtyProcessInspection> {
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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<string> {
|
||||
const result = await this.mux.request('pty.serialize', {
|
||||
ids: ids.map((id) => this.toRelayPtyId(id))
|
||||
|
|
|
|||
|
|
@ -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<string | null>
|
||||
inspectProcess?: (
|
||||
ptyId: string
|
||||
) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }>
|
||||
confirmForegroundProcess?: (ptyId: string) => Promise<string | null>
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -1427,6 +1427,9 @@ type RuntimePtyController = {
|
|||
markReversibleStops?(ptyIds: readonly string[]): () => void
|
||||
getCwd?(ptyId: string): Promise<string | null>
|
||||
getForegroundProcess(ptyId: string): Promise<string | null>
|
||||
inspectProcess?(
|
||||
ptyId: string
|
||||
): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }>
|
||||
confirmForegroundProcess?(ptyId: string): Promise<string | null>
|
||||
hasChildProcesses?(ptyId: string): Promise<boolean>
|
||||
clearBuffer?(ptyId: string): Promise<void>
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1373,6 +1373,9 @@ export type PreloadApi = {
|
|||
publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void
|
||||
hasChildProcesses: (id: string) => Promise<boolean>
|
||||
getForegroundProcess: (id: string) => Promise<string | null>
|
||||
inspectProcess: (
|
||||
id: string
|
||||
) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }>
|
||||
confirmForegroundProcess: (id: string) => Promise<string | null>
|
||||
getCwd: (id: string) => Promise<string>
|
||||
getSize: (id: string) => Promise<{ cols: number; rows: number } | null>
|
||||
|
|
|
|||
|
|
@ -981,6 +981,10 @@ const api = {
|
|||
/** Return the PTY foreground process basename when available (e.g. "codex"). */
|
||||
getForegroundProcess: (id: string): Promise<string | null> =>
|
||||
ipcRenderer.invoke('pty:getForegroundProcess', { id }),
|
||||
inspectProcess: (
|
||||
id: string
|
||||
): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> =>
|
||||
ipcRenderer.invoke('pty:inspectProcess', { id }),
|
||||
confirmForegroundProcess: (id: string): Promise<string | null> =>
|
||||
ipcRenderer.invoke('pty:confirmForegroundProcess', { id }),
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): 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<PtyProcessSummary[]> {
|
||||
const results: PtyProcessSummary[] = []
|
||||
for (const [id, managed] of this.ptys) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ async function getLiveCodexSessionPtyIds(state: AppState): Promise<string[]> {
|
|||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2934,6 +2934,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['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('~'),
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue