From b232df732bbf2afb97de0d03f6d8ab3f01e3b2f9 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 21 Jul 2026 23:51:28 -0400 Subject: [PATCH] fix(terminal): make remote agent sessions host-authoritative (#9687) --- config/localization-coverage-allowlist.json | 10 +- config/reliability-gates.jsonc | 169 ++- .../remote-agent-session-authority-repro.mjs | 364 ++++++ .../remote-agent-session-repro-client.mjs | 29 + .../remote-agent-session-repro-fixture.mjs | 29 + ...-daemon-generation-safety-investigation.md | 17 +- .../remote-agent-session-host-authority.md | 456 +++++++ package.json | 1 + .../daemon/daemon-create-or-attach-result.ts | 4 + ...n-foreground-confirmation-protocol.test.ts | 3 +- .../daemon/daemon-protocol-version.test.ts | 18 + src/main/daemon/daemon-protocol-version.ts | 6 +- src/main/daemon/daemon-pty-adapter.test.ts | 141 ++- src/main/daemon/daemon-pty-adapter.ts | 225 +++- src/main/daemon/daemon-pty-provider.test.ts | 2 +- src/main/daemon/daemon-pty-provider.ts | 19 +- src/main/daemon/daemon-pty-router.test.ts | 161 ++- src/main/daemon/daemon-pty-router.ts | 33 +- src/main/daemon/daemon-server.ts | 55 +- src/main/daemon/daemon-stream-events.ts | 3 +- .../degraded-daemon-pty-provider.test.ts | 19 +- .../daemon/degraded-daemon-pty-provider.ts | 40 +- src/main/daemon/pty-session-id.test.ts | 33 +- src/main/daemon/pty-session-id.ts | 9 + src/main/daemon/session.test.ts | 2 +- src/main/daemon/session.ts | 8 +- .../terminal-host-agent-session-claim.ts | 44 + ...terminal-host-agent-session-generations.ts | 21 + .../terminal-host-agent-session.test.ts | 84 ++ .../daemon/terminal-host-create-contract.ts | 17 +- src/main/daemon/terminal-host-options.ts | 2 + .../daemon/terminal-host-session-create.ts | 143 +++ src/main/daemon/terminal-host-session-cwd.ts | 11 + .../daemon/terminal-host-session-listing.ts | 31 + src/main/daemon/terminal-host-tombstones.ts | 29 + src/main/daemon/terminal-host.ts | 206 +-- src/main/daemon/types.ts | 13 + src/main/index.ts | 5 + src/main/ipc/pty.test.ts | 1123 ++++++++++++++++- src/main/ipc/pty.ts | 508 +++++++- src/main/ipc/ssh.test.ts | 5 +- src/main/ipc/ssh.ts | 2 +- .../profile-project-session-state.test.ts | 157 +++ .../profile-project-session-state.ts | 42 + .../profile-project-session-transfer.ts | 29 +- .../profile-project-transfer-payload.ts | 4 + src/main/persistence.test.ts | 385 +++++- src/main/persistence.ts | 113 +- src/main/providers/local-pty-provider.test.ts | 28 +- src/main/providers/local-pty-provider.ts | 23 +- src/main/providers/pty-spawn-result.ts | 7 + .../ssh-agent-session-capabilities.ts | 50 + .../ssh-agent-session-claim-validation.ts | 90 ++ .../ssh-agent-session-create-operation.ts | 65 + .../ssh-agent-session-process-list.ts | 28 + .../providers/ssh-capability-probe-waiter.ts | 24 + src/main/providers/ssh-pty-errors.ts | 12 + ...der-agent-session-create-operation.test.ts | 158 +++ ...ssh-pty-provider-claim-incarnation.test.ts | 56 + .../providers/ssh-pty-provider-contract.ts | 23 + .../ssh-pty-provider-exit-race.test.ts | 78 ++ .../ssh-pty-provider-process-events.test.ts | 175 +++ src/main/providers/ssh-pty-provider.test.ts | 369 ++++-- src/main/providers/ssh-pty-provider.ts | 319 +++-- .../providers/ssh-pty-session-reattach.ts | 104 ++ src/main/providers/ssh-pty-spawn-env.ts | 32 + src/main/providers/ssh-pty-spawn-exit-race.ts | 41 + src/main/providers/ssh-pty-spawn-request.ts | 47 + src/main/providers/types.ts | 30 +- .../agent-session-claim-identity.test.ts | 43 + .../runtime/agent-session-claim-identity.ts | 177 +++ src/main/runtime/mobile-rpc-allowlist.test.ts | 2 + ...on-terminal-persistence-retirement.test.ts | 335 +++++ ...session-terminal-persistence-retirement.ts | 258 ++++ ...mobile-session-terminal-retirement.test.ts | 268 ++++ .../mobile-session-terminal-retirement.ts | 286 +++++ ...ca-runtime-agent-session-operation.test.ts | 292 +++++ .../orca-runtime-terminal-retirement.test.ts | 666 ++++++++++ src/main/runtime/orca-runtime.test.ts | 228 +++- src/main/runtime/orca-runtime.ts | 992 ++++++++++++++- ...session-host-authority.integration.test.ts | 233 ++++ src/main/runtime/rpc/errors.ts | 4 +- .../runtime/rpc/methods/agent-session.test.ts | 325 +++++ src/main/runtime/rpc/methods/agent-session.ts | 228 ++++ src/main/runtime/rpc/methods/index.ts | 2 + .../rpc/methods/session-tab-close-methods.ts | 5 +- .../rpc/methods/session-tabs-schemas.ts | 4 +- .../runtime/rpc/methods/session-tabs.test.ts | 35 +- src/main/runtime/runtime-rpc.ts | 2 + ...e-session-terminal-membership-authority.ts | 293 +++++ .../ssh/ssh-relay-session-incarnation.test.ts | 111 ++ ...elay-session-reconnect-incarnation.test.ts | 283 +++++ .../ssh/ssh-relay-session-test-fixtures.ts | 3 +- src/main/ssh/ssh-relay-session.test.ts | 4 +- src/main/ssh/ssh-relay-session.ts | 99 +- src/relay/pty-handler.test.ts | 260 +++- src/relay/pty-handler.ts | 220 +++- .../ai-vault-session-launch-actions.ts | 39 +- .../tab-group/AiVaultSessionDropLayer.tsx | 29 +- .../useTabGroupWorkspaceModel.focus.test.ts | 19 +- .../tab-group/useTabGroupWorkspaceModel.ts | 21 +- .../terminal-pane/pty-connection-types.ts | 4 + .../terminal-pane/pty-connection.test.ts | 10 +- .../terminal-pane/pty-connection.ts | 14 + .../terminal-pane/pty-transport-types.ts | 8 + .../terminal-pane/pty-transport.test.ts | 152 ++- .../remote-runtime-pty-transport.test.ts | 326 ++++- .../remote-runtime-pty-transport.ts | 244 +++- src/renderer/src/hooks/useIpcEvents.test.ts | 5 +- src/renderer/src/hooks/useIpcEvents.ts | 14 +- src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- .../agent-background-session-test-state.ts | 250 ++++ .../src/lib/ai-vault-resume-command.test.ts | 6 +- .../src/lib/ai-vault-resume-command.ts | 30 +- .../launch-agent-background-session.test.ts | 301 ++--- .../lib/launch-agent-background-session.ts | 35 +- .../lib/launch-agent-in-new-tab-cwd.test.ts | 9 +- ...aunch-agent-in-new-tab-web-runtime.test.ts | 138 ++ .../src/lib/launch-agent-in-new-tab.test.ts | 107 +- .../src/lib/launch-agent-in-new-tab.ts | 20 +- .../src/lib/launch-agent-web-host-tab.ts | 86 +- .../src/lib/launch-ai-vault-session.test.ts | 12 +- .../src/lib/launch-ai-vault-session.ts | 23 +- ...eeping-agent-session-remote-compat.test.ts | 64 + .../lib/runtime-agent-background-create.ts | 80 ++ .../src/lib/sleeping-agent-session-launch.ts | 1 + .../workspace-session-host-field-ownership.ts | 58 + .../src/lib/workspace-session-host-records.ts | 61 + .../lib/workspace-session-host-split.test.ts | 53 + .../src/lib/workspace-session-host-split.ts | 222 ++-- .../runtime/agent-session-create-operation.ts | 72 ++ .../agent-session-operation-id.test.ts | 77 ++ .../src/runtime/agent-session-operation-id.ts | 15 + .../remote-agent-session-launch.test.ts | 140 ++ .../runtime/remote-agent-session-launch.ts | 45 + ...mote-runtime-session-tabs-inflight.test.ts | 39 + .../remote-runtime-session-tabs-inflight.ts | 26 +- .../src/runtime/runtime-client-target.ts | 21 + .../src/runtime/runtime-rpc-client.test.ts | 76 ++ .../src/runtime/runtime-rpc-client.ts | 62 +- .../src/runtime/web-agent-session-handoff.ts | 91 ++ .../src/runtime/web-runtime-session.test.ts | 589 ++++++++- .../src/runtime/web-runtime-session.ts | 288 ++++- .../src/runtime/web-session-tabs-sync.test.ts | 212 +++- .../src/runtime/web-session-tabs-sync.ts | 94 +- .../src/store/slices/runtime-status.ts | 2 +- src/renderer/src/store/slices/terminals.ts | 8 + src/shared/agent-session-host-authority.ts | 211 ++++ .../claimed-agent-pty-owner-snapshot.ts | 189 +++ src/shared/claimed-agent-pty-owner.test.ts | 265 ++++ src/shared/claimed-agent-pty-owner.ts | 277 ++++ src/shared/protocol-version.ts | 5 +- src/shared/pty-incarnation.ts | 5 + src/shared/runtime-types.ts | 4 +- src/shared/types.ts | 16 + src/shared/workspace-session-schema.ts | 17 +- ...daemon-generation-reconnect-safety.spec.ts | 16 +- 161 files changed, 16007 insertions(+), 1583 deletions(-) create mode 100644 config/scripts/remote-agent-session-authority-repro.mjs create mode 100644 config/scripts/remote-agent-session-repro-client.mjs create mode 100644 config/scripts/remote-agent-session-repro-fixture.mjs create mode 100644 docs/reference/remote-agent-session-host-authority.md create mode 100644 src/main/daemon/daemon-protocol-version.test.ts create mode 100644 src/main/daemon/terminal-host-agent-session-claim.ts create mode 100644 src/main/daemon/terminal-host-agent-session-generations.ts create mode 100644 src/main/daemon/terminal-host-agent-session.test.ts create mode 100644 src/main/daemon/terminal-host-session-create.ts create mode 100644 src/main/daemon/terminal-host-session-cwd.ts create mode 100644 src/main/daemon/terminal-host-session-listing.ts create mode 100644 src/main/daemon/terminal-host-tombstones.ts create mode 100644 src/main/orca-profiles/profile-project-session-state.test.ts create mode 100644 src/main/providers/ssh-agent-session-capabilities.ts create mode 100644 src/main/providers/ssh-agent-session-claim-validation.ts create mode 100644 src/main/providers/ssh-agent-session-create-operation.ts create mode 100644 src/main/providers/ssh-agent-session-process-list.ts create mode 100644 src/main/providers/ssh-capability-probe-waiter.ts create mode 100644 src/main/providers/ssh-pty-errors.ts create mode 100644 src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts create mode 100644 src/main/providers/ssh-pty-provider-claim-incarnation.test.ts create mode 100644 src/main/providers/ssh-pty-provider-contract.ts create mode 100644 src/main/providers/ssh-pty-provider-exit-race.test.ts create mode 100644 src/main/providers/ssh-pty-provider-process-events.test.ts create mode 100644 src/main/providers/ssh-pty-session-reattach.ts create mode 100644 src/main/providers/ssh-pty-spawn-env.ts create mode 100644 src/main/providers/ssh-pty-spawn-exit-race.ts create mode 100644 src/main/providers/ssh-pty-spawn-request.ts create mode 100644 src/main/runtime/agent-session-claim-identity.test.ts create mode 100644 src/main/runtime/agent-session-claim-identity.ts create mode 100644 src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts create mode 100644 src/main/runtime/mobile-session-terminal-persistence-retirement.ts create mode 100644 src/main/runtime/mobile-session-terminal-retirement.test.ts create mode 100644 src/main/runtime/mobile-session-terminal-retirement.ts create mode 100644 src/main/runtime/orca-runtime-agent-session-operation.test.ts create mode 100644 src/main/runtime/orca-runtime-terminal-retirement.test.ts create mode 100644 src/main/runtime/remote-agent-session-host-authority.integration.test.ts create mode 100644 src/main/runtime/rpc/methods/agent-session.test.ts create mode 100644 src/main/runtime/rpc/methods/agent-session.ts create mode 100644 src/main/runtime/workspace-session-terminal-membership-authority.ts create mode 100644 src/main/ssh/ssh-relay-session-incarnation.test.ts create mode 100644 src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts create mode 100644 src/renderer/src/lib/agent-background-session-test-state.ts create mode 100644 src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts create mode 100644 src/renderer/src/lib/resume-sleeping-agent-session-remote-compat.test.ts create mode 100644 src/renderer/src/lib/runtime-agent-background-create.ts create mode 100644 src/renderer/src/lib/workspace-session-host-field-ownership.ts create mode 100644 src/renderer/src/lib/workspace-session-host-records.ts create mode 100644 src/renderer/src/runtime/agent-session-create-operation.ts create mode 100644 src/renderer/src/runtime/agent-session-operation-id.test.ts create mode 100644 src/renderer/src/runtime/agent-session-operation-id.ts create mode 100644 src/renderer/src/runtime/remote-agent-session-launch.test.ts create mode 100644 src/renderer/src/runtime/remote-agent-session-launch.ts create mode 100644 src/renderer/src/runtime/runtime-client-target.ts create mode 100644 src/renderer/src/runtime/web-agent-session-handoff.ts create mode 100644 src/shared/agent-session-host-authority.ts create mode 100644 src/shared/claimed-agent-pty-owner-snapshot.ts create mode 100644 src/shared/claimed-agent-pty-owner.test.ts create mode 100644 src/shared/claimed-agent-pty-owner.ts create mode 100644 src/shared/pty-incarnation.ts diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index fe51488c706..b48256adff2 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -1 +1,9 @@ -[] +[ + { + "filePath": "src/renderer/src/lib/agent-background-session-test-state.ts", + "kind": "object-property:title", + "text": "Terminal 1", + "dynamic": false, + "count": 1 + } +] diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 20535188c2c..595ee5a6b97 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -920,7 +920,7 @@ "providers": ["daemon", "runtime", "ssh", "wsl"], "coveredPlatforms": ["windows"], "coveredProviders": ["daemon", "runtime"], - "coverageNotes": "A native Windows v21/v22/v23/v24/v25 named-pipe harness calls the production desktop legacy-adapter scanner and covers warm reattach, current-to-next upgrade, parallel clients, repeated reconnects, client relaunch, exact process identity, bounded failed disposal, and PTY-root/descendant survival. Deterministic host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, missing intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. Docker is unavailable and WSL is not installed on this runner, so live SSH/WSL remain gaps.", + "coverageNotes": "Recorded native Windows evidence covers the v21/v22/v23/v24/v25 named-pipe matrix. The current harness additionally includes the v26 agent-authority boundary while retaining v24 clean-disconnect and v25 startup-ingress coverage; that six-generation Windows rerun remains to be collected. Deterministic host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, authenticated legacy and unattributed intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. Docker is unavailable and WSL is not installed on this runner, so live SSH/WSL remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/9749", "https://github.com/stablyai/orca/issues/8871", @@ -928,7 +928,7 @@ "https://github.com/stablyai/orca/issues/9229" ], "invariant": "Reconnect, replay, or lifecycle observations must never kill a live PTY. Destructive close requires explicit user intent; lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Missing or incompatible evidence keeps and audits. Legacy daemon hello and warm reattachment remain non-destructive.", - "oracle": "Start five isolated native-Windows daemon generations on distinct versioned named pipes, let the production desktop scanner discover v21-v24 from a v25 client, attach live and stale-mirror canaries with exact root/descendant PID-start identities, reconnect and relaunch the production router path, issue repeated desktop and remote-profile lifecycle closes, and require every daemon, root, and descendant to remain alive with zero session-killed events. Unit contracts require unknown/stale/reused/cross-profile claims and live PTYs to refuse without kill or renderer-close calls, old servers to return method_not_found with no destructive fallback, explicit user close to remain destructive, and dead whole-headless state to retire without signalling its retained PTY id.", + "oracle": "Start six isolated native-Windows daemon generations on distinct versioned named pipes, let the production desktop scanner discover v21-v25 from a v26 client, attach live and stale-mirror canaries with exact root/descendant PID-start identities, reconnect and relaunch the production router path, issue repeated desktop and remote-profile lifecycle closes, and require every daemon, root, and descendant to remain alive with zero session-killed events. Unit contracts require unknown/stale/reused/cross-profile claims and live PTYs to refuse without kill or renderer-close calls, old servers to return method_not_found with no destructive fallback, authenticated legacy and explicit user closes to remain destructive, and dead whole-headless state to retire without signalling its retained PTY id.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", "pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" @@ -949,7 +949,7 @@ { "file": "tests/e2e/daemon-generation-reconnect-safety.spec.ts", "assertions": [ - "the production scanner discovers v21/v22/v23/v24 from v25 and every generation accepts repeated client hellos while every exact daemon, PTY-root, and descendant incarnation remains alive", + "the production scanner discovers v21/v22/v23/v24/v25 from v26 and every generation accepts repeated client hellos while every exact daemon, PTY-root, and descendant incarnation remains alive", "desktop and two remote profiles repeat lifecycle closes before and after client relaunch with zero session-killed events", "shutdown-dispose-failed drops named-pipe authority within the deadline and exact fixture cleanup leaves no process tree" ] @@ -957,9 +957,9 @@ { "file": "src/main/runtime/orca-runtime.test.ts", "assertions": [ - "live, unknown, stale, missing-intent, and non-owner lifecycle closes invoke neither PTY kill nor renderer close", + "live, unknown, stale, missing-intent, non-owner, and inventory-proven but not yet pane-bound lifecycle closes invoke neither PTY kill nor renderer close", "dead whole-headless retirement removes stale state without signalling a retained PTY id", - "explicit user close remains destructive" + "explicit and authenticated legacy user closes remain destructive" ] }, { @@ -1377,6 +1377,165 @@ ], "demotionRule": "Demote or quarantine if failures are non-actionable or if a duplicate resume escape occurs outside the modeled matrix." }, + { + "id": "agent-session.remote-host-authority", + "title": "Remote agent sessions have one host-authoritative PTY and durable surface lifecycle", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-session", + "layer": "runtime-controller-provider-renderer-contract", + "surfaces": [ + "remote agent launch and explicit resume", + "multi-client remote runtime sessions", + "daemon and relay reconnect", + "terminal exit retirement and restart restore", + "mixed-version fallback" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coverageNotes": "Deterministic macOS tests cover controller claims, daemon and SSH/relay operation replay, mixed-version selection, runtime ownership, exact provisional handoff, and durable terminal retirement. The real repro runs two independent clients against one headless remote Orca runtime over the encrypted pairing path and a real daemon-backed PTY. SSH coverage is contract/fault-injection coverage; WSL and live SSH hosts remain gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/8878", + "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 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, and old daemon/relay capabilities; assert safe adoption or explicit failure without a second spawn. 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 test:repro:remote-agent-session" + ], + "testFiles": [ + "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" + ], + "assertionRefs": [ + { + "file": "src/shared/claimed-agent-pty-owner.test.ts", + "assertions": [ + "concurrent exact claims spawn once and later callers adopt the canonical owner", + "same identity in another worktree conflicts and cannot be found as the current scope's owner", + "generation-guarded exit and authoritative reconciliation cannot retire a replacement owner" + ] + }, + { + "file": "src/main/runtime/orca-runtime-agent-session-operation.test.ts", + "assertions": [ + "old execution owners select exact legacy fallback before trust, spawn, or ledger mutation", + "nested SSH Pi resume selects legacy before reading the remote-only transcript path locally", + "fresh operation retries replay one result and retain a fence after an ambiguous physical commit" + ] + }, + { + "file": "src/main/runtime/remote-agent-session-host-authority.integration.test.ts", + "assertions": [ + "independent runtime clients converge on one canonical live agent-session owner", + "retries and concurrent requests cannot create a second physical PTY" + ] + }, + { + "file": "src/main/runtime/orca-runtime-terminal-retirement.test.ts", + "assertions": [ + "an exact PTY exit retires host membership and stale topology cannot recreate the surface", + "incarnation fencing prevents an old delayed exit from retiring a replacement PTY" + ] + }, + { + "file": "src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts", + "assertions": [ + "a causally post-operation inventory waits out an older request and concurrent confirmations share the fresh request" + ] + }, + { + "file": "src/renderer/src/runtime/web-runtime-session.test.ts", + "assertions": [ + "a causally post-create list confirms only the exact provisional tab and terminal-handle generation when another create is in flight" + ] + }, + { + "file": "src/renderer/src/runtime/web-session-tabs-sync.test.ts", + "assertions": [ + "only an exact structured-create handoff retires its provisional tab", + "an absent host tab retires its exact provisional handoff only after a causally post-create snapshot while unrelated tabs remain" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-21", + "runner": "local", + "platform": "macos", + "command": "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", + "result": "passed", + "durationSeconds": 7.67, + "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", + "runner": "local", + "platform": "macos", + "command": "pnpm test:repro:remote-agent-session", + "result": "passed", + "durationSeconds": 48.47, + "summary": "The build-backed headless remote Orca harness passed over encrypted WebSocket pairing with two independent clients, proving one spawn, retry adoption, durable exit retirement, stale-publication rejection, and no restart resurrection." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "focused ownership, compatibility, lifecycle, and renderer handoff tests; build-backed repro tracked separately" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New experimental gate with deterministic local coverage and no soak history yet." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The motivating remote-client duplicate-resume and exited-surface repros are encoded in deterministic lower-layer tests and the real remote harness; saved CI red/green 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." + }, + "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 live Linux/Windows and SSH/WSL provider evidence before claiming full platform/provider coverage." + ], + "knownGaps": [ + "The real remote-server harness currently runs on macOS and uses a local daemon-backed execution owner; Linux and Windows runs remain uncollected.", + "SSH and relay failure ordering is deterministic contract coverage, not a live SSH-host journey; WSL has no provider-specific run.", + "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." + ], + "demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if a retry can physically spawn twice, if a stale exit/publication can replace or resurrect a terminal, or if mixed-version fallback occurs after an authority side effect." + }, { "id": "terminal-geometry.visible-convergence", "title": "Visible desktop terminals converge across xterm, fit, PTY, shell, and runtime mirror size", diff --git a/config/scripts/remote-agent-session-authority-repro.mjs b/config/scripts/remote-agent-session-authority-repro.mjs new file mode 100644 index 00000000000..3c92f3cda90 --- /dev/null +++ b/config/scripts/remote-agent-session-authority-repro.mjs @@ -0,0 +1,364 @@ +#!/usr/bin/env node + +import { execFileSync, spawn } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import { createInterface } from 'node:readline' + +const repoRoot = path.resolve(import.meta.dirname, '..', '..') +const clientScript = path.join(import.meta.dirname, 'remote-agent-session-repro-client.mjs') +const fixtureScript = path.join(import.meta.dirname, 'remote-agent-session-repro-fixture.mjs') +// Why: macOS limits Unix-domain socket paths to 104 bytes; the server profile +// creates nested daemon/runtime sockets below this disposable directory. +const scratch = mkdtempSync(path.join(os.tmpdir(), 'oa-')) +const profilePath = path.join(scratch, 'profile') +const projectPath = path.join(scratch, 'repo') +const binPath = path.join(scratch, 'bin') +const spawnMarkerPath = path.join(scratch, 'agent-spawns.txt') +const exitTriggerPath = path.join(scratch, 'exit-agent') +const childProcesses = new Set() +let server = null + +try { + mkdirSync(profilePath, { recursive: true }) + mkdirSync(projectPath, { recursive: true }) + mkdirSync(binPath, { recursive: true }) + execFileSync('git', ['init', projectPath], { stdio: 'ignore' }) + execFileSync( + 'git', + [ + '-C', + projectPath, + '-c', + 'user.name=Orca Repro', + '-c', + 'user.email=orca-repro@example.invalid', + 'commit', + '--allow-empty', + '-m', + 'Initial repro fixture' + ], + { stdio: 'ignore' } + ) + const fixtureAgentPath = installFixtureAgent(binPath) + writeFileSync( + path.join(profilePath, 'orca-data.json'), + JSON.stringify({ + settings: { agentCmdOverrides: { codex: quoteFixtureAgentCommand(fixtureAgentPath) } } + }) + ) + + const port = await reservePort() + const firstReady = await startServer(port) + const pairingCode = firstReady.pairing.url + + const addedRepo = await callClient(pairingCode, 'repo.add', { path: projectPath }) + assertOk(addedRepo, 'fixture repo registration') + const worktreeList = await callClient(pairingCode, 'worktree.detectedList', { + repo: `id:${addedRepo.result.repo.id}` + }) + assertOk(worktreeList, 'fixture worktree discovery') + const fixtureWorktree = worktreeList.result.worktrees.find( + (candidate) => candidate.repoId === addedRepo.result.repo.id + ) + if (!fixtureWorktree) { + throw new Error( + `fixture worktree was not discovered: ${JSON.stringify(worktreeList.result.worktrees)}` + ) + } + const worktree = `id:${fixtureWorktree.id}` + const resumeRequest = { + kind: 'explicit', + worktree, + agent: 'codex', + providerSession: { key: 'session_id', id: 'remote-authority-repro' }, + presentation: 'background' + } + + const [first, second] = await Promise.all([ + callClient(pairingCode, 'terminal.ensureAgentSession', resumeRequest), + callClient(pairingCode, 'terminal.ensureAgentSession', resumeRequest) + ]) + assertOk(first, 'first racing resume') + assertOk(second, 'second racing resume') + const dispositions = [first.result.disposition, second.result.disposition].sort() + assertJsonEqual(dispositions, ['adopted', 'created'], 'race dispositions') + assertSameTerminal(first.result.terminal, second.result.terminal) + await waitFor(() => countSpawnMarkers() === 1, 'exactly one fixture agent spawn') + + const retry = await callClient(pairingCode, 'terminal.ensureAgentSession', resumeRequest) + assertOk(retry, 'resume retry') + if (retry.result.disposition !== 'adopted') { + throw new Error(`resume retry was ${retry.result.disposition}, expected adopted`) + } + assertSameTerminal(first.result.terminal, retry.result.terminal) + if (countSpawnMarkers() !== 1) { + throw new Error('resume retry started a second agent') + } + + const closed = await callClient(pairingCode, 'terminal.close', { + terminal: first.result.terminal.handle + }) + assertOk(closed, 'fixture terminal close') + await waitFor(async () => { + const [terminals, tabs] = await Promise.all([ + callClient(pairingCode, 'terminal.list', { worktree }), + callClient(pairingCode, 'session.tabs.list', { worktree }) + ]) + return ( + terminals.ok && + tabs.ok && + terminals.result.terminals.length === 0 && + tabs.result.tabs.length === 0 + ) + }, 'exited surface retirement') + + const oldTerminal = first.result.terminal + if (oldTerminal.tabId && oldTerminal.paneKey) { + const leafId = oldTerminal.paneKey.slice(oldTerminal.paneKey.indexOf(':') + 1) + await callClient(pairingCode, 'session.tabs.updatePaneLayout', { + worktree, + tabId: oldTerminal.tabId, + root: { type: 'leaf', id: leafId, ptyId: oldTerminal.ptyId ?? undefined } + }).catch(() => null) + } + + const [afterStaleTerminals, afterStaleTabs] = await Promise.all([ + callClient(pairingCode, 'terminal.list', { worktree }), + callClient(pairingCode, 'session.tabs.list', { worktree }) + ]) + assertOk(afterStaleTerminals, 'terminal list after stale publication') + assertOk(afterStaleTabs, 'tab list after stale publication') + assertJsonEqual(afterStaleTerminals.result.terminals, [], 'terminal stale-write resurrection') + assertJsonEqual(afterStaleTabs.result.tabs, [], 'tab stale-write resurrection') + + await stopServer() + const restarted = await startServer(port) + const restartPairingCode = restarted.pairing.url + const [afterRestartTerminals, afterRestartTabs] = await Promise.all([ + callClient(restartPairingCode, 'terminal.list', { worktree }), + callClient(restartPairingCode, 'session.tabs.list', { worktree }) + ]) + assertOk(afterRestartTerminals, 'terminal list after restart') + assertOk(afterRestartTabs, 'tab list after restart') + assertJsonEqual(afterRestartTerminals.result.terminals, [], 'terminal resurrection after restart') + assertJsonEqual(afterRestartTabs.result.tabs, [], 'tab resurrection after restart') + + process.stdout.write( + 'PASS remote agent-session authority: one spawn, retry adoption, durable exit retirement, no restart resurrection\n' + ) +} finally { + await stopServer().catch(() => {}) + for (const child of childProcesses) { + child.kill() + } + rmSync(scratch, { recursive: true, force: true }) +} + +function installFixtureAgent(targetDir) { + const nodePath = process.execPath + if (process.platform === 'win32') { + const commandPath = path.join(targetDir, 'codex.cmd') + writeFileSync(commandPath, `@"${nodePath}" "${fixtureScript}" %*\r\n`) + return commandPath + } + const commandPath = path.join(targetDir, 'codex') + writeFileSync( + commandPath, + `#!/bin/sh\nexec ${shellQuote(nodePath)} ${shellQuote(fixtureScript)} "$@"\n` + ) + chmodSync(commandPath, 0o755) + return commandPath +} + +function quoteFixtureAgentCommand(commandPath) { + return process.platform === 'win32' + ? `"${commandPath.replaceAll('"', '""')}"` + : shellQuote(commandPath) +} + +function shellQuote(value) { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +async function reservePort() { + return await new Promise((resolve, reject) => { + const listener = net.createServer() + listener.once('error', reject) + listener.listen(0, '127.0.0.1', () => { + const address = listener.address() + const port = typeof address === 'object' && address ? address.port : 0 + listener.close((error) => (error ? reject(error) : resolve(port))) + }) + }) +} + +async function startServer(port) { + const electronPath = await import('electron').then((module) => module.default) + const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'PATH' + const pathDelimiter = process.platform === 'win32' ? ';' : ':' + const env = { + ...process.env, + [pathKey]: `${binPath}${pathDelimiter}${process.env[pathKey] ?? ''}`, + ORCA_DEV_USER_DATA_PATH: profilePath, + ORCA_USER_DATA_PATH: profilePath, + ORCA_REPRO_SPAWN_MARKER: spawnMarkerPath, + ORCA_REPRO_EXIT_TRIGGER: exitTriggerPath, + ...(process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: '1' } : {}) + } + server = spawn( + electronPath, + [ + repoRoot, + '--serve', + '--serve-json', + '--serve-port', + String(port), + '--serve-pairing-address', + `127.0.0.1:${port}` + ], + { cwd: repoRoot, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true } + ) + childProcesses.add(server) + let stderr = '' + server.stderr.on('data', (chunk) => { + stderr += String(chunk) + }) + const lines = createInterface({ input: server.stdout }) + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`server readiness timed out\n${stderr}`)) + }, 30_000) + lines.on('line', (line) => { + try { + const parsed = JSON.parse(line) + if (parsed.type === 'orca_server_ready' && parsed.pairing?.url) { + clearTimeout(timeout) + resolve(parsed) + } + } catch { + // Startup diagnostics are allowed before the one structured ready line. + } + }) + server.once('exit', (code) => { + clearTimeout(timeout) + reject(new Error(`server exited before readiness with code ${code}\n${stderr}`)) + }) + server.once('error', reject) + }) +} + +async function stopServer() { + const current = server + server = null + if (!current) { + return + } + childProcesses.delete(current) + if (current.exitCode !== null) { + return + } + current.kill('SIGTERM') + await new Promise((resolve) => { + const timeout = setTimeout(() => { + current.kill('SIGKILL') + resolve() + }, 8_000) + current.once('exit', () => { + clearTimeout(timeout) + resolve() + }) + }) +} + +async function callClient(pairingCode, method, params) { + return await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [clientScript, pairingCode, method, JSON.stringify(params)], + { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true } + ) + childProcesses.add(child) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { + stdout += String(chunk) + }) + child.stderr.on('data', (chunk) => { + stderr += String(chunk) + }) + child.once('error', reject) + child.once('exit', (code) => { + childProcesses.delete(child) + try { + const response = JSON.parse(stdout.trim()) + if (code !== 0 && response.ok !== false) { + reject(new Error(`client ${method} exited ${code}: ${stderr}`)) + return + } + resolve(response) + } catch (error) { + reject( + new Error(`client ${method} returned invalid JSON: ${stdout}\n${stderr}`, { + cause: error + }) + ) + } + }) + }) +} + +function countSpawnMarkers() { + if (!existsSync(spawnMarkerPath)) { + return 0 + } + return readFileSync(spawnMarkerPath, 'utf8').split(/\r?\n/).filter(Boolean).length +} + +async function waitFor(predicate, description) { + const deadline = Date.now() + 15_000 + let lastError = null + while (Date.now() < deadline) { + try { + if (await predicate()) { + return + } + } catch (error) { + lastError = error + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`timed out waiting for ${description}`, { cause: lastError }) +} + +function assertOk(response, description) { + if (!response?.ok) { + throw new Error(`${description} failed: ${JSON.stringify(response)}`) + } +} + +function assertSameTerminal(left, right) { + assertJsonEqual( + [left.handle, left.tabId, left.paneKey, left.ptyId], + [right.handle, right.tabId, right.paneKey, right.ptyId], + 'canonical terminal identity' + ) +} + +function assertJsonEqual(actual, expected, description) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `${description}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}` + ) + } +} diff --git a/config/scripts/remote-agent-session-repro-client.mjs b/config/scripts/remote-agent-session-repro-client.mjs new file mode 100644 index 00000000000..13d418b95cb --- /dev/null +++ b/config/scripts/remote-agent-session-repro-client.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module' +import path from 'node:path' + +const require = createRequire(import.meta.url) +const repoRoot = path.resolve(import.meta.dirname, '..', '..') +const { parsePairingCode } = require(path.join(repoRoot, 'out', 'shared', 'pairing.js')) +const { RemoteRuntimeRequestConnection } = require( + path.join(repoRoot, 'out', 'shared', 'remote-runtime-request-connection.js') +) + +const [pairingCode, method, rawParams] = process.argv.slice(2) +const pairing = pairingCode ? parsePairingCode(pairingCode) : null +if (!pairing || !method || rawParams === undefined) { + console.error('usage: remote-agent-session-repro-client ') + process.exit(2) +} + +const connection = new RemoteRuntimeRequestConnection(pairing) +try { + const response = await connection.request(method, JSON.parse(rawParams), 20_000) + process.stdout.write(`${JSON.stringify(response)}\n`) + if (!response.ok) { + process.exitCode = 1 + } +} finally { + connection.close() +} diff --git a/config/scripts/remote-agent-session-repro-fixture.mjs b/config/scripts/remote-agent-session-repro-fixture.mjs new file mode 100644 index 00000000000..a19be3f7188 --- /dev/null +++ b/config/scripts/remote-agent-session-repro-fixture.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +import { appendFileSync, existsSync } from 'node:fs' + +const markerPath = process.env.ORCA_REPRO_SPAWN_MARKER +const exitTriggerPath = process.env.ORCA_REPRO_EXIT_TRIGGER +if (!markerPath || !exitTriggerPath) { + process.exit(2) +} + +appendFileSync(markerPath, `${process.pid}:${process.ppid}\n`) + +const interval = setInterval(() => { + if (!existsSync(exitTriggerPath)) { + return + } + clearInterval(interval) + try { + // Why: the agent is a child of the startup shell; terminating that shell + // produces a real PTY exit instead of merely returning to its prompt. + process.kill(process.ppid, 'SIGTERM') + } catch { + // The parent may already have exited after the trigger was observed. + } + process.exit(0) +}, 25) + +process.on('SIGTERM', () => process.exit(0)) +process.on('SIGINT', () => process.exit(0)) diff --git a/docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md b/docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md index 819269d1efa..8a31a894b07 100644 --- a/docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md +++ b/docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md @@ -373,9 +373,10 @@ with the lifecycle request: `killPtys:false`; a renderer-owned parent or partial split remains with its authoritative owner. Thus a reusable tab ID or incomplete provider read cannot become destructive authority. -6. Reasonless legacy-client closes on a new host are successful conservative - refusals with an authoritative replay. Older clients and servers therefore - degrade to a visible retained tab, not terminal loss. +6. Reasonless closes from authenticated legacy mobile or runtime clients retain + their pre-change explicit-user meaning, so upgrading only the host does not + break close. Their old lifecycle/user ambiguity remains until the client + upgrades; unattributed in-process reasonless calls are refused and replayed. 7. Renderer close intents are scoped by runtime environment and worktree, and terminal-incarnation evidence must match that exact runtime environment. Identical tab/worktree IDs in another profile cannot suppress or authorize @@ -513,7 +514,7 @@ scenarios in 99.1 seconds; the practical Electron clean-relaunch check passed again in 24.0 seconds, and mixed-version retirement/live-session preservation passed 2/2 in 23.1 seconds. -Latest focused verification includes 14/14 host adjudication tests, 948/949 +Latest focused verification includes 15/15 host adjudication tests, 948/949 focused production/RPC/renderer tests, 133/133 daemon discovery/adoption/ retirement/access-failure tests, all three typechecks, and 215 remote-runtime, multi-client, remote-server, SSH-provider, WSL-context, remote @@ -533,8 +534,8 @@ additional contract gaps: - a stale PTY-exit callback could borrow a replacement or sibling handle from tab-wide state; lifecycle evidence now comes only from the exact callback PTY; - the legacy close endpoint accepted lifecycle reasons without incarnation - evidence and refused old mobile close taps; it now accepts only explicit user - intent, with reasonless compatibility limited to authenticated mobile clients; + evidence; it now accepts only explicit user intent, with reasonless + compatibility retained for authenticated legacy mobile and runtime clients; - keep-on-unknown preserved the PTY but could leave its client mirror hidden; the host now republishes unchanged authority when inventory is unavailable. @@ -543,7 +544,7 @@ the fixture protocol list collision-safe. The final native no-build run passed both scenarios in 98.8 seconds, the fresh-build run passed in 137.1 seconds, mixed-version retirement passed, and practical Electron restart/input passed two scenarios with one intentionally skipped wedge scenario. Current-main -remote recovery (112 tests), focused close/reconnect suites, all 14 host +remote recovery (112 tests), focused close/reconnect suites, all 15 host adjudication cases, all typechecks, reliability gates, max-lines, changed lint, formatting, and diff checks pass. The full lint command remains blocked only by pre-existing current-main switch-exhaustiveness and localization findings; no @@ -600,7 +601,7 @@ source-text assertion that does not match CRLF on Windows. | Reconnect bursts, app quit/relaunch, simultaneous clients, repeated IDs, profile and remote-runtime boundaries | Three router rebuilds, a full reconnect-client process exit/relaunch, parallel direct clients, and six desktop/two-profile lifecycle attempts per persisted ID are native. Environment-scoped close-intent and remote-runtime transport suites supply deterministic profile-switch/remote-server boundary proof. | Proven (native transport plus deterministic profile/provider boundaries) | | `shutdown-dispose-failed` has a bounded non-authoritative state without conflating process death | The native refusal fixture loses pipe authority within the deadline, rejects a late client, logs the failure, proves its daemon/root/descendant still live, then cleans only exact recorded fixture incarnations. | Proven | | Smallest immediate fix preserves legacy adoption and separates broader retirement/descendant cleanup | Additive `session.tabs.closeLifecycle`, host liveness/incarnation adjudication, no destructive fallback, and state-only dead-headless retirement leave hello/adoption, #9138/#9229 retirement, and #9704/#9752 descendant semantics unchanged. | Proven | -| Missing evidence keeps/audits; retirement is incarnation/profile safe; one owner has destructive authority | Host tests cover unavailable inventory, stale publication/handle, live split siblings, renderer ownership, and reasonless clients. Renderer tests cover exact environment handles and cross-profile intent isolation. Lifecycle requests never signal a PTY or relay renderer teardown. | Proven | +| Missing evidence keeps/audits; retirement is incarnation/profile safe; one owner has destructive authority | Host tests cover unavailable inventory, stale publication/handle, live split siblings, renderer ownership, and authenticated legacy versus unattributed reasonless callers. Renderer tests cover exact environment handles and cross-profile intent isolation. Lifecycle requests never signal a PTY or relay renderer teardown. | Proven | | Windows identity, dead-parent/never-adopted, ACL/access failure, rapid reconnect, and multi-client behavior | Native CIM `CreationDate` identity is exact; the five-generation run covers rapid reconnect and concurrent clients. The 133-test daemon group covers never-adopted retirement, admission fencing, overlapping clients, and EACCES/EPERM process-signal failures. | Proven (native identity/reconnect; deterministic ACL failure) | | No production PowerShell/CIM hot path, polling/listener/handle leak, or reconnect storm | Process enumeration exists only in fixture helpers; production adds no subprocess or timer. Refreshes coalesce by environment/worktree, listener ownership is unchanged, every fixture allocation has bounded cleanup, and 25-burst stress evidence is recorded above. | Proven | | Cross-platform, SSH, WSL, remote-server, and multiple-client compatibility | 215 deterministic tests cover remote runtime/server, shared control, SSH provider, WSL host context, and PTY transport. Platform-specific fixture behavior is runtime-gated. | Deterministic proof complete; live Linux SSH/WSL unavailable | diff --git a/docs/reference/remote-agent-session-host-authority.md b/docs/reference/remote-agent-session-host-authority.md new file mode 100644 index 00000000000..80a905abef7 --- /dev/null +++ b/docs/reference/remote-agent-session-host-authority.md @@ -0,0 +1,456 @@ +# Remote agent-session host authority + +Status: implemented single-PR v1 design for issues #8878 and #9352; deterministic validation complete. + +## Reliability contract + +- **Invariant (`agent-session.remote-host-authority`):** one provider-session identity has at most one live PTY owner and canonical host surface on every claim-capable route; exact exit retires that incarnation durably. +- **Failure source:** issues #8878 and #9352, including concurrent remote clients, ambiguous replies, exit-before-publication, and stale client snapshots. +- **Oracle:** the focused ownership/lifecycle matrix and `pnpm test:repro:remote-agent-session` prove one physical spawn, canonical retry adoption, exact exit retirement, stale-publication rejection, and no restart resurrection. +- **Gate:** the experimental `agent-session.remote-host-authority` entry in `config/reliability-gates.jsonc`. +- **Coverage:** deterministic macOS local/daemon/remote-runtime evidence plus SSH/relay fault-injection; Linux, Windows, WSL, and live SSH remain explicit gaps. +- **Performance budget:** no polling or terminal-output work; admission-only provider reconciliation is inflight-deduped, and operation state is capped and expiring. +- **Diagnostics:** structured RPC error codes, PTY incarnation IDs, owner generations, operation dispositions, and the repro artifact distinguish fallback, adoption, conflict, and retirement failures. +- **Residual gaps:** durable fresh-operation journaling, automatic sleep checkpoints, verified nested-SSH namespaces, and multi-process profile coordination are documented under Future extensions. + +## Summary + +A remote Orca host, not an attached renderer, decides whether a provider agent +session already has a live PTY. Clients send structured intent (fresh launch or +explicit provider identity); the host returns one canonical terminal surface. + +This fixes two related failures: + +- A paired client could consume its own persisted sleep record and launch a + second TUI while the remote host still owned the first one (#8878). +- An exited host terminal could remain in `session.tabs.list` as a handle-less + placeholder, get persisted by clients, and return as a ghost tab (#9352). + +The v1 protocol deliberately fails closed after authority side effects begin. +At mixed-version boundaries the host may return +`agent_session_legacy_required`, but only after a read-only execution-owner +check and before trust, claim, spawn, or any retained replay fence. The client can then run +its retained exact legacy request, so upgrading any subset of clients, hosts, +daemons, or relays does not remove workflows that worked before the upgrade. + +## Scope and guarantees + +This change guarantees: + +1. Runtime-owned worktrees always queue renderer sleep records into the normal + transport. A fully capable route turns that intent into an authoritative + ensure/adoption; mixed-version routes preserve the legacy wake behavior. +2. On capable hosts, known provider sessions resume through a structured + `terminal.ensureAgentSession` request. +3. On claim-capable execution routes, concurrent or repeated ensures for the + same canonical provider identity return one execution owner and one + canonical terminal surface. +4. A fresh launch uses `terminal.createAgentSession` with a caller-scoped + operation ID. Stable runtime surface identity, daemon session identity, and + relay operation identity prevent response-loss retries from creating a + second process while their respective owner remains alive. +5. New hosts continue accepting agent-bearing legacy terminal-create RPCs, so + old clients behave exactly as they did before the host upgrade. +6. Structured explicit resume returns `agent_session_legacy_required` before + side effects when a daemon is old or an SSH route cannot attest its execution + namespace. A claim-capable route still fails closed on malformed, + conflicting, or unknown ownership after dispatch. +7. Natural or explicit PTY exit retires only the exact PTY incarnation and + terminal surface from both `terminal.list` and `session.tabs.list`, repairs + active/group topology, and removes durable host persistence. +8. New clients use structured authority only when advertised. Capability + absence or a transient read-only probe failure selects the exact legacy + payload; protocol incompatibility remains blocked. + +The following are not v1 guarantees: + +- automatic resume of an intentionally sleeping remote agent; +- fresh-launch exactly-once behavior across a full runtime process restart; +- host-authoritative deduplication of resumes through an unverified + direct/nested SSH execution namespace (those launches retain legacy behavior); +- supervising a provider process after its owning PTY exits; +- coordinating multiple independent Orca main processes for one profile; +- preventing a nonconforming new client from deliberately sending the same + legacy wire request as an old client; authenticated request-level capability + negotiation does not yet exist, so the server cannot distinguish them. + +Those constraints are explicit so future work can extend the protocol without +weakening the v1 safety boundary. + +## Authority model + +There are three layers: + +| Layer | Responsibility | +| --------------------------------- | --------------------------------------------------------------------------------------- | +| Client/renderer | Sends structured intent and mirrors host snapshots | +| Runtime/controller | Resolves worktree and provider identity, signs a claim, publishes the canonical surface | +| Execution owner (daemon or relay) | Atomically claim-or-spawn, prove liveness, and recover live claims from listings | + +The execution owner is the lowest process that can atomically answer “is there +already a live PTY for this agent identity?” Keeping the registry there closes +the race between multiple runtime calls. The controller also keeps a registry +above providers so separate local/SSH routes cannot independently claim the +same identity. + +## Structured requests + +### Explicit resume + +`terminal.ensureAgentSession` accepts only a supported agent and normalized +provider identity: + +```ts +{ + kind: 'explicit' + worktree: string + agent: ResumableTuiAgent + providerSession: { + key: 'session_id' | 'conversation_id' + id: string + transcriptPath?: string + } + agentArgs?: string | null + launchPreferences?: { model?: string; effort?: string; mode?: string } + presentation?: 'focused' | 'background' + placement?: { tabId?: string; leafId?: string } +} +``` + +The host canonicalizes the provider identity, binds it to the execution +namespace and canonical worktree, and signs a digest claim. Raw resume commands +do not cross a claim-capable boundary; compatibility-selected legacy and +unverified nested SSH paths retain their prior opaque command behavior. + +The execution owner performs one atomic operation: + +```text +claim absent -> reserve -> spawn -> publish live owner -> created +claim live -> prove PTY liveness -> return canonical owner -> adopted +claim unknown -> fail closed; do not spawn +claim conflict -> fail closed; do not spawn +``` + +Only an adopted owner may override the requested tab, leaf, handle, or PTY ID. +A fresh provider result must match the surface requested by the host. + +### Fresh launch + +`terminal.createAgentSession` accepts structured agent, prompt-delivery mode, +launch preferences, optional explicit agent arguments, and a cryptographically +random client operation ID. Draft prompts remain drafts; submitted prompts use +the normal startup-delivery path. Omitted agent arguments preserve host +defaults, while an explicit string is preserved as a client override and an +explicit null/empty value clears host argument defaults. Free-form client +environment variables are deliberately not accepted because PATH, loaders, and +other process authority remain host-owned. + +The runtime reserves the caller-scoped operation before any asynchronous +workspace or capability preflight, then fingerprints the host-resolved request +under the authenticated device identity: + +- same caller + operation ID + same fingerprint returns `replayed`; +- same caller + operation ID + different fingerprint fails; +- malformed, future-dated, expired, or over-capacity operations fail closed. + +The operation ledger is memory-bound and retained for 24 hours. Pre-spawn +failures release the entry for a safe retry. Once PTY creation commits, or the +provider reports an unknown physical outcome, the same rejected promise remains +as the replay fence. This handles response loss and ordinary reconnects to the +same running host. It does not claim exactly-once creation after the runtime +process itself restarts. + +Physical commit is the native-spawn boundary, not listener registration or +surface publication. The in-process provider reports it immediately after +`node-pty` returns; daemon and relay paths report it when their lower owner +returns from spawn/create-or-attach. Commit reporting is one-shot across these +layers. Any later error retains the operation fence because the PTY may already +exist even if publication failed. + +The runtime derives the execution-operation ID, tab ID, leaf ID, and terminal +handle deterministically from the authenticated operation. A daemon-backed +spawn derives a legacy-length session ID from that execution operation, so +`createOrAttach` returns the same PTY after a lost response without shrinking +the accepted worktree-ID boundary or skipping first-spawn setup. An SSH +provider performs a bounded read-only relay probe before structured work: + +- a relay advertising `agentSessionCreateOperationVersion: 1` receives the + operation ID and replays one successful spawn result for 24 hours; +- an older, malformed, or temporarily unreachable relay makes the host return + `agent_session_legacy_required`, after which the client sends its unchanged + legacy payload; +- negative relay capability results are not pinned, so an in-place upgrade is + observed on the next request. + +Relay operation-owned PTYs survive stale request contexts so the retry can +recover the same PTY and incarnation. Ordinary stale shell spawns keep the +existing cleanup behavior. + +## Claim identity + +The claim contains no raw provider session ID. A host-only signer hashes: + +- normalized agent/provider identity; +- canonical worktree scope; +- execution machine and principal; +- container/runtime namespace; +- a conservative provider-root bucket. The v1 implementation deliberately + merges account roots for an agent, which can produce a safe conflict but + cannot authorize duplicate execution. + +The wire binding includes a key ID, digest version, identity digest, worktree +scope digest, and agent kind. Owner state adds a random generation, PTY ID, and +canonical surface. + +Generation and PTY-incarnation guards prevent a late exit or liveness result +from releasing, retiring, or adopting a replacement owner that reused the same +PTY ID. + +## Recovery and failure semantics + +Before every claimed ensure, the controller stages complete listings from local +and registered SSH providers, validates every owner, and atomically replaces +the authoritative portion of its registry. Absent owners are pruned only for +providers whose listing is authoritative; disconnected scopes retain their +fence. Active reservations survive reconciliation. Valid metadata also rebuilds +PTY-to-provider routing. + +Controller-owned in-process fallback claims are intentionally not serialized +in ordinary local process listings. Their listing absence is therefore not +authoritative: the controller keeps the claim while the exact PTY incarnation +remains listed and releases it through the normal exit path. Daemon routers and +degraded providers likewise advertise listing authority only for a proven +PTY-to-provider route; an unknown ID never falls through to an unrelated +current/fallback provider for this decision. + +Recovery is fail closed: + +- owner PTY differs from the listed session: `agent_session_ownership_unknown`; +- two listings disagree about an identity or generation: + `agent_session_conflict`; +- the recorded provider is disconnected or unregistered: + `execution_owner_unavailable`; +- a claim-bearing spawn reaches a daemon/relay without execution-owner claim + protocol v2 (including PTY incarnation proof): + `agent_session_claim_unavailable`. Nested SSH routes that cannot construct a + claim are selected into legacy behavior before this boundary. + +Unknown liveness never means dead. A transient relay outage therefore retains +the claim and cannot authorize a replacement agent. + +Daemon adoption is attach-only. If the owner exits between liveness proof and +attach, the request fails rather than falling through to a new unclaimed shell. + +Serialized relay shell state intentionally omits provider claims. Spawn-based +revival creates a new shell and cannot inherit authority from the old process. + +## Renderer behavior + +Runtime-owned worktrees queue cached resume evidence through the same mounted +pane transport regardless of capability-cache timing. A capable host adopts or +creates one canonical owner; an old host or execution owner receives the exact +legacy launch. A cold or expired cache therefore cannot bypass authority or +remove the pre-change workflow. + +AI Vault resumes use provider metadata for agents with a structured identity, +including Antigravity conversation IDs and Pi transcript/session paths. If +metadata or host capability is absent, Orca preserves the prior opaque legacy +resume request instead of blocking the user. + +Background launches, quick launches, and mounted remote panes use +`terminal.createAgentSession` on capable hosts. Otherwise each call site sends +the exact pre-change `terminal.create` or `session.tabs.createTerminal` payload. +The common router calls legacy after only these safe outcomes: the host capability +is unavailable before dispatch; the structured RPC returns the stable +pre-side-effect `agent_session_legacy_required` code; or a replaced old host +returns `method_not_found`, proving it never recognized the structured request. +Timeouts, malformed results, and every other structured error never downgrade. + +Structured create/ensure responses record an exact +environment/worktree/provisional-tab to canonical-host-tab handoff. Snapshot +reconciliation removes a provisional pane only when its requested tab ID is +mirrored or that explicit handoff points to a host tab in the snapshot; agent +kind alone is never identity. This prevents an unrelated Claude/Codex session +from deleting a same-agent automatic-resume pane. The matched pane's pending +startup and automatic-resume claim are removed atomically, and the client +re-accepts the current host snapshot in case it arrived before the response. + +Every structured result is host-owned. If snapshot handoff destroys the +provisional transport while create/ensure is in flight, late completion cannot +close the canonical PTY even when reconciliation is still catching up. + +After the host accepts creation, a later tab-move or snapshot-refresh failure +still returns `created`. Reporting the launch as failed would invite a retry +with a new operation ID and could duplicate the fresh agent. + +## Exit and persistence lifecycle + +PTY exit is terminal authority. A generic persisted `sleeping` row is not a +reason to preserve a surface. Only an exact, runtime-owned stop transaction may +temporarily preserve the intentional handle-less surface. Otherwise the +runtime: + +1. verifies the PTY incarnation and identifies the exact worktree/tab/leaf; +2. removes that leaf from the host snapshot; +3. removes an empty parent tab; +4. repairs split groups, active group/tab, recent order, and layout; +5. removes the terminal binding from the persisted host workspace session and + advances that repo's host topology revision; +6. synchronously flushes that retirement before publishing the in-memory + absence; +7. rebases later renderer writes onto the host's current terminal membership, + so metadata and layout edits remain writable but missing/live panes cannot + be added or removed by a stale client. + +The durable fence is one monotonic revision per affected repo, not one record +per historical close or deleted worktree. Its storage is therefore proportional +to repos plus current terminal surfaces. A real host-admitted spawn advances +the revision when it adds a tab or leaf, allowing fresh terminals after a +retirement while an older renderer snapshot remains unable to revive the old +surface. Legacy per-pane tombstones are accepted for mixed-version recovery +and collapsed into the repo revision on the next normalized write. +The revision remains private to each execution host: renderer hydration and +writes omit it, avoiding collisions when different hosts contain the same repo +ID. Each host preserves the revision while rebasing client session writes. + +An exit can also beat initial terminal registration. The runtime records that +PTY/incarnation before any surface exists, rejects registration of the same or +unproven incarnation before mutating provider/output sequence, execution +context, ownership, lease, binding, handle, terminal, or tab state. The native +callback or successful lower-owner return still reports physical-spawn commit +before this admission check so a lost provider response cannot authorize a +second fresh agent. A proven different incarnation, or an explicit new local +lifecycle for a provider that cannot report incarnation identity, clears the +fence. When registration rejects the recorded incarnation, that specific +caller's fence is released after rejection so repeated early-exit failures do +not accumulate process memory. + +Registration intent is explicit: the controller marks the expected PTY before +dispatch and clears that intent on every success or failure path. Surface +absence is never treated as evidence that registration is still in flight. +SSH and daemon providers also settle an attach/create response against any exit +that arrived in the same transport batch before returning control upward. This +keeps response/exit ordering and incarnation comparison at the layer that can +observe both events. + +“Explicitly killed” is treated as a normal terminal-gone lifecycle outcome in +the remote transport, not as an unexpected product-error toast. + +## Protocol and compatibility + +The runtime protocol remains v3, with minimum compatible client and server v2. +This change adds optional RPCs and fields, so a protocol fence would make a +rolling upgrade worse without providing an authorization boundary. The runtime +advertises `agent-session.host-authority.v1`; clients negotiate that capability +before choosing a launch path. + +| Client | Host | Result | +| ------ | ---- | -------------------------------------------------------------------------------------------------------- | +| New | New | Structured authority is enabled when the resolved execution owner also supports it. | +| New | Old | The client selects legacy before spawn, or falls back on safe `method_not_found`; behavior is unchanged. | +| Old | New | The host still accepts legacy agent-bearing terminal creates; behavior is unchanged. | +| Old | Old | Unchanged legacy behavior. | + +Capability probing is read-only. A transient probe failure may select legacy, +because no structured side effect has started. A real protocol compatibility +block is still surfaced and never bypassed. A capable host then checks the +resolved daemon or relay. Only `agent_session_legacy_required`, emitted before +trust, claim, spawn, or any retained replay fence, permits legacy; every later error stays on +the structured path. The other post-dispatch exception is `method_not_found` +from an old host, which proves the method could not have started. Host and +lower-owner unsupported verdicts are not pinned, and observing a new runtime ID +invalidates a predecessor's positive verdict, so rolling upgrades and process +replacement re-probe promptly. The SSH probe is bounded below the client RPC +timeout, concurrent callers have independent cancellation, successful +structured creates require PTY and incarnation identity, and request +cancellation is checked at the real provider seams: after asynchronous +capability/connection preflight and immediately before local native spawn, +daemon `createOrAttach`, or SSH `pty.spawn`. Once SSH dispatch begins, an +operation failure is treated as an unknown physical outcome rather than a safe +fresh retry. + +The same monotonic rule applies below the runtime: + +| Runtime/controller | Execution owner | Result | +| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------- | +| New | New daemon | Stable operation-derived session ID makes retry attach to the same PTY. | +| New | Old daemon | The host returns `legacy_required` before side effects; the client sends its exact old resume/create request. | +| New | New relay | Relay operation ledger replays the same PTY and incarnation. | +| New | Old relay | The host returns `legacy_required` before side effects; the client sends its exact old spawn request. | +| Old | New daemon/relay | New optional fields are absent, so pre-change behavior is unchanged. | + +This contract is monotonic: upgrading any subset never removes a workflow that +worked before. The bug fix activates only where every authority layer required +for that specific path can prove support. + +## Deterministic reproduction harness + +Run: + +```sh +pnpm test:repro:remote-agent-session +``` + +The harness builds Orca, starts a real headless Electron `orca serve` process on +an ephemeral port, and connects independent Node client processes over the +normal encrypted WebSocket pairing path. It creates and registers a real Git +repository in an isolated profile and uses the real daemon claim registry with +a controlled agent subprocess. No installed agent, external service, fixed +port, timing race, or Docker daemon is needed. + +It asserts: + +- two clients race the same structured resume; +- exactly one daemon subprocess is spawned; +- both clients receive the same canonical handle, tab, pane, and PTY; +- a retry that may have lost its earlier response adopts that owner; +- a real `terminal.close` produces PTY exit and both `terminal.list` and + `session.tabs.list` omit the surface; +- a stale layout publication cannot recreate the retired surface; +- restarting the serve process with the same profile cannot resurrect the + terminal or tab. + +Lower-level tests separately cover daemon attach races, controller recovery, +provider disconnects, conflicting listings, old SSH relays, malformed SSH +claim results, cancellation at physical provider seams, pre-publication native +spawn failures, exact provisional handoff, early exit before registration, and +exit-driven durable retirement. + +## Future extensions + +### Host-owned automatic sleep checkpoints + +Automatic remote sleep/resume should be added only as a host transaction: + +1. persist a random, generation-bound checkpoint before stopping; +2. publish a non-connectable transition state; +3. stop and verify the exact owner; +4. commit sleeping state only after the owner is gone; +5. consume the checkpoint atomically during ensure. + +Until this exists, renderer-local records may trigger resume intent but cannot +authorize a second owner on a claim-capable route. Compatibility-selected +legacy mode keeps the pre-change behavior. + +### Durable fresh-operation journal + +If fresh-launch exactly-once behavior must survive runtime restart, replace the +memory ledger with a profile-scoped durable journal. It must persist the caller, +operation ID, request fingerprint, canonical result/tombstone, and retention +deadline before returning success. Capacity must reject rather than evict an +unexpired tombstone. + +### Verified SSH execution namespaces + +Direct or nested SSH agent-session authority requires relay-attested machine, +principal, container, and provider-root identity plus a separately versioned +claim capability. Connection labels or target aliases are not proof. Until +that attestation exists, the host requests exact legacy fallback before spawn; +v1 does not claim deduplication for that route. + +### Multi-process coordination + +Supporting multiple Orca main processes against one profile requires an +OS-held coordinator lease around claim and journal mutation. The current v1 +contract coordinates clients of one runtime/controller process and its daemon +or registered relays. diff --git a/package.json b/package.json index d724005a273..e2765aca656 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", "prepare": "husky", "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", + "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", "check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", diff --git a/src/main/daemon/daemon-create-or-attach-result.ts b/src/main/daemon/daemon-create-or-attach-result.ts index 79417577ab7..d7a2ac64ba8 100644 --- a/src/main/daemon/daemon-create-or-attach-result.ts +++ b/src/main/daemon/daemon-create-or-attach-result.ts @@ -1,5 +1,7 @@ import type { TuiAgent } from '../../shared/types' import type { ShellReadyState, TerminalSnapshot } from './types' +import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type DaemonCreateOrAttachResult = { isNew: boolean @@ -10,6 +12,8 @@ export type DaemonCreateOrAttachResult = { launchAgent?: TuiAgent /** Undefined only when talking to a daemon predating WSL session context. */ wslDistro?: string | null + agentSessionEnsure?: AgentSessionClaimedSpawnResult + incarnationId?: PtyIncarnationId } export function getDaemonSessionResultMetadata(session: { diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 8e8a483dfd1..5e5e63e1104 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,10 +3,11 @@ 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(25) + expect(PROTOCOL_VERSION).toBe(26) 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) }) }) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts new file mode 100644 index 00000000000..9fb4784b687 --- /dev/null +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + AGENT_SESSION_CREATE_OPERATION_DAEMON_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) + 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) + ) + }) +}) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 6b5dfe8c376..e8c88774f53 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,10 +1,12 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -export const PROTOCOL_VERSION = 25 +export const PROTOCOL_VERSION = 26 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 + 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 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index af402b4a8ae..73c21f42f5d 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -129,6 +129,16 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { getMacDaemonSystemResolverHealthMock.mockResolvedValue('unknown') }) + it('reports whether its daemon protocol can participate in agent claims', () => { + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 23 }) + + expect(adapter.supportsAgentSessionClaims()).toBe(true) + expect(legacy.supportsAgentSessionClaims()).toBe(false) + expect(adapter.supportsAgentSessionCreateOperations()).toBe(true) + expect(legacy.supportsAgentSessionCreateOperations()).toBe(false) + legacy.dispose() + }) + afterEach(async () => { adapter?.dispose() await server?.shutdown() @@ -206,6 +216,135 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) + it('does not republish adapter state when stream exit beats the create reply', async () => { + const sessionId = 'exit-before-create-reply' + const exits: { id: string; incarnationId?: string }[] = [] + adapter.onExit((payload) => exits.push(payload)) + const client = ( + adapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const originalRequest = client.request.bind(client) + vi.spyOn(client, 'request').mockImplementation(async (type: string, payload?: unknown) => { + const response = await originalRequest(type, payload) + if (type === 'createOrAttach') { + const exitCount = exits.length + lastSubprocess._simulateExit(0) + await waitFor(() => exits.length === exitCount + 1) + } + return response + }) + + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(exits).toHaveLength(2) + expect(exits[0]?.incarnationId).toBeDefined() + expect(exits[1]?.incarnationId).toBeDefined() + expect(exits[1]?.incarnationId).not.toBe(exits[0]?.incarnationId) + const internals = adapter as unknown as { + activeSessionIds: Set + sessionIncarnations: Map + pendingSpawnOperationsBySessionId: Map + } + expect(internals.activeSessionIds.has(sessionId)).toBe(false) + expect(internals.sessionIncarnations.has(sessionId)).toBe(false) + expect(internals.pendingSpawnOperationsBySessionId.has(sessionId)).toBe(false) + }) + + it('does not republish an adopted canonical id when its exit beats the reply', async () => { + const claim = { + digestVersion: 1 as const, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' as const + } + const surface = { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed' + } + const canonicalId = 'canonical-claimed-session' + const first = await adapter.spawn({ + cols: 80, + rows: 24, + sessionId: canonicalId, + agentSessionEnsure: { claim, surface } + }) + expect(first.agentSessionEnsure?.disposition).toBe('created') + + const exits: { id: string; incarnationId?: string }[] = [] + adapter.onExit((payload) => exits.push(payload)) + const client = ( + adapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const originalRequest = client.request.bind(client) + vi.spyOn(client, 'request').mockImplementation(async (type: string, payload?: unknown) => { + const response = await originalRequest(type, payload) + if (type === 'createOrAttach') { + const exitCount = exits.length + lastSubprocess._simulateExit(0) + await waitFor(() => exits.length === exitCount + 1) + } + return response + }) + + const adopted = await adapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'different-requested-session', + agentSessionEnsure: { + claim, + surface: { ...surface, terminalHandle: 'term_retry' } + } + }) + + expect(adopted.id).toBe(canonicalId) + expect(adopted.agentSessionEnsure?.disposition).toBe('adopted') + expect(adapter.didExitBeforeSpawnReply(adopted)).toBe(true) + const internals = adapter as unknown as { + activeSessionIds: Set + sessionIncarnations: Map + pendingSpawnOperationsBySessionId: Map + pendingClaimSpawnOperations: Set + } + expect(internals.activeSessionIds.has(canonicalId)).toBe(false) + expect(internals.sessionIncarnations.has(canonicalId)).toBe(false) + expect(internals.pendingSpawnOperationsBySessionId.has('different-requested-session')).toBe( + false + ) + expect(internals.pendingClaimSpawnOperations.size).toBe(0) + }) + + it('does not dispatch createOrAttach when cancellation wins during preflight', async () => { + let finishPreflight: (() => void) | undefined + const preflight = new Promise((resolve) => { + finishPreflight = resolve + }) + const internals = adapter as unknown as { + ensureConnected(): Promise + client: { request: (...args: unknown[]) => Promise } + } + const ensureConnected = vi + .spyOn(internals, 'ensureConnected') + .mockImplementation(() => preflight) + const request = vi.spyOn(internals.client, 'request') + const abort = new AbortController() + + const spawning = adapter.spawn({ cols: 80, rows: 24, signal: abort.signal }) + await waitFor(() => ensureConnected.mock.calls.length === 1) + abort.abort() + finishPreflight?.() + + await expect(spawning).rejects.toThrow('client_disconnected') + expect(request).not.toHaveBeenCalledWith('createOrAttach', expect.anything()) + }) + it('uses worktreeId as session prefix when provided', async () => { const result = await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-1' }) expect(result.id).toContain('wt-1') @@ -656,7 +795,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { lastSubprocess._simulateExit(42) await waitFor(() => exits.length > 0) - expect(exits[0]).toEqual({ id, code: 42 }) + expect(exits[0]).toEqual({ id, code: 42, incarnationId: expect.any(String) }) }) }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index a8a8b7815c0..fc20ae5e110 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -10,6 +10,8 @@ import { supportsPtyStartupBarrier } from './shell-ready' import { CODEX_SHELL_READY_TIMEOUT_MS } from './session' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, + AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, PROTOCOL_VERSION, supportsPtyStartupIngress, @@ -20,6 +22,11 @@ import { type SessionInfo, type TakePendingOutputResult } from './types' +import { + isAgentSessionClaimedSpawnResult, + isAgentSessionOwnerBinding, + type AgentSessionOwnerBinding +} from '../../shared/agent-session-host-authority' import type { IPtyProvider, PtyBackgroundStreamEvent, @@ -34,6 +41,7 @@ import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' type ColdRestorePayload = { @@ -44,6 +52,12 @@ type ColdRestorePayload = { oscLinks?: TerminalOscLinkRange[] } +type PendingDaemonSpawnOperation = { + exitsBySessionId: Map + ignoredExitIncarnationIds: Set + ignoreNextExit: boolean +} + function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null { // Why: alt-screen snapshots are the TUI buffer; prefer its normal scrollback so a dead TUI isn't revived as the fresh shell's active screen. return restoreInfo.modes.alternateScreen @@ -108,7 +122,11 @@ export class DaemonPtyAdapter implements IPtyProvider { transformed?: boolean seq?: number }) => void)[] = [] - private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] + private exitListeners: ((payload: { + id: string + code: number + incarnationId?: PtyIncarnationId + }) => void)[] = [] private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] private removeEventListener: (() => void) | null = null private initialCwds = new Map() @@ -119,6 +137,9 @@ export class DaemonPtyAdapter implements IPtyProvider { private coldRestoreCache = new Map() private sleepRestoreSessionIds = new Set() private activeSessionIds = new Set() + private sessionIncarnations = new Map() + private pendingSpawnOperationsBySessionId = new Map>() + private pendingClaimSpawnOperations = new Set() private dirtySessionVersions = new Map() // Why: a cold-restored session is a fresh shell atop a pre-crash log; incremental appends would be rejected on restore, so the first tick re-anchors with a full snapshot. private sessionsNeedingFullCheckpoint = new Set() @@ -179,12 +200,54 @@ export class DaemonPtyAdapter implements IPtyProvider { return this.historyManager } - async spawn(opts: PtySpawnOptions): Promise { - return this.withDaemonRetry(() => this.doSpawn(opts)) + supportsAgentSessionClaims(): boolean { + return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION } - private async doSpawn(opts: PtySpawnOptions): Promise { + providesAgentSessionOwnerListings(_ptyId: string): boolean { + return this.supportsAgentSessionClaims() + } + + supportsAgentSessionCreateOperations(): boolean { + // Why: old daemons never advertised the lower-owner protocol, so preserve their legacy launch. + return this.protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION + } + + async spawn(opts: PtySpawnOptions): Promise { const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId) + const operation = { + exitsBySessionId: new Map(), + ignoredExitIncarnationIds: new Set(), + ignoreNextExit: false + } + const operations = this.pendingSpawnOperationsBySessionId.get(sessionId) ?? new Set() + operations.add(operation) + this.pendingSpawnOperationsBySessionId.set(sessionId, operations) + if (opts.agentSessionEnsure) { + this.pendingClaimSpawnOperations.add(operation) + } + try { + return await this.withDaemonRetry(() => this.doSpawn({ ...opts, sessionId }, operation)) + } finally { + this.pendingClaimSpawnOperations.delete(operation) + operations.delete(operation) + if (operations.size === 0) { + this.pendingSpawnOperationsBySessionId.delete(sessionId) + } + } + } + + private async doSpawn( + opts: PtySpawnOptions, + operation: PendingDaemonSpawnOperation + ): Promise { + if ( + opts.agentSessionEnsure && + this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION + ) { + throw new Error('agent_session_claim_unavailable') + } + let sessionId = opts.sessionId! let wslDistro = resolveWslSessionContext({ cwd: opts.cwd, sessionId, @@ -251,8 +314,11 @@ export class DaemonPtyAdapter implements IPtyProvider { ? CODEX_SHELL_READY_TIMEOUT_MS : undefined - const createOrAttach = (historySeed: string | null) => - this.client.request('createOrAttach', { + const createOrAttach = (historySeed: string | null) => { + if (opts.signal?.aborted) { + throw new Error('client_disconnected') + } + return this.client.request('createOrAttach', { sessionId, cols: effectiveCols, rows: effectiveRows, @@ -271,11 +337,31 @@ export class DaemonPtyAdapter implements IPtyProvider { ...(historySeed ? { historySeed } : {}), ...(this.supportsStartupIngress && opts.startupIngress ? { startupIngress: opts.startupIngress } - : {}) + : {}), + ...(opts.agentSessionEnsure ? { agentSessionEnsure: opts.agentSessionEnsure } : {}) }) + } let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null let result = await createOrAttach(scrollback) + if (opts.agentSessionEnsure && !isAgentSessionClaimedSpawnResult(result.agentSessionEnsure)) { + // Why: a claim-incapable owner may already have spawned before returning + // a malformed response; retire only this requested session before failing closed. + await this.client.request('kill', { sessionId }).catch(() => {}) + throw new Error('agent_session_claim_unavailable') + } + sessionId = result.agentSessionEnsure?.owner.ptyId ?? sessionId + const exitedResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) + if (exitedResult) { + return exitedResult + } + if (result.incarnationId) { + this.sessionIncarnations.set(sessionId, result.incarnationId) + } + const claimResult = (): Pick | Record => + result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {} + const incarnationResult = (): Pick | Record => + result.incarnationId ? { incarnationId: result.incarnationId } : {} let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro // Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons. wslDistro = providerWslDistro ?? undefined @@ -304,7 +390,9 @@ export class DaemonPtyAdapter implements IPtyProvider { } return { id: sessionId, + ...incarnationResult(), pid, + ...claimResult(), ...launchIdentity(), coldRestore: cachedRestore, ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), @@ -318,12 +406,24 @@ export class DaemonPtyAdapter implements IPtyProvider { restoreInfo = detectColdRestore({ ignoreCleanEnd: true }) scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null if (restoreInfo && scrollback) { - // Why: the aliveness probe raced with session death, so the first create lacked recovery bytes; replace it before exposing the PTY. + // Why: the aliveness probe raced with session death, so the first + // create lacked recovery bytes. Replace it before exposing the PTY. + if (result.incarnationId) { + operation.ignoredExitIncarnationIds.add(result.incarnationId) + } + operation.ignoreNextExit = true await this.client.request('kill', { sessionId, immediate: true }) effectiveCwd = restoreInfo.cwd effectiveCols = restoreInfo.cols effectiveRows = restoreInfo.rows result = await createOrAttach(scrollback) + const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) + if (exitedRetryResult) { + return exitedRetryResult + } + if (result.incarnationId) { + this.sessionIncarnations.set(sessionId, result.incarnationId) + } providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro wslDistro = providerWslDistro ?? undefined if (wslDistro) { @@ -363,7 +463,9 @@ export class DaemonPtyAdapter implements IPtyProvider { this.coldRestoreCache.set(sessionId, coldRestore) return { id: sessionId, + ...incarnationResult(), pid, + ...claimResult(), ...launchIdentity(), coldRestore, ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), @@ -373,7 +475,9 @@ export class DaemonPtyAdapter implements IPtyProvider { } return { id: sessionId, + ...incarnationResult(), pid, + ...claimResult(), ...launchIdentity(), ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}) @@ -405,7 +509,9 @@ export class DaemonPtyAdapter implements IPtyProvider { if (!isReattach || !result.snapshot) { return { id: sessionId, + ...incarnationResult(), pid, + ...claimResult(), ...launchIdentity(), ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}), @@ -422,7 +528,9 @@ export class DaemonPtyAdapter implements IPtyProvider { const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags return { id: sessionId, + ...incarnationResult(), pid, + ...claimResult(), ...launchIdentity(), ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), snapshot: snapshotPayload, @@ -441,6 +549,36 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + private resultForExitBeforeSpawnReply( + sessionId: string, + result: CreateOrAttachResult, + operation: PendingDaemonSpawnOperation + ): PtySpawnResult | null { + const matchingExit = (operation.exitsBySessionId.get(sessionId) ?? []).some( + (exit) => + !(exit.incarnationId && operation.ignoredExitIncarnationIds.has(exit.incarnationId)) && + (!exit.incarnationId || + !result.incarnationId || + exit.incarnationId === result.incarnationId) + ) + if (!matchingExit) { + return null + } + // Why: stream exit can beat the control reply; return proof upward without republishing dead adapter state. + const exitedResult: PtySpawnResult = { + id: sessionId, + exitedBeforeSpawnReply: true, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), + ...(!result.isNew ? { isReattach: true } : {}) + } + return exitedResult + } + + didExitBeforeSpawnReply(result: PtySpawnResult): boolean { + return result.exitedBeforeSpawnReply === true + } + async attach(id: string): Promise { await this.ensureConnected() if (!this.supportsAuthoritativeBufferSnapshots) { @@ -777,20 +915,42 @@ export class DaemonPtyAdapter implements IPtyProvider { const { worktreeId } = parsePtySessionId(s.sessionId) return { id: s.sessionId, + ...(s.incarnationId ? { incarnationId: s.incarnationId } : {}), // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. cwd: s.cwd ?? this.initialCwds.get(s.sessionId) ?? '', title: 'shell', ...(worktreeId ? { worktreeId } : {}), - ...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}) + ...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}), + ...this.validatedAgentSessionOwners(s.agentSessionOwners) } }) } - // Why keep both: the Manage Sessions panel needs full SessionInfo (pid/state/createdAt) that listProcesses drops for the IPtyProvider contract. + private validatedAgentSessionOwners( + owners: unknown + ): { agentSessionOwners: AgentSessionOwnerBinding[] } | Record { + if (owners === undefined) { + return {} + } + if (!Array.isArray(owners) || !owners.every(isAgentSessionOwnerBinding)) { + throw new Error('agent_session_ownership_unknown') + } + return owners.length > 0 ? { agentSessionOwners: owners } : {} + } + + // Why: the Manage Sessions panel needs the full SessionInfo (pid, state, + // createdAt) per session for display; listProcesses drops that detail for + // the IPtyProvider contract. Keep both in parallel rather than widening + // the provider surface. async listSessions(): Promise { await this.ensureConnected() const result = await this.client.request('listSessions', undefined) - return result.sessions.filter((s) => s.isAlive) + return result.sessions + .filter((s) => s.isAlive) + .map((session) => ({ + ...session, + ...this.validatedAgentSessionOwners(session.agentSessionOwners) + })) } getActiveSessionIds(): string[] { @@ -813,8 +973,15 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: don't catch listener throws — matches the natural onExit fanout so synthetic exits keep the same error semantics. // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration for (const listener of [...this.exitListeners]) { - listener({ id, code }) + listener({ + id, + code, + ...(this.sessionIncarnations.get(id) + ? { incarnationId: this.sessionIncarnations.get(id) } + : {}) + }) } + this.sessionIncarnations.delete(id) } } @@ -868,7 +1035,9 @@ export class DaemonPtyAdapter implements IPtyProvider { return () => {} } - onExit(callback: (payload: { id: string; code: number }) => void): () => void { + onExit( + callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void + ): () => void { this.exitListeners.push(callback) return () => { const idx = this.exitListeners.indexOf(callback) @@ -1350,6 +1519,29 @@ export class DaemonPtyAdapter implements IPtyProvider { fact: event.payload }) } else if (event.event === 'exit') { + const pendingOperations = new Set([ + ...(this.pendingSpawnOperationsBySessionId.get(event.sessionId) ?? []), + ...this.pendingClaimSpawnOperations + ]) + for (const operation of pendingOperations) { + if (operation.ignoreNextExit) { + operation.ignoreNextExit = false + continue + } + const exits = operation.exitsBySessionId.get(event.sessionId) ?? [] + exits.push( + event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {} + ) + operation.exitsBySessionId.set(event.sessionId, exits) + } + const currentIncarnationId = this.sessionIncarnations.get(event.sessionId) + if ( + event.payload.incarnationId && + currentIncarnationId && + event.payload.incarnationId !== currentIncarnationId + ) { + return + } this.activeSessionIds.delete(event.sessionId) this.dirtySessionVersions.delete(event.sessionId) // Why: a reused sessionId must not inherit the dead session's owed resume (stray resumePty) or backgrounded/thinned state. @@ -1371,9 +1563,14 @@ export class DaemonPtyAdapter implements IPtyProvider { } this.initialCwds.delete(event.sessionId) this.wslDistrosBySessionId.delete(event.sessionId) + this.sessionIncarnations.delete(event.sessionId) // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration for (const listener of [...this.exitListeners]) { - listener({ id: event.sessionId, code: event.payload.code }) + listener({ + id: event.sessionId, + code: event.payload.code, + ...(event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {}) + }) } } }) diff --git a/src/main/daemon/daemon-pty-provider.test.ts b/src/main/daemon/daemon-pty-provider.test.ts index f4d4b431ad6..50800ffec89 100644 --- a/src/main/daemon/daemon-pty-provider.test.ts +++ b/src/main/daemon/daemon-pty-provider.test.ts @@ -164,7 +164,7 @@ describe('DaemonPtyProvider', () => { lastSubprocess._simulateExit(42) await waitFor(() => exits.length > 0) - expect(exits[0]).toEqual({ id: 's1', code: 42 }) + expect(exits[0]).toEqual({ id: 's1', code: 42, incarnationId: expect.any(String) }) }) }) diff --git a/src/main/daemon/daemon-pty-provider.ts b/src/main/daemon/daemon-pty-provider.ts index b4bd2bd0284..2c75f9dcfb8 100644 --- a/src/main/daemon/daemon-pty-provider.ts +++ b/src/main/daemon/daemon-pty-provider.ts @@ -1,5 +1,6 @@ import { DaemonClient } from './client' import type { DaemonEvent, CreateOrAttachResult } from './types' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type DaemonPtyProviderOptions = { socketPath: string @@ -18,6 +19,7 @@ export type DaemonSpawnOptions = { export type DaemonSpawnResult = { id: string + incarnationId?: PtyIncarnationId isNew: boolean pid: number | null } @@ -25,7 +27,11 @@ export type DaemonSpawnResult = { export class DaemonPtyProvider { private client: DaemonClient private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] - private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] + private exitListeners: ((payload: { + id: string + code: number + incarnationId?: PtyIncarnationId + }) => void)[] = [] private removeEventListener: (() => void) | null = null constructor(opts: DaemonPtyProviderOptions) { @@ -51,6 +57,7 @@ export class DaemonPtyProvider { return { id: opts.sessionId, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), isNew: result.isNew, pid: result.pid } @@ -87,7 +94,9 @@ export class DaemonPtyProvider { } } - onExit(callback: (payload: { id: string; code: number }) => void): () => void { + onExit( + callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void + ): () => void { this.exitListeners.push(callback) return () => { const idx = this.exitListeners.indexOf(callback) @@ -120,7 +129,11 @@ export class DaemonPtyProvider { } } else if (event.event === 'exit') { for (const listener of this.exitListeners) { - listener({ id: event.sessionId, code: event.payload.code }) + listener({ + id: event.sessionId, + code: event.payload.code, + ...(event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {}) + }) } } }) diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index bf2d6ff8944..e003cbede2c 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -2,12 +2,16 @@ import { describe, expect, it, vi } from 'vitest' import { DaemonPtyRouter } from './daemon-pty-router' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' -import { GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION } from './types' +import { + AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION +} from './types' type AdapterMock = DaemonPtyAdapter & { emitData: (id: string, data: string, sequenceChars?: number) => void emitBackground: (event: PtyBackgroundStreamEvent) => void - emitExit: (id: string, code: number) => void + emitExit: (id: string, code: number, incarnationId?: string) => void } const LARGE_RECONCILE_SESSION_COUNT = 150_000 @@ -30,11 +34,18 @@ function createAdapter( const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = [] const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] - const exitListeners: ((payload: { id: string; code: number }) => void)[] = [] + const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] = + [] return { protocolVersion, supportsGitCredentialGuardHost: () => protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, + supportsAgentSessionClaims: () => + protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + supportsAgentSessionCreateOperations: () => + protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + providesAgentSessionOwnerListings: () => + protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, canProvideAuthoritativeBufferSnapshot: () => protocolVersion >= 20, spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { const id = opts.sessionId ?? `${label}-new` @@ -94,15 +105,17 @@ function createAdapter( } } }), - onExit: vi.fn((callback: (payload: { id: string; code: number }) => void) => { - exitListeners.push(callback) - return () => { - const idx = exitListeners.indexOf(callback) - if (idx !== -1) { - exitListeners.splice(idx, 1) + onExit: vi.fn( + (callback: (payload: { id: string; code: number; incarnationId?: string }) => void) => { + exitListeners.push(callback) + return () => { + const idx = exitListeners.indexOf(callback) + if (idx !== -1) { + exitListeners.splice(idx, 1) + } } } - }), + ), ackColdRestore: vi.fn(), clearTombstone: vi.fn(), reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), @@ -118,9 +131,9 @@ function createAdapter( listener(event) } }, - emitExit: (id: string, code: number) => { + emitExit: (id: string, code: number, incarnationId?: string) => { for (const listener of exitListeners) { - listener({ id, code }) + listener({ id, code, ...(incarnationId ? { incarnationId } : {}) }) } }, _writes: writes @@ -128,6 +141,130 @@ function createAdapter( } describe('DaemonPtyRouter', () => { + it('reports separate conservative resume and fresh-create boundaries', () => { + const current = createAdapter( + 'current', + [], + undefined, + AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION + ) + const legacy = createAdapter( + 'legacy', + [], + undefined, + AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION - 1 + ) + const mixed = new DaemonPtyRouter({ current, legacy: [legacy] }) + const old = new DaemonPtyRouter({ current: legacy, legacy: [] }) + + expect(mixed.supportsAgentSessionClaims()).toBe(false) + expect(mixed.supportsAgentSessionCreateOperations()).toBe(true) + expect(old.supportsAgentSessionClaims()).toBe(false) + expect(old.supportsAgentSessionCreateOperations()).toBe(false) + }) + + it('only treats owner listings as authoritative for a mapped daemon route', async () => { + const current = createAdapter( + 'current', + [], + undefined, + AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION + ) + const legacy = createAdapter( + 'legacy', + ['legacy-session'], + undefined, + AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION + ) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + const created = await router.spawn({ cols: 80, rows: 24 }) + + expect(router.providesAgentSessionOwnerListings('legacy-session')).toBe(true) + expect(router.providesAgentSessionOwnerListings(created.id)).toBe(true) + expect(router.providesAgentSessionOwnerListings('unknown-session')).toBe(false) + }) + + it('does not publish a route when the adapter proves exit before reply', async () => { + const current = createAdapter('current') + let finishSpawn: ((result: PtySpawnResult) => void) | undefined + vi.mocked(current.spawn).mockImplementation( + () => + new Promise((resolve) => { + finishSpawn = resolve + }) + ) + const router = new DaemonPtyRouter({ current, legacy: [] }) + + const spawning = router.spawn({ cols: 80, rows: 24, sessionId: 'raced-session' }) + finishSpawn?.({ + id: 'raced-session', + incarnationId: 'raced-incarnation', + exitedBeforeSpawnReply: true + }) + await expect(spawning).resolves.toMatchObject({ exitedBeforeSpawnReply: true }) + + const internals = router as unknown as { + sessionAdapters: Map + } + expect(internals.sessionAdapters.has('raced-session')).toBe(false) + }) + + it('routes a replacement when only an older incarnation exits during spawn', async () => { + const current = createAdapter('current') + let finishSpawn: ((result: PtySpawnResult) => void) | undefined + vi.mocked(current.spawn).mockImplementation( + () => + new Promise((resolve) => { + finishSpawn = resolve + }) + ) + const router = new DaemonPtyRouter({ current, legacy: [] }) + + const spawning = router.spawn({ cols: 80, rows: 24, sessionId: 'reused-session' }) + current.emitExit('reused-session', 0, 'incarnation-old') + finishSpawn?.({ id: 'reused-session', incarnationId: 'incarnation-current' }) + await spawning + + const internals = router as unknown as { + sessionAdapters: Map + } + expect(internals.sessionAdapters.get('reused-session')).toBe(current) + }) + + it('preserves canonical claimed-owner exit proof from the adapter', async () => { + const current = createAdapter('current') + let finishSpawn: ((result: PtySpawnResult) => void) | undefined + vi.mocked(current.spawn).mockImplementation( + () => + new Promise((resolve) => { + finishSpawn = resolve + }) + ) + const router = new DaemonPtyRouter({ current, legacy: [] }) + + const spawning = router.spawn({ + cols: 80, + rows: 24, + sessionId: 'requested-session', + agentSessionEnsure: {} as never + }) + finishSpawn?.({ + id: 'canonical-session', + incarnationId: 'canonical-incarnation', + exitedBeforeSpawnReply: true + }) + + await expect(spawning).resolves.toMatchObject({ + id: 'canonical-session', + exitedBeforeSpawnReply: true + }) + const internals = router as unknown as { + sessionAdapters: Map + } + expect(internals.sessionAdapters.has('canonical-session')).toBe(false) + }) + it('reports snapshot capability for the adapter that owns each session', async () => { const current = createAdapter('current', ['current-session'], undefined, 22) const legacy = createAdapter('legacy', ['legacy-session'], undefined, 19) diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 8400576eb32..4d1e3d723de 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -7,6 +7,7 @@ import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter @@ -20,7 +21,11 @@ export class DaemonPtyRouter implements IPtyProvider { transformed?: boolean seq?: number }) => void)[] = [] - private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] + private exitListeners: ((payload: { + id: string + code: number + incarnationId?: PtyIncarnationId + }) => void)[] = [] constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { this.current = opts.current @@ -60,7 +65,10 @@ export class DaemonPtyRouter implements IPtyProvider { const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined const target = adapter ?? this.current const result = await target.spawn(opts) - this.sessionAdapters.set(result.id, target) + // Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof. + if (!result.exitedBeforeSpawnReply) { + this.sessionAdapters.set(result.id, target) + } return result } @@ -69,6 +77,23 @@ export class DaemonPtyRouter implements IPtyProvider { return adapter.supportsGitCredentialGuardHost() } + supportsAgentSessionClaims(): boolean { + // Why: a legacy daemon may still own a resumable PTY, so authority requires every route. + return this.allAdapters().every((adapter) => adapter.supportsAgentSessionClaims()) + } + + providesAgentSessionOwnerListings(ptyId: string): boolean { + const adapter = this.sessionAdapters.get(ptyId) + // Why: an unmapped id may belong to any preserved daemon generation; + // only an established route can make an omitted owner authoritative. + return adapter?.providesAgentSessionOwnerListings(ptyId) === true + } + + supportsAgentSessionCreateOperations(): boolean { + // Fresh sessions always route to the current daemon; legacy adapters only retain old IDs. + return this.current.supportsAgentSessionCreateOperations() + } + async attach(id: string): Promise { await this.adapterFor(id).attach(id) } @@ -226,7 +251,9 @@ export class DaemonPtyRouter implements IPtyProvider { return () => {} } - onExit(callback: (payload: { id: string; code: number }) => void): () => void { + onExit( + callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void + ): () => void { this.exitListeners.push(callback) return () => { const idx = this.exitListeners.indexOf(callback) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 9da8c2e48e3..698dde316ab 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -32,6 +32,10 @@ import { type HelloMessage, type DaemonRequest } from './types' +import { + isAgentSessionExecutionClaim, + isAgentSessionSurfaceBinding +} from '../../shared/agent-session-host-authority' export type DaemonServerOptions = { socketPath: string @@ -679,8 +683,16 @@ export class DaemonServer { } this.createOrAttachInFlight++ const p = request.payload + let routedSessionId = p.sessionId let result: Awaited> try { + if ( + p.agentSessionEnsure !== undefined && + (!isAgentSessionExecutionClaim(p.agentSessionEnsure.claim) || + !isAgentSessionSurfaceBinding(p.agentSessionEnsure.surface)) + ) { + throw new Error('agent_session_identity_required') + } await this.preparePtySpawnUnlessCanceled(p.sessionId, clientId) result = await this.host.createOrAttach({ sessionId: p.sessionId, @@ -702,16 +714,20 @@ export class DaemonServer { ...(p.shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } : {}), + ...(p.agentSessionEnsure ? { agentSessionEnsure: p.agentSessionEnsure } : {}), + onSessionResolved: (sessionId) => { + routedSessionId = sessionId + }, streamClient: { onData: (data, rawLength = data.length, transformed = false, seq) => { // Scan BEFORE enqueue: the batcher may drop this chunk, but its facts must be captured regardless. - this.transientFactRelay.onSessionData(p.sessionId, data) - const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId) + this.transientFactRelay.onSessionData(routedSessionId, data) + const lastInputAt = this.lastInputAtBySessionId.get(routedSessionId) const isInteractiveOutput = data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS && lastInputAt !== undefined && performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS - this.streamDataBatcher.enqueue(clientId, p.sessionId, data, { + this.streamDataBatcher.enqueue(clientId, routedSessionId, data, { flushImmediately: isInteractiveOutput, flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS, rawLength, @@ -719,22 +735,22 @@ export class DaemonServer { seq }) }, - onExit: (code) => { + onExit: (code, incarnationId) => { // Why: exit tears down renderer handlers, so it must ride the ordered queue behind final output. - this.log.log('session-exited', { sessionId: p.sessionId, code }) - this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, { + this.log.log('session-exited', { sessionId: routedSessionId, code }) + this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { type: 'event', event: 'exit', - sessionId: p.sessionId, - payload: { code } + sessionId: routedSessionId, + payload: { code, incarnationId } }) this.streamDataBatcher.flush(clientId) recordDaemonStreamBacklogEvent('sessionExit', { - sessionIdSuffix: p.sessionId.slice(-10) + sessionIdSuffix: routedSessionId.slice(-10) }) - this.transientFactRelay.onSessionExit(p.sessionId) - this.streamClientIdBySessionId.delete(p.sessionId) - this.lastInputAtBySessionId.delete(p.sessionId) + this.transientFactRelay.onSessionExit(routedSessionId) + this.streamClientIdBySessionId.delete(routedSessionId) + this.lastInputAtBySessionId.delete(routedSessionId) this.reevaluateIdleShutdown() } } @@ -743,18 +759,19 @@ export class DaemonServer { this.createOrAttachInFlight-- this.reevaluateIdleShutdown() } - this.streamClientIdBySessionId.set(p.sessionId, clientId) + routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? p.sessionId + this.streamClientIdBySessionId.set(routedSessionId, clientId) // Why an attach-time marker: background resync can precede this attach, so scan suppression must start at the new stream's head. - if (this.transientFactRelay.isBackgrounded(p.sessionId)) { - this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, { + if (this.transientFactRelay.isBackgrounded(routedSessionId)) { + this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { type: 'event', event: 'sessionBackgroundMarker', - sessionId: p.sessionId, + sessionId: routedSessionId, payload: { background: true } }) } this.log.log(result.isNew ? 'session-created' : 'session-attached', { - sessionId: p.sessionId, + sessionId: routedSessionId, pid: result.pid }) return { @@ -762,9 +779,11 @@ export class DaemonServer { snapshot: result.snapshot, pid: result.pid, shellState: result.shellState, + incarnationId: result.incarnationId, ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), wslDistro: result.wslDistro, - ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}) + ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) } } diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts index b47e7413992..1d2caa2c77a 100644 --- a/src/main/daemon/daemon-stream-events.ts +++ b/src/main/daemon/daemon-stream-events.ts @@ -1,5 +1,6 @@ // ─── Events (Daemon → Client, on stream socket) ──────────────────── import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type DataEvent = { type: 'event' @@ -19,7 +20,7 @@ export type ExitEvent = { type: 'event' event: 'exit' sessionId: string - payload: { code: number } + payload: { code: number; incarnationId?: PtyIncarnationId } } export type TerminalErrorEvent = { diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index 188173816db..87dc721db0f 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -9,7 +9,11 @@ type ProviderMock = IPtyProvider & { emitExit: (id: string, code: number) => void } -function createProvider(label: string, sessions: string[] = []): ProviderMock { +function createProvider( + label: string, + sessions: string[] = [], + authoritativeOwnerListings = false +): ProviderMock { const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = [] const replayListeners: ((payload: { id: string; data: string }) => void)[] = [] @@ -22,6 +26,7 @@ function createProvider(label: string, sessions: string[] = []): ProviderMock { }), attach: vi.fn(async () => {}), hasPty: vi.fn((id: string) => sessions.includes(id)), + providesAgentSessionOwnerListings: vi.fn(() => authoritativeOwnerListings), write: vi.fn(), resize: vi.fn(), shutdown: vi.fn(async (id: string) => { @@ -95,7 +100,7 @@ function createDaemonAdapter( sessions: string[] = [] ): DaemonPtyAdapter & ProviderMock { return { - ...createProvider(label, sessions), + ...createProvider(label, sessions, true), protocolVersion: 13, listSessions: vi.fn(async () => []), ackColdRestore: vi.fn(), @@ -109,6 +114,16 @@ function createDaemonAdapter( } describe('DegradedDaemonPtyProvider', () => { + it('only delegates owner-listing authority to the provider that owns the id', async () => { + const current = createDaemonAdapter('daemon', ['daemon-session']) + const fallback = createProvider('fallback', [], true) + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + await provider.discoverDaemonSessions() + + expect(provider.providesAgentSessionOwnerListings('daemon-session')).toBe(true) + expect(provider.providesAgentSessionOwnerListings('unknown-session')).toBe(false) + }) + it('routes fresh foreground confirmation to the session owner', async () => { const current = createDaemonAdapter('daemon', ['daemon-session']) const fallback = createProvider('fallback') diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 82fb2c49e98..0dd1577a614 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -10,22 +10,15 @@ import type { PtySpawnResult } from '../providers/types' -type ManagedPtyProvider = IPtyProvider & { - disconnectOnly?: () => Promise - dispose?: () => void -} - export class DegradedDaemonPtyProvider implements IPtyProvider { readonly routesFreshSpawnsToLocalProvider = true - // Why: the preserved daemon answers protocol but cannot spawn fresh PTYs. - // Surfaced (e.g. via pty:management:listSessions) so the UI can warn that - // new terminals are running without daemon persistence until a restart. + // Why: surface that fresh PTYs lack daemon persistence until restart. readonly isDegraded = true private current: DaemonPtyAdapter private legacy: DaemonPtyAdapter[] - private fallback: ManagedPtyProvider - private sessionProviders = new Map() + private fallback: IPtyProvider + private sessionProviders = new Map() private unsubscribers: (() => void)[] = [] private dataListeners: ((payload: PtyDataEvent) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] @@ -33,7 +26,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { constructor(opts: { current: DaemonPtyAdapter legacy: DaemonPtyAdapter[] - fallback: ManagedPtyProvider + fallback: IPtyProvider }) { this.current = opts.current this.legacy = opts.legacy @@ -83,12 +76,15 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { hasPty(id: string): boolean { const mapped = this.sessionProviders.get(id) - if (mapped) { - return mapped.hasPty?.(id) ?? true - } - return this.findProviderForExistingSession(id) !== null + return mapped ? (mapped.hasPty?.(id) ?? true) : this.findProviderForExistingSession(id) !== null } + // Why: an unknown id cannot borrow listing authority from the fresh-spawn provider. + providesAgentSessionOwnerListings = (ptyId: string): boolean => + ( + this.sessionProviders.get(ptyId) ?? this.findProviderForExistingSession(ptyId) + )?.providesAgentSessionOwnerListings?.(ptyId) === true + write(id: string, data: string): void { this.providerFor(id).write(id, data) } @@ -139,8 +135,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { id: string, opts?: { scrollbackRows?: number } ): Promise { - // Why: a preserved legacy daemon can still thin its monitoring stream; - // recovery must reach the adapter that owns that session's full model. + // Why: recovery must reach the legacy adapter that owns the thinned session model. return (await this.providerFor(id).getBufferSnapshot?.(id, opts)) ?? null } @@ -294,8 +289,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { fanoutCurrentDaemonSyntheticExits(code: number): void { for (const id of this.getCurrentDaemonSessionIds()) { this.sessionProviders.delete(id) - // Why: sessions discovered from listProcesses may not exist in the - // adapter's active-session set, but restart still kills that daemon. + // Why: restart kills listed sessions even when the adapter did not track them active. // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration for (const listener of [...this.exitListeners]) { listener({ id, code }) @@ -320,7 +314,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return this.allDaemonAdapters() } - private providerFor(sessionId: string): ManagedPtyProvider { + private providerFor(sessionId: string): IPtyProvider { return ( this.sessionProviders.get(sessionId) ?? this.findProviderForExistingSession(sessionId) ?? @@ -328,7 +322,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { ) } - private findProviderForExistingSession(sessionId: string): ManagedPtyProvider | null { + private findProviderForExistingSession(sessionId: string): IPtyProvider | null { for (const provider of this.allProviders()) { if (provider.hasPty?.(sessionId) === true) { this.sessionProviders.set(sessionId, provider) @@ -338,7 +332,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return null } - private sessionIdsForProvider(provider: ManagedPtyProvider): string[] { + private sessionIdsForProvider(provider: IPtyProvider): string[] { return [...this.sessionProviders] .filter(([, mappedProvider]) => mappedProvider === provider) .map(([id]) => id) @@ -351,7 +345,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { : null } - private allProviders(): ManagedPtyProvider[] { + private allProviders(): IPtyProvider[] { return [this.fallback, ...this.allDaemonAdapters()] } diff --git a/src/main/daemon/pty-session-id.test.ts b/src/main/daemon/pty-session-id.test.ts index 6ba42f0876a..d242d7f3893 100644 --- a/src/main/daemon/pty-session-id.test.ts +++ b/src/main/daemon/pty-session-id.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { isSafePtySessionId, mintPtySessionId, parsePtySessionId } from './pty-session-id' +import { + isSafePtySessionId, + mintPtySessionId, + parsePtySessionId, + ptySessionIdForAgentCreateOperation +} from './pty-session-id' const USER_DATA = '/tmp/orca-userdata' @@ -23,6 +28,32 @@ describe('mintPtySessionId', () => { }) }) +describe('ptySessionIdForAgentCreateOperation', () => { + it('derives the same daemon session for retries of one host operation', () => { + const operationId = 'a'.repeat(43) + + expect(ptySessionIdForAgentCreateOperation('repo::/tmp/worktree', operationId)).toBe( + 'repo::/tmp/worktree@@aaaaaaaa' + ) + expect(ptySessionIdForAgentCreateOperation(undefined, operationId)).toBe('aaaaaaaa') + }) + + it('produces a safe session id for a path-shaped worktree', () => { + const id = ptySessionIdForAgentCreateOperation('repo::/Users/dev/worktree', 'b'.repeat(43)) + + expect(isSafePtySessionId(id, USER_DATA)).toBe(true) + expect(parsePtySessionId(id)).toEqual({ worktreeId: 'repo::/Users/dev/worktree' }) + }) + + it('preserves the legacy worktree length boundary', () => { + const worktreeId = `repo::/${'w'.repeat(495)}` + const id = ptySessionIdForAgentCreateOperation(worktreeId, 'c'.repeat(43)) + + expect(id).toHaveLength(512) + expect(isSafePtySessionId(id, USER_DATA)).toBe(true) + }) +}) + describe('isSafePtySessionId', () => { it('accepts minted UUIDs', () => { expect(isSafePtySessionId(mintPtySessionId(), USER_DATA)).toBe(true) diff --git a/src/main/daemon/pty-session-id.ts b/src/main/daemon/pty-session-id.ts index 544df53db0c..69324104af6 100644 --- a/src/main/daemon/pty-session-id.ts +++ b/src/main/daemon/pty-session-id.ts @@ -24,6 +24,15 @@ export function mintPtySessionId(worktreeId?: string): string { : randomUUID() } +export function ptySessionIdForAgentCreateOperation( + worktreeId: string | undefined, + operationId: string +): string { + // Why: keep the legacy eight-character suffix budget so max-length worktree IDs still launch. + const suffix = operationId.slice(0, 8) + return worktreeId ? `${worktreeId}${PTY_SESSION_ID_SEPARATOR}${suffix}` : suffix +} + /** * Why: `effectiveSessionId` is used as a filesystem key for provider hook * state and legacy Pi overlay cleanup under app.getPath('userData'). The diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 1f709330e2d..bc4b45484f3 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -664,7 +664,7 @@ describe('Session', () => { expect(onData).toHaveBeenCalledWith('late output') expect(onExit).toHaveBeenCalledTimes(1) - expect(onExit).toHaveBeenCalledWith(23) + expect(onExit).toHaveBeenCalledWith(23, session.incarnationId) expect(session.exitCode).toBe(23) }) }) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 67a51d6a37d..7edaa500663 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -11,6 +11,7 @@ import { import { isPowerShellProcess } from '../../shared/shell-process-detection' import { killWithDescendantSweep } from '../pty-descendant-termination' import type { TuiAgent } from '../../shared/types' +import { randomUUID } from 'node:crypto' import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { PtyStartupIngress, @@ -92,11 +93,12 @@ export type SessionOptions = { type AttachedClient = { token: symbol onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void - onExit: (code: number) => void + onExit: (code: number, incarnationId: string) => void } export class Session { readonly sessionId: string + readonly incarnationId = randomUUID() readonly terminalHandle: string | null readonly launchAgent: TuiAgent | null readonly wslDistro: string | null @@ -525,7 +527,7 @@ export class Session { this.emulator.dispose() for (const client of clientsToNotify) { - client.onExit(-1) + client.onExit(-1, this.incarnationId) } } @@ -670,7 +672,7 @@ export class Session { this.disposeSubprocessHandle() for (const client of this.attachedClients) { - client.onExit(code) + client.onExit(code, this.incarnationId) } // Why: hand off to the owner's reaper (disposes emulator, drops session from host map); else dead sessions accumulate. diff --git a/src/main/daemon/terminal-host-agent-session-claim.ts b/src/main/daemon/terminal-host-agent-session-claim.ts new file mode 100644 index 00000000000..b9fc70cf47b --- /dev/null +++ b/src/main/daemon/terminal-host-agent-session-claim.ts @@ -0,0 +1,44 @@ +import type { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner' +import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' + +export type InternalCreateOrAttachOptions = CreateOrAttachOptions & { + agentSessionGeneration?: string + attachOnly?: boolean +} + +export async function createOrAttachClaimedAgentSession(args: { + options: CreateOrAttachOptions + owners: ClaimedAgentPtyOwnerRegistry + isLive: (owner: AgentSessionOwnerBinding) => boolean + createOrAttach: (options: InternalCreateOrAttachOptions) => Promise +}): Promise { + const ensureRequest = args.options.agentSessionEnsure + if (!ensureRequest) { + return await args.createOrAttach(args.options) + } + let created: CreateOrAttachResult | null = null + const ensured = await args.owners.ensure({ + claim: ensureRequest.claim, + surface: ensureRequest.surface, + spawn: async ({ generation }) => { + created = await args.createOrAttach({ + ...args.options, + agentSessionGeneration: generation + }) + return { ptyId: args.options.sessionId } + }, + isLive: args.isLive + }) + if (ensured.disposition === 'created' && created) { + return { ...(created as CreateOrAttachResult), agentSessionEnsure: ensured } + } + const adopted = await args.createOrAttach({ + ...args.options, + sessionId: ensured.owner.ptyId, + command: undefined, + agentSessionEnsure: undefined, + attachOnly: true + }) + return { ...adopted, agentSessionEnsure: ensured } +} diff --git a/src/main/daemon/terminal-host-agent-session-generations.ts b/src/main/daemon/terminal-host-agent-session-generations.ts new file mode 100644 index 00000000000..aa83254a4e5 --- /dev/null +++ b/src/main/daemon/terminal-host-agent-session-generations.ts @@ -0,0 +1,21 @@ +import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' + +export class TerminalHostAgentSessionGenerations { + private readonly byPtyId = new Map() + + isCurrent(owner: AgentSessionOwnerBinding, isPtyLive: boolean): boolean { + return isPtyLive && this.byPtyId.get(owner.ptyId) === owner.generation + } + + remember(ptyId: string, generation: string | undefined, isPtyLive: boolean): void { + if (generation && isPtyLive) { + this.byPtyId.set(ptyId, generation) + } + } + + forget(ptyId: string, generation?: string): void { + if (generation === undefined || this.byPtyId.get(ptyId) === generation) { + this.byPtyId.delete(ptyId) + } + } +} diff --git a/src/main/daemon/terminal-host-agent-session.test.ts b/src/main/daemon/terminal-host-agent-session.test.ts new file mode 100644 index 00000000000..99090306594 --- /dev/null +++ b/src/main/daemon/terminal-host-agent-session.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from './session' +import { TerminalHost } from './terminal-host' + +function createClaimedSubprocess(): SubprocessHandle & { exit: () => void } { + let onExit: ((code: number) => void) | null = null + return { + pid: 99_999, + getForegroundProcess: () => 'codex', + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + forceKill: vi.fn(), + signal: vi.fn(), + onData: vi.fn(), + onExit: (listener) => { + onExit = listener + }, + dispose: vi.fn(), + exit: () => onExit?.(0) + } +} + +describe('TerminalHost agent-session claims', () => { + let host: TerminalHost + let subprocess: ReturnType | undefined + const spawnSubprocess = vi.fn(() => { + subprocess = createClaimedSubprocess() + return subprocess + }) + const claim = { + digestVersion: 1 as const, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' as const + } + const surface = { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed' + } + + beforeEach(() => { + spawnSubprocess.mockClear() + host = new TerminalHost({ spawnSubprocess }) + }) + + afterEach(async () => { + subprocess?.exit() + await host.dispose() + }) + + it('adopts one claimed provider session across different requested daemon ids', async () => { + const first = await host.createOrAttach({ + sessionId: 'session-claimed-first', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() }, + agentSessionEnsure: { claim, surface } + }) + const second = await host.createOrAttach({ + sessionId: 'session-claimed-retry', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() }, + agentSessionEnsure: { + claim, + surface: { ...surface, terminalHandle: 'term_retry' } + } + }) + + expect(first.agentSessionEnsure).toMatchObject({ + disposition: 'created', + owner: { ptyId: 'session-claimed-first', surface } + }) + expect(second.agentSessionEnsure).toMatchObject({ + disposition: 'adopted', + owner: { ptyId: 'session-claimed-first', surface } + }) + expect(spawnSubprocess).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 47fba51b2c3..e98144e516f 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -2,6 +2,12 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery import type { TuiAgent } from '../../shared/types' import type { ShellReadyState, TerminalSnapshot } from './types' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import type { + AgentSessionClaimedSpawnResult, + AgentSessionExecutionClaim, + AgentSessionSurfaceBinding +} from '../../shared/agent-session-host-authority' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type CreateOrAttachOptions = { sessionId: string @@ -21,10 +27,17 @@ export type CreateOrAttachOptions = { shellReadyTimeoutMs?: number historySeed?: string startupIngress?: PtyStartupIngressIntent + agentSessionEnsure?: { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + } streamClient: { onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void - onExit: (code: number) => void + onExit: (code: number, incarnationId: PtyIncarnationId) => void } + /** Lets the daemon route output under the adopted owner's canonical id before + * attaching its stream callbacks. */ + onSessionResolved?: (sessionId: string) => void } export type CreateOrAttachResult = { @@ -36,4 +49,6 @@ export type CreateOrAttachResult = { launchAgent?: TuiAgent wslDistro: string | null attachToken: symbol + incarnationId: PtyIncarnationId + agentSessionEnsure?: AgentSessionClaimedSpawnResult } diff --git a/src/main/daemon/terminal-host-options.ts b/src/main/daemon/terminal-host-options.ts index a879a618c25..55496f9e50f 100644 --- a/src/main/daemon/terminal-host-options.ts +++ b/src/main/daemon/terminal-host-options.ts @@ -1,4 +1,5 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TuiAgent } from '../../shared/types' import type { SubprocessHandle } from './session' import type { TakePendingOutputResult, TerminalSnapshot } from './types' @@ -12,6 +13,7 @@ export type TerminalHostOptions = { envToDelete?: string[] command?: string startupCommandDelivery?: StartupCommandDelivery + launchAgent?: TuiAgent shellOverride?: string terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts new file mode 100644 index 00000000000..f61fb25d798 --- /dev/null +++ b/src/main/daemon/terminal-host-session-create.ts @@ -0,0 +1,143 @@ +import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' +import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' +import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' +import { normalizePtySize } from './daemon-pty-size' +import { Session } from './session' +import { shellPathSupportsPtyStartupBarrier } from './shell-ready' +import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' +import type { CreateOrAttachResult } from './terminal-host-create-contract' +import type { TerminalHostOptions } from './terminal-host-options' +import type { TerminalHostTombstones } from './terminal-host-tombstones' +import type { TerminalSessionTeardown } from './terminal-session-teardown' +import { SessionNotFoundError } from './types' +import { resolveWslSessionContext } from './wsl-session-context' + +type TerminalHostSessionCreateDependencies = { + sessions: Map + sessionTeardown: TerminalSessionTeardown + killedTombstones: TerminalHostTombstones + spawnSubprocess: TerminalHostOptions['spawnSubprocess'] + creationFenced: boolean + onDeadSessionRemoved: (sessionId: string) => void + onSessionCreated: (sessionId: string, generation: string | undefined, isAlive: boolean) => void + onSessionExit: (sessionId: string, generation: string | undefined) => void +} + +export async function createOrAttachTerminalSession( + opts: InternalCreateOrAttachOptions, + deps: TerminalHostSessionCreateDependencies +): Promise { + if (deps.creationFenced) { + throw new Error('Terminal host is shutting down') + } + opts.onSessionResolved?.(opts.sessionId) + const existing = deps.sessions.get(opts.sessionId) + + // Why: descendant capture must finish before attach or recreation, or the + // caller could receive a doomed session while teardown owns its process. + if (deps.sessionTeardown.get(opts.sessionId) || existing?.isTerminating) { + throw new SessionNotFoundError(opts.sessionId) + } + + if (existing && existing.isAlive && !existing.isTerminating) { + const snapshot = existing.getSnapshot() + existing.detachAllClients() + const token = existing.attachClient(opts.streamClient) + return { + isNew: false, + snapshot, + pid: existing.pid, + shellState: existing.shellState, + incarnationId: existing.incarnationId, + ...getDaemonSessionResultMetadata(existing), + attachToken: token + } + } + + if (existing?.isAlive && existing.isTerminating) { + // Why: replacing a SIGKILLed-but-unreaped child could hide two live + // generations behind the same public session id. + throw new Error(`Session "${opts.sessionId}" is terminating`) + } + if (opts.attachOnly) { + // Why: an adopted claim proves only one owner generation; it must never + // turn an exit race into permission to spawn an unclaimed shell. + throw new SessionNotFoundError(opts.sessionId) + } + + if (existing) { + existing.dispose() + deps.sessions.delete(opts.sessionId) + deps.onDeadSessionRemoved(opts.sessionId) + } + + deps.killedTombstones.clearForCreate(opts.sessionId) + const size = normalizePtySize(opts.cols, opts.rows) + const wslDistro = resolveWslSessionContext(opts)?.distro + const subprocess = deps.spawnSubprocess({ + sessionId: opts.sessionId, + cols: size.cols, + rows: size.rows, + cwd: opts.cwd, + env: opts.env, + envToDelete: opts.envToDelete, + command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery, + ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation + }) + + // Why: a fallback shell does not emit the preferred shell's ready marker; + // retaining the stale capability would indefinitely queue its first command. + const shellReadySupported = + (opts.shellReadySupported ?? false) && + (subprocess.shellPath === undefined || shellPathSupportsPtyStartupBarrier(subprocess.shellPath)) + const session = new Session({ + sessionId: opts.sessionId, + cols: size.cols, + rows: size.rows, + terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, + launchAgent: opts.launchAgent, + subprocess, + ownerBackend: resolvePtyOwnerBackend({ + platform: process.platform, + shellPath: subprocess.shellPath, + wslDistro + }), + shellReadySupported, + historySeed: opts.historySeed, + ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), + wslDistro, + onExit: () => deps.onSessionExit(opts.sessionId, opts.agentSessionGeneration), + ...(opts.shellReadyTimeoutMs !== undefined + ? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs } + : {}) + }) + + deps.sessions.set(opts.sessionId, session) + deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive) + const token = session.attachClient(opts.streamClient) + + if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) { + const submit = process.platform === 'win32' ? '\r' : '\n' + // Why: only Orca-wrapped shells advertise the paste-safe startup barrier. + session.write( + buildStartupCommandSubmission(opts.command, { + submit, + bracketedPasteSafe: shellReadySupported + }) + ) + } + + return { + isNew: true, + snapshot: null, + pid: subprocess.pid, + shellState: session.shellState, + incarnationId: session.incarnationId, + ...getDaemonSessionResultMetadata(session), + attachToken: token + } +} diff --git a/src/main/daemon/terminal-host-session-cwd.ts b/src/main/daemon/terminal-host-session-cwd.ts new file mode 100644 index 00000000000..6de0df3a815 --- /dev/null +++ b/src/main/daemon/terminal-host-session-cwd.ts @@ -0,0 +1,11 @@ +import { resolveProcessCwd } from '../providers/process-cwd' +import type { Session } from './session' + +export async function resolveTerminalHostSessionCwd(session: Session): Promise { + const tracked = session.getCwd() + if (tracked) { + return tracked + } + const resolved = await resolveProcessCwd(session.pid) + return resolved || null +} diff --git a/src/main/daemon/terminal-host-session-listing.ts b/src/main/daemon/terminal-host-session-listing.ts new file mode 100644 index 00000000000..2f0e99c332d --- /dev/null +++ b/src/main/daemon/terminal-host-session-listing.ts @@ -0,0 +1,31 @@ +import type { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner' +import type { Session } from './session' +import type { SessionInfo } from './types' + +export function listLiveTerminalHostSessions( + sessions: ReadonlyMap, + agentSessionOwners: ClaimedAgentPtyOwnerRegistry +): SessionInfo[] { + const result: SessionInfo[] = [] + for (const session of sessions.values()) { + if (!session.isAlive) { + continue + } + const size = session.getAppliedSize() + result.push({ + sessionId: session.sessionId, + incarnationId: session.incarnationId, + state: session.state, + shellState: session.shellState, + isAlive: true, + ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), + pid: session.pid, + cwd: session.getCwd(), + cols: size?.cols ?? 0, + rows: size?.rows ?? 0, + createdAt: 0, + agentSessionOwners: agentSessionOwners.listForPty(session.sessionId) + }) + } + return result +} diff --git a/src/main/daemon/terminal-host-tombstones.ts b/src/main/daemon/terminal-host-tombstones.ts new file mode 100644 index 00000000000..145962db90b --- /dev/null +++ b/src/main/daemon/terminal-host-tombstones.ts @@ -0,0 +1,29 @@ +export class TerminalHostTombstones { + private readonly killed = new Map() + + constructor(private readonly capacity: number) {} + + clearForCreate(sessionId: string): void { + this.killed.delete(sessionId) + } + + record(sessionId: string): void { + this.killed.delete(sessionId) + this.killed.set(sessionId, Date.now()) + if (this.killed.size <= this.capacity) { + return + } + const oldest = this.killed.keys().next().value + if (oldest) { + this.killed.delete(oldest) + } + } + + has(sessionId: string): boolean { + return this.killed.has(sessionId) + } + + clear(): void { + this.killed.clear() + } +} diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index e6e0eb01145..796f1939fc6 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,9 +1,4 @@ -import { Session } from './session' -import { normalizePtySize } from './daemon-pty-size' -import { shellPathSupportsPtyStartupBarrier } from './shell-ready' -import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' -import { resolveProcessCwd } from '../providers/process-cwd' -import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' +import type { Session } from './session' import { SessionNotFoundError, type SessionInfo, @@ -14,8 +9,13 @@ import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-hos import type { TerminalHostOptions } from './terminal-host-options' import { shutdownTerminalHostSessions } from './terminal-host-session-shutdown' import { TerminalSessionTeardown } from './terminal-session-teardown' -import { resolveWslSessionContext } from './wsl-session-context' -import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' +import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner' +import { createOrAttachClaimedAgentSession } from './terminal-host-agent-session-claim' +import { TerminalHostAgentSessionGenerations } from './terminal-host-agent-session-generations' +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' export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' export type { TerminalHostOptions } from './terminal-host-options' @@ -25,134 +25,52 @@ const DEFAULT_MAX_TOMBSTONES = 1000 export class TerminalHost { private sessions = new Map() private sessionTeardown = new TerminalSessionTeardown(this.sessions) - private killedTombstones = new Map() + private killedTombstones: TerminalHostTombstones private spawnSubprocess: TerminalHostOptions['spawnSubprocess'] private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint'] private maxTombstones: number private creationFenced = false private disposePromise: Promise | null = null + private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() + private readonly agentSessionGenerations = new TerminalHostAgentSessionGenerations() constructor(opts: TerminalHostOptions) { this.spawnSubprocess = opts.spawnSubprocess this.onFinalCheckpoint = opts.onFinalCheckpoint this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES + this.killedTombstones = new TerminalHostTombstones(this.maxTombstones) } - /** - * Creates a terminal session or attaches to an existing live one. - * - * Startup commands are written through stdin only when the subprocess did not - * already deliver them through shell launch arguments. - */ async createOrAttach(opts: CreateOrAttachOptions): Promise { - if (this.creationFenced) { - throw new Error('Terminal host is shutting down') - } - const existing = this.sessions.get(opts.sessionId) - - // Why: async descendant capture must finish before attach/recreate, or we hand out a doomed session. - if (this.sessionTeardown.get(opts.sessionId) || existing?.isTerminating) { - throw new SessionNotFoundError(opts.sessionId) - } - - if (existing && existing.isAlive && !existing.isTerminating) { - const snapshot = existing.getSnapshot() - existing.detachAllClients() - const token = existing.attachClient(opts.streamClient) - return { - isNew: false, - snapshot, - pid: existing.pid, - shellState: existing.shellState, - ...getDaemonSessionResultMetadata(existing), - attachToken: token - } - } - - if (existing?.isAlive && existing.isTerminating) { - // Why: replacing a SIGKILLed-but-unreaped child would leak its native handles and hide two generations under one id. - throw new Error(`Session "${opts.sessionId}" is terminating`) - } - - if (existing) { - existing.dispose() - this.sessions.delete(opts.sessionId) - } - - // Clear tombstone if re-creating a killed session - this.killedTombstones.delete(opts.sessionId) - const size = normalizePtySize(opts.cols, opts.rows) - const wslDistro = resolveWslSessionContext(opts)?.distro - - const subprocess = this.spawnSubprocess({ - sessionId: opts.sessionId, - cols: size.cols, - rows: size.rows, - cwd: opts.cwd, - env: opts.env, - envToDelete: opts.envToDelete, - command: opts.command, - startupCommandDelivery: opts.startupCommandDelivery, - ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation - }) - - // Why: the pre-spawn flag goes stale if spawn fell back to a shell (e.g. /bin/sh) that never emits the ready marker. - const shellReadySupported = - (opts.shellReadySupported ?? false) && - (subprocess.shellPath === undefined || - shellPathSupportsPtyStartupBarrier(subprocess.shellPath)) - - const session = new Session({ - sessionId: opts.sessionId, - cols: size.cols, - rows: size.rows, - terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, - launchAgent: opts.launchAgent, - subprocess, - ownerBackend: resolvePtyOwnerBackend({ - platform: process.platform, - shellPath: subprocess.shellPath, - wslDistro - }), - shellReadySupported, - historySeed: opts.historySeed, - ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), - wslDistro, - // Why: reap the dead session (dispose emulator + drop from map) on subprocess exit, not at daemon shutdown. - onExit: () => this.reapSession(opts.sessionId), - ...(opts.shellReadyTimeoutMs !== undefined - ? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs } - : {}) - }) - - this.sessions.set(opts.sessionId, session) - - const token = session.attachClient(opts.streamClient) - - if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) { - // Why: startup commands must run inside the long-lived interactive shell the daemon keeps for the pane. - // Why CR on Windows: PSReadLine/cmd.exe submit on CR; a bare LF leaves it unsubmitted (POSIX accepts CR via ICRNL). - const submit = process.platform === 'win32' ? '\r' : '\n' - // Why: bracketed-paste only for Orca-wrapped bash/zsh (== shell-ready supported); other shells use the raw submit path. - session.write( - buildStartupCommandSubmission(opts.command, { - submit, - bracketedPasteSafe: shellReadySupported + return await createOrAttachClaimedAgentSession({ + options: opts, + owners: this.agentSessionOwners, + isLive: (owner) => + this.agentSessionGenerations.isCurrent( + owner, + Boolean(this.sessions.get(owner.ptyId)?.isAlive) + ), + createOrAttach: async (options) => { + if (options.agentSessionGeneration && this.sessions.get(options.sessionId)?.isAlive) { + throw new Error('agent_session_claim_unavailable') + } + return await createOrAttachTerminalSession(options, { + sessions: this.sessions, + sessionTeardown: this.sessionTeardown, + killedTombstones: this.killedTombstones, + spawnSubprocess: this.spawnSubprocess, + creationFenced: this.creationFenced, + onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId), + onSessionCreated: (sessionId, generation, isAlive) => + this.agentSessionGenerations.remember(sessionId, generation, isAlive), + onSessionExit: (sessionId, generation) => { + this.agentSessionOwners.release(sessionId, generation) + this.agentSessionGenerations.forget(sessionId, generation) + this.reapSession(sessionId) + } }) - ) - } - - return { - isNew: true, - snapshot: null, - pid: subprocess.pid, - shellState: session.shellState, - ...getDaemonSessionResultMetadata(session), - attachToken: token - } + } + }) } write(sessionId: string, data: string): void { @@ -189,7 +107,7 @@ export class TerminalHost { } const session = this.getAliveSession(sessionId) const killed = this.sessionTeardown.killSession(sessionId, session, opts.immediate === true) - this.recordTombstone(sessionId) + this.killedTombstones.record(sessionId) return Promise.resolve(killed) } @@ -213,14 +131,7 @@ export class TerminalHost { } async getCwd(sessionId: string): Promise { - const session = this.getAliveSession(sessionId) - const tracked = session.getCwd() - if (tracked) { - return tracked - } - // Why: emulator cwd stays null (Orca rcfiles emit OSC 133 not OSC 7), so fall back to the live process cwd. - const resolved = await resolveProcessCwd(session.pid) - return resolved || null + return await resolveTerminalHostSessionCwd(this.getAliveSession(sessionId)) } // Why: null-not-throw — fetched for the tab-bar icon, so a vanished pane should quietly yield "no agent". @@ -289,26 +200,7 @@ export class TerminalHost { } listSessions(): SessionInfo[] { - const result: SessionInfo[] = [] - for (const [, session] of this.sessions) { - if (!session.isAlive) { - continue - } - const size = session.getAppliedSize() - result.push({ - sessionId: session.sessionId, - state: session.state, - shellState: session.shellState, - isAlive: true, - ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), - pid: session.pid, - cwd: session.getCwd(), - cols: size?.cols ?? 0, - rows: size?.rows ?? 0, - createdAt: 0 - }) - } - return result + return listLiveTerminalHostSessions(this.sessions, this.agentSessionOwners) } dispose(): Promise { @@ -339,16 +231,4 @@ export class TerminalHost { } return session } - - private recordTombstone(sessionId: string): void { - this.killedTombstones.delete(sessionId) - this.killedTombstones.set(sessionId, Date.now()) - - if (this.killedTombstones.size > this.maxTombstones) { - const oldest = this.killedTombstones.keys().next().value - if (oldest) { - this.killedTombstones.delete(oldest) - } - } - } } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 5c148dbf238..bec511d8e1e 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -12,10 +12,17 @@ export type { import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TuiAgent } from '../../shared/types' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import type { + AgentSessionExecutionClaim, + AgentSessionOwnerBinding, + AgentSessionSurfaceBinding +} from '../../shared/agent-session-host-authority' export type { TerminalModes } from './terminal-modes' import type { TerminalSnapshot } from './terminal-snapshot' export type { TerminalSnapshot } from './terminal-snapshot' export { + AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, CLEAN_DISCONNECT_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, @@ -72,6 +79,10 @@ export type CreateOrAttachRequest = { /** Recovered ANSI applied before the new subprocess can emit startup output. */ historySeed?: string startupIngress?: PtyStartupIngressIntent + agentSessionEnsure?: { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + } } } @@ -339,6 +350,7 @@ export type SystemResolverHealthResult = { export type SessionInfo = { sessionId: string + incarnationId?: string state: SessionState shellState: ShellReadyState isAlive: boolean @@ -348,6 +360,7 @@ export type SessionInfo = { cols: number rows: number createdAt: number + agentSessionOwners?: AgentSessionOwnerBinding[] } // Why: SessionInfo + source protocol version, so the Manage Sessions UI can diff --git a/src/main/index.ts b/src/main/index.ts index a1e50ba3b42..a24373bd647 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -41,6 +41,7 @@ import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-cl import { resolveConsent } from './telemetry/consent' import { triggerStartupNotificationRegistration } from './ipc/notifications' import { OrcaRuntimeService } from './runtime/orca-runtime' +import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity' import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' import { resolveAdvertisedPairingEndpoint } from './runtime/pairing-endpoint' import { ServeReadinessPublisher } from './server/serve-readiness' @@ -1985,6 +1986,10 @@ app.whenReady().then(async () => { .map((account) => ({ id: account.id, managedHomePath: account.managedHomePath })) }) const runtimeService = new OrcaRuntimeService(store, stats, { + agentSessionClaimSigner: loadAgentSessionClaimSigner( + getProfileUserDataPath(), + getProfileUserDataPath() + ), // Why: resolve the PTY provider lazily — a daemon swap happens later, so an eager reference would freeze the pre-daemon provider (design §4.3). getLocalProvider: () => getLocalPtyProvider(), // Why: SSH relay providers register after construction and may reconnect, so destructive cleanup must resolve the current generation. diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index cc7428dac3b..3a7cf4b23d5 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -10,6 +10,7 @@ import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard- import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import type { TuiAgent } from '../../shared/types' +import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it @@ -198,9 +199,11 @@ import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-age import { registerPtyHandlers, registerSshPtyProvider, + clearPtyOwnershipForConnection, clearProviderPtyState, deletePtyOwnership, getPtyRendererDeliveryDebugSnapshot, + getPtyIdsForConnection, resetPtyRendererDeliveryDebug, getPtyIdForPaneKey, hasPendingRendererSerializerForPaneKey, @@ -208,7 +211,9 @@ import { setLocalPtyProvider, rebindLocalProviderListeners, unregisterSshPtyProvider, - getLocalPtyProvider + getLocalPtyProvider, + isCurrentPtyExit, + restorePtyIncarnation } from './pty' import { _resetLocalPtyProviderStateForTest } from '../providers/local-pty-provider' import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-login-shell' @@ -226,7 +231,7 @@ import { import { SSH_PTY_IDENTITY_MISMATCH_ERROR, SSH_SESSION_EXPIRED_ERROR -} from '../providers/ssh-pty-provider' +} from '../providers/ssh-pty-errors' import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl' import { acquireWatcherRemovalGate } from './watcher-removal-gate' @@ -635,6 +640,759 @@ describe('registerPtyHandlers', () => { } } + function createAgentClaimProvider(args: { + sessions?: { + id: string + incarnationId?: string + cwd: string + title: string + agentSessionOwners?: AgentSessionOwnerBinding[] + }[] + livePtyIds?: ReadonlySet + spawn?: ReturnType + authoritativeOwnerListings?: boolean + }) { + return { + spawn: args.spawn ?? vi.fn(async () => ({ id: 'unexpected-spawn' })), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => args.sessions ?? []), + providesAgentSessionOwnerListings: vi.fn(() => args.authoritativeOwnerListings !== false), + hasPty: vi.fn((id: string) => args.livePtyIds?.has(id) ?? false), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } + } + + const recoveredAgentClaim = { + digestVersion: 1 as const, + keyId: 'claim-key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' as const + } + const recoveredAgentSurface = { + worktreeId: 'repo-1::/tmp/recovered-worktree', + tabId: '11111111-1111-4111-8111-111111111111', + leafId: '22222222-2222-4222-8222-222222222222', + terminalHandle: 'term_recovered' + } + + function registerAgentClaimController(): { + spawn: (args: Record) => Promise + } { + let controller: + | { + spawn: (args: Record) => Promise + } + | undefined + const runtime = { + setPtyController: vi.fn((next) => { + controller = next + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + if (!controller) { + throw new Error('PTY controller was not registered') + } + return controller + } + + it('does not dispatch a runtime PTY spawn after its client disconnects', async () => { + const provider = createAgentClaimProvider({}) + setLocalPtyProvider(provider as never) + const controller = registerAgentClaimController() + const abort = new AbortController() + abort.abort() + + await expect( + controller.spawn({ cols: 80, rows: 24, cwd: '/tmp/worktree', signal: abort.signal }) + ).rejects.toThrow('client_disconnected') + expect(provider.spawn).not.toHaveBeenCalled() + }) + + it('rejects a canonical daemon owner that exited before its spawn reply', async () => { + const claim = { + ...recoveredAgentClaim, + identityDigest: 'ededededededededededededededededededededede' + } + const canonicalOwner: AgentSessionOwnerBinding = { + claim, + generation: 'generation-canonical-exited', + phase: 'live', + ptyId: 'pty-canonical-exited', + surface: recoveredAgentSurface + } + const physicalSpawn = vi.fn(async () => ({ + id: canonicalOwner.ptyId, + incarnationId: 'incarnation-canonical-exited', + exitedBeforeSpawnReply: true as const, + agentSessionEnsure: { disposition: 'adopted' as const, owner: canonicalOwner } + })) + const provider = createAgentClaimProvider({ spawn: physicalSpawn }) + setLocalPtyProvider(provider as never) + let controller: { spawn(args: Record): Promise } | undefined + const runtime = { + setPtyController: vi.fn((next) => { + controller = next + }), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + + await expect( + controller!.spawn({ + cols: 80, + rows: 24, + cwd: '/tmp/recovered-worktree', + sessionId: 'different-requested-id', + agentSessionEnsure: { claim, surface: recoveredAgentSurface } + }) + ).rejects.toThrow('agent_session_exited_during_start') + + expect(physicalSpawn).toHaveBeenCalledOnce() + expect(runtime.registerPty).not.toHaveBeenCalled() + expect(runtime.registerPreAllocatedHandleForPty).not.toHaveBeenCalled() + expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith( + 'different-requested-id', + 'incarnation-canonical-exited' + ) + }) + + it('rejects renderer spawn publication when the provider reply proves exit', async () => { + const connectionId = 'ssh-renderer-exited-reply' + const appPtyId = `ssh:${connectionId}@@relay-pty` + const provider = { + spawn: vi.fn(async () => ({ + id: appPtyId, + incarnationId: 'incarnation-renderer-exited', + exitedBeforeSpawnReply: true as const + })), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } + const store = { + upsertSshRemotePtyLease: vi.fn(), + persistPtyBinding: vi.fn(), + removeSshRemotePtyLease: vi.fn(), + markSshRemotePtyLease: vi.fn() + } + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_renderer_exited'), + preAllocateHandleForPty: vi.fn(() => 'term_renderer_exited'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerSshPtyProvider(connectionId, provider as never) + try { + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const leafId = '33333333-3333-4333-8333-333333333333' + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp/worktree', + connectionId, + worktreeId: 'repo::/tmp/worktree', + tabId: 'tab-renderer-exited', + leafId + }) + ).rejects.toThrow('agent_session_exited_during_start') + + expect(runtime.registerPty).not.toHaveBeenCalled() + expect(runtime.registerPreAllocatedHandleForPty).not.toHaveBeenCalled() + expect(store.persistPtyBinding).not.toHaveBeenCalled() + expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled() + expect(getPtyIdsForConnection(connectionId)).toEqual([]) + } finally { + unregisterSshPtyProvider(connectionId) + } + }) + + it('rejects renderer persistence when a local PTY exits before spawn settles', async () => { + const ptyId = 'pty-renderer-early-exit' + const incarnationId = 'incarnation-renderer-early-exit' + const runtime = new OrcaRuntimeService() + const registerRuntimePty = vi.spyOn(runtime, 'registerPty') + const provider = createAgentClaimProvider({ + spawn: vi.fn(async () => { + runtime.onPtySpawned(ptyId, incarnationId) + runtime.onPtyExit(ptyId, 0, incarnationId) + return { id: ptyId, incarnationId } + }), + authoritativeOwnerListings: false + }) + const store = { persistPtyBinding: vi.fn() } + setLocalPtyProvider(provider as never) + registerPtyHandlers( + mainWindow as never, + runtime, + undefined, + undefined, + undefined, + store as never + ) + const leafId = '44444444-4444-4444-8444-444444444444' + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp/worktree', + worktreeId: 'repo::/tmp/worktree', + tabId: 'tab-renderer-early-exit', + leafId + }) + ).rejects.toThrow('agent_session_exited_during_start') + + expect(store.persistPtyBinding).not.toHaveBeenCalled() + expect(registerRuntimePty).not.toHaveBeenCalled() + const internals = runtime as unknown as { + earlyExitedPtyIncarnations: Map + pendingPtyRegistrationIncarnations: Map + } + expect(internals.earlyExitedPtyIncarnations.size).toBe(0) + expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0) + clearProviderPtyState(ptyId) + }) + + it('adopts a live controller-owned local fallback when listings cannot serialize claims', async () => { + const sessions: { + id: string + incarnationId: string + cwd: string + title: string + }[] = [] + const physicalSpawn = vi.fn(async () => { + const result = { id: 'pty-local-claim', incarnationId: 'incarnation-local-claim' } + sessions.push({ ...result, cwd: '/tmp/worktree', title: 'Codex' }) + return result + }) + const provider = createAgentClaimProvider({ + sessions, + spawn: physicalSpawn, + authoritativeOwnerListings: false + }) + Object.assign(provider, { routesFreshSpawnsToLocalProvider: true }) + setLocalPtyProvider(provider as never) + const controller = registerAgentClaimController() + const request = { + cols: 80, + rows: 24, + cwd: '/tmp/worktree', + agentSessionEnsure: { + claim: recoveredAgentClaim, + surface: recoveredAgentSurface + } + } + + await expect(controller.spawn(request)).resolves.toMatchObject({ + agentSessionEnsure: { disposition: 'created' } + }) + await expect(controller.spawn(request)).resolves.toMatchObject({ + id: 'pty-local-claim', + agentSessionEnsure: { disposition: 'adopted' } + }) + expect(physicalSpawn).toHaveBeenCalledOnce() + clearProviderPtyState('pty-local-claim') + }) + + it('adopts a daemon owner recovered from provider listing before claimed ensure', async () => { + const owner: AgentSessionOwnerBinding = { + claim: recoveredAgentClaim, + generation: 'generation-recovered', + phase: 'live', + ptyId: 'pty-recovered-owner', + surface: recoveredAgentSurface + } + const provider = createAgentClaimProvider({ + sessions: [ + { + id: owner.ptyId, + incarnationId: 'incarnation-recovered', + cwd: '/tmp/recovered-worktree', + title: 'Codex', + agentSessionOwners: [owner] + } + ], + livePtyIds: new Set([owner.ptyId]) + }) + setLocalPtyProvider(provider as never) + const controller = registerAgentClaimController() + + const result = await controller.spawn({ + cols: 80, + rows: 24, + cwd: '/tmp/recovered-worktree', + agentSessionEnsure: { claim: recoveredAgentClaim, surface: recoveredAgentSurface } + }) + + expect(result).toMatchObject({ + id: owner.ptyId, + incarnationId: 'incarnation-recovered', + agentSessionEnsure: { disposition: 'adopted', owner } + }) + expect(isCurrentPtyExit({ id: owner.ptyId })).toBe(false) + expect(isCurrentPtyExit({ id: owner.ptyId, incarnationId: 'incarnation-old' })).toBe(false) + expect(isCurrentPtyExit({ id: owner.ptyId, incarnationId: 'incarnation-recovered' })).toBe(true) + expect(provider.spawn).not.toHaveBeenCalled() + clearProviderPtyState(owner.ptyId) + }) + + it('releases an adopted-owner fence when that owner exits during admission', async () => { + const incarnationId = 'incarnation-adopted-exit' + const owner: AgentSessionOwnerBinding = { + claim: recoveredAgentClaim, + generation: 'generation-adopted-exit', + phase: 'live', + ptyId: 'pty-adopted-exit', + surface: recoveredAgentSurface + } + const runtime = new OrcaRuntimeService() + const provider = createAgentClaimProvider({ + sessions: [ + { + id: owner.ptyId, + incarnationId, + cwd: '/tmp/recovered-worktree', + title: 'Codex', + agentSessionOwners: [owner] + } + ], + livePtyIds: new Set([owner.ptyId]) + }) + provider.listProcesses.mockImplementation(async () => { + if (provider.listProcesses.mock.calls.length > 1) { + runtime.onPtyExit(owner.ptyId, 0, incarnationId) + } + return [ + { + id: owner.ptyId, + incarnationId, + cwd: '/tmp/recovered-worktree', + title: 'Codex', + agentSessionOwners: [owner] + } + ] + }) + setLocalPtyProvider(provider as never) + registerPtyHandlers(mainWindow as never, runtime) + const controller = ( + runtime as unknown as { + ptyController: { spawn(args: Record): Promise } + } + ).ptyController + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + cwd: '/tmp/recovered-worktree', + agentSessionEnsure: { claim: recoveredAgentClaim, surface: recoveredAgentSurface } + }) + ).rejects.toThrow('agent_session_exited_during_start') + + const internals = runtime as unknown as { + earlyExitedPtyIncarnations: Map + } + expect(internals.earlyExitedPtyIncarnations.has(owner.ptyId)).toBe(false) + }) + + it('rejects stale exits immediately after SSH reconnect restores an incarnation', () => { + const ptyId = 'ssh:target-1@@pty-reconnected' + restorePtyIncarnation(ptyId, 'incarnation-current') + + expect(isCurrentPtyExit({ id: ptyId, incarnationId: 'incarnation-old' })).toBe(false) + expect(isCurrentPtyExit({ id: ptyId, incarnationId: 'incarnation-current' })).toBe(true) + clearProviderPtyState(ptyId) + }) + + it('fails closed when a recovered claimed owner omits incarnation proof', async () => { + const owner: AgentSessionOwnerBinding = { + claim: { + ...recoveredAgentClaim, + identityDigest: '1212121212121212121212121212121212121212121' + }, + generation: 'generation-no-incarnation', + phase: 'live', + ptyId: 'pty-owner-without-incarnation', + surface: recoveredAgentSurface + } + const provider = createAgentClaimProvider({ + sessions: [ + { + id: owner.ptyId, + cwd: '/tmp/recovered-worktree', + title: 'Codex', + agentSessionOwners: [owner] + } + ] + }) + setLocalPtyProvider(provider as never) + const controller = registerAgentClaimController() + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + cwd: '/tmp/recovered-worktree', + agentSessionEnsure: { claim: owner.claim, surface: recoveredAgentSurface } + }) + ).rejects.toThrow('agent_session_ownership_unknown') + expect(provider.spawn).not.toHaveBeenCalled() + }) + + it('fails closed without spawning when a recovered owner provider disconnects', async () => { + const connectionId = 'ssh-agent-owner-gone' + const ownerPtyId = `ssh:${connectionId}@@relay-owner` + const claim = { + ...recoveredAgentClaim, + identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc' + } + const owner: AgentSessionOwnerBinding = { + claim, + generation: 'generation-remote', + phase: 'live', + ptyId: ownerPtyId, + surface: recoveredAgentSurface + } + const remoteProvider = createAgentClaimProvider({ + sessions: [ + { + id: ownerPtyId, + incarnationId: 'incarnation-remote', + cwd: '/tmp/recovered-worktree', + title: 'Codex', + agentSessionOwners: [owner] + } + ], + livePtyIds: new Set([ownerPtyId]) + }) + registerSshPtyProvider(connectionId, remoteProvider as never) + setLocalPtyProvider(createAgentClaimProvider({}) as never) + const controller = registerAgentClaimController() + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + cwd: '/tmp/recovered-worktree', + agentSessionEnsure: { claim, surface: recoveredAgentSurface } + }) + ).resolves.toMatchObject({ id: ownerPtyId }) + + unregisterSshPtyProvider(connectionId) + const localSpawn = vi.fn(async () => ({ id: 'must-not-spawn' })) + setLocalPtyProvider(createAgentClaimProvider({ spawn: localSpawn }) as never) + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + cwd: '/tmp/recovered-worktree', + agentSessionEnsure: { claim, surface: recoveredAgentSurface } + }) + ).rejects.toThrow('execution_owner_unavailable') + expect(localSpawn).not.toHaveBeenCalled() + clearProviderPtyState(ownerPtyId) + }) + + it('fails closed when provider listings disagree about a recovered claim owner', async () => { + const claim = { + ...recoveredAgentClaim, + identityDigest: 'ddddddddddddddddddddddddddddddddddddddddddd' + } + const localOwner: AgentSessionOwnerBinding = { + claim, + generation: 'generation-conflict', + phase: 'live', + ptyId: 'pty-conflict-local', + surface: recoveredAgentSurface + } + const remoteOwner: AgentSessionOwnerBinding = { + ...localOwner, + ptyId: 'ssh:ssh-agent-conflict@@pty-conflict-remote' + } + const localSpawn = vi.fn(async () => ({ id: 'must-not-spawn' })) + setLocalPtyProvider( + createAgentClaimProvider({ + sessions: [ + { + id: localOwner.ptyId, + incarnationId: 'incarnation-conflict-local', + cwd: '/tmp/recovered-worktree', + title: 'Codex', + agentSessionOwners: [localOwner] + } + ], + spawn: localSpawn + }) as never + ) + registerSshPtyProvider( + 'ssh-agent-conflict', + createAgentClaimProvider({ + sessions: [ + { + id: remoteOwner.ptyId, + incarnationId: 'incarnation-conflict-remote', + cwd: '/tmp/recovered-worktree', + title: 'Codex', + agentSessionOwners: [remoteOwner] + } + ] + }) as never + ) + const controller = registerAgentClaimController() + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + cwd: '/tmp/recovered-worktree', + agentSessionEnsure: { claim, surface: recoveredAgentSurface } + }) + ).rejects.toThrow('agent_session_conflict') + expect(localSpawn).not.toHaveBeenCalled() + + unregisterSshPtyProvider('ssh-agent-conflict') + clearProviderPtyState(localOwner.ptyId) + clearProviderPtyState(remoteOwner.ptyId) + }) + + it('converges after conflicting listings shrink to one exact owner', async () => { + const claim = { + ...recoveredAgentClaim, + identityDigest: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' + } + const ownerA: AgentSessionOwnerBinding = { + claim, + generation: 'generation-a', + phase: 'live', + ptyId: 'pty-conflict-a', + surface: recoveredAgentSurface + } + const ownerB: AgentSessionOwnerBinding = { + ...ownerA, + generation: 'generation-b', + ptyId: 'ssh:ssh-agent-converge@@pty-conflict-b' + } + const localSessions = [ + { + id: ownerA.ptyId, + incarnationId: 'incarnation-conflict-a', + cwd: '/tmp/recovered-worktree', + title: 'Codex', + agentSessionOwners: [ownerA] + } + ] + const remoteSessions = [ + { + id: ownerB.ptyId, + incarnationId: 'incarnation-conflict-b', + cwd: '/tmp/recovered-worktree', + title: 'Codex', + agentSessionOwners: [ownerB] + } + ] + const local = createAgentClaimProvider({ sessions: localSessions }) + setLocalPtyProvider(local as never) + registerSshPtyProvider( + 'ssh-agent-converge', + createAgentClaimProvider({ sessions: remoteSessions }) as never + ) + const controller = registerAgentClaimController() + const request = { + cols: 80, + rows: 24, + cwd: '/tmp/recovered-worktree', + agentSessionEnsure: { claim, surface: recoveredAgentSurface } + } + + await expect(controller.spawn(request)).rejects.toThrow('agent_session_conflict') + localSessions.splice(0) + await expect(controller.spawn(request)).resolves.toMatchObject({ + id: ownerB.ptyId, + agentSessionEnsure: { disposition: 'adopted', owner: ownerB } + }) + expect(local.spawn).not.toHaveBeenCalled() + + unregisterSshPtyProvider('ssh-agent-converge') + clearProviderPtyState(ownerA.ptyId) + clearProviderPtyState(ownerB.ptyId) + }) + + it('does not adopt a stale generation when its PTY id is reused without ownership', async () => { + const claim = { + ...recoveredAgentClaim, + identityDigest: 'fffffffffffffffffffffffffffffffffffffffffff' + } + const oldOwner: AgentSessionOwnerBinding = { + claim, + generation: 'generation-old', + phase: 'live', + ptyId: 'pty-reused', + surface: recoveredAgentSurface + } + const sessions = [ + { + id: oldOwner.ptyId, + incarnationId: 'incarnation-old', + cwd: '/tmp/recovered-worktree', + title: 'Codex', + agentSessionOwners: [oldOwner] + } + ] + const spawn = vi.fn( + async (options: { + agentSessionEnsure?: { claim: typeof claim; surface: typeof recoveredAgentSurface } + }) => { + const ensured = options.agentSessionEnsure + if (!ensured) { + throw new Error('missing test claim') + } + const owner: AgentSessionOwnerBinding = { + claim: ensured.claim, + generation: 'generation-new', + phase: 'live', + ptyId: 'pty-new-owner', + surface: ensured.surface + } + sessions.push({ + id: owner.ptyId, + incarnationId: 'incarnation-new', + cwd: '/tmp/recovered-worktree', + title: 'Codex', + agentSessionOwners: [owner] + }) + return { + id: owner.ptyId, + agentSessionEnsure: { disposition: 'created' as const, owner } + } + } + ) + const provider = createAgentClaimProvider({ sessions, spawn }) + setLocalPtyProvider(provider as never) + const controller = registerAgentClaimController() + const request = { + cols: 80, + rows: 24, + cwd: '/tmp/recovered-worktree', + agentSessionEnsure: { claim, surface: recoveredAgentSurface } + } + + await expect(controller.spawn(request)).resolves.toMatchObject({ id: oldOwner.ptyId }) + sessions[0] = { ...sessions[0], agentSessionOwners: [] } + await expect(controller.spawn(request)).resolves.toMatchObject({ + id: 'pty-new-owner', + agentSessionEnsure: { disposition: 'created' } + }) + expect(spawn).toHaveBeenCalledOnce() + + clearProviderPtyState(oldOwner.ptyId) + clearProviderPtyState('pty-new-owner') + }) + + it('preserves an owner fence across disconnect and adopts it after reconnect', async () => { + const connectionId = 'ssh-agent-reconnect' + const claim = { + ...recoveredAgentClaim, + identityDigest: '9999999999999999999999999999999999999999999' + } + const owner: AgentSessionOwnerBinding = { + claim, + generation: 'generation-reconnect', + phase: 'live', + ptyId: `ssh:${connectionId}@@pty-owner`, + surface: recoveredAgentSurface + } + const sessions = [ + { + id: owner.ptyId, + incarnationId: 'incarnation-reconnect', + cwd: '/tmp/recovered-worktree', + title: 'Codex', + agentSessionOwners: [owner] + } + ] + const firstProvider = createAgentClaimProvider({ sessions }) + setLocalPtyProvider(createAgentClaimProvider({}) as never) + registerSshPtyProvider(connectionId, firstProvider as never) + const controller = registerAgentClaimController() + const request = { + cols: 80, + rows: 24, + cwd: '/tmp/recovered-worktree', + connectionId, + agentSessionEnsure: { claim, surface: recoveredAgentSurface } + } + + await expect(controller.spawn(request)).resolves.toMatchObject({ id: owner.ptyId }) + unregisterSshPtyProvider(connectionId) + clearPtyOwnershipForConnection(connectionId) + await expect(controller.spawn({ ...request, connectionId: undefined })).rejects.toThrow( + 'execution_owner_unavailable' + ) + + const reconnected = createAgentClaimProvider({ sessions }) + registerSshPtyProvider(connectionId, reconnected as never) + await expect(controller.spawn(request)).resolves.toMatchObject({ + id: owner.ptyId, + agentSessionEnsure: { disposition: 'adopted', owner } + }) + expect(reconnected.spawn).not.toHaveBeenCalled() + + unregisterSshPtyProvider(connectionId) + clearProviderPtyState(owner.ptyId) + }) + function getPtyAckDataListener(): ( event: unknown, args: { id: string; charCount?: number; processedChars?: number } @@ -1566,13 +2324,17 @@ describe('registerPtyHandlers', () => { function setupDaemonAdapter( supportsGitCredentialGuardHost = true, - reportedWslDistro?: string | null + reportedWslDistro?: string | null, + supportsAgentSessionClaims = true, + supportsAgentSessionCreateOperations = supportsAgentSessionClaims ) { const daemonSpawn = vi.fn( async (options: { env: Record sessionId?: string isNewSession?: boolean + agentSessionCreateOperationId?: string + command?: string }) => ({ id: options.sessionId ?? 'daemon-pty', ...(reportedWslDistro !== undefined ? { wslDistro: reportedWslDistro } : {}) @@ -1581,6 +2343,8 @@ describe('registerPtyHandlers', () => { setLocalPtyProvider({ spawn: daemonSpawn, supportsGitCredentialGuardHost: () => supportsGitCredentialGuardHost, + supportsAgentSessionClaims: () => supportsAgentSessionClaims, + supportsAgentSessionCreateOperations: () => supportsAgentSessionCreateOperations, write: vi.fn(), resize: vi.fn(), kill: vi.fn(), @@ -2582,6 +3346,74 @@ describe('registerPtyHandlers', () => { expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined, 'pi') }) + it('reuses one attach-style daemon session for fresh-agent operation retries', async () => { + const daemonSpawn = setupDaemonAdapter() + let controller: + | { spawn: (args: Record) => Promise<{ id: string }> } + | undefined + const runtime = { + setPtyController: vi.fn((next) => { + controller = next + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const request = { + cols: 80, + rows: 24, + env: {}, + worktreeId: 'wt-alpha', + agentSessionCreateOperationId: 'a'.repeat(43) + } + + await controller!.spawn(request) + await controller!.spawn(request) + + const first = daemonSpawn.mock.calls.at(-2)?.[0] + const second = daemonSpawn.mock.calls.at(-1)?.[0] + expect(first?.sessionId).toBe('wt-alpha@@aaaaaaaa') + expect(second?.sessionId).toBe(first?.sessionId) + expect(first?.isNewSession).toBe(true) + expect(second?.isNewSession).toBe(true) + expect(first?.agentSessionCreateOperationId).toBe('a'.repeat(43)) + expect(second?.agentSessionCreateOperationId).toBe('a'.repeat(43)) + }) + + it('does not downgrade a structured claim after dispatch reaches an old daemon', async () => { + const daemonSpawn = setupDaemonAdapter(true, undefined, false) + let controller: + | { spawn: (args: Record) => Promise<{ id: string }> } + | undefined + const runtime = { + setPtyController: vi.fn((next) => { + controller = next + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + await expect( + controller!.spawn({ + cols: 80, + rows: 24, + worktreeId: recoveredAgentSurface.worktreeId, + tabId: recoveredAgentSurface.tabId, + leafId: recoveredAgentSurface.leafId, + command: "codex resume 'provider-session-1'", + agentSessionEnsure: { + claim: recoveredAgentClaim, + surface: recoveredAgentSurface + } + }) + ).rejects.toThrow('agent_session_claim_unavailable') + + expect(daemonSpawn).not.toHaveBeenCalled() + }) + it('falls back to process.env.PI_CODING_AGENT_DIR when baseEnv lacks it on the daemon path', async () => { // Why: buildPtyHostEnv reads `baseEnv.X ?? process.env.X` so the agent-dir guard works whether Pi's env came over IPC or via daemon fork. const env = await daemonSpawnAndGetEnv({}, undefined, undefined, { @@ -3133,7 +3965,7 @@ describe('registerPtyHandlers', () => { 'remote-pty', 'terminated' ) - expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined) }) it('controller kill does not duplicate exits when the provider emits exit during shutdown', async () => { @@ -3183,7 +4015,7 @@ describe('registerPtyHandlers', () => { await Promise.resolve() expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined) expect( mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) @@ -3237,7 +4069,7 @@ describe('registerPtyHandlers', () => { await expect(stopPromise).resolves.toBe(true) expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined) expect( mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) @@ -3302,7 +4134,7 @@ describe('registerPtyHandlers', () => { 'remote-pty', 'terminated' ) - expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined) }) it('splits the teardown budget so the liveness RPC gets only what shutdown left', async () => { @@ -3469,6 +4301,59 @@ describe('registerPtyHandlers', () => { expect(runtime.onPtyExit).not.toHaveBeenCalled() }) + it('does not accept an incarnation-less exit as proof that the current PTY stopped', async () => { + const exitListeners = new Set< + (payload: { id: string; code: number; incarnationId?: string }) => void + >() + const provider = { + spawn: vi.fn(async () => ({ id: 'local-incarnated', incarnationId: 'incarnation-live' })), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(async () => { + for (const listener of exitListeners) { + listener({ id: 'local-incarnated', code: 0 }) + } + }), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } + setLocalPtyProvider(provider as never) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + spawn: (args: { cols: number; rows: number }) => Promise<{ id: string }> + stopAndWait: (ptyId: string) => Promise + } + + await controller.spawn({ cols: 80, rows: 24 }) + await expect(controller.stopAndWait('local-incarnated')).resolves.toBe(true) + + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-incarnated', -1, 'incarnation-live') + }) + it('runtime controller kill routes app-scoped SSH ids through the parsed provider when ownership is absent', async () => { const localShutdown = vi.fn() setLocalPtyProvider({ @@ -3588,7 +4473,7 @@ describe('registerPtyHandlers', () => { expect(localShutdown).not.toHaveBeenCalled() expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated') - expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', -1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', -1, undefined) }) it('marks a detached SSH lease terminated when runtime controller kill has no provider', async () => { @@ -3620,7 +4505,7 @@ describe('registerPtyHandlers', () => { 'remote-pty', 'terminated' ) - expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined) }) it('preserves an SSH lease when runtime controller kill shutdown fails transiently', async () => { @@ -3681,7 +4566,7 @@ describe('registerPtyHandlers', () => { 'remote-pty', 'terminated' ) - expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined) }) it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => { @@ -3938,7 +4823,7 @@ describe('registerPtyHandlers', () => { immediate: true, keepHistory: true }) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1, undefined) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { id: 'local-pty', code: -1 @@ -3987,7 +4872,7 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:kill')!(null, { id: 'local-pty' }) expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined) expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual( [['pty:exit', { id: 'local-pty', code: 0 }]] ) @@ -4033,7 +4918,7 @@ describe('registerPtyHandlers', () => { } expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) - expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1, undefined) expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual( [['pty:exit', { id: 'local-pty', code: -1 }]] ) @@ -4235,7 +5120,7 @@ describe('registerPtyHandlers', () => { seq: 13, rawLength: 'daemon output'.length }) - expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0) + expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0, undefined) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { id: result.id, code: 0 @@ -5284,7 +6169,7 @@ describe('registerPtyHandlers', () => { expect.any(String), 'wt-1', null, - { tabId: 'tab-1', leafId }, + { tabId: 'tab-1', leafId, incarnationId: expect.any(String) }, false ) }) @@ -5554,10 +6439,174 @@ describe('registerPtyHandlers', () => { worktreeId: 'wt-1', tabId: 'tab-headless', leafId, - ptyId: expect.any(String) + ptyId: expect.any(String), + incarnationId: expect.any(String) }) }) + it('reports lower-owner commit before rejecting an early-exited runtime incarnation', async () => { + const persistPtyBinding = vi.fn() + const onPtySpawnCommitted = vi.fn() + const runtime = new OrcaRuntimeService({ + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getGitHubCache: () => ({ pr: {}, issue: {} }) as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: true + }), + persistPtyBinding + } as never) + const provider = createAgentClaimProvider({ + spawn: vi.fn(async () => { + runtime.onPtySpawned('pty-early-exit', 'incarnation-early-exit') + runtime.onPtyExit('pty-early-exit', 0, 'incarnation-early-exit') + return { + id: 'pty-early-exit', + incarnationId: 'incarnation-early-exit', + providerSequence: { value: 17, generation: 'reset' as const }, + wslDistro: 'Ubuntu' + } + }), + authoritativeOwnerListings: false + }) + setLocalPtyProvider(provider as never) + registerPtyHandlers(mainWindow as never, runtime, undefined, undefined, undefined, { + persistPtyBinding + } as never) + const controller = ( + runtime as unknown as { + ptyController: { + spawn(args: Record): Promise + } + } + ).ptyController + const tabId = '11111111-1111-4111-8111-111111111111' + const leafId = '22222222-2222-4222-8222-222222222222' + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'repo::/tmp/worktree', + tabId, + leafId, + preAllocatedHandle: 'term_early_exit', + persistHostSessionBinding: true, + onPtySpawnCommitted + }) + ).rejects.toThrow('agent_session_exited_during_start') + + expect(onPtySpawnCommitted).toHaveBeenCalledOnce() + expect(persistPtyBinding).not.toHaveBeenCalled() + const internals = runtime as unknown as { + handleByPtyId: Map + providerSequenceInitializedPtys: Set + ptyOutputSequenceById: Map + ptysById: Map + wslDistroByPtyId: Map + earlyExitedPtyIncarnations: Map + } + expect(internals.handleByPtyId.has('pty-early-exit')).toBe(false) + expect(internals.providerSequenceInitializedPtys.has('pty-early-exit')).toBe(false) + expect(internals.ptyOutputSequenceById.has('pty-early-exit')).toBe(false) + expect(internals.ptysById.get('pty-early-exit')?.connected).not.toBe(true) + expect(internals.wslDistroByPtyId.has('pty-early-exit')).toBe(false) + expect(internals.earlyExitedPtyIncarnations.has('pty-early-exit')).toBe(false) + clearProviderPtyState('pty-early-exit') + }) + + it('does not retain a claimed owner when its PTY exits before controller admission', async () => { + const runtime = new OrcaRuntimeService({ + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getGitHubCache: () => ({ pr: {}, issue: {} }) as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: true + }) + } as never) + const sessions: { + id: string + incarnationId: string + cwd: string + title: string + }[] = [] + let attempt = 0 + const physicalSpawn = vi.fn(async () => { + attempt += 1 + const incarnationId = attempt === 1 ? 'incarnation-exited' : 'incarnation-live' + if (attempt === 1) { + runtime.onPtySpawned('pty-claimed-admission', incarnationId) + runtime.onPtyExit('pty-claimed-admission', 0, incarnationId) + } else { + sessions.push({ + id: 'pty-claimed-admission', + incarnationId, + cwd: '/tmp/worktree', + title: 'Codex' + }) + } + return { id: 'pty-claimed-admission', incarnationId } + }) + const provider = createAgentClaimProvider({ + sessions, + spawn: physicalSpawn, + authoritativeOwnerListings: false + }) + Object.assign(provider, { routesFreshSpawnsToLocalProvider: true }) + setLocalPtyProvider(provider as never) + registerPtyHandlers(mainWindow as never, runtime) + const controller = ( + runtime as unknown as { + ptyController: { spawn(args: Record): Promise } + } + ).ptyController + const request = { + cols: 80, + rows: 24, + cwd: '/tmp/worktree', + agentSessionEnsure: { + claim: recoveredAgentClaim, + surface: recoveredAgentSurface + } + } + + await expect(controller.spawn(request)).rejects.toThrow('agent_session_exited_during_start') + expect( + isCurrentPtyExit({ + id: 'pty-claimed-admission', + incarnationId: 'unrelated-incarnation' + }) + ).toBe(true) + await expect(controller.spawn(request)).resolves.toMatchObject({ + id: 'pty-claimed-admission', + agentSessionEnsure: { disposition: 'created' } + }) + expect(physicalSpawn).toHaveBeenCalledTimes(2) + clearProviderPtyState('pty-claimed-admission') + }) + it('reuses runtime materialization when renderer focuses the same pane during spawn', async () => { type RuntimeSpawnController = { spawn(args: { @@ -6598,9 +7647,14 @@ describe('registerPtyHandlers', () => { }): Promise<{ id: string }> } const appPtyId = 'ssh:ssh-fresh-fail@@relay-pty' - const remoteShutdown = vi.fn() + const incarnationId = 'incarnation-fresh-fail' + const runtime = new OrcaRuntimeService() + const remoteShutdown = vi.fn(async () => { + // Model the relay's exit callback winning before shutdown resolves. + runtime.onPtyExit(appPtyId, 0, incarnationId) + }) registerSshPtyProvider('ssh-fresh-fail', { - spawn: vi.fn(async () => ({ id: appPtyId })), + spawn: vi.fn(async () => ({ id: appPtyId, incarnationId })), write: vi.fn(), resize: vi.fn(), shutdown: remoteShutdown, @@ -6628,19 +7682,6 @@ describe('registerPtyHandlers', () => { removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn() } - let controller: RuntimeSpawnController | null = null - const runtime = { - setPtyController: vi.fn((value) => { - controller = value - }), - createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), - registerPreAllocatedHandleForPty: vi.fn(), - registerPty: vi.fn(), - noteTerminalSpawnCommand: vi.fn(), - onPtySpawned: vi.fn(), - onPtyExit: vi.fn(), - onPtyData: vi.fn() - } try { registerPtyHandlers( @@ -6651,7 +7692,8 @@ describe('registerPtyHandlers', () => { undefined, store as never ) - const spawnController = controller as unknown as RuntimeSpawnController + const spawnController = (runtime as unknown as { ptyController: RuntimeSpawnController }) + .ptyController const leafId = '11111111-1111-4111-8111-111111111111' await expect( @@ -6672,6 +7714,12 @@ describe('registerPtyHandlers', () => { expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled() expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId) expect(piClearPtyMock).toHaveBeenCalledWith(appPtyId) + const internals = runtime as unknown as { + earlyExitedPtyIncarnations: Map + pendingPtyRegistrationIncarnations: Map + } + expect(internals.earlyExitedPtyIncarnations.size).toBe(0) + expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0) } finally { unregisterSshPtyProvider('ssh-fresh-fail') } @@ -11064,7 +12112,12 @@ describe('registerPtyHandlers', () => { worktreeId: 'repo-1::/tmp' }) - expect(result).toEqual({ id: expect.any(String), pid: 12345, wslDistro: null }) + expect(result).toEqual({ + id: expect.any(String), + pid: 12345, + wslDistro: null, + incarnationId: expect.any(String) + }) expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock).toHaveBeenCalledWith( '/bin/zsh', @@ -11803,6 +12856,7 @@ describe('registerPtyHandlers', () => { const spawnResult = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string + incarnationId: string } // Without the guard the second load would sweep this PTY as a prior-generation orphan; under recovery-in-flight neither load may touch it. @@ -11863,6 +12917,7 @@ describe('registerPtyHandlers', () => { const spawnResult = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string + incarnationId: string } // First load only advances generation; the second sees this PTY as a prior-load orphan — with the flag false the guard must NOT suppress the sweep. @@ -11871,7 +12926,7 @@ describe('registerPtyHandlers', () => { await Promise.resolve() expect(killSpy).toHaveBeenCalled() - expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1) + expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1, spawnResult.incarnationId) const listed = await getLocalPtyProvider().listProcesses() expect(listed.some((info) => info.id === spawnResult.id)).toBe(false) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 5738485dfda..b8b30484c70 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -62,10 +62,14 @@ import { SSH_SESSION_EXPIRED_ERROR, isSshPtyIdentityMismatchError, isSshPtyNotFoundError -} from '../providers/ssh-pty-provider' +} from '../providers/ssh-pty-errors' import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { createPtySpawnTiming } from './pty-spawn-timing' -import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' +import { + isSafePtySessionId, + mintPtySessionId, + ptySessionIdForAgentCreateOperation +} from '../daemon/pty-session-id' import { resolveWslSessionContext } from '../daemon/wsl-session-context' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' @@ -113,6 +117,11 @@ import { } from '../../shared/terminal-startup-cwd' import { isWslUncPath } from '../../shared/wsl-paths' import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import { + agentSessionOwnerBindingsEqual, + ClaimedAgentPtyOwnerRegistry +} from '../../shared/claimed-agent-pty-owner' import { clearMigrationUnsupportedPty, clearMigrationUnsupportedPtysForPaneKey @@ -157,6 +166,7 @@ import { } from '../project-groups/folder-workspace-path-status' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId (null = local provider). @@ -171,6 +181,12 @@ const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 const PRODUCER_FLOW_CONTROL_ENABLED = true // Why: post-spawn write/resize/kill calls carry only the PTY ID; map it to its connectionId so ops route to the right provider. const ptyOwnership = new Map() +const ptyIncarnationById = new Map() + +export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean { + const current = ptyIncarnationById.get(payload.id) + return !current || payload.incarnationId === current +} // Why: mobile clients must mirror desktop PTY geometry even before the renderer can provide an xterm snapshot (e.g. right after tab creation). const ptySizes = new Map() // Why: the "recent user input" signal is PTY-scoped and must be cleared by every teardown path, incl. SSH/daemon shutdowns that skip the local exit listener. @@ -226,7 +242,84 @@ type PaneSpawnReservationResult = { } & Partial // Why: mobile materialization and a newly-focused pane can race to spawn the same leaf; key by paneKey so the loser adopts the winner's PTY. const paneSpawnReservationsByPaneKey = new Map() -// Why: PTY ids are reusable and teardown callbacks carry no incarnation token; bind the generation to its spawn result so teardown never guesses. +// Why: one main process can route the same remote provider namespace through +// multiple SSH relays; coordinate claims above every provider boundary too. +const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() +let agentSessionOwnerReconciliation: Promise | null = null + +function assertSpawnReplyWasLive(result: PtySpawnResult): void { + if (!result.exitedBeforeSpawnReply) { + return + } + // Why: lower owners can resolve a different canonical id, so controller-local pending ids cannot prove this exit. + throw Object.assign(new Error('agent_session_exited_during_start'), { + agentSessionOperationOutcome: 'unknown' as const + }) +} + +async function reconcileAgentSessionOwnerListings(): Promise { + if (agentSessionOwnerReconciliation) { + return await agentSessionOwnerReconciliation + } + const reconciliation = (async () => { + const providers: { provider: IPtyProvider; connectionId: string | null }[] = [ + { provider: localProvider, connectionId: null }, + ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) + ] + const listings = await Promise.all( + providers.map(async ({ provider, connectionId }) => ({ + connectionId, + sessions: await provider.listProcesses() + })) + ) + const advertisedOwners: AgentSessionOwnerBinding[] = [] + const advertisedOwnerSessions: { + id: string + connectionId: string | null + incarnationId: string + }[] = [] + for (const { connectionId, sessions } of listings) { + for (const session of sessions) { + const incarnationId = session.incarnationId + let hasAdvertisedOwner = false + for (const owner of session.agentSessionOwners ?? []) { + if (owner.ptyId !== session.id || !isPtyIncarnationId(incarnationId)) { + // Why: a recovered claim without process-incarnation proof cannot safely reject a delayed exit. + throw new Error('agent_session_ownership_unknown') + } + advertisedOwners.push(owner) + hasAdvertisedOwner = true + } + if (hasAdvertisedOwner && isPtyIncarnationId(incarnationId)) { + advertisedOwnerSessions.push({ id: session.id, connectionId, incarnationId }) + } + } + } + agentSessionOwners.reconcileAuthoritative(advertisedOwners, { + // Why: an unregistered relay can still own a live PTY during reconnect; + // only providers that serialize claims may make listing absence authoritative. + isInAuthoritativeScope: (owner) => { + const provider = tryGetProviderForAgentSessionOwner(owner.ptyId) + return provider?.providesAgentSessionOwnerListings?.(owner.ptyId) === true + } + }) + for (const session of advertisedOwnerSessions) { + ptyOwnership.set(session.id, session.connectionId) + ptyIncarnationById.set(session.id, session.incarnationId) + } + })() + agentSessionOwnerReconciliation = reconciliation + try { + await reconciliation + } finally { + if (agentSessionOwnerReconciliation === reconciliation) { + agentSessionOwnerReconciliation = null + } + } +} +// Why: bind the declaration generation directly to its spawn result. PTY ids +// are reusable and teardown callbacks carry no incarnation token, so teardown +// must never guess which pending renderer generation it owns. const pendingPtyIdBySerializerGeneration = new Map() // Why: hasRendererSerializer probe needs a ptyId-keyed signal; a later spawn starts a fresh incarnation, subscription abort owns waiter cleanup. const rendererSerializerReadiness = new RendererTerminalSerializerReadiness() @@ -430,6 +523,16 @@ function closeStartupQueryAuthorityForPty(ptyId: string): void { } } +function tryGetProviderForAgentSessionOwner(ptyId: string): IPtyProvider | undefined { + const ownedConnectionId = ptyOwnership.get(ptyId) + const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(ptyId) : null + try { + return getProvider(parsedSshId?.connectionId ?? ownedConnectionId) + } catch { + return undefined + } +} + function normalizeNodePtySpawnError(err: unknown): Error { const rawMessage = err instanceof Error ? err.message : String(err) const hintedMessage = addNodePtyRecoveryHint(rawMessage) @@ -470,6 +573,27 @@ async function isProviderPtyLive( ) } +async function isProviderAgentSessionOwnerLive( + provider: IPtyProvider, + owner: AgentSessionOwnerBinding +): Promise { + const session = (await provider.listProcesses()).find((candidate) => candidate.id === owner.ptyId) + if (!session) { + return false + } + if (provider.providesAgentSessionOwnerListings?.(owner.ptyId) !== true) { + // Why: in-process local owners cannot serialize the controller claim; exact incarnation + // liveness keeps that claim authoritative until the normal PTY exit releases it. + const expectedIncarnation = ptyIncarnationById.get(owner.ptyId) + return expectedIncarnation !== undefined && session.incarnationId === expectedIncarnation + } + return Boolean( + session.agentSessionOwners?.some((candidate) => + agentSessionOwnerBindingsEqual(candidate, owner) + ) + ) +} + async function verifyPtyStopped( provider: IPtyProvider, ptyId: string, @@ -495,13 +619,15 @@ function finishPtyShutdown( id: string, connectionId: string | null | undefined, store: Store | undefined -): void { +): string | undefined { + const incarnationId = ptyIncarnationById.get(id) clearProviderPtyState(id) if (connectionId) { store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') } ptyOwnership.delete(id) markClaudePtyExited(id) + return incarnationId } // ─── Host PTY env assembly ────────────────────────────────────────── @@ -1098,14 +1224,16 @@ export function getPtyIdsForConnection(connectionId: string): string[] { } /** - * Remove all PTY ownership entries for a given connectionId. - * Why: SSH close leaves stale ownership entries that route later spawns to a dead provider and grow unbounded. + * Remove transient PTY routing entries for a disconnected connection. + * Claimed agent owners remain fenced because the relay process may survive and + * prove the exact same generation after reconnect. */ export function clearPtyOwnershipForConnection(connectionId: string): void { for (const [ptyId, connId] of ptyOwnership) { if (connId === connectionId) { - // Why: SSH close bypasses the local onExit, so sweep paneKey-scoped caches manually or they leak. - clearProviderPtyState(ptyId) + // Why: pane-scoped caches cannot route while disconnected, but claimed + // ownership must survive until reconnect makes absence authoritative. + clearProviderPtyState(ptyId, { preserveAgentSessionOwners: true }) ptyOwnership.delete(ptyId) } } @@ -1113,13 +1241,22 @@ export function clearPtyOwnershipForConnection(connectionId: string): void { // ─── Provider-scoped PTY state cleanup ────────────────────────────── -export function clearProviderPtyState(id: string): void { - // Why: OpenCode and Pi allocate PTY-scoped state outside the node-pty process table; centralizing cleanup avoids a teardown path forgetting one provider's overlay/hook state. +export function clearProviderPtyState( + id: string, + opts: { preserveAgentSessionOwners?: boolean } = {} +): void { + if (!opts.preserveAgentSessionOwners) { + agentSessionOwners.release(id) + } + // Why: OpenCode and Pi both allocate PTY-scoped runtime state outside the + // node-pty process table. Centralizing provider cleanup avoids drift where a + // new teardown path forgets to remove one provider's overlay/hook state. openCodeHookService.clearPty(id) piTitlebarExtensionService.clearPty(id) // Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards. markClaudePtyExited(id) ptySizes.delete(id) + ptyIncarnationById.delete(id) lastInputAtByPty.delete(id) interactiveOutputCharsByPty.delete(id) activeRendererPtys.delete(id) @@ -1178,7 +1315,17 @@ export function setPtyOwnership(id: string, connectionId: string | null): void { ptyOwnership.set(id, connectionId) } -// Why: store onData/onExit unsubscribers so macOS re-activation re-calling registerPtyHandlers doesn't leak duplicate listeners forwarding every event twice. +export function restorePtyIncarnation(id: string, incarnationId: string): void { + if (!isPtyIncarnationId(incarnationId)) { + throw new Error('Invalid PTY incarnation') + } + ptyIncarnationById.set(id, incarnationId) +} + +// Why: localProvider.onData/onExit return unsubscribe functions. Without +// storing and calling these on re-registration, macOS app re-activation +// creates a new BrowserWindow and re-calls registerPtyHandlers, leaking +// duplicate listeners that forward every event twice. let localDataUnsub: (() => void) | null = null let localExitUnsub: (() => void) | null = null let localBackgroundStreamUnsub: (() => void) | null = null @@ -1515,12 +1662,15 @@ export function registerPtyHandlers( } return env }, - onSpawned: (id) => runtime?.onPtySpawned(id), - onExit: (id, code) => { + onSpawned: (id, incarnationId) => runtime?.onPtySpawned(id, incarnationId), + onExit: (id, code, incarnationId) => { + if (!isCurrentPtyExit({ id, incarnationId })) { + return + } clearProviderPtyState(id) ptyOwnership.delete(id) markClaudePtyExited(id) - runtime?.onPtyExit(id, code) + runtime?.onPtyExit(id, code, incarnationId) }, onData: (id, data, timestamp, sequenceChars, transformed) => runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed) @@ -2382,8 +2532,12 @@ export function registerPtyHandlers( opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } ): Promise { let providerExitObserved = false + const expectedIncarnationId = ptyIncarnationById.get(id) const unsubscribe = provider.onExit((payload) => { - if (payload.id === id) { + if ( + payload.id === id && + (!expectedIncarnationId || payload.incarnationId === expectedIncarnationId) + ) { providerExitObserved = true } }) @@ -2532,6 +2686,9 @@ export function registerPtyHandlers( } }) localExitUnsub = localProvider.onExit((payload) => { + if (!isCurrentPtyExit(payload)) { + return + } if (consumeSyntheticKillExit(payload.id)) { return } @@ -2539,7 +2696,7 @@ export function registerPtyHandlers( clearProviderPtyState(payload.id) ptyOwnership.delete(payload.id) markClaudePtyExited(payload.id) - runtime?.onPtyExit(payload.id, payload.code) + runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId) } sendPtyExitToRenderer(payload) }) @@ -2795,14 +2952,19 @@ export function registerPtyHandlers( !args.connectionId && !(provider instanceof LocalPtyProvider) && !routesFreshSpawnsToLocalProvider(provider) - const requestedSessionId = args.sessionId?.trim() + const callerRequestedSessionId = args.sessionId?.trim() + const requestedSessionId = + callerRequestedSessionId ?? + (isDaemonHostSpawn && args.agentSessionCreateOperationId + ? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId) + : undefined) const sessionId = requestedSessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) const effectiveSessionRelayId = sessionId !== undefined ? getRelayPtyId(args.connectionId, sessionId) : undefined const effectiveSessionAppId = sessionId !== undefined ? getAppPtyId(args.connectionId, sessionId) : undefined - const isMintedSessionId = requestedSessionId === undefined && isDaemonHostSpawn + const isMintedSessionId = callerRequestedSessionId === undefined && isDaemonHostSpawn const expectedWslDistro = !args.connectionId ? (resolveWslSessionContext({ cwd, @@ -2912,6 +3074,14 @@ export function registerPtyHandlers( deadlineMs: 5_000 } } + let ptySpawnCommitReported = false + const reportPtySpawnCommitted = (): void => { + if (ptySpawnCommitReported) { + return + } + ptySpawnCommitReported = true + args.onPtySpawnCommitted?.() + } spawnOptions.envToDelete = mergePtyEnvDeletions( mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []), isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [] @@ -2983,6 +3153,35 @@ export function registerPtyHandlers( ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined } + if ( + args.agentSessionEnsure && + (await (provider as IPtyProvider).supportsAgentSessionClaims?.()) === false + ) { + // Why: runtime routing must select legacy before dispatch; never downgrade here after it began. + throw new Error('agent_session_claim_unavailable') + } + if ( + args.agentSessionCreateOperationId && + (await (provider as IPtyProvider).supportsAgentSessionCreateOperations?.()) === false + ) { + throw new Error('execution_owner_unavailable') + } + if (args.agentSessionEnsure) { + spawnOptions.agentSessionEnsure = args.agentSessionEnsure + } + if (args.agentSessionCreateOperationId) { + spawnOptions.agentSessionCreateOperationId = args.agentSessionCreateOperationId + } + if (args.signal) { + spawnOptions.signal = args.signal + } + if ( + args.onPtySpawnCommitted && + (provider instanceof LocalPtyProvider || routesFreshSpawnsToLocalProvider(provider)) + ) { + // Why: local fallback has no lower operation ledger, so commit must be reported at native spawn. + spawnOptions.onPtySpawnCommitted = reportPtySpawnCommitted + } const existingPaneSpawn = materializedPaneKey ? paneSpawnReservationsByPaneKey.get(materializedPaneKey) @@ -2999,6 +3198,8 @@ export function registerPtyHandlers( ? reservePaneSpawn(materializedPaneKey) : null let result: PtySpawnResult + let rejectedRegistrationCandidate: PtySpawnResult | null = null + let pendingRegistrationPtyId: string | null = null let preparedProvisionalExecutionContext = false try { try { @@ -3006,6 +3207,10 @@ export function registerPtyHandlers( trustedTerminalHandleEnv.add(args.preAllocatedHandle) } const expectedPtyId = effectiveSessionAppId ?? sessionId + if (expectedPtyId) { + runtime?.beginPtyRegistration?.(expectedPtyId) + pendingRegistrationPtyId = expectedPtyId + } if (isDaemonHostSpawn && expectedPtyId) { preparedProvisionalExecutionContext = runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { @@ -3016,7 +3221,93 @@ export function registerPtyHandlers( const sequenceBeforeProviderSpawn = expectedPtyId ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) : 0 - result = await provider.spawn(spawnOptions) + const assertClientStillConnected = (): void => { + if (args.signal?.aborted) { + throw new Error('client_disconnected') + } + } + if (args.agentSessionEnsure) { + // Why: daemon-backed claims can outlive this controller; import all + // proven owners before deciding that an identity is absent. + await reconcileAgentSessionOwnerListings() + const recoveredOwner = agentSessionOwners.find(args.agentSessionEnsure.claim) + if (recoveredOwner && pendingRegistrationPtyId !== recoveredOwner.ptyId) { + if (pendingRegistrationPtyId) { + runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId) + } + runtime?.beginPtyRegistration?.( + recoveredOwner.ptyId, + ptyIncarnationById.get(recoveredOwner.ptyId) + ) + pendingRegistrationPtyId = recoveredOwner.ptyId + } + let providerResult: PtySpawnResult | null = null + const ensured = await agentSessionOwners.ensure({ + claim: args.agentSessionEnsure.claim, + surface: args.agentSessionEnsure.surface, + spawn: async () => { + assertClientStillConnected() + providerResult = await provider.spawn(spawnOptions) + rejectedRegistrationCandidate = providerResult + // Why: a successful lower-owner return proves physical work committed even if admission sees an early exit. + reportPtySpawnCommitted() + assertSpawnReplyWasLive(providerResult) + runtime?.assertPtyRegistrationAllowed?.( + providerResult.id, + providerResult.incarnationId + ) + if (providerResult.incarnationId) { + // Why: local providers cannot serialize controller claims, so liveness proof + // needs the exact incarnation before the registry promotes the new owner. + ptyIncarnationById.set(providerResult.id, providerResult.incarnationId) + } + const providerEnsure = providerResult.agentSessionEnsure + return { + ptyId: providerResult.id, + ...(providerEnsure + ? { + owner: providerEnsure.owner, + disposition: providerEnsure.disposition + } + : {}) + } + }, + isLive: async (owner) => { + const ownerProvider = tryGetProviderForAgentSessionOwner(owner.ptyId) + if (!ownerProvider) { + // Why: a disconnected relay may keep its PTY alive during the + // grace window; missing transport is unknown, never absence. + throw new Error('execution_owner_unavailable') + } + return await isProviderAgentSessionOwnerLive(ownerProvider, owner) + } + }) + result = providerResult ?? { + id: ensured.owner.ptyId, + isReattach: true, + // Why: adoption from an authoritative listing must preserve the + // incarnation proof used to reject a delayed exit from an older process. + incarnationId: ptyIncarnationById.get(ensured.owner.ptyId) + } + result.agentSessionEnsure = ensured + } else { + assertClientStillConnected() + result = await provider.spawn(spawnOptions) + rejectedRegistrationCandidate = result + // Why: daemon/relay returns cross the physical commit boundary before controller admission. + reportPtySpawnCommitted() + assertSpawnReplyWasLive(result) + } + rejectedRegistrationCandidate ??= result + if (pendingRegistrationPtyId !== result.id) { + if (pendingRegistrationPtyId) { + runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId) + } + runtime?.beginPtyRegistration?.(result.id, result.incarnationId) + pendingRegistrationPtyId = result.id + } + // Why: admission precedes sequence/context state and every durable publication below. + runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId) if (result.providerSequence) { runtime?.synchronizePtyOutputSequenceFromProvider?.( result.id, @@ -3039,6 +3330,19 @@ export function registerPtyHandlers( }) } const rawMessage = err instanceof Error ? err.message : String(err) + if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) { + runtime?.releaseRejectedPtyRegistrationFence?.( + rejectedRegistrationCandidate.id, + rejectedRegistrationCandidate.incarnationId + ) + } + if (pendingRegistrationPtyId) { + runtime?.cancelPendingPtyRegistration?.( + pendingRegistrationPtyId, + rejectedRegistrationCandidate?.incarnationId + ) + pendingRegistrationPtyId = null + } const spawnError = normalizeNodePtySpawnError(err) const isIdentityMismatch = isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) @@ -3072,7 +3376,28 @@ export function registerPtyHandlers( trustedTerminalHandleEnv.delete(args.preAllocatedHandle) } } + if (result.agentSessionEnsure?.disposition === 'adopted') { + const owner = result.agentSessionEnsure.owner + ptyOwnership.set(result.id, args.connectionId ?? ptyOwnership.get(result.id) ?? null) + runtime?.registerPreAllocatedHandleForPty(result.id, owner.surface.terminalHandle) + if (result.incarnationId) { + ptyIncarnationById.set(result.id, result.incarnationId) + } + runtime?.registerPty(result.id, owner.surface.worktreeId, args.connectionId ?? null, { + tabId: owner.surface.tabId, + leafId: owner.surface.leafId, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) + }) + return { + id: result.id, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + agentSessionEnsure: result.agentSessionEnsure + } + } ptyOwnership.set(result.id, args.connectionId ?? null) + if (result.incarnationId) { + ptyIncarnationById.set(result.id, result.incarnationId) + } // Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero. if ( isNativeWindowsLocalPtySpawn({ @@ -3115,6 +3440,7 @@ export function registerPtyHandlers( tabId: hostSessionBinding.tabId, leafId: hostSessionBinding.leafId, ptyId: result.id, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), ...(cwd ? { startupCwd: cwd } : {}) }) } catch (err) { @@ -3128,7 +3454,9 @@ export function registerPtyHandlers( } clearProviderPtyState(result.id) } - throw new Error(createTerminalSessionStateSaveFailureMessage()) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) } persistSshLease() } @@ -3145,12 +3473,19 @@ export function registerPtyHandlers( isValidTerminalTabId(args.tabId) && args.tabId.length <= 512 && metadataLeafId !== null - ? { tabId: args.tabId, leafId: metadataLeafId } + ? { + tabId: args.tabId, + leafId: metadataLeafId, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) + } : undefined, !args.connectionId ? shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) : undefined ) + } else { + // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. + runtime?.cancelPendingPtyRegistration?.(result.id, result.incarnationId) } // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). runtime?.noteTerminalSpawnCommand?.(result.id, args.command ?? null) @@ -3193,10 +3528,26 @@ export function registerPtyHandlers( : null }) } - const response = { id: result.id } + const response = { + id: result.id, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + } return resolvePaneSpawnReservation(materializedPaneKey, paneSpawnReservation, response) } catch (err) { - // Why: any later throw must settle the reservation, or it lingers and every future spawn for this pane awaits a promise that never resolves (reject no-ops if already resolved). + if (pendingRegistrationPtyId) { + runtime?.cancelPendingPtyRegistration?.( + pendingRegistrationPtyId, + rejectedRegistrationCandidate?.incarnationId + ) + pendingRegistrationPtyId = null + } + // Why: once the reservation is created, any later throw — spawn + // failure, persist failure, or a post-spawn helper such as + // registerPty/rememberPaneKeyForPty/track — must settle it. Otherwise + // it lingers in paneSpawnReservationsByPaneKey and every future spawn + // for this pane awaits a promise that never resolves. reject is a + // no-op once the reservation has already resolved. rejectPaneSpawnReservation(materializedPaneKey, paneSpawnReservation, err) throw err } finally { @@ -3222,9 +3573,11 @@ export function registerPtyHandlers( provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) } catch { if (connectionId) { - // Why: runtime/CLI close can target a detached SSH PTY after its provider was unregistered; tombstone the lease so reconnect can't revive it. - finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1) + // Why: runtime/CLI close can target a detached SSH PTY after its + // provider was unregistered. Tombstone the lease so reconnect does + // not revive a terminal the user explicitly closed. + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, -1, incarnationId) rememberSyntheticKillExit(ptyId) sendPtyExitToRenderer({ id: ptyId, code: -1 }) return true @@ -3234,17 +3587,17 @@ export function registerPtyHandlers( // Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit. void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) .then((providerExitObserved) => { - finishPtyShutdown(ptyId, connectionId, store) + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) if (!providerExitObserved) { - runtime?.onPtyExit(ptyId, -1) + runtime?.onPtyExit(ptyId, -1, incarnationId) rememberSyntheticKillExit(ptyId) sendPtyExitToRenderer({ id: ptyId, code: -1 }) } }) .catch((err) => { if (isPtyAlreadyGoneError(err)) { - finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1) + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, -1, incarnationId) rememberSyntheticKillExit(ptyId) sendPtyExitToRenderer({ id: ptyId, code: -1 }) return @@ -3252,8 +3605,9 @@ export function registerPtyHandlers( console.warn( `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` ) - // Why: close runtime tails but keep provider ownership so a retry can still target a PTY that survived the failed shutdown. - runtime?.onPtyExit(ptyId, -1) + // Why: close runtime tails without clearing provider ownership, so + // a retry can still target a PTY that survived the failed shutdown. + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) }) return true } @@ -3264,7 +3618,7 @@ export function registerPtyHandlers( console.warn( `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` ) - runtime?.onPtyExit(ptyId, -1) + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) }) return true } @@ -3306,9 +3660,10 @@ export function registerPtyHandlers( provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) } catch { if (connectionId) { - // Why: an absent SSH provider means no live target to await, but the relay lease must still be tombstoned. - finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1) + // Why: an absent SSH provider means there is no live target left to + // await, but the relay lease must still be tombstoned. + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, -1, incarnationId) rememberSyntheticKillExit(ptyId) sendPtyExitToRenderer({ id: ptyId, code: -1 }) return true @@ -3342,9 +3697,9 @@ export function registerPtyHandlers( ) return false } - finishPtyShutdown(ptyId, connectionId, store) + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) if (!providerExitObserved) { - runtime?.onPtyExit(ptyId, -1) + runtime?.onPtyExit(ptyId, -1, incarnationId) rememberSyntheticKillExit(ptyId) sendPtyExitToRenderer({ id: ptyId, code: -1 }) } @@ -3947,6 +4302,8 @@ export function registerPtyHandlers( markHiddenRendererPty(preSpawnHiddenMarkId) } let result: PtySpawnResult + let rejectedRegistrationCandidate: PtySpawnResult | null = null + let pendingRegistrationPtyId: string | null = null let preparedProvisionalExecutionContext = false try { try { @@ -3955,6 +4312,10 @@ export function registerPtyHandlers( } spawnTiming.mark('options') const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId + if (expectedPtyId) { + runtime?.beginPtyRegistration?.(expectedPtyId) + pendingRegistrationPtyId = expectedPtyId + } if (isDaemonHostSpawn && expectedPtyId) { preparedProvisionalExecutionContext = runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { @@ -3966,6 +4327,16 @@ export function registerPtyHandlers( ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) : 0 result = await provider.spawn(spawnOptions) + rejectedRegistrationCandidate = result + if (pendingRegistrationPtyId !== result.id) { + if (pendingRegistrationPtyId) { + runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId) + } + runtime?.beginPtyRegistration?.(result.id, result.incarnationId) + pendingRegistrationPtyId = result.id + } + assertSpawnReplyWasLive(result) + runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId) if (result.providerSequence) { runtime?.synchronizePtyOutputSequenceFromProvider?.( result.id, @@ -3993,6 +4364,19 @@ export function registerPtyHandlers( unmarkHiddenRendererPty(preSpawnHiddenMarkId) } const rawMessage = err instanceof Error ? err.message : String(err) + if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) { + runtime?.releaseRejectedPtyRegistrationFence?.( + rejectedRegistrationCandidate.id, + rejectedRegistrationCandidate.incarnationId + ) + } + if (pendingRegistrationPtyId) { + runtime?.cancelPendingPtyRegistration?.( + pendingRegistrationPtyId, + rejectedRegistrationCandidate?.incarnationId + ) + pendingRegistrationPtyId = null + } const spawnError = normalizeNodePtySpawnError(err) const isIdentityMismatch = isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) @@ -4051,6 +4435,9 @@ export function registerPtyHandlers( reattach: result.isReattach ?? false }) ptyOwnership.set(result.id, args.connectionId ?? null) + if (result.incarnationId) { + ptyIncarnationById.set(result.id, result.incarnationId) + } if (initiallyHidden) { // Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn). markHiddenRendererPty(result.id) @@ -4085,7 +4472,6 @@ export function registerPtyHandlers( ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) // Why: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217). if ( - (isDaemonHostSpawn || args.connectionId) && store && typeof args.worktreeId === 'string' && typeof args.tabId === 'string' && @@ -4097,6 +4483,7 @@ export function registerPtyHandlers( tabId: args.tabId, leafId: validatedLeafId, ptyId: result.id, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), ...(cwd ? { startupCwd: cwd } : {}) }) } catch (err) { @@ -4113,7 +4500,9 @@ export function registerPtyHandlers( if (!result.isReattach && args.connectionId && store) { store.removeSshRemotePtyLease(args.connectionId, relayResultId) } - throw new Error(createTerminalSessionStateSaveFailureMessage()) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) } } // Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md. @@ -4186,12 +4575,20 @@ export function registerPtyHandlers( isValidTerminalTabId(args.tabId) && args.tabId.length <= 512 && metadataLeafId !== null - ? { tabId: args.tabId, leafId: metadataLeafId } + ? { + tabId: args.tabId, + leafId: metadataLeafId, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) + } : undefined, !args.connectionId ? shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) : undefined ) + pendingRegistrationPtyId = null + } else if (pendingRegistrationPtyId) { + runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId, result.incarnationId) + pendingRegistrationPtyId = null } // Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one. runtime?.noteTerminalSpawnCommand?.( @@ -4270,7 +4667,19 @@ export function registerPtyHandlers( } return resolvePaneSpawnReservation(reservationPaneKey, paneSpawnReservation, response) } catch (err) { - // Why: any later throw must settle the reservation, else it lingers and every future spawn for this pane awaits a promise that never resolves (reject no-ops if already resolved). + if (pendingRegistrationPtyId) { + runtime?.cancelPendingPtyRegistration?.( + pendingRegistrationPtyId, + rejectedRegistrationCandidate?.incarnationId + ) + pendingRegistrationPtyId = null + } + // Why: once the reservation is created, any later throw — + // spawn failure, persist failure, or a post-spawn helper such as + // seedHeadlessTerminal/registerPty/track — must settle it. Otherwise + // it lingers in paneSpawnReservationsByPaneKey and every future spawn + // for this pane awaits a promise that never resolves. reject is a + // no-op once the reservation has already resolved. rejectPaneSpawnReservation(reservationPaneKey, paneSpawnReservation, err) throw err } finally { @@ -4753,9 +5162,11 @@ export function registerPtyHandlers( } const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id) if (!provider && connectionId) { - // Why: detached SSH PTYs keep ownership after provider unregister, and hydrated app-scoped ids may arrive pre-ownership; tombstone instead of falling back local. - finishPtyShutdown(args.id, connectionId, store) - runtime?.onPtyExit(args.id, -1) + // Why: detached SSH PTYs intentionally keep ownership after their + // provider is unregistered; hydrated app-scoped ids can also arrive + // before ownership is rebuilt. Tombstone instead of falling back local. + const incarnationId = finishPtyShutdown(args.id, connectionId, store) + runtime?.onPtyExit(args.id, -1, incarnationId) rememberSyntheticKillExit(args.id) sendPtyExitToRenderer({ id: args.id, code: -1 }) return @@ -4774,10 +5185,11 @@ export function registerPtyHandlers( } /* session already dead — cleanup below handles the rest */ } - // Why: some shutdown paths don't emit onExit via the provider listener; this cleanup is idempotent and covers already-dead PTYs. - finishPtyShutdown(args.id, connectionId, store) + // Why: some shutdown paths do not emit onExit through the provider listener. + // Explicit cleanup is idempotent and covers already-dead PTYs. + const incarnationId = finishPtyShutdown(args.id, connectionId, store) if (!providerExitObserved) { - runtime?.onPtyExit(args.id, -1) + runtime?.onPtyExit(args.id, -1, incarnationId) rememberSyntheticKillExit(args.id) sendPtyExitToRenderer({ id: args.id, code: -1 }) } diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 49b6bcba83a..7c7cc82edc1 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -163,6 +163,7 @@ vi.mock('./pty', () => ({ setPtyOwnership: vi.fn(), getSshPtyProvider: vi.fn(), getPtyIdsForConnection: vi.fn().mockReturnValue([]), + isCurrentPtyExit: vi.fn().mockReturnValue(true), isRendererPtyOutputPaused: vi.fn().mockReturnValue(false) })) @@ -815,7 +816,7 @@ describe('SSH IPC handlers', () => { 'hello'.length, undefined ) - expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7) + expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7, undefined) }) it('mirrors SSH state broadcasts onto the runtime client-event stream', async () => { @@ -1192,7 +1193,7 @@ describe('SSH IPC handlers', () => { 'hello'.length, undefined ) - expect(secondRuntime.onPtyExit).toHaveBeenCalledWith('remote-pty', 9) + expect(secondRuntime.onPtyExit).toHaveBeenCalledWith('remote-pty', 9, undefined) expect(firstRuntime.onPtyData).not.toHaveBeenCalled() expect(firstRuntime.onPtyExit).not.toHaveBeenCalled() expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index f8f55ecaae8..a3cd63b8392 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -19,7 +19,7 @@ import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' import { isAuthError } from '../ssh/ssh-connection-utils' import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' -import { isSshPtyNotFoundError } from '../providers/ssh-pty-provider' +import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors' import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { registerSshBrowseHandler } from './ssh-browse' import { diff --git a/src/main/orca-profiles/profile-project-session-state.test.ts b/src/main/orca-profiles/profile-project-session-state.test.ts new file mode 100644 index 00000000000..c60622ac9ba --- /dev/null +++ b/src/main/orca-profiles/profile-project-session-state.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { + mergeWorkspaceSessions, + removeRepoFromWorkspaceSession +} from './profile-project-session-state' +import { extractSessionForTransfer } from './profile-project-session-transfer' + +const REMOVED_WORKTREE_ID = 'repo-a::/removed' +const RETAINED_WORKTREE_ID = 'repo-b::/retained' +const REMOVED_REPO_ID = 'repo-a' +const RETAINED_REPO_ID = 'repo-b' + +describe('profile project session state', () => { + it('keeps topology revisions monotonic while merging session authority records', () => { + const base = { + ...getDefaultWorkspaceSession(), + terminalTopologyRevisionByRepoId: { [REMOVED_REPO_ID]: 5 }, + terminalPtyIncarnationsByPaneKey: { 'base-tab:leaf': 'base-incarnation' } + } + const incoming = { + ...getDefaultWorkspaceSession(), + terminalTopologyRevisionByRepoId: { + [REMOVED_REPO_ID]: 3, + [RETAINED_REPO_ID]: 7 + }, + terminalPtyIncarnationsByPaneKey: { 'incoming-tab:leaf': 'incoming-incarnation' } + } + + const result = mergeWorkspaceSessions(base, incoming) + + expect(result.terminalTopologyRevisionByRepoId).toEqual({ + [REMOVED_REPO_ID]: 5, + [RETAINED_REPO_ID]: 7 + }) + expect(result.terminalPtyIncarnationsByPaneKey).toEqual({ + 'base-tab:leaf': 'base-incarnation', + 'incoming-tab:leaf': 'incoming-incarnation' + }) + }) + + it('rekeys terminal membership authority during project transfer', () => { + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [REMOVED_WORKTREE_ID]: [ + { + id: 'transferred-tab', + worktreeId: REMOVED_WORKTREE_ID, + title: 'Transferred', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'transferred-pty' + } + ] + }, + terminalTopologyRevisionByRepoId: { [REMOVED_REPO_ID]: 6 }, + terminalPtyIncarnationsByPaneKey: { + 'transferred-tab:leaf': 'transferred-incarnation' + }, + terminalSurfaceTombstonesByPaneKey: { + 'retired-tab:leaf': { + worktreeId: REMOVED_WORKTREE_ID, + parentTabId: 'retired-tab', + leafId: 'leaf', + ptyId: 'retired-pty', + incarnationId: 'retired-incarnation', + retiredAt: 1 + } + } + } + + const result = extractSessionForTransfer(session, 'repo-a', 'repo-c') + const transferredWorktreeId = 'repo-c::/removed' + + expect(result.terminalTopologyRevisionByRepoId).toEqual({ 'repo-c': 6 }) + expect(result.terminalPtyIncarnationsByPaneKey).toEqual({ + 'transferred-tab:leaf': 'transferred-incarnation' + }) + expect(result.terminalSurfaceTombstonesByPaneKey?.['retired-tab:leaf']?.worktreeId).toBe( + transferredWorktreeId + ) + }) + + it('prunes terminal membership authority records with a removed repo', () => { + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [REMOVED_WORKTREE_ID]: [ + { + id: 'removed-tab', + worktreeId: REMOVED_WORKTREE_ID, + title: 'Removed', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'removed-pty' + } + ], + [RETAINED_WORKTREE_ID]: [ + { + id: 'retained-tab', + worktreeId: RETAINED_WORKTREE_ID, + title: 'Retained', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'retained-pty' + } + ] + }, + terminalTopologyRevisionByRepoId: { + [REMOVED_REPO_ID]: 3, + [RETAINED_REPO_ID]: 4 + }, + terminalSurfaceTombstonesByPaneKey: { + 'removed-tab:removed-leaf': { + worktreeId: REMOVED_WORKTREE_ID, + parentTabId: 'removed-tab', + leafId: 'removed-leaf', + ptyId: 'removed-pty', + incarnationId: 'removed-incarnation', + retiredAt: 1 + }, + 'retained-tab:retained-leaf': { + worktreeId: RETAINED_WORKTREE_ID, + parentTabId: 'retained-tab', + leafId: 'retained-leaf', + ptyId: 'retained-pty', + incarnationId: 'retained-incarnation', + retiredAt: 2 + } + }, + terminalPtyIncarnationsByPaneKey: { + 'removed-tab:removed-leaf': 'removed-incarnation', + 'retained-tab:retained-leaf': 'retained-incarnation' + } + } + + const result = removeRepoFromWorkspaceSession(session, 'repo-a') + + expect(result.terminalTopologyRevisionByRepoId).toEqual({ + [RETAINED_REPO_ID]: 4 + }) + expect(result.terminalSurfaceTombstonesByPaneKey).toEqual({ + 'retained-tab:retained-leaf': + session.terminalSurfaceTombstonesByPaneKey['retained-tab:retained-leaf'] + }) + expect(result.terminalPtyIncarnationsByPaneKey).toEqual({ + 'retained-tab:retained-leaf': 'retained-incarnation' + }) + }) +}) diff --git a/src/main/orca-profiles/profile-project-session-state.ts b/src/main/orca-profiles/profile-project-session-state.ts index 0d80880a19d..7b5eb1e4491 100644 --- a/src/main/orca-profiles/profile-project-session-state.ts +++ b/src/main/orca-profiles/profile-project-session-state.ts @@ -8,6 +8,17 @@ import { removeRepoWorktreeRecord } from './profile-project-worktree-identity' +function mergeTerminalTopologyRevisions( + base: Record | undefined, + incoming: Record | undefined +): Record { + const merged = { ...base } + for (const [worktreeId, revision] of Object.entries(incoming ?? {})) { + merged[worktreeId] = Math.max(merged[worktreeId] ?? 0, revision) + } + return merged +} + export function mergeHostWorkspaceSessions( existing: Partial> | undefined, incoming: Partial> @@ -74,6 +85,18 @@ export function mergeWorkspaceSessions( ...base.defaultTerminalTabsAppliedByWorktreeId, ...incoming.defaultTerminalTabsAppliedByWorktreeId }, + terminalPtyIncarnationsByPaneKey: { + ...base.terminalPtyIncarnationsByPaneKey, + ...incoming.terminalPtyIncarnationsByPaneKey + }, + terminalTopologyRevisionByRepoId: mergeTerminalTopologyRevisions( + base.terminalTopologyRevisionByRepoId, + incoming.terminalTopologyRevisionByRepoId + ), + terminalSurfaceTombstonesByPaneKey: { + ...base.terminalSurfaceTombstonesByPaneKey, + ...incoming.terminalSurfaceTombstonesByPaneKey + }, activeWorktreeIdsOnShutdown: [ ...(base.activeWorktreeIdsOnShutdown ?? []), ...(incoming.activeWorktreeIdsOnShutdown ?? []) @@ -141,6 +164,25 @@ export function removeRepoFromWorkspaceSession( next.defaultTerminalTabsAppliedByWorktreeId, repoId ) + next.terminalTopologyRevisionByRepoId = removeRepoWorktreeRecord( + next.terminalTopologyRevisionByRepoId, + repoId + ) + if (next.terminalSurfaceTombstonesByPaneKey) { + next.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( + Object.entries(next.terminalSurfaceTombstonesByPaneKey).filter( + ([, tombstone]) => !ownerKeyBelongsToRepo(tombstone.worktreeId, repoId) + ) + ) + } + if (next.terminalPtyIncarnationsByPaneKey) { + next.terminalPtyIncarnationsByPaneKey = Object.fromEntries( + Object.entries(next.terminalPtyIncarnationsByPaneKey).filter(([paneKey]) => { + const separator = paneKey.lastIndexOf(':') + return separator < 1 || !removedTerminalTabIds.has(paneKey.slice(0, separator)) + }) + ) + } if (next.activeWorktreeId && isRepoWorktreeId(repoId, next.activeWorktreeId)) { next.activeWorktreeId = null } diff --git a/src/main/orca-profiles/profile-project-session-transfer.ts b/src/main/orca-profiles/profile-project-session-transfer.ts index 8380e31301b..4736599df23 100644 --- a/src/main/orca-profiles/profile-project-session-transfer.ts +++ b/src/main/orca-profiles/profile-project-session-transfer.ts @@ -40,7 +40,8 @@ function hasTransferredSessionState(session: WorkspaceSessionState): boolean { Object.keys(session.openFilesByWorktree ?? {}).length > 0 || Object.keys(session.browserTabsByWorktree ?? {}).length > 0 || Object.keys(session.unifiedTabs ?? {}).length > 0 || - Object.keys(session.tabGroups ?? {}).length > 0 + Object.keys(session.tabGroups ?? {}).length > 0 || + Object.keys(session.terminalTopologyRevisionByRepoId ?? {}).length > 0 ) } @@ -120,6 +121,10 @@ export function extractSessionForTransfer( source.defaultTerminalTabsAppliedByWorktreeId, (value) => structuredClone(value) ) + transferred.terminalTopologyRevisionByRepoId = mapOwnerRecord( + source.terminalTopologyRevisionByRepoId, + (value) => value + ) transferred.terminalLayoutsByTabId = {} for (const tabId of copiedTerminalTabIds) { const layout = source.terminalLayoutsByTabId[tabId] @@ -127,6 +132,28 @@ export function extractSessionForTransfer( transferred.terminalLayoutsByTabId[tabId] = structuredClone(layout) } } + transferred.terminalPtyIncarnationsByPaneKey = Object.fromEntries( + Object.entries(source.terminalPtyIncarnationsByPaneKey ?? {}).filter(([paneKey]) => { + const separator = paneKey.lastIndexOf(':') + return separator > 0 && copiedTerminalTabIds.has(paneKey.slice(0, separator)) + }) + ) + transferred.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( + Object.entries(source.terminalSurfaceTombstonesByPaneKey ?? {}).flatMap( + ([paneKey, tombstone]) => + isRepoWorktreeId(oldRepoId, tombstone.worktreeId) + ? [ + [ + paneKey, + { + ...structuredClone(tombstone), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tombstone.worktreeId) + } + ] as const + ] + : [] + ) + ) transferred.activeWorktreeIdsOnShutdown = source.activeWorktreeIdsOnShutdown ?.filter((worktreeId) => isRepoWorktreeId(oldRepoId, worktreeId)) .map((worktreeId) => rekeyWorktreeId(oldRepoId, newRepoId, worktreeId)) diff --git a/src/main/orca-profiles/profile-project-transfer-payload.ts b/src/main/orca-profiles/profile-project-transfer-payload.ts index 68075a71a11..f8e458df4df 100644 --- a/src/main/orca-profiles/profile-project-transfer-payload.ts +++ b/src/main/orca-profiles/profile-project-transfer-payload.ts @@ -123,7 +123,11 @@ function collectSessionWorktreeIds( addOwnerKeys(session.activeGroupIdByWorktree) addOwnerKeys(session.lastVisitedAtByWorktreeId) addOwnerKeys(session.defaultTerminalTabsAppliedByWorktreeId) + addOwnerKeys(session.terminalTopologyRevisionByRepoId) addOwnerKeys(session.activeFileIdByWorktree) + for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { + add(tombstone.worktreeId) + } add(session.activeWorktreeId) const activeScope = session.activeWorkspaceKey ? parseWorkspaceKey(session.activeWorkspaceKey) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 43f94be0942..fb65a4cf8ea 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -3420,6 +3420,22 @@ describe('Store', () => { expect(store.getWorktreeMeta('r2::/other')!.displayName).toBe('other') }) + it('does not retain topology authority for historically removed repos', async () => { + const store = await createStore() + + for (let index = 0; index < 25; index += 1) { + const repoId = `removed-${index}` + store.addRepo(makeRepo({ id: repoId, path: `/repo-${index}` })) + store.setWorkspaceSession({ + ...store.getWorkspaceSession(), + terminalTopologyRevisionByRepoId: { [repoId]: 1 } + }) + store.removeProject(repoId) + } + + expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId).toEqual({}) + }) + it('removeProject removes the derived project host setup compatibility record', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) @@ -8021,6 +8037,95 @@ describe('Store', () => { }) }) + it('admits a fresh host spawn after retirement while rejecting an older renderer topology', async () => { + const store = await createStore() + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + terminalTopologyRevisionByRepoId: { wt1: 1 } + }) + + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'fresh-tab', + leafId: TEST_LEAF_1, + ptyId: 'fresh-pty', + incarnationId: 'fresh-incarnation' + }) + + const admitted = structuredClone(store.getWorkspaceSession()) + expect(admitted.terminalTopologyRevisionByRepoId?.wt1).toBe(2) + expect(admitted.tabsByWorktree.wt1).toEqual([ + expect.objectContaining({ id: 'fresh-tab', ptyId: 'fresh-pty' }) + ]) + + store.setWorkspaceSession({ + ...admitted, + tabsByWorktree: { + ...admitted.tabsByWorktree, + wt1: admitted.tabsByWorktree.wt1.map((tab) => ({ + ...tab, + title: 'Fresh title', + sortOrder: 7 + })) + }, + terminalLayoutsByTabId: { + ...admitted.terminalLayoutsByTabId, + 'fresh-tab': { + ...admitted.terminalLayoutsByTabId['fresh-tab'], + titlesByLeafId: { [TEST_LEAF_1]: 'Fresh pane title' } + } + } + }) + expect(store.getWorkspaceSession().tabsByWorktree.wt1[0]).toMatchObject({ + id: 'fresh-tab', + ptyId: 'fresh-pty', + title: 'Fresh title', + sortOrder: 7 + }) + expect(store.getWorkspaceSession().terminalLayoutsByTabId['fresh-tab'].titlesByLeafId).toEqual({ + [TEST_LEAF_1]: 'Fresh pane title' + }) + + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [ + { + id: 'retired-tab', + worktreeId: 'wt1', + title: 'Retired', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'retired-pty' + } + ] + }, + terminalLayoutsByTabId: { + 'retired-tab': { + root: { type: 'leaf', leafId: TEST_LEAF_2 }, + activeLeafId: TEST_LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_2]: 'retired-pty' } + } + }, + terminalPtyIncarnationsByPaneKey: { + [`retired-tab:${TEST_LEAF_2}`]: 'retired-incarnation' + }, + terminalTopologyRevisionByRepoId: { wt1: 1 } + }) + + const afterStaleWrite = store.getWorkspaceSession() + expect(afterStaleWrite.tabsByWorktree.wt1).toEqual([ + expect.objectContaining({ id: 'fresh-tab', ptyId: 'fresh-pty' }) + ]) + expect(afterStaleWrite.terminalLayoutsByTabId['retired-tab']).toBeUndefined() + expect( + afterStaleWrite.terminalPtyIncarnationsByPaneKey?.[`retired-tab:${TEST_LEAF_2}`] + ).toBeUndefined() + }) + it('adds a missing split leaf to the durable root when a new pane spawns before layout debounce', async () => { const store = await createStore() store.setWorkspaceSession({ @@ -8078,6 +8183,227 @@ describe('Store', () => { }) }) + it('advances host topology when a live spawn adds a split leaf after retirement', async () => { + const store = await createStore() + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalTopologyRevisionByRepoId: { wt1: 1 } + }) + + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_2, + ptyId: 'pty-2' + }) + + const session = store.getWorkspaceSession() + expect(session.terminalTopologyRevisionByRepoId?.wt1).toBe(2) + expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_1]: 'pty-1', + [TEST_LEAF_2]: 'pty-2' + }) + }) + + it('keeps worktree deletion authoritative against stale writes and later same-path reuse', async () => { + const store = await createStore() + store.setWorktreeMeta('wt1', { displayName: 'Worktree' }) + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'old-tab', worktreeId: 'wt1', ptyId: 'old-pty' })] + }, + terminalLayoutsByTabId: { + 'old-tab': { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'old-pty' } + } + }, + terminalPtyIncarnationsByPaneKey: { + [`old-tab:${TEST_LEAF_1}`]: 'old-incarnation' + }, + terminalTopologyRevisionByRepoId: { wt1: 1 } + }) + const stale = structuredClone(store.getWorkspaceSession()) + + store.removeWorktreeMeta('wt1') + + expect(store.getWorkspaceSession().tabsByWorktree.wt1).toBeUndefined() + expect(store.getWorkspaceSession().terminalLayoutsByTabId['old-tab']).toBeUndefined() + expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.wt1).toBe(2) + + store.setWorkspaceSession(stale) + expect(store.getWorkspaceSession().tabsByWorktree.wt1).toEqual([]) + + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'fresh-tab', + leafId: TEST_LEAF_2, + ptyId: 'fresh-pty', + incarnationId: 'fresh-incarnation' + }) + expect(store.getWorkspaceSession().tabsByWorktree.wt1).toEqual([ + expect.objectContaining({ id: 'fresh-tab', ptyId: 'fresh-pty' }) + ]) + expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.wt1).toBe(3) + }) + + it('fences a delayed terminal snapshot after an empty worktree is deleted', async () => { + const store = await createStore() + const worktreeId = 'repo::/empty-worktree' + store.setWorktreeMeta(worktreeId, { displayName: 'Empty worktree' }) + const stale = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'late-tab', worktreeId, ptyId: 'late-pty' })] + } + } + + store.removeWorktreeMeta(worktreeId) + expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo).toBe(1) + + store.setWorkspaceSession(stale) + + expect(store.getWorkspaceSession().tabsByWorktree[worktreeId]).toEqual([]) + }) + + it('advances deletion authority when only a legacy retirement fence existed', async () => { + const store = await createStore() + const worktreeId = 'repo::/legacy-tombstone' + store.setWorktreeMeta(worktreeId, { displayName: 'Legacy worktree' }) + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + terminalSurfaceTombstonesByPaneKey: { + 'old-tab:old-leaf': { + worktreeId, + parentTabId: 'old-tab', + leafId: 'old-leaf', + ptyId: 'old-pty', + incarnationId: 'old-incarnation', + retiredAt: 1 + } + } + }) + const revisionBeforeDelete = + store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo ?? 0 + + store.removeWorktreeMeta(worktreeId) + + expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo).toBe( + revisionBeforeDelete + 1 + ) + }) + + it('enforces one repo epoch across siblings and admits a fresh sibling spawn', async () => { + const store = await createStore() + const worktreeA = 'repo::/worktree-a' + const worktreeB = 'repo::/worktree-b' + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeA]: [makeTerminalTab({ id: 'tab-a', worktreeId: worktreeA, ptyId: 'pty-a' })], + [worktreeB]: [makeTerminalTab({ id: 'tab-b', worktreeId: worktreeB, ptyId: 'pty-b' })] + }, + terminalTopologyRevisionByRepoId: { repo: 1 } + }) + + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeB]: [makeTerminalTab({ id: 'stale-tab', worktreeId: worktreeB })] + } + }) + + expect(store.getWorkspaceSession().tabsByWorktree[worktreeA]?.[0]?.id).toBe('tab-a') + expect(store.getWorkspaceSession().tabsByWorktree[worktreeB]?.[0]?.id).toBe('tab-b') + + store.persistPtyBinding({ + worktreeId: worktreeB, + tabId: 'fresh-tab', + leafId: TEST_LEAF_1, + ptyId: 'fresh-pty' + }) + + expect(store.getWorkspaceSession().tabsByWorktree[worktreeB].map((tab) => tab.id)).toEqual([ + 'tab-b', + 'fresh-tab' + ]) + expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo).toBe(2) + }) + + it('keeps one deletion watermark for many historical worktrees in the same repo', async () => { + const store = await createStore() + + for (let index = 0; index < 25; index += 1) { + const worktreeId = `repo::/worktree-${index}` + store.setWorktreeMeta(worktreeId, { displayName: `Worktree ${index}` }) + store.persistPtyBinding({ + worktreeId, + tabId: `tab-${index}`, + leafId: TEST_LEAF_1, + ptyId: `pty-${index}` + }) + store.removeWorktreeMeta(worktreeId) + } + + const session = store.getWorkspaceSession() + expect(Object.keys(session.terminalTopologyRevisionByRepoId ?? {})).toEqual(['repo']) + expect(session.tabsByWorktree).toEqual({}) + }) + + it('does not remove colliding worktree ids from other execution-host partitions', async () => { + const store = await createStore() + const worktreeId = 'repo::/same-path' + store.setWorktreeMeta(worktreeId, { displayName: 'Local worktree' }) + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'local-tab', worktreeId })] + } + }) + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'remote-a-tab', worktreeId })] + } + }, + 'runtime:env-a' + ) + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'remote-b-tab', worktreeId })] + } + }, + 'runtime:env-b' + ) + + store.removeWorktreeMeta(worktreeId) + + expect(store.getWorkspaceSession().tabsByWorktree[worktreeId]).toBeUndefined() + expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]?.[0]?.id).toBe( + 'remote-a-tab' + ) + expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]?.[0]?.id).toBe( + 'remote-b-tab' + ) + }) + it('preserves a sync-persisted UUID root when a stale empty layout write arrives', async () => { const store = await createStore() store.setWorkspaceSession({ @@ -9931,6 +10257,7 @@ describe('Store.migrateWorktreeIdentity', () => { activeGroupIdByWorktree: { [OLD]: 'group1' }, lastVisitedAtByWorktreeId: { [OLD]: 123 }, defaultTerminalTabsAppliedByWorktreeId: { [OLD]: true }, + terminalTopologyRevisionByRepoId: { repo1: 4 }, sleepingAgentSessionsByPaneKey: { 'tab1:leaf': { paneKey: 'tab1:leaf', @@ -9953,7 +10280,18 @@ describe('Store.migrateWorktreeIdentity', () => { activeWorkspaceKey: OLD_WORKSPACE_KEY, activeWorktreeId: OLD, tabsByWorktree: { [OLD]: [makeTerminalTab({ id: 'host-tab', worktreeId: OLD })] }, - terminalLayoutsByTabId: {} + terminalLayoutsByTabId: {}, + terminalTopologyRevisionByRepoId: { repo1: 8 }, + terminalSurfaceTombstonesByPaneKey: { + 'host-tab:leaf': { + worktreeId: OLD, + parentTabId: 'host-tab', + leafId: 'leaf', + ptyId: 'host-pty', + incarnationId: 'host-incarnation', + retiredAt: 1 + } + } }, 'runtime:env-a' ) @@ -9997,12 +10335,15 @@ describe('Store.migrateWorktreeIdentity', () => { expect(session.activeGroupIdByWorktree?.[NEW]).toBe('group1') expect(session.lastVisitedAtByWorktreeId?.[NEW]).toBe(123) expect(session.defaultTerminalTabsAppliedByWorktreeId?.[NEW]).toBe(true) + expect(session.terminalTopologyRevisionByRepoId?.repo1).toBe(4) expect(session.sleepingAgentSessionsByPaneKey?.['tab1:leaf']?.worktreeId).toBe(NEW) const hostSession = store.getWorkspaceSession('runtime:env-a') expect(hostSession.tabsByWorktree[OLD]).toBeUndefined() expect(hostSession.tabsByWorktree[NEW]?.[0]?.worktreeId).toBe(NEW) expect(hostSession.activeWorkspaceKey).toBe(NEW_WORKSPACE_KEY) + expect(hostSession.terminalTopologyRevisionByRepoId?.repo1).toBe(9) + expect(hostSession.terminalSurfaceTombstonesByPaneKey).toEqual({}) }) it('rewrites parentWorktreeId back-references in other lineage entries', async () => { @@ -10149,6 +10490,48 @@ describe('Store host-partitioned workspace sessions', () => { expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local') }) + it('preserves and enforces equal repo-id topology authority independently per host', async () => { + const store = await createStore() + const worktreeId = 'duplicate::/worktree' + const staleTabs = { + [worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId, ptyId: 'stale-pty' })] + } + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + terminalTopologyRevisionByRepoId: { duplicate: 2 } + }, + 'runtime:env-a' + ) + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'live-tab', worktreeId, ptyId: 'live-pty' })] + }, + terminalTopologyRevisionByRepoId: { duplicate: 7 } + }, + 'runtime:env-b' + ) + + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), tabsByWorktree: staleTabs }, + 'runtime:env-a' + ) + store.patchWorkspaceSession({ tabsByWorktree: staleTabs }, 'runtime:env-a') + + expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toEqual([]) + expect( + store.getWorkspaceSession('runtime:env-a').terminalTopologyRevisionByRepoId?.duplicate + ).toBe(2) + expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]?.[0]?.id).toBe( + 'live-tab' + ) + expect( + store.getWorkspaceSession('runtime:env-b').terminalTopologyRevisionByRepoId?.duplicate + ).toBe(7) + }) + it('defaults an omitted hostId to the local partition', async () => { const store = await createStore() store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a') diff --git a/src/main/persistence.ts b/src/main/persistence.ts index aba79a4561c..ebb0ebacca6 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -77,6 +77,11 @@ import { } from '../shared/task-source-context' import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' +import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement' +import { + removeRepoFromHostWorkspaceSessions, + removeRepoFromWorkspaceSession +} from './orca-profiles/profile-project-session-state' import { hardenExistingSecureFile } from '../shared/secure-file' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, @@ -2343,7 +2348,8 @@ function cloneWorkspaceSessionState(session: WorkspaceSessionState): WorkspaceSe function removeWorkspaceSessionOwner( session: WorkspaceSessionState | undefined, - ownerKey: string + ownerKey: string, + options: { advanceTerminalTopologyRevision?: boolean } = {} ): WorkspaceSessionState | undefined { if (!session) { return session @@ -2359,6 +2365,30 @@ function removeWorkspaceSessionOwner( next.activeTabId = null } } + if (next.terminalPtyIncarnationsByPaneKey) { + const removedTabIds = new Set(removedTerminalTabs.map((tab) => tab.id)) + next.terminalPtyIncarnationsByPaneKey = Object.fromEntries( + Object.entries(next.terminalPtyIncarnationsByPaneKey).filter(([paneKey]) => { + const separator = paneKey.lastIndexOf(':') + return separator < 1 || !removedTabIds.has(paneKey.slice(0, separator)) + }) + ) + } + if (next.terminalSurfaceTombstonesByPaneKey) { + next.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( + Object.entries(next.terminalSurfaceTombstonesByPaneKey).filter( + ([, tombstone]) => tombstone.worktreeId !== ownerKey + ) + ) + } + const repoId = getRepoIdFromWorktreeId(ownerKey) + const previousTopologyRevision = next.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + if (options.advanceTerminalTopologyRevision) { + next.terminalTopologyRevisionByRepoId = { + ...next.terminalTopologyRevisionByRepoId, + [repoId]: previousTopologyRevision + 1 + } + } if (next.openFilesByWorktree) { delete next.openFilesByWorktree[ownerKey] @@ -4137,6 +4167,11 @@ export class Store { // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. delete this.state.sparsePresetsByRepo[id] this.pruneWorktreeStateForRepo(id, null) + this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) + this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( + this.state.workspaceSessionsByHostId, + id + ) this.scheduleSave() } @@ -4153,6 +4188,21 @@ export class Store { this.syncProjectHostSetupCompatibilityState() // Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject). this.pruneWorktreeStateForRepo(id, idStillPresent ? hostId : null) + if (!idStillPresent) { + this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) + this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( + this.state.workspaceSessionsByHostId, + id + ) + } else if (parseExecutionHostId(hostId)?.kind === 'runtime') { + const session = this.state.workspaceSessionsByHostId?.[hostId] + if (session) { + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [hostId]: removeRepoFromWorkspaceSession(session, id) + } + } + } this.scheduleSave() } @@ -4772,6 +4822,11 @@ export class Store { delete this.state.worktreeMeta[worktreeId] delete this.state.worktreeLineageById[worktreeId] delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] + this.state.workspaceSession = removeWorkspaceSessionOwner( + this.state.workspaceSession, + worktreeId, + { advanceTerminalTopologyRevision: true } + )! this.scheduleSave() } @@ -4915,6 +4970,21 @@ export class Store { sessionChanged = true } } + if (session.terminalSurfaceTombstonesByPaneKey) { + let tombstonesChanged = false + const nextTombstones = { ...session.terminalSurfaceTombstonesByPaneKey } + for (const [paneKey, tombstone] of Object.entries(nextTombstones)) { + if (tombstone.worktreeId !== oldWorktreeId) { + continue + } + nextTombstones[paneKey] = { ...tombstone, worktreeId: newWorktreeId } + tombstonesChanged = true + } + if (tombstonesChanged) { + session.terminalSurfaceTombstonesByPaneKey = nextTombstones + sessionChanged = true + } + } return sessionChanged } @@ -5502,6 +5572,11 @@ export class Store { /** Persist a non-'local' host partition; remote hosts skip setLocalWorkspaceSession's local-daemon PTY-binding race guards. */ private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void { + // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. + session = sanitizeWorkspaceSessionTerminalRetirements( + session, + this.state.workspaceSessionsByHostId?.[hostId] + ) const pruned = pruneWorkspaceSessionBrowserHistory( pruneLocalTerminalScrollbackBuffers(session, this.state.repos) ) @@ -5513,12 +5588,13 @@ export class Store { } private setLocalWorkspaceSession(session: PersistedState['workspaceSession']): void { + const prior = this.state.workspaceSession + session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) session = pruneWorkspaceSessionBrowserHistory( pruneLocalTerminalScrollbackBuffers(session, this.state.repos) ) // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. - const prior = this.state.workspaceSession const normalized = normalizeWorkspaceSessionPaneIdentities( session, prior?.terminalLayoutsByTabId @@ -5798,6 +5874,7 @@ export class Store { tabId: string leafId: string ptyId: string + incarnationId?: string startupCwd?: string }): void { const session = this.state.workspaceSession @@ -5805,11 +5882,38 @@ export class Store { return } const sessionBeforeBinding = cloneWorkspaceSessionState(session) + const paneKey = `${args.tabId}:${args.leafId}` + let terminalMembershipChanged = false + const advanceTopologyAfterMembershipChange = (): void => { + const repoId = getRepoIdFromWorktreeId(args.worktreeId) + const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + if (!terminalMembershipChanged || currentRevision <= 0) { + return + } + // Why: a real host-admitted spawn after a retirement must be distinguishable from a stale renderer replay. + session.terminalTopologyRevisionByRepoId = { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: currentRevision + 1 + } + } + if (args.incarnationId) { + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [paneKey]: args.incarnationId + } + if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + session.terminalSurfaceTombstonesByPaneKey = { + ...session.terminalSurfaceTombstonesByPaneKey + } + delete session.terminalSurfaceTombstonesByPaneKey[paneKey] + } + } const tabs = session.tabsByWorktree?.[args.worktreeId] const tab = tabs?.find((t) => t.id === args.tabId) if (tab) { tab.ptyId = args.ptyId } else { + terminalMembershipChanged = true // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. const nextTabs = [ ...(tabs ?? []), @@ -5831,6 +5935,7 @@ export class Store { } if (!isTerminalLeafId(args.leafId)) { // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. + advanceTopologyAfterMembershipChange() try { this.flushOrThrow() } catch (err) { @@ -5842,11 +5947,13 @@ export class Store { const layout = session.terminalLayoutsByTabId?.[args.tabId] if (layout) { if (!layout.root) { + terminalMembershipChanged = true // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. layout.root = { type: 'leaf', leafId: args.leafId } layout.activeLeafId = args.leafId layout.expandedLeafId = null } else if (!layoutContainsLeafId(layout.root, args.leafId)) { + terminalMembershipChanged = true // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. layout.root = { type: 'split', @@ -5864,6 +5971,7 @@ export class Store { [args.leafId]: args.ptyId } } else { + terminalMembershipChanged = true // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. session.terminalLayoutsByTabId = { ...session.terminalLayoutsByTabId, @@ -5875,6 +5983,7 @@ export class Store { } } } + advanceTopologyAfterMembershipChange() try { this.flushOrThrow() } catch (err) { diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 76d5800e3ef..c2dcfcff523 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -425,8 +425,24 @@ describe('LocalPtyProvider', () => { it('invokes onSpawned callback', async () => { const onSpawned = vi.fn() provider.configure({ onSpawned }) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - expect(onSpawned).toHaveBeenCalledWith(id) + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) + expect(onSpawned).toHaveBeenCalledWith(id, incarnationId) + }) + + it('reports physical commit before post-spawn publication can fail', async () => { + spawnMock.mockClear() + const committed = vi.fn() + provider.configure({ + onSpawned: () => { + throw new Error('post-spawn publication failed') + } + }) + + await expect( + provider.spawn({ cols: 80, rows: 24, onPtySpawnCommitted: committed }) + ).rejects.toThrow('post-spawn publication failed') + expect(spawnMock).toHaveBeenCalledOnce() + expect(committed).toHaveBeenCalledOnce() }) it('invokes buildSpawnEnv callback to customize environment', async () => { @@ -1186,9 +1202,9 @@ describe('LocalPtyProvider', () => { it('invokes onExit callback via the node-pty exit handler', async () => { const onExit = vi.fn() provider.configure({ onExit }) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) await provider.shutdown(id, { immediate: true }) - expect(onExit).toHaveBeenCalledWith(id, -1) + expect(onExit).toHaveBeenCalledWith(id, -1, incarnationId) }) it('does not destroy after an intentional Windows shutdown kill', async () => { @@ -1734,12 +1750,12 @@ describe('LocalPtyProvider', () => { it('notifies exit listeners when PTY exits', async () => { const exitHandler = vi.fn() provider.onExit(exitHandler) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) // Simulate node-pty exit event exitCb?.({ exitCode: 0 }) - expect(exitHandler).toHaveBeenCalledWith({ id, code: 0 }) + expect(exitHandler).toHaveBeenCalledWith({ id, code: 0, incarnationId }) }) it('allows unsubscribing from events', async () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 638a0018187..226b5167e8d 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: splitting spawn() would scatter tightly coupled PTY lifecycle logic (scan → ready → write → exit) with no cleaner ownership seam. */ import { basename, delimiter } from 'node:path' +import { randomUUID } from 'node:crypto' import { win32 as pathWin32 } from 'node:path' import { resolveWindowsShellLaunchArgs } from './windows-shell-args' import { @@ -78,6 +79,7 @@ const PANE_IDENTITY_ENV_KEYS = [ let ptyCounter = 0 const ptyProcesses = new Map() +const ptyIncarnations = new Map() // Why: only agent sessions get descendant tree-kill (tool children run in detached groups SIGHUP can't reach); plain terminals skip it so nohup-detached children survive. const ptyAgentSessionIds = new Set() // Why: descendant capture is async, so reattach/duplicate shutdown must wait for the original owner, not return a dying PTY. @@ -123,7 +125,7 @@ type DataCallback = (payload: { transformed?: boolean seq?: number }) => void -type ExitCallback = (payload: { id: string; code: number }) => void +type ExitCallback = (payload: { id: string; code: number; incarnationId?: string }) => void const dataListeners = new Set() const exitListeners = new Set() @@ -236,6 +238,7 @@ function clearPtyState(id: string): void { disposePtyListeners(id) disposePtyExitListener(id) ptyProcesses.delete(id) + ptyIncarnations.delete(id) ptyAgentSessionIds.delete(id) ptyShellName.delete(id) ptyAgentForegroundContextPaths.delete(id) @@ -488,8 +491,8 @@ export type LocalPtyProviderOptions = { getWindowsShell?: () => string | undefined getWindowsPowerShellImplementation?: () => 'auto' | 'powershell.exe' | 'pwsh.exe' | undefined pwshAvailable?: () => boolean - onSpawned?: (id: string) => void - onExit?: (id: string, code: number) => void + onSpawned?: (id: string, incarnationId: string) => void + onExit?: (id: string, code: number, incarnationId: string) => void onData?: ( id: string, data: string, @@ -529,6 +532,7 @@ export class LocalPtyProvider implements IPtyProvider { } } const id = allocatePtyId(reattachId ?? undefined) + const incarnationId = randomUUID() const startupAgentRecognition = args.command ? recognizeAgentProcessFromCommandLine(args.command) @@ -802,6 +806,9 @@ export class LocalPtyProvider implements IPtyProvider { } await prepareLocalPtySpawn(id) + if (args.signal?.aborted) { + throw new Error('client_disconnected') + } // Why: another same-id request can win while this one awaits preflight; attach before launching a redundant shell. const concurrentWinner = reattachId ? reattachLocalPty(id, args.cols, args.rows) : null if (concurrentWinner) { @@ -823,6 +830,7 @@ export class LocalPtyProvider implements IPtyProvider { : undefined, windowsFallbackAttempts }) + args.onPtySpawnCommitted?.() shellPath = spawnResult.shellPath // Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write. if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) { @@ -862,7 +870,8 @@ export class LocalPtyProvider implements IPtyProvider { getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId }) ) ptyLoadGeneration.set(id, loadGeneration) - this.opts.onSpawned?.(id) + ptyIncarnations.set(id, incarnationId) + this.opts.onSpawned?.(id, incarnationId) const emitIngressData = (emission: PtyIngressEmission): void => { const sequenceChars = emission.rawEndSeq - emission.rawStartSeq @@ -987,9 +996,9 @@ export class LocalPtyProvider implements IPtyProvider { startupIngressByPty.delete(id) // Why: release the master ptmx fd on natural exit, else a clean exit leaks the fd until GC. See docs/fix-pty-fd-leak.md. destroyPtyProcess(proc, { alreadyKilled: wasTerminationRequested }) - this.opts.onExit?.(id, exitCode) + this.opts.onExit?.(id, exitCode, incarnationId) for (const cb of exitListeners) { - cb({ id, code: exitCode }) + cb({ id, code: exitCode, incarnationId }) } }) if (onExitDisposable) { @@ -1018,6 +1027,7 @@ export class LocalPtyProvider implements IPtyProvider { const pid = typeof rawPid === 'number' && Number.isFinite(rawPid) && rawPid > 0 ? rawPid : null return { id, + incarnationId, pid, ...(spawnedWslDistro !== undefined ? { wslDistro: spawnedWslDistro } : {}) } @@ -1309,6 +1319,7 @@ export class LocalPtyProvider implements IPtyProvider { async listProcesses(): Promise { return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ id, + ...(ptyIncarnations.get(id) ? { incarnationId: ptyIncarnations.get(id) } : {}), cwd: ptyInitialCwd.get(id) ?? '', title: proc.process || ptyShellName.get(id) || 'shell', ...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}), diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 5a126e7e77d..4521925730e 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -1,10 +1,17 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TuiAgent } from '../../shared/types' +import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type PtySpawnResult = { + agentSessionEnsure?: AgentSessionClaimedSpawnResult /** App-facing PTY id. Remote providers must return globally routable ids, * not relay-local handles, because renderer/runtime IPC routes by this key. */ id: string + /** Opaque provider-owned identity for this process behind a reusable PTY id. */ + incarnationId?: PtyIncarnationId + /** The provider observed this exact spawn exit before its control reply settled. */ + exitedBeforeSpawnReply?: true /** OS-level pid of the shell process, when available at spawn time. * Why: the memory collector needs this to walk each PTY's process * subtree. Daemon-backed providers return it from the RPC result; diff --git a/src/main/providers/ssh-agent-session-capabilities.ts b/src/main/providers/ssh-agent-session-capabilities.ts new file mode 100644 index 00000000000..ea5664ed781 --- /dev/null +++ b/src/main/providers/ssh-agent-session-capabilities.ts @@ -0,0 +1,50 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { proveSshAgentSessionClaimCapability } from './ssh-agent-session-claim-validation' +import { sshSupportsAgentSessionCreateOperations } from './ssh-agent-session-create-operation' +import { waitForSshCapabilityProbe } from './ssh-capability-probe-waiter' + +export class SshAgentSessionCapabilities { + private claimProbe: Promise | null = null + private claimSupported = false + private createOperationProbe: Promise | null = null + + constructor(private readonly mux: SshChannelMultiplexer) {} + + async supportsClaims(options: { signal?: AbortSignal } = {}): Promise { + const probe = this.claimProbe ?? proveSshAgentSessionClaimCapability(this.mux) + this.claimProbe = probe + try { + await waitForSshCapabilityProbe(probe, options.signal) + this.claimSupported = true + return true + } catch { + if (!options.signal?.aborted && this.claimProbe === probe) { + // Why: negative physical probes must follow a relay upgraded on this connection. + this.claimProbe = null + this.claimSupported = false + } + return false + } + } + + providesOwnerListings(): boolean { + return this.claimSupported + } + + async supportsCreateOperations(options: { signal?: AbortSignal } = {}): Promise { + const probe = this.createOperationProbe ?? sshSupportsAgentSessionCreateOperations(this.mux) + this.createOperationProbe = probe + let supported: boolean + try { + supported = await waitForSshCapabilityProbe(probe, options.signal) + } catch { + // Why: one canceled waiter must not cancel or evict the shared physical probe used by peers. + return false + } + if (!supported && this.createOperationProbe === probe) { + // Why: negative capability results must follow a relay upgraded on the same connection. + this.createOperationProbe = null + } + return supported + } +} diff --git a/src/main/providers/ssh-agent-session-claim-validation.ts b/src/main/providers/ssh-agent-session-claim-validation.ts new file mode 100644 index 00000000000..eaf33487a34 --- /dev/null +++ b/src/main/providers/ssh-agent-session-claim-validation.ts @@ -0,0 +1,90 @@ +import { + AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION, + isAgentSessionClaimedSpawnResult, + type AgentSessionExecutionClaim, + type AgentSessionSurfaceBinding +} from '../../shared/agent-session-host-authority' +import type { PtySpawnResult } from './pty-spawn-result' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-create-operation' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' + +export type ClaimedSshSpawnValidation = + | { valid: true } + | { valid: false; cleanup: 'created' | 'none'; error: string } + +export async function proveSshAgentSessionClaimCapability( + mux: SshChannelMultiplexer, + options: { signal?: AbortSignal } = {} +): Promise { + try { + const result = (await mux.request('pty.getCapabilities', undefined, { + signal: options.signal, + timeoutMs: SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS + })) as { + agentSessionClaimVersion?: unknown + } + if (result.agentSessionClaimVersion !== AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION) { + throw new Error('unsupported') + } + } catch { + throw new Error('agent_session_claim_unavailable') + } +} + +function claimsEqual( + actual: AgentSessionExecutionClaim, + expected: AgentSessionExecutionClaim +): boolean { + return ( + actual.digestVersion === expected.digestVersion && + actual.keyId === expected.keyId && + actual.identityDigest === expected.identityDigest && + actual.worktreeScopeDigest === expected.worktreeScopeDigest && + actual.agent === expected.agent + ) +} + +function surfacesEqual( + actual: AgentSessionSurfaceBinding, + expected: AgentSessionSurfaceBinding +): boolean { + return ( + actual.worktreeId === expected.worktreeId && + actual.tabId === expected.tabId && + actual.leafId === expected.leafId && + actual.terminalHandle === expected.terminalHandle + ) +} + +export function validateClaimedSshSpawn( + result: PtySpawnResult, + expected: { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + } +): ClaimedSshSpawnValidation { + const claimed = result.agentSessionEnsure + if (!isAgentSessionClaimedSpawnResult(claimed)) { + // Why: without a disposition we cannot prove the returned PTY was newly + // created, so killing it could terminate a canonical adopted owner. + return { valid: false, cleanup: 'none', error: 'execution_owner_unavailable' } + } + const cleanup = claimed.disposition === 'created' ? 'created' : 'none' + if (claimed.owner.ptyId !== result.id) { + return { valid: false, cleanup, error: 'agent_session_ownership_unknown' } + } + if (!claimsEqual(claimed.owner.claim, expected.claim)) { + return { valid: false, cleanup, error: 'agent_session_ownership_unknown' } + } + if ( + claimed.disposition === 'created' && + !surfacesEqual(claimed.owner.surface, expected.surface) + ) { + return { valid: false, cleanup, error: 'agent_session_ownership_unknown' } + } + if (!isPtyIncarnationId(result.incarnationId)) { + return { valid: false, cleanup, error: 'agent_session_ownership_unknown' } + } + return { valid: true } +} diff --git a/src/main/providers/ssh-agent-session-create-operation.ts b/src/main/providers/ssh-agent-session-create-operation.ts new file mode 100644 index 00000000000..9b8d1d88415 --- /dev/null +++ b/src/main/providers/ssh-agent-session-create-operation.ts @@ -0,0 +1,65 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import type { PtySpawnResult } from './pty-spawn-result' + +export const SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS = 5_000 + +export function assertSshAgentSessionCreateResult( + result: unknown +): asserts result is PtySpawnResult { + const candidate = result as Partial | null + if ( + typeof candidate?.id === 'string' && + candidate.id.length > 0 && + candidate.id.length <= 512 && + isPtyIncarnationId(candidate.incarnationId) + ) { + return + } + // Why: a malformed success arrived after dispatch, so retain the replay fence instead of + // falling back or issuing a fresh operation that could duplicate a live PTY. + throw Object.assign(new Error('execution_owner_unavailable'), { + agentSessionOperationOutcome: 'unknown' as const + }) +} + +export async function sshSupportsAgentSessionCreateOperations( + mux: SshChannelMultiplexer, + options: { signal?: AbortSignal } = {} +): Promise { + try { + const result = (await mux.request('pty.getCapabilities', undefined, { + signal: options.signal, + timeoutMs: SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS + })) as { + agentSessionCreateOperationVersion?: unknown + } + return ( + result.agentSessionCreateOperationVersion === AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION + ) + } catch { + // Why: capability probing does not spawn, so an old relay can safely keep legacy behavior. + return false + } +} + +export async function requestSshAgentSessionCreate(args: { + mux: SshChannelMultiplexer + params: Record + operationId?: string + signal?: AbortSignal +}): Promise { + try { + return await (args.signal + ? args.mux.request('pty.spawn', args.params, { signal: args.signal }) + : args.mux.request('pty.spawn', args.params)) + } catch (error) { + if (!args.operationId) { + throw error + } + const spawnError = error instanceof Error ? error : new Error(String(error)) + // Why: after request dispatch, either an old relay or a capable replay ledger may own a PTY. + throw Object.assign(spawnError, { agentSessionOperationOutcome: 'unknown' as const }) + } +} diff --git a/src/main/providers/ssh-agent-session-process-list.ts b/src/main/providers/ssh-agent-session-process-list.ts new file mode 100644 index 00000000000..cb1447aff71 --- /dev/null +++ b/src/main/providers/ssh-agent-session-process-list.ts @@ -0,0 +1,28 @@ +import { isAgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import type { PtyProcessInfo } from './types' + +export function mapSshPtyProcessList( + sessions: PtyProcessInfo[], + toAppPtyId: (id: string) => string +): PtyProcessInfo[] { + return sessions.map((session) => { + if (session.agentSessionOwners?.length && !isPtyIncarnationId(session.incarnationId)) { + throw new Error('agent_session_ownership_unknown') + } + return { + ...session, + id: toAppPtyId(session.id), + ...(session.agentSessionOwners + ? { + agentSessionOwners: session.agentSessionOwners.map((owner) => { + if (!isAgentSessionOwnerBinding(owner) || owner.ptyId !== session.id) { + throw new Error('agent_session_ownership_unknown') + } + return { ...owner, ptyId: toAppPtyId(owner.ptyId) } + }) + } + : {}) + } + }) +} diff --git a/src/main/providers/ssh-capability-probe-waiter.ts b/src/main/providers/ssh-capability-probe-waiter.ts new file mode 100644 index 00000000000..b1a79cb5885 --- /dev/null +++ b/src/main/providers/ssh-capability-probe-waiter.ts @@ -0,0 +1,24 @@ +export function waitForSshCapabilityProbe(probe: Promise, signal?: AbortSignal): Promise { + if (!signal) { + return probe + } + if (signal.aborted) { + return Promise.reject(new Error('client_disconnected')) + } + return new Promise((resolve, reject) => { + const onAbort = (): void => { + reject(new Error('client_disconnected')) + } + signal.addEventListener('abort', onAbort, { once: true }) + void probe.then( + (result) => { + signal.removeEventListener('abort', onAbort) + resolve(result) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(error) + } + ) + }) +} diff --git a/src/main/providers/ssh-pty-errors.ts b/src/main/providers/ssh-pty-errors.ts new file mode 100644 index 00000000000..a8c551f228b --- /dev/null +++ b/src/main/providers/ssh-pty-errors.ts @@ -0,0 +1,12 @@ +export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' +export const SSH_PTY_IDENTITY_MISMATCH_ERROR = 'SSH_PTY_IDENTITY_MISMATCH' + +export function isSshPtyNotFoundError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /PTY ".+" not found/i.test(message) +} + +export function isSshPtyIdentityMismatchError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return message.includes(SSH_PTY_IDENTITY_MISMATCH_ERROR) || /identity mismatch/i.test(message) +} diff --git a/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts b/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts new file mode 100644 index 00000000000..25d456d9407 --- /dev/null +++ b/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority' +import { SshPtyProvider } from './ssh-pty-provider' + +describe('SSH fresh agent-session create operations', () => { + const request = vi.fn() + let provider: SshPtyProvider + + beforeEach(() => { + request.mockReset() + provider = new SshPtyProvider('conn-1', { + request, + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn(() => false) + } as never) + }) + + it('sends operation identity only to a capable relay', async () => { + request.mockImplementation(async (method: string) => + method === 'pty.getCapabilities' + ? { agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION } + : { id: 'pty-operation', incarnationId: 'incarnation-operation' } + ) + + await provider.spawn({ + cols: 80, + rows: 24, + command: 'codex', + agentSessionCreateOperationId: 'a'.repeat(43) + }) + + expect(request).toHaveBeenNthCalledWith(1, 'pty.getCapabilities', undefined, { + signal: undefined, + timeoutMs: 5_000 + }) + expect(request).toHaveBeenNthCalledWith(2, 'pty.spawn', { + cols: 80, + rows: 24, + cwd: undefined, + env: { POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'true' }, + command: 'codex', + agentSessionCreateOperationId: 'a'.repeat(43) + }) + }) + + it('does not downgrade after structured dispatch reaches an old relay', async () => { + request.mockResolvedValueOnce({}) + + await expect( + provider.spawn({ + cols: 80, + rows: 24, + command: 'codex', + agentSessionCreateOperationId: 'b'.repeat(43) + }) + ).rejects.toThrow('execution_owner_unavailable') + expect(request).toHaveBeenCalledOnce() + }) + + it('keeps a client-selected old-relay spawn byte-for-byte legacy', async () => { + request.mockResolvedValueOnce({ id: 'pty-legacy' }) + + await expect( + provider.spawn({ + cols: 80, + rows: 24, + command: 'codex' + }) + ).resolves.toMatchObject({ id: 'ssh:conn-1@@pty-legacy' }) + + expect(request).toHaveBeenNthCalledWith(1, 'pty.spawn', { + cols: 80, + rows: 24, + cwd: undefined, + env: { POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'true' }, + command: 'codex' + }) + }) + + it('re-probes a negative capability after an in-place relay upgrade', async () => { + request.mockResolvedValueOnce({}).mockResolvedValueOnce({ + agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION + }) + + await expect(provider.supportsAgentSessionCreateOperations()).resolves.toBe(false) + await expect(provider.supportsAgentSessionCreateOperations()).resolves.toBe(true) + expect(request).toHaveBeenCalledTimes(2) + }) + + it('keeps a shared capability probe alive when one waiter disconnects', async () => { + let finishProbe!: (result: { agentSessionCreateOperationVersion: number }) => void + request.mockReturnValueOnce( + new Promise((resolve) => { + finishProbe = resolve + }) + ) + const abort = new AbortController() + const canceled = provider.supportsAgentSessionCreateOperations({ signal: abort.signal }) + const live = provider.supportsAgentSessionCreateOperations() + + abort.abort() + await expect(canceled).resolves.toBe(false) + finishProbe({ + agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION + }) + await expect(live).resolves.toBe(true) + expect(request).toHaveBeenCalledOnce() + }) + + it('does not dispatch create after cancellation during its capability gate', async () => { + let finishProbe!: (result: { agentSessionCreateOperationVersion: number }) => void + request.mockReturnValueOnce( + new Promise((resolve) => { + finishProbe = resolve + }) + ) + const abort = new AbortController() + const spawn = provider.spawn({ + cols: 80, + rows: 24, + command: 'codex', + agentSessionCreateOperationId: 'd'.repeat(43), + signal: abort.signal + }) + + abort.abort() + finishProbe({ + agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION + }) + await expect(spawn).rejects.toThrow('client_disconnected') + expect(request.mock.calls.map((call) => call[0])).toEqual(['pty.getCapabilities']) + }) + + it('fences a malformed successful structured-create response', async () => { + request + .mockResolvedValueOnce({ + agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION + }) + .mockResolvedValueOnce({ id: 'pty-without-incarnation' }) + + const failure = await provider + .spawn({ + cols: 80, + rows: 24, + command: 'codex', + agentSessionCreateOperationId: 'c'.repeat(43) + }) + .catch((error: unknown) => error) + + expect(failure).toMatchObject({ + message: 'execution_owner_unavailable', + agentSessionOperationOutcome: 'unknown' + }) + expect(request).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/providers/ssh-pty-provider-claim-incarnation.test.ts b/src/main/providers/ssh-pty-provider-claim-incarnation.test.ts new file mode 100644 index 00000000000..ec0fe36527d --- /dev/null +++ b/src/main/providers/ssh-pty-provider-claim-incarnation.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority' +import { SshPtyProvider } from './ssh-pty-provider' + +describe('SSH claimed PTY incarnation validation', () => { + it('retires a created owner with an invalid incarnation identity', async () => { + const claim = { + digestVersion: 1 as const, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' as const + } + const surface = { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed' + } + const request = vi.fn(async (method: string) => { + if (method === 'pty.getCapabilities') { + return { agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION } + } + if (method === 'pty.spawn') { + return { + id: 'pty-invalid-incarnation', + incarnationId: 'i'.repeat(129), + agentSessionEnsure: { + disposition: 'created', + owner: { + claim, + generation: 'generation-invalid-incarnation', + phase: 'live', + ptyId: 'pty-invalid-incarnation', + surface + } + } + } + } + return undefined + }) + const provider = new SshPtyProvider('conn-1', { + request, + notify: vi.fn(), + onNotification: vi.fn() + } as never) + + await expect( + provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } }) + ).rejects.toThrow('agent_session_ownership_unknown') + expect(request).toHaveBeenCalledWith('pty.shutdown', { + id: 'pty-invalid-incarnation', + immediate: true + }) + }) +}) diff --git a/src/main/providers/ssh-pty-provider-contract.ts b/src/main/providers/ssh-pty-provider-contract.ts new file mode 100644 index 00000000000..9043133b03c --- /dev/null +++ b/src/main/providers/ssh-pty-provider-contract.ts @@ -0,0 +1,23 @@ +import type { PtyIncarnationId } from '../../shared/pty-incarnation' + +export type RemoteCliBridgeEnv = { + binDir: string + relayDir: string + nodePath: string + sockPath: string + pathDelimiter?: ':' | ';' +} + +export type SshPtyDataCallback = (payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number +}) => void +export type SshPtyReplayCallback = (payload: { id: string; data: string }) => void +export type SshPtyExitCallback = (payload: { + id: string + code: number + incarnationId?: PtyIncarnationId +}) => void diff --git a/src/main/providers/ssh-pty-provider-exit-race.test.ts b/src/main/providers/ssh-pty-provider-exit-race.test.ts new file mode 100644 index 00000000000..12616e4d133 --- /dev/null +++ b/src/main/providers/ssh-pty-provider-exit-race.test.ts @@ -0,0 +1,78 @@ +import { expect, it, vi } from 'vitest' +import { SshPtyProvider } from './ssh-pty-provider' + +it('rejects a fresh SSH PTY whose exit shares the spawn response batch', async () => { + const mux = { + request: vi.fn(), + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + } + const provider = new SshPtyProvider('conn-1', mux as never) + const exitListener = vi.fn() + provider.onExit(exitListener) + mux.request.mockImplementation(async (method: string) => { + if (method === 'pty.spawn') { + const notify = mux.onNotification.mock.calls[0]?.[0] + notify?.('pty.exit', { + id: 'pty-raced', + code: 0, + incarnationId: 'incarnation-raced' + }) + return { id: 'pty-raced', incarnationId: 'incarnation-raced' } + } + return undefined + }) + + await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow( + 'agent_session_exited_during_start' + ) + + expect(exitListener).toHaveBeenCalledWith({ + id: 'ssh:conn-1@@pty-raced', + code: 0, + incarnationId: 'incarnation-raced' + }) + mux.request.mockResolvedValue({ id: 'pty-next', incarnationId: 'incarnation-next' }) + await expect(provider.spawn({ cols: 80, rows: 24 })).resolves.toMatchObject({ + id: 'ssh:conn-1@@pty-next', + incarnationId: 'incarnation-next' + }) +}) + +it('rejects an SSH reattach whose matching exit shares the attach reply batch', async () => { + const mux = { + request: vi.fn(), + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + } + const provider = new SshPtyProvider('conn-1', mux as never) + mux.request.mockImplementation(async (method: string) => { + if (method === 'pty.attach') { + const notify = mux.onNotification.mock.calls[0]?.[0] + notify?.('pty.exit', { + id: 'pty-existing', + code: 0, + incarnationId: 'incarnation-existing' + }) + return { incarnationId: 'incarnation-existing' } + } + return undefined + }) + + await expect( + provider.spawn({ cols: 80, rows: 24, sessionId: 'ssh:conn-1@@pty-existing' }) + ).rejects.toThrow('agent_session_exited_during_start') + + mux.request.mockResolvedValue({ incarnationId: 'incarnation-next' }) + await expect( + provider.spawn({ cols: 80, rows: 24, sessionId: 'ssh:conn-1@@pty-existing' }) + ).resolves.toMatchObject({ + id: 'ssh:conn-1@@pty-existing', + incarnationId: 'incarnation-next', + isReattach: true + }) +}) diff --git a/src/main/providers/ssh-pty-provider-process-events.test.ts b/src/main/providers/ssh-pty-provider-process-events.test.ts new file mode 100644 index 00000000000..2d033aa40d3 --- /dev/null +++ b/src/main/providers/ssh-pty-provider-process-events.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SshPtyProvider } from './ssh-pty-provider' + +type MockMultiplexer = { + request: ReturnType + notify: ReturnType + onNotification: ReturnType + dispose: ReturnType + isDisposed: ReturnType +} + +function createMockMux(): MockMultiplexer { + return { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + } +} + +describe('SshPtyProvider process listings and events', () => { + let mux: MockMultiplexer + let provider: SshPtyProvider + const scopedPty1 = 'ssh:conn-1@@pty-1' + + beforeEach(() => { + mux = createMockMux() + provider = new SshPtyProvider('conn-1', mux as never) + }) + + it('scopes process listings and bounds the relay RPC by the teardown deadline', async () => { + const processes = [{ id: 'pty-1', cwd: '/home', title: 'zsh', worktreeId: 'repo::/home' }] + mux.request.mockResolvedValue(processes) + + await expect(provider.listProcesses()).resolves.toEqual([ + { id: scopedPty1, cwd: '/home', title: 'zsh', worktreeId: 'repo::/home' } + ]) + expect(mux.request).toHaveBeenLastCalledWith('pty.listProcesses', undefined, undefined) + + vi.useFakeTimers() + try { + mux.request.mockResolvedValue([]) + await provider.listProcesses({ deadlineMs: Date.now() + 4321 }) + expect(mux.request).toHaveBeenLastCalledWith('pty.listProcesses', undefined, { + timeoutMs: 4321 + }) + } finally { + vi.useRealTimers() + } + }) + + it('scopes recovered claim owner ids with their SSH connection', async () => { + mux.request.mockResolvedValue([ + { + id: 'pty-1', + incarnationId: 'incarnation-1', + cwd: '/home', + title: 'codex', + agentSessionOwners: [ + { + claim: { + digestVersion: 1, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' + }, + generation: 'generation-1', + phase: 'live', + ptyId: 'pty-1', + surface: { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed' + } + } + ] + } + ]) + + await expect(provider.listProcesses()).resolves.toMatchObject([ + { + id: scopedPty1, + incarnationId: 'incarnation-1', + agentSessionOwners: [{ ptyId: scopedPty1 }] + } + ]) + }) + + it('rejects recovered claimed owners without PTY incarnation proof', async () => { + mux.request.mockResolvedValue([ + { + id: 'pty-1', + cwd: '/home', + title: 'codex', + agentSessionOwners: [ + { + claim: { + digestVersion: 1, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' + }, + generation: 'generation-1', + phase: 'live', + ptyId: 'pty-1', + surface: { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed' + } + } + ] + } + ]) + + await expect(provider.listProcesses()).rejects.toThrow('agent_session_ownership_unknown') + }) + + it('forwards data, replay, and incarnation-aware exit notifications', () => { + const dataHandler = vi.fn() + const replayHandler = vi.fn() + const exitHandler = vi.fn() + provider.onData(dataHandler) + provider.onReplay(replayHandler) + provider.onExit(exitHandler) + const notify = mux.onNotification.mock.calls[0][0] + + notify('pty.data', { id: 'pty-1', data: 'output' }) + notify('pty.data', { id: 'pty-1', data: '', rawLength: 9, seq: 9, transformed: true }) + notify('pty.replay', { id: 'pty-1', data: 'buffered output' }) + notify('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) + + expect(dataHandler).toHaveBeenNthCalledWith(1, { id: scopedPty1, data: 'output' }) + expect(dataHandler).toHaveBeenNthCalledWith(2, { + id: scopedPty1, + data: '', + sequenceChars: 9, + seq: 9, + transformed: true + }) + expect(replayHandler).toHaveBeenCalledWith({ id: scopedPty1, data: 'buffered output' }) + expect(exitHandler).toHaveBeenCalledWith({ + id: scopedPty1, + code: 0, + incarnationId: 'incarnation-1' + }) + }) + + it('supports listener removal, fanout, and connection namespaces', () => { + const removed = vi.fn() + const first = vi.fn() + const second = vi.fn() + const unsubscribe = provider.onData(removed) + provider.onData(first) + provider.onData(second) + unsubscribe() + mux.onNotification.mock.calls[0][0]('pty.data', { id: 'pty-1', data: 'first' }) + + expect(removed).not.toHaveBeenCalled() + expect(first).toHaveBeenCalled() + expect(second).toHaveBeenCalled() + + const otherMux = createMockMux() + const otherProvider = new SshPtyProvider('conn-2', otherMux as never) + const other = vi.fn() + otherProvider.onData(other) + otherMux.onNotification.mock.calls[0][0]('pty.data', { id: 'pty-1', data: 'second' }) + expect(other).toHaveBeenCalledWith({ id: 'ssh:conn-2@@pty-1', data: 'second' }) + }) +}) diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index cac21f2fe97..1891d8b07d8 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshPtyProvider } from './ssh-pty-provider' import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env' import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress' +import { AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority' type MockMultiplexer = { request: ReturnType @@ -35,7 +36,226 @@ describe('SshPtyProvider', () => { expect(provider.getConnectionId()).toBe('conn-1') }) + it('keeps a shared claim probe alive when one waiter disconnects', async () => { + let finishProbe!: (result: { agentSessionClaimVersion: number }) => void + mux.request.mockReturnValueOnce( + new Promise((resolve) => { + finishProbe = resolve + }) + ) + const abort = new AbortController() + const canceled = provider.supportsAgentSessionClaims({ signal: abort.signal }) + const live = provider.supportsAgentSessionClaims() + + abort.abort() + await expect(canceled).resolves.toBe(false) + finishProbe({ agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION }) + await expect(live).resolves.toBe(true) + expect(mux.request).toHaveBeenCalledOnce() + }) + describe('spawn', () => { + const claim = { + digestVersion: 1 as const, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' as const + } + const surface = { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed' + } + + it('proves relay claim support before a claimed spawn', async () => { + mux.request.mockImplementation(async (method: string) => { + if (method === 'pty.getCapabilities') { + return { + agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION + } + } + if (method === 'pty.spawn') { + return { + id: 'pty-1', + incarnationId: 'incarnation-1', + agentSessionEnsure: { + disposition: 'created', + owner: { + claim, + generation: 'generation-1', + phase: 'live', + ptyId: 'pty-1', + surface + } + } + } + } + return undefined + }) + + await expect( + provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } }) + ).resolves.toMatchObject({ + id: scopedPty1, + agentSessionEnsure: { owner: { ptyId: scopedPty1 } } + }) + expect(mux.request.mock.calls.map((call) => call[0])).toEqual([ + 'pty.getCapabilities', + 'pty.spawn' + ]) + }) + + it('fails before spawn when the relay cannot prove claim support', async () => { + mux.request.mockRejectedValue(new Error('method not found')) + + await expect( + provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } }) + ).rejects.toThrow('agent_session_claim_unavailable') + expect(mux.request).toHaveBeenCalledTimes(1) + expect(mux.request).not.toHaveBeenCalledWith('pty.spawn', expect.anything()) + }) + + it('fails closed without killing when a claimed response omits its disposition', async () => { + mux.request.mockImplementation(async (method: string) => { + if (method === 'pty.getCapabilities') { + return { + agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION + } + } + if (method === 'pty.spawn') { + return { id: 'pty-unclaimed' } + } + return undefined + }) + + await expect( + provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } }) + ).rejects.toThrow('execution_owner_unavailable') + expect(mux.request).not.toHaveBeenCalledWith('pty.shutdown', expect.anything()) + }) + + it.each([ + { + name: 'PTY identity', + mutate: (owner: Record) => ({ ...owner, ptyId: 'other-pty' }) + }, + { + name: 'claim', + mutate: (owner: Record) => ({ + ...owner, + claim: { ...claim, identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc' } + }) + }, + { + name: 'fresh surface', + mutate: (owner: Record) => ({ + ...owner, + surface: { ...surface, tabId: 'other-tab' } + }) + } + ])('physically retires a created owner with mismatched $name', async ({ mutate }) => { + mux.request.mockImplementation(async (method: string) => { + if (method === 'pty.getCapabilities') { + return { + agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION + } + } + if (method === 'pty.spawn') { + return { + id: 'pty-malformed', + incarnationId: 'incarnation-malformed', + agentSessionEnsure: { + disposition: 'created', + owner: mutate({ + claim, + generation: 'generation-malformed', + phase: 'live', + ptyId: 'pty-malformed', + surface + }) + } + } + } + return undefined + }) + + await expect( + provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } }) + ).rejects.toThrow('agent_session_ownership_unknown') + expect(mux.request).toHaveBeenCalledWith('pty.shutdown', { + id: 'pty-malformed', + immediate: true + }) + }) + + it('does not kill a canonical adopted owner when its response is semantically invalid', async () => { + mux.request.mockImplementation(async (method: string) => { + if (method === 'pty.getCapabilities') { + return { + agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION + } + } + if (method === 'pty.spawn') { + return { + id: 'pty-canonical', + incarnationId: 'incarnation-canonical', + agentSessionEnsure: { + disposition: 'adopted', + owner: { + claim: { ...claim, identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc' }, + generation: 'generation-canonical', + phase: 'live', + ptyId: 'pty-canonical', + surface + } + } + } + } + return undefined + }) + + await expect( + provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } }) + ).rejects.toThrow('agent_session_ownership_unknown') + expect(mux.request).not.toHaveBeenCalledWith('pty.shutdown', expect.anything()) + }) + + it('retains the unavailable fence when physical cleanup cannot be proven', async () => { + mux.request.mockImplementation(async (method: string) => { + if (method === 'pty.getCapabilities') { + return { + agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION + } + } + if (method === 'pty.spawn') { + return { + id: 'pty-malformed', + incarnationId: 'incarnation-malformed', + agentSessionEnsure: { + disposition: 'created', + owner: { + claim, + generation: 'generation-malformed', + phase: 'live', + ptyId: 'other-pty', + surface + } + } + } + } + if (method === 'pty.shutdown') { + throw new Error('Timed out waiting for PTY process exit') + } + return undefined + }) + + await expect( + provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } }) + ).rejects.toThrow('execution_owner_unavailable') + }) + it('sends pty.spawn request through multiplexer', async () => { mux.request.mockResolvedValue({ id: 'pty-1' }) @@ -326,7 +546,10 @@ describe('SshPtyProvider', () => { }) it('reattaches an existing session and returns attach replay separately from snapshot', async () => { - mux.request.mockResolvedValue({ replay: 'buffered-output' }) + mux.request.mockResolvedValue({ + replay: 'buffered-output', + incarnationId: 'incarnation-reattached' + }) const result = await provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-old' }) @@ -339,7 +562,8 @@ describe('SshPtyProvider', () => { expect(result).toEqual({ id: 'ssh:conn-1@@pty-old', isReattach: true, - replay: 'buffered-output' + replay: 'buffered-output', + incarnationId: 'incarnation-reattached' }) }) @@ -439,17 +663,39 @@ describe('SshPtyProvider', () => { }) it('attachForReconnect returns replay without relay notification', async () => { - mux.request.mockResolvedValue({ replay: 'restored output' }) + mux.request.mockResolvedValue({ + replay: 'restored output', + incarnationId: 'incarnation-reconnect' + }) const result = await provider.attachForReconnect(scopedPty1) - expect(result).toEqual({ replay: 'restored output' }) + expect(result).toEqual({ + replay: 'restored output', + incarnationId: 'incarnation-reconnect' + }) expect(mux.request).toHaveBeenCalledWith('pty.attach', { id: 'pty-1', suppressReplayNotification: true }) }) + it('keeps missing incarnation compatible with an old relay', async () => { + mux.request.mockResolvedValue({ replay: 'legacy replay' }) + + await expect(provider.attachForReconnect(scopedPty1)).resolves.toEqual({ + replay: 'legacy replay' + }) + }) + + it('rejects a present malformed attach incarnation', async () => { + mux.request.mockResolvedValue({ incarnationId: '' }) + + await expect(provider.attachForReconnect(scopedPty1)).rejects.toThrow( + 'Invalid SSH PTY attach incarnation' + ) + }) + it('attachForReconnect forwards expected identity when provided', async () => { await provider.attachForReconnect(scopedPty1, { paneKey: 'tab-a:leaf-a', @@ -594,124 +840,9 @@ describe('SshPtyProvider', () => { ) }) - it('listProcesses returns process list', async () => { - const processes = [{ id: 'pty-1', cwd: '/home', title: 'zsh', worktreeId: 'repo::/home' }] - mux.request.mockResolvedValue(processes) - const result = await provider.listProcesses() - expect(result).toEqual([ - { id: scopedPty1, cwd: '/home', title: 'zsh', worktreeId: 'repo::/home' } - ]) - expect(mux.request).toHaveBeenCalledWith('pty.listProcesses', undefined, undefined) - }) - - it('listProcesses bounds the relay RPC by the teardown deadline', async () => { - vi.useFakeTimers() - try { - mux.request.mockResolvedValue([]) - await provider.listProcesses({ deadlineMs: Date.now() + 4321 }) - expect(mux.request).toHaveBeenCalledWith('pty.listProcesses', undefined, { timeoutMs: 4321 }) - } finally { - vi.useRealTimers() - } - }) - it('getDefaultShell returns shell path', async () => { mux.request.mockResolvedValue('/bin/bash') const result = await provider.getDefaultShell() expect(result).toBe('/bin/bash') }) - - describe('event listeners', () => { - it('forwards pty.data notifications to data listeners', () => { - const handler = vi.fn() - provider.onData(handler) - - // Get the notification handler that was registered - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('pty.data', { id: 'pty-1', data: 'output' }) - - expect(handler).toHaveBeenCalledWith({ id: scopedPty1, data: 'output' }) - }) - - it('forwards empty transformed relay spans without reinterpreting them', () => { - const handler = vi.fn() - provider.onData(handler) - const notifHandler = mux.onNotification.mock.calls[0][0] - - notifHandler('pty.data', { - id: 'pty-1', - data: '', - rawLength: 9, - seq: 9, - transformed: true - }) - - expect(handler).toHaveBeenCalledWith({ - id: scopedPty1, - data: '', - sequenceChars: 9, - seq: 9, - transformed: true - }) - }) - - it('forwards pty.replay notifications to replay listeners', () => { - const handler = vi.fn() - provider.onReplay(handler) - - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('pty.replay', { id: 'pty-1', data: 'buffered output' }) - - expect(handler).toHaveBeenCalledWith({ id: scopedPty1, data: 'buffered output' }) - }) - - it('forwards pty.exit notifications to exit listeners', () => { - const handler = vi.fn() - provider.onExit(handler) - - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('pty.exit', { id: 'pty-1', code: 0 }) - - expect(handler).toHaveBeenCalledWith({ id: scopedPty1, code: 0 }) - }) - - it('allows unsubscribing from events', () => { - const handler = vi.fn() - const unsub = provider.onData(handler) - unsub() - - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('pty.data', { id: 'pty-1', data: 'output' }) - - expect(handler).not.toHaveBeenCalled() - }) - - it('supports multiple listeners', () => { - const handler1 = vi.fn() - const handler2 = vi.fn() - provider.onData(handler1) - provider.onData(handler2) - - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('pty.data', { id: 'pty-1', data: 'output' }) - - expect(handler1).toHaveBeenCalled() - expect(handler2).toHaveBeenCalled() - }) - - it('namespaces identical relay ids from different SSH connections', () => { - const otherMux = createMockMux() - const otherProvider = new SshPtyProvider('conn-2', otherMux as never) - const firstHandler = vi.fn() - const secondHandler = vi.fn() - provider.onData(firstHandler) - otherProvider.onData(secondHandler) - - mux.onNotification.mock.calls[0][0]('pty.data', { id: 'pty-1', data: 'first' }) - otherMux.onNotification.mock.calls[0][0]('pty.data', { id: 'pty-1', data: 'second' }) - - expect(firstHandler).toHaveBeenCalledWith({ id: scopedPty1, data: 'first' }) - expect(secondHandler).toHaveBeenCalledWith({ id: 'ssh:conn-2@@pty-1', data: 'second' }) - }) - }) }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 48240e27e25..8527c829712 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -1,63 +1,46 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' -import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env' -import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress' import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size' +import type { + RemoteCliBridgeEnv, + SshPtyDataCallback, + SshPtyExitCallback, + SshPtyReplayCallback +} from './ssh-pty-provider-contract' +import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation' +import { + assertSshAgentSessionCreateResult, + requestSshAgentSessionCreate +} from './ssh-agent-session-create-operation' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import { mapSshPtyProcessList } from './ssh-agent-session-process-list' +import { + parseSshPtyAttachResult, + reattachSshPtySessionWithExitFence, + type SshPtyAttachResult +} from './ssh-pty-session-reattach' +import { buildSshPtySpawnRequest } from './ssh-pty-spawn-request' +import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' +import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' -type DataCallback = (payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - seq?: number -}) => void -type ReplayCallback = (payload: { id: string; data: string }) => void -type ExitCallback = (payload: { id: string; code: number }) => void -type RemoteCliBridgeEnv = { - binDir: string - relayDir: string - nodePath: string - sockPath: string - pathDelimiter?: ':' | ';' -} - -export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' -export const SSH_PTY_IDENTITY_MISMATCH_ERROR = 'SSH_PTY_IDENTITY_MISMATCH' - -export function isSshPtyNotFoundError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err) - return /PTY ".+" not found/i.test(message) -} - -export function isSshPtyIdentityMismatchError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err) - return message.includes(SSH_PTY_IDENTITY_MISMATCH_ERROR) || /identity mismatch/i.test(message) -} - -// Why: providers take an absolute teardown deadline, but the mux takes a relative -// timeout — convert only here, at the RPC itself, so sequential relay calls share -// the remaining budget (undefined keeps the multiplexer default timeout). +// Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { return deadlineMs === undefined ? undefined : { timeoutMs: Math.max(1, deadlineMs - Date.now()) } } -/** - * Remote PTY provider that proxies all operations through the relay - * via the JSON-RPC multiplexer. Implements the same IPtyProvider interface - * as LocalPtyProvider so the dispatch layer can route transparently. - */ +/** Remote PTY provider that proxies IPtyProvider operations through the relay. */ export class SshPtyProvider implements IPtyProvider { private mux: SshChannelMultiplexer private connectionId: string - private dataListeners = new Set() - private replayListeners = new Set() - private exitListeners = new Set() - // Why: store the unsubscribe handle so dispose() can detach from the - // multiplexer. Without this, notification callbacks keep firing after - // the provider is torn down on disconnect, routing events to stale state. + private dataListeners = new Set() + private replayListeners = new Set() + private exitListeners = new Set() + // Why: stale notification callbacks must not outlive a disconnected provider. private unsubscribeNotifications: (() => void) | null = null readonly getAppliedSize: NonNullable + private readonly agentSessionCapabilities: SshAgentSessionCapabilities + private spawnExitRaces = new SshPtySpawnExitRaceTracker() constructor( connectionId: string, @@ -66,9 +49,9 @@ export class SshPtyProvider implements IPtyProvider { ) { this.connectionId = connectionId this.mux = mux + this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux) this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId) - // Subscribe to relay notifications for PTY events this.unsubscribeNotifications = mux.onNotification((method, params) => { switch (method) { case 'pty.data': @@ -92,8 +75,15 @@ export class SshPtyProvider implements IPtyProvider { break case 'pty.exit': + this.spawnExitRaces.recordExit(params.id as string, params.incarnationId) for (const cb of this.exitListeners) { - cb({ id: this.toAppPtyId(params.id as string), code: params.code as number }) + cb({ + id: this.toAppPtyId(params.id as string), + code: params.code as number, + ...(isPtyIncarnationId(params.incarnationId) + ? { incarnationId: params.incarnationId } + : {}) + }) } break } @@ -110,9 +100,7 @@ export class SshPtyProvider implements IPtyProvider { this.exitListeners.clear() } - getConnectionId(): string { - return this.connectionId - } + getConnectionId = (): string => this.connectionId private toRelayPtyId(id: string): string { return toRelaySshPtyId(this.connectionId, id) @@ -123,118 +111,109 @@ export class SshPtyProvider implements IPtyProvider { } async spawn(opts: PtySpawnOptions): Promise { - // Why: when sessionId is present, the caller is requesting reattach to an - // existing relay PTY (persisted across app restart). pty.attach replays - // the buffered output the relay kept alive during the grace window. - if (opts.sessionId) { - const relaySessionId = this.toRelayPtyId(opts.sessionId) - console.warn( - `[ssh-pty] spawn() called with sessionId=${opts.sessionId}, attempting pty.attach` - ) - try { - // Why: pass the pane's expected identity so the relay can reject a - // cross-generation id collision (see pty-handler attach) instead of - // replaying the wrong shell into this pane. ORCA_PANE_KEY is the - // renderer's per-pane identity; ORCA_TAB_ID is the coarser fallback. - const expectedPaneKey = opts.paneKey ?? opts.env?.ORCA_PANE_KEY - const expectedTabId = opts.tabId ?? opts.env?.ORCA_TAB_ID - const attachResult = (await this.mux.request('pty.attach', { - id: relaySessionId, - cols: opts.cols, - rows: opts.rows, - suppressReplayNotification: true, - ...(expectedPaneKey ? { expectedPaneKey } : {}), - ...(expectedTabId ? { expectedTabId } : {}) - })) as { replay?: string } - console.warn( - `[ssh-pty] pty.attach succeeded for ${opts.sessionId}, replay=${!!attachResult.replay}` - ) - return { - id: this.toAppPtyId(relaySessionId), - isReattach: true, - ...(attachResult.replay ? { replay: attachResult.replay } : {}) - } - } catch (err) { - // Why: pty.attach fails when the relay grace window has elapsed. - // Surface the exact condition so the renderer can clear the stale - // binding before replacing the dead relay PTY in the same pane. - console.warn(`[ssh-pty] pty.attach FAILED for ${opts.sessionId}:`, err) - if (isSshPtyNotFoundError(err)) { - const mismatchMarker = isSshPtyIdentityMismatchError(err) - ? ` ${SSH_PTY_IDENTITY_MISMATCH_ERROR}` - : '' - throw new Error(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}${mismatchMarker}`) - } - throw err + if (opts.agentSessionEnsure && opts.sessionId) { + throw new Error('agent_session_claim_unavailable') + } + if (opts.agentSessionEnsure) { + const supportsClaims = await this.supportsAgentSessionClaims({ signal: opts.signal }) + if (opts.signal?.aborted) { + throw new Error('client_disconnected') + } + if (!supportsClaims) { + throw new Error('agent_session_claim_unavailable') } } + if (opts.sessionId) { + return await reattachSshPtySessionWithExitFence({ + mux: this.mux, + connectionId: this.connectionId, + sessionId: opts.sessionId, + options: opts, + exitRaceTracker: this.spawnExitRaces + }) + } - const result = await this.mux.request('pty.spawn', { - cols: opts.cols, - rows: opts.rows, - cwd: opts.cwd, - env: this.withRemoteCliBridgeEnv(opts.env, opts.envToDelete), - ...(opts.envToDelete?.length ? { envToDelete: opts.envToDelete } : {}), - // Why: the relay's plugin-overlay env augmenter needs to know which - // Pi-compatible agent is being launched, while commandDelivery tells it - // whether to submit the command itself for runtime-owned background PTYs. - ...(opts.command ? { command: opts.command } : {}), - ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), - ...(opts.shellOverride !== undefined ? { shellOverride: opts.shellOverride } : {}), - ...(opts.terminalWindowsWslDistro !== undefined - ? { terminalWindowsWslDistro: opts.terminalWindowsWslDistro } - : {}), - ...(opts.commandDelivery ? { commandDelivery: opts.commandDelivery } : {}), - ...(opts.startupCommandDelivery - ? { startupCommandDelivery: opts.startupCommandDelivery } - : {}), - // Why: main may strip ORCA_PANE_KEY/ORCA_TAB_ID from the shell env when - // remote hooks are disabled, but the relay still needs attach identity - // metadata to reject cross-generation PTY id collisions. - ...(opts.paneKey ? { paneKey: opts.paneKey } : {}), - ...(opts.tabId ? { tabId: opts.tabId } : {}), - ...(opts.startupIngress - ? { - startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, - startupIngress: opts.startupIngress + const supportsCreateOperation = opts.agentSessionCreateOperationId + ? await this.supportsAgentSessionCreateOperations({ signal: opts.signal }) + : false + if (opts.signal?.aborted) { + throw new Error('client_disconnected') + } + if (opts.agentSessionCreateOperationId && !supportsCreateOperation) { + // Why: host routing owns legacy selection; a changed relay must not downgrade after dispatch. + throw new Error('execution_owner_unavailable') + } + const operation = this.spawnExitRaces.begin() + try { + const result = await requestSshAgentSessionCreate({ + mux: this.mux, + operationId: opts.agentSessionCreateOperationId, + signal: opts.signal, + params: buildSshPtySpawnRequest({ + options: opts, + remoteCliBridgeEnv: this.remoteCliBridgeEnv, + supportsCreateOperation + }) + }) + if (opts.agentSessionCreateOperationId) { + assertSshAgentSessionCreateResult(result) + } + const spawnResult = result as PtySpawnResult + if (this.spawnExitRaces.didMatchingExitArrive(operation, spawnResult)) { + // Why: relay notification can share the response batch; no controller registration may follow. + throw Object.assign(new Error('agent_session_exited_during_start'), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + const claimed = spawnResult.agentSessionEnsure + if (opts.agentSessionEnsure) { + const validation = validateClaimedSshSpawn(spawnResult, opts.agentSessionEnsure) + if (!validation.valid) { + if (validation.cleanup === 'created' && typeof spawnResult.id === 'string') { + try { + // Why: immediate relay shutdown resolves only after physical exit; + // a best-effort graceful request cannot prove the duplicate is gone. + await this.mux.request('pty.shutdown', { id: spawnResult.id, immediate: true }) + } catch { + throw new Error('execution_owner_unavailable') + } } - : {}) - }) - return { - ...(result as PtySpawnResult), - id: this.toAppPtyId((result as PtySpawnResult).id), - ...(opts.sessionId ? { sessionExpired: true } : {}) + throw new Error(validation.error) + } + } + return { + ...spawnResult, + id: this.toAppPtyId(spawnResult.id), + ...(claimed + ? { + agentSessionEnsure: { + ...claimed, + owner: { + ...claimed.owner, + ptyId: this.toAppPtyId(claimed.owner.ptyId) + } + } + } + : {}), + ...(opts.sessionId ? { sessionExpired: true } : {}) + } + } finally { + this.spawnExitRaces.finish(operation) } } - private withRemoteCliBridgeEnv( - env: Record | undefined, - envToDelete?: readonly string[] - ): Record { - const merged = { ...env } - if (this.remoteCliBridgeEnv) { - const pathDelimiter = this.remoteCliBridgeEnv.pathDelimiter ?? ':' - const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null - if (pathKey) { - const pathValue = merged[pathKey] ?? '' - merged[pathKey] = pathValue.split(pathDelimiter).includes(this.remoteCliBridgeEnv.binDir) - ? pathValue - : pathValue - ? `${this.remoteCliBridgeEnv.binDir}${pathDelimiter}${pathValue}` - : this.remoteCliBridgeEnv.binDir - } - merged.ORCA_REMOTE_CLI_BIN_DIR = this.remoteCliBridgeEnv.binDir - merged.ORCA_RELAY_DIR = this.remoteCliBridgeEnv.relayDir - merged.ORCA_RELAY_NODE_PATH = this.remoteCliBridgeEnv.nodePath - merged.ORCA_RELAY_SOCKET_PATH = this.remoteCliBridgeEnv.sockPath - } - // Why: match local/daemon precedence—managed defaults and augmentations - // cannot resurrect values the caller explicitly removed. - for (const key of envToDelete ?? []) { - delete merged[key] - } - seedPowerlevel10kWizardEnv(merged, { envToDelete }) - return merged + async supportsAgentSessionClaims(options: { signal?: AbortSignal } = {}): Promise { + return await this.agentSessionCapabilities.supportsClaims(options) + } + + providesAgentSessionOwnerListings(_ptyId: string): boolean { + return this.agentSessionCapabilities.providesOwnerListings() + } + + async supportsAgentSessionCreateOperations( + options: { signal?: AbortSignal } = {} + ): Promise { + return await this.agentSessionCapabilities.supportsCreateOperations(options) } async attach(id: string): Promise { @@ -244,18 +223,19 @@ export class SshPtyProvider implements IPtyProvider { async attachForReconnect( id: string, expected?: { paneKey?: string; tabId?: string } - ): Promise<{ replay?: string }> { + ): Promise { // Why: reconnect owns replay delivery so stale/duplicate attach results can // be filtered before they reach the renderer. The expected identity lets the // relay reject a cross-generation id collision instead of reattaching this // lease to a different pane's freshly spawned PTY. - const result = (await this.mux.request('pty.attach', { - id: this.toRelayPtyId(id), - suppressReplayNotification: true, - ...(expected?.paneKey ? { expectedPaneKey: expected.paneKey } : {}), - ...(expected?.tabId ? { expectedTabId: expected.tabId } : {}) - })) as { replay?: string } | undefined - return result ?? {} + return parseSshPtyAttachResult( + await this.mux.request('pty.attach', { + id: this.toRelayPtyId(id), + suppressReplayNotification: true, + ...(expected?.paneKey ? { expectedPaneKey: expected.paneKey } : {}), + ...(expected?.tabId ? { expectedTabId: expected.tabId } : {}) + }) + ) } write(id: string, data: string): void { @@ -337,10 +317,7 @@ export class SshPtyProvider implements IPtyProvider { undefined, relayTimeoutOptions(opts?.deadlineMs) ) - return (result as PtyProcessInfo[]).map((session) => ({ - ...session, - id: this.toAppPtyId(session.id) - })) + return mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id)) } async getDefaultShell(): Promise { @@ -353,17 +330,17 @@ export class SshPtyProvider implements IPtyProvider { return result as { name: string; path: string }[] } - onData(callback: DataCallback): () => void { + onData(callback: SshPtyDataCallback): () => void { this.dataListeners.add(callback) return () => this.dataListeners.delete(callback) } - onReplay(callback: ReplayCallback): () => void { + onReplay(callback: SshPtyReplayCallback): () => void { this.replayListeners.add(callback) return () => this.replayListeners.delete(callback) } - onExit(callback: ExitCallback): () => void { + onExit(callback: SshPtyExitCallback): () => void { this.exitListeners.add(callback) return () => this.exitListeners.delete(callback) } diff --git a/src/main/providers/ssh-pty-session-reattach.ts b/src/main/providers/ssh-pty-session-reattach.ts new file mode 100644 index 00000000000..5970f85da0a --- /dev/null +++ b/src/main/providers/ssh-pty-session-reattach.ts @@ -0,0 +1,104 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { isPtyIncarnationId, type PtyIncarnationId } from '../../shared/pty-incarnation' +import { + SSH_PTY_IDENTITY_MISMATCH_ERROR, + SSH_SESSION_EXPIRED_ERROR, + isSshPtyIdentityMismatchError, + isSshPtyNotFoundError +} from './ssh-pty-errors' +import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' +import type { PtySpawnOptions, PtySpawnResult } from './types' +import type { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' + +export type SshPtyAttachResult = { + replay?: string + incarnationId?: PtyIncarnationId +} + +export function parseSshPtyAttachResult(value: unknown): SshPtyAttachResult { + if (value === undefined || value === null) { + return {} + } + if (typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Invalid SSH PTY attach response') + } + const result = value as { replay?: unknown; incarnationId?: unknown } + if (result.replay !== undefined && typeof result.replay !== 'string') { + throw new Error('Invalid SSH PTY attach replay') + } + if (result.incarnationId !== undefined && !isPtyIncarnationId(result.incarnationId)) { + // Why: a present-but-invalid identity cannot safely fence delayed exits from a reused relay id. + throw new Error('Invalid SSH PTY attach incarnation') + } + return { + ...(typeof result.replay === 'string' ? { replay: result.replay } : {}), + ...(isPtyIncarnationId(result.incarnationId) ? { incarnationId: result.incarnationId } : {}) + } +} + +export async function reattachSshPtySession(args: { + mux: SshChannelMultiplexer + connectionId: string + sessionId: string + options: PtySpawnOptions +}): Promise { + const relaySessionId = toRelaySshPtyId(args.connectionId, args.sessionId) + console.warn(`[ssh-pty] spawn() called with sessionId=${args.sessionId}, attempting pty.attach`) + try { + // Why: expected pane identity prevents a reused relay id from attaching the wrong shell. + const expectedPaneKey = args.options.paneKey ?? args.options.env?.ORCA_PANE_KEY + const expectedTabId = args.options.tabId ?? args.options.env?.ORCA_TAB_ID + const attachResult = parseSshPtyAttachResult( + await args.mux.request('pty.attach', { + id: relaySessionId, + cols: args.options.cols, + rows: args.options.rows, + suppressReplayNotification: true, + ...(expectedPaneKey ? { expectedPaneKey } : {}), + ...(expectedTabId ? { expectedTabId } : {}) + }) + ) + console.warn( + `[ssh-pty] pty.attach succeeded for ${args.sessionId}, replay=${!!attachResult.replay}` + ) + return { + id: toAppSshPtyId(args.connectionId, relaySessionId), + isReattach: true, + ...(attachResult.replay ? { replay: attachResult.replay } : {}), + ...(attachResult.incarnationId ? { incarnationId: attachResult.incarnationId } : {}) + } + } catch (error) { + // Why: an expired relay lease must be surfaced distinctly so the renderer clears its binding. + console.warn(`[ssh-pty] pty.attach FAILED for ${args.sessionId}:`, error) + if (isSshPtyNotFoundError(error)) { + const mismatchMarker = isSshPtyIdentityMismatchError(error) + ? ` ${SSH_PTY_IDENTITY_MISMATCH_ERROR}` + : '' + throw new Error(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}${mismatchMarker}`) + } + throw error + } +} + +export async function reattachSshPtySessionWithExitFence( + args: Parameters[0] & { + exitRaceTracker: SshPtySpawnExitRaceTracker + } +): Promise { + const operation = args.exitRaceTracker.begin() + try { + const result = await reattachSshPtySession(args) + const relayPtyId = toRelaySshPtyId(args.connectionId, result.id) + if ( + args.exitRaceTracker.didMatchingExitArrive(operation, { + id: relayPtyId, + incarnationId: result.incarnationId + }) + ) { + throw new Error('agent_session_exited_during_start') + } + return result + } finally { + args.exitRaceTracker.finish(operation) + } +} diff --git a/src/main/providers/ssh-pty-spawn-env.ts b/src/main/providers/ssh-pty-spawn-env.ts new file mode 100644 index 00000000000..ad9cc7377a4 --- /dev/null +++ b/src/main/providers/ssh-pty-spawn-env.ts @@ -0,0 +1,32 @@ +import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env' +import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract' + +export function buildSshPtySpawnEnv(args: { + env: Record | undefined + envToDelete?: readonly string[] + remoteCliBridgeEnv?: RemoteCliBridgeEnv +}): Record { + const merged = { ...args.env } + if (args.remoteCliBridgeEnv) { + const pathDelimiter = args.remoteCliBridgeEnv.pathDelimiter ?? ':' + const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null + if (pathKey) { + const pathValue = merged[pathKey] ?? '' + merged[pathKey] = pathValue.split(pathDelimiter).includes(args.remoteCliBridgeEnv.binDir) + ? pathValue + : pathValue + ? `${args.remoteCliBridgeEnv.binDir}${pathDelimiter}${pathValue}` + : args.remoteCliBridgeEnv.binDir + } + merged.ORCA_REMOTE_CLI_BIN_DIR = args.remoteCliBridgeEnv.binDir + merged.ORCA_RELAY_DIR = args.remoteCliBridgeEnv.relayDir + merged.ORCA_RELAY_NODE_PATH = args.remoteCliBridgeEnv.nodePath + merged.ORCA_RELAY_SOCKET_PATH = args.remoteCliBridgeEnv.sockPath + } + // Why: match local/daemon precedence—managed defaults cannot restore explicitly removed values. + for (const key of args.envToDelete ?? []) { + delete merged[key] + } + seedPowerlevel10kWizardEnv(merged, { envToDelete: args.envToDelete }) + return merged +} diff --git a/src/main/providers/ssh-pty-spawn-exit-race.ts b/src/main/providers/ssh-pty-spawn-exit-race.ts new file mode 100644 index 00000000000..0658e3e23eb --- /dev/null +++ b/src/main/providers/ssh-pty-spawn-exit-race.ts @@ -0,0 +1,41 @@ +import { isPtyIncarnationId, type PtyIncarnationId } from '../../shared/pty-incarnation' + +type PendingSshPtySpawn = { + exits: { relayPtyId: string; incarnationId?: PtyIncarnationId }[] +} + +export class SshPtySpawnExitRaceTracker { + private pending = new Set() + + begin(): PendingSshPtySpawn { + const operation = { exits: [] } + this.pending.add(operation) + return operation + } + + recordExit(relayPtyId: string, incarnationId: unknown): void { + for (const operation of this.pending) { + operation.exits.push({ + relayPtyId, + ...(isPtyIncarnationId(incarnationId) ? { incarnationId } : {}) + }) + } + } + + didMatchingExitArrive( + operation: PendingSshPtySpawn, + result: { id: string; incarnationId?: PtyIncarnationId } + ): boolean { + return operation.exits.some( + (exit) => + exit.relayPtyId === result.id && + (!exit.incarnationId || + !result.incarnationId || + exit.incarnationId === result.incarnationId) + ) + } + + finish(operation: PendingSshPtySpawn): void { + this.pending.delete(operation) + } +} diff --git a/src/main/providers/ssh-pty-spawn-request.ts b/src/main/providers/ssh-pty-spawn-request.ts new file mode 100644 index 00000000000..889c0813ee9 --- /dev/null +++ b/src/main/providers/ssh-pty-spawn-request.ts @@ -0,0 +1,47 @@ +import type { PtySpawnOptions } from './types' +import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract' +import { buildSshPtySpawnEnv } from './ssh-pty-spawn-env' +import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress' + +export function buildSshPtySpawnRequest(args: { + options: PtySpawnOptions + remoteCliBridgeEnv?: RemoteCliBridgeEnv + supportsCreateOperation: boolean +}): Record { + const { options } = args + return { + cols: options.cols, + rows: options.rows, + cwd: options.cwd, + env: buildSshPtySpawnEnv({ + env: options.env, + envToDelete: options.envToDelete, + remoteCliBridgeEnv: args.remoteCliBridgeEnv + }), + ...(options.envToDelete?.length ? { envToDelete: options.envToDelete } : {}), + // Why: the relay needs launch identity for plugin env overlays and provider-side delivery. + ...(options.command ? { command: options.command } : {}), + ...(options.launchAgent ? { launchAgent: options.launchAgent } : {}), + ...(options.shellOverride !== undefined ? { shellOverride: options.shellOverride } : {}), + ...(options.terminalWindowsWslDistro !== undefined + ? { terminalWindowsWslDistro: options.terminalWindowsWslDistro } + : {}), + ...(options.commandDelivery ? { commandDelivery: options.commandDelivery } : {}), + ...(options.startupCommandDelivery + ? { startupCommandDelivery: options.startupCommandDelivery } + : {}), + // Why: attach identity must survive even when hook variables are stripped from the shell env. + ...(options.paneKey ? { paneKey: options.paneKey } : {}), + ...(options.tabId ? { tabId: options.tabId } : {}), + ...(options.startupIngress + ? { + startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, + startupIngress: options.startupIngress + } + : {}), + ...(options.agentSessionEnsure ? { agentSessionEnsure: options.agentSessionEnsure } : {}), + ...(args.supportsCreateOperation + ? { agentSessionCreateOperationId: options.agentSessionCreateOperationId } + : {}) + } +} diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index fca74501c10..ba12c07e1ef 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -26,6 +26,12 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges import type { GitProviderStatusOptions } from './git-provider-status-options' import type { PtyBackgroundStreamEvent, PtyDataEvent } from './pty-provider-events' import type { PtySpawnResult } from './pty-spawn-result' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { + AgentSessionExecutionClaim, + AgentSessionOwnerBinding, + AgentSessionSurfaceBinding +} from '../../shared/agent-session-host-authority' export type { PtyBackgroundStreamEvent, @@ -96,24 +102,44 @@ export type PtySpawnOptions = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' /** Fresh-spawn-only source authority installed before any PTY output is released. */ startupIngress?: PtyStartupIngressIntent + agentSessionEnsure?: { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + } + /** Host-scoped structured-create identity used only for lower-owner replay. */ + agentSessionCreateOperationId?: string + /** Signals that the native process exists even if later publication fails. */ + onPtySpawnCommitted?: () => void + /** Cancels only before physical dispatch; operation identity fences later ambiguity. */ + signal?: AbortSignal } export type { PtySpawnResult } export type PtyProcessInfo = { id: string + incarnationId?: PtyIncarnationId cwd: string title: string /** Owning worktree when the provider can report it authoritatively. */ worktreeId?: string /** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */ terminalHandle?: string + agentSessionOwners?: AgentSessionOwnerBinding[] } +type PtyProbeOptions = { signal?: AbortSignal } + export type IPtyProvider = { spawn(opts: PtySpawnOptions): Promise /** Whether this spawn target can append the Git guard after its final env merge. */ supportsGitCredentialGuardHost?: (sessionId?: string) => boolean + /** Explicit false selects pre-claim legacy spawn for a preserved old daemon. */ + supportsAgentSessionClaims?: (options?: PtyProbeOptions) => boolean | Promise + /** Whether missing claim metadata in this PTY's process listing proves absence. */ + providesAgentSessionOwnerListings?: (ptyId: string) => boolean + /** Whether fresh structured creates can replay one spawn across a lost relay response. */ + supportsAgentSessionCreateOperations?: (options?: PtyProbeOptions) => boolean | Promise attach(id: string): Promise hasPty?: (id: string) => boolean write(id: string, data: string): void @@ -189,7 +215,9 @@ export type IPtyProvider = { getProfiles(): Promise<{ name: string; path: string }[]> onData(callback: (payload: PtyDataEvent) => void): () => void onReplay(callback: (payload: { id: string; data: string }) => void): () => void - onExit(callback: (payload: { id: string; code: number }) => void): () => void + onExit( + callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void + ): () => void } // ─── Filesystem Provider ──────────────────────────────────────────── diff --git a/src/main/runtime/agent-session-claim-identity.test.ts b/src/main/runtime/agent-session-claim-identity.test.ts new file mode 100644 index 00000000000..39392ea9e72 --- /dev/null +++ b/src/main/runtime/agent-session-claim-identity.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { + canonicalizeAgentSessionIdentity, + createEphemeralAgentSessionClaimSigner +} from './agent-session-claim-identity' + +describe('agent session claim identity', () => { + it('creates stable opaque identity and worktree digests', () => { + const signer = createEphemeralAgentSessionClaimSigner('profile-1') + const identity = canonicalizeAgentSessionIdentity('codex', { + key: 'session_id', + id: 'session-1' + }) + const namespace = { + machine: 'machine', + principal: 'user', + container: 'native', + providerRoot: 'default' + } + + const first = signer.createClaim({ namespace, identity, canonicalWorktreeId: 'worktree-1' }) + const second = signer.createClaim({ namespace, identity, canonicalWorktreeId: 'worktree-1' }) + const otherWorktree = signer.createClaim({ + namespace, + identity, + canonicalWorktreeId: 'worktree-2' + }) + + expect(first).toEqual(second) + expect(first.identityDigest).not.toContain('session-1') + expect(otherWorktree.identityDigest).toBe(first.identityDigest) + expect(otherWorktree.worktreeScopeDigest).not.toBe(first.worktreeScopeDigest) + }) + + it('rejects malformed and unsupported provider identity', () => { + expect(() => + canonicalizeAgentSessionIdentity('codex', { key: 'session_id', id: '-unsafe' }) + ).toThrow('agent_session_identity_required') + expect(() => + canonicalizeAgentSessionIdentity('blank', { key: 'session_id', id: 'session-1' }) + ).toThrow('agent_session_identity_required') + }) +}) diff --git a/src/main/runtime/agent-session-claim-identity.ts b/src/main/runtime/agent-session-claim-identity.ts new file mode 100644 index 00000000000..2bc276f87e5 --- /dev/null +++ b/src/main/runtime/agent-session-claim-identity.ts @@ -0,0 +1,177 @@ +import { createHash, createHmac, randomBytes } from 'node:crypto' +import { + closeSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + statSync, + writeFileSync +} from 'node:fs' +import { dirname, isAbsolute, join, normalize } from 'node:path' +import { + AGENT_SESSION_CLAIM_DIGEST_VERSION, + type AgentSessionExecutionClaim +} from '../../shared/agent-session-host-authority' +import { + getAgentResumeArgv, + isResumableTuiAgent, + normalizeAgentProviderSession, + type AgentProviderSessionMetadata, + type ResumableTuiAgent +} from '../../shared/agent-session-resume' + +const COORDINATION_KEY_BYTES = 32 +const COORDINATION_KEY_FILE = 'agent-session-authority.key' +const TRANSCRIPT_PATH_MAX_BYTES = 16 * 1024 + +export type ProviderExecutionNamespace = { + machine: string + principal: string + container: string + providerRoot: string +} + +export type CanonicalAgentSessionIdentity = { + agent: ResumableTuiAgent + providerSession: AgentProviderSessionMetadata +} + +function encodeFields(fields: readonly string[]): Buffer { + const chunks: Buffer[] = [] + for (const field of fields) { + const value = Buffer.from(field, 'utf8') + const length = Buffer.allocUnsafe(4) + length.writeUInt32BE(value.length) + chunks.push(length, value) + } + return Buffer.concat(chunks) +} + +function canonicalPathForPlatform(value: string): string { + const canonical = normalize(realpathSync(value)) + return process.platform === 'win32' ? canonical.toLocaleLowerCase('en-US') : canonical +} + +export function canonicalizeAgentSessionIdentity( + agent: unknown, + rawProviderSession: unknown +): CanonicalAgentSessionIdentity { + if (!isResumableTuiAgent(agent)) { + throw new Error('agent_session_identity_required') + } + const providerSession = normalizeAgentProviderSession(rawProviderSession) + if (!providerSession || !getAgentResumeArgv(agent, providerSession)) { + throw new Error('agent_session_identity_required') + } + if (agent !== 'pi') { + return { agent, providerSession } + } + const transcriptPath = providerSession.transcriptPath + if ( + !transcriptPath || + !isAbsolute(transcriptPath) || + Buffer.byteLength(transcriptPath, 'utf8') > TRANSCRIPT_PATH_MAX_BYTES + ) { + throw new Error('agent_session_identity_required') + } + const canonicalTranscriptPath = canonicalPathForPlatform(transcriptPath) + if (!statSync(canonicalTranscriptPath).isFile()) { + throw new Error('agent_session_identity_required') + } + return { + agent, + providerSession: { ...providerSession, transcriptPath: canonicalTranscriptPath } + } +} + +export class AgentSessionClaimSigner { + readonly keyId: string + + constructor( + private readonly authorityDomainId: string, + private readonly key: Buffer + ) { + if (key.length !== COORDINATION_KEY_BYTES) { + throw new Error('agent_session_ownership_unknown') + } + this.keyId = createHash('sha256').update(key).digest('base64url').slice(0, 22) + } + + createClaim(args: { + namespace: ProviderExecutionNamespace + identity: CanonicalAgentSessionIdentity + canonicalWorktreeId: string + }): AgentSessionExecutionClaim { + const namespaceFields = [ + args.namespace.machine, + args.namespace.principal, + args.namespace.container, + args.namespace.providerRoot + ] + const identityFields = [ + 'orca-agent-session-claim-v1', + this.authorityDomainId, + ...namespaceFields, + args.identity.agent, + args.identity.providerSession.key, + args.identity.providerSession.id, + args.identity.agent === 'pi' ? (args.identity.providerSession.transcriptPath ?? '') : '' + ] + const worktreeFields = [ + 'orca-agent-session-worktree-v1', + this.authorityDomainId, + ...namespaceFields, + args.canonicalWorktreeId + ] + return { + digestVersion: AGENT_SESSION_CLAIM_DIGEST_VERSION, + keyId: this.keyId, + identityDigest: createHmac('sha256', this.key) + .update(encodeFields(identityFields)) + .digest('base64url'), + worktreeScopeDigest: createHmac('sha256', this.key) + .update(encodeFields(worktreeFields)) + .digest('base64url'), + agent: args.identity.agent + } + } +} + +export function loadAgentSessionClaimSigner( + profileDirectory: string, + authorityDomainId: string +): AgentSessionClaimSigner { + const keyPath = join(profileDirectory, COORDINATION_KEY_FILE) + mkdirSync(dirname(keyPath), { recursive: true }) + let key: Buffer + try { + key = readFileSync(keyPath) + } catch { + const candidate = randomBytes(COORDINATION_KEY_BYTES) + let fd: number | null = null + try { + fd = openSync(keyPath, 'wx', 0o600) + writeFileSync(fd, candidate) + key = candidate + } catch { + key = readFileSync(keyPath) + } finally { + if (fd !== null) { + closeSync(fd) + } + } + } + // Why: a replaced/corrupt key could make a surviving owner look absent; + // refuse authority instead of silently minting an incomparable namespace. + if (key.length !== COORDINATION_KEY_BYTES) { + throw new Error('agent_session_ownership_unknown') + } + return new AgentSessionClaimSigner(authorityDomainId, key) +} + +export function createEphemeralAgentSessionClaimSigner( + authorityDomainId: string +): AgentSessionClaimSigner { + return new AgentSessionClaimSigner(authorityDomainId, randomBytes(COORDINATION_KEY_BYTES)) +} diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index bf5a63b736c..91c93306b32 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -8,6 +8,8 @@ const MOBILE_DYNAMIC_RPC_METHODS = [ // mobile source scan below, but still must stay mobile-authorized. 'accounts.selectClaude', 'accounts.selectCodex', + 'terminal.createAgentSession', + 'terminal.ensureAgentSession', 'github.updateIssue', 'github.updatePRState', 'gitlab.updateIssue', diff --git a/src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts b/src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts new file mode 100644 index 00000000000..42c1289d857 --- /dev/null +++ b/src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { + retireTerminalSurfaceFromPersistence, + sanitizeWorkspaceSessionTerminalRetirements +} from './mobile-session-terminal-persistence-retirement' + +const WORKTREE_ID = 'repo::/worktree' +const REPO_ID = 'repo' + +describe('mobile session terminal persistence retirement', () => { + it('de-persists a final leaf and repairs active group state', () => { + const session = { + ...getDefaultWorkspaceSession(), + activeTabId: 'terminal', + activeTabIdByWorktree: { [WORKTREE_ID]: 'terminal' }, + activeTabTypeByWorktree: { [WORKTREE_ID]: 'terminal' as const }, + activeGroupIdByWorktree: { [WORKTREE_ID]: 'terminal-group' }, + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'terminal', + ptyId: 'pty-left', + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + terminal: { + root: { type: 'leaf' as const, leafId: 'left' }, + activeLeafId: 'left', + expandedLeafId: null, + ptyIdsByLeafId: { left: 'pty-left' } + } + }, + unifiedTabs: { + [WORKTREE_ID]: [ + { + id: 'terminal', + entityId: 'terminal', + groupId: 'terminal-group', + worktreeId: WORKTREE_ID, + contentType: 'terminal' as const, + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + { + id: 'notes', + entityId: 'notes.md', + groupId: 'notes-group', + worktreeId: WORKTREE_ID, + contentType: 'editor' as const, + label: 'Notes', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 2 + } + ] + }, + tabGroups: { + [WORKTREE_ID]: [ + { + id: 'terminal-group', + worktreeId: WORKTREE_ID, + activeTabId: 'terminal', + tabOrder: ['terminal'] + }, + { + id: 'notes-group', + worktreeId: WORKTREE_ID, + activeTabId: 'notes', + tabOrder: ['notes'] + } + ] + }, + tabGroupLayouts: { + [WORKTREE_ID]: { + type: 'split' as const, + direction: 'horizontal' as const, + first: { type: 'leaf' as const, groupId: 'terminal-group' }, + second: { type: 'leaf' as const, groupId: 'notes-group' } + } + }, + remoteSessionIdsByTabId: { terminal: 'pty-left' } + } + + const result = retireTerminalSurfaceFromPersistence(session, { + worktreeId: WORKTREE_ID, + parentTabId: 'terminal', + leafId: 'left', + ptyId: 'pty-left' + }) + + expect(result.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(result.terminalLayoutsByTabId.terminal).toBeUndefined() + expect(result.unifiedTabs?.[WORKTREE_ID].map((tab) => tab.id)).toEqual(['notes']) + expect(result.tabGroups?.[WORKTREE_ID].map((group) => group.id)).toEqual(['notes-group']) + expect(result.tabGroupLayouts?.[WORKTREE_ID]).toEqual({ + type: 'leaf', + groupId: 'notes-group' + }) + expect(result.activeTabIdByWorktree?.[WORKTREE_ID]).toBe('notes') + expect(result.activeTabTypeByWorktree?.[WORKTREE_ID]).toBe('editor') + expect(result.activeGroupIdByWorktree?.[WORKTREE_ID]).toBe('notes-group') + expect(result.remoteSessionIdsByTabId?.terminal).toBeUndefined() + }) + + it('retires an exact split leaf when only its sibling has a persisted PTY mapping', () => { + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'terminal', + ptyId: 'pty-right', + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + terminal: { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: 'left' }, + second: { type: 'leaf' as const, leafId: 'right' } + }, + activeLeafId: 'left', + expandedLeafId: null, + ptyIdsByLeafId: { right: 'pty-right' } + } + } + } + + const result = retireTerminalSurfaceFromPersistence(session, { + worktreeId: WORKTREE_ID, + parentTabId: 'terminal', + leafId: 'left', + ptyId: 'pty-left' + }) + + expect(result.tabsByWorktree[WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: 'terminal', ptyId: 'pty-right' }) + ]) + expect(result.terminalLayoutsByTabId.terminal).toMatchObject({ + root: { type: 'leaf', leafId: 'right' }, + ptyIdsByLeafId: { right: 'pty-right' } + }) + }) + + it('fences an absent exact leaf without deleting its live sibling parent', () => { + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'terminal', + ptyId: 'pty-right', + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + terminal: { + root: { type: 'leaf' as const, leafId: 'right' }, + activeLeafId: 'right', + expandedLeafId: null, + ptyIdsByLeafId: { right: 'pty-right' } + } + }, + terminalPtyIncarnationsByPaneKey: { 'terminal:left': 'incarnation-left' }, + remoteSessionIdsByTabId: { terminal: 'pty-right' } + } + + const result = retireTerminalSurfaceFromPersistence(session, { + worktreeId: WORKTREE_ID, + parentTabId: 'terminal', + leafId: 'left', + ptyId: 'pty-left', + incarnationId: 'incarnation-left', + retiredAt: 42 + }) + + expect(result.tabsByWorktree[WORKTREE_ID]).toEqual(session.tabsByWorktree[WORKTREE_ID]) + expect(result.terminalLayoutsByTabId.terminal).toEqual(session.terminalLayoutsByTabId.terminal) + expect(result.remoteSessionIdsByTabId).toEqual({ terminal: 'pty-right' }) + expect(result.terminalPtyIncarnationsByPaneKey?.['terminal:left']).toBeUndefined() + expect(result.terminalSurfaceTombstonesByPaneKey).toEqual({}) + expect(result.terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1) + }) + + it('does not treat a sibling parent PTY as the exact leaf when layout is unavailable', () => { + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'terminal', + ptyId: 'pty-right', + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + + const result = retireTerminalSurfaceFromPersistence(session, { + worktreeId: WORKTREE_ID, + parentTabId: 'terminal', + leafId: 'left', + ptyId: 'pty-left', + incarnationId: 'incarnation-left' + }) + + expect(result.tabsByWorktree[WORKTREE_ID]).toEqual(session.tabsByWorktree[WORKTREE_ID]) + expect(result.terminalSurfaceTombstonesByPaneKey).toEqual({}) + expect(result.terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1) + }) + + it('rebases stale writes onto durable host membership without retaining pane history', () => { + const staleSession = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'terminal', + ptyId: 'pty-left', + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + terminal: { + root: { type: 'leaf' as const, leafId: 'left' }, + activeLeafId: 'left', + expandedLeafId: null, + ptyIdsByLeafId: { left: 'pty-left' } + } + }, + terminalPtyIncarnationsByPaneKey: { 'terminal:left': 'incarnation-a' } + } + const retired = retireTerminalSurfaceFromPersistence(staleSession, { + worktreeId: WORKTREE_ID, + parentTabId: 'terminal', + leafId: 'left', + ptyId: 'pty-left', + incarnationId: 'incarnation-a', + retiredAt: 42 + }) + + const afterStaleWrite = sanitizeWorkspaceSessionTerminalRetirements(staleSession, retired) + expect(afterStaleWrite.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(afterStaleWrite.terminalSurfaceTombstonesByPaneKey).toEqual({}) + expect(afterStaleWrite.terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1) + + const afterRestart = sanitizeWorkspaceSessionTerminalRetirements(staleSession, afterStaleWrite) + expect(afterRestart.tabsByWorktree[WORKTREE_ID]).toEqual([]) + + const untrustedReplacement = sanitizeWorkspaceSessionTerminalRetirements( + { + ...staleSession, + terminalPtyIncarnationsByPaneKey: { 'terminal:left': 'incarnation-b' } + }, + afterRestart + ) + expect(untrustedReplacement.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(untrustedReplacement.terminalPtyIncarnationsByPaneKey).toBeUndefined() + }) + + it('migrates legacy tombstones into one repo watermark', () => { + const stale = { + ...getDefaultWorkspaceSession(), + terminalSurfaceTombstonesByPaneKey: { + 'terminal:left': { + worktreeId: WORKTREE_ID, + parentTabId: 'terminal', + leafId: 'left', + ptyId: 'pty-left', + incarnationId: 'incarnation-a', + retiredAt: 42 + } + } + } + + const migrated = sanitizeWorkspaceSessionTerminalRetirements(stale, stale) + + expect(migrated.terminalSurfaceTombstonesByPaneKey).toEqual({}) + expect(migrated.terminalTopologyRevisionByRepoId).toEqual({ [REPO_ID]: 1 }) + }) + + it('keeps retirement state proportional to repos across many worktrees and closed panes', () => { + let session = getDefaultWorkspaceSession() + for (let index = 0; index < 1_000; index += 1) { + session = retireTerminalSurfaceFromPersistence(session, { + worktreeId: `${REPO_ID}::/worktree-${index}`, + parentTabId: `terminal-${index}`, + leafId: `leaf-${index}`, + ptyId: `pty-${index}`, + incarnationId: `incarnation-${index}` + }) + } + + expect(session.terminalSurfaceTombstonesByPaneKey).toEqual({}) + expect(Object.keys(session.terminalTopologyRevisionByRepoId ?? {})).toEqual([REPO_ID]) + expect(session.terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1_000) + }) +}) diff --git a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts new file mode 100644 index 00000000000..017b0f6ab47 --- /dev/null +++ b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts @@ -0,0 +1,258 @@ +import type { WorkspaceSessionState, WorkspaceVisibleTabType } from '../../shared/types' +import { + pruneTabGroupLayoutAfterRetirement, + repairMobileSessionTabGroupsAfterRetirement, + retireLeavesFromTerminalLayout, + type RetiredTerminalSurface +} from './mobile-session-terminal-retirement' +import { + advanceTerminalTopologyRevision, + rebaseWorkspaceSessionTerminalMembership +} from './workspace-session-terminal-membership-authority' + +function visibleTypeForContentType( + contentType: string | undefined +): WorkspaceVisibleTabType | undefined { + if (contentType === 'terminal') { + return 'terminal' + } + if (contentType === 'browser') { + return 'browser' + } + if (contentType === 'simulator') { + return 'simulator' + } + return contentType ? 'editor' : undefined +} + +function layoutContainsLeaf( + node: WorkspaceSessionState['terminalLayoutsByTabId'][string]['root'], + leafId: string +): boolean { + if (!node) { + return false + } + return node.type === 'leaf' + ? node.leafId === leafId + : layoutContainsLeaf(node.first, leafId) || layoutContainsLeaf(node.second, leafId) +} + +function recordTerminalSurfaceRetirement( + session: WorkspaceSessionState, + surface: RetiredTerminalSurface, + paneKey: string +): WorkspaceSessionState { + const terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey + } + delete terminalPtyIncarnationsByPaneKey[paneKey] + const terminalSurfaceTombstonesByPaneKey = { + ...session.terminalSurfaceTombstonesByPaneKey + } + delete terminalSurfaceTombstonesByPaneKey[paneKey] + return advanceTerminalTopologyRevision( + { + ...session, + terminalPtyIncarnationsByPaneKey, + terminalSurfaceTombstonesByPaneKey + }, + surface.worktreeId + ) +} + +export function retireTerminalSurfaceFromPersistence( + session: WorkspaceSessionState, + surface: RetiredTerminalSurface +): WorkspaceSessionState { + const paneKey = `${surface.parentTabId}:${surface.leafId}` + const boundIncarnationId = session.terminalPtyIncarnationsByPaneKey?.[paneKey] + if (surface.incarnationId && boundIncarnationId && boundIncarnationId !== surface.incarnationId) { + return session + } + const persistedTabs = session.tabsByWorktree[surface.worktreeId] ?? [] + const persistedTab = persistedTabs.find((tab) => tab.id === surface.parentTabId) + const layout = session.terminalLayoutsByTabId[surface.parentTabId] + const exactLeafInLayout = Boolean(layout && layoutContainsLeaf(layout.root, surface.leafId)) + const leafPtyId = exactLeafInLayout ? layout?.ptyIdsByLeafId?.[surface.leafId] : undefined + if (leafPtyId && leafPtyId !== surface.ptyId) { + return session + } + + const isLegacyFinalSurface = !layout && persistedTab?.ptyId === surface.ptyId + if (!exactLeafInLayout && !isLegacyFinalSurface) { + // Why: tab.ptyId may describe a live sibling. The absent exact leaf still + // needs a tombstone, but sibling evidence must not remove its parent. + return recordTerminalSurfaceRetirement(session, surface, paneKey) + } + + const nextLayout = + exactLeafInLayout && layout + ? retireLeavesFromTerminalLayout(layout, new Set([surface.leafId])) + : null + const removeParent = !nextLayout + const nextTabsForWorktree = removeParent + ? persistedTabs.filter((tab) => tab.id !== surface.parentTabId) + : persistedTabs.map((tab) => + tab.id === surface.parentTabId + ? { + ...tab, + ptyId: + nextLayout.ptyIdsByLeafId?.[nextLayout.activeLeafId ?? ''] ?? + Object.values(nextLayout.ptyIdsByLeafId ?? {})[0] ?? + null + } + : tab + ) + const terminalLayoutsByTabId = { ...session.terminalLayoutsByTabId } + if (nextLayout) { + terminalLayoutsByTabId[surface.parentTabId] = nextLayout + } else { + delete terminalLayoutsByTabId[surface.parentTabId] + } + + const unifiedTabsForWorktree = session.unifiedTabs?.[surface.worktreeId] ?? [] + const unifiedTabs = session.unifiedTabs + ? { + ...session.unifiedTabs, + [surface.worktreeId]: removeParent + ? unifiedTabsForWorktree.filter( + (tab) => tab.id !== surface.parentTabId && tab.entityId !== surface.parentTabId + ) + : unifiedTabsForWorktree + } + : undefined + const validTopLevelIds = new Set((unifiedTabs?.[surface.worktreeId] ?? []).map((tab) => tab.id)) + for (const tab of nextTabsForWorktree) { + validTopLevelIds.add(tab.id) + } + const persistedGroups = session.tabGroups?.[surface.worktreeId] + const repairedGroups = repairMobileSessionTabGroupsAfterRetirement( + persistedGroups, + validTopLevelIds + ) + const tabGroups = session.tabGroups + ? { + ...session.tabGroups, + [surface.worktreeId]: (repairedGroups ?? []).map((group) => ({ + ...group, + worktreeId: surface.worktreeId + })) + } + : undefined + const retainedGroupIds = new Set(repairedGroups?.map((group) => group.id) ?? []) + const repairedGroupLayout = pruneTabGroupLayoutAfterRetirement( + session.tabGroupLayouts?.[surface.worktreeId], + retainedGroupIds + ) + const tabGroupLayouts = session.tabGroupLayouts ? { ...session.tabGroupLayouts } : undefined + if (tabGroupLayouts) { + if (repairedGroupLayout) { + tabGroupLayouts[surface.worktreeId] = repairedGroupLayout + } else { + delete tabGroupLayouts[surface.worktreeId] + } + } + + const previousActiveTabId = session.activeTabIdByWorktree?.[surface.worktreeId] + const activeTabStillExists = previousActiveTabId && validTopLevelIds.has(previousActiveTabId) + const nextActiveTabId = + (activeTabStillExists ? previousActiveTabId : undefined) ?? + repairedGroups?.find( + (group) => group.id === session.activeGroupIdByWorktree?.[surface.worktreeId] + )?.activeTabId ?? + repairedGroups?.[0]?.activeTabId ?? + [...validTopLevelIds][0] ?? + null + const activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [surface.worktreeId]: nextActiveTabId + } + const activeTabTypeByWorktree = { ...session.activeTabTypeByWorktree } + const activeUnifiedTab = unifiedTabs?.[surface.worktreeId]?.find( + (tab) => tab.id === nextActiveTabId + ) + const nextActiveType = visibleTypeForContentType(activeUnifiedTab?.contentType) + if (nextActiveType) { + activeTabTypeByWorktree[surface.worktreeId] = nextActiveType + } else if (!nextActiveTabId) { + delete activeTabTypeByWorktree[surface.worktreeId] + } + const activeGroupIdByWorktree = { ...session.activeGroupIdByWorktree } + const nextActiveGroupId = + repairedGroups?.find((group) => group.tabOrder.includes(nextActiveTabId ?? ''))?.id ?? + repairedGroups?.[0]?.id + if (nextActiveGroupId) { + activeGroupIdByWorktree[surface.worktreeId] = nextActiveGroupId + } else { + delete activeGroupIdByWorktree[surface.worktreeId] + } + const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } + if (removeParent) { + delete remoteSessionIdsByTabId[surface.parentTabId] + } else if (remoteSessionIdsByTabId[surface.parentTabId] === surface.ptyId) { + remoteSessionIdsByTabId[surface.parentTabId] = + nextLayout?.ptyIdsByLeafId?.[nextLayout.activeLeafId ?? ''] ?? + Object.values(nextLayout?.ptyIdsByLeafId ?? {})[0] ?? + '' + if (!remoteSessionIdsByTabId[surface.parentTabId]) { + delete remoteSessionIdsByTabId[surface.parentTabId] + } + } + return recordTerminalSurfaceRetirement( + { + ...session, + activeTabId: + session.activeTabId === surface.parentTabId ? nextActiveTabId : session.activeTabId, + tabsByWorktree: { + ...session.tabsByWorktree, + [surface.worktreeId]: nextTabsForWorktree + }, + terminalLayoutsByTabId, + activeTabIdByWorktree, + ...(unifiedTabs ? { unifiedTabs } : {}), + ...(tabGroups ? { tabGroups } : {}), + ...(tabGroupLayouts ? { tabGroupLayouts } : {}), + ...(session.activeGroupIdByWorktree ? { activeGroupIdByWorktree } : {}), + ...(session.activeTabTypeByWorktree ? { activeTabTypeByWorktree } : {}), + ...(session.remoteSessionIdsByTabId ? { remoteSessionIdsByTabId } : {}) + }, + surface, + paneKey + ) +} + +export function sanitizeWorkspaceSessionTerminalRetirements( + incoming: WorkspaceSessionState, + prior: WorkspaceSessionState | undefined +): WorkspaceSessionState { + if ( + !prior?.terminalSurfaceTombstonesByPaneKey && + !incoming.terminalSurfaceTombstonesByPaneKey && + !prior?.terminalTopologyRevisionByRepoId + ) { + return incoming + } + const bindings = { + ...prior?.terminalPtyIncarnationsByPaneKey, + ...incoming.terminalPtyIncarnationsByPaneKey + } + const tombstones = { + ...prior?.terminalSurfaceTombstonesByPaneKey, + ...incoming.terminalSurfaceTombstonesByPaneKey + } + const hasLegacyTombstones = Object.keys(tombstones).length > 0 + let next: WorkspaceSessionState = { + ...incoming, + terminalPtyIncarnationsByPaneKey: hasLegacyTombstones + ? bindings + : incoming.terminalPtyIncarnationsByPaneKey, + terminalSurfaceTombstonesByPaneKey: tombstones + } + for (const tombstone of Object.values(tombstones)) { + next = retireTerminalSurfaceFromPersistence(next, tombstone) + } + return rebaseWorkspaceSessionTerminalMembership( + { ...next, terminalSurfaceTombstonesByPaneKey: {} }, + prior + ) +} diff --git a/src/main/runtime/mobile-session-terminal-retirement.test.ts b/src/main/runtime/mobile-session-terminal-retirement.test.ts new file mode 100644 index 00000000000..3eb85afaae0 --- /dev/null +++ b/src/main/runtime/mobile-session-terminal-retirement.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { + RuntimeMobileSessionTabsSnapshot, + RuntimeMobileSessionTerminalTab +} from '../../shared/runtime-types' +import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement' +import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' + +const WORKTREE_ID = 'repo::/worktree' + +function splitSnapshot(): RuntimeMobileSessionTabsSnapshot { + const parentLayout = { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: 'left' }, + second: { type: 'leaf' as const, leafId: 'right' } + }, + activeLeafId: 'left', + expandedLeafId: 'left', + ptyIdsByLeafId: { left: 'pty-left', right: 'pty-right' }, + buffersByLeafId: { left: 'left buffer', right: 'right buffer' }, + titlesByLeafId: { left: 'Left', right: 'Right' } + } + return { + worktree: WORKTREE_ID, + publicationEpoch: 'host', + snapshotVersion: 4, + activeGroupId: 'terminals', + activeTabId: 'terminal::left', + activeTabType: 'terminal', + tabGroups: [ + { + id: 'terminals', + activeTabId: 'terminal', + tabOrder: ['terminal', 'notes'], + recentTabIds: ['notes', 'terminal'] + }, + { id: 'secondary', activeTabId: 'other', tabOrder: ['other'] } + ], + tabGroupLayout: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'terminals' }, + second: { type: 'leaf', groupId: 'secondary' } + }, + tabs: [ + { + type: 'terminal', + id: 'terminal::left', + parentTabId: 'terminal', + leafId: 'left', + ptyId: 'pty-left', + title: 'Left', + parentLayout, + isActive: true + }, + { + type: 'terminal', + id: 'terminal::right', + parentTabId: 'terminal', + leafId: 'right', + ptyId: 'pty-right', + title: 'Right', + parentLayout, + isActive: false + }, + { + type: 'markdown', + id: 'notes', + title: 'Notes', + filePath: '/worktree/notes.md', + relativePath: 'notes.md', + language: 'markdown', + mode: 'edit', + isDirty: false, + sourceFileId: 'notes.md', + sourceFilePath: '/worktree/notes.md', + sourceRelativePath: 'notes.md', + documentVersion: '1', + isActive: false + }, + { + type: 'file', + id: 'other', + title: 'Other', + filePath: '/worktree/other.ts', + relativePath: 'other.ts', + language: 'typescript', + isDirty: false, + isActive: false + } + ] + } +} + +describe('mobile session terminal retirement', () => { + it('retires one split leaf and rewrites every surviving sibling layout', () => { + const result = retireTerminalSurfacesFromSnapshot({ + snapshot: splitSnapshot(), + ptyId: 'pty-left' + }) + + expect(result?.snapshot).toMatchObject({ + snapshotVersion: 5, + activeGroupId: 'terminals', + activeTabId: 'terminal::right', + activeTabType: 'terminal' + }) + expect(result?.snapshot.tabs).toHaveLength(3) + expect(result?.snapshot.tabs[0]).toMatchObject({ + id: 'terminal::right', + isActive: true, + parentLayout: { + root: { type: 'leaf', leafId: 'right' }, + activeLeafId: 'right', + expandedLeafId: null, + ptyIdsByLeafId: { right: 'pty-right' }, + buffersByLeafId: { right: 'right buffer' }, + titlesByLeafId: { right: 'Right' } + } + }) + }) + + it('removes the final parent, empty group, and group-layout leaf', () => { + const first = retireTerminalSurfacesFromSnapshot({ + snapshot: splitSnapshot(), + ptyId: 'pty-left' + })! + const result = retireTerminalSurfacesFromSnapshot({ + snapshot: first.snapshot, + ptyId: 'pty-right' + }) + + expect(result?.snapshot).toMatchObject({ + activeGroupId: 'terminals', + activeTabId: 'notes', + activeTabType: 'markdown', + tabGroupLayout: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'terminals' }, + second: { type: 'leaf', groupId: 'secondary' } + } + }) + expect(result?.snapshot.tabGroups?.[0]).toMatchObject({ + id: 'terminals', + activeTabId: 'notes', + tabOrder: ['notes'] + }) + expect(result?.snapshot.tabs.map((tab) => tab.id)).toEqual(['notes', 'other']) + }) + + it('does not retire an exact surface rebound to a replacement PTY', () => { + const snapshot = splitSnapshot() + const rebound = { + ...snapshot, + tabs: snapshot.tabs.map((tab) => + tab.type === 'terminal' && tab.leafId === 'left' + ? { + ...tab, + ptyId: 'pty-replacement', + parentLayout: tab.parentLayout + ? { + ...tab.parentLayout, + ptyIdsByLeafId: { + ...tab.parentLayout.ptyIdsByLeafId, + left: 'pty-replacement' + } + } + : undefined + } + : tab + ) + } + + expect( + retireTerminalSurfacesFromSnapshot({ + snapshot: rebound, + ptyId: 'pty-left', + exactSurfaces: [{ parentTabId: 'terminal', leafId: 'left' }] + }) + ).toBeNull() + }) + + it('de-persists an exact leaf without removing its live sibling', () => { + const session = { + ...getDefaultWorkspaceSession(), + activeTabId: 'terminal', + activeTabIdByWorktree: { [WORKTREE_ID]: 'terminal' }, + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'terminal', + ptyId: 'pty-left', + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + terminal: (splitSnapshot().tabs[0] as RuntimeMobileSessionTerminalTab).parentLayout! + } + } + + const result = retireTerminalSurfaceFromPersistence(session, { + worktreeId: WORKTREE_ID, + parentTabId: 'terminal', + leafId: 'left', + ptyId: 'pty-left' + }) + + expect(result.tabsByWorktree[WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: 'terminal', ptyId: 'pty-right' }) + ]) + expect(result.terminalLayoutsByTabId.terminal).toMatchObject({ + root: { type: 'leaf', leafId: 'right' }, + activeLeafId: 'right', + ptyIdsByLeafId: { right: 'pty-right' } + }) + }) + + it('retires a permanently exited surface despite a stale sleeping record', () => { + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'terminal', + ptyId: 'pty-left', + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + terminal: { + root: { type: 'leaf' as const, leafId: 'left' }, + activeLeafId: 'left', + expandedLeafId: null, + ptyIdsByLeafId: { left: 'pty-left' } + } + }, + sleepingAgentSessionsByPaneKey: { + 'terminal:left': {} as never + } + } + + const result = retireTerminalSurfaceFromPersistence(session, { + worktreeId: WORKTREE_ID, + parentTabId: 'terminal', + leafId: 'left', + ptyId: 'pty-left' + }) + + expect(result.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(result.terminalLayoutsByTabId.terminal).toBeUndefined() + }) +}) diff --git a/src/main/runtime/mobile-session-terminal-retirement.ts b/src/main/runtime/mobile-session-terminal-retirement.ts new file mode 100644 index 00000000000..acd75fb5580 --- /dev/null +++ b/src/main/runtime/mobile-session-terminal-retirement.ts @@ -0,0 +1,286 @@ +import type { + RuntimeMobileSessionSnapshotTab, + RuntimeMobileSessionTabGroup, + RuntimeMobileSessionTabsSnapshot, + RuntimeMobileSessionTerminalTab +} from '../../shared/runtime-types' +import type { + TabGroupLayoutNode, + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../shared/types' + +export type RetiredTerminalSurface = { + worktreeId: string + parentTabId: string + leafId: string + ptyId: string + incarnationId?: string + retiredAt?: number +} + +function pruneTerminalPane( + node: TerminalPaneLayoutNode | null, + retiredLeafIds: ReadonlySet +): TerminalPaneLayoutNode | null { + if (!node) { + return null + } + if (node.type === 'leaf') { + return retiredLeafIds.has(node.leafId) ? null : node + } + const first = pruneTerminalPane(node.first, retiredLeafIds) + const second = pruneTerminalPane(node.second, retiredLeafIds) + if (first && second) { + return { ...node, first, second } + } + return first ?? second +} + +function collectTerminalLeafIds(node: TerminalPaneLayoutNode | null): string[] { + if (!node) { + return [] + } + return node.type === 'leaf' + ? [node.leafId] + : [...collectTerminalLeafIds(node.first), ...collectTerminalLeafIds(node.second)] +} + +function omitLeafRecords( + values: Record | undefined, + retiredLeafIds: ReadonlySet +): Record | undefined { + if (!values) { + return undefined + } + const retained = Object.fromEntries( + Object.entries(values).filter(([leafId]) => !retiredLeafIds.has(leafId)) + ) + return Object.keys(retained).length > 0 ? retained : undefined +} + +export function retireLeavesFromTerminalLayout( + layout: TerminalLayoutSnapshot, + retiredLeafIds: ReadonlySet +): TerminalLayoutSnapshot | null { + const root = pruneTerminalPane(layout.root, retiredLeafIds) + if (!root) { + return null + } + const retainedLeafIds = collectTerminalLeafIds(root) + const retainedLeafIdSet = new Set(retainedLeafIds) + const activeLeafId = + layout.activeLeafId && retainedLeafIdSet.has(layout.activeLeafId) + ? layout.activeLeafId + : retainedLeafIds[0]! + return { + ...layout, + root, + activeLeafId, + expandedLeafId: + layout.expandedLeafId && retainedLeafIdSet.has(layout.expandedLeafId) + ? layout.expandedLeafId + : null, + ptyIdsByLeafId: omitLeafRecords(layout.ptyIdsByLeafId, retiredLeafIds), + buffersByLeafId: omitLeafRecords(layout.buffersByLeafId, retiredLeafIds), + scrollbackRefsByLeafId: omitLeafRecords(layout.scrollbackRefsByLeafId, retiredLeafIds), + titlesByLeafId: omitLeafRecords(layout.titlesByLeafId, retiredLeafIds) + } +} + +export function pruneTabGroupLayoutAfterRetirement( + layout: TabGroupLayoutNode | undefined, + retainedGroupIds: ReadonlySet +): TabGroupLayoutNode | undefined { + if (!layout) { + return undefined + } + if (layout.type === 'leaf') { + return retainedGroupIds.has(layout.groupId) ? layout : undefined + } + const first = pruneTabGroupLayoutAfterRetirement(layout.first, retainedGroupIds) + const second = pruneTabGroupLayoutAfterRetirement(layout.second, retainedGroupIds) + if (first && second) { + return { ...layout, first, second } + } + return first ?? second +} + +function chooseGroupActiveTab( + group: RuntimeMobileSessionTabGroup, + retainedTabIds: ReadonlySet +): string | null { + if (group.activeTabId && retainedTabIds.has(group.activeTabId)) { + return group.activeTabId + } + const recent = (group.recentTabIds ?? []).toReversed().find((tabId) => retainedTabIds.has(tabId)) + return recent ?? group.tabOrder.find((tabId) => retainedTabIds.has(tabId)) ?? null +} + +export function repairMobileSessionTabGroupsAfterRetirement( + groups: readonly RuntimeMobileSessionTabGroup[] | undefined, + validTopLevelIds: ReadonlySet +): RuntimeMobileSessionTabGroup[] | undefined { + if (!groups) { + return undefined + } + const repaired = groups.flatMap((group) => { + const tabOrder = group.tabOrder.filter((tabId) => validTopLevelIds.has(tabId)) + if (tabOrder.length === 0) { + return [] + } + const retained = new Set(tabOrder) + const recentTabIds = group.recentTabIds?.filter((tabId) => retained.has(tabId)) + return [ + { + ...group, + tabOrder, + activeTabId: chooseGroupActiveTab(group, retained), + ...(recentTabIds && recentTabIds.length > 0 ? { recentTabIds } : {}) + } + ] + }) + return repaired.length > 0 ? repaired : undefined +} + +function topLevelTabId(tab: RuntimeMobileSessionSnapshotTab): string { + return tab.type === 'terminal' ? tab.parentTabId : tab.id +} + +function chooseActiveSurface( + tabs: readonly RuntimeMobileSessionSnapshotTab[], + previousActiveId: string | null, + groups: readonly RuntimeMobileSessionTabGroup[] | undefined, + previousActiveGroupId: string | null +): RuntimeMobileSessionSnapshotTab | null { + const previous = previousActiveId ? tabs.find((tab) => tab.id === previousActiveId) : undefined + if (previous) { + return previous + } + const activeGroup = + groups?.find((group) => group.id === previousActiveGroupId) ?? groups?.[0] ?? null + const activeTopLevelId = activeGroup?.activeTabId + return ( + (activeTopLevelId + ? (tabs.find((tab) => topLevelTabId(tab) === activeTopLevelId && tab.isActive) ?? + tabs.find((tab) => topLevelTabId(tab) === activeTopLevelId)) + : undefined) ?? + tabs.find((tab) => tab.isActive) ?? + tabs[0] ?? + null + ) +} + +function terminalMatchesRetirement( + tab: RuntimeMobileSessionTerminalTab, + ptyId: string, + exactSurfaceKeys: ReadonlySet, + exactOnly: boolean +): boolean { + const surfaceKey = `${tab.parentTabId}\0${tab.leafId}` + if (exactSurfaceKeys.has(surfaceKey)) { + const leafPtyId = tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] + return (!tab.ptyId || tab.ptyId === ptyId) && (!leafPtyId || leafPtyId === ptyId) + } + if (exactOnly) { + return false + } + return tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId +} + +export function retireTerminalSurfacesFromSnapshot(args: { + snapshot: RuntimeMobileSessionTabsSnapshot + ptyId: string + exactSurfaces?: readonly Pick[] + exactOnly?: boolean +}): { snapshot: RuntimeMobileSessionTabsSnapshot; retired: RetiredTerminalSurface[] } | null { + const exactSurfaceKeys = new Set( + (args.exactSurfaces ?? []).map((surface) => `${surface.parentTabId}\0${surface.leafId}`) + ) + const retiredTabs = args.snapshot.tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => + tab.type === 'terminal' && + terminalMatchesRetirement(tab, args.ptyId, exactSurfaceKeys, args.exactOnly === true) + ) + if (retiredTabs.length === 0) { + return null + } + + const retiredLeafIdsByParent = new Map>() + for (const tab of retiredTabs) { + const leafIds = retiredLeafIdsByParent.get(tab.parentTabId) ?? new Set() + leafIds.add(tab.leafId) + retiredLeafIdsByParent.set(tab.parentTabId, leafIds) + } + const retiredIds = new Set(retiredTabs.map((tab) => tab.id)) + let tabs = args.snapshot.tabs.filter((tab) => !retiredIds.has(tab.id)) + tabs = tabs.map((tab) => { + if (tab.type !== 'terminal') { + return tab + } + const retiredLeafIds = retiredLeafIdsByParent.get(tab.parentTabId) + if (!retiredLeafIds) { + return tab + } + const sourceLayout = + tab.parentLayout ?? + retiredTabs.find((retired) => retired.parentTabId === tab.parentTabId)?.parentLayout + const parentLayout = sourceLayout + ? retireLeavesFromTerminalLayout(sourceLayout, retiredLeafIds) + : undefined + return { + ...tab, + ...(parentLayout ? { parentLayout } : {}), + isActive: + tab.isActive || + retiredTabs.some((retired) => retired.parentTabId === tab.parentTabId && retired.isActive) + } + }) + + const validTopLevelIds = new Set(tabs.map(topLevelTabId)) + const tabGroups = repairMobileSessionTabGroupsAfterRetirement( + args.snapshot.tabGroups, + validTopLevelIds + ) + const active = chooseActiveSurface( + tabs, + args.snapshot.activeTabId, + tabGroups, + args.snapshot.activeGroupId + ) + tabs = tabs.map((tab) => ({ ...tab, isActive: tab.id === active?.id })) + const activeTopLevelId = active ? topLevelTabId(active) : null + const activeGroupId = + (activeTopLevelId + ? tabGroups?.find((group) => group.tabOrder.includes(activeTopLevelId))?.id + : undefined) ?? + tabGroups?.[0]?.id ?? + null + const retainedGroupIds = new Set(tabGroups?.map((group) => group.id) ?? []) + + return { + snapshot: { + ...args.snapshot, + snapshotVersion: args.snapshot.snapshotVersion + 1, + activeGroupId, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + ...(tabGroups ? { tabGroups } : { tabGroups: undefined }), + ...(args.snapshot.tabGroupLayout + ? { + tabGroupLayout: pruneTabGroupLayoutAfterRetirement( + args.snapshot.tabGroupLayout, + retainedGroupIds + ) + } + : {}), + tabs + }, + retired: retiredTabs.map((tab) => ({ + worktreeId: args.snapshot.worktree, + parentTabId: tab.parentTabId, + leafId: tab.leafId, + ptyId: args.ptyId + })) + } +} diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts new file mode 100644 index 00000000000..56fa18cbb5b --- /dev/null +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + RuntimeCreateAgentSessionRequest, + RuntimeCreateAgentSessionResult +} from '../../shared/agent-session-host-authority' +import { OrcaRuntimeService } from './orca-runtime' + +function operationId(now = Date.now()): string { + return `${now}-0123456789abcdef0123456789abcdef` +} + +function request( + clientOperationId: string, + overrides: Partial = {} +): RuntimeCreateAgentSessionRequest { + return { + clientOperationId, + worktree: 'id:worktree-1', + agent: 'codex', + prompt: 'do the thing', + presentation: 'background', + ...overrides + } +} + +function terminal() { + return { + handle: 'term_operation', + tabId: '11111111-1111-4111-8111-111111111111', + paneKey: '11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222', + ptyId: 'pty-operation', + worktreeId: 'worktree-1', + title: null, + surface: 'background' as const + } +} + +function createRuntime(provider?: { + supportsAgentSessionClaims?: () => boolean + supportsAgentSessionCreateOperations?: () => boolean +}) { + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + }) + } as never, + undefined, + provider ? { getLocalProvider: () => provider as never } : undefined + ) + const internal = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: ReturnType + markLocalWorkspaceTrustedForAgent: ReturnType + markRemoteWorkspaceTrustedForAgent: ReturnType + } + internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ + id: 'worktree-1', + path: '/tmp/worktree-1', + connectionId: null + })) + internal.markLocalWorkspaceTrustedForAgent = vi.fn() + internal.markRemoteWorkspaceTrustedForAgent = vi.fn() + return runtime +} + +describe('agent-session create operation ledger', () => { + it('selects legacy before trust, spawn, or ledger state for an old daemon', async () => { + const provider = { + supportsAgentSessionClaims: vi.fn(() => false), + supportsAgentSessionCreateOperations: vi.fn(() => false) + } + const runtime = createRuntime(provider) + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + const internal = runtime as unknown as { + markLocalWorkspaceTrustedForAgent: ReturnType + } + const id = operationId() + + await expect(runtime.createAgentSession(request(id))).rejects.toThrow( + 'agent_session_legacy_required' + ) + await expect( + runtime.ensureAgentSession({ + kind: 'explicit', + worktree: 'id:worktree-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-session-1' } + }) + ).rejects.toThrow('agent_session_legacy_required') + + expect(createTerminal).not.toHaveBeenCalled() + expect(internal.markLocalWorkspaceTrustedForAgent).not.toHaveBeenCalled() + + provider.supportsAgentSessionCreateOperations.mockReturnValue(true) + await expect(runtime.createAgentSession(request(id))).resolves.toMatchObject({ + disposition: 'created' + }) + provider.supportsAgentSessionCreateOperations.mockReturnValue(false) + await expect(runtime.createAgentSession(request(id))).resolves.toMatchObject({ + disposition: 'replayed' + }) + expect(createTerminal).toHaveBeenCalledOnce() + }) + + it('requests exact client legacy fallback before nested SSH side effects', async () => { + const runtime = createRuntime() + const internal = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: ReturnType + } + internal.resolveTerminalWorkspaceLaunchScope.mockResolvedValue({ + id: 'worktree-1', + path: '/remote/worktree-1', + connectionId: 'ssh-1' + }) + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + + await expect( + runtime.ensureAgentSession({ + kind: 'explicit', + worktree: 'id:worktree-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-session-1' } + }) + ).rejects.toThrow('agent_session_legacy_required') + + expect(createTerminal).not.toHaveBeenCalled() + }) + + it('selects nested SSH legacy fallback before reading a Pi transcript path locally', async () => { + const runtime = createRuntime() + const internal = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: ReturnType + markRemoteWorkspaceTrustedForAgent: ReturnType + } + internal.resolveTerminalWorkspaceLaunchScope.mockResolvedValue({ + id: 'worktree-1', + path: '/remote/worktree-1', + connectionId: 'ssh-1' + }) + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + + await expect( + runtime.ensureAgentSession({ + kind: 'explicit', + worktree: 'id:worktree-1', + agent: 'pi', + providerSession: { + key: 'session_id', + id: 'provider-session-1', + transcriptPath: '/remote-only/pi/session.jsonl' + } + }) + ).rejects.toThrow('agent_session_legacy_required') + + expect(createTerminal).not.toHaveBeenCalled() + expect(internal.markRemoteWorkspaceTrustedForAgent).not.toHaveBeenCalled() + }) + + it('replays the same completed operation without spawning again', async () => { + const runtime = createRuntime() + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + const id = operationId() + + await expect( + runtime.createAgentSession(request(id), { clientId: 'device-a' }) + ).resolves.toMatchObject({ disposition: 'created' }) + await expect( + runtime.createAgentSession(request(id), { clientId: 'device-a' }) + ).resolves.toMatchObject({ disposition: 'replayed' }) + expect(createTerminal).toHaveBeenCalledOnce() + }) + + it('joins concurrent retries and conflicts on a changed fingerprint', async () => { + const runtime = createRuntime() + let finish!: (result: ReturnType) => void + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const id = operationId() + const first = runtime.createAgentSession(request(id), { clientId: 'device-a' }) + const joined = runtime.createAgentSession(request(id), { clientId: 'device-a' }) + + await expect( + runtime.createAgentSession(request(id, { prompt: 'changed' }), { clientId: 'device-a' }) + ).rejects.toThrow('agent_session_operation_conflict') + await expect( + runtime.createAgentSession(request(id, { agentArgs: '--profile changed' }), { + clientId: 'device-a' + }) + ).rejects.toThrow('agent_session_operation_conflict') + finish(terminal()) + await expect(first).resolves.toMatchObject({ disposition: 'created' }) + await expect(joined).resolves.toMatchObject({ disposition: 'replayed' }) + expect(createTerminal).toHaveBeenCalledOnce() + }) + + it('isolates operation ids by authenticated caller', async () => { + const runtime = createRuntime() + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + const id = operationId() + + await runtime.createAgentSession(request(id), { clientId: 'device-a' }) + await runtime.createAgentSession(request(id), { clientId: 'device-b' }) + expect(createTerminal).toHaveBeenCalledTimes(2) + }) + + it('rejects an expired unseen operation before terminal creation', async () => { + const runtime = createRuntime() + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + const expired = operationId(Date.now() - 25 * 60 * 60 * 1_000) + + await expect( + runtime.createAgentSession(request(expired), { clientId: 'device-a' }) + ).rejects.toThrow('agent_session_operation_expired') + expect(createTerminal).not.toHaveBeenCalled() + }) + + it('releases a failed pre-spawn operation for a safe retry', async () => { + const runtime = createRuntime() + const createTerminal = vi + .spyOn(runtime, 'createTerminal') + .mockRejectedValueOnce(new Error('pre-spawn failure')) + .mockResolvedValueOnce(terminal()) + const id = operationId() + + await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow( + 'pre-spawn failure' + ) + await expect( + runtime.createAgentSession(request(id), { clientId: 'device-a' }) + ).resolves.toMatchObject({ disposition: 'created' }) + expect(createTerminal).toHaveBeenCalledTimes(2) + expect(createTerminal.mock.calls[0]?.[1]).toMatchObject({ + tabId: createTerminal.mock.calls[1]?.[1]?.tabId, + leafId: createTerminal.mock.calls[1]?.[1]?.leafId, + preAllocatedHandle: createTerminal.mock.calls[1]?.[1]?.preAllocatedHandle, + agentSessionCreateOperationId: + createTerminal.mock.calls[1]?.[1]?.agentSessionCreateOperationId + }) + expect(createTerminal.mock.calls[0]?.[1]?.agentSessionCreateOperationId).toMatch( + /^[A-Za-z0-9_-]{43}$/ + ) + }) + + it.each([ + ['controller admission fails', 'agent_session_exited_during_start'], + ['publication fails', 'post-spawn publication failure'] + ])('retains a replay fence when %s after physical spawn commit', async (_case, message) => { + const runtime = createRuntime() + const failure = new Error(message) + const createTerminal = vi + .spyOn(runtime, 'createTerminal') + .mockImplementation(async (_worktree, opts) => { + opts?.onPtySpawnCommitted?.() + throw failure + }) + const id = operationId() + + await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow( + failure.message + ) + await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow( + failure.message + ) + expect(createTerminal).toHaveBeenCalledOnce() + }) + + it('retains a replay fence when the provider reports an unknown spawn outcome', async () => { + const runtime = createRuntime() + const failure = Object.assign(new Error('cleanup could not prove exit'), { + agentSessionOperationOutcome: 'unknown' as const + }) + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockRejectedValue(failure) + const id = operationId() + + const attempts: Promise[] = [ + runtime.createAgentSession(request(id), { clientId: 'device-a' }), + runtime.createAgentSession(request(id), { clientId: 'device-a' }) + ] + await expect(Promise.all(attempts)).rejects.toThrow(failure.message) + await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow( + failure.message + ) + expect(createTerminal).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/orca-runtime-terminal-retirement.test.ts b/src/main/runtime/orca-runtime-terminal-retirement.test.ts new file mode 100644 index 00000000000..0cfe1937572 --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-retirement.test.ts @@ -0,0 +1,666 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../shared/types' +import { sanitizeWorkspaceSessionTerminalRetirements } from './mobile-session-terminal-persistence-retirement' +import { OrcaRuntimeService } from './orca-runtime' + +const WORKTREE_ID = 'repo::/worktree' +const REPO_ID = 'repo' + +function makeSplitSnapshot(): RuntimeMobileSessionTabsSnapshot { + const parentLayout = { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: 'left' }, + second: { type: 'leaf' as const, leafId: 'right' } + }, + activeLeafId: 'left', + expandedLeafId: 'left', + ptyIdsByLeafId: { left: 'pty-left', right: 'pty-right' } + } + return { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: 1, + activeGroupId: 'group', + activeTabId: 'tab::left', + activeTabType: 'terminal', + tabGroups: [{ id: 'group', activeTabId: 'tab', tabOrder: ['tab'] }], + tabs: [ + { + type: 'terminal', + id: 'tab::left', + parentTabId: 'tab', + leafId: 'left', + ptyId: 'pty-left', + title: 'Left', + parentLayout, + isActive: true + }, + { + type: 'terminal', + id: 'tab::right', + parentTabId: 'tab', + leafId: 'right', + ptyId: 'pty-right', + title: 'Right', + parentLayout, + isActive: false + } + ] + } +} + +function syncSplit(runtime: OrcaRuntimeService, snapshot = makeSplitSnapshot()): void { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: 'left', + layout: + snapshot.tabs[0]?.type === 'terminal' + ? (snapshot.tabs[0].parentLayout?.root ?? null) + : null + } + ], + leaves: [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + leafId: 'left', + paneRuntimeId: 1, + ptyId: 'pty-left' + }, + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + leafId: 'right', + paneRuntimeId: 2, + ptyId: 'pty-right' + } + ], + mobileSessionTabs: [snapshot] + }) +} + +function makePersistedSplitSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'tab', + ptyId: 'pty-left', + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + tab: { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: 'left' }, + second: { type: 'leaf' as const, leafId: 'right' } + }, + activeLeafId: 'left', + expandedLeafId: null, + ptyIdsByLeafId: { left: 'pty-left', right: 'pty-right' } + } + } + } +} + +describe('OrcaRuntimeService terminal surface retirement', () => { + it('releases each early-exit fence after its matching registration is rejected', () => { + const runtime = new OrcaRuntimeService() + const internals = runtime as unknown as { + earlyExitedPtyIncarnations: Map + } + + for (let index = 0; index < 1_000; index += 1) { + const ptyId = `pty-early-${index}` + const incarnationId = `incarnation-${index}` + runtime.beginPtyRegistration(ptyId, incarnationId) + runtime.onPtyExit(ptyId, 0, incarnationId) + expect(() => runtime.assertPtyRegistrationAllowed(ptyId, incarnationId)).toThrow( + 'agent_session_exited_during_start' + ) + runtime.releaseRejectedPtyRegistrationFence(ptyId, incarnationId) + } + + expect(internals.earlyExitedPtyIncarnations.size).toBe(0) + }) + + it('does not retain fences for completed surface-less lifecycles', () => { + const runtime = new OrcaRuntimeService() + const internals = runtime as unknown as { + earlyExitedPtyIncarnations: Map + pendingPtyRegistrationIncarnations: Map + } + + for (let index = 0; index < 1_000; index += 1) { + runtime.onPtySpawned(`pty-headless-${index}`, `incarnation-${index}`, { + awaitsRegistration: false + }) + runtime.onPtyExit(`pty-headless-${index}`, 0, `incarnation-${index}`) + } + + expect(internals.earlyExitedPtyIncarnations.size).toBe(0) + expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0) + }) + + it('fences an early-exited replacement even when its pane already exists', () => { + const runtime = new OrcaRuntimeService() + runtime.attachWindow(1) + syncSplit(runtime) + runtime.registerPty('pty-left', WORKTREE_ID, null, { + tabId: 'tab', + leafId: 'left', + incarnationId: 'incarnation-old' + }) + + runtime.onPtySpawned('pty-left', 'incarnation-replacement') + runtime.onPtyExit('pty-left', 0, 'incarnation-replacement') + + expect(() => + runtime.assertPtyRegistrationAllowed('pty-left', 'incarnation-replacement') + ).toThrow('agent_session_exited_during_start') + runtime.releaseRejectedPtyRegistrationFence('pty-left', 'incarnation-replacement') + const internals = runtime as unknown as { + earlyExitedPtyIncarnations: Map + pendingPtyRegistrationIncarnations: Map + } + expect(internals.earlyExitedPtyIncarnations.size).toBe(0) + expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0) + }) + + it('retires the exact split leaf and rejects a stale renderer resurrection', async () => { + const runtime = new OrcaRuntimeService() + runtime.attachWindow(1) + const staleSnapshot = makeSplitSnapshot() + syncSplit(runtime, staleSnapshot) + + runtime.onPtyExit('pty-left', 0) + + expect(await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).toMatchObject({ + activeTabId: 'tab::right', + tabs: [ + { + id: 'tab::right', + status: 'ready', + terminal: expect.stringMatching(/^term_/), + isActive: true, + parentLayout: { + root: { type: 'leaf', leafId: 'right' }, + activeLeafId: 'right', + expandedLeafId: null, + ptyIdsByLeafId: { right: 'pty-right' } + } + } + ] + }) + + syncSplit(runtime, { ...staleSnapshot, snapshotVersion: 2 }) + + const afterStaleFrame = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(afterStaleFrame.tabs.map((tab) => tab.id)).toEqual(['tab::right']) + }) + + it('rejects one stale shared-PTY surface without removing its live sibling', () => { + const session = makePersistedSplitSession() + session.tabsByWorktree[WORKTREE_ID]![0]!.ptyId = 'pty-shared' + session.terminalLayoutsByTabId.tab = { + root: { type: 'leaf', leafId: 'right' }, + activeLeafId: 'right', + expandedLeafId: null, + ptyIdsByLeafId: { right: 'pty-shared' } + } + session.terminalPtyIncarnationsByPaneKey = { 'tab:right': 'incarnation-current' } + session.terminalTopologyRevisionByRepoId = { [REPO_ID]: 1 } + const runtime = new OrcaRuntimeService({ getWorkspaceSession: () => session } as never) + runtime.attachWindow(1) + runtime.registerPty('pty-shared', WORKTREE_ID, null, { + tabId: 'tab', + leafId: 'right', + incarnationId: 'incarnation-current' + }) + const snapshot = makeSplitSnapshot() + const incoming = { + ...snapshot, + tabs: snapshot.tabs.map((tab) => + tab.type === 'terminal' + ? { + ...tab, + ptyId: 'pty-shared', + parentLayout: tab.parentLayout + ? { + ...tab.parentLayout, + ptyIdsByLeafId: { left: 'pty-shared', right: 'pty-shared' } + } + : undefined + } + : tab + ) + } + type IncomingTerminalTab = Extract<(typeof incoming.tabs)[number], { type: 'terminal' }> + const rightTab = incoming.tabs.find( + (tab): tab is IncomingTerminalTab => tab.type === 'terminal' && tab.leafId === 'right' + )! + const hostSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...incoming, + activeTabId: rightTab.id, + tabs: [ + { + ...rightTab, + parentLayout: { + root: { type: 'leaf', leafId: 'right' }, + activeLeafId: 'right', + expandedLeafId: null, + ptyIdsByLeafId: { right: 'pty-shared' } + } + } + ] + } + + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: 'right', + layout: { type: 'leaf', leafId: 'right' } + } + ], + leaves: [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + leafId: 'right', + paneRuntimeId: 2, + ptyId: 'pty-shared' + } + ], + mobileSessionTabs: [hostSnapshot] + }) + ;( + runtime as unknown as { + mobileSessionTabsByWorktree: Map + } + ).mobileSessionTabsByWorktree.set(WORKTREE_ID, hostSnapshot) + + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: 'right', + layout: incoming.tabs[0]?.type === 'terminal' ? incoming.tabs[0].parentLayout!.root : null + } + ], + leaves: [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + leafId: 'left', + paneRuntimeId: 1, + ptyId: 'pty-shared' + }, + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + leafId: 'right', + paneRuntimeId: 2, + ptyId: 'pty-shared' + } + ], + mobileSessionTabs: [incoming] + }) + + const internalSnapshot = ( + runtime as unknown as { + mobileSessionTabsByWorktree: Map + } + ).mobileSessionTabsByWorktree.get(WORKTREE_ID) + expect(internalSnapshot?.tabs).toEqual([ + expect.objectContaining({ id: 'tab::right', ptyId: 'pty-shared' }) + ]) + }) + + it('honors a legacy persisted tombstone before its first migration write', async () => { + const session = makePersistedSplitSession() + session.tabsByWorktree[WORKTREE_ID]![0]!.ptyId = 'pty-right' + session.terminalLayoutsByTabId.tab = { + root: { type: 'leaf', leafId: 'right' }, + activeLeafId: 'right', + expandedLeafId: null, + ptyIdsByLeafId: { right: 'pty-right' } + } + Object.assign(session, { + terminalSurfaceTombstonesByPaneKey: { + 'tab:left': { + worktreeId: WORKTREE_ID, + parentTabId: 'tab', + leafId: 'left', + ptyId: 'pty-left', + incarnationId: 'incarnation-left', + retiredAt: 42 + } + } + }) + const runtime = new OrcaRuntimeService({ getWorkspaceSession: () => session } as never) + runtime.attachWindow(1) + runtime.registerPty('pty-right', WORKTREE_ID, null, { + tabId: 'tab', + leafId: 'right', + incarnationId: 'incarnation-right' + }) + + syncSplit(runtime) + + const tabs = (await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs + expect(tabs).toHaveLength(1) + expect(tabs[0]).toMatchObject({ type: 'terminal', ptyId: 'pty-right' }) + }) + + it('publishes the host-rebased layout after a stale client pane update', async () => { + let session = makePersistedSplitSession() + session.tabsByWorktree[WORKTREE_ID]![0]!.ptyId = 'pty-right' + session.terminalLayoutsByTabId.tab = { + root: { type: 'leaf', leafId: 'right' }, + activeLeafId: 'right', + expandedLeafId: null, + ptyIdsByLeafId: { right: 'pty-right' } + } + Object.assign(session, { + terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } + }) + const runtime = new OrcaRuntimeService({ + getWorkspaceSession: () => session, + setWorkspaceSession: (incoming: WorkspaceSessionState) => { + session = sanitizeWorkspaceSessionTerminalRetirements(incoming, session) + } + } as never) + + await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + await runtime.updateMobileSessionPaneLayout(`id:${WORKTREE_ID}`, { + tabId: 'tab', + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'left' }, + second: { type: 'leaf', leafId: 'right' } + }, + expandedLeafId: null, + titlesByLeafId: { right: 'Survivor' } + }) + + const tabs = (await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs + expect(tabs).toHaveLength(1) + expect(tabs[0]).toMatchObject({ + type: 'terminal', + ptyId: 'pty-right', + parentLayout: { + root: { type: 'leaf', leafId: 'right' } + } + }) + }) + + it('retires a permanently exited surface despite a stale sleeping record', async () => { + const session = { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { 'tab:left': {} as never } + } + const runtime = new OrcaRuntimeService({ + getWorkspaceSession: () => session, + setWorkspaceSession: vi.fn(), + flushOrThrow: vi.fn() + } as never) + runtime.attachWindow(1) + syncSplit(runtime) + + runtime.onPtyExit('pty-left', 0) + + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(result.tabs.find((tab) => tab.id === 'tab::left')).toBeUndefined() + expect(result.tabs.find((tab) => tab.id === 'tab::right')).toMatchObject({ + status: 'ready' + }) + }) + + it('ignores a delayed exit from an older incarnation of a reused PTY id', async () => { + const setWorkspaceSession = vi.fn() + const runtime = new OrcaRuntimeService({ + getWorkspaceSession: () => makePersistedSplitSession(), + setWorkspaceSession + } as never) + runtime.attachWindow(1) + syncSplit(runtime) + runtime.registerPty('pty-left', WORKTREE_ID, null, { + tabId: 'tab', + leafId: 'left', + incarnationId: 'incarnation-a' + }) + runtime.registerPty('pty-left', WORKTREE_ID, null, { + tabId: 'tab', + leafId: 'left', + incarnationId: 'incarnation-b' + }) + + runtime.onPtyExit('pty-left', 0, 'incarnation-a') + + expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([ + expect.objectContaining({ id: 'tab::left', status: 'ready' }), + expect.objectContaining({ id: 'tab::right', status: 'ready' }) + ]) + expect(setWorkspaceSession).not.toHaveBeenCalled() + }) + + it('retires a durable surface after reconnect proves a newer incarnation', async () => { + const session = makePersistedSplitSession() + const setWorkspaceSession = vi.fn() + const runtime = new OrcaRuntimeService({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow: vi.fn() + } as never) + runtime.attachWindow(1) + syncSplit(runtime) + runtime.registerPty('pty-left', WORKTREE_ID, null, { + tabId: 'tab', + leafId: 'left', + incarnationId: 'incarnation-before-reconnect' + }) + + runtime.acceptPtyIncarnationForExit('pty-left', 'incarnation-after-reconnect') + runtime.onPtyExit('pty-left', 0, 'incarnation-after-reconnect') + + expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([ + expect.objectContaining({ id: 'tab::right', status: 'ready' }) + ]) + expect(setWorkspaceSession).toHaveBeenCalledWith( + expect.objectContaining({ + terminalLayoutsByTabId: { + tab: expect.objectContaining({ + root: { type: 'leaf', leafId: 'right' }, + ptyIdsByLeafId: { right: 'pty-right' } + }) + } + }) + ) + }) + + it('publishes only same-repo retirements individually accepted by persistence', async () => { + let session = makePersistedSplitSession() + session.terminalLayoutsByTabId.tab.ptyIdsByLeafId = { + left: 'pty-shared', + right: 'pty-shared' + } + session.tabsByWorktree[WORKTREE_ID]![0]!.ptyId = 'pty-shared' + session.terminalPtyIncarnationsByPaneKey = { + 'tab:left': 'incarnation-exiting', + 'tab:right': 'incarnation-newer' + } + const setWorkspaceSession = vi.fn((next: WorkspaceSessionState) => { + session = next + }) + const runtime = new OrcaRuntimeService({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow: vi.fn() + } as never) + runtime.attachWindow(1) + const snapshot = makeSplitSnapshot() + const sharedSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + tabs: snapshot.tabs.map((tab) => + tab.type === 'terminal' + ? { + ...tab, + ptyId: 'pty-shared', + parentLayout: tab.parentLayout + ? { + ...tab.parentLayout, + ptyIdsByLeafId: { left: 'pty-shared', right: 'pty-shared' } + } + : undefined + } + : tab + ) + } + syncSplit(runtime, sharedSnapshot) + runtime.registerPty('pty-shared', WORKTREE_ID, null, { + tabId: 'tab', + leafId: 'left', + incarnationId: 'incarnation-exiting' + }) + const published: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((event) => published.push(event)) + + runtime.onPtyExit('pty-shared', 0, 'incarnation-exiting') + + expect(session.terminalLayoutsByTabId.tab).toMatchObject({ + root: { type: 'leaf', leafId: 'right' }, + ptyIdsByLeafId: { right: 'pty-shared' } + }) + expect(session.terminalPtyIncarnationsByPaneKey).toEqual({ + 'tab:right': 'incarnation-newer' + }) + expect(published.at(-1)?.tabs).toEqual([ + expect.objectContaining({ + ptyId: 'pty-shared', + parentLayout: expect.objectContaining({ root: { type: 'leaf', leafId: 'right' } }) + }) + ]) + expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([ + expect.objectContaining({ + ptyId: 'pty-shared', + parentLayout: expect.objectContaining({ root: { type: 'leaf', leafId: 'right' } }) + }) + ]) + expect(setWorkspaceSession).toHaveBeenCalledOnce() + unsubscribe() + }) + + it('de-persists an exact surface even when there is no mobile snapshot', () => { + const session = makePersistedSplitSession() + const setWorkspaceSession = vi.fn() + const flushOrThrow = vi.fn() + const runtime = new OrcaRuntimeService({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: 'left', + layout: { type: 'leaf', leafId: 'left' } + } + ], + leaves: [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + leafId: 'left', + paneRuntimeId: 1, + ptyId: 'pty-left' + } + ] + }) + runtime.registerPty('pty-left', WORKTREE_ID, null, { + tabId: 'tab', + leafId: 'left', + incarnationId: 'incarnation-a' + }) + + runtime.onPtyExit('pty-left', 0, 'incarnation-a') + + expect(setWorkspaceSession).toHaveBeenCalledWith( + expect.objectContaining({ + terminalLayoutsByTabId: { + tab: expect.objectContaining({ + root: { type: 'leaf', leafId: 'right' }, + ptyIdsByLeafId: { right: 'pty-right' } + }) + }, + terminalSurfaceTombstonesByPaneKey: {}, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } + }) + ) + expect(flushOrThrow).toHaveBeenCalledOnce() + }) + + it('does not publish absence when the durable retirement flush fails', async () => { + const session = makePersistedSplitSession() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const runtime = new OrcaRuntimeService({ + getWorkspaceSession: () => session, + setWorkspaceSession: vi.fn(), + flushOrThrow: vi.fn(() => { + throw new Error('disk unavailable') + }) + } as never) + runtime.attachWindow(1) + syncSplit(runtime) + runtime.registerPty('pty-left', WORKTREE_ID, null, { + tabId: 'tab', + leafId: 'left', + incarnationId: 'incarnation-a' + }) + const events: unknown[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((event) => events.push(event)) + + runtime.onPtyExit('pty-left', 0, 'incarnation-a') + + expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([ + expect.objectContaining({ id: 'tab::left' }), + expect.objectContaining({ id: 'tab::right' }) + ]) + expect(events).toEqual([]) + expect(errorSpy).toHaveBeenCalledWith( + '[runtime] failed to persist terminal retirement:', + expect.any(Error) + ) + unsubscribe() + errorSpy.mockRestore() + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 49d7a7ec80a..bef47413a9b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -93,6 +93,10 @@ import { import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { makePaneKey } from '../../shared/stable-pane-id' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' +import type { + AgentSessionExecutionClaim, + AgentSessionSurfaceBinding +} from '../../shared/agent-session-host-authority' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' import { RpcDispatcher } from './rpc/dispatcher' import type { RpcRequest } from './rpc/core' @@ -10537,6 +10541,153 @@ describe('OrcaRuntimeService', () => { ) }) + it('does not register or publish a PTY incarnation that exited before spawn resolved', async () => { + const runtime = new OrcaRuntimeService(store) + const tabId = '11111111-1111-4111-8111-111111111111' + const leafId = '22222222-2222-4222-8222-222222222222' + runtime.setPtyController({ + spawn: vi.fn(async () => { + runtime.beginPtyRegistration('pty-exited-during-start', 'incarnation-exited-during-start') + runtime.onPtyExit('pty-exited-during-start', 0, 'incarnation-exited-during-start') + return { + id: 'pty-exited-during-start', + incarnationId: 'incarnation-exited-during-start' + } + }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex', + presentation: 'background', + tabId, + leafId, + persistHostSessionBinding: true + }) + ).rejects.toThrow('agent_session_exited_during_start') + await expect(runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)).resolves.toMatchObject({ + terminals: [] + }) + await expect(runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).resolves.toMatchObject({ + tabs: [] + }) + const internals = runtime as unknown as { + handleByPtyId: Map + ptysById: Map + } + expect(internals.handleByPtyId.has('pty-exited-during-start')).toBe(false) + expect(internals.ptysById.has('pty-exited-during-start')).toBe(false) + }) + + it('adopts the execution owner canonical surface for repeated structured resumes', async () => { + let canonicalOwner: + | { + claim: AgentSessionExecutionClaim + generation: string + phase: 'live' + ptyId: string + surface: AgentSessionSurfaceBinding + } + | undefined + const spawn = vi.fn(async (options) => { + const ensure = options.agentSessionEnsure + expect(ensure).toBeDefined() + canonicalOwner ??= { + claim: ensure!.claim, + generation: 'generation-1', + phase: 'live', + ptyId: 'pty-claimed', + surface: ensure!.surface + } + return { + id: 'pty-claimed', + agentSessionEnsure: { + disposition: spawn.mock.calls.length === 1 ? ('created' as const) : ('adopted' as const), + owner: canonicalOwner + } + } + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const request = { + kind: 'explicit' as const, + worktree: `id:${TEST_WORKTREE_ID}`, + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'provider-session-1' } + } + const first = await runtime.ensureAgentSession(request) + const second = await runtime.ensureAgentSession(request) + + expect(first.disposition).toBe('created') + expect(second.disposition).toBe('adopted') + expect(second.terminal).toMatchObject({ + handle: first.terminal.handle, + tabId: first.terminal.tabId, + paneKey: first.terminal.paneKey + }) + expect(spawn).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining("'resume' 'provider-session-1'"), + agentSessionEnsure: expect.objectContaining({ + claim: expect.objectContaining({ agent: 'codex' }) + }) + }) + ) + }) + + it('builds structured fresh drafts with supported launch preferences on the host', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent-draft' }) + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + agentCmdOverrides: { claude: 'host-claude' }, + agentDefaultArgs: { claude: '--host-default' }, + agentDefaultEnv: { claude: { HOST_PROFILE: 'true' } } + }) + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createAgentSession( + { + clientOperationId: `${Date.now()}-${'ab'.repeat(16)}`, + worktree: `id:${TEST_WORKTREE_ID}`, + agent: 'claude', + prompt: 'review before sending', + promptDelivery: 'draft', + agentArgs: '--permission-mode plan', + launchPreferences: { model: 'opus', effort: 'high' } + }, + { clientId: 'renderer-1', clientKind: 'runtime' } + ) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringMatching( + /^host-claude '--model' 'opus'.*'--permission-mode' 'plan'.*--prefill 'review before sending'/ + ), + env: expect.objectContaining({ HOST_PROFILE: 'true' }) + }) + ) + expect(spawn.mock.calls[0]?.[0]?.command).not.toContain('--host-default') + }) + it('applies Settings agent defaults to bare agent command terminal creates', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtimeStore = { @@ -17932,7 +18083,7 @@ describe('OrcaRuntimeService', () => { ) }) - it('does not publish exited saved PTY bindings as ready terminal streams', async () => { + it('retires exited saved PTY bindings instead of publishing a pending ghost', async () => { const runtime = new OrcaRuntimeService(store) runtime.attachWindow(1) runtime.syncWindowGraph(1, { @@ -17966,14 +18117,12 @@ describe('OrcaRuntimeService', () => { const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) - expect(result.tabs).toEqual([ - expect.objectContaining({ - type: 'terminal', - id: 'tab-1::pane:1', - status: 'pending-handle', - terminal: null - }) - ]) + expect(result).toMatchObject({ + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) }) it('resolves mobile terminal surfaces by exact split leaf', async () => { @@ -19779,7 +19928,7 @@ describe('OrcaRuntimeService', () => { expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined() }) - it('pushes PTY-backed mobile session readiness changes when a server PTY exits', async () => { + it('pushes PTY-backed mobile session retirement when a server PTY exits', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ @@ -19801,14 +19950,10 @@ describe('OrcaRuntimeService', () => { expect(events).toEqual([ expect.objectContaining({ - tabs: [ - expect.objectContaining({ - type: 'terminal', - parentTabId: 'laptop-tab', - status: 'pending-handle', - terminal: null - }) - ] + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] }) ]) await expect(runtime.readTerminal(laptopTerminal.handle)).resolves.toMatchObject({ @@ -22259,6 +22404,53 @@ describe('OrcaRuntimeService', () => { expect(listProcesses).toHaveBeenCalledTimes(1) }) + it('keeps a live persisted PTY whose pane binding has not reconnected yet', async () => { + const ptyId = 'persisted-pty' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId }) + } + }) + ) + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: TEST_WORKTREE_PATH, title: 'Live' }] + }) + + const result = await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { + reason: 'cleanup' + }) + + expect(result).toEqual({ + closed: true, + refused: true, + refusalReason: 'live-host-pty', + snapshotRepublished: true + }) + expect(kill).not.toHaveBeenCalled() + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() + }) + it('keeps an explicit user close destructive while the PTY is live', async () => { const { runtime, closeTerminalTab } = makeAdoptedLiveTabRuntime() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8d911cc8f39..a6cf911cdda 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -35,6 +35,27 @@ import { type AgentStatusOrchestrationContext, type AgentStatusEntry } from '../../shared/agent-status-types' +import type { + AgentSessionClaimedSpawnResult, + AgentSessionExecutionClaim, + AgentSessionSurfaceBinding, + AgentLaunchPreferences, + RuntimeAgentSessionRpcCaller, + RuntimeCreateAgentSessionRequest, + RuntimeCreateAgentSessionResult, + RuntimeEnsureAgentSessionRequest, + RuntimeEnsureAgentSessionResult +} from '../../shared/agent-session-host-authority' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS, + parseAgentSessionOperationTimestamp +} from '../../shared/agent-session-host-authority' +import { + canonicalizeAgentSessionIdentity, + createEphemeralAgentSessionClaimSigner, + type AgentSessionClaimSigner +} from './agent-session-claim-identity' import { hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, @@ -205,6 +226,7 @@ import type { TerminalPaneSplitSource } from '../../shared/feature-education-tel import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, WORKTREE_ID_SEPARATOR, + getRepoIdFromWorktreeId, splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' @@ -234,7 +256,12 @@ import { MAX_QUICK_COMMANDS, type TerminalQuickCommandMutation } from '../../shared/terminal-quick-commands' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import { + buildAgentDraftLaunchPlan, + buildAgentResumeStartupPlan, + buildAgentStartupPlan +} from '../../shared/tui-agent-startup' import { repoIsRemote } from '../../shared/agent-launch-remote' import { isAgentForegroundWrapperProcess, @@ -385,6 +412,15 @@ import { buildHeadlessTabGroupMove, buildHeadlessTabGroupSplit } from './headless-tab-group-split-layout' +import { + retireTerminalSurfacesFromSnapshot, + type RetiredTerminalSurface +} from './mobile-session-terminal-retirement' +import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' +import { + advanceTerminalTopologyRevision, + hasHostAuthoritativeTerminalMembership +} from './workspace-session-terminal-membership-authority' import { RuntimeEmulatorCommands, setEmulatorBridge } from './orca-runtime-emulator' import type { EmulatorBridge } from '../emulator/emulator-bridge' import { RuntimeFileCommands } from './orca-runtime-files' @@ -1058,6 +1094,7 @@ function isCursorAgentOrchestrationTarget( type RuntimePtyWorktreeRecord = { ptyId: string + incarnationId: PtyIncarnationId | null worktreeId: string connectionId: string | null // Why: a Windows host can own both native and WSL panes; preamble command @@ -1119,8 +1156,16 @@ type TerminalCreateOptions = { tabId?: string leafId?: string sessionId?: string - persistHostSessionBinding?: boolean preAllocatedHandle?: string + persistHostSessionBinding?: boolean + // Why: only the host-derived structured resume path may attach provider + // identity; opaque terminal.create commands remain ordinary shells. + agentSessionClaim?: AgentSessionExecutionClaim + agentSessionCreateOperationId?: string + signal?: AbortSignal + // Why: idempotent create operations must retain their fence after the PTY + // exists, even if later runtime publication fails. + onPtySpawnCommitted?: () => void // Why: the headless mobile-session create publishes its own authoritative // snapshot (with the correct target group) right after spawn. Skip the // intermediate pty-backed publish so the new tab doesn't briefly flash in @@ -1136,6 +1181,31 @@ function mergeTerminalEnvDeletionKeys( return merged.length > 0 ? merged : undefined } +type AgentSessionCreateOperation = { + fingerprint: string + promise: Promise +} + +function isAgentSessionOperationOutcomeUnknown(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'agentSessionOperationOutcome' in error && + error.agentSessionOperationOutcome === 'unknown' + ) +} + +const AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT = 512 +const AGENT_SESSION_OPERATION_GLOBAL_LIMIT = 4_096 + +function deterministicAgentSessionUuid(seed: string): string { + const hex = createHash('sha256').update(seed).digest('hex').slice(0, 32).split('') + hex[12] = '4' + hex[16] = ((Number.parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16) + const value = hex.join('') + return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}` +} + type PtyForegroundAgentRefresh = { promise: Promise startedAfterTitleObservation: number @@ -1310,7 +1380,19 @@ type RuntimePtyController = { sessionId?: string persistHostSessionBinding?: boolean terminalColorQueryReplies?: { foreground?: string; background?: string } - }): Promise<{ id: string; wslDistro?: string }> + agentSessionEnsure?: { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + } + agentSessionCreateOperationId?: string + signal?: AbortSignal + onPtySpawnCommitted?: () => void + }): Promise<{ + id: string + incarnationId?: PtyIncarnationId + wslDistro?: string + agentSessionEnsure?: AgentSessionClaimedSpawnResult + }> write(ptyId: string, data: string): boolean kill(ptyId: string): boolean stopAndWait?( @@ -2322,6 +2404,14 @@ export class OrcaRuntimeService { listener: (snapshot: RuntimeMobileSessionTabsResult) => void clientNavigationId?: string }>() + // Why: one watermark per repo replaces per-closed-pane fences while preserving stale-write safety. + private terminalTopologyRevisionByRepoId = new Map() + // Why: provider exit can beat surface registration; that exact dead incarnation must never publish. + private earlyExitedPtyIncarnations = new Map() + private pendingPtyRegistrationIncarnations = new Map() + // Why: exact-stop is the current sleep transaction boundary; its exit must + // leave the renderer's intentional sleeping surface available for wake. + private intentionalHandlelessPtyStops = new Map() // Why: coalesces title/status-driven session.tabs emits so spinner churn // doesn't fan out (and per-client JSON.stringify) a snapshot several times a // second. Emit reads the latest snapshot, so only the freshest version ships. @@ -2728,6 +2818,8 @@ export class OrcaRuntimeService { private readonly prepareAiVaultSessionResumeFn: | ((args: AiVaultPrepareSessionResumeArgs) => Promise) | null + private readonly agentSessionClaimSigner: AgentSessionClaimSigner + private readonly agentSessionCreateOperations = new Map() private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null @@ -2766,6 +2858,7 @@ export class OrcaRuntimeService { ) => Promise buildAgentHookPtyEnv?: () => Record getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus + agentSessionClaimSigner?: AgentSessionClaimSigner } ) { this.store = store @@ -2795,6 +2888,8 @@ export class OrcaRuntimeService { this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') this.prepareAiVaultSessionResumeFn = deps?.prepareAiVaultSessionResume ?? null + this.agentSessionClaimSigner = + deps?.agentSessionClaimSigner ?? createEphemeralAgentSessionClaimSigner(this.runtimeId) this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null // Why: the ConPTY spawn mark can land after daemon stream data already // created this PTY's emulator; the mark retrofits the DA1 override here @@ -3487,6 +3582,7 @@ export class OrcaRuntimeService { const previousTabs = this.tabs const previousLeaves = this.leaves this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) + const lifecycleLeaves = this.reconcileMobileSessionRetirementFences(graph.leaves) const changedMobileWorktrees = this.syncMobileSessionTabs(graph.mobileSessionTabs) const nextLeaves = new Map() const graphSyncedAt = this.nextTitleObservationSequence() @@ -3494,7 +3590,7 @@ export class OrcaRuntimeService { // Why: renderer reloads can briefly republish the same leaf with no ptyId; // keep live CLI handles usable while the UI graph rebuilds. const preserveLivePtysDuringReload = this.graphStatus === 'reloading' - for (const leaf of graph.leaves) { + for (const leaf of lifecycleLeaves) { const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) const existing = this.leaves.get(leafKey) const ptyId = @@ -4323,6 +4419,11 @@ export class OrcaRuntimeService { split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } } ): void { + if ( + !this.isMobileSessionSurfaceMembershipAllowed(worktreeId, args.tabId, args.leafId, pty.ptyId) + ) { + return + } const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const ownerAgent = pty.launchAgent ?? pty.foregroundAgent const title = normalizeCompatibleAgentTitleForOwner( @@ -4456,6 +4557,181 @@ export class OrcaRuntimeService { } } + private mobileSessionSnapshotHasSurface( + worktreeId: string, + parentTabId: string, + leafId: string + ): boolean { + return Boolean( + this.mobileSessionTabsByWorktree + .get(worktreeId) + ?.tabs.some( + (tab) => + tab.type === 'terminal' && tab.parentTabId === parentTabId && tab.leafId === leafId + ) + ) + } + + private isMobileSessionSurfaceMembershipAllowed( + worktreeId: string, + parentTabId: string, + leafId: string, + candidatePtyId: string | null | undefined + ): boolean { + const session = this.store?.getWorkspaceSession?.() + const repoId = getRepoIdFromWorktreeId(worktreeId) + if ( + !hasHostAuthoritativeTerminalMembership(session, worktreeId) && + (session !== undefined || !this.terminalTopologyRevisionByRepoId.has(repoId)) + ) { + return true + } + if (this.mobileSessionSnapshotHasSurface(worktreeId, parentTabId, leafId)) { + return true + } + if (!candidatePtyId) { + return false + } + const pty = this.ptysById.get(candidatePtyId) + const pane = parsePaneKey(pty?.paneKey ?? '') + return Boolean( + pty?.connected && + pty.worktreeId === worktreeId && + pty.tabId === parentTabId && + pane?.leafId === leafId + ) + } + + private reconcileMobileSessionRetirementFences( + leaves: readonly RuntimeSyncedLeaf[] + ): RuntimeSyncedLeaf[] { + return leaves.filter((leaf) => + this.isMobileSessionSurfaceMembershipAllowed( + leaf.worktreeId, + leaf.tabId, + leaf.leafId, + leaf.ptyId + ) + ) + } + + private applyMobileSessionRetirementFences( + snapshot: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionTabsSnapshot { + let next = snapshot + for (const tab of snapshot.tabs) { + if ( + tab.type !== 'terminal' || + this.isMobileSessionSurfaceMembershipAllowed( + snapshot.worktree, + tab.parentTabId, + tab.leafId, + tab.ptyId + ) + ) { + continue + } + const retired = retireTerminalSurfacesFromSnapshot({ + snapshot: next, + ptyId: tab.ptyId ?? '', + exactSurfaces: [{ parentTabId: tab.parentTabId, leafId: tab.leafId }], + exactOnly: true + }) + if (retired) { + next = retired.snapshot + } + } + return next + } + + private retireMobileSessionSurfacesForPty( + ptyId: string, + incarnationId: string, + exactSurfaces: readonly Pick[] + ): void { + const retiredSurfaceByKey = new Map() + for (const surface of exactSurfaces) { + retiredSurfaceByKey.set(`${surface.worktreeId}\0${surface.parentTabId}\0${surface.leafId}`, { + ...surface, + ptyId, + incarnationId + }) + } + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + const retired = retireTerminalSurfacesFromSnapshot({ + snapshot, + ptyId, + exactSurfaces: exactSurfaces.filter((surface) => surface.worktreeId === worktreeId) + }) + if (!retired) { + continue + } + for (const surface of retired.retired) { + retiredSurfaceByKey.set( + `${surface.worktreeId}\0${surface.parentTabId}\0${surface.leafId}`, + { ...surface, incarnationId } + ) + } + } + const retiredSurfaces = [...retiredSurfaceByKey.values()] + if (retiredSurfaces.length === 0) { + return + } + let publishableRetiredSurfaces = retiredSurfaces + const session = this.store?.getWorkspaceSession?.() + if (session) { + // Why: publishing absence before its host membership fence is durable lets a crash or + // stale renderer write resurrect the retired surface. + if (!this.store?.setWorkspaceSession || !this.store.flushOrThrow) { + return + } + let nextSession = session + const acceptedSurfaces: RetiredTerminalSurface[] = [] + for (const surface of retiredSurfaces) { + const candidate = retireTerminalSurfaceFromPersistence(nextSession, surface) + if (candidate !== nextSession) { + acceptedSurfaces.push(surface) + nextSession = candidate + } + } + if (acceptedSurfaces.length === 0) { + return + } + try { + this.store.setWorkspaceSession(nextSession) + this.store.flushOrThrow() + } catch (error) { + console.error('[runtime] failed to persist terminal retirement:', error) + return + } + // Why: one repo epoch can cover multiple exits, but only surfaces individually accepted by persistence may disappear. + publishableRetiredSurfaces = acceptedSurfaces + } else { + for (const surface of retiredSurfaces) { + const repoId = getRepoIdFromWorktreeId(surface.worktreeId) + this.terminalTopologyRevisionByRepoId.set( + repoId, + (this.terminalTopologyRevisionByRepoId.get(repoId) ?? 0) + 1 + ) + } + } + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + const retired = retireTerminalSurfacesFromSnapshot({ + snapshot, + ptyId, + exactSurfaces: publishableRetiredSurfaces.filter( + (surface) => surface.worktreeId === worktreeId + ), + // Why: discovery is broad by PTY id, but publication may remove only surfaces whose durable retirement was accepted. + exactOnly: true + }) + if (retired) { + this.mobileSessionTabsByWorktree.set(worktreeId, retired.snapshot) + this.notifyMobileSessionTabsChanged(worktreeId) + } + } + } + private buildHeadlessMobileSessionTerminalTabs( worktreeId: string, persistedTabs: readonly TerminalTab[] @@ -4472,7 +4748,7 @@ export class OrcaRuntimeService { if (leafIds.length === 0) { leafIds.push(this.deriveHeadlessLegacyTerminalLeafId(tab.id)) } - return leafIds.map((leafId) => { + return leafIds.flatMap((leafId) => { const ptyId = layout?.ptyIdsByLeafId?.[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null) const title = @@ -4481,21 +4757,23 @@ export class OrcaRuntimeService { tab.title?.trim() || tab.defaultTitle?.trim() || `Terminal ${index + 1}` - return { - type: 'terminal' as const, - id: `${tab.id}::${leafId}`, - parentTabId: tab.id, - leafId, - title, - ...(ptyId ? { ptyId } : {}), - ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), - ...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}), - ...(layout ? { parentLayout: this.cloneTerminalLayoutSnapshot(layout) } : {}), - ...(tab.color != null ? { color: tab.color } : {}), - ...(tab.isPinned ? { isPinned: true } : {}), - ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), - isActive: this.isPersistedTerminalLeafActive(worktreeId, tab.id, leafId, layout) - } + return [ + { + type: 'terminal' as const, + id: `${tab.id}::${leafId}`, + parentTabId: tab.id, + leafId, + title, + ...(ptyId ? { ptyId } : {}), + ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), + ...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}), + ...(layout ? { parentLayout: this.cloneTerminalLayoutSnapshot(layout) } : {}), + ...(tab.color != null ? { color: tab.color } : {}), + ...(tab.isPinned ? { isPinned: true } : {}), + ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), + isActive: this.isPersistedTerminalLeafActive(worktreeId, tab.id, leafId, layout) + } + ] }) }) } @@ -4878,7 +5156,7 @@ export class OrcaRuntimeService { if (!result.closed) { throw new Error('tab_not_found') } - this.store.setWorkspaceSession(result.session) + this.store.setWorkspaceSession(advanceTerminalTopologyRevision(result.session, worktreeId)) return result.ptyIdsToKill } @@ -5381,8 +5659,18 @@ export class OrcaRuntimeService { // reads (and a still-synced leaf retains its record), so record // presence is not liveness — only `connected` counts, or a genuinely // dead tab never retires and the echo loops forever. - const leafHasConnectedPty = (leaf: RuntimeMobileSessionTerminalTab): boolean => - this.findPtyForMobileTerminalTab(worktreeId, leaf)?.connected === true + const leafHasConnectedPty = (leaf: RuntimeMobileSessionTerminalTab): boolean => { + const snapshotPtyIds = [ + leaf.ptyId, + leaf.parentLayout?.ptyIdsByLeafId?.[leaf.leafId] + ].filter((ptyId): ptyId is string => Boolean(ptyId)) + // Why: daemon discovery can prove the PTY live before its pane binding + // reconnects; missing metadata is never authority to retire it. + return ( + this.findPtyForMobileTerminalTab(worktreeId, leaf)?.connected === true || + snapshotPtyIds.some((ptyId) => observedPtyIds?.has(ptyId) === true) + ) + } if (parentLeaves.some(leafHasConnectedPty)) { // Why: when the echo addresses a dead leaf under a live sibling we // still refuse (every reachable close path below destroys the whole @@ -5730,8 +6018,15 @@ export class OrcaRuntimeService { ? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId) : args.tabId const resolvedArgs = { ...args, tabId: hostTabId } - this.persistHeadlessTerminalPaneLayout(resolvedArgs) - this.applyHeadlessTerminalPaneLayoutToSnapshot(worktreeId, resolvedArgs) + const acceptedLayout = this.persistHeadlessTerminalPaneLayout(resolvedArgs) + if (acceptedLayout) { + this.applyHeadlessTerminalPaneLayoutToSnapshot(worktreeId, { + tabId: hostTabId, + root: acceptedLayout.root, + expandedLeafId: acceptedLayout.expandedLeafId, + ...(acceptedLayout.titlesByLeafId ? { titlesByLeafId: acceptedLayout.titlesByLeafId } : {}) + }) + } return { updated: true } } @@ -5863,16 +6158,16 @@ export class OrcaRuntimeService { root: TerminalPaneLayoutNode | null expandedLeafId: string | null titlesByLeafId?: Record - }): void { + }): TerminalLayoutSnapshot | undefined { const session = this.store?.getWorkspaceSession?.() if (!session || !this.store?.setWorkspaceSession) { - return + return undefined } const existing = session.terminalLayoutsByTabId?.[args.tabId] if (!existing) { - return + return undefined } - this.store.setWorkspaceSession({ + const candidate = { ...session, terminalLayoutsByTabId: { ...session.terminalLayoutsByTabId, @@ -5883,7 +6178,13 @@ export class OrcaRuntimeService { ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) } } - }) + } + this.store.setWorkspaceSession(candidate) + // Why: persistence may reject stale membership while accepting its metadata; publish only that rebased layout. + return ( + this.store.getWorkspaceSession?.()?.terminalLayoutsByTabId[args.tabId] ?? + candidate.terminalLayoutsByTabId[args.tabId] + ) } private applyHeadlessTerminalPaneLayoutToSnapshot( @@ -6592,9 +6893,20 @@ export class OrcaRuntimeService { return false } - onPtySpawned(ptyId: string): void { + onPtySpawned( + ptyId: string, + incarnationId?: PtyIncarnationId, + options: { awaitsRegistration?: boolean } = {} + ): void { + if (options.awaitsRegistration !== false) { + // Why: surface absence cannot distinguish an in-flight admission from a completed headless lifecycle. + this.pendingPtyRegistrationIncarnations.set(ptyId, incarnationId ?? null) + } const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (pty) { + if (incarnationId) { + pty.incarnationId = incarnationId + } pty.connected = true pty.disconnectedAt = null } @@ -6609,9 +6921,10 @@ export class OrcaRuntimeService { ptyId: string, worktreeId: string, connectionId: string | null = null, - binding?: { tabId: string; leafId: string }, + binding?: { tabId: string; leafId: string; incarnationId?: PtyIncarnationId }, isWsl?: boolean ): void { + this.assertPtyDidNotExitBeforeRegistration(ptyId, binding?.incarnationId) // Why: record the renderer pane identity at spawn time so a stalled graph // sync can't hide that a live PTY already backs a pending mobile create. const paneKey = @@ -6622,8 +6935,18 @@ export class OrcaRuntimeService { connected: true, connectionId, ...(isWsl !== undefined ? { isWsl } : {}), - ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}) + ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}), + ...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {}) }) + const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) + if ( + pendingIncarnation === null || + pendingIncarnation === undefined || + binding?.incarnationId === undefined || + pendingIncarnation === binding.incarnationId + ) { + this.pendingPtyRegistrationIncarnations.delete(ptyId) + } // Why: the renderer's own PTY spawn is the reliable signal that the pending // mobile create's tab is live; publish its surface main-side (#7587). if (binding && paneKey) { @@ -6631,6 +6954,80 @@ export class OrcaRuntimeService { } } + assertPtyRegistrationAllowed(ptyId: string, incarnationId?: PtyIncarnationId): void { + // Why: the controller must reject an early exit before persisting bindings or handles. + this.assertPtyDidNotExitBeforeRegistration(ptyId, incarnationId) + } + + releaseRejectedPtyRegistrationFence( + ptyId: string, + candidateIncarnation?: PtyIncarnationId + ): void { + if (!this.earlyExitedPtyIncarnations.has(ptyId)) { + return + } + const exitedIncarnation = this.earlyExitedPtyIncarnations.get(ptyId) ?? null + if ( + exitedIncarnation === null || + candidateIncarnation === undefined || + exitedIncarnation === candidateIncarnation + ) { + // Why: the rejected spawn call was the fence's sole late publisher; retaining it leaks fresh PTY ids. + this.earlyExitedPtyIncarnations.delete(ptyId) + this.pendingPtyRegistrationIncarnations.delete(ptyId) + } + } + + beginPtyRegistration(ptyId: string, incarnationId?: PtyIncarnationId): void { + this.pendingPtyRegistrationIncarnations.set(ptyId, incarnationId ?? null) + } + + acceptPtyIncarnationForExit(ptyId: string, incarnationId: PtyIncarnationId): void { + const pty = this.ptysById.get(ptyId) + if (pty) { + // Why: a reconnect attach reply can prove the exit generation after stale local proof was cleared. + pty.incarnationId = incarnationId + } + } + + cancelPendingPtyRegistration(ptyId: string, incarnationId?: PtyIncarnationId): void { + const pending = this.pendingPtyRegistrationIncarnations.get(ptyId) + if ( + !this.pendingPtyRegistrationIncarnations.has(ptyId) || + (pending !== null && incarnationId !== undefined && pending !== incarnationId) + ) { + return + } + this.pendingPtyRegistrationIncarnations.delete(ptyId) + const exited = this.earlyExitedPtyIncarnations.get(ptyId) + if ( + exited === null || + exited === undefined || + incarnationId === undefined || + exited === incarnationId + ) { + this.earlyExitedPtyIncarnations.delete(ptyId) + } + } + + private assertPtyDidNotExitBeforeRegistration( + ptyId: string, + candidateIncarnation?: PtyIncarnationId + ): void { + if (this.earlyExitedPtyIncarnations.has(ptyId)) { + const exitedIncarnation = this.earlyExitedPtyIncarnations.get(ptyId) ?? null + const nextIncarnation = candidateIncarnation ?? null + if ( + exitedIncarnation === null || + nextIncarnation === null || + exitedIncarnation === nextIncarnation + ) { + throw new Error('agent_session_exited_during_start') + } + this.earlyExitedPtyIncarnations.delete(ptyId) + } + } + preparePtyExecutionContext( ptyId: string, wslDistro: string | null, @@ -6645,6 +7042,8 @@ export class OrcaRuntimeService { } if (options.resetIncarnation) { + // Why: an explicit new lifecycle supersedes an unidentifiable exit from the reused PTY id. + this.earlyExitedPtyIncarnations.delete(ptyId) this.disposeHeadlessTerminal(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) @@ -9927,8 +10326,54 @@ export class OrcaRuntimeService { } } - onPtyExit(ptyId: string, exitCode: number): void { + onPtyExit(ptyId: string, exitCode: number, exitIncarnationId?: PtyIncarnationId): void { + const pty = this.ptysById.get(ptyId) + if (exitIncarnationId && pty?.incarnationId && exitIncarnationId !== pty.incarnationId) { + return + } + const incarnationId = + exitIncarnationId ?? + pty?.incarnationId ?? + `runtime:${this.runtimeId}:${this.getPtyLifecycleGeneration(ptyId)}` this.advancePtyLifecycleGeneration(ptyId) + const exactSurfaceByKey = new Map< + string, + Pick + >() + for (const leaf of this.getLeavesForPty(ptyId)) { + exactSurfaceByKey.set(`${leaf.worktreeId}\0${leaf.tabId}\0${leaf.leafId}`, { + worktreeId: leaf.worktreeId, + parentTabId: leaf.tabId, + leafId: leaf.leafId + }) + } + const parsedPaneKey = parsePaneKey(pty?.paneKey ?? '') + if (pty?.tabId && parsedPaneKey) { + exactSurfaceByKey.set(`${pty.worktreeId}\0${pty.tabId}\0${parsedPaneKey.leafId}`, { + worktreeId: pty.worktreeId, + parentTabId: pty.tabId, + leafId: parsedPaneKey.leafId + }) + } + const exactSurfaces = [...exactSurfaceByKey.values()] + const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) + const exitMatchesPendingRegistration = + this.pendingPtyRegistrationIncarnations.has(ptyId) && + (pendingIncarnation === null || + exitIncarnationId === null || + exitIncarnationId === undefined || + pendingIncarnation === exitIncarnationId) + if (exitMatchesPendingRegistration) { + // Why: reused surfaces can look registered while their replacement incarnation still awaits admission. + this.earlyExitedPtyIncarnations.set( + ptyId, + exitIncarnationId ?? pendingIncarnation ?? pty?.incarnationId ?? null + ) + } + const intentionalStopIncarnation = this.intentionalHandlelessPtyStops.get(ptyId) + const preservesIntentionalHandlelessSurface = + this.intentionalHandlelessPtyStops.has(ptyId) && + (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) advertisedUrlWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) @@ -10003,14 +10448,19 @@ export class OrcaRuntimeService { this.remoteDesktopViewerRevisions.delete(ptyId) this.disposeHeadlessTerminal(ptyId) this.agentDetector?.onExit(ptyId) - const pty = this.ptysById.get(ptyId) if (pty) { pty.connected = false pty.disconnectedAt = Date.now() pty.lastExitCode = exitCode this.resolvePtyExitWaiters(pty, ptyId) this.pruneDisconnectedPtyTranscript(pty) + } + if (preservesIntentionalHandlelessSurface) { this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) + } else { + // Why: permanent process exit is absence, not a starting/sleeping tab. + // Retire before publishing so paired clients never persist a ghost. + this.retireMobileSessionSurfacesForPty(ptyId, incarnationId, exactSurfaces) } for (const leaf of this.getLeavesForPty(ptyId)) { @@ -14326,6 +14776,7 @@ export class OrcaRuntimeService { } const repo = await this.resolveRepoSelector(repoSelector) this.store.removeProject(repo.id) + this.terminalTopologyRevisionByRepoId.delete(repo.id) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(repo.id) invalidateAuthorizedRootsCache() @@ -19681,6 +20132,382 @@ export class OrcaRuntimeService { } } + private getAgentSessionExecutionNamespace( + workspace: TerminalWorkspaceLaunchScope, + agent: TuiAgent + ): { machine: string; principal: string; container: string; providerRoot: string } | null { + if (workspace.connectionId) { + // Why: SSH target ids are not execution-namespace proof. Preserve the + // legacy launch until an attested route can safely participate in claims. + return null + } + const wsl = parseWslUncPath(workspace.path) + const principal = + typeof process.getuid === 'function' + ? `uid:${process.getuid()}` + : `user:${process.env.USERNAME ?? ''}` + return { + machine: wsl ? 'wsl-host' : `native:${process.platform}`, + principal, + container: wsl ? `wsl:${wsl.distro.toLocaleLowerCase('en-US')}` : 'native', + // Why: merging account roots is conservative (it may conflict) and can + // never permit two TUIs to own one provider session. + providerRoot: `profile-default:${agent}` + } + } + + private async executionOwnerSupportsAgentSessionOperation( + workspace: TerminalWorkspaceLaunchScope, + operation: 'resume' | 'create', + signal?: AbortSignal + ): Promise { + const provider = workspace.connectionId + ? this.getSshProviderFn?.(workspace.connectionId) + : this.getLocalProvider() + if (!provider) { + // An unavailable route is not proof of an old owner; preserve the structured failure. + return true + } + const probe = + operation === 'resume' + ? provider.supportsAgentSessionClaims + : provider.supportsAgentSessionCreateOperations + if (!probe) { + // Local in-process PTYs need no wire negotiation; unknown SSH providers are legacy. + return workspace.connectionId === null + } + try { + return (await probe.call(provider, { signal })) === true + } catch { + // Why: this read-only check has not launched anything, so the old route remains safe. + return false + } + } + + private toAgentSessionOptions( + preferences: AgentLaunchPreferences | undefined + ): Record | undefined { + if (!preferences) { + return undefined + } + const options = { + ...(preferences.model ? { model: preferences.model } : {}), + ...(preferences.effort ? { effort: preferences.effort } : {}), + ...(preferences.mode ? { mode: preferences.mode } : {}) + } + return Object.keys(options).length > 0 ? options : undefined + } + + async ensureAgentSession( + request: RuntimeEnsureAgentSessionRequest, + _caller: RuntimeAgentSessionRpcCaller = {} + ): Promise { + if (request.kind === 'automatic') { + // Legacy renderer sleep records are migration evidence, not host authority. + throw new Error('agent_session_resume_not_authorized') + } + if (!this.store) { + throw new Error('runtime_unavailable') + } + const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) + const namespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) + if ( + !namespace || + !(await this.executionOwnerSupportsAgentSessionOperation(workspace, 'resume', _caller.signal)) + ) { + // Why: the renderer still holds the exact old request and may retry it before any side effect. + throw new Error('agent_session_legacy_required') + } + // Why: nested SSH paths belong to the execution owner, so compatibility selection must happen before local filesystem canonicalization. + const identity = canonicalizeAgentSessionIdentity(request.agent, request.providerSession) + const claim = this.agentSessionClaimSigner.createClaim({ + namespace, + identity, + canonicalWorktreeId: workspace.id + }) + const settings = this.store.getSettings() + if (!isTuiAgentEnabled(request.agent, settings.disabledTuiAgents)) { + throw new Error('Selected agent is disabled. Choose an enabled agent before resuming.') + } + const platform = this.getAgentLaunchPlatformForWorkspace(workspace) + const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : Boolean(workspace.connectionId) + const shell = resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) + const startup = buildAgentResumeStartupPlan({ + agent: request.agent, + providerSession: identity.providerSession, + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs: + request.agentArgs !== undefined + ? request.agentArgs + : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), + sessionOptions: this.toAgentSessionOptions(request.launchPreferences), + platform, + shell, + isRemote + }) + if (!startup) { + throw new Error('agent_session_identity_required') + } + if (workspace.connectionId) { + await this.markRemoteWorkspaceTrustedForAgent( + request.agent, + workspace.connectionId, + workspace.path + ) + } else { + this.markLocalWorkspaceTrustedForAgent(request.agent, workspace.path) + } + if (_caller.signal?.aborted) { + throw new Error('client_disconnected') + } + const terminal = await this.createTerminal(`id:${workspace.id}`, { + command: startup.launchCommand, + env: startup.env, + launchConfig: startup.launchConfig, + launchAgent: request.agent, + presentation: request.presentation ?? 'background', + tabId: request.placement?.tabId, + leafId: request.placement?.leafId, + persistHostSessionBinding: true, + agentSessionClaim: claim, + signal: _caller.signal + }) + return { + terminal, + disposition: terminal.agentSessionDisposition ?? 'created' + } + } + + async createAgentSession( + request: RuntimeCreateAgentSessionRequest, + caller: RuntimeAgentSessionRpcCaller = {} + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + const now = Date.now() + const operationTimestamp = parseAgentSessionOperationTimestamp(request.clientOperationId) + if ( + operationTimestamp === null || + operationTimestamp > now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + ) { + throw new Error('agent_session_operation_invalid') + } + const callerKey = caller.clientId?.trim() || `trusted-local:${caller.clientKind ?? 'runtime'}` + const operationKey = `${callerKey}\0${request.clientOperationId}` + const requestFingerprint = createHash('sha256') + .update( + JSON.stringify([ + request.worktree, + request.agent, + request.prompt ?? null, + request.promptDelivery ?? null, + request.agentArgs ?? null, + request.agentArgs === undefined ? 'host-default' : 'client-override', + request.launchPreferences?.model ?? null, + request.launchPreferences?.effort ?? null, + request.launchPreferences?.mode ?? null, + request.startupCwd ?? null, + request.presentation ?? null, + request.placement?.tabId ?? null, + request.placement?.leafId ?? null, + request.viewMode ?? null + ]) + ) + .digest('base64url') + const existing = this.agentSessionCreateOperations.get(operationKey) + if (existing) { + if (existing.fingerprint !== requestFingerprint) { + throw new Error('agent_session_operation_conflict') + } + const replayed = await existing.promise + return { ...replayed, disposition: 'replayed' } + } + if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { + // Why: once a tombstone could have expired, an unseen replay must never + // be reinterpreted as permission to start another fresh agent. + throw new Error('agent_session_operation_expired') + } + let callerOperationCount = 0 + const callerPrefix = `${callerKey}\0` + for (const key of this.agentSessionCreateOperations.keys()) { + if (key.startsWith(callerPrefix)) { + callerOperationCount += 1 + } + } + if ( + callerOperationCount >= AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT || + this.agentSessionCreateOperations.size >= AGENT_SESSION_OPERATION_GLOBAL_LIMIT + ) { + // Why: tombstones cannot be evicted early without making an old replay + // capable of spawning again; reject new IDs until retained entries age out. + throw new Error('agent_session_operation_capacity') + } + let retainReplayFence = false + const operation = (async (): Promise => { + // Why: reserve the client operation before any async preflight so concurrent retries cannot + // both observe an empty ledger and reach the execution owner independently. + const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) + if ( + !(await this.executionOwnerSupportsAgentSessionOperation( + workspace, + 'create', + caller.signal + )) + ) { + // Why: the exact legacy launch remains client-owned until this pre-spawn check succeeds. + throw new Error('agent_session_legacy_required') + } + const startupCwd = this.resolveWorkspaceTerminalStartupCwd(workspace, request.startupCwd) + // Why: aliases and object property order are client syntax, not authority; + // fingerprint the host-resolved fields in one fixed order. + const resolvedFingerprint = createHash('sha256') + .update( + JSON.stringify([ + workspace.id, + request.agent, + request.prompt ?? null, + request.promptDelivery ?? null, + request.agentArgs ?? null, + request.agentArgs === undefined ? 'host-default' : 'client-override', + request.launchPreferences?.model ?? null, + request.launchPreferences?.effort ?? null, + request.launchPreferences?.mode ?? null, + startupCwd ?? null, + request.presentation ?? null, + request.placement?.tabId ?? null, + request.placement?.leafId ?? null, + request.viewMode ?? null + ]) + ) + .digest('base64url') + const settings = this.store!.getSettings() + if (!isTuiAgentEnabled(request.agent, settings.disabledTuiAgents)) { + throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') + } + const platform = this.getAgentLaunchPlatformForWorkspace(workspace) + const isRemote = workspace.repo + ? repoIsRemote(workspace.repo) + : Boolean(workspace.connectionId) + const shell = resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) + const startupArgs = { + agent: request.agent, + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs: + request.agentArgs !== undefined + ? request.agentArgs + : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), + sessionOptions: this.toAgentSessionOptions(request.launchPreferences), + platform, + shell, + isRemote + } + const startup = + request.promptDelivery === 'draft' + ? buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' }) + : buildAgentStartupPlan({ + ...startupArgs, + prompt: request.prompt ?? '', + allowEmptyPromptLaunch: true + }) + if (!startup) { + throw new Error('agent_session_identity_required') + } + if (workspace.connectionId) { + await this.markRemoteWorkspaceTrustedForAgent( + request.agent, + workspace.connectionId, + workspace.path + ) + } else { + this.markLocalWorkspaceTrustedForAgent(request.agent, workspace.path) + } + if (caller.signal?.aborted) { + throw new Error('client_disconnected') + } + let terminal: RuntimeTerminalCreate + const executionOperationId = createHash('sha256') + .update(this.runtimeId) + .update('\0') + .update(operationKey) + .update('\0') + .update(resolvedFingerprint) + .digest('base64url') + const operationTabId = + request.placement?.tabId ?? deterministicAgentSessionUuid(`${executionOperationId}:tab`) + const operationLeafId = + request.placement?.leafId ?? deterministicAgentSessionUuid(`${executionOperationId}:leaf`) + const operationHandle = `term_${deterministicAgentSessionUuid(`${executionOperationId}:handle`)}` + try { + terminal = await this.createTerminal(`id:${workspace.id}`, { + command: startup.launchCommand, + env: startup.env, + launchConfig: startup.launchConfig, + launchAgent: request.agent, + startupCommandDelivery: startup.startupCommandDelivery, + cwd: startupCwd, + presentation: request.presentation ?? 'background', + tabId: operationTabId, + leafId: operationLeafId, + preAllocatedHandle: operationHandle, + viewMode: request.viewMode, + persistHostSessionBinding: true, + agentSessionCreateOperationId: executionOperationId, + signal: caller.signal, + onPtySpawnCommitted: () => { + retainReplayFence = true + } + }) + } catch (error) { + if (isAgentSessionOperationOutcomeUnknown(error)) { + retainReplayFence = true + } + throw error + } + return { terminal, disposition: 'created' } + })() + this.agentSessionCreateOperations.set(operationKey, { + fingerprint: requestFingerprint, + promise: operation + }) + const expireOperation = (): void => { + const expiresAt = Math.max(now, operationTimestamp) + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + const timer = setTimeout( + () => { + if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) { + this.agentSessionCreateOperations.delete(operationKey) + } + }, + Math.max(1, expiresAt - Date.now()) + ) + timer.unref?.() + } + try { + const result = await operation + expireOperation() + return result + } catch (error) { + if (retainReplayFence) { + // Why: the first PTY may still be alive; replay the same failure until + // expiry instead of interpreting a lost outcome as a fresh spawn grant. + expireOperation() + } else if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) { + this.agentSessionCreateOperations.delete(operationKey) + } + throw error + } + } + async createTerminal( worktreeSelector?: string, opts: TerminalCreateOptions = {} @@ -19695,7 +20522,8 @@ export class OrcaRuntimeService { const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null const shouldCreateInBackground = worktreeSelector !== undefined && - ((!requiresRendererFocus && opts.rendererBacked !== true) || + (Boolean(opts.agentSessionClaim) || + (!requiresRendererFocus && opts.rendererBacked !== true) || // Why: `orca serve` exposes the local runtime without a renderer // window. Renderer-backed Codex terminals are preferred for the app, // but headless CLI users still need a usable terminal handle. @@ -19707,9 +20535,17 @@ export class OrcaRuntimeService { } const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts) + let ptySpawnCommitReported = false + const reportPtySpawnCommitted = (): void => { + if (ptySpawnCommitReported) { + return + } + ptySpawnCommitReported = true + launchOpts.onPtySpawnCommitted?.() + } const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path - const preAllocatedHandle = + let preAllocatedHandle = launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle() // Why: mint tabId in main before spawn so paneKey is known at PTY env // build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys @@ -19723,9 +20559,9 @@ export class OrcaRuntimeService { isValidHostTerminalTabId(hintedTabId) && launchOpts.leafId !== undefined && isTerminalLeafId(launchOpts.leafId) - const tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID() - const leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : randomUUID() - const paneKey = makePaneKey(tabId, leafId) + let tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID() + let leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : randomUUID() + let paneKey = makePaneKey(tabId, leafId) const launchToken = launchOpts.launchConfig ? (launchOpts.launchToken ?? randomUUID()) : undefined @@ -19798,6 +20634,9 @@ export class OrcaRuntimeService { ) const terminalColorQueryReplies = launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors() + if (launchOpts.signal?.aborted) { + throw new Error('client_disconnected') + } const result = await this.ptyController.spawn({ cols: 120, rows: 40, @@ -19821,6 +20660,24 @@ export class OrcaRuntimeService { tabId, leafId, ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), + ...(launchOpts.agentSessionClaim + ? { + agentSessionEnsure: { + claim: launchOpts.agentSessionClaim, + surface: { + worktreeId: workspace.id, + tabId, + leafId, + terminalHandle: preAllocatedHandle + } + } + } + : {}), + ...(launchOpts.agentSessionCreateOperationId + ? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId } + : {}), + ...(launchOpts.signal ? { signal: launchOpts.signal } : {}), + ...(launchOpts.onPtySpawnCommitted ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), // Why: a headless-created pane has no renderer session writer. Persist // its tab/leaf binding at spawn so a later promoted window reattaches @@ -19831,11 +20688,31 @@ export class OrcaRuntimeService { ? { persistHostSessionBinding: true } : {}) }) + reportPtySpawnCommitted() + if (result.agentSessionEnsure) { + const canonicalSurface = result.agentSessionEnsure.owner.surface + preAllocatedHandle = canonicalSurface.terminalHandle + tabId = canonicalSurface.tabId + leafId = canonicalSurface.leafId + paneKey = makePaneKey(tabId, leafId) + } + try { + this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId) + } catch (error) { + if (error instanceof Error && error.message === 'agent_session_exited_during_start') { + this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId) + } + throw error + } this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) if (result.wslDistro) { this.preparePtyExecutionContext(result.id, result.wslDistro) } - this.registerPty(result.id, workspace.id, workspace.connectionId) + this.registerPty(result.id, workspace.id, workspace.connectionId, { + tabId, + leafId, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) + }) const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { if (launchOpts.title) { @@ -19906,6 +20783,9 @@ export class OrcaRuntimeService { worktreeId: workspace.id, title: launchOpts.title ?? null, surface, + ...(result.agentSessionEnsure + ? { agentSessionDisposition: result.agentSessionEnsure.disposition } + : {}), ...(warning ? { warning } : {}) } } @@ -21108,7 +21988,10 @@ export class OrcaRuntimeService { envToDelete: opts.envToDelete, connectionId: workspace.connectionId, worktreeId: workspace.id, - preAllocatedHandle + preAllocatedHandle, + tabId: parentTabId, + leafId, + persistHostSessionBinding: true }) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) if (result.wslDistro) { @@ -21354,8 +22237,18 @@ export class OrcaRuntimeService { const stoppedPtyIds: string[] = [] for (const ptyId of [...expected].sort()) { - if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) { - throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) + if (opts.keepHistory) { + this.intentionalHandlelessPtyStops.set( + ptyId, + this.ptysById.get(ptyId)?.incarnationId ?? null + ) + } + try { + if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) { + throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) + } + } finally { + this.intentionalHandlelessPtyStops.delete(ptyId) } stoppedPtyIds.push(ptyId) } @@ -22581,6 +23474,7 @@ export class OrcaRuntimeService { | 'connectionId' | 'isWsl' | 'wslDistro' + | 'incarnationId' > > = {} ): RuntimePtyWorktreeRecord { @@ -22599,6 +23493,7 @@ export class OrcaRuntimeService { : null pty = { ptyId, + incarnationId: state.incarnationId ?? null, worktreeId, connectionId, isWsl: state.isWsl ?? null, @@ -22647,6 +23542,9 @@ export class OrcaRuntimeService { } pty.worktreeId = worktreeId + if (state.incarnationId !== undefined) { + pty.incarnationId = state.incarnationId + } if (state.connectionId !== undefined) { pty.connectionId = state.connectionId if (state.connectionId !== null) { @@ -22752,7 +23650,8 @@ export class OrcaRuntimeService { } if (worktreeId) { this.recordPtyWorktree(session.id, worktreeId, { - connected: true + connected: true, + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}) }) } // Why: fire-and-forget so this listing hot path doesn't serialize a relay round-trip per session and a throw can't abort the sweep below. @@ -23043,7 +23942,8 @@ export class OrcaRuntimeService { ) { continue } - const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(snapshot, existing) + const fencedSnapshot = this.applyMobileSessionRetirementFences(snapshot) + const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(fencedSnapshot, existing) // Why: clients drop same-epoch frames whose version isn't strictly newer, // and main-local touches may already have emitted a higher version than // the renderer's counter — keep the stored version strictly monotonic so diff --git a/src/main/runtime/remote-agent-session-host-authority.integration.test.ts b/src/main/runtime/remote-agent-session-host-authority.integration.test.ts new file mode 100644 index 00000000000..95fc9cf1fd2 --- /dev/null +++ b/src/main/runtime/remote-agent-session-host-authority.integration.test.ts @@ -0,0 +1,233 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { parsePairingCode } from '../../shared/pairing' +import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection' +import type { + RuntimeEnsureAgentSessionResult, + RuntimeEnsureAgentSessionRequest +} from '../../shared/agent-session-host-authority' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import type { SubprocessHandle } from '../daemon/session' +import { TerminalHost } from '../daemon/terminal-host' +import { OrcaRuntimeService } from './orca-runtime' +import { OrcaRuntimeRpcServer } from './runtime-rpc' + +const TEST_TIMEOUT_MS = 15_000 +const REQUEST_TIMEOUT_MS = 5_000 + +type ControlledSubprocess = SubprocessHandle & { exit: (code?: number) => void } + +function createControlledSubprocess(): ControlledSubprocess { + let onExit: ((code: number) => void) | null = null + let exited = false + const exit = (code = 0): void => { + if (exited) { + return + } + exited = true + onExit?.(code) + } + return { + pid: 41_000, + getForegroundProcess: () => (exited ? null : 'claude'), + write: vi.fn(), + resize: vi.fn(), + kill: () => exit(0), + forceKill: () => exit(137), + signal: vi.fn(), + onData: vi.fn(), + onExit: (listener) => { + onExit = listener + }, + dispose: vi.fn(), + exit + } +} + +function requirePairing(server: OrcaRuntimeRpcServer, name: string) { + const offer = server.createPairingOffer({ name, scope: 'runtime' }) + if (!offer.available) { + throw new Error('pairing unavailable') + } + const pairing = parsePairingCode(offer.pairingUrl) + if (!pairing) { + throw new Error('invalid pairing') + } + return pairing +} + +describe('remote agent-session host authority integration', () => { + const cleanups: (() => void | Promise)[] = [] + + afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) { + await cleanup() + } + }) + + it( + 'deduplicates racing remote resumes, adopts retries, and retires exited surfaces', + { timeout: TEST_TIMEOUT_MS }, + async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-agent-authority-repro-')) + cleanups.push(() => rmSync(userDataPath, { recursive: true, force: true })) + + const subprocesses: ControlledSubprocess[] = [] + const spawnSubprocess = vi.fn(() => { + const subprocess = createControlledSubprocess() + subprocesses.push(subprocess) + return subprocess + }) + const host = new TerminalHost({ spawnSubprocess }) + cleanups.push(() => host.dispose()) + + const store = { + getSettings: () => ({ + workspaceDir: userDataPath, + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + }), + getRepos: () => [], + getRepo: () => undefined, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getProjects: () => [] + } + const runtime = new OrcaRuntimeService(store as never) + let nextRequestedSession = 0 + runtime.setPtyController({ + spawn: async (options) => { + const requestedSessionId = `remote-repro-${++nextRequestedSession}` + let resolvedSessionId = requestedSessionId + const result = await host.createOrAttach({ + sessionId: requestedSessionId, + cols: options.cols, + rows: options.rows, + cwd: options.cwd, + env: options.env, + command: options.command, + startupCommandDelivery: options.startupCommandDelivery, + launchAgent: options.launchAgent, + agentSessionEnsure: options.agentSessionEnsure, + streamClient: { + onData: (data) => runtime.onPtyData(resolvedSessionId, data, Date.now()), + onExit: (code) => runtime.onPtyExit(resolvedSessionId, code) + }, + onSessionResolved: (sessionId) => { + resolvedSessionId = sessionId + } + }) + return { + id: result.agentSessionEnsure?.owner.ptyId ?? resolvedSessionId, + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + } + }, + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + await server.start() + cleanups.push(() => server.stop()) + + const firstClient = new RemoteRuntimeRequestConnection(requirePairing(server, 'client-one')) + const secondClient = new RemoteRuntimeRequestConnection(requirePairing(server, 'client-two')) + const retryClient = new RemoteRuntimeRequestConnection(requirePairing(server, 'retry-client')) + cleanups.push(() => firstClient.close()) + cleanups.push(() => secondClient.close()) + cleanups.push(() => retryClient.close()) + + const request: RuntimeEnsureAgentSessionRequest = { + kind: 'explicit', + worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}`, + agent: 'claude', + providerSession: { key: 'session_id', id: 'provider-session-repro' }, + presentation: 'background' + } + const [first, second] = await Promise.all([ + firstClient.request( + 'terminal.ensureAgentSession', + request, + REQUEST_TIMEOUT_MS + ), + secondClient.request( + 'terminal.ensureAgentSession', + request, + REQUEST_TIMEOUT_MS + ) + ]) + + expect(first.ok).toBe(true) + expect(second.ok).toBe(true) + if (!first.ok || !second.ok) { + throw new Error('structured resume failed') + } + expect([first.result.disposition, second.result.disposition].sort()).toEqual([ + 'adopted', + 'created' + ]) + expect(second.result.terminal).toMatchObject({ + handle: first.result.terminal.handle, + tabId: first.result.terminal.tabId, + paneKey: first.result.terminal.paneKey, + ptyId: first.result.terminal.ptyId + }) + expect(spawnSubprocess).toHaveBeenCalledOnce() + expect(host.listSessions()).toHaveLength(1) + + // Why: a retry cannot prove whether its previous response arrived, so + // the provider identity—not a new client operation—must recover the owner. + const retry = await retryClient.request( + 'terminal.ensureAgentSession', + request, + REQUEST_TIMEOUT_MS + ) + expect(retry).toMatchObject({ + ok: true, + result: { + disposition: 'adopted', + terminal: { + handle: first.result.terminal.handle, + tabId: first.result.terminal.tabId, + paneKey: first.result.terminal.paneKey, + ptyId: first.result.terminal.ptyId + } + } + }) + expect(spawnSubprocess).toHaveBeenCalledOnce() + + subprocesses[0]?.exit(0) + await vi.waitFor(async () => { + const [terminals, tabs] = await Promise.all([ + firstClient.request<{ terminals: unknown[] }>( + 'terminal.list', + { worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}` }, + REQUEST_TIMEOUT_MS + ), + secondClient.request( + 'session.tabs.list', + { worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}` }, + REQUEST_TIMEOUT_MS + ) + ]) + expect(terminals).toMatchObject({ ok: true, result: { terminals: [] } }) + expect(tabs).toMatchObject({ ok: true, result: { tabs: [] } }) + }) + } + ) +}) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 15c592499f6..823859e0be1 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -6,6 +6,7 @@ import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core' import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error-recovery' import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types' import { LINEAR_ERROR_CODES } from '../../../shared/linear-agent-access' +import { AGENT_SESSION_RPC_ERROR_CODES } from '../../../shared/agent-session-host-authority' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -49,7 +50,8 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'no_active_terminal', 'repo_not_found', 'timeout', - 'invalid_limit' + 'invalid_limit', + ...AGENT_SESSION_RPC_ERROR_CODES ]) const COMPUTER_PASSTHROUGH_CODES: ReadonlySet = new Set(Object.values(COMPUTER_ERROR_CODES)) diff --git a/src/main/runtime/rpc/methods/agent-session.test.ts b/src/main/runtime/rpc/methods/agent-session.test.ts new file mode 100644 index 00000000000..a8fbbd06b30 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-session.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_CAPABILITIES, + RUNTIME_PROTOCOL_VERSION +} from '../../../../shared/protocol-version' +import { AGENT_SESSION_RPC_ERROR_CODES } from '../../../../shared/agent-session-host-authority' +import { AGENT_SESSION_OPERATION_FUTURE_SKEW_MS } from '../../../../shared/agent-session-host-authority' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest, RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { AGENT_SESSION_METHODS } from './agent-session' +import { TERMINAL_METHODS } from './terminal' + +function request(method: string, params: unknown): RpcRequest { + return { id: 'request-1', authToken: 'token', method, params } +} + +function terminalResult(disposition: 'created' | 'adopted' | 'replayed' = 'created') { + return { + terminal: { + handle: 'term_1', + worktreeId: 'worktree-1', + title: null + }, + disposition + } +} + +function runtimeStub() { + return { + getRuntimeId: () => 'runtime-1', + ensureAgentSession: vi.fn().mockResolvedValue(terminalResult()), + createAgentSession: vi.fn().mockResolvedValue(terminalResult()) + } +} + +describe('agent session RPC methods', () => { + it('dispatches an explicit structured resume without an authoritative command', async () => { + const runtime = runtimeStub() + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: AGENT_SESSION_METHODS + }) + + const response = await dispatcher.dispatch( + request('terminal.ensureAgentSession', { + kind: 'explicit', + worktree: 'id:worktree-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-session-1' }, + agentArgs: '--profile review', + launchPreferences: { model: 'gpt-5', effort: 'high' }, + presentation: 'focused', + placement: { tabId: 'tab-1', leafId: 'leaf-1' } + }) + ) + + expect(response).toMatchObject({ ok: true, result: { disposition: 'created' } }) + expect(runtime.ensureAgentSession).toHaveBeenCalledWith( + { + kind: 'explicit', + worktree: 'id:worktree-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-session-1' }, + agentArgs: '--profile review', + launchPreferences: { model: 'gpt-5', effort: 'high' }, + presentation: 'focused', + placement: { tabId: 'tab-1', leafId: 'leaf-1' } + }, + {} + ) + }) + + it('keeps automatic authority checkpoint-only', async () => { + const runtime = runtimeStub() + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: AGENT_SESSION_METHODS + }) + + const response = await dispatcher.dispatch( + request('terminal.ensureAgentSession', { + kind: 'automatic', + sleepingCheckpointId: 'checkpoint_123456789012345678901', + worktree: 'id:forged', + placement: { tabId: 'forged' } + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.ensureAgentSession).not.toHaveBeenCalled() + }) + + it('rejects mismatched agent/provider identity before runtime mutation', async () => { + const runtime = runtimeStub() + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: AGENT_SESSION_METHODS + }) + + const response = await dispatcher.dispatch( + request('terminal.ensureAgentSession', { + kind: 'explicit', + worktree: 'id:worktree-1', + agent: 'antigravity', + providerSession: { key: 'session_id', id: 'provider-session-1' } + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.ensureAgentSession).not.toHaveBeenCalled() + }) + + it('rejects opaque fresh-launch authority and malformed operation IDs', async () => { + const runtime = runtimeStub() + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: AGENT_SESSION_METHODS + }) + + const opaque = await dispatcher.dispatch( + request('terminal.createAgentSession', { + clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef', + worktree: 'id:worktree-1', + agent: 'codex', + command: 'codex resume provider-session-1' + }) + ) + const malformed = await dispatcher.dispatch( + request('terminal.createAgentSession', { + clientOperationId: 'not-time-sortable', + worktree: 'id:worktree-1', + agent: 'codex' + }) + ) + + expect(opaque).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(malformed).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.createAgentSession).not.toHaveBeenCalled() + }) + + it('preserves legacy agent-bearing terminal.create requests for mixed-version clients', async () => { + const createTerminal = vi.fn().mockResolvedValue({ handle: 'term-1' }) + const dedupeTerminalCreate = vi.fn( + async ( + _clientIdentity: string, + worktree: string | undefined, + _clientMutationId: string | undefined, + _reconcileExisting: boolean, + run: (worktree: string | undefined, handle: string | undefined) => Promise + ) => run(worktree, undefined) + ) + const runtime = { + getRuntimeId: () => 'runtime-1', + createTerminal, + dedupeTerminalCreate + } + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: TERMINAL_METHODS + }) + + const response = await dispatcher.dispatch( + request('terminal.create', { + worktree: 'id:worktree-1', + command: 'codex resume provider-session-1', + launchAgent: 'codex' + }) + ) + + expect(response.ok).toBe(true) + expect(dedupeTerminalCreate).toHaveBeenCalledWith( + 'local', + 'id:worktree-1', + undefined, + false, + expect.any(Function) + ) + expect(createTerminal).toHaveBeenCalledWith('id:worktree-1', { + command: 'codex resume provider-session-1', + startupCommandDelivery: undefined, + env: undefined, + launchAgent: 'codex', + title: undefined, + focus: false, + rendererBacked: false, + activate: false, + presentation: undefined, + tabId: undefined, + leafId: undefined + }) + }) + + it('rejects future-dated operation IDs before runtime mutation', async () => { + const runtime = runtimeStub() + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: AGENT_SESSION_METHODS + }) + const now = 1_752_883_200_000 + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now) + + const response = await dispatcher.dispatch( + request('terminal.createAgentSession', { + clientOperationId: `${now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 1}-0123456789abcdef0123456789abcdef`, + worktree: 'id:worktree-1', + agent: 'codex' + }) + ) + dateNow.mockRestore() + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent_session_operation_invalid' } + }) + expect(runtime.createAgentSession).not.toHaveBeenCalled() + }) + + it('passes authenticated caller identity outside the request payload', async () => { + const runtime = runtimeStub() + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: AGENT_SESSION_METHODS + }) + const replies: RpcResponse[] = [] + + await dispatcher.dispatchStreaming( + request('terminal.createAgentSession', { + clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef', + worktree: 'id:worktree-1', + agent: 'codex', + prompt: 'Fix the race', + promptDelivery: 'draft', + agentArgs: '--profile review', + launchPreferences: { model: 'gpt-5', effort: 'high' }, + viewMode: 'chat' + }), + (response) => replies.push(JSON.parse(response) as RpcResponse), + { clientId: 'authenticated-device', clientKind: 'runtime' } + ) + + expect(replies).toHaveLength(1) + expect(replies[0]).toMatchObject({ ok: true, result: { disposition: 'created' } }) + expect(runtime.createAgentSession).toHaveBeenCalledWith( + { + clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef', + worktree: 'id:worktree-1', + agent: 'codex', + prompt: 'Fix the race', + promptDelivery: 'draft', + agentArgs: '--profile review', + launchPreferences: { model: 'gpt-5', effort: 'high' }, + viewMode: 'chat' + }, + { clientId: 'authenticated-device', clientKind: 'runtime' } + ) + }) + + it('rejects draft delivery without a non-empty prompt', async () => { + const runtime = runtimeStub() + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: AGENT_SESSION_METHODS + }) + + const response = await dispatcher.dispatch( + request('terminal.createAgentSession', { + clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef', + worktree: 'id:worktree-1', + agent: 'claude', + prompt: ' ', + promptDelivery: 'draft' + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.createAgentSession).not.toHaveBeenCalled() + }) + + it('rejects oversized structured agent arguments before runtime mutation', async () => { + const runtime = runtimeStub() + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: AGENT_SESSION_METHODS + }) + + const response = await dispatcher.dispatch( + request('terminal.createAgentSession', { + clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef', + worktree: 'id:worktree-1', + agent: 'codex', + agentArgs: 'a'.repeat(16 * 1024 + 1) + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.createAgentSession).not.toHaveBeenCalled() + }) + + it.each(AGENT_SESSION_RPC_ERROR_CODES)('preserves stable runtime error %s', async (code) => { + const runtime = runtimeStub() + runtime.ensureAgentSession.mockRejectedValueOnce(new Error(code)) + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: AGENT_SESSION_METHODS + }) + + const response = await dispatcher.dispatch( + request('terminal.ensureAgentSession', { + kind: 'automatic', + sleepingCheckpointId: 'checkpoint_123456789012345678901' + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code } }) + }) + + it('advertises the capability without moving the mixed-version protocol fence', () => { + expect(RUNTIME_PROTOCOL_VERSION).toBe(3) + expect(MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION).toBe(2) + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY) + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-session.ts b/src/main/runtime/rpc/methods/agent-session.ts new file mode 100644 index 00000000000..18a1c26cca1 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-session.ts @@ -0,0 +1,228 @@ +import { z } from 'zod' +import { + getAgentResumeArgv, + hasUnsafeProviderSessionIdChars, + RESUMABLE_TUI_AGENTS +} from '../../../../shared/agent-session-resume' +import type { + RuntimeAgentSessionRpcCaller, + RuntimeCreateAgentSessionRequest, + RuntimeCreateAgentSessionResult, + RuntimeEnsureAgentSessionRequest, + RuntimeEnsureAgentSessionResult +} from '../../../../shared/agent-session-host-authority' +import { + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS, + parseAgentSessionOperationTimestamp +} from '../../../../shared/agent-session-host-authority' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { defineMethod, type RpcAnyMethod } from '../core' + +const MAX_WORKTREE_SELECTOR_LENGTH = 32_768 +const MAX_TRANSCRIPT_PATH_BYTES = 16 * 1024 +const MAX_PROMPT_BYTES = 256 * 1024 +const MAX_AGENT_ARGS_BYTES = 16 * 1024 +const MAX_LAUNCH_PREFERENCE_LENGTH = 512 + +const StrictNonEmptyString = (max: number, message: string) => + z + .string() + .min(1, message) + .max(max, message) + .refine((value) => value === value.trim(), `${message}; surrounding whitespace is invalid`) + +const WorktreeSelector = StrictNonEmptyString( + MAX_WORKTREE_SELECTOR_LENGTH, + 'Invalid worktree selector' +) + +const Presentation = z.enum(['background', 'focused']) + +const Placement = z + .object({ + tabId: z + .string() + .min(1) + .max(512) + .refine(isValidTerminalTabId, 'Invalid terminal tab ID') + .optional(), + leafId: z.string().min(1).max(128).optional() + }) + .strict() + .refine((value) => value.tabId !== undefined || value.leafId !== undefined, { + message: 'Placement must include a tab or leaf ID' + }) + +const LaunchPreferences = z + .object({ + model: StrictNonEmptyString( + MAX_LAUNCH_PREFERENCE_LENGTH, + 'Invalid model preference' + ).optional(), + effort: StrictNonEmptyString( + MAX_LAUNCH_PREFERENCE_LENGTH, + 'Invalid effort preference' + ).optional(), + mode: StrictNonEmptyString(MAX_LAUNCH_PREFERENCE_LENGTH, 'Invalid mode preference').optional() + }) + .strict() + +const PromptDelivery = z.enum(['auto-submit', 'draft']) + +const AgentArgs = z + .string() + .refine( + (value) => Buffer.byteLength(value, 'utf8') <= MAX_AGENT_ARGS_BYTES, + 'Agent arguments are too large' + ) + .nullable() + +const ProviderSession = z + .object({ + key: z.enum(['session_id', 'conversation_id']), + id: StrictNonEmptyString(512, 'Invalid provider session ID').refine( + (value) => !value.startsWith('-') && !hasUnsafeProviderSessionIdChars(value), + 'Invalid provider session ID' + ), + transcriptPath: z + .string() + .min(1) + .refine((value) => value === value.trim(), 'Invalid transcript path') + .refine( + (value) => + !hasUnsafeProviderSessionIdChars(value) && + Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES, + 'Invalid transcript path' + ) + .optional() + }) + .strict() + +const AutomaticEnsure = z + .object({ + kind: z.literal('automatic'), + sleepingCheckpointId: z + .string() + .min(32) + .max(128) + .regex(/^[A-Za-z0-9_-]+$/), + presentation: Presentation.optional() + }) + .strict() + +const ExplicitEnsure = z + .object({ + kind: z.literal('explicit'), + worktree: WorktreeSelector, + agent: z.enum(RESUMABLE_TUI_AGENTS), + providerSession: ProviderSession, + agentArgs: AgentArgs.optional(), + launchPreferences: LaunchPreferences.optional(), + presentation: Presentation.optional(), + placement: Placement.optional() + }) + .strict() + .superRefine((value, context) => { + if (getAgentResumeArgv(value.agent, value.providerSession) === null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['providerSession'], + message: 'Provider session is not resumable for this agent' + }) + } + }) + +export const EnsureAgentSessionParams: z.ZodType = + z.discriminatedUnion('kind', [AutomaticEnsure, ExplicitEnsure]) + +export const CreateAgentSessionParams: z.ZodType = z + .object({ + clientOperationId: z + .string() + .refine( + (value) => parseAgentSessionOperationTimestamp(value) !== null, + 'Invalid agent operation ID' + ), + worktree: WorktreeSelector, + agent: z.string().refine(isTuiAgent, 'Unknown agent preset'), + prompt: z + .string() + .refine( + (value) => Buffer.byteLength(value, 'utf8') <= MAX_PROMPT_BYTES, + 'Prompt is too large' + ) + .optional(), + promptDelivery: PromptDelivery.optional(), + agentArgs: AgentArgs.optional(), + launchPreferences: LaunchPreferences.optional(), + startupCwd: z.string().min(1).max(MAX_WORKTREE_SELECTOR_LENGTH).optional(), + presentation: Presentation.optional(), + placement: Placement.optional(), + viewMode: z.enum(['terminal', 'chat']).optional() + }) + .strict() + .superRefine((value, context) => { + if (value.promptDelivery === 'draft' && !value.prompt?.trim()) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['prompt'], + message: 'Draft delivery requires a non-empty prompt' + }) + } + }) + +type AgentSessionRuntime = OrcaRuntimeService & { + ensureAgentSession( + request: RuntimeEnsureAgentSessionRequest, + caller?: RuntimeAgentSessionRpcCaller + ): Promise + createAgentSession( + request: RuntimeCreateAgentSessionRequest, + caller?: RuntimeAgentSessionRpcCaller + ): Promise +} + +function callerContext( + clientId: string | undefined, + clientKind: 'mobile' | 'runtime' | undefined, + signal: AbortSignal | undefined +): RuntimeAgentSessionRpcCaller { + return { + ...(clientId !== undefined ? { clientId } : {}), + ...(clientKind !== undefined ? { clientKind } : {}), + ...(signal ? { signal } : {}) + } +} + +function assertOperationTimestampWithinFutureSkew(clientOperationId: string): void { + const timestamp = parseAgentSessionOperationTimestamp(clientOperationId) + if (timestamp === null || timestamp > Date.now() + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS) { + // Why: a future-dated ID could look new again after its idempotency tombstone is collected. + throw new Error('agent_session_operation_invalid') + } +} + +export const AGENT_SESSION_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.ensureAgentSession', + params: EnsureAgentSessionParams, + handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) => + (runtime as AgentSessionRuntime).ensureAgentSession( + params, + callerContext(pairedDeviceId ?? clientId, clientKind, signal) + ) + }), + defineMethod({ + name: 'terminal.createAgentSession', + params: CreateAgentSessionParams, + handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) => { + assertOperationTimestampWithinFutureSkew(params.clientOperationId) + return (runtime as AgentSessionRuntime).createAgentSession( + params, + callerContext(pairedDeviceId ?? clientId, clientKind, signal) + ) + } + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 00d2fd974be..be9c828f117 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -35,6 +35,7 @@ import { CLIPBOARD_METHODS } from './clipboard' import { HOST_CAPABILITY_METHODS } from './host-capabilities' import { EMULATOR_METHODS } from './emulator' import { PAIRING_METHODS } from './pairing' +import { AGENT_SESSION_METHODS } from './agent-session' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when @@ -45,6 +46,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...AUTOMATION_METHODS, ...REPO_METHODS, ...WORKTREE_METHODS, + ...AGENT_SESSION_METHODS, ...TERMINAL_METHODS, ...BROWSER_CORE_METHODS, ...BROWSER_SCREENCAST_METHODS, diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index c7e4ca4ad0c..0a3af68a90f 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -10,7 +10,7 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ withSpan( 'runtime.session-tabs.close', async (span) => { - if (!params.reason && context.clientKind !== 'mobile') { + if (!params.reason && context.clientKind === undefined) { const result = await context.runtime.refuseUnattributedMobileSessionTabClose( params.worktree, params.tabId @@ -35,7 +35,8 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ attribution: 'session-tab-close', origin: context.clientKind ?? 'in-process', closeReason: - params.reason ?? (context.clientKind === 'mobile' ? 'legacy-mobile-user' : 'missing'), + params.reason ?? + (context.clientKind ? `legacy-${context.clientKind}-user` : 'missing'), connectionGeneration: context.connectionId ?? 'in-process', requestId: context.requestId ?? 'in-process' } diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 1e25e8a4b8d..ac600e661ed 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -28,8 +28,8 @@ export const ActivateTab = WorktreeTabSelector.extend({ }) export const CloseTab = ActivateTab.extend({ - // Why: optional so legacy clients still validate; policy conservatively - // attributes only authenticated mobile callers as user intent. + // Why: optional preserves authenticated legacy user closes; lifecycle intent + // uses the additive evidence-bearing method instead. reason: z.literal('user').optional() }) diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index f37922c16cb..eb70d393c0d 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -136,6 +136,28 @@ describe('session tab RPC methods', () => { expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled() }) + it('preserves reasonless explicit closes from authenticated legacy runtime clients', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + refuseUnattributedMobileSessionTabClose: vi.fn(), + closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const replies: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.close', { worktree: 'id:wt-1', tabId: 'tab-1' }), + (response) => replies.push(response), + { clientKind: 'runtime', pairedDeviceId: 'legacy-runtime' } + ) + + expect(replies).toHaveLength(1) + expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', { + reason: 'user' + }) + expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled() + }) + it.each(['pty-exit', 'cleanup'] as const)( 'rejects %s on the legacy close endpoint before host adjudication', async (reason) => { @@ -262,7 +284,7 @@ describe('session tab RPC methods', () => { }) }) - it('dispatches terminal creation with the requested tab group', async () => { + it('dispatches ordinary terminal creation with the requested tab group', async () => { const runtime = { getRuntimeId: () => 'test-runtime', createMobileSessionTerminal: vi.fn().mockResolvedValue({ @@ -321,7 +343,9 @@ describe('session tab RPC methods', () => { activate: true, select: undefined, clientNavigationId: undefined, - navigation: 'all' + navigation: 'all', + clientMutationId: undefined, + signal: undefined }) }) @@ -357,7 +381,7 @@ describe('session tab RPC methods', () => { ) }) - it('dispatches terminal creation with a requested agent preset', async () => { + it('preserves legacy agent creation for mixed-version clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', createMobileSessionTerminal: vi.fn().mockResolvedValue({ @@ -390,13 +414,16 @@ describe('session tab RPC methods', () => { afterTabId: undefined, targetGroupId: undefined, command: undefined, + cwd: undefined, startupCommandDelivery: undefined, agent: 'codex', agentPrompt: 'Review this diff', activate: undefined, select: undefined, clientNavigationId: undefined, - navigation: 'all' + navigation: 'all', + clientMutationId: undefined, + signal: undefined }) }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index e11ae523fcf..10b366cbd96 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -356,6 +356,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'terminal.close', 'terminal.closeTab', 'terminal.create', + 'terminal.createAgentSession', + 'terminal.ensureAgentSession', 'terminal.focus', 'terminal.agentStatus', 'terminal.getAutoRestoreFit', diff --git a/src/main/runtime/workspace-session-terminal-membership-authority.ts b/src/main/runtime/workspace-session-terminal-membership-authority.ts new file mode 100644 index 00000000000..9dace4c8cb1 --- /dev/null +++ b/src/main/runtime/workspace-session-terminal-membership-authority.ts @@ -0,0 +1,293 @@ +import type { + Tab, + TabGroup, + TerminalLayoutSnapshot, + TerminalPaneLayoutNode, + WorkspaceSessionState +} from '../../shared/types' +import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import { pruneTabGroupLayoutAfterRetirement } from './mobile-session-terminal-retirement' + +function collectLeafIds(node: TerminalPaneLayoutNode | null, ids: Set): void { + if (!node) { + return + } + if (node.type === 'leaf') { + ids.add(node.leafId) + return + } + collectLeafIds(node.first, ids) + collectLeafIds(node.second, ids) +} + +function layoutHasSameMembership( + candidate: TerminalLayoutSnapshot, + current: TerminalLayoutSnapshot +): boolean { + const candidateIds = new Set() + const currentIds = new Set() + collectLeafIds(candidate.root, candidateIds) + collectLeafIds(current.root, currentIds) + return ( + candidateIds.size === currentIds.size && + [...candidateIds].every((leafId) => currentIds.has(leafId)) + ) +} + +function rebaseLayout( + candidate: TerminalLayoutSnapshot | undefined, + current: TerminalLayoutSnapshot | undefined +): TerminalLayoutSnapshot | undefined { + if (!current) { + return undefined + } + if (!candidate || !layoutHasSameMembership(candidate, current)) { + return current + } + return { + ...candidate, + // Why: renderer layout metadata may move, but only host-persisted live bindings may name PTYs. + ptyIdsByLeafId: { + ...candidate.ptyIdsByLeafId, + ...current.ptyIdsByLeafId + } + } +} + +function terminalUnifiedTabMatches(tab: Tab, terminalTabIds: ReadonlySet): boolean { + return ( + tab.contentType === 'terminal' && + (terminalTabIds.has(tab.id) || terminalTabIds.has(tab.entityId)) + ) +} + +function rebaseUnifiedTabs( + candidate: readonly Tab[], + current: readonly Tab[], + terminalTabIds: ReadonlySet +): Tab[] { + const result = candidate.filter( + (tab) => tab.contentType !== 'terminal' || terminalUnifiedTabMatches(tab, terminalTabIds) + ) + const representedTerminalIds = new Set( + result.filter((tab) => tab.contentType === 'terminal').flatMap((tab) => [tab.id, tab.entityId]) + ) + for (const tab of current) { + if ( + terminalUnifiedTabMatches(tab, terminalTabIds) && + !representedTerminalIds.has(tab.id) && + !representedTerminalIds.has(tab.entityId) + ) { + result.push(tab) + } + } + return result +} + +function rebaseTabGroups( + groups: readonly TabGroup[], + validTabIds: ReadonlySet +): TabGroup[] { + return groups.flatMap((group) => { + const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) + if (tabOrder.length === 0) { + return [] + } + const activeTabId = + group.activeTabId && tabOrder.includes(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) + const recentTabIds = group.recentTabIds?.filter((tabId) => tabOrder.includes(tabId)) + return [ + { + ...group, + tabOrder, + activeTabId, + ...(recentTabIds && recentTabIds.length > 0 ? { recentTabIds } : {}) + } + ] + }) +} + +function rebaseIncarnationBindings( + session: WorkspaceSessionState, + prior: WorkspaceSessionState +): Record | undefined { + const terminalTabIds = new Set( + Object.values(session.tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)) + ) + const allowedPaneKeys = new Set() + for (const tabId of terminalTabIds) { + const layout = session.terminalLayoutsByTabId[tabId] + if (!layout) { + continue + } + const leafIds = new Set() + collectLeafIds(layout.root, leafIds) + for (const leafId of leafIds) { + allowedPaneKeys.add(`${tabId}:${leafId}`) + } + } + const merged = { + ...session.terminalPtyIncarnationsByPaneKey, + ...prior.terminalPtyIncarnationsByPaneKey + } + const retained = Object.fromEntries( + Object.entries(merged).filter(([paneKey]) => { + const separator = paneKey.lastIndexOf(':') + if (separator < 1) { + return false + } + const tabId = paneKey.slice(0, separator) + return session.terminalLayoutsByTabId[tabId] + ? allowedPaneKeys.has(paneKey) + : terminalTabIds.has(tabId) + }) + ) + return Object.keys(retained).length > 0 ? retained : undefined +} + +export function advanceTerminalTopologyRevision( + session: WorkspaceSessionState, + worktreeId: string +): WorkspaceSessionState { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return { + ...session, + terminalTopologyRevisionByRepoId: { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1 + } + } +} + +export function hasHostAuthoritativeTerminalMembership( + session: WorkspaceSessionState | undefined, + worktreeId: string +): boolean { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return ( + (session?.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) > 0 || + Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).some( + (tombstone) => tombstone.worktreeId === worktreeId + ) + ) +} + +export function rebaseWorkspaceSessionTerminalMembership( + incoming: WorkspaceSessionState, + prior: WorkspaceSessionState | undefined +): WorkspaceSessionState { + if (!prior?.terminalTopologyRevisionByRepoId) { + return incoming + } + const terminalTopologyRevisionByRepoId = { ...incoming.terminalTopologyRevisionByRepoId } + for (const [repoId, revision] of Object.entries(prior.terminalTopologyRevisionByRepoId)) { + terminalTopologyRevisionByRepoId[repoId] = Math.max( + revision, + terminalTopologyRevisionByRepoId[repoId] ?? 0 + ) + } + const tabsByWorktree = { ...incoming.tabsByWorktree } + const terminalLayoutsByTabId = { ...incoming.terminalLayoutsByTabId } + const unifiedTabs = { ...incoming.unifiedTabs } + const tabGroups = { ...incoming.tabGroups } + const tabGroupLayouts = { ...incoming.tabGroupLayouts } + const activeTabIdByWorktree = { ...incoming.activeTabIdByWorktree } + let includeUnifiedTabs = incoming.unifiedTabs !== undefined + let includeTabGroups = incoming.tabGroups !== undefined + let includeTabGroupLayouts = incoming.tabGroupLayouts !== undefined + let rebasedMembership = false + const worktreeIds = new Set([ + ...Object.keys(prior.tabsByWorktree), + ...Object.keys(incoming.tabsByWorktree) + ]) + for (const worktreeId of worktreeIds) { + const repoId = getRepoIdFromWorktreeId(worktreeId) + const revision = terminalTopologyRevisionByRepoId[repoId] ?? 0 + const priorRevision = prior.terminalTopologyRevisionByRepoId[repoId] ?? 0 + const incomingRevision = incoming.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + if (revision <= 0 || incomingRevision > priorRevision) { + continue + } + rebasedMembership = true + const currentTabs = prior.tabsByWorktree[worktreeId] ?? [] + const candidateTabsById = new Map( + (incoming.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab]) + ) + const terminalTabIds = new Set(currentTabs.map((tab) => tab.id)) + const tabs = currentTabs.map((current) => { + const candidate = candidateTabsById.get(current.id) + return candidate ? { ...candidate, ptyId: current.ptyId } : current + }) + for (const candidate of incoming.tabsByWorktree[worktreeId] ?? []) { + if (!terminalTabIds.has(candidate.id)) { + delete terminalLayoutsByTabId[candidate.id] + } + } + for (const tabId of terminalTabIds) { + const layout = rebaseLayout( + incoming.terminalLayoutsByTabId[tabId], + prior.terminalLayoutsByTabId[tabId] + ) + if (layout) { + terminalLayoutsByTabId[tabId] = layout + } else { + delete terminalLayoutsByTabId[tabId] + } + } + const rebasedUnifiedTabs = rebaseUnifiedTabs( + incoming.unifiedTabs?.[worktreeId] ?? [], + prior.unifiedTabs?.[worktreeId] ?? [], + terminalTabIds + ) + if (includeUnifiedTabs || rebasedUnifiedTabs.length > 0) { + unifiedTabs[worktreeId] = rebasedUnifiedTabs + includeUnifiedTabs = true + } + const validTabIds = new Set([...terminalTabIds, ...rebasedUnifiedTabs.map((tab) => tab.id)]) + const rebasedGroups = rebaseTabGroups( + incoming.tabGroups?.[worktreeId] ?? prior.tabGroups?.[worktreeId] ?? [], + validTabIds + ) + if (includeTabGroups || rebasedGroups.length > 0) { + tabGroups[worktreeId] = rebasedGroups + includeTabGroups = true + } + const rebasedGroupLayout = pruneTabGroupLayoutAfterRetirement( + incoming.tabGroupLayouts?.[worktreeId] ?? prior.tabGroupLayouts?.[worktreeId], + new Set(rebasedGroups.map((group) => group.id)) + ) + if (rebasedGroupLayout) { + tabGroupLayouts[worktreeId] = rebasedGroupLayout + includeTabGroupLayouts = true + } else { + delete tabGroupLayouts[worktreeId] + } + if (!validTabIds.has(activeTabIdByWorktree[worktreeId] ?? '')) { + activeTabIdByWorktree[worktreeId] = + (prior.activeTabIdByWorktree?.[worktreeId] && + validTabIds.has(prior.activeTabIdByWorktree[worktreeId] ?? '') + ? prior.activeTabIdByWorktree[worktreeId] + : (rebasedGroups[0]?.activeTabId ?? tabs[0]?.id)) ?? null + } + tabsByWorktree[worktreeId] = tabs + } + const next: WorkspaceSessionState = { + ...incoming, + terminalTopologyRevisionByRepoId, + ...(rebasedMembership + ? { + tabsByWorktree, + terminalLayoutsByTabId, + activeTabIdByWorktree, + ...(includeUnifiedTabs ? { unifiedTabs } : {}), + ...(includeTabGroups ? { tabGroups } : {}), + ...(includeTabGroupLayouts ? { tabGroupLayouts } : {}) + } + : {}) + } + return rebasedMembership + ? { ...next, terminalPtyIncarnationsByPaneKey: rebaseIncarnationBindings(next, prior) } + : next +} diff --git a/src/main/ssh/ssh-relay-session-incarnation.test.ts b/src/main/ssh/ssh-relay-session-incarnation.test.ts new file mode 100644 index 00000000000..a98180308b2 --- /dev/null +++ b/src/main/ssh/ssh-relay-session-incarnation.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SshRelaySession } from './ssh-relay-session' +import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' + +const { muxRequestMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn() })) + +vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) +vi.mock('./ssh-channel-multiplexer', () => ({ + SshChannelMultiplexer: class MockSshChannelMultiplexer { + notify = vi.fn() + request = muxRequestMock + onNotification = vi.fn().mockReturnValue(() => {}) + onRequest = vi.fn().mockReturnValue(() => {}) + onDispose = vi.fn().mockReturnValue(() => {}) + dispose = vi.fn() + isDisposed = vi.fn().mockReturnValue(false) + } +})) +vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ + installRemoteManagedAgentHooks: vi.fn().mockResolvedValue([]) +})) +vi.mock('../providers/ssh-pty-provider', () => ({ + SshPtyProvider: class MockSshPtyProvider { + onData = vi.fn().mockReturnValue(() => {}) + onReplay = vi.fn().mockReturnValue(() => {}) + onExit = vi.fn().mockReturnValue(() => {}) + attach = vi.fn().mockResolvedValue(undefined) + attachForReconnect = vi.fn().mockResolvedValue({}) + dispose = vi.fn() + } +})) +vi.mock('../providers/ssh-filesystem-provider', () => ({ + SshFilesystemProvider: class MockSshFilesystemProvider { + dispose = vi.fn() + } +})) +vi.mock('../providers/ssh-git-provider', () => ({ + SshGitProvider: class MockSshGitProvider {} +})) +vi.mock('../ipc/pty', () => ({ + registerSshPtyProvider: vi.fn(), + unregisterSshPtyProvider: vi.fn(), + getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), + getPtyIdsForConnection: vi.fn().mockReturnValue([]), + clearPtyOwnershipForConnection: vi.fn(), + clearProviderPtyState: vi.fn(), + deletePtyOwnership: vi.fn(), + setPtyOwnership: vi.fn(), + restorePtyIncarnation: vi.fn(), + isCurrentPtyExit: vi.fn(() => true), + answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) +})) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + registerSshFilesystemProvider: vi.fn(), + unregisterSshFilesystemProvider: vi.fn(), + getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) +})) +vi.mock('../providers/ssh-git-dispatch', () => ({ + registerSshGitProvider: vi.fn(), + unregisterSshGitProvider: vi.fn() +})) + +const { registerSshPtyProvider, clearProviderPtyState, deletePtyOwnership, isCurrentPtyExit } = + await import('../ipc/pty') + +describe('SSH relay PTY incarnation exits', () => { + beforeEach(() => { + vi.clearAllMocks() + muxRequestMock.mockResolvedValue([]) + mockDeploySuccess() + vi.mocked(isCurrentPtyExit).mockReturnValue(true) + }) + + it('drops a stale exit before ownership cleanup and propagates a current incarnation', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const runtime = { onPtyData: vi.fn(), onPtyExit: vi.fn() } + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + runtime as never + ) + await session.establish(mockConn) + const provider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onExit: ReturnType + } + const onExit = provider.onExit.mock.calls[0]?.[0] as (payload: { + id: string + code: number + incarnationId: string + }) => void + vi.mocked(isCurrentPtyExit).mockReturnValueOnce(false) + + onExit({ id: 'ssh:target-1@@pty-reused', code: 0, incarnationId: 'old-incarnation' }) + + expect(clearProviderPtyState).not.toHaveBeenCalled() + expect(deletePtyOwnership).not.toHaveBeenCalled() + expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled() + expect(runtime.onPtyExit).not.toHaveBeenCalled() + expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) + + onExit({ id: 'ssh:target-1@@pty-reused', code: 7, incarnationId: 'current-incarnation' }) + expect(runtime.onPtyExit).toHaveBeenCalledWith( + 'ssh:target-1@@pty-reused', + 7, + 'current-incarnation' + ) + }) +}) diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts new file mode 100644 index 00000000000..1a3e8721064 --- /dev/null +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -0,0 +1,283 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SshRelaySession } from './ssh-relay-session' +import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' + +const { muxRequestMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn() })) + +vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) +vi.mock('./ssh-channel-multiplexer', () => ({ + SshChannelMultiplexer: class MockSshChannelMultiplexer { + notify = vi.fn() + request = muxRequestMock + onNotification = vi.fn().mockReturnValue(() => {}) + onRequest = vi.fn().mockReturnValue(() => {}) + onDispose = vi.fn().mockReturnValue(() => {}) + dispose = vi.fn() + isDisposed = vi.fn().mockReturnValue(false) + } +})) +vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ + installRemoteManagedAgentHooks: vi.fn() +})) +vi.mock('../providers/ssh-pty-provider', () => ({ + isSshPtyNotFoundError: (error: unknown) => String(error).includes('not found'), + isSshPtyIdentityMismatchError: (error: unknown) => String(error).includes('identity mismatch'), + SshPtyProvider: class MockSshPtyProvider { + onData = vi.fn().mockReturnValue(() => {}) + onReplay = vi.fn().mockReturnValue(() => {}) + onExit = vi.fn().mockReturnValue(() => {}) + attach = vi.fn().mockResolvedValue(undefined) + attachForReconnect = vi.fn().mockResolvedValue({}) + dispose = vi.fn() + } +})) +vi.mock('../providers/ssh-filesystem-provider', () => ({ + SshFilesystemProvider: class MockSshFilesystemProvider { + dispose = vi.fn() + } +})) +vi.mock('../providers/ssh-git-provider', () => ({ + SshGitProvider: class MockSshGitProvider {} +})) +vi.mock('../ipc/pty', () => ({ + registerSshPtyProvider: vi.fn(), + unregisterSshPtyProvider: vi.fn(), + getSshPtyProvider: vi.fn(), + getPtyIdsForConnection: vi.fn().mockReturnValue([]), + clearPtyOwnershipForConnection: vi.fn(), + clearProviderPtyState: vi.fn(), + deletePtyOwnership: vi.fn(), + setPtyOwnership: vi.fn(), + restorePtyIncarnation: vi.fn(), + isCurrentPtyExit: vi.fn(() => true), + answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) +})) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + registerSshFilesystemProvider: vi.fn(), + unregisterSshFilesystemProvider: vi.fn(), + getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) +})) +vi.mock('../providers/ssh-git-dispatch', () => ({ + registerSshGitProvider: vi.fn(), + unregisterSshGitProvider: vi.fn() +})) + +const { + registerSshPtyProvider, + getSshPtyProvider, + getPtyIdsForConnection, + setPtyOwnership, + restorePtyIncarnation +} = await import('../ipc/pty') + +const APP_PTY_ID = 'ssh:target-1@@pty-live' +const INCARNATION_LEAF_ID = '11111111-1111-4111-8111-111111111111' + +function detachedLease() { + return { + targetId: 'target-1', + ptyId: 'pty-live', + state: 'detached' as const, + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: INCARNATION_LEAF_ID + } +} + +function emitExitDuringAttach(payload: { id: string; code: number; incarnationId?: string }): void { + const registeredProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onExit: ReturnType + } + const exitHandler = registeredProvider.onExit.mock.calls[0]?.[0] as + | ((exit: typeof payload) => void) + | undefined + queueMicrotask(() => exitHandler?.(payload)) +} + +describe('SshRelaySession reconnect incarnation ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS + muxRequestMock.mockReset() + muxRequestMock.mockResolvedValue([]) + mockDeploySuccess() + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + }) + + it('restores and persists exact incarnation proof from reconnect attach', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const incarnationId = 'incarnation-reconnect' + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect: vi.fn().mockResolvedValue({ incarnationId }), + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< + typeof mockStore.getSshRemotePtyLeases + >) + const runtime = { onPtySpawned: vi.fn(), registerPty: vi.fn() } + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + runtime as never + ) + + await session.establish(mockConn) + + expect(restorePtyIncarnation).toHaveBeenCalledWith(APP_PTY_ID, incarnationId) + expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', { + tabId: 'tab-1', + leafId: INCARNATION_LEAF_ID, + incarnationId + }) + expect(runtime.onPtySpawned).not.toHaveBeenCalled() + expect(mockStore.persistPtyBinding).toHaveBeenCalledWith({ + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: INCARNATION_LEAF_ID, + ptyId: APP_PTY_ID, + incarnationId + }) + expect(vi.mocked(mockStore.persistPtyBinding).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(mockStore.markSshRemotePtyLease).mock.invocationCallOrder[0]! + ) + }) + + it('does not restore a PTY whose matching exit shares the attach reply batch', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const incarnationId = 'incarnation-exited-during-attach' + const runtime = { + acceptPtyIncarnationForExit: vi.fn(), + onPtyExit: vi.fn(), + onPtySpawned: vi.fn(), + registerPty: vi.fn() + } + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect: vi.fn().mockImplementation(async () => { + emitExitDuringAttach({ id: APP_PTY_ID, code: 0, incarnationId }) + emitExitDuringAttach({ id: APP_PTY_ID, code: 0, incarnationId: 'incarnation-stale' }) + return { incarnationId, replay: 'dead-output' } + }), + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< + typeof mockStore.getSshRemotePtyLeases + >) + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + runtime as never + ) + + await session.establish(mockConn) + + expect(runtime.onPtyExit).toHaveBeenCalledWith(APP_PTY_ID, 0, incarnationId) + expect(runtime.acceptPtyIncarnationForExit).toHaveBeenCalledWith(APP_PTY_ID, incarnationId) + expect(runtime.registerPty).not.toHaveBeenCalled() + expect(restorePtyIncarnation).toHaveBeenCalledWith(APP_PTY_ID, incarnationId) + expect(setPtyOwnership).not.toHaveBeenCalled() + expect(mockStore.persistPtyBinding).not.toHaveBeenCalled() + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + 'target-1', + 'pty-live', + 'terminated' + ) + expect( + vi + .mocked(mockWindow.webContents.send) + .mock.calls.some(([channel]) => channel === 'pty:replay') + ).toBe(false) + }) + + it('ignores an older incarnation exit while reconnecting a reused PTY id', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const currentIncarnationId = 'incarnation-current' + const runtime = { + acceptPtyIncarnationForExit: vi.fn(), + onPtyExit: vi.fn(), + onPtySpawned: vi.fn(), + registerPty: vi.fn() + } + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect: vi.fn().mockImplementation(async () => { + emitExitDuringAttach({ + id: APP_PTY_ID, + code: 0, + incarnationId: 'incarnation-old' + }) + return { incarnationId: currentIncarnationId, replay: 'live-output' } + }), + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< + typeof mockStore.getSshRemotePtyLeases + >) + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + runtime as never + ) + + await session.establish(mockConn) + + expect(runtime.onPtyExit).not.toHaveBeenCalled() + expect(runtime.acceptPtyIncarnationForExit).not.toHaveBeenCalled() + expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', { + tabId: 'tab-1', + leafId: INCARNATION_LEAF_ID, + incarnationId: currentIncarnationId + }) + expect(setPtyOwnership).toHaveBeenCalledWith(APP_PTY_ID, 'target-1') + expect(mockStore.persistPtyBinding).toHaveBeenCalledWith( + expect.objectContaining({ ptyId: APP_PTY_ID, incarnationId: currentIncarnationId }) + ) + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { + id: APP_PTY_ID, + data: 'live-output' + }) + }) + + it('keeps the attached PTY when incarnation backfill persistence fails', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const incarnationId = 'incarnation-reconnect' + vi.mocked(getSshPtyProvider).mockReturnValue({ + attachForReconnect: vi.fn().mockResolvedValue({ incarnationId }), + dispose: vi.fn() + } as unknown as ReturnType) + vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< + typeof mockStore.getSshRemotePtyLeases + >) + vi.mocked(mockStore.persistPtyBinding).mockImplementationOnce(() => { + throw new Error('disk full') + }) + const runtime = { onPtySpawned: vi.fn(), registerPty: vi.fn() } + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + runtime as never + ) + + await expect(session.establish(mockConn)).resolves.toBeUndefined() + + expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', { + tabId: 'tab-1', + leafId: INCARNATION_LEAF_ID, + incarnationId + }) + expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-live', 'attached') + expect(consoleError).toHaveBeenCalledWith( + '[ssh-relay-session] Failed to persist reconnect incarnation:', + expect.any(Error) + ) + consoleError.mockRestore() + }) +}) diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index 0b4e8188c07..679758d5e11 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -19,7 +19,8 @@ export function createMockDeps(): SshRelaySessionTestDeps { getRepos: vi.fn().mockReturnValue([]), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), markSshRemotePtyLease: vi.fn(), - markSshRemotePtyLeases: vi.fn() + markSshRemotePtyLeases: vi.fn(), + persistPtyBinding: vi.fn() } as unknown as Store const mockPortForward = { removeAllForwards: vi.fn() diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 826911bf0a6..8fe7a292bcf 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -69,7 +69,9 @@ vi.mock('../ipc/pty', () => ({ clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn() + setPtyOwnership: vi.fn(), + restorePtyIncarnation: vi.fn(), + isCurrentPtyExit: vi.fn(() => true) })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 146f2b2dd5b..fdd0b115203 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -7,11 +7,9 @@ import { execCommand } from './ssh-relay-deploy-helpers' import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' import type { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { - SshPtyProvider, - isSshPtyIdentityMismatchError, - isSshPtyNotFoundError -} from '../providers/ssh-pty-provider' +import { SshPtyProvider } from '../providers/ssh-pty-provider' +import type { SshPtyExitCallback } from '../providers/ssh-pty-provider-contract' +import { isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from '../providers/ssh-pty-errors' import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' import { SshGitProvider } from '../providers/ssh-git-provider' @@ -34,7 +32,9 @@ import { clearPtyOwnershipForConnection, clearProviderPtyState, deletePtyOwnership, - setPtyOwnership + setPtyOwnership, + restorePtyIncarnation, + isCurrentPtyExit } from '../ipc/pty' import { recordHiddenRendererPtyDataDrop, @@ -71,6 +71,9 @@ import { isValidTerminalTabId } from '../../shared/terminal-tab-id' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' +type SshPtyExitPayload = Parameters[0] +type PendingPtyReattach = { exits: SshPtyExitPayload[] } + type RemoteCliBridgeEnv = { remoteHome: string binDir: string @@ -147,6 +150,7 @@ export class SshRelaySession { private hostPlatform: RemoteHostPlatform | null = null private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null private forwardedReattachReplayByPty = new Map() + private pendingPtyReattaches = new Map() constructor( readonly targetId: string, @@ -956,19 +960,32 @@ export class SshRelaySession { } }) ptyProvider.onExit((payload) => { - const relayPtyId = toRelaySshPtyId(this.targetId, payload.id) - clearProviderPtyState(payload.id) - deletePtyOwnership(payload.id) - this.forwardedReattachReplayByPty.delete(payload.id) - this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') - this.runtime?.onPtyExit(payload.id, payload.code) - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('pty:exit', payload) + const pendingReattach = this.pendingPtyReattaches.get(payload.id) + if (pendingReattach) { + // Why: attach response and exit can share one transport batch, before incarnation restoration runs. + pendingReattach.exits.push(payload) + return } + if (!isCurrentPtyExit(payload)) { + return + } + this.retireExitedPty(payload) }) } + private retireExitedPty(payload: SshPtyExitPayload): void { + const relayPtyId = toRelaySshPtyId(this.targetId, payload.id) + clearProviderPtyState(payload.id) + deletePtyOwnership(payload.id) + this.forwardedReattachReplayByPty.delete(payload.id) + this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') + this.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId) + const win = this.getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('pty:exit', payload) + } + } + private replayFingerprint(data: string): string { const head = data.slice(0, REPLAY_FINGERPRINT_EDGE_CHARS) const tail = data.slice(-REPLAY_FINGERPRINT_EDGE_CHARS) @@ -1001,6 +1018,7 @@ export class SshRelaySession { const activeLeases = this.store .getSshRemotePtyLeases(this.targetId) .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') + const activeLeaseByPtyId = new Map(activeLeases.map((lease) => [lease.ptyId, lease])) const leasedPtyIds = activeLeases.map((lease) => lease.ptyId) // Why: pass pane identity so the relay can reject cross-generation id collisions; tabId falls back for pre-leafId leases. const expectedIdentityByPtyId = new Map( @@ -1028,6 +1046,9 @@ export class SshRelaySession { if (!shouldContinue()) { return } + const appPtyId = toAppSshPtyId(this.targetId, ptyId) + const pendingReattach: PendingPtyReattach = { exits: [] } + this.pendingPtyReattaches.set(appPtyId, pendingReattach) try { const expectedIdentity = expectedIdentityByPtyId.get(ptyId) const attachResult = @@ -1037,15 +1058,55 @@ export class SshRelaySession { if (!shouldContinue()) { return } - const appPtyId = toAppSshPtyId(this.targetId, ptyId) + const exitDuringAttach = pendingReattach.exits.find( + (exit) => + !exit.incarnationId || + !attachResult.incarnationId || + exit.incarnationId === attachResult.incarnationId + ) + if (exitDuringAttach) { + if (attachResult.incarnationId) { + restorePtyIncarnation(appPtyId, attachResult.incarnationId) + this.runtime?.acceptPtyIncarnationForExit(appPtyId, attachResult.incarnationId) + } + this.retireExitedPty(exitDuringAttach) + continue + } setPtyOwnership(appPtyId, this.targetId) + if (attachResult.incarnationId) { + restorePtyIncarnation(appPtyId, attachResult.incarnationId) + const lease = activeLeaseByPtyId.get(ptyId) + if (lease?.worktreeId && lease.tabId && lease.leafId) { + this.runtime?.registerPty(appPtyId, lease.worktreeId, this.targetId, { + tabId: lease.tabId, + leafId: lease.leafId, + incarnationId: attachResult.incarnationId + }) + // Why: reconnect may be the first new-relay response that can backfill exact exit fencing. + try { + this.store.persistPtyBinding({ + worktreeId: lease.worktreeId, + tabId: lease.tabId, + leafId: lease.leafId, + ptyId: appPtyId, + incarnationId: attachResult.incarnationId + }) + } catch (error) { + // Why: this backfill improves future fencing but must not disconnect an already-live relay PTY. + console.error('[ssh-relay-session] Failed to persist reconnect incarnation:', error) + } + } else { + this.runtime?.onPtySpawned(appPtyId, attachResult.incarnationId, { + awaitsRegistration: false + }) + } + } this.store.markSshRemotePtyLease(this.targetId, ptyId, 'attached') this.forwardReattachReplay(appPtyId, attachResult.replay ?? '') } catch (err) { if (!isSshPtyNotFoundError(err)) { throw err } - const appPtyId = toAppSshPtyId(this.targetId, ptyId) if (isSshPtyIdentityMismatchError(err)) { console.warn( `[ssh-relay-session] Ignoring stale PTY ${ptyId} for ${this.targetId} after relay identity mismatch: ${ @@ -1068,6 +1129,10 @@ export class SshRelaySession { if (win && !win.isDestroyed()) { win.webContents.send('pty:exit', { id: appPtyId, code: -1 }) } + } finally { + if (this.pendingPtyReattaches.get(appPtyId) === pendingReattach) { + this.pendingPtyReattaches.delete(appPtyId) + } } } } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 3e01840c7b5..60f944456d4 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -40,10 +40,15 @@ import { } from './pty-handler' import type { RelayDispatcher } from './dispatcher' +type TestRequestContext = { + isStale: () => boolean + signal?: AbortSignal +} + function createMockDispatcher() { const requestHandlers = new Map< string, - (params: Record, context?: { isStale: () => boolean }) => Promise + (params: Record, context?: TestRequestContext) => Promise >() const notificationHandlers = new Map) => void>() const notifications: { method: string; params?: Record }[] = [] @@ -52,10 +57,7 @@ function createMockDispatcher() { onRequest: vi.fn( ( method: string, - handler: ( - params: Record, - context?: { isStale: () => boolean } - ) => Promise + handler: (params: Record, context?: TestRequestContext) => Promise ) => { requestHandlers.set(method, handler) } @@ -73,7 +75,7 @@ function createMockDispatcher() { async callRequest( method: string, params: Record = {}, - context?: { isStale: () => boolean } + context?: TestRequestContext ) { const handler = requestHandlers.get(method) if (!handler) { @@ -97,6 +99,24 @@ describe('PtyHandler', () => { let dispatcher: ReturnType let handler: PtyHandler + async function spawnPty( + params: Record = {} + ): Promise<{ id: string; incarnationId: string }> { + return (await dispatcher.callRequest('pty.spawn', params)) as { + id: string + incarnationId: string + } + } + + async function attachPty( + params: Record + ): Promise<{ incarnationId: string; replay?: string }> { + return (await dispatcher.callRequest('pty.attach', params)) as { + incarnationId: string + replay?: string + } + } + beforeEach(() => { vi.useFakeTimers() mockPtySpawn.mockReset() @@ -176,8 +196,8 @@ describe('PtyHandler', () => { }) it('spawns a PTY and returns an id', async () => { - const result = await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) - expect(result).toEqual({ id: 'pty-1' }) + const result = await spawnPty({ cols: 80, rows: 24 }) + expect(result).toEqual({ id: 'pty-1', incarnationId: expect.any(String) }) expect(mockPtySpawn).toHaveBeenCalled() expect(handler.activePtyCount).toBe(1) }) @@ -224,6 +244,128 @@ describe('PtyHandler', () => { expect(spawnOptions.env.NODE_ENV).toBe('production') }) + it('replays an operation-owned spawn after its first response becomes stale', async () => { + const operationId = 'a'.repeat(43) + + await dispatcher.callRequest( + 'pty.spawn', + { cols: 80, rows: 24, agentSessionCreateOperationId: operationId }, + { isStale: () => mockPtySpawn.mock.calls.length > 0 } + ) + const replayed = await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + agentSessionCreateOperationId: operationId + }) + + expect(replayed).toEqual({ id: 'pty-1', incarnationId: expect.any(String) }) + expect(mockPtySpawn).toHaveBeenCalledOnce() + expect(mockPtyInstance.kill).not.toHaveBeenCalled() + expect(handler.activePtyCount).toBe(1) + }) + + it('retains an operation fence when publication fails after native spawn', async () => { + const operationId = 'f'.repeat(43) + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn(() => { + throw new Error('listener publication failed') + }) + }) + const request = { + cols: 80, + rows: 24, + agentSessionCreateOperationId: operationId + } + + await expect(dispatcher.callRequest('pty.spawn', request)).rejects.toThrow( + 'listener publication failed' + ) + await expect(dispatcher.callRequest('pty.spawn', request)).rejects.toThrow( + 'listener publication failed' + ) + expect(mockPtySpawn).toHaveBeenCalledOnce() + expect(handler.activePtyCount).toBe(1) + }) + + it('releases a canceled operation before native spawn after module preflight', async () => { + let finishModuleLoad!: (value: { spawn: typeof mockPtySpawn }) => void + const moduleLoad = new Promise<{ spawn: typeof mockPtySpawn }>((resolve) => { + finishModuleLoad = resolve + }) + const internals = handler as unknown as { + loadPty(): Promise<{ spawn: typeof mockPtySpawn } | null> + } + const loadPty = vi.spyOn(internals, 'loadPty').mockReturnValueOnce(moduleLoad) + const abort = new AbortController() + const operationId = 'c'.repeat(43) + const request = { cols: 80, rows: 24, agentSessionCreateOperationId: operationId } + const spawning = dispatcher.callRequest('pty.spawn', request, { + isStale: () => abort.signal.aborted, + signal: abort.signal + }) + + abort.abort() + finishModuleLoad({ spawn: mockPtySpawn }) + await expect(spawning).rejects.toThrow('client_disconnected') + expect(mockPtySpawn).not.toHaveBeenCalled() + + loadPty.mockResolvedValue({ spawn: mockPtySpawn }) + await expect(dispatcher.callRequest('pty.spawn', request)).resolves.toMatchObject({ + id: expect.stringMatching(/^pty-/) + }) + expect(mockPtySpawn).toHaveBeenCalledOnce() + }) + + it('rejects malformed create operation ids before spawning', async () => { + await expect( + dispatcher.callRequest('pty.spawn', { agentSessionCreateOperationId: 'not-valid' }) + ).rejects.toThrow('agent_session_operation_invalid') + expect(mockPtySpawn).not.toHaveBeenCalled() + }) + + it('adopts only the exact claimed owner generation on relay retry', async () => { + const agentSessionEnsure = { + claim: { + digestVersion: 1, + keyId: 'claim-key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' + }, + surface: { + worktreeId: 'repo::/tmp/worktree', + tabId: '11111111-1111-4111-8111-111111111111', + leafId: '22222222-2222-4222-8222-222222222222', + terminalHandle: 'term_claimed' + } + } + + const first = (await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + agentSessionEnsure + })) as Record + const second = (await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + agentSessionEnsure + })) as Record + + expect(first).toMatchObject({ + id: 'pty-1', + agentSessionEnsure: { disposition: 'created' } + }) + expect(second).toMatchObject({ + id: 'pty-1', + agentSessionEnsure: { disposition: 'adopted' } + }) + expect(second.agentSessionEnsure).toMatchObject({ + owner: (first.agentSessionEnsure as { owner: unknown }).owner + }) + expect(mockPtySpawn).toHaveBeenCalledOnce() + }) + it('normalizes a missing native binding as degraded node-pty availability', async () => { mockPtySpawn.mockImplementationOnce(() => { throw new Error( @@ -768,8 +910,9 @@ describe('PtyHandler', () => { process.env.SHELL = '/bin/bash' process.env.HOME = homeDir + let spawn!: { id: string; incarnationId: string } try { - await dispatcher.callRequest('pty.spawn', { + spawn = await spawnPty({ env: { HOME: homeDir }, command: 'echo fallback', commandDelivery: 'provider', @@ -799,11 +942,14 @@ describe('PtyHandler', () => { data: '\x1b]777;orca-shell-ready' }) - const result = await dispatcher.callRequest('pty.attach', { + const result = await attachPty({ id: 'pty-1', suppressReplayNotification: true }) - expect(result).toEqual({ replay: '\x1b]777;orca-shell-ready' }) + expect(result).toEqual({ + incarnationId: spawn.incarnationId, + replay: '\x1b]777;orca-shell-ready' + }) } ) @@ -885,16 +1031,16 @@ describe('PtyHandler', () => { }), onExit: vi.fn() }) - await dispatcher.callRequest('pty.spawn', {}) + const spawn = await spawnPty() dataCallback?.('prompt$ ') const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) try { - const result = await dispatcher.callRequest('pty.attach', { + const result = await attachPty({ id: 'pty-1', suppressReplayNotification: true }) - expect(result).toEqual({ replay: 'prompt$ ' }) + expect(result).toEqual({ incarnationId: spawn.incarnationId, replay: 'prompt$ ' }) } finally { aliveSpy.mockRestore() } @@ -914,7 +1060,13 @@ describe('PtyHandler', () => { } mockPtySpawn.mockReturnValue(term) - await dispatcher.callRequest('pty.spawn', {}, { isStale: () => true }) + await dispatcher.callRequest( + 'pty.spawn', + {}, + { + isStale: () => mockPtySpawn.mock.calls.length > 0 + } + ) // Why: assert via the captured spy reference rather than term.kill because // disposeManagedPty() neutralizes managed.pty.kill (replaces it with a @@ -938,7 +1090,7 @@ describe('PtyHandler', () => { await dispatcher.callRequest( 'pty.spawn', { command: 'echo stale', commandDelivery: 'provider' }, - { isStale: () => true } + { isStale: () => mockPtySpawn.mock.calls.length > 0 } ) vi.advanceTimersByTime(50) @@ -1048,7 +1200,7 @@ describe('PtyHandler', () => { id: 'pty-1', suppressReplayNotification: true }) - ).resolves.toEqual({ replay: 'prompt' }) + ).resolves.toEqual({ replay: 'prompt', incarnationId: expect.any(String) }) }) it('leaves startup queries untouched for an unsupported relay capability version', async () => { @@ -1264,15 +1416,15 @@ describe('PtyHandler', () => { onExit: vi.fn() }) - await dispatcher.callRequest('pty.spawn', {}) + const spawn = await spawnPty() dataCallback!('buffered output') - const result = await dispatcher.callRequest('pty.attach', { + const result = await attachPty({ id: 'pty-1', suppressReplayNotification: true }) - expect(result).toEqual({ replay: 'buffered output' }) + expect(result).toEqual({ incarnationId: spawn.incarnationId, replay: 'buffered output' }) expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.replay', expect.anything()) vi.advanceTimersByTime(8) expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', expect.anything()) @@ -1288,13 +1440,13 @@ describe('PtyHandler', () => { onExit: vi.fn() }) - await dispatcher.callRequest('pty.spawn', {}) + const spawn = await spawnPty() dataCallback!('buffered output') dispatcher.notify.mockClear() - const result = await dispatcher.callRequest('pty.attach', { id: 'pty-1' }) + const result = await attachPty({ id: 'pty-1' }) - expect(result).toEqual({}) + expect(result).toEqual({ incarnationId: spawn.incarnationId }) expect(dispatcher.notify).toHaveBeenCalledWith('pty.replay', { id: 'pty-1', data: 'buffered output' @@ -1308,8 +1460,9 @@ describe('PtyHandler', () => { const oldTabId = process.env.ORCA_TAB_ID delete process.env.ORCA_PANE_KEY delete process.env.ORCA_TAB_ID + let spawn!: { id: string; incarnationId: string } try { - await dispatcher.callRequest('pty.spawn', { + spawn = await spawnPty({ env: { FOO: 'bar' }, paneKey: 'tab-a:leaf-a', tabId: 'tab-a' @@ -1332,7 +1485,7 @@ describe('PtyHandler', () => { expect(spawnOptions.env.ORCA_TAB_ID).toBeUndefined() await expect( - dispatcher.callRequest('pty.attach', { + attachPty({ id: 'pty-1', expectedPaneKey: 'tab-b:leaf-b', expectedTabId: 'tab-b' @@ -1340,12 +1493,12 @@ describe('PtyHandler', () => { ).rejects.toThrow('PTY "pty-1" not found') await expect( - dispatcher.callRequest('pty.attach', { + attachPty({ id: 'pty-1', expectedPaneKey: 'tab-a:leaf-a', expectedTabId: 'tab-a' }) - ).resolves.toEqual({}) + ).resolves.toEqual({ incarnationId: spawn.incarnationId }) }) it('notifies on PTY exit and removes from map', async () => { @@ -1358,11 +1511,15 @@ describe('PtyHandler', () => { }) }) - await dispatcher.callRequest('pty.spawn', {}) + const spawn = await spawnPty() expect(handler.activePtyCount).toBe(1) exitCallback!({ exitCode: 0 }) - expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { id: 'pty-1', code: 0 }) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { + id: 'pty-1', + code: 0, + incarnationId: spawn.incarnationId + }) expect(handler.activePtyCount).toBe(0) }) @@ -1379,7 +1536,7 @@ describe('PtyHandler', () => { }) }) - await dispatcher.callRequest('pty.spawn', {}) + const spawn = await spawnPty() dataCallback!('final output') exitCallback!({ exitCode: 0 }) @@ -1387,7 +1544,11 @@ describe('PtyHandler', () => { id: 'pty-1', data: 'final output' }) - expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'pty.exit', { id: 'pty-1', code: 0 }) + expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'pty.exit', { + id: 'pty-1', + code: 0, + incarnationId: spawn.incarnationId + }) }) it('writes data to PTY via pty.data notification', async () => { @@ -1544,7 +1705,13 @@ describe('PtyHandler', () => { it('does not retry stale-spawn cleanup after the Windows kill deadline', async () => { await withWindowsPlatform(async () => { const mockKill = mockKillablePty() - await dispatcher.callRequest('pty.spawn', {}, { isStale: () => true }) + await dispatcher.callRequest( + 'pty.spawn', + {}, + { + isStale: () => mockPtySpawn.mock.calls.length > 0 + } + ) expectBareKills(mockKill, 1) vi.advanceTimersByTime(5000) expectBareKills(mockKill, 1) @@ -1623,7 +1790,7 @@ describe('PtyHandler', () => { const exits: { id: string; paneKey?: string }[] = [] handler.setExitListener((evt) => exits.push(evt)) - await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-fallback:0' } }) + const spawn = await spawnPty({ env: { ORCA_PANE_KEY: 'tab-fallback:0' } }) await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false }) vi.advanceTimersByTime(5000) @@ -1634,7 +1801,11 @@ describe('PtyHandler', () => { expect(mockKill).toHaveBeenCalledWith('SIGTERM') expect(mockKill).toHaveBeenCalledWith('SIGKILL') - expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { id: 'pty-1', code: 137 }) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { + id: 'pty-1', + code: 137, + incarnationId: spawn.incarnationId + }) expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-fallback:0' }]) expect(handler.activePtyCount).toBe(0) }) @@ -1770,22 +1941,22 @@ describe('PtyHandler', () => { onExit: vi.fn() }) - await dispatcher.callRequest('pty.spawn', {}) + const spawn = await spawnPty() dataCallback!('initial output') - const r1 = await dispatcher.callRequest('pty.attach', { + const r1 = await attachPty({ id: 'pty-1', suppressReplayNotification: true }) - expect(r1).toEqual({ replay: 'initial output' }) + expect(r1).toEqual({ incarnationId: spawn.incarnationId, replay: 'initial output' }) dataCallback!(' more') - const r2 = await dispatcher.callRequest('pty.attach', { + const r2 = await attachPty({ id: 'pty-1', suppressReplayNotification: true }) - expect(r2).toEqual({ replay: 'initial output more' }) + expect(r2).toEqual({ incarnationId: spawn.incarnationId, replay: 'initial output more' }) }) it('second app restart still replays full buffer', async () => { @@ -1798,30 +1969,33 @@ describe('PtyHandler', () => { onExit: vi.fn() }) - await dispatcher.callRequest('pty.spawn', {}) + const spawn = await spawnPty() dataCallback!('$ while true; do date; done\r\n') dataCallback!('Mon Apr 28\r\n') - await dispatcher.callRequest('pty.attach', { + const firstAttach = await attachPty({ id: 'pty-1', suppressReplayNotification: true }) + expect(firstAttach.incarnationId).toBe(spawn.incarnationId) dataCallback!('Tue Apr 29\r\n') - await dispatcher.callRequest('pty.attach', { + const secondAttach = await attachPty({ id: 'pty-1', suppressReplayNotification: true }) + expect(secondAttach.incarnationId).toBe(spawn.incarnationId) dataCallback!('Wed Apr 30\r\n') - const result = await dispatcher.callRequest('pty.attach', { + const result = await attachPty({ id: 'pty-1', suppressReplayNotification: true }) expect(result).toEqual({ + incarnationId: spawn.incarnationId, replay: '$ while true; do date; done\r\nMon Apr 28\r\nTue Apr 29\r\nWed Apr 30\r\n' }) }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b9c4fae001a..2bc9ead9cfe 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -3,6 +3,7 @@ import type { IPty } from 'node-pty' import type * as NodePty from 'node-pty' import { existsSync } from 'node:fs' import { join } from 'node:path' +import { randomUUID } from 'node:crypto' import { resolveWindowsGitBashShellPath } from '../main/git-bash' import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell' import type { RelayDispatcher, RequestContext } from './dispatcher' @@ -47,6 +48,17 @@ import { type PtyIngressEmission } from '../shared/pty-startup-ingress' import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend' +import { + agentSessionOwnerBindingsEqual, + ClaimedAgentPtyOwnerRegistry +} from '../shared/claimed-agent-pty-owner' +import { + AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION, + AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION, + isAgentSessionExecutionClaim, + isAgentSessionSurfaceBinding, + type AgentSessionOwnerBinding +} from '../shared/agent-session-host-authority' function isMissingNodePtyNativeBinding(error: unknown): boolean { return ( @@ -57,6 +69,7 @@ function isMissingNodePtyNativeBinding(error: unknown): boolean { type ManagedPty = { id: string + incarnationId: string pty: IPty initialCwd: string buffered: string @@ -83,8 +96,20 @@ type ManagedPty = { startupIngress?: PtyStartupIngress startupIngressIntent?: ReturnType ownerBackend: PtyOwnerBackend + agentSessionOwners?: AgentSessionOwnerBinding[] } +type RelayAgentSessionCreateResult = { + id: string + incarnationId: string + replay?: string + agentSessionEnsure?: unknown +} + +const AGENT_SESSION_CREATE_OPERATION_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/ +const AGENT_SESSION_CREATE_OPERATION_RETENTION_MS = 24 * 60 * 60 * 1000 +const AGENT_SESSION_CREATE_OPERATION_LIMIT = 4_096 + type PendingPtyOutput = { data: string rawLength?: number @@ -200,10 +225,12 @@ function resolvePtyShellOverride(shellOverride: string): string { type PtyProcessSummary = { id: string + incarnationId: string cwd: string title: string worktreeId?: string terminalHandle?: string + agentSessionOwners?: AgentSessionOwnerBinding[] } type SerializedPtyEntry = { @@ -221,6 +248,7 @@ type SerializedPtyEntry = { envToDelete?: string[] /** Optional for state serialized by relays predating the credential guard. */ gitCredentialPromptGuarded?: boolean + agentSessionOwners?: AgentSessionOwnerBinding[] } function sanitizeEnvToDelete(value: unknown): string[] { @@ -283,6 +311,11 @@ export class PtyHandler { private exitListener: PtyExitListener | null = null // Why: env augmenters run on every spawn so each PTY sees live hook coords without the dispatcher knowing about agent hooks. private envAugmenters: PtyEnvAugmenter[] = [] + private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() + private readonly agentSessionCreateOperations = new Map< + string, + Promise + >() constructor(dispatcher: RelayDispatcher, graceTimeMs = DEFAULT_GRACE_TIME_MS) { this.dispatcher = dispatcher @@ -535,8 +568,13 @@ export class PtyHandler { this.clearStartupCommandTimer(managed) this.releaseRelayIngress(managed) this.flushPtyOutput(managed.id) - this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode }) + this.dispatcher.notify('pty.exit', { + id: managed.id, + code: exitCode, + incarnationId: managed.incarnationId + }) this.notifyExitListener(managed) + this.agentSessionOwners.release(managed.id) this.ptys.delete(managed.id) this.clearPtyFlowState(managed.id) // Why: release the ptmx fd on natural exit, else the master fd leaks until GC (docs/fix-pty-fd-leak.md). @@ -583,6 +621,11 @@ 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.getCapabilities', async () => ({ + startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, + agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION, + agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION + })) this.dispatcher.onRequest('pty.listProcesses', () => this.listProcesses()) this.dispatcher.onRequest('pty.getDefaultShell', async () => resolveDefaultShell()) this.dispatcher.onRequest('pty.serialize', (p) => this.serialize(p)) @@ -800,14 +843,141 @@ export class PtyHandler { private async spawn( params: Record, context?: RequestContext - ): Promise<{ id: string }> { + ): Promise { + const operationId = params.agentSessionCreateOperationId + if (operationId === undefined) { + return await this.spawnOnce(params, context) + } + if ( + typeof operationId !== 'string' || + !AGENT_SESSION_CREATE_OPERATION_ID_PATTERN.test(operationId) + ) { + throw new Error('agent_session_operation_invalid') + } + const existing = this.agentSessionCreateOperations.get(operationId) + if (existing) { + return await existing + } + if (this.agentSessionCreateOperations.size >= AGENT_SESSION_CREATE_OPERATION_LIMIT) { + throw new Error('agent_session_operation_capacity') + } + const operation = this.spawnOnce(params, context) + this.agentSessionCreateOperations.set(operationId, operation) + try { + const result = await operation + this.expireAgentSessionCreateOperation(operationId, operation) + return result + } catch (error) { + const outcomeUnknown = + typeof error === 'object' && + error !== null && + 'agentSessionOperationOutcome' in error && + error.agentSessionOperationOutcome === 'unknown' + if (outcomeUnknown) { + // Why: the native PTY may be live; replay the same failure instead of spawning again. + this.expireAgentSessionCreateOperation(operationId, operation) + } else if (this.agentSessionCreateOperations.get(operationId) === operation) { + this.agentSessionCreateOperations.delete(operationId) + } + throw error + } + } + + private expireAgentSessionCreateOperation( + operationId: string, + operation: Promise + ): void { + const timer = setTimeout(() => { + if (this.agentSessionCreateOperations.get(operationId) === operation) { + this.agentSessionCreateOperations.delete(operationId) + } + }, AGENT_SESSION_CREATE_OPERATION_RETENTION_MS) + timer.unref?.() + } + + private async spawnOnce( + params: Record, + context?: RequestContext + ): Promise { const env = params.env as Record | undefined const worktreeId = env?.ORCA_WORKTREE_ID const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined const cwd = typeof params.cwd === 'string' ? params.cwd : resolveDefaultCwd() const finishCreation = this.beginPtyCreation([worktreePath, cwd]) + let physicalSpawnCommitted = false + const markPhysicalSpawnCommitted = (): void => { + physicalSpawnCommitted = true + } try { - return await this.spawnAfterAdmission(params, context) + const ensure = params.agentSessionEnsure as { claim?: unknown; surface?: unknown } | undefined + if (!ensure) { + return await this.spawnAfterAdmission(params, context, markPhysicalSpawnCommitted) + } + if ( + !isAgentSessionExecutionClaim(ensure.claim) || + !isAgentSessionSurfaceBinding(ensure.surface) + ) { + throw new Error('agent_session_identity_required') + } + const claim = ensure.claim + const surface = ensure.surface + const result = await this.agentSessionOwners.ensure({ + claim, + surface, + spawn: async ({ generation }) => { + const created = await this.spawnAfterAdmission( + params, + context, + markPhysicalSpawnCommitted + ) + const managed = this.ptys.get(created.id) + if (managed) { + managed.agentSessionOwners = [ + { + claim, + generation, + phase: 'live', + ptyId: created.id, + surface + } + ] + } + return { ptyId: created.id } + }, + isLive: (owner) => { + const managed = this.ptys.get(owner.ptyId) + return Boolean( + managed && + !managed.disposed && + (!managed.pty.pid || isProcessAlive(managed.pty.pid)) && + managed.agentSessionOwners?.some((candidate) => + agentSessionOwnerBindingsEqual(candidate, owner) + ) + ) + } + }) + const managed = this.ptys.get(result.owner.ptyId) + if (!managed || managed.disposed) { + this.agentSessionOwners.release(result.owner.ptyId, result.owner.generation) + throw new Error('agent_session_exited_during_start') + } + managed.agentSessionOwners = this.agentSessionOwners.listForPty(managed.id) + return { + id: managed.id, + incarnationId: managed.incarnationId, + agentSessionEnsure: result, + ...(result.disposition === 'adopted' && managed.buffered + ? { replay: managed.buffered } + : {}) + } + } catch (error) { + if (!physicalSpawnCommitted) { + throw error + } + const message = error instanceof Error ? error.message : String(error) + throw Object.assign(new Error(message), { + agentSessionOperationOutcome: 'unknown' as const + }) } finally { finishCreation() } @@ -815,8 +985,9 @@ export class PtyHandler { private async spawnAfterAdmission( params: Record, - context?: RequestContext - ): Promise<{ id: string }> { + context?: RequestContext, + onPhysicalSpawnCommitted?: () => void + ): Promise<{ id: string; incarnationId: string }> { const pty = await this.loadPty() if (!pty) { throw new Error('node-pty is not available on this remote host') @@ -875,7 +1046,16 @@ export class PtyHandler { emitReadyMarker: shouldEmitShellReadyMarker }) - // Why: SSH exec channels give a minimal env; a login shell sources startup files so PATH includes Homebrew/nvm/user CLIs. + if (context?.signal?.aborted || context?.isStale()) { + // Why: cancellation remains side-effect-free until the exact native spawn seam. + throw new Error('client_disconnected') + } + + // Why: SSH exec channels give the relay a minimal environment without + // .zprofile/.bash_profile sourced. Spawning a login shell ensures PATH + // includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh). + // When overlays are injected, the launch wrapper keeps those paths after + // user startup files re-export their defaults. let term: IPty try { term = pty.spawn(shell, shellLaunch.args, { @@ -895,6 +1075,7 @@ export class PtyHandler { } throw error } + onPhysicalSpawnCommitted?.() // Why: capture paneKey so the exit listener can evict per-pane caches without a separate ptyId→paneKey map. const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined @@ -909,6 +1090,7 @@ export class PtyHandler { : undefined const managed: ManagedPty = { id, + incarnationId: randomUUID(), pty: term, initialCwd: cwd, buffered: '', @@ -942,8 +1124,10 @@ export class PtyHandler { : {}) } this.wireAndStore(managed) - if (context?.isStale()) { - // Why: a client reconnect mid-spawn discards the response, so no renderer can own this PTY — shut it down. + if (context?.isStale() && !params.agentSessionEnsure && !params.agentSessionCreateOperationId) { + // Why: if the client reconnected while pty.spawn was in flight, the + // response is discarded and no renderer can own this PTY. Shut it down + // immediately so it does not linger as an unreachable remote shell. this.releaseStartupCommand(managed) this.requestGracefulKill(managed, 'terminate stale') } else if (managed.startupCommand) { @@ -954,10 +1138,12 @@ export class PtyHandler { : STARTUP_COMMAND_WRITE_DELAY_MS ) } - return { id } + return { id, incarnationId: managed.incarnationId } } - private async attach(params: Record): Promise<{ replay?: string }> { + private async attach( + params: Record + ): Promise<{ incarnationId: string; replay?: string }> { const id = params.id as string const managed = this.ptys.get(id) // Why: after dispose, pty.kill is a POSIX no-op; treat disposed as not-found so failures aren't silent. @@ -971,6 +1157,7 @@ export class PtyHandler { this.releaseRelayIngress(managed) this.flushPtyOutput(id) this.notifyExitListener(managed) + this.agentSessionOwners.release(managed.id) disposeManagedPty(managed) this.ptys.delete(id) this.clearPtyFlowState(id) @@ -998,11 +1185,11 @@ export class PtyHandler { this.pendingOutputByPty.delete(id) this.clearOutputFlushTimerIfIdle() if (params.suppressReplayNotification) { - return { replay: managed.buffered } + return { incarnationId: managed.incarnationId, replay: managed.buffered } } this.dispatcher.notify('pty.replay', { id, data: managed.buffered }) } - return {} + return { incarnationId: managed.incarnationId } } private writeData(params: Record): void { @@ -1206,10 +1393,14 @@ export class PtyHandler { (await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell' results.push({ id, + incarnationId: managed.incarnationId, cwd: managed.initialCwd, title, ...(managed.worktreeId ? { worktreeId: managed.worktreeId } : {}), - ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) + ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}), + ...(this.agentSessionOwners.listForPty(id).length + ? { agentSessionOwners: this.agentSessionOwners.listForPty(id) } + : {}) }) } return results @@ -1321,6 +1512,7 @@ export class PtyHandler { }) this.wireAndStore({ id: entry.id, + incarnationId: randomUUID(), pty: term, initialCwd: entry.cwd, buffered: '', @@ -1368,6 +1560,7 @@ export class PtyHandler { if (this.disposePromise) { return this.disposePromise } + this.agentSessionCreateOperations.clear() const disposePromise = this.disposePtys(options.waitForPhysicalExit !== false) this.disposePromise = disposePromise void disposePromise.catch(() => { @@ -1427,6 +1620,7 @@ export class PtyHandler { } if (this.ptys.get(managed.id) === managed && !managed.disposed) { this.notifyExitListener(managed) + this.agentSessionOwners.release(managed.id) disposeManagedPty(managed) this.ptys.delete(managed.id) this.clearPtyFlowState(managed.id) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 5540b8f3059..43504842aaa 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -85,9 +85,7 @@ export function useAiVaultSessionLaunchActions({ ) ) } catch (error) { - toast.error( - error instanceof Error ? error.message : 'Could not prepare this session for resume.' - ) + notifyAiVaultSessionPreparationFailure(error) } }, [buildResumeCommand] @@ -123,17 +121,21 @@ export function useAiVaultSessionLaunchActions({ ...buildResumeStartup(preparedSession, targetId.worktreeId) }) if (launchResult.tabId === null) { - void launchResult.runtimeLaunch.then((created) => { - if (!created) { + void launchResult.runtimeLaunch.then((outcome) => { + if (outcome.status === 'failed') { toast.error( - translate( - 'auto.lib.launch.agent.in.new.tab.11cce5cc77', - 'Could not launch {{value0}} in a new terminal.', - { value0: agentLabel(session.agent) } - ) + outcome.message || + translate( + 'auto.lib.launch.agent.in.new.tab.11cce5cc77', + 'Could not launch {{value0}} in a new terminal.', + { value0: agentLabel(session.agent) } + ) ) return } + if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) { + activateAiVaultResumeWorkspace(targetId.worktreeId) + } showQueuedToast() }) return @@ -143,11 +145,7 @@ export function useAiVaultSessionLaunchActions({ } showQueuedToast() }) - .catch((error: unknown) => { - toast.error( - error instanceof Error ? error.message : 'Could not prepare this session for resume.' - ) - }) + .catch(notifyAiVaultSessionPreparationFailure) }, [activeWorktree?.id, activeWorktreeId, buildResumeStartup, targetState] ) @@ -205,6 +203,17 @@ export function useAiVaultSessionLaunchActions({ } } +function notifyAiVaultSessionPreparationFailure(error: unknown): void { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.right.sidebar.AiVaultPanel.prepareSessionResumeFailed', + 'Could not prepare this session for resume.' + ) + ) +} + function resolveAiVaultTargetWorkspacePath( state: AiVaultSessionResumeTargetState, workspaceId: string diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index 4728383f2f7..3bf3e2ea0ac 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -12,6 +12,7 @@ import { hasAiVaultSessionDragData, readAiVaultSessionDragData } from '@/lib/ai-vault-session-drag' +import { getAiVaultAgentProviderSession } from '@/lib/ai-vault-resume-command' import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' import { useAppStore } from '@/store' import { resolveDropZone } from './tab-drop-zone' @@ -227,6 +228,11 @@ export default function AiVaultSessionDropLayer({ if (!startup) { throw new Error('Orca could not prepare this legacy Codex session. Retry resume.') } + const providerSession = getAiVaultAgentProviderSession({ + agent: payload.agent, + sessionId: payload.sessionId, + filePath: payload.sessionFilePath + }) const launchResult = launchAiVaultSessionInNewTab({ agent: payload.agent, worktreeId, @@ -234,18 +240,20 @@ export default function AiVaultSessionDropLayer({ ...(startup.env ? { env: startup.env } : {}), ...(startup.envToDelete ? { envToDelete: startup.envToDelete } : {}), ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), + ...(providerSession ? { providerSession } : {}), targetGroupId: dropTarget.groupId, splitDirection: dropTarget.zone === 'center' ? undefined : dropTarget.zone }) if (launchResult.tabId === null) { - void launchResult.runtimeLaunch.then((created) => { - if (!created) { + void launchResult.runtimeLaunch.then((outcome) => { + if (outcome.status === 'failed') { toast.error( - translate( - 'auto.lib.launch.agent.in.new.tab.11cce5cc77', - 'Could not launch {{value0}} in a new terminal.', - { value0: payload.agent } - ) + outcome.message || + translate( + 'auto.lib.launch.agent.in.new.tab.11cce5cc77', + 'Could not launch {{value0}} in a new terminal.', + { value0: payload.agent } + ) ) return } @@ -257,7 +265,12 @@ export default function AiVaultSessionDropLayer({ }) .catch((error: unknown) => { toast.error( - error instanceof Error ? error.message : 'Could not prepare this session for resume.' + error instanceof Error + ? error.message + : translate( + 'auto.components.right.sidebar.AiVaultPanel.prepareSessionResumeFailed', + 'Could not prepare this session for resume.' + ) ) }) return true diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts index a27baad0804..43f94fdd7a3 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ createBrowserTab: vi.fn(), createEmptySplitGroup: vi.fn(), createTab: vi.fn(), + createWebRuntimeSessionTerminal: vi.fn(), destroyWorkspaceWebviews: vi.fn(), dispatchEvent: vi.fn(), dropUnifiedTab: vi.fn(), @@ -71,7 +72,7 @@ vi.mock('../../runtime/web-runtime-session', () => ({ activateWebRuntimeSessionTab: mocks.activateWebRuntimeSessionTab, closeWebRuntimeSessionTab: mocks.closeWebRuntimeSessionTab, createWebRuntimeSessionBrowserTab: vi.fn(), - createWebRuntimeSessionTerminal: vi.fn(), + createWebRuntimeSessionTerminal: mocks.createWebRuntimeSessionTerminal, isWebRuntimeSessionActive: mocks.isWebRuntimeSessionActive, toHostSessionTabId: (tabId: string) => tabId })) @@ -164,6 +165,10 @@ function resetStore(): void { describe('useTabGroupWorkspaceModel terminal activation focus', () => { beforeEach(() => { vi.clearAllMocks() + mocks.createWebRuntimeSessionTerminal.mockResolvedValue({ + status: 'failed', + message: 'The workspace is not connected to a remote Orca host.' + }) resetStore() vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0) @@ -191,6 +196,18 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('terminal-1', null) }) + it('falls back to a local shell when the typed remote-create outcome is unavailable', async () => { + mocks.createTab.mockReturnValue({ id: 'terminal-new' }) + const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel') + const model = useTabGroupWorkspaceModel({ groupId: 'group-1', worktreeId: 'wt-1' }) + + model.commands.newTerminalWithShell('zsh') + await vi.waitFor(() => expect(mocks.createTab).toHaveBeenCalled()) + + expect(mocks.createTab).toHaveBeenCalledWith('wt-1', 'group-1', 'zsh') + expect(mocks.setActiveTab).toHaveBeenCalledWith('terminal-new') + }) + it('returns keyboard focus to the active split pane leaf when a terminal tab is activated', async () => { storeBox.state = { ...storeBox.state, diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 6dc3b0b36d1..c3e4cfcc69a 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -616,15 +616,18 @@ export function useTabGroupWorkspaceModel({ }, newTerminalWithShell: (shellOverride: string) => { void (async () => { - if ( - await createWebRuntimeSessionTerminal({ - worktreeId, - environmentId: getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId), - targetGroupId: groupId, - command: shellOverride, - activate: true - }) - ) { + const environmentId = getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + worktreeId + ) + const outcome = await createWebRuntimeSessionTerminal({ + worktreeId, + environmentId, + targetGroupId: groupId, + command: shellOverride, + activate: true + }) + if (outcome.status === 'created' || isWebRuntimeSessionActive(environmentId)) { return } const terminal = createTab(worktreeId, groupId, shellOverride) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 690b0bfd2f3..7b8736917a4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -12,6 +12,7 @@ import type { } from '../../../../shared/agent-session-resume' import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import type { PtyTransportRecoveryState } from './pty-transport-types' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' export type PtyConnectionDeps = { tabId: string @@ -29,7 +30,10 @@ export type PtyConnectionDeps = { resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent + /** Explicit CLI override for host-owned agent launches; omission uses host settings. */ + agentArgsOverride?: string | null draftPrompt?: string + sessionOptions?: Record /** Telemetry payload for `agent_started`. Forwarded to `pty:spawn` * so main fires the event only after the spawn succeeds. */ telemetry?: EventProps<'agent_started'> 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 b3b5383ee28..fbd75076560 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -14899,7 +14899,14 @@ describe('connectPanePty', () => { const pane = createPane(2) const manager = createManager(2) - const deps = createDeps() + const deps = createDeps({ + startup: { + command: "codex '--profile' 'recipe'", + launchAgent: 'codex', + launchConfig: { agentArgs: '--profile recipe', agentEnv: {} }, + agentArgsOverride: '--profile recipe' + } + }) connectPanePty(pane as never, manager as never, deps as never) @@ -14908,6 +14915,7 @@ describe('connectPanePty', () => { expect.any(Object) ) expect(createdTransportOptions[0]?.cwdFallback).toBeUndefined() + expect(createdTransportOptions[0]?.agentArgsOverride).toBe('--profile recipe') expect(transport.connect).toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8a004e091e9..b71777861a4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -30,6 +30,7 @@ import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport' import type { PtyTransportRecoveryState } from './pty-transport-types' import { createIpcPtyTransport } from './pty-transport' import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport' +import { toAgentLaunchPreferences } from '@/runtime/agent-session-create-operation' import { getConnectionId } from '@/lib/connection-context' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { @@ -3327,6 +3328,7 @@ export function connectPanePty( const terminalColorQueryReplies = terminalTheme ? { foreground: terminalTheme.foreground, background: terminalTheme.background } : undefined + const agentLaunchPreferences = toAgentLaunchPreferences(paneStartup?.sessionOptions) const transportOptions = { cwd: deps.cwd, // Why: only fresh local IPC spawns may recover from a saved startup cwd @@ -3355,6 +3357,18 @@ export function connectPanePty( ...(paneStartup?.resumeProviderSession ? { resumeProviderSession: paneStartup.resumeProviderSession } : {}), + ...((paneStartup?.initialAgentStatus?.prompt ?? paneStartup?.draftPrompt) + ? { agentPrompt: paneStartup?.initialAgentStatus?.prompt ?? paneStartup?.draftPrompt } + : {}), + ...(paneStartup?.initialAgentStatus?.prompt + ? { agentPromptDelivery: 'auto-submit' as const } + : paneStartup?.draftPrompt + ? { agentPromptDelivery: 'draft' as const } + : {}), + ...(paneStartup?.agentArgsOverride !== undefined + ? { agentArgsOverride: paneStartup.agentArgsOverride } + : {}), + ...(agentLaunchPreferences ? { agentLaunchPreferences } : {}), ...(launchToken ? { launchToken } : {}), ...(paneStartup?.launchAgent ? { launchAgent: paneStartup.launchAgent } : {}), ...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 31d5b1a3a63..bc92f80c806 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -3,6 +3,10 @@ import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { + AgentLaunchPreferences, + AgentPromptDelivery +} from '../../../../shared/agent-session-host-authority' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { EventProps } from '../../../../shared/telemetry-events' @@ -167,6 +171,10 @@ export type IpcPtyTransportOptions = { command?: string launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + agentPrompt?: string + agentPromptDelivery?: AgentPromptDelivery + agentArgsOverride?: string | null + agentLaunchPreferences?: AgentLaunchPreferences launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 34ec9ac7567..a145d82488e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -1913,19 +1913,32 @@ describe('createRemoteRuntimePtyTransport', () => { unsubscribe: unsubscribeFn, sendBinary: vi.fn() } - runtimeCall.mockResolvedValue({ - id: 'rpc-create', - ok: true, - result: { - terminal: { - handle: 'term-remote', - worktreeId: 'repo1::/remote/wt', - title: null, - surface: 'background' - } - }, - _meta: { runtimeId: 'runtime-remote' } - }) + runtimeCall.mockImplementation(async (args: { method?: string }) => + args.method === 'status.get' + ? { + id: 'rpc-status', + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + }, + _meta: { runtimeId: 'runtime-remote' } + } + : { + id: 'rpc-create', + ok: true, + result: { + terminal: { + handle: 'term-remote', + worktreeId: 'repo1::/remote/wt', + title: null, + surface: 'background' + } + }, + _meta: { runtimeId: 'runtime-remote' } + } + ) runtimeSubscribe.mockImplementation( async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { subscriptionCallbacks = callbacks @@ -2063,6 +2076,119 @@ describe('createRemoteRuntimePtyTransport', () => { expect(onData).toHaveBeenCalledWith(' world', expect.objectContaining({ seq: 4 })) }) + it('routes provider resumes through the host authority without sending the client command', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'repo1::/remote/wt', + command: "claude '--resume' 'provider-session'", + env: { CLIENT_ONLY: 'must-not-cross' }, + launchAgent: 'claude', + agentArgsOverride: '--permission-mode plan', + resumeProviderSession: { key: 'session_id', id: 'provider-session' }, + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111' + }) + + await transport.connect({ url: '', callbacks: {} }) + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.ensureAgentSession', + params: { + kind: 'explicit', + worktree: 'id:repo1::/remote/wt', + agent: 'claude', + providerSession: { key: 'session_id', id: 'provider-session' }, + agentArgs: '--permission-mode plan', + placement: { + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111' + }, + presentation: 'background' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.create', + params: expect.objectContaining({ command: expect.any(String) }) + }) + ) + }) + + it('treats an explicitly killed remote session as normal retirement', async () => { + runtimeCall.mockImplementation(async (args: { method?: string }) => + args.method === 'terminal.create' + ? { + id: 'rpc-create', + ok: false, + error: { + code: 'terminal_gone', + message: 'Session "pty-dead" was explicitly killed' + } + } + : { + id: 'rpc-status', + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'repo1::/remote/wt' + }) + const onError = vi.fn() + + await expect(transport.connect({ url: '', callbacks: { onError } })).resolves.toBeUndefined() + expect(onError).not.toHaveBeenCalled() + }) + + it('routes fresh agents through an idempotent host-built launch', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'repo1::/remote/wt', + command: "codex 'fix the race'", + env: { CLIENT_ONLY: 'must-not-cross' }, + launchAgent: 'codex', + agentPrompt: 'fix the race', + agentPromptDelivery: 'draft', + agentLaunchPreferences: { model: 'gpt-5', effort: 'high' }, + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111' + }) + + await transport.connect({ url: '', callbacks: {} }) + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.createAgentSession', + params: { + clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/), + worktree: 'id:repo1::/remote/wt', + agent: 'codex', + prompt: 'fix the race', + promptDelivery: 'draft', + launchPreferences: { model: 'gpt-5', effort: 'high' }, + placement: { + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111' + }, + presentation: 'background' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.create', + params: expect.objectContaining({ command: expect.any(String) }) + }) + ) + }) + it('forwards input over the stream and disconnects without closing shared remote sessions', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 300a33e52e2..8e93522c79b 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -19,6 +19,7 @@ import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shar describe('createRemoteRuntimePtyTransport', () => { const runtimeCall = vi.fn() const runtimeSubscribe = vi.fn() + const refreshSessionTabsSnapshot = vi.fn(async () => {}) const subscriptionSendBinary = vi.fn() let subscriptionCallbacks: { onResponse: (response: unknown) => void @@ -126,9 +127,13 @@ describe('createRemoteRuntimePtyTransport', () => { beforeEach(() => { vi.resetModules() vi.doUnmock('../../runtime/remote-runtime-terminal-multiplexer') + vi.doMock('@/runtime/web-runtime-session', () => ({ + refreshWebRuntimeSessionTabsSnapshot: refreshSessionTabsSnapshot + })) vi.clearAllMocks() subscriptionCallbacks = null subscriptionSendBinary.mockReset() + refreshSessionTabsSnapshot.mockClear() runtimeCall.mockResolvedValue({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) runtimeSubscribe.mockImplementation( async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { @@ -549,6 +554,74 @@ describe('createRemoteRuntimePtyTransport', () => { } }) + it('replays an ambiguous structured agent create without downgrading after cutoff', async () => { + vi.useFakeTimers() + try { + let reachable = false + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method === 'status.get') { + return { + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } + } + if (args.method === 'terminal.createAgentSession' && reachable) { + return { + ok: true, + result: { + disposition: 'replayed', + terminal: { handle: 'terminal-agent-recovered' } + } + } + } + throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), { + code: 'runtime_timeout' + }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + launchAgent: 'codex' + }) + + const connect = transport.connect({ url: '', callbacks: {} }) + await vi.advanceTimersByTimeAsync(60_000) + await connect + + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + const initialCreates = runtimeCall.mock.calls + .map(([args]) => args as { method: string; params?: { clientOperationId?: string } }) + .filter((args) => args.method === 'terminal.createAgentSession') + expect(initialCreates.length).toBeGreaterThan(0) + const operationId = initialCreates[0].params?.clientOperationId + expect(operationId).toMatch(/\S+/) + + reachable = true + expect(transport.retryRecovery?.()).toBe(true) + await vi.waitFor(() => + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-agent-recovered') + ) + + const allCreates = runtimeCall.mock.calls + .map(([args]) => args as { method: string; params?: { clientOperationId?: string } }) + .filter((args) => args.method === 'terminal.createAgentSession') + expect(allCreates.every((args) => args.params?.clientOperationId === operationId)).toBe(true) + expect(runtimeCall.mock.calls.some(([args]) => args.method === 'terminal.create')).toBe(false) + expect(runtimeCall.mock.calls.filter(([args]) => args.method === 'status.get')).toHaveLength( + 1 + ) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + it('scopes the same legacy handle independently for each runtime environment', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const first = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' }) @@ -1485,6 +1558,109 @@ describe('createRemoteRuntimePtyTransport', () => { transport.destroy?.() }) + it('does not close a live owner adopted after provisional pane handoff', async () => { + let resolveEnsure: (value: unknown) => void = () => {} + runtimeCall.mockImplementation((args) => { + if (args.method === 'status.get') { + return Promise.resolve({ + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + }) + } + if (args.method === 'terminal.ensureAgentSession') { + return new Promise((resolve) => { + resolveEnsure = resolve + }) + } + return Promise.resolve({ ok: true, result: {} }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + launchAgent: 'codex', + resumeProviderSession: { key: 'session_id', id: 'live-session' } + }) + + const connect = transport.connect({ url: '', callbacks: {} }) + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.ensureAgentSession' }) + ) + ) + transport.destroy?.() + resolveEnsure({ + ok: true, + result: { + disposition: 'adopted', + terminal: { handle: 'terminal-live', worktreeId: 'wt-1', title: null } + } + }) + await connect + + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.close' }) + ) + }) + + it('does not close a structured create after provisional pane handoff', async () => { + let resolveCreate: (value: unknown) => void = () => {} + runtimeCall.mockImplementation((args) => { + if (args.method === 'status.get') { + return Promise.resolve({ + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + }) + } + if (args.method === 'terminal.createAgentSession') { + return new Promise((resolve) => { + resolveCreate = resolve + }) + } + return Promise.resolve({ ok: true, result: {} }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'provisional-tab', + leafId: 'provisional-leaf', + launchAgent: 'codex' + }) + + const connect = transport.connect({ url: '', callbacks: {} }) + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.createAgentSession' }) + ) + ) + transport.destroy?.() + resolveCreate({ + ok: true, + result: { + disposition: 'created', + terminal: { + handle: 'terminal-live', + tabId: 'canonical-host-tab', + leafId: 'canonical-host-leaf' + } + } + }) + await connect + + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.close' }) + ) + }) + it('passes activation intent when creating the remote runtime terminal', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const transport = createRemoteRuntimePtyTransport('env-1', { @@ -1564,7 +1740,19 @@ describe('createRemoteRuntimePtyTransport', () => { ) }) - it('prefers connect-time launch metadata when creating the remote runtime terminal', async () => { + it('uses connect-time agent identity while the remote host builds the launch', async () => { + runtimeCall.mockImplementation(async (args: { method?: string }) => + args.method === 'status.get' + ? { + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } + : { ok: true, result: { terminal: { handle: 'terminal-1' } } } + ) const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1', @@ -1572,6 +1760,7 @@ describe('createRemoteRuntimePtyTransport', () => { leafId: 'pane:1', command: "codex 'old'", launchConfig: { agentArgs: '--old', agentEnv: {} }, + agentArgsOverride: '--profile captured', launchToken: 'old-token', launchAgent: 'codex' }) @@ -1597,26 +1786,139 @@ describe('createRemoteRuntimePtyTransport', () => { expect(runtimeCall).toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-1', - method: 'terminal.create', + method: 'terminal.ensureAgentSession', params: expect.objectContaining({ - command: "codex '--model' 'gpt-5' 'resume' 'session-1'", - env: { CODEX_PROFILE: 'captured', ORCA_AGENT_LAUNCH_TOKEN: 'fresh-token' }, - launchConfig: { - agentArgs: '--model gpt-5', - agentEnv: { CODEX_PROFILE: 'captured' } - }, - launchToken: 'fresh-token', - launchAgent: 'codex', - resumeProviderSession: { + kind: 'explicit', + worktree: 'id:wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1', transcriptPath: '/home/example/.codex/sessions/2026/07/20/rollout-a.jsonl' - } + }, + agentArgs: '--profile captured', + placement: { tabId: 'tab-1', leafId: 'pane:1' }, + presentation: 'background' }) }) ) }) + it('records the exact provisional handoff and refreshes a snapshot that arrived early', async () => { + runtimeCall.mockImplementation(async (args: { method?: string }) => + args.method === 'status.get' + ? { + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } + : { + ok: true, + result: { + disposition: 'created', + terminal: { + handle: 'terminal-1', + tabId: 'canonical-host-tab', + leafId: 'canonical-host-leaf' + } + } + } + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { resolveWebAgentSessionHandoff } = + await import('../../runtime/web-agent-session-handoff') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'provisional-tab', + leafId: 'provisional-leaf', + launchAgent: 'codex' + }) + + await transport.connect({ url: '', callbacks: {} }) + + expect( + resolveWebAgentSessionHandoff({ + environmentId: 'env-1', + worktreeId: 'wt-1', + provisionalTabId: 'provisional-tab' + }) + ).toBe('canonical-host-tab') + expect(refreshSessionTabsSnapshot).toHaveBeenCalledWith('env-1', 'wt-1', { + acceptCurrentSnapshot: true, + confirmAgentSessionHandoff: { + provisionalTabId: 'provisional-tab', + hostTabId: 'canonical-host-tab', + hostTerminalHandle: 'terminal-1' + } + }) + }) + + it('preserves the connect-time legacy payload when host authority is unavailable', async () => { + runtimeCall.mockImplementation(async (args: { method?: string }) => + args.method === 'status.get' + ? { + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [] + } + } + : { ok: true, result: { terminal: { handle: 'terminal-legacy' } } } + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + command: "codex 'old'", + launchConfig: { agentArgs: '--old', agentEnv: {} }, + launchToken: 'old-token', + launchAgent: 'codex' + }) + + await transport.connect({ + url: '', + command: "codex '--model' 'gpt-5' 'resume' 'session-1'", + env: { CODEX_PROFILE: 'captured', ORCA_AGENT_LAUNCH_TOKEN: 'fresh-token' }, + launchConfig: { + agentArgs: '--model gpt-5', + agentEnv: { CODEX_PROFILE: 'captured' } + }, + launchToken: 'fresh-token', + launchAgent: 'codex', + callbacks: {} + }) + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.create', + params: { + worktree: 'id:wt-1', + clientMutationId: expect.any(String), + command: "codex '--model' 'gpt-5' 'resume' 'session-1'", + env: { CODEX_PROFILE: 'captured', ORCA_AGENT_LAUNCH_TOKEN: 'fresh-token' }, + launchConfig: { + agentArgs: '--model gpt-5', + agentEnv: { CODEX_PROFILE: 'captured' } + }, + launchToken: 'fresh-token', + launchAgent: 'codex', + tabId: 'tab-1', + leafId: 'pane:1', + focus: false, + presentation: 'background' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.createAgentSession' }) + ) + }) + it('activates pending host session mirrors instead of creating duplicate terminals', async () => { runtimeCall.mockImplementation((args) => { if (args.method === 'session.tabs.activate') { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index f801ced674e..773b7f07b07 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -4,6 +4,10 @@ import { isRecoverableRemoteRuntimeConnectionError, toRemoteRuntimeClientErrorLike } from '../../../../shared/remote-runtime-client-error-classification' +import type { + RuntimeCreateAgentSessionResult, + RuntimeEnsureAgentSessionResult +} from '../../../../shared/agent-session-host-authority' import type { RuntimeMobileSessionTerminalClientTab, RuntimeMobileSessionTabsResult, @@ -48,11 +52,19 @@ import { RemoteRuntimePtyRecoveryState } from './remote-runtime-pty-recovery-state' import { createBrowserUuid } from '@/lib/browser-uuid' +import { + createAgentSessionCreateOperation, + withAgentSessionCreateOperationId +} from '@/runtime/agent-session-create-operation' import { replaceFitOverridePtyId, setFitOverride } from '@/lib/pane-manager/mobile-fit-overrides' import { replaceDriverPtyId, setDriverForPty } from '@/lib/pane-manager/mobile-driver-state' import { isWebTerminalSurfaceTabId, toHostSessionTabId } from '@/runtime/web-terminal-surface-id' import { listRemoteRuntimeSessionTabsDeduped } from '@/runtime/remote-runtime-session-tabs-inflight' import { subscribeAcceptedWebSessionTerminalHandle } from '@/runtime/web-session-terminal-handle-events' +import { runRemoteAgentSessionLaunch } from '@/runtime/remote-agent-session-launch' +import { useAppStore } from '@/store' +import { recordWebAgentSessionHandoff } from '@/runtime/web-agent-session-handoff' +import { refreshWebRuntimeSessionTabsSnapshot } from '@/runtime/web-runtime-session' const REMOTE_TERMINAL_INPUT_FLUSH_MS = 8 const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33 @@ -61,6 +73,11 @@ const HOST_SESSION_REPLACEMENT_POLL_MAX_MS = 1_000 const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000 const TERMINAL_CREATE_RETRY_DELAYS_MS = [250, 500, 1000, 2000, 4000, 8000, 15_000, 30_000] as const +type RemoteAgentSessionLaunchResult = + | RuntimeEnsureAgentSessionResult + | RuntimeCreateAgentSessionResult + | { terminal: RuntimeTerminalCreate; disposition?: undefined } + function isRemoteTerminalStaleMessage(message: string): boolean { return message.includes('terminal_handle_stale') } @@ -69,7 +86,8 @@ function isRemoteTerminalGoneMessage(message: string): boolean { return ( message.includes('terminal_exited') || message.includes('terminal_gone') || - message.includes('no_connected_pty') + message.includes('no_connected_pty') || + message.toLocaleLowerCase('en-US').includes('explicitly killed') ) } @@ -88,6 +106,10 @@ export function createRemoteRuntimePtyTransport( launchToken, launchAgent, terminalColorQueryReplies, + agentPrompt, + agentPromptDelivery, + agentArgsOverride, + agentLaunchPreferences, worktreeId, tabId, leafId, @@ -140,6 +162,8 @@ export function createRemoteRuntimePtyTransport( } | null = null // Why: after an unknown result, every later attempt must reconcile first so older runtimes cannot duplicate the PTY. let terminalCreateNeedsReconciliation = false + // Why: once a structured outcome is ambiguous, only its stable host operation may be replayed. + let agentSessionRequiresHostAuthorityReplay = false let terminalCreateUnknownOutcomeError: unknown = null let lastConnectOptions: Parameters[0] | null = null const viewportClaimReadyWaiters = new Set<(ready: boolean) => void>() @@ -154,6 +178,9 @@ export function createRemoteRuntimePtyTransport( // Why: tab/leaf ids are shared by paired viewers; the instance suffix keeps one viewer's refresh off peer records. const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}:${createBrowserUuid()}` const terminalCreateMutationId = createBrowserUuid() + // Why: reconnect retries must replay one host operation instead of creating + // another fresh agent when the first response was lost. + const agentCreateOperation = createAgentSessionCreateOperation() const outputProcessor = createPtyOutputProcessor({ onTitleChange, onBell, @@ -196,6 +223,25 @@ export function createRemoteRuntimePtyTransport( storedCallbacks.onRecoveryStateChange?.(state) } + function hostSnapshotOwnsLaunch( + result: RemoteAgentSessionLaunchResult, + environmentId: string + ): boolean { + if (result.disposition !== undefined) { + // Why: every structured launch is host-owned; provisional teardown must + // never close its canonical terminal while snapshot reconciliation catches up. + return true + } + const scopedPtyId = toRemoteRuntimePtyId(result.terminal.handle, environmentId) + return (useAppStore.getState().tabsByWorktree[worktreeId ?? ''] ?? []).some( + (tab) => + tab.ptyId === scopedPtyId || + (result.terminal.tabId !== undefined && + isWebTerminalSurfaceTabId(tab.id) && + toHostSessionTabId(tab.id) === result.terminal.tabId) + ) + } + function findReadyHostSessionHandle( snapshot: RuntimeMobileSessionTabsResult, hostTabId: string @@ -442,14 +488,23 @@ export function createRemoteRuntimePtyTransport( return recovery.currentPhase === 'disconnected' } - async function createTerminalWithUnknownOutcomeRecovery( - params: Record, + async function createWithUnknownOutcomeRecovery( + kind: 'terminal' | 'agent-session', + invoke: ( + timeoutMs: number, + reconcileExisting: boolean + ) => Promise, environmentId: string, expectedLifecycleEpoch: number - ): Promise<{ terminal: RuntimeTerminalCreate } | null> { + ): Promise { let retryAttempt = 0 - let idempotencySupported = false - let reconcileExisting = terminalCreateNeedsReconciliation + // Structured operations already carry their replay proof; ordinary terminal.create + // must prove v2 support before retrying an outcome the client cannot observe. + let idempotencySupported = kind === 'agent-session' + let reconcileExisting = + kind === 'agent-session' + ? agentSessionRequiresHostAuthorityReplay + : terminalCreateNeedsReconciliation let recoveryDeadlineAt: number | null = recovery.isActive ? Date.now() + REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS : null @@ -525,22 +580,18 @@ export function createRemoteRuntimePtyTransport( break } try { - return await callRuntimeForEnvironment<{ terminal: RuntimeTerminalCreate }>( - environmentId, - 'terminal.create', - { - ...params, - ...(reconcileExisting ? { reconcileExisting: true } : {}) - }, - Math.min(15_000, createRemainingMs ?? 15_000) - ) + return await invoke(Math.min(15_000, createRemainingMs ?? 15_000), reconcileExisting) } catch (error) { lastError = error const clientError = toRemoteRuntimeClientErrorLike(error) if (!isRecoverableRemoteRuntimeConnectionError(clientError)) { throw error } - terminalCreateNeedsReconciliation = true + if (kind === 'agent-session') { + agentSessionRequiresHostAuthorityReplay = true + } else { + terminalCreateNeedsReconciliation = true + } terminalCreateUnknownOutcomeError ??= error reconcileExisting = true const startsRecovery = recoveryDeadlineAt === null @@ -1145,33 +1196,101 @@ export function createRemoteRuntimePtyTransport( const resumeProviderSessionToSend = options.resumeProviderSession ?? resumeProviderSession const launchTokenToSend = options.launchToken ?? launchToken const launchAgentToSend = options.launchAgent ?? launchAgent - const created = await createTerminalWithUnknownOutcomeRecovery( - { - worktree: toRuntimeTerminalWorktreeSelector(worktreeId), - clientMutationId: terminalCreateMutationId, - ...(commandToSend !== undefined ? { command: commandToSend } : {}), - ...(startupCommandDeliveryToSend !== undefined - ? { startupCommandDelivery: startupCommandDeliveryToSend } - : {}), - ...(envToSend !== undefined ? { env: envToSend } : {}), - ...(envToDeleteToSend !== undefined ? { envToDelete: envToDeleteToSend } : {}), - ...(launchConfigToSend !== undefined ? { launchConfig: launchConfigToSend } : {}), - ...(resumeProviderSessionToSend !== undefined - ? { resumeProviderSession: resumeProviderSessionToSend } - : {}), - ...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}), - ...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}), - ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), - tabId, - leafId, - focus: false, - // Why: transport backs an already-mounted pane; activation is local state, not permission for remote UI reveal. - presentation: 'background', - ...(activate === true ? { activate: true } : {}) - }, - createEnvironmentId, - connectLifecycleEpoch - ) + const legacyCreateParams = { + worktree: toRuntimeTerminalWorktreeSelector(worktreeId), + clientMutationId: terminalCreateMutationId, + ...(commandToSend !== undefined ? { command: commandToSend } : {}), + ...(startupCommandDeliveryToSend !== undefined + ? { startupCommandDelivery: startupCommandDeliveryToSend } + : {}), + ...(envToSend !== undefined ? { env: envToSend } : {}), + ...(envToDeleteToSend !== undefined ? { envToDelete: envToDeleteToSend } : {}), + ...(launchConfigToSend !== undefined ? { launchConfig: launchConfigToSend } : {}), + ...(resumeProviderSessionToSend !== undefined + ? { resumeProviderSession: resumeProviderSessionToSend } + : {}), + ...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}), + ...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}), + ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), + tabId, + leafId, + focus: false, + // Why: transport backs an already-mounted pane; activation is local state, not permission for remote UI reveal. + presentation: 'background' as const, + ...(activate === true ? { activate: true } : {}) + } + const legacyCreate = () => + createWithUnknownOutcomeRecovery( + 'terminal', + (timeoutMs, reconcileExisting) => + callRuntimeForEnvironment<{ terminal: RuntimeTerminalCreate }>( + createEnvironmentId, + 'terminal.create', + { + ...legacyCreateParams, + ...(reconcileExisting ? { reconcileExisting: true } : {}) + }, + timeoutMs + ), + createEnvironmentId, + connectLifecycleEpoch + ) + const hostAuthorityCreate = () => + createWithUnknownOutcomeRecovery( + 'agent-session', + (timeoutMs) => + resumeProviderSessionToSend + ? callRuntimeForEnvironment( + createEnvironmentId, + 'terminal.ensureAgentSession', + { + kind: 'explicit', + worktree: toRuntimeTerminalWorktreeSelector(worktreeId), + agent: launchAgentToSend!, + providerSession: resumeProviderSessionToSend, + ...(agentArgsOverride !== undefined ? { agentArgs: agentArgsOverride } : {}), + ...(agentLaunchPreferences + ? { launchPreferences: agentLaunchPreferences } + : {}), + placement: { tabId, leafId }, + presentation: 'background' + }, + timeoutMs + ) + : callRuntimeForEnvironment( + createEnvironmentId, + 'terminal.createAgentSession', + withAgentSessionCreateOperationId( + { + worktree: toRuntimeTerminalWorktreeSelector(worktreeId), + agent: launchAgentToSend!, + ...(agentPrompt ? { prompt: agentPrompt } : {}), + ...(agentPromptDelivery ? { promptDelivery: agentPromptDelivery } : {}), + ...(agentArgsOverride !== undefined + ? { agentArgs: agentArgsOverride } + : {}), + ...(agentLaunchPreferences + ? { launchPreferences: agentLaunchPreferences } + : {}), + placement: { tabId, leafId }, + presentation: 'background' + }, + agentCreateOperation.clientOperationId + ), + timeoutMs + ), + createEnvironmentId, + connectLifecycleEpoch + ) + const created = launchAgentToSend + ? agentSessionRequiresHostAuthorityReplay + ? await hostAuthorityCreate() + : await runRemoteAgentSessionLaunch({ + environmentId: createEnvironmentId, + hostAuthority: hostAuthorityCreate, + legacy: legacyCreate + }) + : await legacyCreate() if (!created) { if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) { connecting = false @@ -1179,16 +1298,36 @@ export function createRemoteRuntimePtyTransport( } return } + const createdTerminal = created.terminal + if (created.disposition !== undefined && tabId && createdTerminal.tabId) { + recordWebAgentSessionHandoff({ + environmentId: createEnvironmentId, + worktreeId, + provisionalTabId: tabId, + hostTabId: createdTerminal.tabId, + hostTerminalHandle: createdTerminal.handle + }) + // Snapshot parity must not delay attachment to a terminal the host already created. + void refreshWebRuntimeSessionTabsSnapshot(createEnvironmentId, worktreeId, { + acceptCurrentSnapshot: true, + confirmAgentSessionHandoff: { + provisionalTabId: tabId, + hostTabId: createdTerminal.tabId, + hostTerminalHandle: createdTerminal.handle + } + }) + } if (destroyed || lifecycleEpoch !== connectLifecycleEpoch) { if ( - created.terminal.handle !== handle || - createEnvironmentId !== currentRuntimeEnvironmentId + !hostSnapshotOwnsLaunch(created, createEnvironmentId) && + (createdTerminal.handle !== handle || + createEnvironmentId !== currentRuntimeEnvironmentId) ) { - await closeRemoteTerminal(created.terminal.handle, createEnvironmentId) + await closeRemoteTerminal(createdTerminal.handle, createEnvironmentId) } return } - handle = created.terminal.handle + handle = createdTerminal.handle remotePtyId = toRemoteRuntimePtyId(handle, currentRuntimeEnvironmentId) connected = true @@ -1218,8 +1357,13 @@ export function createRemoteRuntimePtyTransport( if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) { connecting = false recovery.cancel() - storedCallbacks.onError?.(runtimeTerminalErrorMessage(error)) - emitRecoveryState() + const message = runtimeTerminalErrorMessage(error) + if (isRemoteTerminalGoneMessage(message)) { + handleRemoteTerminalError(error) + } else { + emitRecoveryState() + storedCallbacks.onError?.(message) + } } return undefined } @@ -1414,7 +1558,7 @@ export function createRemoteRuntimePtyTransport( !terminalEnded && !connected && !handle && - terminalCreateNeedsReconciliation && + (terminalCreateNeedsReconciliation || agentSessionRequiresHostAuthorityReplay) && lastConnectOptions && recovery.currentPhase === 'disconnected' ) { diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 40e59841751..b00a64f2e35 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1797,7 +1797,10 @@ describe('useIpcEvents updater integration', () => { const replyTerminalCreate = vi.fn() const dispatchEvent = vi.fn() const createFloatingWorkspaceTerminalTab = vi.fn() - const createWebRuntimeSessionTerminal = vi.fn().mockResolvedValue(false) + const createWebRuntimeSessionTerminal = vi.fn().mockResolvedValue({ + status: 'failed', + message: 'The workspace is not connected to a remote Orca host.' + }) const focusRuntimeTerminalSurface = vi.fn(() => false) const focusTerminalTabSurface = vi.fn() let floatingPanelFocused = false diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index c1a4a903615..834c52afbcf 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2429,13 +2429,13 @@ export function useIpcEvents(): void { return } void (async () => { - if ( - await createWebRuntimeSessionTerminal({ - worktreeId, - environmentId: getWorktreeRuntimeEnvironmentId(worktreeId), - activate: true - }) - ) { + const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId) + const outcome = await createWebRuntimeSessionTerminal({ + worktreeId, + environmentId, + activate: true + }) + if (outcome.status === 'created' || isWebRuntimeSessionActive(environmentId)) { return } const newTab = store.createTab(worktreeId) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 50268e7bab3..7d5d03e8570 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10359,7 +10359,8 @@ "worktreeUnavailable": "Worktree is no longer available.", "openSupportedWorkspace": "Open a workspace before resuming a session.", "sessionHostMismatchUnsupported": "This session belongs to a different host. Open a workspace on the same host to resume it.", - "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." + "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead.", + "prepareSessionResumeFailed": "Could not prepare this session for resume." }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -13470,6 +13471,11 @@ "91b5c8d7e6": "GitLab" } } + }, + "runtime": { + "webRuntimeSession": { + "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + } } }, "components": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 06441af5501..886b36bb2bd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10336,7 +10336,8 @@ "worktreeUnavailable": "El worktree ya no está disponible.", "openSupportedWorkspace": "Abre un workspace antes de reanudar una sesión.", "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial.", - "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Abre un espacio de trabajo en el mismo host para reanudarla." + "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Abre un espacio de trabajo en el mismo host para reanudarla.", + "prepareSessionResumeFailed": "Could not prepare this session for resume." }, "AiVaultPanelControls": { "scanningSessions": "Buscando sesiones", @@ -13447,6 +13448,11 @@ "91b5c8d7e6": "GitLab" } } + }, + "runtime": { + "webRuntimeSession": { + "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + } } }, "components": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c12b104b83b..d487281a670 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10336,7 +10336,8 @@ "worktreeUnavailable": "ワークツリーは利用できなくなりました。", "openSupportedWorkspace": "Open a workspace before resuming a session.", "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。", - "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。再開するには同じホスト上のワークスペースを開いてください。" + "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。再開するには同じホスト上のワークスペースを開いてください。", + "prepareSessionResumeFailed": "Could not prepare this session for resume." }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -13447,6 +13448,11 @@ "91b5c8d7e6": "GitLab" } } + }, + "runtime": { + "webRuntimeSession": { + "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + } } }, "components": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 73bdecea602..6888ce7c53c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10336,7 +10336,8 @@ "worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다.", "openSupportedWorkspace": "세션을 재개하기 전에 워크스페이스를 여세요.", "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요.", - "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 재개하려면 같은 호스트의 워크스페이스를 여세요." + "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 재개하려면 같은 호스트의 워크스페이스를 여세요.", + "prepareSessionResumeFailed": "Could not prepare this session for resume." }, "AiVaultPanelControls": { "scanningSessions": "세션 스캔 중", @@ -13447,6 +13448,11 @@ "91b5c8d7e6": "GitLab" } } + }, + "runtime": { + "webRuntimeSession": { + "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + } } }, "components": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 538dd579a20..f9a298fc367 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10336,7 +10336,8 @@ "worktreeUnavailable": "工作树不再可用。", "openSupportedWorkspace": "在恢复会话之前,请先打开一个工作区。", "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。", - "sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。" + "sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。", + "prepareSessionResumeFailed": "Could not prepare this session for resume." }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", @@ -13447,6 +13448,11 @@ "91b5c8d7e6": "GitLab" } } + }, + "runtime": { + "webRuntimeSession": { + "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + } } }, "components": { diff --git a/src/renderer/src/lib/agent-background-session-test-state.ts b/src/renderer/src/lib/agent-background-session-test-state.ts new file mode 100644 index 00000000000..fae70ea4e3c --- /dev/null +++ b/src/renderer/src/lib/agent-background-session-test-state.ts @@ -0,0 +1,250 @@ +import { expect, vi } from 'vitest' +import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +import { resetRemoteRuntimeTerminalMultiplexersForTests } from '@/runtime/remote-runtime-terminal-multiplexer' + +type TestMock = ReturnType + +export const AGENT_BACKGROUND_SESSION_UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +export type AgentBackgroundSessionTestState = { + activeRepoId: string + activeWorktreeId: string + lastTerminalInputAtByPaneKey: Record + settings: { + agentCmdOverrides: Record + activeRuntimeEnvironmentId: string | null + terminalMainSideEffectAuthority: boolean | undefined + } + projects: { + id: string + localWindowsRuntimePreference: + | { kind: 'inherit-global' } + | { kind: 'windows-host' } + | { kind: 'wsl'; distro: string | null } + }[] + repos: { id: string; connectionId: string | null; path: string }[] + worktreesByRepo: Record< + string, + { id: string; repoId: string; projectId: string; path: string; displayName: string }[] + > + tabsByWorktree: Record + terminalLayoutsByTabId: Record< + string, + { ptyIdsByLeafId?: Record } + > + ptyIdsByTabId: Record + sshConnectionStates: Map + transientClearedAgentStatusConnectionIds: Record + allWorktrees: () => { id: string; repoId: string; path: string }[] + createTab: TestMock + setTabCustomTitle: TestMock + updateTabPtyId: TestMock + closeTab: TestMock + setTabLayout: TestMock + clearTabPtyId: TestMock + setAgentStatus: TestMock + registerAgentLaunchConfig: TestMock + clearAgentLaunchConfig: TestMock +} + +export function createAgentBackgroundSessionTestState(mocks: { + createTab: TestMock + setTabCustomTitle: TestMock + updateTabPtyId: TestMock + closeTab: TestMock + setTabLayout: TestMock + registerAgentLaunchConfig: TestMock +}): AgentBackgroundSessionTestState { + const state = { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + lastTerminalInputAtByPaneKey: {}, + settings: { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: null as string | null, + terminalMainSideEffectAuthority: undefined as boolean | undefined + }, + projects: [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'inherit-global' as const } + } + ] as { + id: string + localWindowsRuntimePreference: + | { kind: 'inherit-global' } + | { kind: 'windows-host' } + | { kind: 'wsl'; distro: string | null } + }[], + repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: '/repo/worktree', + displayName: 'main' + } + ] + }, + tabsByWorktree: { 'wt-1': [] as { id: string; title: string }[] }, + terminalLayoutsByTabId: {} as Record< + string, + { ptyIdsByLeafId?: Record } + >, + ptyIdsByTabId: {} as Record, + sshConnectionStates: new Map(), + transientClearedAgentStatusConnectionIds: {} as Record, + allWorktrees: () => state.worktreesByRepo['repo-1'], + createTab: mocks.createTab, + setTabCustomTitle: mocks.setTabCustomTitle, + updateTabPtyId: mocks.updateTabPtyId, + closeTab: mocks.closeTab, + setTabLayout: mocks.setTabLayout, + clearTabPtyId: vi.fn(), + setAgentStatus: vi.fn(), + registerAgentLaunchConfig: mocks.registerAgentLaunchConfig, + clearAgentLaunchConfig: vi.fn() + } + return state +} + +export function resetAgentBackgroundSessionTestState(state: AgentBackgroundSessionTestState): void { + state.activeRepoId = 'repo-1' + state.activeWorktreeId = 'wt-1' + state.lastTerminalInputAtByPaneKey = {} + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined + } + state.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }] + state.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] + state.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: '/repo/worktree', + displayName: 'main' + } + ] + } + state.tabsByWorktree = { 'wt-1': [] } + state.terminalLayoutsByTabId = {} + state.ptyIdsByTabId = {} + state.sshConnectionStates = new Map() + state.transientClearedAgentStatusConnectionIds = {} +} + +export function useRemoteAgentBackgroundRuntime(state: AgentBackgroundSessionTestState): void { + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: 'env-1', + terminalMainSideEffectAuthority: undefined + } +} + +export function expectStableAgentBackgroundPaneSpawn(spawn: TestMock): string { + const spawnArgs = spawn.mock.calls[0]?.[0] + const paneKey = spawnArgs?.env?.ORCA_PANE_KEY + const leafId = spawnArgs?.leafId + expect(typeof paneKey).toBe('string') + expect(typeof leafId).toBe('string') + expect(leafId).toMatch(AGENT_BACKGROUND_SESSION_UUID_RE) + expect(paneKey).toBe(`tab-1:${leafId}`) + return paneKey +} + +export function stubAgentBackgroundSessionWindow(mocks: { + dispatchEvent: TestMock + spawn: TestMock + write: TestMock + kill: TestMock + markTrusted: TestMock + runtimeEnvironmentCall: TestMock + runtimeEnvironmentSubscribe: TestMock +}): void { + vi.stubGlobal('window', { + dispatchEvent: mocks.dispatchEvent, + api: { + pty: { spawn: mocks.spawn, write: mocks.write, kill: mocks.kill }, + agentTrust: { markTrusted: mocks.markTrusted }, + runtime: { call: vi.fn() }, + runtimeEnvironments: { + call: mocks.runtimeEnvironmentCall, + subscribe: mocks.runtimeEnvironmentSubscribe + } + } + }) +} + +export function resetAgentBackgroundSessionTestHarness(args: { + state: AgentBackgroundSessionTestState + createTab: TestMock + closeTab: TestMock + setTabLayout: TestMock + updateTabPtyId: TestMock + spawn: TestMock + write: TestMock + kill: TestMock + markTrusted: TestMock + dispatchEvent: TestMock + getLaunchPlatform: TestMock + runtimeCall: TestMock + runtimeTransportCall: TestMock + runtimeSubscribe: TestMock + subscribeToData: TestMock + subscribeToExit: TestMock +}): void { + resetRemoteRuntimeTerminalMultiplexersForTests() + clearRuntimeCompatibilityCacheForTests() + vi.clearAllMocks() + args.getLaunchPlatform.mockReturnValue('linux') + args.runtimeTransportCall.mockImplementation( + (request) => + createCompatibleRuntimeStatusResponseIfNeeded(request) ?? + (args.runtimeCall as unknown as (value: unknown) => unknown)(request) + ) + resetAgentBackgroundSessionTestState(args.state) + args.createTab.mockImplementation(() => { + const tab = { id: 'tab-1', title: 'Terminal 1' } + args.state.tabsByWorktree['wt-1'].push(tab) + return tab + }) + args.closeTab.mockImplementation((tabId: string) => { + args.state.tabsByWorktree['wt-1'] = args.state.tabsByWorktree['wt-1'].filter( + (tab) => tab.id !== tabId + ) + }) + args.setTabLayout.mockImplementation((tabId: string, layout) => { + args.state.terminalLayoutsByTabId[tabId] = layout + }) + args.updateTabPtyId.mockImplementation((tabId: string, ptyId: string) => { + args.state.ptyIdsByTabId[tabId] = [ptyId] + }) + args.spawn.mockResolvedValue({ id: 'pty-1' }) + args.runtimeCall.mockResolvedValue({ + ok: true, + result: { terminal: { handle: 'terminal-1', worktreeId: 'wt-1', title: null } } + }) + args.runtimeSubscribe.mockImplementation(async (_request, callbacks) => { + queueMicrotask(() => callbacks.onResponse({ ok: true, result: { type: 'ready' } })) + return { unsubscribe: vi.fn(), sendBinary: vi.fn() } + }) + args.subscribeToData.mockReturnValue(vi.fn()) + args.subscribeToExit.mockReturnValue(vi.fn()) + stubAgentBackgroundSessionWindow({ + dispatchEvent: args.dispatchEvent, + spawn: args.spawn, + write: args.write, + kill: args.kill, + markTrusted: args.markTrusted, + runtimeEnvironmentCall: args.runtimeTransportCall, + runtimeEnvironmentSubscribe: args.runtimeSubscribe + }) +} diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 543ea0aeb65..9ef9117a863 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -344,7 +344,8 @@ describe('ai vault resume command runtime', () => { agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'", agentArgs: '--dangerously-skip-permissions --effort max', agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' } - } + }, + providerSession: { key: 'session_id', id: 'session-1' } }) }) @@ -511,7 +512,8 @@ describe('ai vault resume command runtime', () => { }) ).toMatchObject({ command: "cd '/home/alice/repo' && codex 'resume' 'session one'", - envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], + providerSession: { key: 'session_id', id: 'session one' } }) }) diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index b59751f08ce..165442ce18e 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -6,6 +6,7 @@ import { } from '../../../shared/ai-vault-types' import { isResumableTuiAgent, + type AgentProviderSessionMetadata, type SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import { @@ -41,6 +42,7 @@ export type AiVaultResumeStartup = { env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig + providerSession?: AgentProviderSessionMetadata } type AiVaultResumeWorktreeArgs = { @@ -80,6 +82,7 @@ export function buildAiVaultResumeStartupForWorktree( } function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVaultResumeStartup { + const providerSession = getAiVaultAgentProviderSession(args.session) if ( args.session.executionHostId && args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID && @@ -89,7 +92,8 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault ) { return { command: args.session.resumeCommand, - ...realHomeCodexResumeEnvDeletion(args.session) + ...realHomeCodexResumeEnvDeletion(args.session), + ...(providerSession ? { providerSession } : {}) } } const platform = @@ -109,10 +113,10 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault ? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell) : 'powershell' : undefined - if (isResumableTuiAgent(args.session.agent)) { + if (providerSession && isResumableTuiAgent(args.session.agent)) { const startupPlan = buildAgentResumeStartupPlan({ agent: args.session.agent, - providerSession: { key: 'session_id', id: args.session.sessionId }, + providerSession, cmdOverrides: { ...args.state.settings?.agentCmdOverrides, ...(args.commandOverride?.trim() ? { [args.session.agent]: args.commandOverride } : {}) @@ -136,7 +140,8 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault }), ...(startupPlan.env ? { env: startupPlan.env } : {}), ...realHomeCodexResumeEnvDeletion(args.session), - launchConfig: startupPlan.launchConfig + launchConfig: startupPlan.launchConfig, + providerSession } } } @@ -177,6 +182,23 @@ function resolveAiVaultResumeShell(args: AiVaultResumeWorktreeArgs): AgentStartu return resolveStartupShell(platform, shell) } +export function getAiVaultAgentProviderSession( + session: Pick & { filePath?: string } +): AgentProviderSessionMetadata | null { + if (!isResumableTuiAgent(session.agent)) { + return null + } + if (session.agent === 'antigravity') { + return { key: 'conversation_id', id: session.sessionId } + } + if (session.agent === 'pi') { + return session.filePath + ? { key: 'session_id', id: session.sessionId, transcriptPath: session.filePath } + : null + } + return { key: 'session_id', id: session.sessionId } +} + function getAiVaultResumeCodexHome( codexHome: string | null, platform: NodeJS.Platform diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 2bce1932d90..a82996c97f7 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -1,9 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT } from '@/constants/terminal' -import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' -import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' -import { resetRemoteRuntimeTerminalMultiplexersForTests } from '@/runtime/remote-runtime-terminal-multiplexer' import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import { + AGENT_BACKGROUND_SESSION_UUID_RE as UUID_RE, + createAgentBackgroundSessionTestState, + expectStableAgentBackgroundPaneSpawn, + resetAgentBackgroundSessionTestHarness, + useRemoteAgentBackgroundRuntime +} from '@/lib/agent-background-session-test-state' const mockSpawn = vi.fn() const mockKill = vi.fn() @@ -24,70 +28,14 @@ const mockPasteDraftWhenAgentReady = vi.fn() const mockMarkTrusted = vi.fn() const mockDispatchEvent = vi.fn() const mockGetAgentLaunchPlatformForRepo = vi.fn<() => NodeJS.Platform>() -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ - -function expectStablePaneSpawn(): string { - const spawnArgs = mockSpawn.mock.calls[0]?.[0] - const paneKey = spawnArgs?.env?.ORCA_PANE_KEY - const leafId = spawnArgs?.leafId - expect(typeof paneKey).toBe('string') - expect(typeof leafId).toBe('string') - expect(leafId).toMatch(UUID_RE) - expect(paneKey).toBe(`tab-1:${leafId}`) - return paneKey -} - -const state = { - activeWorktreeId: 'wt-1', - lastTerminalInputAtByPaneKey: {}, - settings: { - agentCmdOverrides: {}, - activeRuntimeEnvironmentId: null as string | null, - terminalMainSideEffectAuthority: undefined as boolean | undefined - }, - projects: [ - { - id: 'repo-1', - localWindowsRuntimePreference: { kind: 'inherit-global' as const } - } - ] as { - id: string - localWindowsRuntimePreference: - | { kind: 'inherit-global' } - | { kind: 'windows-host' } - | { kind: 'wsl'; distro: string | null } - }[], - repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], - worktreesByRepo: { - 'repo-1': [ - { - id: 'wt-1', - repoId: 'repo-1', - projectId: 'repo-1', - path: '/repo/worktree', - displayName: 'main' - } - ] - }, - tabsByWorktree: { 'wt-1': [] as { id: string; title: string }[] }, - terminalLayoutsByTabId: {} as Record< - string, - { ptyIdsByLeafId?: Record } - >, - ptyIdsByTabId: {} as Record, - sshConnectionStates: new Map(), - transientClearedAgentStatusConnectionIds: {} as Record, - allWorktrees: vi.fn(() => state.worktreesByRepo['repo-1']), +const state = createAgentBackgroundSessionTestState({ createTab: mockCreateTab, setTabCustomTitle: mockSetTabCustomTitle, updateTabPtyId: mockUpdateTabPtyId, closeTab: mockCloseTab, setTabLayout: mockSetTabLayout, - clearTabPtyId: vi.fn(), - setAgentStatus: vi.fn(), - registerAgentLaunchConfig: mockRegisterAgentLaunchConfig, - clearAgentLaunchConfig: vi.fn() -} + registerAgentLaunchConfig: mockRegisterAgentLaunchConfig +}) vi.mock('@/store', () => ({ useAppStore: { @@ -120,86 +68,23 @@ vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ describe('launchAgentBackgroundSession', () => { beforeEach(() => { - resetRemoteRuntimeTerminalMultiplexersForTests() - clearRuntimeCompatibilityCacheForTests() - vi.clearAllMocks() - mockGetAgentLaunchPlatformForRepo.mockReturnValue('linux') - mockRuntimeEnvironmentTransportCall.mockImplementation( - (args) => - createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) - ) - state.settings = { - agentCmdOverrides: {}, - activeRuntimeEnvironmentId: null, - terminalMainSideEffectAuthority: undefined - } - state.projects = [ - { - id: 'repo-1', - localWindowsRuntimePreference: { kind: 'inherit-global' } - } - ] - state.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] - state.worktreesByRepo = { - 'repo-1': [ - { - id: 'wt-1', - repoId: 'repo-1', - projectId: 'repo-1', - path: '/repo/worktree', - displayName: 'main' - } - ] - } - state.tabsByWorktree = { 'wt-1': [] } - state.terminalLayoutsByTabId = {} - state.ptyIdsByTabId = {} - state.sshConnectionStates = new Map() - state.transientClearedAgentStatusConnectionIds = {} - mockCreateTab.mockImplementation(() => { - const tab = { id: 'tab-1', title: 'Terminal 1' } - state.tabsByWorktree['wt-1'].push(tab) - return tab - }) - mockCloseTab.mockImplementation((tabId: string) => { - state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].filter((tab) => tab.id !== tabId) - }) - mockSetTabLayout.mockImplementation((tabId: string, layout) => { - state.terminalLayoutsByTabId[tabId] = layout - }) - mockUpdateTabPtyId.mockImplementation((tabId: string, ptyId: string) => { - state.ptyIdsByTabId[tabId] = [ptyId] - }) - mockSpawn.mockResolvedValue({ id: 'pty-1' }) - mockRuntimeEnvironmentCall.mockResolvedValue({ - ok: true, - result: { terminal: { handle: 'terminal-1', worktreeId: 'wt-1', title: null } } - }) - mockRuntimeEnvironmentSubscribe.mockImplementation(async (_args, callbacks) => { - queueMicrotask(() => callbacks.onResponse({ ok: true, result: { type: 'ready' } })) - return { unsubscribe: vi.fn(), sendBinary: vi.fn() } - }) - mockSubscribeToPtyData.mockReturnValue(vi.fn()) - mockSubscribeToPtyExit.mockReturnValue(vi.fn()) - vi.stubGlobal('window', { + resetAgentBackgroundSessionTestHarness({ + state, + createTab: mockCreateTab, + closeTab: mockCloseTab, + getLaunchPlatform: mockGetAgentLaunchPlatformForRepo, + runtimeCall: mockRuntimeEnvironmentCall, + runtimeTransportCall: mockRuntimeEnvironmentTransportCall, + runtimeSubscribe: mockRuntimeEnvironmentSubscribe, + subscribeToData: mockSubscribeToPtyData, + subscribeToExit: mockSubscribeToPtyExit, + setTabLayout: mockSetTabLayout, + updateTabPtyId: mockUpdateTabPtyId, dispatchEvent: mockDispatchEvent, - api: { - pty: { - spawn: mockSpawn, - write: mockWrite, - kill: mockKill - }, - agentTrust: { - markTrusted: mockMarkTrusted - }, - runtime: { - call: vi.fn() - }, - runtimeEnvironments: { - call: mockRuntimeEnvironmentTransportCall, - subscribe: mockRuntimeEnvironmentSubscribe - } - } + kill: mockKill, + markTrusted: mockMarkTrusted, + spawn: mockSpawn, + write: mockWrite }) }) @@ -239,7 +124,7 @@ describe('launchAgentBackgroundSession', () => { tabId: 'tab-1' }) ) - const paneKey = expectStablePaneSpawn() + const paneKey = expectStableAgentBackgroundPaneSpawn(mockSpawn) const leafId = paneKey.slice('tab-1:'.length) expect(mockSetTabLayout).toHaveBeenCalledWith( 'tab-1', @@ -325,11 +210,7 @@ describe('launchAgentBackgroundSession', () => { }) it('closes a runtime terminal when its tab closes before creation resolves', async () => { - state.settings = { - agentCmdOverrides: {}, - activeRuntimeEnvironmentId: 'env-1', - terminalMainSideEffectAuthority: undefined - } + useRemoteAgentBackgroundRuntime(state) let resolveCreate!: (result: { ok: true result: { terminal: { handle: string; worktreeId: string; title: null } } @@ -341,7 +222,7 @@ describe('launchAgentBackgroundSession', () => { resolveCreate = resolve }) mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => { - if (args.method === 'terminal.create') { + if (args.method === 'terminal.createAgentSession') { return createResult } return Promise.resolve({ ok: true, result: {} }) @@ -356,7 +237,7 @@ describe('launchAgentBackgroundSession', () => { await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce()) await vi.waitFor(() => expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( - expect.objectContaining({ method: 'terminal.create' }) + expect.objectContaining({ method: 'terminal.createAgentSession' }) ) ) state.tabsByWorktree['wt-1'] = [] @@ -392,7 +273,7 @@ describe('launchAgentBackgroundSession', () => { prompt: 'run the automation' }) - const paneKey = expectStablePaneSpawn() + const paneKey = expectStableAgentBackgroundPaneSpawn(mockSpawn) const leafId = paneKey.slice('tab-1:'.length) expect(mockRegisterAgentLaunchConfig).toHaveBeenLastCalledWith(paneKey, effectiveLaunchConfig, { agentType: 'claude', @@ -475,11 +356,15 @@ describe('launchAgentBackgroundSession', () => { const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void dataSidecar('\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07') - expectStablePaneSpawn() - expect(state.setAgentStatus.mock.calls.at(-1)?.[4]).toEqual({ connectionId: 'ssh-a' }) - expect(state.setAgentStatus.mock.calls.at(-1)?.[5]).toEqual({ - launchToken: expect.stringMatching(UUID_RE) - }) + const paneKey = expectStableAgentBackgroundPaneSpawn(mockSpawn) + expect(state.setAgentStatus).toHaveBeenCalledWith( + paneKey, + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }), + undefined, + undefined, + { connectionId: 'ssh-a' }, + { launchToken: expect.stringMatching(UUID_RE) } + ) }) it('skips the duplicate OSC store write under main side-effect authority', async () => { @@ -517,16 +402,26 @@ describe('launchAgentBackgroundSession', () => { prompt: 'check the status spinner' }) - expectStablePaneSpawn() - expect(state.setAgentStatus.mock.calls.at(-1)?.[4]).toEqual({ connectionId: 'ssh-a' }) - expect(state.setAgentStatus.mock.calls.at(-1)?.[5]).toEqual({ - launchConfig: { - agentCommand: "command-code --trust '--yolo'", - agentArgs: '--yolo', - agentEnv: {} + const paneKey = expectStableAgentBackgroundPaneSpawn(mockSpawn) + expect(state.setAgentStatus).toHaveBeenCalledWith( + paneKey, + { + state: 'working', + prompt: 'check the status spinner', + agentType: 'command-code' }, - launchToken: expect.stringMatching(UUID_RE) - }) + undefined, + undefined, + { connectionId: 'ssh-a' }, + { + launchConfig: { + agentCommand: "command-code --trust '--yolo'", + agentArgs: '--yolo', + agentEnv: {} + }, + launchToken: expect.stringMatching(UUID_RE) + } + ) }) it('uses a sidecar exit watcher so completion survives terminal attachment', async () => { @@ -782,11 +677,7 @@ describe('launchAgentBackgroundSession', () => { }) it('creates background sessions on the active runtime environment', async () => { - state.settings = { - agentCmdOverrides: {}, - activeRuntimeEnvironmentId: 'env-1', - terminalMainSideEffectAuthority: undefined - } + useRemoteAgentBackgroundRuntime(state) const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') const result = await launchAgentBackgroundSession({ @@ -797,9 +688,10 @@ describe('launchAgentBackgroundSession', () => { expect(mockSpawn).not.toHaveBeenCalled() const params = mockRuntimeEnvironmentCall.mock.calls[0]?.[0]?.params - const paneKey = params?.env?.ORCA_PANE_KEY - const leafId = typeof paneKey === 'string' ? paneKey.slice('tab-1:'.length) : '' + const leafId = params?.placement?.leafId expect(leafId).toMatch(UUID_RE) + // Why: background launches have no explicit recipe override, so remote host settings win. + expect(params).not.toHaveProperty('agentArgs') expect(mockRegisterAgentLaunchConfig).toHaveBeenCalledWith( `tab-1:${leafId}`, { @@ -824,18 +716,14 @@ describe('launchAgentBackgroundSession', () => { ) expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', - method: 'terminal.create', + method: 'terminal.createAgentSession', params: expect.objectContaining({ + clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/), worktree: 'id:wt-1', - command: "claude '--dangerously-skip-permissions' 'run the automation'", - launchAgent: 'claude', - env: expect.objectContaining({ - ORCA_PANE_KEY: `tab-1:${leafId}`, - ORCA_TAB_ID: 'tab-1', - ORCA_WORKTREE_ID: 'wt-1' - }), - tabId: 'tab-1', - leafId, + agent: 'claude', + prompt: 'run the automation', + promptDelivery: 'auto-submit', + placement: { tabId: 'tab-1', leafId }, presentation: 'background' }), timeoutMs: 15_000 @@ -858,12 +746,53 @@ describe('launchAgentBackgroundSession', () => { }) }) + it('preserves the legacy background spawn on an old remote host', async () => { + useRemoteAgentBackgroundRuntime(state) + mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => { + if (request.method === 'status.get') { + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'old-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [] + } + }) + } + return Promise.resolve({ + id: 'create', + ok: true, + result: { terminal: { handle: 'legacy-terminal-1' } } + }) + }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await expect( + launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run remotely' + }) + ).resolves.toMatchObject({ ptyId: 'remote:env-1@@legacy-terminal-1' }) + + expect(mockRuntimeEnvironmentTransportCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.create', + params: expect.objectContaining({ + worktree: 'id:wt-1', + command: "claude '--dangerously-skip-permissions' 'run remotely'", + launchAgent: 'claude', + presentation: 'background' + }) + }) + ) + }) + it('closes a created runtime terminal when its data subscription fails', async () => { - state.settings = { - agentCmdOverrides: {}, - activeRuntimeEnvironmentId: 'env-1', - terminalMainSideEffectAuthority: undefined - } + useRemoteAgentBackgroundRuntime(state) mockRuntimeEnvironmentSubscribe.mockRejectedValueOnce(new Error('subscription failed')) const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 15df8a92aef..9ab4a5e684d 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -25,15 +25,14 @@ import { import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' -import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { retireProvider, retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal' import { createBrowserUuid } from '@/lib/browser-uuid' +import { createRuntimeAgentBackgroundTerminal } from '@/lib/runtime-agent-background-create' import { subscribeToRuntimeTerminalData, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' -import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery' import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' @@ -190,27 +189,25 @@ export async function launchAgentBackgroundSession( if (runtimeTarget.kind === 'environment') { // Why: runtime environments execute on the server; using local pty.spawn // would silently run automation on the client for a remote workspace. - const created = await callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( - runtimeTarget, - 'terminal.create', - { - worktree: toRuntimeWorktreeSelector(worktreeId), + const created = await createRuntimeAgentBackgroundTerminal({ + environmentId: runtimeTarget.environmentId, + worktreeId, + tabId: tab.id, + leafId, + agent, + ...(hasPrompt && !isFollowupPath ? { prompt: trimmedPrompt } : {}), + ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), + legacy: { command: startupPlan.launchCommand, - launchConfig: startupPlan.launchConfig, - launchToken, - launchAgent: agent, + env: paneEnv, ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), - env: paneEnv, - title, - tabId: tab.id, - leafId, - // Why: local renderer owns the hidden tab; remote runtime should not reveal UI. - presentation: 'background' - }, - { timeoutMs: 15_000 } - ) + launchConfig: startupPlan.launchConfig, + launchToken, + ...(title ? { title } : {}) + } + }) runtimeTerminalHandle = created.terminal.handle ptyId = toRemoteRuntimePtyId(runtimeTerminalHandle, runtimeTarget.environmentId) } else { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-cwd.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-cwd.test.ts index 572469e18e4..ec6af85e7d9 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-cwd.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-cwd.test.ts @@ -122,11 +122,10 @@ describe('launchAgentInNewTab initial cwd', () => { expect.objectContaining({ worktreeId: 'wt-1', environmentId: 'web-runtime', - promptAfterReady: { - content: 'continue the unfinished task', - submit: true, - forcePaste: true - } + prompt: 'continue the unfinished task', + promptDelivery: 'submit-after-ready', + pastePromptAfterReady: 'continue the unfinished task', + submitPastedPrompt: true }) ) expect(store.createTab).not.toHaveBeenCalled() diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts new file mode 100644 index 00000000000..3ab3b997920 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + createTab: vi.fn(), + closeTab: vi.fn(), + createWebRuntimeSessionTerminal: vi.fn(), + setActiveTabType: vi.fn() +})) + +const store = { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + settings: { + agentCmdOverrides: {} as Record, + agentDefaultArgs: {} as Record, + agentDefaultEnv: {} as Record>, + activeRuntimeEnvironmentId: 'web-runtime' as string | null + }, + projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } }], + repos: [{ id: 'repo-1', connectionId: null, path: '/repo' }], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: '/repo/worktree', + displayName: 'main' + } + ] + }, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] as { id: string; launchAgent?: string }[] }, + openFiles: [] as { id: string; worktreeId: string }[], + browserTabsByWorktree: {} as Record, + tabBarOrderByWorktree: {} as Record, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + sshConnectionStates: new Map(), + transientClearedAgentStatusConnectionIds: {}, + allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']), + createTab: mocks.createTab, + closeTab: mocks.closeTab, + queueTabStartupCommand: vi.fn(), + setActiveTabType: mocks.setActiveTabType, + setTabBarOrder: vi.fn(), + setAgentStatus: vi.fn(), + seedNativeChatLaunchPrompt: vi.fn(), + markNativeChatLaunchPromptFailed: vi.fn() +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: vi.fn() } })) +vi.mock('@/components/tab-bar/reconcile-order', () => ({ reconcileTabOrder: vi.fn(() => []) })) +vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn() })) +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn(), + tuiAgentToAgentKind: (agent: string) => agent +})) +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionTerminal: mocks.createWebRuntimeSessionTerminal, + isWebRuntimeSessionActive: vi.fn(() => true), + isWebTerminalSurfaceTabId: vi.fn(() => false) +})) + +describe('launchAgentInNewTab paired web runtime', () => { + beforeEach(() => { + vi.clearAllMocks() + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: 'web-runtime' + } + store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } + mocks.createWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) + }) + + it('delegates agent quick launch to the host runtime', async () => { + store.tabsByWorktree['wt-1'].push({ id: 'stale-agent-tab', launchAgent: 'claude' }) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + groupId: 'group-1' + }) + + expect(result).toEqual(expect.objectContaining({ tabId: null, pasteDraftAfterLaunch: false })) + expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + targetGroupId: 'group-1', + activate: true, + agentSessionKind: 'fresh', + agent: 'claude', + viewMode: 'terminal' + }) + expect(mocks.createTab).not.toHaveBeenCalled() + await Promise.resolve() + expect(mocks.setActiveTabType).toHaveBeenCalledWith('terminal') + expect(mocks.closeTab).toHaveBeenCalledWith('stale-agent-tab', { reason: 'cleanup' }) + }) + + it('forwards prompt launch env and captured config to the host runtime', async () => { + store.settings.agentDefaultArgs = { codex: '--model gpt-5 --reasoning-effort high' } + store.settings.agentDefaultEnv = { codex: { CODEX_PROFILE: 'captured' } } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'fix the spinner', + groupId: 'group-1' + }) + + expect(result).toEqual(expect.objectContaining({ tabId: null, pasteDraftAfterLaunch: false })) + expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + targetGroupId: 'group-1', + activate: true, + agentSessionKind: 'fresh', + launchAgent: 'codex', + command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", + env: { CODEX_PROFILE: 'captured' }, + launchConfig: { + agentCommand: "codex '--model' 'gpt-5' '--reasoning-effort' 'high'", + agentArgs: '--model gpt-5 --reasoning-effort high', + agentEnv: { CODEX_PROFILE: 'captured' } + }, + startupCommandDelivery: 'shell-ready', + prompt: 'fix the spinner', + promptDelivery: 'auto-submit', + viewMode: 'terminal' + }) + expect(mocks.createTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 547bbefed7e..35d069935c8 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -30,6 +30,10 @@ const store = { terminalWindowsShell?: string experimentalNativeChat?: boolean openAgentTabsInChatByDefault?: boolean + nativeChatSessionOptions?: Record< + string, + { model?: string; valuesByModel?: Record> } + > }, projects: [ { @@ -123,7 +127,7 @@ describe('launchAgentInNewTab', () => { beforeEach(() => { vi.clearAllMocks() mockIsWebRuntimeSessionActive.mockReturnValue(false) - mockCreateWebRuntimeSessionTerminal.mockResolvedValue(true) + mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) store.activeRepoId = 'repo-1' store.activeWorktreeId = 'wt-1' store.settings = { @@ -277,92 +281,41 @@ describe('launchAgentInNewTab', () => { }) }) - it('delegates agent quick launch to the host runtime in paired web clients', async () => { + it('preserves paired-host draft delivery and supported launch preferences', async () => { mockIsWebRuntimeSessionActive.mockReturnValue(true) store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, agentDefaultEnv: {}, - activeRuntimeEnvironmentId: 'web-runtime' - } - store.tabsByWorktree = { - 'wt-1': [ - { id: 'tab-1' }, - { id: 'stale-agent-tab', launchAgent: 'claude' } as { id: string; launchAgent: string } - ] + activeRuntimeEnvironmentId: 'web-runtime', + nativeChatSessionOptions: { + claude: { + model: 'opus', + valuesByModel: { opus: { effort: 'high', fastMode: true } } + } + } } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1', - groupId: 'group-1' + prompt: 'review before sending', + promptDelivery: 'draft', + agentArgs: '--permission-mode plan' }) - expect(result).toEqual( + expect(result).toEqual(expect.objectContaining({ tabId: null, pasteDraftAfterLaunch: false })) + expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith( expect.objectContaining({ - tabId: null, - pasteDraftAfterLaunch: false + launchAgent: 'claude', + prompt: 'review before sending', + promptDelivery: 'draft', + agentArgs: '--permission-mode plan', + launchPreferences: { model: 'opus', effort: 'high' } }) ) - expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - environmentId: 'web-runtime', - targetGroupId: 'group-1', - activate: true, - agent: 'claude', - viewMode: 'terminal' - }) expect(mockCreateTab).not.toHaveBeenCalled() - expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() - await Promise.resolve() - expect(mockSetActiveTabType).toHaveBeenCalledWith('terminal') - expect(store.closeTab).toHaveBeenCalledWith('stale-agent-tab', { - reason: 'cleanup' - }) - }) - - it('forwards prompt launch env and captured config to paired web runtime hosts', async () => { - mockIsWebRuntimeSessionActive.mockReturnValue(true) - store.settings = { - agentCmdOverrides: {}, - agentDefaultArgs: { codex: '--model gpt-5 --reasoning-effort high' }, - agentDefaultEnv: { codex: { CODEX_PROFILE: 'captured' } }, - activeRuntimeEnvironmentId: 'web-runtime' - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - const result = launchAgentInNewTab({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'fix the spinner', - groupId: 'group-1' - }) - - expect(result).toEqual( - expect.objectContaining({ - tabId: null, - pasteDraftAfterLaunch: false - }) - ) - expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - environmentId: 'web-runtime', - targetGroupId: 'group-1', - activate: true, - command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", - env: { CODEX_PROFILE: 'captured' }, - startupCommandDelivery: 'shell-ready', - launchConfig: { - agentCommand: "codex '--model' 'gpt-5' '--reasoning-effort' 'high'", - agentArgs: '--model gpt-5 --reasoning-effort high', - agentEnv: { CODEX_PROFILE: 'captured' } - }, - launchAgent: 'codex', - viewMode: 'terminal' - }) - expect(mockCreateTab).not.toHaveBeenCalled() - expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() }) it('propagates the default chat mode to paired web runtime launches', async () => { @@ -383,6 +336,7 @@ describe('launchAgentInNewTab', () => { expect.objectContaining({ worktreeId: 'wt-1', environmentId: 'web-runtime', + agentSessionKind: 'fresh', agent: 'codex', viewMode: 'chat' }) @@ -407,6 +361,7 @@ describe('launchAgentInNewTab', () => { expect.objectContaining({ worktreeId: 'wt-1', environmentId: 'web-runtime', + agentSessionKind: 'fresh', agent: 'codex', viewMode: 'terminal' }) @@ -415,7 +370,10 @@ describe('launchAgentInNewTab', () => { it('surfaces a toast when host agent launch fails in paired web clients', async () => { mockIsWebRuntimeSessionActive.mockReturnValue(true) - mockCreateWebRuntimeSessionTerminal.mockResolvedValue(false) + mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ + status: 'failed', + message: 'Upgrade the remote Orca host before starting or resuming agent sessions.' + }) store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, @@ -430,7 +388,9 @@ describe('launchAgentInNewTab', () => { }) await Promise.resolve() - expect(mockToastError).toHaveBeenCalledWith('Could not launch claude in a new terminal.') + expect(mockToastError).toHaveBeenCalledWith( + 'Upgrade the remote Orca host before starting or resuming agent sessions.' + ) expect(mockSetActiveTabType).not.toHaveBeenCalled() }) @@ -551,7 +511,8 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: 'codex "--model" "gpt-5" "fix the spinner"' + command: 'codex "--model" "gpt-5" "fix the spinner"', + agentArgsOverride: '--model gpt-5' }) ) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 01c2f027636..606ec025747 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -205,18 +205,14 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI environmentId: runtimeEnvironmentId, groupId, cwd: initialCwd, - hasPrompt, startupPlan, - ...(pasteDraftAfterLaunch !== null - ? { - promptAfterReady: { - content: pasteDraftAfterLaunch, - submit: submitPastedPrompt, - forcePaste: promptDelivery === 'submit-after-ready' - } - } - : {}), - // Why: send the client's resolved terminal choice explicitly, else a paired host applies its own default. + prompt: trimmedPrompt, + promptDelivery, + pastePromptAfterReady: pasteDraftAfterLaunch, + submitPastedPrompt, + agentArgs, + // Why: omission means terminal locally, but would let a paired host apply + // its own default; send the client's resolved terminal choice explicitly. viewMode: initialViewModeProps.viewMode ?? 'terminal', onPromptDelivered }) @@ -247,6 +243,8 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, launchAgent: agent, + ...(agentArgs !== undefined ? { agentArgsOverride: agentArgs } : {}), + ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), diff --git a/src/renderer/src/lib/launch-agent-web-host-tab.ts b/src/renderer/src/lib/launch-agent-web-host-tab.ts index 5e574d39aa5..bfbf5d5c36f 100644 --- a/src/renderer/src/lib/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -1,13 +1,15 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' -import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { createWebRuntimeAgentSessionTerminal, createWebRuntimeSessionTerminal, isWebTerminalSurfaceTabId } from '@/runtime/web-runtime-session' +import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import type { Tab, TuiAgent } from '../../../shared/types' +import type { AgentPromptDelivery } from '../../../shared/agent-session-host-authority' import { translate } from '@/i18n/i18n' +import { toAgentLaunchPreferences } from '@/runtime/agent-session-create-operation' function removeStaleLocalAgentTabsForWebHostLaunch(worktreeId: string): void { const state = useAppStore.getState() @@ -34,13 +36,12 @@ export function launchAgentInWebHostTab(args: { environmentId: string | null groupId?: string cwd?: string | null - hasPrompt: boolean startupPlan: AgentStartupPlan - promptAfterReady?: { - content: string - submit: boolean - forcePaste: boolean - } + prompt: string + promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' + pastePromptAfterReady: string | null + submitPastedPrompt: boolean + agentArgs?: string | null viewMode?: Tab['viewMode'] onPromptDelivered?: () => void }): Promise<{ delivered: boolean; failureNotified: boolean }> { @@ -50,12 +51,19 @@ export function launchAgentInWebHostTab(args: { environmentId, groupId, cwd, - hasPrompt, startupPlan, - promptAfterReady, + prompt, + promptDelivery, + pastePromptAfterReady, + submitPastedPrompt, + agentArgs, viewMode, onPromptDelivered } = args + const hasPrompt = prompt.length > 0 + const launchPreferences = toAgentLaunchPreferences(startupPlan.sessionOptions) + const structuredPromptDelivery: AgentPromptDelivery = + promptDelivery === 'draft' ? 'draft' : 'auto-submit' removeStaleLocalAgentTabsForWebHostLaunch(worktreeId) const launch = { worktreeId, @@ -64,41 +72,44 @@ export function launchAgentInWebHostTab(args: { activate: true, ...(cwd?.trim() ? { cwd } : {}), ...(viewMode ? { viewMode } : {}), + agentSessionKind: 'fresh', ...(hasPrompt ? { + launchAgent: agent, command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, - launchAgent: agent, ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}) } - : { agent }) - } - const creation = promptAfterReady - ? createWebRuntimeAgentSessionTerminal({ - ...launch, - agent, - promptAfterReady: promptAfterReady.content, - submitPrompt: promptAfterReady.submit, - forcePromptPaste: promptAfterReady.forcePaste - }) - : createWebRuntimeSessionTerminal(launch) + : { agent }), + ...(hasPrompt && pastePromptAfterReady === null ? { prompt } : {}), + ...(hasPrompt && pastePromptAfterReady === null + ? { promptDelivery: structuredPromptDelivery } + : {}), + ...(agentArgs !== undefined ? { agentArgs } : {}), + ...(launchPreferences ? { launchPreferences } : {}) + } as const - return creation.then((result) => { - const created = typeof result === 'boolean' ? result : result.created - const promptDelivered = typeof result === 'boolean' ? result : result.promptDelivered + const handleCreation = ({ + outcome, + promptDelivered + }: { + outcome: Awaited> + promptDelivered: boolean + }): { delivered: boolean; failureNotified: boolean } => { // Why: created means the host accepted the launch, not that a local tab // exists; keep pruning stale local rows until the snapshot mirrors. removeStaleLocalAgentTabsForWebHostLaunch(worktreeId) - if (!created) { + if (outcome.status === 'failed') { toast.error( - translate( - 'auto.lib.launch.agent.in.new.tab.11cce5cc77', - 'Could not launch {{value0}} in a new terminal.', - { value0: agent } - ) + outcome.message || + translate( + 'auto.lib.launch.agent.in.new.tab.11cce5cc77', + 'Could not launch {{value0}} in a new terminal.', + { value0: agent } + ) ) return { delivered: false, failureNotified: true } } @@ -107,5 +118,18 @@ export function launchAgentInWebHostTab(args: { onPromptDelivered?.() } return { delivered: promptDelivered, failureNotified: false } - }) + } + + if (pastePromptAfterReady !== null) { + return createWebRuntimeAgentSessionTerminal({ + ...launch, + agent, + promptAfterReady: pastePromptAfterReady, + submitPrompt: submitPastedPrompt, + forcePromptPaste: promptDelivery === 'submit-after-ready' + }).then(handleCreation) + } + return createWebRuntimeSessionTerminal(launch).then((outcome) => + handleCreation({ outcome, promptDelivered: outcome.status === 'created' && hasPrompt }) + ) } diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index e84e8979696..9eaee5b643f 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -58,7 +58,7 @@ describe('launchAiVaultSessionInNewTab', () => { vi.clearAllMocks() runtimeMocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue(null) runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(false) - runtimeMocks.createWebRuntimeSessionTerminal.mockResolvedValue(true) + runtimeMocks.createWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) mockState.tabsByWorktree = {} mockState.openFiles = [] mockState.browserTabsByWorktree = {} @@ -153,7 +153,8 @@ describe('launchAiVaultSessionInNewTab', () => { agentCommand: 'codex', agentArgs: '', agentEnv: { CODEX_PROFILE: 'runtime' } - } + }, + providerSession: { key: 'session_id', id: 'session-1' } }) expect(result.tabId).toBeNull() @@ -161,6 +162,8 @@ describe('launchAiVaultSessionInNewTab', () => { worktreeId: 'wt-1', environmentId: 'env-1', targetGroupId: 'group-1', + agentSessionKind: 'resume', + launchAgent: 'codex', command: "codex resume 'session-1'", env: { CODEX_PROFILE: 'runtime' }, envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], @@ -169,14 +172,15 @@ describe('launchAiVaultSessionInNewTab', () => { agentArgs: '', agentEnv: { CODEX_PROFILE: 'runtime' } }, - launchAgent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + agentArgs: '', activate: true }) expect(mockCreateTab).not.toHaveBeenCalled() expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() if (result.tabId === null) { - await expect(result.runtimeLaunch).resolves.toBe(true) + await expect(result.runtimeLaunch).resolves.toEqual({ status: 'created' }) } expect(mockSetActiveTabType).toHaveBeenCalledWith('terminal') }) diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index 306cd6d8088..6e0b9a897c4 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -7,12 +7,16 @@ import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' import type { AiVaultAgent } from '../../../shared/ai-vault-types' -import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../shared/agent-session-resume' import type { TabSplitDirection } from '@/store/slices/tabs' +import type { WebRuntimeTerminalCreateOutcome } from '@/runtime/web-runtime-session' export type LaunchAiVaultSessionInNewTabResult = | { tabId: string; groupId?: string } - | { tabId: null; groupId?: string; runtimeLaunch: Promise } + | { tabId: null; groupId?: string; runtimeLaunch: Promise } export function launchAiVaultSessionInNewTab(args: { agent: AiVaultAgent @@ -21,6 +25,7 @@ export function launchAiVaultSessionInNewTab(args: { env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig + providerSession?: AgentProviderSessionMetadata targetGroupId?: string splitDirection?: TabSplitDirection }): LaunchAiVaultSessionInNewTabResult { @@ -32,22 +37,26 @@ export function launchAiVaultSessionInNewTab(args: { worktreeId: args.worktreeId, environmentId: runtimeEnvironmentId, ...(targetGroupId ? { targetGroupId } : {}), + agentSessionKind: 'resume', + launchAgent: args.agent, command: args.command, ...(args.env ? { env: args.env } : {}), ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), - launchAgent: args.agent, + ...(args.providerSession ? { providerSession: args.providerSession } : {}), + ...(args.launchConfig ? { agentArgs: args.launchConfig.agentArgs } : {}), activate: true - }).then((created) => { - if (created) { + }) + const observedRuntimeLaunch = runtimeLaunch.then((outcome) => { + if (outcome.status === 'created') { useAppStore.getState().setActiveTabType('terminal') } - return created + return outcome }) return { tabId: null, ...(targetGroupId ? { groupId: targetGroupId } : {}), - runtimeLaunch + runtimeLaunch: observedRuntimeLaunch } } diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-remote-compat.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-remote-compat.test.ts new file mode 100644 index 00000000000..247b8c475f9 --- /dev/null +++ b/src/renderer/src/lib/resume-sleeping-agent-session-remote-compat.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { useAppStore } from '@/store' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialState = useAppStore.getState() + +function record(origin: 'live' | 'quit'): SleepingAgentSessionRecord { + return { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin + } +} + +function setRemoteSleepRecord(value: SleepingAgentSessionRecord): void { + useAppStore.setState({ + settings: { ...initialState.settings, activeRuntimeEnvironmentId: 'env-1' }, + tabsByWorktree: { + 'wt-1': [ + { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + sleepingAgentSessionsByPaneKey: { [value.paneKey]: value } + } as never) +} + +afterEach(() => { + useAppStore.setState(initialState, true) +}) + +describe('remote sleeping-agent compatibility', () => { + it('queues records for transport-level host authority on a capable host', () => { + const value = record('live') + setRemoteSleepRecord(value) + + expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[value.paneKey]).toBeUndefined() + }) + + it('preserves legacy automatic wake when host authority is not known', () => { + const value = record('quit') + setRemoteSleepRecord(value) + + expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[value.paneKey]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/runtime-agent-background-create.ts b/src/renderer/src/lib/runtime-agent-background-create.ts new file mode 100644 index 00000000000..70915ae63d4 --- /dev/null +++ b/src/renderer/src/lib/runtime-agent-background-create.ts @@ -0,0 +1,80 @@ +import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' +import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' +import type { SessionOptionValue } from '../../../shared/native-chat-session-options' +import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' +import type { TuiAgent } from '../../../shared/types' +import { + createAgentSessionCreateOperation, + toAgentLaunchPreferences, + withAgentSessionCreateOperationId +} from '@/runtime/agent-session-create-operation' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { runRemoteAgentSessionLaunch } from '@/runtime/remote-agent-session-launch' + +export async function createRuntimeAgentBackgroundTerminal(args: { + environmentId: string + worktreeId: string + tabId: string + leafId: string + agent: TuiAgent + prompt?: string + sessionOptions?: Record + legacy: { + command: string + env: Record + startupCommandDelivery?: StartupCommandDelivery + launchConfig: SleepingAgentLaunchConfig + launchToken: string + title?: string + } +}): Promise<{ terminal: RuntimeTerminalCreate }> { + const operation = createAgentSessionCreateOperation() + const launchPreferences = toAgentLaunchPreferences(args.sessionOptions) + return await runRemoteAgentSessionLaunch({ + environmentId: args.environmentId, + hostAuthority: () => + operation.run((clientOperationId) => + callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( + { kind: 'environment', environmentId: args.environmentId }, + 'terminal.createAgentSession', + withAgentSessionCreateOperationId( + { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + agent: args.agent, + ...(args.prompt + ? { prompt: args.prompt, promptDelivery: 'auto-submit' as const } + : {}), + ...(launchPreferences ? { launchPreferences } : {}), + placement: { tabId: args.tabId, leafId: args.leafId }, + // Why: local renderer owns the hidden tab; remote runtime should not reveal UI. + presentation: 'background' + }, + clientOperationId + ), + { timeoutMs: 15_000 } + ) + ), + legacy: ({ skipCompatibilityCheck }) => + callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( + { kind: 'environment', environmentId: args.environmentId }, + 'terminal.create', + { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + command: args.legacy.command, + ...(args.legacy.startupCommandDelivery + ? { startupCommandDelivery: args.legacy.startupCommandDelivery } + : {}), + env: args.legacy.env, + launchConfig: args.legacy.launchConfig, + launchToken: args.legacy.launchToken, + launchAgent: args.agent, + ...(args.legacy.title ? { title: args.legacy.title } : {}), + tabId: args.tabId, + leafId: args.leafId, + presentation: 'background' + }, + { timeoutMs: 15_000, skipCompatibilityCheck } + ) + }) +} diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index fea4c3d5de8..50fee2a1e84 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -103,6 +103,7 @@ export function launchSleepingAgentSession( launchConfig: startupPlan.launchConfig, resumeProviderSession: record.providerSession, launchAgent: record.agent, + ...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}), ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), diff --git a/src/renderer/src/lib/workspace-session-host-field-ownership.ts b/src/renderer/src/lib/workspace-session-host-field-ownership.ts new file mode 100644 index 00000000000..1f5c4098e85 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-host-field-ownership.ts @@ -0,0 +1,58 @@ +import type { WorkspaceSessionState } from '../../../shared/types' + +export type WorkspaceSessionFieldOwnership = + | 'global' + | 'hostPrivate' + | 'worktreeKeyed' + | 'worktreeArray' + | 'tabKeyed' + | 'browserWorkspaceKeyed' + | 'fileKeyed' + | 'sleepingAgentKeyed' + | 'paneKeyed' + | 'surfaceTombstoneKeyed' + +export const WORKSPACE_SESSION_FIELD_OWNERSHIP = { + activeRepoId: 'global', + activeWorktreeId: 'global', + activeTabId: 'global', + browserUrlHistory: 'global', + // Why: SSH remains local-owned, so its connection identifiers stay in the local slice. + activeConnectionIdsAtShutdown: 'global', + tabsByWorktree: 'worktreeKeyed', + openFilesByWorktree: 'worktreeKeyed', + activeFileIdByWorktree: 'worktreeKeyed', + activeBrowserTabIdByWorktree: 'worktreeKeyed', + activeTabTypeByWorktree: 'worktreeKeyed', + activeTabIdByWorktree: 'worktreeKeyed', + browserTabsByWorktree: 'worktreeKeyed', + unifiedTabs: 'worktreeKeyed', + tabGroups: 'worktreeKeyed', + tabGroupLayouts: 'worktreeKeyed', + activeGroupIdByWorktree: 'worktreeKeyed', + lastVisitedAtByWorktreeId: 'worktreeKeyed', + defaultTerminalTabsAppliedByWorktreeId: 'worktreeKeyed', + activeWorkspaceKey: 'global', + activeWorktreeIdsOnShutdown: 'worktreeArray', + terminalLayoutsByTabId: 'tabKeyed', + remoteSessionIdsByTabId: 'tabKeyed', + browserPagesByWorkspace: 'browserWorkspaceKeyed', + markdownFrontmatterVisible: 'fileKeyed', + sleepingAgentSessionsByPaneKey: 'sleepingAgentKeyed', + terminalPtyIncarnationsByPaneKey: 'paneKeyed', + // Why: this host-issued fence must never collide while unified renderer state merges equal repo ids across hosts. + terminalTopologyRevisionByRepoId: 'hostPrivate', + terminalSurfaceTombstonesByPaneKey: 'surfaceTombstoneKeyed' +} as const satisfies Record + +// Why: an unclassified persisted field would otherwise disappear from every non-local host. +type MissingOwnership = Exclude< + keyof WorkspaceSessionState, + keyof typeof WORKSPACE_SESSION_FIELD_OWNERSHIP +> +const exhaustive: [MissingOwnership] extends [never] ? true : never = true +void exhaustive + +export const GLOBAL_WORKSPACE_SESSION_FIELDS = ( + Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[] +).filter((field) => WORKSPACE_SESSION_FIELD_OWNERSHIP[field] === 'global') diff --git a/src/renderer/src/lib/workspace-session-host-records.ts b/src/renderer/src/lib/workspace-session-host-records.ts new file mode 100644 index 00000000000..bb34dcde12a --- /dev/null +++ b/src/renderer/src/lib/workspace-session-host-records.ts @@ -0,0 +1,61 @@ +import type { WorkspaceSessionState } from '../../../shared/types' + +export type WorkspaceSessionRecord = Record + +export function isWorkspaceSessionRecord(value: unknown): value is WorkspaceSessionRecord { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +export function buildWorktreeIdByTabId(state: WorkspaceSessionState): Map { + const byTab = new Map() + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree ?? {})) { + for (const tab of tabs) { + byTab.set(tab.id, worktreeId) + } + } + // Why: unified-only tabs still need their host-owned layout and PTY records routed correctly. + for (const tabs of Object.values(state.unifiedTabs ?? {})) { + for (const tab of tabs) { + if (!byTab.has(tab.id)) { + byTab.set(tab.id, tab.worktreeId) + } + } + } + return byTab +} + +export function buildWorktreeIdByFileId(state: WorkspaceSessionState): Map { + const byFile = new Map() + for (const files of Object.values(state.openFilesByWorktree ?? {})) { + for (const file of files) { + byFile.set(file.filePath, file.worktreeId) + } + } + return byFile +} + +export function mergeWorkspaceSessionRecordField( + out: WorkspaceSessionRecord, + field: keyof WorkspaceSessionState, + slice: WorkspaceSessionState +): void { + const value = slice[field] + if (!isWorkspaceSessionRecord(value)) { + return + } + const target = (out[field] ??= {}) as WorkspaceSessionRecord + Object.assign(target, value) +} + +export function mergeWorkspaceSessionArrayField( + out: WorkspaceSessionRecord, + field: keyof WorkspaceSessionState, + slice: WorkspaceSessionState +): void { + const value = slice[field] + if (!Array.isArray(value)) { + return + } + const target = (out[field] ??= []) as unknown[] + target.push(...value) +} diff --git a/src/renderer/src/lib/workspace-session-host-split.test.ts b/src/renderer/src/lib/workspace-session-host-split.test.ts index a2cff0a2eab..6e1448c076f 100644 --- a/src/renderer/src/lib/workspace-session-host-split.test.ts +++ b/src/renderer/src/lib/workspace-session-host-split.test.ts @@ -219,6 +219,44 @@ describe('splitWorkspaceSessionByHost', () => { expect(slices[RUNTIME_A]?.sleepingAgentSessionsByPaneKey).toHaveProperty('pane-a') }) + + it('routes terminal incarnation authority with its owning surface', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { 'a-wt': [makeTab('tab-a', 'a-wt')] }, + terminalPtyIncarnationsByPaneKey: { 'tab-a:leaf-a': 'inc-a' }, + terminalSurfaceTombstonesByPaneKey: { + 'tab-b:leaf-b': { + worktreeId: 'b-wt', + parentTabId: 'tab-b', + leafId: 'leaf-b', + ptyId: 'pty-b', + incarnationId: 'inc-b', + retiredAt: 1 + } + } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[RUNTIME_A]?.terminalPtyIncarnationsByPaneKey).toEqual({ + 'tab-a:leaf-a': 'inc-a' + }) + expect(slices[RUNTIME_B]?.terminalSurfaceTombstonesByPaneKey).toHaveProperty('tab-b:leaf-b') + }) + + it('does not send host-private topology authority through renderer partitions', () => { + const state: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { 'a-wt': [makeTab('tab-a', 'a-wt')] }, + terminalTopologyRevisionByRepoId: { 'a-repo': 7 } + } + + const slices = splitWorkspaceSessionByHost(state, ownerByPrefix()) + + expect(slices[LOCAL_EXECUTION_HOST_ID]?.terminalTopologyRevisionByRepoId).toBeUndefined() + expect(slices[RUNTIME_A]?.terminalTopologyRevisionByRepoId).toBeUndefined() + }) }) describe('mergeWorkspaceSessionsFromHosts', () => { @@ -256,6 +294,21 @@ describe('mergeWorkspaceSessionsFromHosts', () => { expect(merged.tabsByWorktree).toBeUndefined() expect(() => mergeWorkspaceSessionsFromHosts({ [RUNTIME_A]: undefined })).not.toThrow() }) + + it('does not merge host-private topology authority into unified renderer state', () => { + const merged = mergeWorkspaceSessionsFromHosts({ + [LOCAL_EXECUTION_HOST_ID]: { + ...getDefaultWorkspaceSession(), + terminalTopologyRevisionByRepoId: { duplicate: 3 } + }, + [RUNTIME_A]: { + ...getDefaultWorkspaceSession(), + terminalTopologyRevisionByRepoId: { duplicate: 9 } + } + }) + + expect(merged.terminalTopologyRevisionByRepoId).toBeUndefined() + }) }) describe('split → merge round trip', () => { diff --git a/src/renderer/src/lib/workspace-session-host-split.ts b/src/renderer/src/lib/workspace-session-host-split.ts index 2be516f48cc..43343249589 100644 --- a/src/renderer/src/lib/workspace-session-host-split.ts +++ b/src/renderer/src/lib/workspace-session-host-split.ts @@ -1,5 +1,17 @@ import type { WorkspaceSessionState } from '../../../shared/types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import { + GLOBAL_WORKSPACE_SESSION_FIELDS, + WORKSPACE_SESSION_FIELD_OWNERSHIP +} from './workspace-session-host-field-ownership' +import { + buildWorktreeIdByFileId, + buildWorktreeIdByTabId, + isWorkspaceSessionRecord, + mergeWorkspaceSessionArrayField, + mergeWorkspaceSessionRecordField, + type WorkspaceSessionRecord +} from './workspace-session-host-records' /** * Split / merge the unified WorkspaceSessionState across per-host partitions. @@ -25,6 +37,7 @@ export type HostIdByWorktreeId = (worktreeId: string) => ExecutionHostId /** How a WorkspaceSessionState field is partitioned across hosts. * - global: client-wide; always stays in the 'local' slice. + * - hostPrivate: main/runtime-owned; omitted from renderer merge and writes. * - worktreeKeyed: Record keyed by worktree id; each entry goes to its owner. * - worktreeArray: array of worktree ids; each id goes to its owner. * - tabKeyed: Record keyed by tab id; follows the owning tab's worktree. @@ -32,98 +45,6 @@ export type HostIdByWorktreeId = (worktreeId: string) => ExecutionHostId * page record's own worktreeId. * - fileKeyed: Record keyed by editor file id; follows the open file's worktree. * - sleepingAgentKeyed: Record keyed by pane key; follows the record's worktreeId. */ -type FieldOwnership = - | 'global' - | 'worktreeKeyed' - | 'worktreeArray' - | 'tabKeyed' - | 'browserWorkspaceKeyed' - | 'fileKeyed' - | 'sleepingAgentKeyed' - -const FIELD_OWNERSHIP = { - activeRepoId: 'global', - activeWorktreeId: 'global', - activeTabId: 'global', - browserUrlHistory: 'global', - // Why: SSH-connection ids, not worktrees. SSH stays in the local blob today - // (the runtime split intentionally leaves SSH ownership unchanged), so this - // reconnect list rides along in 'local'. - activeConnectionIdsAtShutdown: 'global', - tabsByWorktree: 'worktreeKeyed', - openFilesByWorktree: 'worktreeKeyed', - activeFileIdByWorktree: 'worktreeKeyed', - activeBrowserTabIdByWorktree: 'worktreeKeyed', - activeTabTypeByWorktree: 'worktreeKeyed', - activeTabIdByWorktree: 'worktreeKeyed', - browserTabsByWorktree: 'worktreeKeyed', - unifiedTabs: 'worktreeKeyed', - tabGroups: 'worktreeKeyed', - tabGroupLayouts: 'worktreeKeyed', - activeGroupIdByWorktree: 'worktreeKeyed', - lastVisitedAtByWorktreeId: 'worktreeKeyed', - defaultTerminalTabsAppliedByWorktreeId: 'worktreeKeyed', - activeWorkspaceKey: 'global', - activeWorktreeIdsOnShutdown: 'worktreeArray', - terminalLayoutsByTabId: 'tabKeyed', - remoteSessionIdsByTabId: 'tabKeyed', - browserPagesByWorkspace: 'browserWorkspaceKeyed', - markdownFrontmatterVisible: 'fileKeyed', - sleepingAgentSessionsByPaneKey: 'sleepingAgentKeyed' -} as const satisfies Record - -// Why: a new WorkspaceSessionState field must be classified above or the split -// would silently drop it from every non-local host. This fails compilation -// until the table is updated, mirroring the _exhaustive guard in -// workspace-session.ts. -type _MissingOwnership = Exclude -const _exhaustive: [_MissingOwnership] extends [never] ? true : never = true -void _exhaustive - -const GLOBAL_FIELDS = (Object.keys(FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]).filter( - (field) => FIELD_OWNERSHIP[field] === 'global' -) - -type AnyRecord = Record - -function isPlainRecord(value: unknown): value is AnyRecord { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -/** Build tabId → worktreeId from both the legacy and unified tab models so - * tab-keyed maps (terminal layouts, remote session ids) follow their tab. */ -function buildWorktreeIdByTabId(state: WorkspaceSessionState): Map { - const byTab = new Map() - for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree ?? {})) { - for (const tab of tabs) { - byTab.set(tab.id, worktreeId) - } - } - // Why: unified tabs carry their own worktreeId; index it too so layouts for a - // tab that exists only in the unified model still resolve to an owner. - for (const tabs of Object.values(state.unifiedTabs ?? {})) { - for (const tab of tabs) { - if (!byTab.has(tab.id)) { - byTab.set(tab.id, tab.worktreeId) - } - } - } - return byTab -} - -/** Build editor-file id → worktreeId so markdownFrontmatterVisible (keyed by - * file id) follows the file's worktree. */ -function buildWorktreeIdByFileId(state: WorkspaceSessionState): Map { - const byFile = new Map() - for (const files of Object.values(state.openFilesByWorktree ?? {})) { - for (const file of files) { - // PersistedOpenFile.filePath is the editor tab/file id used elsewhere. - byFile.set(file.filePath, file.worktreeId) - } - } - return byFile -} - type SplitContext = { hostIdByWorktreeId: HostIdByWorktreeId worktreeIdByTabId: Map @@ -145,16 +66,6 @@ function ensureSlice( return slice } -function hostForWorktree( - ctx: SplitContext, - worktreeId: string | undefined -): ExecutionHostId | null { - if (!worktreeId) { - return null - } - return ctx.hostIdByWorktreeId(worktreeId) -} - function assignWorktreeKeyed( slices: HostSessionSlices, template: WorkspaceSessionState, @@ -162,13 +73,13 @@ function assignWorktreeKeyed( value: unknown, ctx: SplitContext ): void { - if (!isPlainRecord(value)) { + if (!isWorkspaceSessionRecord(value)) { return } for (const [worktreeId, entry] of Object.entries(value)) { const host = ctx.hostIdByWorktreeId(worktreeId) - const slice = ensureSlice(slices, host, template) as AnyRecord - const target = (slice[field] ??= {}) as AnyRecord + const slice = ensureSlice(slices, host, template) as WorkspaceSessionRecord + const target = (slice[field] ??= {}) as WorkspaceSessionRecord target[worktreeId] = entry } } @@ -181,14 +92,14 @@ function assignKeyedByResolvedWorktree( resolveWorktreeId: (key: string, entry: unknown) => string | undefined, ctx: SplitContext ): void { - if (!isPlainRecord(value)) { + if (!isWorkspaceSessionRecord(value)) { return } for (const [key, entry] of Object.entries(value)) { const worktreeId = resolveWorktreeId(key, entry) - const host = hostForWorktree(ctx, worktreeId) ?? LOCAL_EXECUTION_HOST_ID - const slice = ensureSlice(slices, host, template) as AnyRecord - const target = (slice[field] ??= {}) as AnyRecord + const host = worktreeId ? ctx.hostIdByWorktreeId(worktreeId) : LOCAL_EXECUTION_HOST_ID + const slice = ensureSlice(slices, host, template) as WorkspaceSessionRecord + const target = (slice[field] ??= {}) as WorkspaceSessionRecord target[key] = entry } } @@ -206,9 +117,9 @@ export function splitWorkspaceSessionByHost( // does not inject `undefined` values that would clobber persisted state when // the slice is applied as a patch. Intentional `undefined` keys are preserved. const template = {} as WorkspaceSessionState - for (const field of GLOBAL_FIELDS) { + for (const field of GLOBAL_WORKSPACE_SESSION_FIELDS) { if (Object.hasOwn(state, field)) { - ;(template as AnyRecord)[field] = state[field] + ;(template as WorkspaceSessionRecord)[field] = state[field] } } @@ -223,23 +134,28 @@ export function splitWorkspaceSessionByHost( worktreeIdByFileId: buildWorktreeIdByFileId(state) } - const localSlice = slices[LOCAL_EXECUTION_HOST_ID] as AnyRecord + const localSlice = slices[LOCAL_EXECUTION_HOST_ID] as WorkspaceSessionRecord - for (const field of Object.keys(FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]) { - const ownership = FIELD_OWNERSHIP[field] + for (const field of Object.keys( + WORKSPACE_SESSION_FIELD_OWNERSHIP + ) as (keyof WorkspaceSessionState)[]) { + const ownership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field] const value = state[field] if (value === undefined) { continue } // Why: a present-but-empty container ({} / []) must survive the round trip. // Seed it on 'local' so merge reproduces the field instead of dropping it. - if (ownership !== 'global') { + if (ownership !== 'global' && ownership !== 'hostPrivate') { localSlice[field] ??= Array.isArray(value) ? [] : {} } switch (ownership) { case 'global': // Already on the template / local slice. break + case 'hostPrivate': + // Main preserves this field while rebasing renderer writes onto host authority. + break case 'worktreeKeyed': assignWorktreeKeyed(slices, template, field, value, ctx) break @@ -249,7 +165,7 @@ export function splitWorkspaceSessionByHost( } for (const worktreeId of value as string[]) { const host = ctx.hostIdByWorktreeId(worktreeId) - const slice = ensureSlice(slices, host, template) as AnyRecord + const slice = ensureSlice(slices, host, template) as WorkspaceSessionRecord const target = (slice[field] ??= []) as string[] target.push(worktreeId) } @@ -297,7 +213,35 @@ export function splitWorkspaceSessionByHost( field, value, (_paneKey, record) => - isPlainRecord(record) && typeof record.worktreeId === 'string' + isWorkspaceSessionRecord(record) && typeof record.worktreeId === 'string' + ? record.worktreeId + : undefined, + ctx + ) + break + case 'paneKeyed': + assignKeyedByResolvedWorktree( + slices, + template, + field, + value, + (paneKey) => { + const separator = paneKey.lastIndexOf(':') + return separator > 0 + ? ctx.worktreeIdByTabId.get(paneKey.slice(0, separator)) + : undefined + }, + ctx + ) + break + case 'surfaceTombstoneKeyed': + assignKeyedByResolvedWorktree( + slices, + template, + field, + value, + (_paneKey, record) => + isWorkspaceSessionRecord(record) && typeof record.worktreeId === 'string' ? record.worktreeId : undefined, ctx @@ -309,32 +253,6 @@ export function splitWorkspaceSessionByHost( return slices } -function mergeRecordField( - out: AnyRecord, - field: keyof WorkspaceSessionState, - slice: WorkspaceSessionState -): void { - const value = slice[field] - if (!isPlainRecord(value)) { - return - } - const target = (out[field] ??= {}) as AnyRecord - Object.assign(target, value) -} - -function mergeArrayField( - out: AnyRecord, - field: keyof WorkspaceSessionState, - slice: WorkspaceSessionState -): void { - const value = slice[field] - if (!Array.isArray(value)) { - return - } - const target = (out[field] ??= []) as unknown[] - target.push(...value) -} - /** Inverse of split: combine per-host slices into one unified session. Global * fields are taken from the 'local' slice (it owns them); worktree/tab-scoped * maps are unioned across all hosts. Tolerates missing or partial slices. */ @@ -344,15 +262,15 @@ export function mergeWorkspaceSessionsFromHosts(slices: HostSessionSlices): Work // Global fields: 'local' wins. Fall back to any slice that has them so a // standalone non-local slice still yields sane active pointers. - for (const field of GLOBAL_FIELDS) { + for (const field of GLOBAL_WORKSPACE_SESSION_FIELDS) { const fromLocal = local?.[field] if (fromLocal !== undefined) { - ;(out as AnyRecord)[field] = fromLocal + ;(out as WorkspaceSessionRecord)[field] = fromLocal continue } for (const slice of Object.values(slices)) { if (slice && slice[field] !== undefined) { - ;(out as AnyRecord)[field] = slice[field] + ;(out as WorkspaceSessionRecord)[field] = slice[field] break } } @@ -362,15 +280,17 @@ export function mergeWorkspaceSessionsFromHosts(slices: HostSessionSlices): Work if (!slice) { continue } - for (const field of Object.keys(FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]) { - const ownership = FIELD_OWNERSHIP[field] - if (ownership === 'global') { + for (const field of Object.keys( + WORKSPACE_SESSION_FIELD_OWNERSHIP + ) as (keyof WorkspaceSessionState)[]) { + const ownership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field] + if (ownership === 'global' || ownership === 'hostPrivate') { continue } if (ownership === 'worktreeArray') { - mergeArrayField(out as AnyRecord, field, slice) + mergeWorkspaceSessionArrayField(out as WorkspaceSessionRecord, field, slice) } else { - mergeRecordField(out as AnyRecord, field, slice) + mergeWorkspaceSessionRecordField(out as WorkspaceSessionRecord, field, slice) } } } diff --git a/src/renderer/src/runtime/agent-session-create-operation.ts b/src/renderer/src/runtime/agent-session-create-operation.ts new file mode 100644 index 00000000000..12fec65f10e --- /dev/null +++ b/src/renderer/src/runtime/agent-session-create-operation.ts @@ -0,0 +1,72 @@ +import type { + AgentLaunchPreferences, + RuntimeCreateAgentSessionRequest +} from '../../../shared/agent-session-host-authority' +import type { SessionOptionValue } from '../../../shared/native-chat-session-options' +import { createAgentSessionOperationId } from './agent-session-operation-id' +import { RuntimeRpcCallError } from './runtime-rpc-client' + +const MAX_AMBIGUOUS_CREATE_ATTEMPTS = 2 + +export type AgentSessionCreateOperation = { + readonly clientOperationId: string + run(invoke: (clientOperationId: string) => Promise): Promise +} + +function isAmbiguousCreateFailure(error: unknown): boolean { + // Why: an RPC failure proves the host answered; only transport loss leaves + // creation unknown and is safe to replay under the same operation ID. + return ( + !(error instanceof RuntimeRpcCallError) && + !(error instanceof Error && error.name === 'AbortError') + ) +} + +export function createAgentSessionCreateOperation(): AgentSessionCreateOperation { + const clientOperationId = createAgentSessionOperationId() + return { + clientOperationId, + async run(invoke) { + let lastError: unknown + for (let attempt = 0; attempt < MAX_AMBIGUOUS_CREATE_ATTEMPTS; attempt += 1) { + try { + return await invoke(clientOperationId) + } catch (error) { + lastError = error + if (!isAmbiguousCreateFailure(error)) { + throw error + } + } + } + throw lastError + } + } +} + +export function toAgentLaunchPreferences( + sessionOptions: Record | null | undefined +): AgentLaunchPreferences | undefined { + if (!sessionOptions) { + return undefined + } + const readString = (key: keyof AgentLaunchPreferences): string | undefined => { + const value = sessionOptions[key] + return typeof value === 'string' && value.trim() ? value.trim() : undefined + } + const model = readString('model') + const effort = readString('effort') + const mode = readString('mode') + const preferences: AgentLaunchPreferences = { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + ...(mode ? { mode } : {}) + } + return Object.keys(preferences).length > 0 ? preferences : undefined +} + +export function withAgentSessionCreateOperationId( + request: Omit, + clientOperationId: string +): RuntimeCreateAgentSessionRequest { + return { ...request, clientOperationId } +} diff --git a/src/renderer/src/runtime/agent-session-operation-id.test.ts b/src/renderer/src/runtime/agent-session-operation-id.test.ts new file mode 100644 index 00000000000..d52e1cc4273 --- /dev/null +++ b/src/renderer/src/runtime/agent-session-operation-id.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentSessionOperationId } from './agent-session-operation-id' +import { + createAgentSessionCreateOperation, + toAgentLaunchPreferences +} from './agent-session-create-operation' +import { RuntimeRpcCallError } from './runtime-rpc-client' + +describe('createAgentSessionOperationId', () => { + it('combines the request time with a cryptographically generated nonce', () => { + const getRandomValues = vi.fn((bytes: Uint8Array) => { + bytes.fill(0xab) + return bytes + }) + vi.stubGlobal('crypto', { getRandomValues }) + + expect(createAgentSessionOperationId(1234)).toBe(`1234-${'ab'.repeat(16)}`) + expect(getRandomValues).toHaveBeenCalledOnce() + vi.unstubAllGlobals() + }) + + it('fails closed when secure randomness is unavailable', () => { + vi.stubGlobal('crypto', undefined) + expect(() => createAgentSessionOperationId(1234)).toThrow('Secure randomness is unavailable') + vi.unstubAllGlobals() + }) +}) + +describe('agent session create operation', () => { + it('retries an ambiguous transport failure with one stable operation ID', async () => { + vi.stubGlobal('crypto', { + getRandomValues: (bytes: Uint8Array) => { + bytes.fill(0xcd) + return bytes + } + }) + const operation = createAgentSessionCreateOperation() + const invoke = vi + .fn<(operationId: string) => Promise>() + .mockRejectedValueOnce(new Error('connection closed before response')) + .mockResolvedValueOnce('created') + + await expect(operation.run(invoke)).resolves.toBe('created') + expect(invoke).toHaveBeenCalledTimes(2) + expect(invoke.mock.calls[0]?.[0]).toBe(operation.clientOperationId) + expect(invoke.mock.calls[1]?.[0]).toBe(operation.clientOperationId) + vi.unstubAllGlobals() + }) + + it('does not retry an authoritative host failure', async () => { + vi.stubGlobal('crypto', { + getRandomValues: (bytes: Uint8Array) => bytes + }) + const operation = createAgentSessionCreateOperation() + const failure = new RuntimeRpcCallError({ + id: 'rpc-1', + ok: false, + error: { code: 'agent_session_operation_capacity', message: 'capacity' } + }) + const invoke = vi.fn().mockRejectedValue(failure) + + await expect(operation.run(invoke)).rejects.toBe(failure) + expect(invoke).toHaveBeenCalledOnce() + vi.unstubAllGlobals() + }) + + it('keeps only supported string launch preferences', () => { + expect( + toAgentLaunchPreferences({ + model: ' gpt-5 ', + effort: 'high', + mode: 'plan', + fastMode: true + }) + ).toEqual({ model: 'gpt-5', effort: 'high', mode: 'plan' }) + }) +}) diff --git a/src/renderer/src/runtime/agent-session-operation-id.ts b/src/renderer/src/runtime/agent-session-operation-id.ts new file mode 100644 index 00000000000..23121351d76 --- /dev/null +++ b/src/renderer/src/runtime/agent-session-operation-id.ts @@ -0,0 +1,15 @@ +function randomOperationNonce(): string { + const cryptoApi = globalThis.crypto + if (!cryptoApi?.getRandomValues) { + // Why: a weak nonce can collide across clients and turn distinct launches + // into the same idempotent operation, so fail instead of using Math.random. + throw new Error('Secure randomness is unavailable for this agent launch.') + } + const bytes = new Uint8Array(16) + cryptoApi.getRandomValues(bytes) + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export function createAgentSessionOperationId(now = Date.now()): string { + return `${now}-${randomOperationNonce()}` +} diff --git a/src/renderer/src/runtime/remote-agent-session-launch.test.ts b/src/renderer/src/runtime/remote-agent-session-launch.test.ts new file mode 100644 index 00000000000..d1c2f073095 --- /dev/null +++ b/src/renderer/src/runtime/remote-agent-session-launch.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + supportsCapability: vi.fn() +})) + +vi.mock('./runtime-rpc-client', () => ({ + RuntimeRpcCallError: class RuntimeRpcCallError extends Error { + code: string + constructor(response: { error: { code: string; message: string } }) { + super(response.error.message) + this.code = response.error.code + } + }, + runtimeEnvironmentSupportsCapability: mocks.supportsCapability +})) + +import { RuntimeRpcCallError } from './runtime-rpc-client' +import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch' + +describe('remote agent-session launch routing', () => { + beforeEach(() => { + mocks.supportsCapability.mockReset() + }) + + it('uses host authority only when the host advertises it', async () => { + const hostAuthority = vi.fn().mockResolvedValue('structured') + const legacy = vi.fn().mockResolvedValue('legacy') + mocks.supportsCapability.mockResolvedValue(true) + + await expect( + runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority, legacy }) + ).resolves.toBe('structured') + expect(hostAuthority).toHaveBeenCalledOnce() + expect(legacy).not.toHaveBeenCalled() + }) + + it('preserves the exact legacy path when the capability is absent', async () => { + const hostAuthority = vi.fn().mockResolvedValue('structured') + const legacy = vi.fn().mockResolvedValue('legacy') + mocks.supportsCapability.mockResolvedValue(false) + + await expect( + runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority, legacy }) + ).resolves.toBe('legacy') + expect(legacy).toHaveBeenCalledOnce() + expect(hostAuthority).not.toHaveBeenCalled() + }) + + it('keeps legacy behavior when a read-only capability probe fails', async () => { + const hostAuthority = vi.fn().mockResolvedValue('structured') + const legacy = vi.fn().mockResolvedValue('legacy') + mocks.supportsCapability.mockRejectedValue(new Error('status temporarily unavailable')) + + await expect( + runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority, legacy }) + ).resolves.toBe('legacy') + expect(legacy).toHaveBeenCalledOnce() + expect(hostAuthority).not.toHaveBeenCalled() + }) + + it('does not bypass an incompatible runtime protocol', async () => { + const compatibilityError = Object.assign(new Error('runtime incompatible'), { + code: 'runtime_compat_block' + }) + const legacy = vi.fn().mockResolvedValue('legacy') + mocks.supportsCapability.mockRejectedValue(compatibilityError) + + await expect( + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + hostAuthority: vi.fn(), + legacy + }) + ).rejects.toBe(compatibilityError) + expect(legacy).not.toHaveBeenCalled() + }) + + it('never downgrades after structured dispatch has started', async () => { + const structuredError = new Error('structured response was lost') + const legacy = vi.fn().mockResolvedValue('legacy') + mocks.supportsCapability.mockResolvedValue(true) + + await expect( + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + hostAuthority: vi.fn().mockRejectedValue(structuredError), + legacy + }) + ).rejects.toBe(structuredError) + expect(legacy).not.toHaveBeenCalled() + }) + + it('uses legacy only for the host pre-side-effect lower-owner response', async () => { + const legacy = vi.fn().mockResolvedValue('legacy') + mocks.supportsCapability.mockResolvedValue(true) + const legacyRequired = new RuntimeRpcCallError({ + id: 'request-1', + ok: false, + error: { code: 'agent_session_legacy_required', message: 'legacy required' } + }) + + await expect( + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + hostAuthority: vi.fn().mockRejectedValue(legacyRequired), + legacy + }) + ).resolves.toBe('legacy') + expect(legacy).toHaveBeenCalledOnce() + }) + + it('uses legacy when a replaced old host does not recognize the structured method', async () => { + const legacy = vi.fn().mockResolvedValue('legacy') + mocks.supportsCapability.mockResolvedValue(true) + const methodNotFound = new RuntimeRpcCallError({ + id: 'request-1', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' } + }) + + await expect( + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + hostAuthority: vi.fn().mockRejectedValue(methodNotFound), + legacy + }) + ).resolves.toBe('legacy') + expect(legacy).toHaveBeenCalledOnce() + }) + + it('uses legacy directly when no structured form exists', async () => { + const legacy = vi.fn().mockResolvedValue('legacy') + + await expect(runRemoteAgentSessionLaunch({ environmentId: 'env-1', legacy })).resolves.toBe( + 'legacy' + ) + expect(mocks.supportsCapability).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/remote-agent-session-launch.ts b/src/renderer/src/runtime/remote-agent-session-launch.ts new file mode 100644 index 00000000000..da8933859ca --- /dev/null +++ b/src/renderer/src/runtime/remote-agent-session-launch.ts @@ -0,0 +1,45 @@ +import { AGENT_SESSION_HOST_AUTHORITY_CAPABILITY } from '../../../shared/agent-session-host-authority' +import { RuntimeRpcCallError, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' +import { isRuntimeCompatBlockError } from './runtime-protocol-compat' + +export async function runRemoteAgentSessionLaunch(args: { + environmentId: string + hostAuthority?: () => Promise + legacy: (options: { skipCompatibilityCheck: boolean }) => Promise +}): Promise { + if (!args.hostAuthority) { + return await args.legacy({ skipCompatibilityCheck: false }) + } + let supported: boolean + try { + supported = await runtimeEnvironmentSupportsCapability( + args.environmentId, + AGENT_SESSION_HOST_AUTHORITY_CAPABILITY + ) + } catch (error) { + if (isRuntimeCompatBlockError(error)) { + throw error + } + // Why: a failed read-only probe has not launched anything, so preserving + // the legacy path cannot duplicate an agent and keeps transient upgrades neutral. + return await args.legacy({ skipCompatibilityCheck: true }) + } + // Why: choose before invoking either path; an ambiguous structured outcome + // must never trigger a legacy retry that could spawn a duplicate. + if (!supported) { + return await args.legacy({ skipCompatibilityCheck: true }) + } + try { + return await args.hostAuthority() + } catch (error) { + if ( + error instanceof RuntimeRpcCallError && + (error.code === 'agent_session_legacy_required' || error.code === 'method_not_found') + ) { + // Why: both responses prove no structured side effect began: the new host rejected an old + // lower owner before dispatch, or an old host never recognized the method. + return await args.legacy({ skipCompatibilityCheck: true }) + } + throw error + } +} diff --git a/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts b/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts index ba66f4fcf17..3d847fe5304 100644 --- a/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts +++ b/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { getRemoteRuntimeSessionTabsInFlightCountForTests, + listRemoteRuntimeSessionTabsAfterCurrentInFlight, listRemoteRuntimeSessionTabsDeduped } from './remote-runtime-session-tabs-inflight' @@ -67,4 +68,42 @@ describe('remote runtime session-tabs in-flight requests', () => { expect(load).toHaveBeenCalledTimes(3) }) + + it('waits out an older request before sharing a post-operation inventory', async () => { + let resolveCurrent: (snapshot: RuntimeMobileSessionTabsResult) => void = () => {} + const currentLoad = vi.fn( + () => + new Promise((resolve) => { + resolveCurrent = resolve + }) + ) + let resolveFresh: (snapshot: RuntimeMobileSessionTabsResult) => void = () => {} + const freshLoad = vi.fn( + () => + new Promise((resolve) => { + resolveFresh = resolve + }) + ) + const ownership = { environmentId: 'env-1', worktreeId: 'wt-1' } + + const current = listRemoteRuntimeSessionTabsDeduped({ ...ownership, load: currentLoad }) + const firstFresh = listRemoteRuntimeSessionTabsAfterCurrentInFlight({ + ...ownership, + load: freshLoad + }) + const secondFresh = listRemoteRuntimeSessionTabsAfterCurrentInFlight({ + ...ownership, + load: freshLoad + }) + + expect(freshLoad).not.toHaveBeenCalled() + resolveCurrent(SNAPSHOT) + await expect(current).resolves.toBe(SNAPSHOT) + await vi.waitFor(() => expect(freshLoad).toHaveBeenCalledOnce()) + resolveFresh({ ...SNAPSHOT, snapshotVersion: 2 }) + await expect(Promise.all([firstFresh, secondFresh])).resolves.toEqual([ + { ...SNAPSHOT, snapshotVersion: 2 }, + { ...SNAPSHOT, snapshotVersion: 2 } + ]) + }) }) diff --git a/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.ts b/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.ts index b5b27029502..94302b184b8 100644 --- a/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.ts +++ b/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.ts @@ -2,12 +2,20 @@ import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-typ const inFlightBySession = new Map>() -export function listRemoteRuntimeSessionTabsDeduped(args: { +type RemoteRuntimeSessionTabsLoad = { environmentId: string worktreeId: string load: () => Promise -}): Promise { - const key = `${args.environmentId}\u0000${args.worktreeId}` +} + +function remoteRuntimeSessionTabsKey(args: { environmentId: string; worktreeId: string }): string { + return `${args.environmentId}\u0000${args.worktreeId}` +} + +export function listRemoteRuntimeSessionTabsDeduped( + args: RemoteRuntimeSessionTabsLoad +): Promise { + const key = remoteRuntimeSessionTabsKey(args) const existing = inFlightBySession.get(key) if (existing) { return existing @@ -23,6 +31,18 @@ export function listRemoteRuntimeSessionTabsDeduped(args: { return request } +export async function listRemoteRuntimeSessionTabsAfterCurrentInFlight( + args: RemoteRuntimeSessionTabsLoad +): Promise { + const current = inFlightBySession.get(remoteRuntimeSessionTabsKey(args)) + if (current) { + // Why: a post-operation absence proof cannot join an inventory request that + // began before the operation committed. + await current.catch(() => undefined) + } + return listRemoteRuntimeSessionTabsDeduped(args) +} + export function getRemoteRuntimeSessionTabsInFlightCountForTests(): number { return inFlightBySession.size } diff --git a/src/renderer/src/runtime/runtime-client-target.ts b/src/renderer/src/runtime/runtime-client-target.ts new file mode 100644 index 00000000000..6e553445401 --- /dev/null +++ b/src/renderer/src/runtime/runtime-client-target.ts @@ -0,0 +1,21 @@ +import type { GlobalSettings } from '../../../shared/types' + +export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } + +export function getActiveRuntimeTarget( + settings: Pick | null | undefined +): RuntimeClientTarget { + const environmentId = settings?.activeRuntimeEnvironmentId?.trim() + return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } +} + +export function settingsForRuntimeOwner( + settings: Pick | null | undefined, + runtimeEnvironmentId: string | null | undefined +): Pick | null | undefined { + if (runtimeEnvironmentId === null) { + return { activeRuntimeEnvironmentId: null } + } + const ownerId = runtimeEnvironmentId?.trim() + return ownerId ? { activeRuntimeEnvironmentId: ownerId } : settings +} diff --git a/src/renderer/src/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts index c9481d2746c..3678743d50d 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeStatus } from '../../../shared/runtime-types' import { callRuntimeRpc, assertRuntimeEnvironmentCapability, @@ -574,6 +575,41 @@ describe('runtime RPC client routing', () => { expect(statusCalls).toBe(2) }) + it('dispatches capability-selected legacy without a redundant status probe', async () => { + const methods: string[] = [] + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + methods.push(method) + if (method === 'status.get') { + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'old-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [] + }, + _meta: { runtimeId: 'old-runtime' } + }) + } + return Promise.resolve({ + id: method, + ok: true, + result: { terminal: { handle: 'legacy' } }, + _meta: { runtimeId: 'old-runtime' } + }) + }) + const target = { kind: 'environment', environmentId: 'env-legacy' } as const + + await expect( + runtimeEnvironmentSupportsCapability('env-legacy', 'agent-session.host-authority.v1') + ).resolves.toBe(false) + await callRuntimeRpc(target, 'terminal.create', {}, { skipCompatibilityCheck: true }) + + expect(methods).toEqual(['status.get', 'terminal.create']) + }) + it('coalesces concurrent cold-cache capability probes onto one status.get', async () => { let statusCalls = 0 runtimeEnvironmentCall.mockImplementation(() => { @@ -655,6 +691,46 @@ describe('runtime RPC client routing', () => { } }) + it('invalidates a positive capability verdict when the endpoint runtime changes', async () => { + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(() => { + statusCalls += 1 + const runtimeId = statusCalls === 1 ? 'runtime-before-restart' : 'runtime-after-restart' + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId, + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: statusCalls === 1 ? ['agent-session.host-authority.v1'] : [] + }, + _meta: { runtimeId } + }) + }) + + await expect( + runtimeEnvironmentSupportsCapability( + 'env-runtime-replaced', + 'agent-session.host-authority.v1' + ) + ).resolves.toBe(true) + clearRecentRuntimeCompatibilityFailure('env-runtime-replaced', { + runtimeId: 'runtime-after-restart', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } as RuntimeStatus) + await expect( + runtimeEnvironmentSupportsCapability( + 'env-runtime-replaced', + 'agent-session.host-authority.v1' + ) + ).resolves.toBe(false) + expect(statusCalls).toBe(2) + }) + it('rejects missing advertised runtime capabilities with the caller message', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'status', diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index 1f024c1a67f..6a0836e38e0 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -1,4 +1,3 @@ -import type { GlobalSettings } from '../../../shared/types' import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../../shared/runtime-types' import type { RuntimeCapability } from '../../../shared/protocol-version' @@ -8,8 +7,13 @@ import { callAbortableRuntimeEnvironment, createRuntimeRpcAbortError } from './abortable-runtime-environment-call' +import type { RuntimeClientTarget } from './runtime-client-target' -export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } +export { + getActiveRuntimeTarget, + settingsForRuntimeOwner, + type RuntimeClientTarget +} from './runtime-client-target' const RUNTIME_COMPATIBILITY_CACHE_MAX = 32 const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000 @@ -46,27 +50,6 @@ export function isRuntimeScopeForbiddenError(error: unknown): boolean { return error instanceof RuntimeRpcCallError && error.code === 'forbidden' } -export function getActiveRuntimeTarget( - settings: Pick | null | undefined -): RuntimeClientTarget { - const environmentId = settings?.activeRuntimeEnvironmentId?.trim() - if (!environmentId) { - return { kind: 'local' } - } - return { kind: 'environment', environmentId } -} - -export function settingsForRuntimeOwner( - settings: Pick | null | undefined, - runtimeEnvironmentId: string | null | undefined -): Pick | null | undefined { - if (runtimeEnvironmentId === null) { - return { activeRuntimeEnvironmentId: null } - } - const ownerId = runtimeEnvironmentId?.trim() - return ownerId ? { activeRuntimeEnvironmentId: ownerId } : settings -} - export async function callRuntimeRpc( target: RuntimeClientTarget, method: string, @@ -75,10 +58,15 @@ export async function callRuntimeRpc( timeoutMs?: number suppressFeatureInteraction?: boolean reuseRecentCompatibilityFailure?: boolean + skipCompatibilityCheck?: boolean signal?: AbortSignal } = {} ): Promise { - if (target.kind === 'environment' && method !== 'status.get') { + if ( + target.kind === 'environment' && + method !== 'status.get' && + options.skipCompatibilityCheck !== true + ) { await ensureRuntimeEnvironmentCompatible(target.environmentId, options) } if (options.signal?.aborted) { @@ -195,13 +183,24 @@ function rememberRuntimeEnvironmentCompatibility( // Why: a live status answer invalidates failures and pending probes from the // dropped connection; only proven-compatible successes remain reusable. -export function clearRecentRuntimeCompatibilityFailure(environmentId: string): void { +export function clearRecentRuntimeCompatibilityFailure( + environmentId: string, + observedStatus?: RuntimeStatus +): void { const trimmed = environmentId.trim() if (!trimmed) { return } const cached = runtimeCompatibilityChecks.get(trimmed) - if (cached && !cached.provenCompatible) { + if ( + cached && + (!cached.provenCompatible || + (observedStatus && + cached.status !== null && + cached.status.runtimeId !== observedStatus.runtimeId)) + ) { + // Why: a saved endpoint can reconnect to a different runtime version; its predecessor's + // positive capability verdict must not route a structured request to the replacement. runtimeCompatibilityChecks.delete(trimmed) } } @@ -298,9 +297,9 @@ export async function runtimeEnvironmentSupportsCapability( ) { const supported = cached.status.capabilities?.includes(capability) === true if (!supported) { - // Why: an unsupported verdict must not survive a remote upgrade. The - // next explicit retry re-probes instead of pinning the old capability set. - runtimeCompatibilityChecks.delete(trimmed) + // Why: retain protocol proof for this legacy dispatch, but force the + // next capability decision to observe an in-place host upgrade. + cached.statusCheckedAt = null } return supported } @@ -310,8 +309,9 @@ export async function runtimeEnvironmentSupportsCapability( } const status = await getRuntimeEnvironmentStatus(trimmed, timeoutMs) const supported = status.capabilities?.includes(capability) === true - if (!supported && runtimeCompatibilityChecks.get(trimmed)?.status === status) { - runtimeCompatibilityChecks.delete(trimmed) + const resolved = runtimeCompatibilityChecks.get(trimmed) + if (!supported && resolved?.status === status) { + resolved.statusCheckedAt = null } return supported } diff --git a/src/renderer/src/runtime/web-agent-session-handoff.ts b/src/renderer/src/runtime/web-agent-session-handoff.ts new file mode 100644 index 00000000000..04f2d2c242c --- /dev/null +++ b/src/renderer/src/runtime/web-agent-session-handoff.ts @@ -0,0 +1,91 @@ +type WebAgentSessionHandoff = { + environmentId: string + worktreeId: string + provisionalTabId: string + hostTabId: string + hostTerminalHandle: string +} + +type WebAgentSessionHandoffKey = Pick< + WebAgentSessionHandoff, + 'environmentId' | 'worktreeId' | 'provisionalTabId' +> + +type WebAgentSessionHandoffState = { + hostTabId: string + hostTerminalHandle: string + postCreateSnapshotConfirmed: boolean +} + +const handoffByProvisionalTab = new Map() + +function handoffKey(args: WebAgentSessionHandoffKey): string { + return `${args.environmentId}\0${args.worktreeId}\0${args.provisionalTabId}` +} + +export function recordWebAgentSessionHandoff(args: WebAgentSessionHandoff): void { + if ( + !args.environmentId.trim() || + !args.worktreeId.trim() || + !args.provisionalTabId.trim() || + !args.hostTabId.trim() || + !args.hostTerminalHandle.trim() + ) { + return + } + handoffByProvisionalTab.set(handoffKey(args), { + hostTabId: args.hostTabId, + hostTerminalHandle: args.hostTerminalHandle, + postCreateSnapshotConfirmed: false + }) +} + +export function resolveWebAgentSessionHandoff(args: WebAgentSessionHandoffKey): string | null { + return handoffByProvisionalTab.get(handoffKey(args))?.hostTabId ?? null +} + +export function isWebAgentSessionHandoffPostCreateSnapshotConfirmed( + args: WebAgentSessionHandoffKey +): boolean { + return handoffByProvisionalTab.get(handoffKey(args))?.postCreateSnapshotConfirmed === true +} + +export function confirmWebAgentSessionHandoffAfterCreate(args: WebAgentSessionHandoff): void { + const key = handoffKey(args) + const handoff = handoffByProvisionalTab.get(key) + if ( + handoff?.hostTabId === args.hostTabId && + handoff.hostTerminalHandle === args.hostTerminalHandle + ) { + handoffByProvisionalTab.set(key, { ...handoff, postCreateSnapshotConfirmed: true }) + } +} + +export function clearWebAgentSessionHandoff(args: WebAgentSessionHandoffKey): void { + handoffByProvisionalTab.delete(handoffKey(args)) +} + +export function clearWebAgentSessionHandoffsForWorktree( + environmentId: string, + worktreeId: string +): void { + const prefix = `${environmentId}\0${worktreeId}\0` + for (const key of handoffByProvisionalTab.keys()) { + if (key.startsWith(prefix)) { + handoffByProvisionalTab.delete(key) + } + } +} + +export function clearWebAgentSessionHandoffsForEnvironment(environmentId: string): void { + const prefix = `${environmentId}\0` + for (const key of handoffByProvisionalTab.keys()) { + if (key.startsWith(prefix)) { + handoffByProvisionalTab.delete(key) + } + } +} + +export function resetWebAgentSessionHandoffsForTests(): void { + handoffByProvisionalTab.clear() +} diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 403e31a1060..939e2a7ad28 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -12,6 +12,7 @@ import { createWebRuntimeSessionTerminal, isWebRuntimeSessionActive, moveWebRuntimeSessionTab, + refreshWebRuntimeSessionTabsSnapshot, setWebRuntimeTabProps, splitWebRuntimeTerminal } from './web-runtime-session' @@ -20,6 +21,13 @@ import { recordWebSessionCloseIntent, resetWebSessionCloseIntentForTests } from './web-session-close-intent' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' +import { + confirmWebAgentSessionHandoffAfterCreate, + isWebAgentSessionHandoffPostCreateSnapshotConfirmed, + recordWebAgentSessionHandoff, + resetWebAgentSessionHandoffsForTests +} from './web-agent-session-handoff' const mocks = vi.hoisted(() => ({ getState: vi.fn(), @@ -80,6 +88,78 @@ function makeSnapshot(): RuntimeMobileSessionTabsResult { } } +describe('refreshWebRuntimeSessionTabsSnapshot', () => { + afterEach(() => { + resetWebAgentSessionHandoffsForTests() + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('confirms only the exact handoff after its post-create list completes', async () => { + const runtimeCall = vi.fn().mockResolvedValue({ + id: 'list', + ok: true, + result: makeSnapshot() + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + mocks.applyFreshWebSessionTabsSnapshot.mockImplementation((state) => state) + recordWebAgentSessionHandoff({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + provisionalTabId: 'provisional-a', + hostTabId: 'host-a', + hostTerminalHandle: 'term_host-a' + }) + recordWebAgentSessionHandoff({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + provisionalTabId: 'provisional-b', + hostTabId: 'host-b', + hostTerminalHandle: 'term_host-b' + }) + + await refreshWebRuntimeSessionTabsSnapshot(ENVIRONMENT_ID, WORKTREE_ID, { + acceptCurrentSnapshot: true, + confirmAgentSessionHandoff: { + provisionalTabId: 'provisional-a', + hostTabId: 'host-a', + hostTerminalHandle: 'term_host-a' + } + }) + + const confirmed = (provisionalTabId: string): boolean => + isWebAgentSessionHandoffPostCreateSnapshotConfirmed({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + provisionalTabId + }) + expect(confirmed('provisional-a')).toBe(true) + expect(confirmed('provisional-b')).toBe(false) + expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( + ENVIRONMENT_ID, + WORKTREE_ID + ) + + recordWebAgentSessionHandoff({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + provisionalTabId: 'provisional-a', + hostTabId: 'host-a', + hostTerminalHandle: 'term_host-a-replacement' + }) + confirmWebAgentSessionHandoffAfterCreate({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + provisionalTabId: 'provisional-a', + hostTabId: 'host-a', + hostTerminalHandle: 'term_host-a' + }) + expect(confirmed('provisional-a')).toBe(false) + }) +}) + describe('activateWebRuntimeSessionWorktree', () => { beforeEach(() => { vi.stubGlobal('__ORCA_WEB_CLIENT__', true) @@ -92,6 +172,7 @@ describe('activateWebRuntimeSessionWorktree', () => { afterEach(() => { vi.unstubAllGlobals() + clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() }) @@ -162,6 +243,7 @@ describe('createWebRuntimeSessionBrowserTab', () => { afterEach(() => { vi.unstubAllGlobals() + clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() }) @@ -441,14 +523,16 @@ describe('createWebRuntimeSessionTerminal', () => { }) mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) mocks.resolveHostSessionTabIdForWebSessionTab.mockReturnValue(null) + mocks.deliverLaunchPromptToAgentTab.mockResolvedValue(true) }) afterEach(() => { vi.unstubAllGlobals() + clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() }) - it('creates paired web terminals through session tabs so host activation is mirrored', async () => { + it('creates paired web agents through host authority so activation is mirrored', async () => { const snapshot = { ...makeSnapshot(), snapshotVersion: 2, @@ -469,15 +553,38 @@ describe('createWebRuntimeSessionTerminal', () => { } const runtimeCall = vi .fn() + .mockResolvedValueOnce({ + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + }) .mockResolvedValueOnce({ id: 'create-terminal', ok: true, result: { - tab: snapshot.tabs[0], - publicationEpoch: snapshot.publicationEpoch, - snapshotVersion: snapshot.snapshotVersion + terminal: { + id: 'pty-2', + handle: 'term_2', + title: 'Terminal 2', + cwd: '/repo/packages/app', + worktreeId: WORKTREE_ID, + tabId: 'host-tab-2', + leafId: 'leaf-1' + }, + disposition: 'created' } }) + .mockResolvedValueOnce({ + id: 'move', + ok: true, + result: { moved: true } + }) .mockResolvedValueOnce({ id: 'list', ok: true, @@ -495,7 +602,6 @@ describe('createWebRuntimeSessionTerminal', () => { await expect( createWebRuntimeSessionTerminal({ worktreeId: WORKTREE_ID, - afterTabId: 'web-terminal-host-tab-1%3A%3Aleaf-1', targetGroupId: 'group-left', command: "codex 'linked issue context'", cwd: '/repo/packages/app', @@ -507,36 +613,44 @@ describe('createWebRuntimeSessionTerminal', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + prompt: 'linked issue context', + promptDelivery: 'draft', + agentArgs: '--model gpt-5 --profile captured', + launchPreferences: { model: 'gpt-5', effort: 'high' }, viewMode: 'chat', activate: true }) - ).resolves.toBe(true) + ).resolves.toEqual({ status: 'created' }) - expect(runtimeCall).toHaveBeenNthCalledWith(1, { + expect(runtimeCall).toHaveBeenNthCalledWith(2, { selector: ENVIRONMENT_ID, - method: 'session.tabs.createTerminal', + method: 'terminal.createAgentSession', params: { + clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/), worktree: `id:${WORKTREE_ID}`, - afterTabId: 'host-tab-1::leaf-1', - targetGroupId: 'group-left', - command: "codex 'linked issue context'", - cwd: '/repo/packages/app', - env: { CODEX_PROFILE: 'captured' }, - envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], - startupCommandDelivery: 'shell-ready', - launchConfig: { - agentArgs: '--model gpt-5', - agentEnv: { CODEX_PROFILE: 'captured' } - }, - launchAgent: 'codex', + agent: 'codex', + prompt: 'linked issue context', + promptDelivery: 'draft', + agentArgs: '--model gpt-5 --profile captured', + launchPreferences: { model: 'gpt-5', effort: 'high' }, + startupCwd: '/repo/packages/app', viewMode: 'chat', - activate: false, - select: true, - navigation: 'caller' + presentation: 'focused' }, timeoutMs: 15_000 }) - expect(runtimeCall).toHaveBeenNthCalledWith(2, { + expect(runtimeCall).toHaveBeenNthCalledWith(3, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.move', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-tab-2', + targetGroupId: 'group-left', + kind: 'move-to-group' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall).toHaveBeenNthCalledWith(4, { selector: ENVIRONMENT_ID, method: 'session.tabs.list', params: { @@ -551,6 +665,57 @@ describe('createWebRuntimeSessionTerminal', () => { ) }) + it('keeps exact legacy ordering when structured creation cannot express afterTabId', async () => { + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'legacy-create', + ok: true, + result: { + tab: { id: 'host-tab-2' }, + publicationEpoch: 'epoch-1', + snapshotVersion: 2 + } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + afterTabId: 'web-terminal-host-tab-1%3A%3Aleaf-1', + targetGroupId: 'group-left', + agentSessionKind: 'fresh', + agent: 'codex', + activate: true + }) + ).resolves.toEqual({ status: 'created' }) + + expect(runtimeCall).toHaveBeenNthCalledWith(1, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.createTerminal', + params: { + worktree: `id:${WORKTREE_ID}`, + afterTabId: 'host-tab-1::leaf-1', + targetGroupId: 'group-left', + command: undefined, + cwd: undefined, + startupCommandDelivery: undefined, + agent: 'codex', + activate: false, + select: true, + navigation: 'caller' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall.mock.calls.map(([request]) => request.method)).toEqual([ + 'session.tabs.createTerminal', + 'session.tabs.list' + ]) + }) + it('can create a terminal without selecting the target worktree', async () => { const setStateResults: unknown[] = [] mocks.setState.mockImplementation((updater: (state: unknown) => unknown) => { @@ -600,39 +765,353 @@ describe('createWebRuntimeSessionTerminal', () => { activate: true, selectWorktree: false }) - ).resolves.toBe(true) + ).resolves.toEqual({ status: 'created' }) expect(setStateResults).not.toContainEqual({ activeWorktreeId: WORKTREE_ID }) }) - it('waits for the paired Agent input before submitting a generated prompt', async () => { - const terminal = { - type: 'terminal' as const, - id: 'host-tab-2::leaf-1', - parentTabId: 'host-tab-2', - leafId: 'leaf-1', - title: 'Claude', - terminal: null, - status: 'pending-handle' as const, - isActive: true - } - const snapshot = { - ...makeSnapshot(), - snapshotVersion: 2, - tabs: [terminal] - } - const runtimeCall = vi - .fn() - .mockResolvedValueOnce({ - id: 'create-terminal', - ok: true, - result: { - tab: terminal, - publicationEpoch: snapshot.publicationEpoch, - snapshotVersion: snapshot.snapshotVersion + it.each(['session.tabs.move', 'session.tabs.list'] as const)( + 'treats %s failure after host creation as accepted so callers do not duplicate the agent', + async (failedMethod) => { + const runtimeCall = vi.fn(async (request: { method: string }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } } + if (request.method === 'terminal.createAgentSession') { + return { + id: 'create', + ok: true, + result: { + terminal: { + id: 'pty-created', + handle: 'term_created', + title: 'Codex', + cwd: '/repo', + worktreeId: WORKTREE_ID, + tabId: 'host-tab-created', + leafId: 'leaf-created' + }, + disposition: 'created' + } + } + } + if (request.method === failedMethod) { + throw new Error(`${failedMethod} unavailable`) + } + return { id: 'ok', ok: true, result: makeSnapshot() } }) - .mockResolvedValueOnce({ id: 'list', ok: true, result: snapshot }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + targetGroupId: failedMethod === 'session.tabs.move' ? 'group-left' : undefined, + launchAgent: 'codex', + activate: true + }) + ).resolves.toEqual({ status: 'created' }) + + expect( + runtimeCall.mock.calls.filter( + ([request]) => request.method === 'terminal.createAgentSession' + ) + ).toHaveLength(1) + } + ) + + it('replays an ambiguous fresh-create failure with the same operation ID', async () => { + const operationIds: string[] = [] + let createAttempts = 0 + const runtimeCall = vi.fn(async (request: { method: string; params?: unknown }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } + } + if (request.method === 'terminal.createAgentSession') { + operationIds.push((request.params as { clientOperationId: string }).clientOperationId) + createAttempts += 1 + if (createAttempts === 1) { + throw new Error('connection closed before response') + } + return { + id: 'create', + ok: true, + result: { + terminal: { + handle: 'term_replayed', + worktreeId: WORKTREE_ID, + tabId: 'host-tab-replayed', + leafId: 'leaf-replayed' + }, + disposition: 'replayed' + } + } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + launchAgent: 'codex', + targetGroupId: 'group-left' + }) + ).resolves.toEqual({ status: 'created' }) + + expect(operationIds).toHaveLength(2) + expect(operationIds[0]).toBe(operationIds[1]) + }) + + it('preserves the legacy fresh-agent path when host authority is unavailable', async () => { + const runtimeCall = vi.fn(async (request: { method: string; params?: unknown }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'old-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [] + } + } + } + if (request.method === 'session.tabs.createTerminal') { + return { + id: 'legacy-create', + ok: true, + result: { + tab: { id: 'legacy-tab-1' }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + } + } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + launchAgent: 'codex', + targetGroupId: 'group-left' + }) + ).resolves.toEqual({ status: 'created' }) + + expect(runtimeCall).toHaveBeenNthCalledWith(2, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.createTerminal', + params: { + worktree: `id:${WORKTREE_ID}`, + afterTabId: undefined, + targetGroupId: 'group-left', + command: undefined, + cwd: undefined, + startupCommandDelivery: undefined, + launchAgent: 'codex', + activate: false, + select: true, + navigation: 'caller' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall.mock.calls.map(([request]) => request.method)).toEqual([ + 'status.get', + 'session.tabs.createTerminal', + 'session.tabs.list' + ]) + }) + + it('preserves the opaque legacy resume payload on an old host', async () => { + const runtimeCall = vi.fn(async (request: { method: string }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'old-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [] + } + } + } + if (request.method === 'session.tabs.createTerminal') { + return { + id: 'legacy-create', + ok: true, + result: { tab: { id: 'legacy-tab-1' }, publicationEpoch: 'epoch-1', snapshotVersion: 1 } + } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + agentSessionKind: 'resume', + launchAgent: 'codex', + command: "codex resume 'session-1'", + env: { CODEX_PROFILE: 'captured' }, + launchConfig: { + agentCommand: 'codex', + agentArgs: '', + agentEnv: { CODEX_PROFILE: 'captured' } + }, + providerSession: { key: 'session_id', id: 'session-1' } + }) + ).resolves.toEqual({ status: 'created' }) + + expect(runtimeCall).toHaveBeenNthCalledWith(2, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.createTerminal', + params: { + worktree: `id:${WORKTREE_ID}`, + afterTabId: undefined, + targetGroupId: undefined, + command: "codex resume 'session-1'", + cwd: undefined, + env: { CODEX_PROFILE: 'captured' }, + startupCommandDelivery: undefined, + launchConfig: { + agentCommand: 'codex', + agentArgs: '', + agentEnv: { CODEX_PROFILE: 'captured' } + }, + launchAgent: 'codex', + activate: false, + select: true, + navigation: 'caller' + }, + timeoutMs: 15_000 + }) + }) + + it('preserves the exact resume when a new host reports an old execution owner', async () => { + const methods: string[] = [] + const runtimeCall = vi.fn(async (request: { method: string }) => { + methods.push(request.method) + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'new-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } + } + if (request.method === 'terminal.ensureAgentSession') { + return { + id: 'ensure', + ok: false, + error: { + code: 'agent_session_legacy_required', + message: 'agent_session_legacy_required' + } + } + } + return { + id: 'legacy-create', + ok: true, + result: { tab: { id: 'legacy-tab-1' }, publicationEpoch: 'epoch-1', snapshotVersion: 1 } + } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + agentSessionKind: 'resume', + launchAgent: 'codex', + command: "codex resume 'session-1'", + env: { CODEX_PROFILE: 'captured' }, + providerSession: { key: 'session_id', id: 'session-1' } + }) + ).resolves.toEqual({ status: 'created' }) + + expect(methods).toEqual([ + 'status.get', + 'terminal.ensureAgentSession', + 'session.tabs.createTerminal', + 'session.tabs.list' + ]) + expect(runtimeCall.mock.calls[2]?.[0]).toMatchObject({ + params: { + command: "codex resume 'session-1'", + env: { CODEX_PROFILE: 'captured' }, + launchAgent: 'codex' + } + }) + }) + + it('delivers generated continuation context after host-authoritative creation', async () => { + const runtimeCall = vi.fn(async (request: { method: string; params?: unknown }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } + } + if (request.method === 'terminal.createAgentSession') { + return { + id: 'create', + ok: true, + result: { + terminal: { + handle: 'term_created', + worktreeId: WORKTREE_ID, + tabId: 'host-tab-2', + leafId: 'leaf-1' + }, + disposition: 'created' + } + } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) @@ -640,14 +1119,20 @@ describe('createWebRuntimeSessionTerminal', () => { await expect( createWebRuntimeAgentSessionTerminal({ worktreeId: WORKTREE_ID, + agentSessionKind: 'fresh', agent: 'claude', command: 'claude', promptAfterReady: 'continue the unfinished task', submitPrompt: true, forcePromptPaste: true }) - ).resolves.toEqual({ created: true, promptDelivered: true }) + ).resolves.toEqual({ outcome: { status: 'created' }, promptDelivered: true }) + const createRequest = runtimeCall.mock.calls.find( + ([request]) => request.method === 'terminal.createAgentSession' + )?.[0] + expect(createRequest).toMatchObject({ params: { agent: 'claude' } }) + expect(createRequest?.params).not.toHaveProperty('prompt') expect(mocks.deliverLaunchPromptToAgentTab).toHaveBeenCalledWith({ tabId: 'web-terminal-host-tab-2', content: 'continue the unfinished task', diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 9777dfac84f..1707bd67075 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -14,11 +14,22 @@ import type { import type { TerminalPaneSplitSource } from '../../../shared/feature-education-telemetry' import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' +import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' +import type { + AgentLaunchPreferences, + AgentPromptDelivery, + RuntimeCreateAgentSessionResult, + RuntimeEnsureAgentSessionResult +} from '../../../shared/agent-session-host-authority' import type { TerminalPaneLayoutNode, TuiAgent } from '../../../shared/types' import type { AppState } from '../store/types' import { getRuntimeEnvironmentIdForWorktree } from '../lib/worktree-runtime-owner' import { useAppStore } from '../store' import { unwrapRuntimeRpcResult } from './runtime-rpc-client' +import { + createAgentSessionCreateOperation, + withAgentSessionCreateOperationId +} from './agent-session-create-operation' import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { recordWebSessionFocusIntent } from './web-session-focus-intent' @@ -30,7 +41,12 @@ import { toWebTerminalSurfaceTabId } from './web-terminal-surface-id' import { deliverLaunchPromptToAgentTab } from '../lib/agent-launch-prompt-delivery' -import { listRemoteRuntimeSessionTabsDeduped } from './remote-runtime-session-tabs-inflight' +import { + listRemoteRuntimeSessionTabsAfterCurrentInFlight, + listRemoteRuntimeSessionTabsDeduped +} from './remote-runtime-session-tabs-inflight' +import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch' +import { translate } from '../i18n/i18n' export { HOST_TERMINAL_SURFACE_SEPARATOR, @@ -47,6 +63,10 @@ export function isWebRuntimeSessionActive( return Boolean(activeRuntimeEnvironmentId?.trim()) } +export type WebRuntimeTerminalCreateOutcome = + | { status: 'created' } + | { status: 'failed'; message: string } + const pendingWebRuntimeSplitMirrorTelemetry = new Map>() const WEB_RUNTIME_SPLIT_MIRROR_SUPPRESSION_TTL_MS = 30_000 let pendingWebRuntimeSplitMirrorTelemetryId = 0 @@ -62,21 +82,30 @@ type CreateWebRuntimeSessionTerminalArgs = { envToDelete?: string[] startupCommandDelivery?: StartupCommandDelivery launchConfig?: SleepingAgentLaunchConfig + launchToken?: string agent?: TuiAgent launchAgent?: TuiAgent + agentSessionKind?: 'fresh' | 'resume' + prompt?: string + promptDelivery?: AgentPromptDelivery + /** Explicit CLI override; omission leaves the remote host's defaults authoritative. */ + agentArgs?: string | null + launchPreferences?: AgentLaunchPreferences + providerSession?: AgentProviderSessionMetadata viewMode?: 'terminal' | 'chat' activate?: boolean selectWorktree?: boolean } type CreatedWebRuntimeSessionTerminal = { - terminal: RuntimeMobileSessionCreateTerminalResult['tab'] + outcome: WebRuntimeTerminalCreateOutcome + hostTabId?: string } export async function createWebRuntimeSessionTerminal( args: CreateWebRuntimeSessionTerminalArgs -): Promise { - return Boolean(await createWebRuntimeSessionTerminalResult(args)) +): Promise { + return (await createWebRuntimeSessionTerminalResult(args)).outcome } export async function createWebRuntimeAgentSessionTerminal( @@ -86,75 +115,211 @@ export async function createWebRuntimeAgentSessionTerminal( submitPrompt: boolean forcePromptPaste: boolean } -): Promise<{ created: boolean; promptDelivered: boolean }> { +): Promise<{ + outcome: WebRuntimeTerminalCreateOutcome + promptDelivered: boolean +}> { const created = await createWebRuntimeSessionTerminalResult(args) - if (!created) { - return { created: false, promptDelivered: false } + if (created.outcome.status === 'failed' || !created.hostTabId) { + return { outcome: created.outcome, promptDelivered: false } } const promptDelivered = await deliverLaunchPromptToAgentTab({ - tabId: toWebTerminalSurfaceTabId(created.terminal.parentTabId), + tabId: toWebTerminalSurfaceTabId(created.hostTabId), content: args.promptAfterReady, agent: args.agent, submit: args.submitPrompt, forcePaste: args.forcePromptPaste }) - return { created: true, promptDelivered } + return { outcome: created.outcome, promptDelivered } } async function createWebRuntimeSessionTerminalResult( args: CreateWebRuntimeSessionTerminalArgs -): Promise { +): Promise { const environmentId = args.environmentId?.trim() ?? useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() ?? null if (!environmentId || !isWebRuntimeSessionActive(environmentId)) { - return null + return { + outcome: { + status: 'failed', + message: translate( + 'auto.runtime.webRuntimeSession.remoteHostDisconnected', + 'The workspace is not connected to a remote Orca host.' + ) + } + } } if (args.selectWorktree !== false) { selectWebRuntimeSessionWorktree(args.worktreeId) } + let hostCreated = false + let createdTabId: string | undefined try { - const response = await window.api.runtimeEnvironments.call({ - selector: environmentId, - method: 'session.tabs.createTerminal', - params: { - worktree: toRuntimeWorktreeSelector(args.worktreeId), - afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined, - targetGroupId: args.targetGroupId, - command: args.command, - cwd: args.cwd, - ...(args.env ? { env: args.env } : {}), - ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), - startupCommandDelivery: args.startupCommandDelivery, - ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), - agent: args.agent, - ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}), - ...(args.viewMode ? { viewMode: args.viewMode } : {}), - // Why: old hosts understand activate:false; new hosts use select/navigation for caller-local focus. - activate: false, - select: args.activate !== false, - navigation: 'caller' - }, - timeoutMs: 15_000 - }) - const createdTerminal = unwrapRuntimeRpcResult( - response as RuntimeRpcResponse - ) - if (args.activate !== false) { - // Why: record focus intent so the reconcile follows to this new terminal instead of sticky-keeping the prior tab. - recordWebSessionFocusIntent(args.worktreeId, createdTerminal.tab.id) + const agent = args.launchAgent ?? args.agent + const agentArgsOverride = + args.agentArgs !== undefined ? args.agentArgs : args.launchConfig?.agentArgs + if (agent) { + let legacyAlreadyPlacedInGroup = false + // Why: structured creation cannot yet express afterTabId; keep the exact legacy placement contract until it can. + const hostAuthority = args.afterTabId + ? undefined + : args.agentSessionKind === 'resume' + ? args.providerSession + ? async () => + unwrapRuntimeRpcResult( + (await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'terminal.ensureAgentSession', + params: { + kind: 'explicit', + worktree: toRuntimeWorktreeSelector(args.worktreeId), + agent, + providerSession: args.providerSession!, + ...(agentArgsOverride !== undefined ? { agentArgs: agentArgsOverride } : {}), + ...(args.launchPreferences + ? { launchPreferences: args.launchPreferences } + : {}), + presentation: args.activate === false ? 'background' : 'focused' + }, + timeoutMs: 15_000 + })) as RuntimeRpcResponse + ) + : undefined + : async () => + await createAgentSessionCreateOperation().run(async (clientOperationId) => + unwrapRuntimeRpcResult( + (await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'terminal.createAgentSession', + params: withAgentSessionCreateOperationId( + { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + agent, + ...(args.prompt ? { prompt: args.prompt } : {}), + ...(args.promptDelivery ? { promptDelivery: args.promptDelivery } : {}), + ...(agentArgsOverride !== undefined + ? { agentArgs: agentArgsOverride } + : {}), + ...(args.launchPreferences + ? { launchPreferences: args.launchPreferences } + : {}), + ...(args.cwd ? { startupCwd: args.cwd } : {}), + ...(args.viewMode ? { viewMode: args.viewMode } : {}), + presentation: args.activate === false ? 'background' : 'focused' + }, + clientOperationId + ), + timeoutMs: 15_000 + })) as RuntimeRpcResponse + ) + ) + const created = await runRemoteAgentSessionLaunch<{ terminal: { tabId?: string } }>({ + environmentId, + ...(hostAuthority ? { hostAuthority } : {}), + legacy: async () => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.createTerminal', + params: { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined, + targetGroupId: args.targetGroupId, + command: args.command, + cwd: args.cwd, + ...(args.env ? { env: args.env } : {}), + ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), + startupCommandDelivery: args.startupCommandDelivery, + ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), + ...(args.launchToken ? { launchToken: args.launchToken } : {}), + ...(args.agent ? { agent: args.agent } : {}), + ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}), + ...(args.viewMode ? { viewMode: args.viewMode } : {}), + // Why: old hosts understand activate:false; new hosts use select/navigation for caller-local focus. + activate: false, + select: args.activate !== false, + navigation: 'caller' + }, + timeoutMs: 15_000 + }) + const legacyCreated = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) + legacyAlreadyPlacedInGroup = true + return { terminal: { tabId: legacyCreated.tab.id } } + } + }) + hostCreated = true + createdTabId = created.terminal.tabId + if (args.targetGroupId && createdTabId && !legacyAlreadyPlacedInGroup) { + await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.move', + params: { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + tabId: createdTabId, + targetGroupId: args.targetGroupId, + kind: 'move-to-group' + }, + timeoutMs: 15_000 + }) + } + } else { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.createTerminal', + params: { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined, + targetGroupId: args.targetGroupId, + command: args.command, + cwd: args.cwd, + ...(args.env ? { env: args.env } : {}), + ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), + startupCommandDelivery: args.startupCommandDelivery, + ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), + ...(args.launchToken ? { launchToken: args.launchToken } : {}), + ...(args.viewMode ? { viewMode: args.viewMode } : {}), + // Why: old hosts understand activate:false; new hosts use select/navigation for caller-local focus. + activate: false, + select: args.activate !== false, + navigation: 'caller' + }, + timeoutMs: 15_000 + }) + const created = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) + hostCreated = true + createdTabId = created.tab.id + } + if (args.activate !== false && createdTabId) { + // Why: record focus intent so the reconcile follows the snapshot's active + // tab to THIS new terminal, instead of sticky-keeping the prior tab. + recordWebSessionFocusIntent(args.worktreeId, createdTabId) } await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId) - return { terminal: createdTerminal.tab } + return { + outcome: { status: 'created' }, + ...(createdTabId ? { hostTabId: createdTabId } : {}) + } } catch (error) { + const message = error instanceof Error ? error.message : String(error) console.warn( - '[web-runtime-session] failed to create terminal:', - error instanceof Error ? error.message : String(error) + hostCreated + ? '[web-runtime-session] terminal created but reconciliation failed:' + : '[web-runtime-session] failed to create terminal:', + message ) - return null + // Why: once the host accepted creation, reporting failure invites the user + // to retry with a new operation ID and can duplicate a fresh agent. + return { + outcome: hostCreated ? { status: 'created' } : { status: 'failed', message }, + ...(createdTabId ? { hostTabId: createdTabId } : {}) + } } } @@ -290,12 +455,29 @@ function findLocalBrowserPageForRemotePage( return null } -async function refreshWebRuntimeSessionTabsSnapshot( +export async function refreshWebRuntimeSessionTabsSnapshot( environmentId: string, - worktreeId: string + worktreeId: string, + options: { + acceptCurrentSnapshot?: boolean + confirmAgentSessionHandoff?: { + provisionalTabId: string + hostTabId: string + hostTerminalHandle: string + } + } = {} ): Promise { try { - const snapshot = await listRemoteRuntimeSessionTabsDeduped({ + if (options.acceptCurrentSnapshot) { + const { acceptReplayedWebSessionTabsSnapshot } = await import('./web-session-tabs-sync') + // Why: the host snapshot may have arrived before structured create returned; + // re-accept its current version after the exact provisional handoff is known. + acceptReplayedWebSessionTabsSnapshot(environmentId, worktreeId) + } + const listSessionTabs = options.confirmAgentSessionHandoff + ? listRemoteRuntimeSessionTabsAfterCurrentInFlight + : listRemoteRuntimeSessionTabsDeduped + const snapshot = await listSessionTabs({ environmentId, worktreeId, load: async () => { @@ -312,6 +494,16 @@ async function refreshWebRuntimeSessionTabsSnapshot( ) } }) + if (options.confirmAgentSessionHandoff) { + const { confirmWebAgentSessionHandoffAfterCreate } = + await import('./web-agent-session-handoff') + // Why: this list completed after structured creation, so absence now proves the exact host tab already retired. + confirmWebAgentSessionHandoffAfterCreate({ + environmentId, + worktreeId, + ...options.confirmAgentSessionHandoff + }) + } const { applyFreshWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } = await import('./web-session-tabs-sync') applyWebSessionTabsStorePatch((state) => { @@ -322,7 +514,7 @@ async function refreshWebRuntimeSessionTabsSnapshot( } catch (error) { // Why: host creation already succeeded; the long-lived session.tabs subscription catches up if this eager refresh fails. console.warn( - '[web-runtime-session] failed to refresh browser tab snapshot:', + '[web-runtime-session] failed to refresh session-tabs snapshot:', error instanceof Error ? error.message : String(error) ) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 6577fd5c801..adc9dee1886 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -22,6 +22,11 @@ import { } from './web-session-reorder-intent' import type { BrowserPage, BrowserWorkspace, Tab, TerminalTab } from '../../../shared/types' import type { OpenFile } from '../store/slices/editor' +import { + confirmWebAgentSessionHandoffAfterCreate, + recordWebAgentSessionHandoff, + resetWebAgentSessionHandoffsForTests +} from './web-agent-session-handoff' import { _getWebSessionTabsTrackingCountsForTest, acceptReplayedWebSessionTabsSnapshot, @@ -106,6 +111,7 @@ describe('applyWebSessionTabsSnapshot', () => { resetWebSessionFocusIntentForTests() resetWebSessionCloseIntentForTests() resetWebSessionReorderIntentForTests() + resetWebAgentSessionHandoffsForTests() }) it('ignores stale or duplicate same-epoch snapshots after a newer version was applied', () => { @@ -722,7 +728,7 @@ describe('applyWebSessionTabsSnapshot', () => { }) }) - it('replaces stale local agent quick-launch tabs once host mirrors arrive', () => { + it('keeps a provisional Claude tab when the host Claude surface is unrelated', () => { const staleLocalAgentTab: TerminalTab = { id: 'local-agent-tab', ptyId: null, @@ -782,14 +788,188 @@ describe('applyWebSessionTabsSnapshot', () => { NOW ) as Partial - const mirroredId = patch.tabsByWorktree?.[WT]?.[0]?.id - expect(mirroredId).toBeTruthy() - expect(patch.tabsByWorktree?.[WT]).toHaveLength(1) - expect(patch.tabsByWorktree?.[WT]?.[0]?.id).not.toBe('local-agent-tab') + expect(patch.tabsByWorktree?.[WT]).toHaveLength(2) + expect(patch.tabsByWorktree?.[WT]?.some((tab) => tab.id === 'local-agent-tab')).toBe(true) expect(patch.unifiedTabsByWorktree?.[WT]?.some((tab) => tab.id === 'local-agent-tab')).toBe( - false + true ) - expect(patch.groupsByWorktree?.[WT]?.[0]?.tabOrder).toEqual([mirroredId]) + }) + + it('replaces only the provisional tab with an exact structured-create handoff', () => { + const provisional = (id: string): TerminalTab => ({ + id, + ptyId: null, + worktreeId: WT, + title: 'Claude', + defaultTitle: 'Claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW, + launchAgent: 'claude' + }) + recordWebAgentSessionHandoff({ + environmentId: ENV, + worktreeId: WT, + provisionalTabId: 'provisional-b', + hostTabId: 'host-tab-1', + hostTerminalHandle: 'term_host-1' + }) + + const patch = applyWebSessionTabsSnapshot( + makeState({ + tabsByWorktree: { + [WT]: [provisional('provisional-a'), provisional('provisional-b')] + } + }), + makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'Claude', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + launchAgent: 'claude', + status: 'ready', + terminal: 'terminal-1' + } + ]), + ENV, + NOW + ) as Partial + + expect(patch.tabsByWorktree?.[WT]?.some((tab) => tab.id === 'provisional-a')).toBe(true) + expect(patch.tabsByWorktree?.[WT]?.some((tab) => tab.id === 'provisional-b')).toBe(false) + expect(patch.tabsByWorktree?.[WT]).toHaveLength(2) + }) + + it('retires an exact provisional handoff only after a post-create snapshot confirms exit', () => { + const provisional = (id: string): TerminalTab => ({ + id, + ptyId: null, + worktreeId: WT, + title: 'Claude', + defaultTitle: 'Claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW, + launchAgent: 'claude' + }) + recordWebAgentSessionHandoff({ + environmentId: ENV, + worktreeId: WT, + provisionalTabId: 'provisional-exited', + hostTabId: 'host-tab-exited', + hostTerminalHandle: 'term_host-exited' + }) + recordWebAgentSessionHandoff({ + environmentId: ENV, + worktreeId: WT, + provisionalTabId: 'provisional-unrelated', + hostTabId: 'host-tab-still-in-flight', + hostTerminalHandle: 'term_host-in-flight' + }) + + const state = makeState({ + tabsByWorktree: { + [WT]: [provisional('provisional-unrelated'), provisional('provisional-exited')] + } + }) + const possiblyPreCreate = applyWebSessionTabsSnapshot(state, makeSnapshot([]), ENV, NOW) + const possiblyPreCreateState = { + ...state, + ...(possiblyPreCreate as Partial) + } + expect(possiblyPreCreateState.tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual([ + 'provisional-unrelated', + 'provisional-exited' + ]) + + confirmWebAgentSessionHandoffAfterCreate({ + environmentId: ENV, + worktreeId: WT, + provisionalTabId: 'provisional-exited', + hostTabId: 'host-tab-exited', + hostTerminalHandle: 'term_host-exited' + }) + const postCreate = applyWebSessionTabsSnapshot( + state, + makeSnapshot([]), + ENV, + NOW + ) as Partial + + expect(postCreate.tabsByWorktree?.[WT]?.map((tab) => tab.id)).toEqual(['provisional-unrelated']) + }) + + it('cleans provisional startup and automatic-resume state during exact handoff', () => { + const provisionalTab: TerminalTab = { + id: 'provisional-resume', + ptyId: null, + worktreeId: WT, + title: 'Codex', + defaultTitle: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW, + launchAgent: 'codex' + } + recordWebAgentSessionHandoff({ + environmentId: ENV, + worktreeId: WT, + provisionalTabId: provisionalTab.id, + hostTabId: 'host-tab-1', + hostTerminalHandle: 'term_host-1' + }) + + const patch = applyWebSessionTabsSnapshot( + makeState({ + tabsByWorktree: { [WT]: [provisionalTab] }, + pendingStartupByTabId: { + [provisionalTab.id]: { command: "codex resume 'session-b'" }, + retained: { command: 'codex' } + }, + automaticAgentResumeClaimsByTabId: { + [provisionalTab.id]: { + worktreeId: WT, + launchAgent: 'codex', + providerSession: { key: 'session_id', id: 'session-b' } + }, + retained: { + worktreeId: WT, + launchAgent: 'codex', + providerSession: { key: 'session_id', id: 'session-a' } + } + } + }), + makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'Codex', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + launchAgent: 'codex', + status: 'ready', + terminal: 'terminal-1' + } + ]), + ENV, + NOW + ) as Partial + + expect(patch.pendingStartupByTabId).toEqual({ retained: { command: 'codex' } }) + expect(patch.automaticAgentResumeClaimsByTabId).toEqual({ + retained: { + worktreeId: WT, + launchAgent: 'codex', + providerSession: { key: 'session_id', id: 'session-a' } + } + }) }) it('keeps stale local agent tabs when the host mirror is for a different agent', () => { @@ -2747,6 +2927,24 @@ describe('applyWebSessionTabsSnapshot', () => { ).toBe('host-browser-unified') }) + it('resolves a canonical agent tab before its confirming snapshot arrives', () => { + recordWebAgentSessionHandoff({ + environmentId: ENV, + worktreeId: WT, + provisionalTabId: 'provisional-agent-tab', + hostTabId: 'canonical-host-tab', + hostTerminalHandle: 'term-canonical' + }) + + expect( + resolveHostSessionTabIdForWebSessionTab(makeState(), { + environmentId: ENV, + worktreeId: WT, + tabId: 'provisional-agent-tab' + }) + ).toBe('canonical-host-tab') + }) + it('removes mirrored browser tabs when the host closes the page', () => { const workspace: BrowserWorkspace = { id: 'local-browser-workspace', diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 59ab5a7fe58..6ba18e58a1a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -25,8 +25,7 @@ import type { TabGroupLayoutNode, TerminalLayoutSnapshot, TerminalPaneLayoutNode, - TerminalTab, - TuiAgent + TerminalTab } from '../../../shared/types' import type { OpenFile } from '../store/slices/editor' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' @@ -70,6 +69,13 @@ import { } from './web-runtime-wake-terminal-respawn' import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay' import { queueAcceptedWebSessionTerminalSnapshot } from './web-session-terminal-handle-events' +import { + clearWebAgentSessionHandoff, + clearWebAgentSessionHandoffsForEnvironment, + clearWebAgentSessionHandoffsForWorktree, + isWebAgentSessionHandoffPostCreateSnapshotConfirmed, + resolveWebAgentSessionHandoff +} from './web-agent-session-handoff' const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' @@ -147,7 +153,8 @@ export type WebSessionTabsSyncState = Pick< | 'unifiedTabsByWorktree' | 'unreadTerminalTabs' | 'sortEpoch' -> +> & + Partial> function isSessionTabsListAllResult(value: unknown): value is SessionTabsListAllResult { return ( @@ -335,6 +342,7 @@ function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeI clearWebRuntimeWakeTerminalRespawnForWorktree(worktreeId) clearWebSessionReorderIntentsForWorktree(worktreeId) clearWebSessionCloseIntentsForRuntimeWorktree(environmentId, worktreeId) + clearWebAgentSessionHandoffsForWorktree(environmentId, worktreeId) const keyPrefix = `${environmentId}:${worktreeId}:` for (const key of hostSessionTabIdByLocalKey.keys()) { if (key.startsWith(keyPrefix)) { @@ -369,6 +377,7 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) hostSessionTabIdByLocalKey.delete(key) } } + clearWebAgentSessionHandoffsForEnvironment(trimmedEnvironmentId) clearAllWebRuntimeWakeTerminalRespawn() clearWebSessionCloseIntentsForEnvironment(trimmedEnvironmentId) } @@ -389,7 +398,16 @@ export function resolveHostSessionTabIdForWebSessionTab( tabId: string } ): string | null { - return hostSessionTabIdByLocalKey.get(hostSessionTabMappingKey(args)) ?? null + return ( + hostSessionTabIdByLocalKey.get(hostSessionTabMappingKey(args)) ?? + // Why: structured create returns canonical identity before its confirming + // snapshot; an immediate user close must already target that host tab. + resolveWebAgentSessionHandoff({ + environmentId: args.environmentId, + worktreeId: args.worktreeId, + provisionalTabId: args.tabId + }) + ) } function isReadyTerminalTab( @@ -493,14 +511,11 @@ function shouldReplaceTerminalTab( environmentId: string, nextRemotePtyIds: ReadonlySet, nextMirroredTerminalIds: ReadonlySet, - nextMirroredLaunchAgents: ReadonlySet + exactProvisionalHandoffs: ReadonlySet ): boolean { - if ( - tab.launchAgent && - !isMirroredTerminalSurfaceId(tab.id) && - nextMirroredLaunchAgents.has(tab.launchAgent) - ) { - // Why: agent quick-launch created a local-only tab before the host snapshot landed; retire only the matching agent's stale row. + if (exactProvisionalHandoffs.has(tab.id)) { + // Why: agent kind is not session identity; retire only the provisional tab + // whose request or structured response identifies this exact host surface. return true } if (isMirroredTerminalSurfaceId(tab.id)) { @@ -1712,10 +1727,27 @@ export function applyWebSessionTabsSnapshot( const nextMirroredTerminalIds = new Set( terminalSurfaceTabs.map((tab) => toWebTerminalSurfaceTabId(tab.parentTabId)) ) - const nextMirroredLaunchAgents = new Set( - terminalSurfaceTabs - .map((tab) => tab.launchAgent) - .filter((agent): agent is TuiAgent => Boolean(agent)) + const nextHostTerminalTabIds = new Set(terminalSurfaceTabs.map((tab) => tab.parentTabId)) + const exactProvisionalHandoffs = new Set( + currentTerminalTabs + .filter((tab) => !isMirroredTerminalSurfaceId(tab.id)) + .filter((tab) => { + if (nextHostTerminalTabIds.has(tab.id)) { + return true + } + const handoff = { + environmentId, + worktreeId, + provisionalTabId: tab.id + } + const hostTabId = resolveWebAgentSessionHandoff(handoff) + return ( + hostTabId !== null && + (nextHostTerminalTabIds.has(hostTabId) || + isWebAgentSessionHandoffPostCreateSnapshotConfirmed(handoff)) + ) + }) + .map((tab) => tab.id) ) const retainedTerminalTabs = currentTerminalTabs.filter( (tab) => @@ -1724,7 +1756,7 @@ export function applyWebSessionTabsSnapshot( environmentId, nextRemotePtyIds, nextMirroredTerminalIds, - nextMirroredLaunchAgents + exactProvisionalHandoffs ) ) const mirroredTerminalTabs = buildMirroredTerminalTabs( @@ -1745,6 +1777,9 @@ export function applyWebSessionTabsSnapshot( const removedTerminalIds = new Set( currentTerminalTabs.filter((tab) => !retainedTerminalIds.has(tab.id)).map((tab) => tab.id) ) + for (const provisionalTabId of exactProvisionalHandoffs) { + clearWebAgentSessionHandoff({ environmentId, worktreeId, provisionalTabId }) + } const targetGroupId = chooseTargetGroupId(state, snapshot) const hostGroupIdByTabId = buildHostGroupIdByTabId(snapshot.tabGroups) @@ -2155,6 +2190,27 @@ export function applyWebSessionTabsSnapshot( } } + const pendingStartupByTabId = state.pendingStartupByTabId ?? {} + let nextPendingStartupByTabId = pendingStartupByTabId + const automaticAgentResumeClaimsByTabId = state.automaticAgentResumeClaimsByTabId ?? {} + let nextAutomaticAgentResumeClaimsByTabId = automaticAgentResumeClaimsByTabId + for (const removedId of exactProvisionalHandoffs) { + if (nextPendingStartupByTabId[removedId]) { + nextPendingStartupByTabId = + nextPendingStartupByTabId === pendingStartupByTabId + ? { ...pendingStartupByTabId } + : nextPendingStartupByTabId + delete nextPendingStartupByTabId[removedId] + } + if (nextAutomaticAgentResumeClaimsByTabId[removedId]) { + nextAutomaticAgentResumeClaimsByTabId = + nextAutomaticAgentResumeClaimsByTabId === automaticAgentResumeClaimsByTabId + ? { ...automaticAgentResumeClaimsByTabId } + : nextAutomaticAgentResumeClaimsByTabId + delete nextAutomaticAgentResumeClaimsByTabId[removedId] + } + } + let nextBrowserPagesByWorkspace = state.browserPagesByWorkspace let nextRemoteBrowserPageHandlesByPageId = state.remoteBrowserPageHandlesByPageId let nextBrowserCertificateFailuresByPageId = state.browserCertificateFailuresByPageId @@ -2430,6 +2486,12 @@ export function applyWebSessionTabsSnapshot( ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs ? { unreadTerminalTabs: nextUnreadTerminalTabs } : {}), + ...(nextPendingStartupByTabId !== pendingStartupByTabId + ? { pendingStartupByTabId: nextPendingStartupByTabId } + : {}), + ...(nextAutomaticAgentResumeClaimsByTabId !== automaticAgentResumeClaimsByTabId + ? { automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId } + : {}), ...(nextBrowserPagesByWorkspace !== state.browserPagesByWorkspace ? { browserPagesByWorkspace: nextBrowserPagesByWorkspace } : {}), diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index cd953432985..c3f5a431d6c 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -114,7 +114,7 @@ export const createRuntimeStatusSlice: StateCreator { const next = new Map(s.runtimeStatusByEnvironmentId) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 999741748f3..f4841b5882f 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -41,6 +41,7 @@ import { import { isWslUncPath } from '../../../../shared/wsl-paths' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime' import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' import type { AgentStartedTelemetry } from '../../lib/worktree-activation' @@ -492,7 +493,10 @@ export type TerminalSlice = { resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent + /** Explicit CLI override for host-owned agent launches; omission uses host settings. */ + agentArgsOverride?: string | null draftPrompt?: string + sessionOptions?: Record /** Initial prompt-start status for agents that lack native prompt hooks. */ initialAgentStatus?: { agent: TuiAgent; prompt: string } /** Show the restored-session banner when this startup command mounts. */ @@ -642,7 +646,9 @@ export type TerminalSlice = { resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent + agentArgsOverride?: string | null draftPrompt?: string + sessionOptions?: Record initialAgentStatus?: { agent: TuiAgent; prompt: string } showSessionRestoredBanner?: boolean telemetry?: AgentStartedTelemetry @@ -660,7 +666,9 @@ export type TerminalSlice = { resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent + agentArgsOverride?: string | null draftPrompt?: string + sessionOptions?: Record initialAgentStatus?: { agent: TuiAgent; prompt: string } showSessionRestoredBanner?: boolean telemetry?: AgentStartedTelemetry diff --git a/src/shared/agent-session-host-authority.ts b/src/shared/agent-session-host-authority.ts new file mode 100644 index 00000000000..61d50b5cb59 --- /dev/null +++ b/src/shared/agent-session-host-authority.ts @@ -0,0 +1,211 @@ +import { + hasUnsafeProviderSessionIdChars, + isResumableTuiAgent, + type AgentProviderSessionMetadata, + type ResumableTuiAgent +} from './agent-session-resume' +import type { RuntimeTerminalCreate, RuntimeTerminalPresentation } from './runtime-types' +import { isTerminalLeafId } from './stable-pane-id' +import { isValidTerminalTabId } from './terminal-tab-id' +import type { TuiAgent } from './types' + +export { AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY as AGENT_SESSION_HOST_AUTHORITY_CAPABILITY } from './protocol-version' + +export const AGENT_SESSION_RPC_ERROR_CODES = [ + 'agent_session_identity_required', + 'agent_session_conflict', + 'agent_session_resume_not_authorized', + 'agent_session_exited_during_start', + 'agent_session_claim_unavailable', + 'agent_session_ownership_unknown', + 'agent_session_checkpoint_stale', + 'agent_session_operation_invalid', + 'agent_session_operation_conflict', + 'agent_session_operation_expired', + 'agent_session_operation_capacity', + 'agent_session_legacy_required', + 'execution_owner_reconciling', + 'execution_owner_unavailable' +] as const + +export type AgentSessionRpcErrorCode = (typeof AGENT_SESSION_RPC_ERROR_CODES)[number] + +export const AGENT_SESSION_CLAIM_DIGEST_VERSION = 1 as const + +export const AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION = 2 as const +export const AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION = 1 as const + +export const AGENT_SESSION_OPERATION_FUTURE_SKEW_MS = 5 * 60 * 1000 +export const AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS = 24 * 60 * 60 * 1000 + +const AGENT_SESSION_OPERATION_ID_PATTERN = /^(\d{13})-[0-9a-f]{32}$/ + +export function parseAgentSessionOperationTimestamp(operationId: string): number | null { + const match = AGENT_SESSION_OPERATION_ID_PATTERN.exec(operationId) + if (!match) { + return null + } + const timestamp = Number(match[1]) + return Number.isSafeInteger(timestamp) ? timestamp : null +} + +const BASE64URL_RE = /^[A-Za-z0-9_-]+$/ +const SHA256_BASE64URL_LENGTH = 43 + +function isBoundedWireString(value: unknown, maxLength: number): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= maxLength && + !hasUnsafeProviderSessionIdChars(value) + ) +} + +export type AgentSessionSurfaceBinding = { + worktreeId: string + tabId: string + leafId: string + terminalHandle: string +} + +export type AgentSessionExecutionClaim = { + digestVersion: typeof AGENT_SESSION_CLAIM_DIGEST_VERSION + keyId: string + identityDigest: string + worktreeScopeDigest: string + agent: ResumableTuiAgent +} + +export type AgentSessionOwnerBinding = { + claim: AgentSessionExecutionClaim + generation: string + phase: 'reserved' | 'live' + ptyId: string + surface: AgentSessionSurfaceBinding +} + +export type AgentSessionClaimedSpawnResult = { + disposition: 'created' | 'adopted' + owner: AgentSessionOwnerBinding +} + +export type AgentLaunchPreferences = { + model?: string + effort?: string + mode?: string +} + +export type AgentPromptDelivery = 'auto-submit' | 'draft' + +export type RuntimeEnsureAgentSessionRequest = + | { + kind: 'automatic' + sleepingCheckpointId: string + presentation?: RuntimeTerminalPresentation + } + | { + kind: 'explicit' + worktree: string + agent: ResumableTuiAgent + providerSession: AgentProviderSessionMetadata + /** Explicit client override. Omission keeps launch defaults host-owned. */ + agentArgs?: string | null + launchPreferences?: AgentLaunchPreferences + presentation?: RuntimeTerminalPresentation + placement?: { tabId?: string; leafId?: string } + } + +export type RuntimeEnsureAgentSessionResult = { + terminal: RuntimeTerminalCreate + disposition: 'created' | 'adopted' +} + +export type RuntimeCreateAgentSessionRequest = { + clientOperationId: string + worktree: string + agent: TuiAgent + prompt?: string + promptDelivery?: AgentPromptDelivery + /** Explicit client override. Omission keeps launch defaults host-owned. */ + agentArgs?: string | null + launchPreferences?: AgentLaunchPreferences + startupCwd?: string + presentation?: RuntimeTerminalPresentation + placement?: { tabId?: string; leafId?: string } + viewMode?: 'terminal' | 'chat' +} + +export type RuntimeCreateAgentSessionResult = { + terminal: RuntimeTerminalCreate + disposition: 'created' | 'replayed' +} + +export type RuntimeAgentSessionRpcCaller = { + clientId?: string + clientKind?: 'mobile' | 'runtime' + signal?: AbortSignal +} + +export function isAgentSessionExecutionClaim(value: unknown): value is AgentSessionExecutionClaim { + if (typeof value !== 'object' || value === null) { + return false + } + const claim = value as Partial + return ( + claim.digestVersion === AGENT_SESSION_CLAIM_DIGEST_VERSION && + isBoundedWireString(claim.keyId, 128) && + BASE64URL_RE.test(claim.keyId) && + typeof claim.identityDigest === 'string' && + claim.identityDigest.length === SHA256_BASE64URL_LENGTH && + BASE64URL_RE.test(claim.identityDigest) && + typeof claim.worktreeScopeDigest === 'string' && + claim.worktreeScopeDigest.length === SHA256_BASE64URL_LENGTH && + BASE64URL_RE.test(claim.worktreeScopeDigest) && + isResumableTuiAgent(claim.agent) + ) +} + +export function isAgentSessionSurfaceBinding(value: unknown): value is AgentSessionSurfaceBinding { + if (typeof value !== 'object' || value === null) { + return false + } + const surface = value as Partial + return ( + isBoundedWireString(surface.worktreeId, 4096) && + isBoundedWireString(surface.tabId, 512) && + isValidTerminalTabId(surface.tabId) && + typeof surface.leafId === 'string' && + isTerminalLeafId(surface.leafId) && + isBoundedWireString(surface.terminalHandle, 128) && + surface.terminalHandle.startsWith('term_') && + BASE64URL_RE.test(surface.terminalHandle) + ) +} + +export function isAgentSessionOwnerBinding(value: unknown): value is AgentSessionOwnerBinding { + if (typeof value !== 'object' || value === null) { + return false + } + const owner = value as Partial + return ( + isAgentSessionExecutionClaim(owner.claim) && + isBoundedWireString(owner.generation, 128) && + (owner.phase === 'reserved' || owner.phase === 'live') && + isBoundedWireString(owner.ptyId, 4096) && + isAgentSessionSurfaceBinding(owner.surface) + ) +} + +export function isAgentSessionClaimedSpawnResult( + value: unknown +): value is AgentSessionClaimedSpawnResult { + if (typeof value !== 'object' || value === null) { + return false + } + const result = value as Partial + return ( + (result.disposition === 'created' || result.disposition === 'adopted') && + isAgentSessionOwnerBinding(result.owner) && + result.owner.phase === 'live' + ) +} diff --git a/src/shared/claimed-agent-pty-owner-snapshot.ts b/src/shared/claimed-agent-pty-owner-snapshot.ts new file mode 100644 index 00000000000..d9cd517e8c2 --- /dev/null +++ b/src/shared/claimed-agent-pty-owner-snapshot.ts @@ -0,0 +1,189 @@ +import type { + AgentSessionExecutionClaim, + AgentSessionOwnerBinding, + AgentSessionSurfaceBinding +} from './agent-session-host-authority' + +export type LiveAgentSessionOwner = AgentSessionOwnerBinding & { phase: 'live' } + +export function agentSessionClaimKey(claim: AgentSessionExecutionClaim): string { + return `${claim.digestVersion}:${claim.keyId}:${claim.agent}:${claim.identityDigest}` +} + +export function agentSessionClaimsEqual( + left: AgentSessionExecutionClaim, + right: AgentSessionExecutionClaim +): boolean { + return ( + left.digestVersion === right.digestVersion && + left.keyId === right.keyId && + left.identityDigest === right.identityDigest && + left.agent === right.agent + ) +} + +export function scopedAgentSessionClaimsEqual( + left: AgentSessionExecutionClaim, + right: AgentSessionExecutionClaim +): boolean { + return ( + agentSessionClaimsEqual(left, right) && left.worktreeScopeDigest === right.worktreeScopeDigest + ) +} + +export function agentSessionSurfacesEqual( + left: AgentSessionSurfaceBinding, + right: AgentSessionSurfaceBinding +): boolean { + return ( + left.worktreeId === right.worktreeId && + left.tabId === right.tabId && + left.leafId === right.leafId && + left.terminalHandle === right.terminalHandle + ) +} + +export function agentSessionOwnerBindingsEqual( + left: AgentSessionOwnerBinding, + right: AgentSessionOwnerBinding +): boolean { + return ( + left.phase === 'live' && + right.phase === 'live' && + left.generation === right.generation && + left.ptyId === right.ptyId && + scopedAgentSessionClaimsEqual(left.claim, right.claim) && + agentSessionSurfacesEqual(left.surface, right.surface) + ) +} + +export function cloneAgentSessionOwner(owner: LiveAgentSessionOwner): LiveAgentSessionOwner { + return { + ...owner, + claim: { ...owner.claim }, + surface: { ...owner.surface } + } +} + +export function prepareRegisteredAgentSessionOwner(args: { + owner: AgentSessionOwnerBinding + existing?: LiveAgentSessionOwner + reserved: boolean + conflicted: boolean +}): LiveAgentSessionOwner | null { + if (args.owner.phase !== 'live') { + throw new Error('agent_session_ownership_unknown') + } + if (args.reserved || args.conflicted) { + throw new Error('agent_session_conflict') + } + if (args.existing) { + if ( + args.existing.generation !== args.owner.generation || + args.existing.ptyId !== args.owner.ptyId + ) { + throw new Error('agent_session_conflict') + } + if (!agentSessionOwnerBindingsEqual(args.existing, args.owner)) { + throw new Error('agent_session_ownership_unknown') + } + return null + } + return cloneAgentSessionOwner({ ...args.owner, phase: 'live' }) +} + +export function buildClaimedAgentPtyOwnerIndex( + live: ReadonlyMap, + conflicts: ReadonlyMap +): Map> { + const result = new Map>() + const add = (key: string, owner: LiveAgentSessionOwner): void => { + const keys = result.get(owner.ptyId) ?? new Set() + keys.add(key) + result.set(owner.ptyId, keys) + } + for (const [key, owner] of live) { + add(key, owner) + } + for (const [key, owners] of conflicts) { + for (const owner of owners) { + add(key, owner) + } + } + return result +} + +function addUniqueEvidence( + evidenceByKey: Map, + owner: AgentSessionOwnerBinding +): void { + const cloned = cloneAgentSessionOwner({ ...owner, phase: 'live' }) + const key = agentSessionClaimKey(cloned.claim) + const evidence = evidenceByKey.get(key) ?? [] + const sameGeneration = evidence.find( + (candidate) => candidate.ptyId === cloned.ptyId && candidate.generation === cloned.generation + ) + if (sameGeneration && !agentSessionOwnerBindingsEqual(sameGeneration, cloned)) { + throw new Error('agent_session_ownership_unknown') + } + if (!evidence.some((candidate) => agentSessionOwnerBindingsEqual(candidate, cloned))) { + evidence.push(cloned) + evidenceByKey.set(key, evidence) + } +} + +export function reconcileClaimedAgentPtyOwnerSnapshot(args: { + live: ReadonlyMap + conflicts: ReadonlyMap + reservedKeys: ReadonlySet + incoming: readonly AgentSessionOwnerBinding[] + isInAuthoritativeScope: (owner: AgentSessionOwnerBinding) => boolean +}): { + live: Map + conflicts: Map +} { + const incomingByKey = new Map() + for (const owner of args.incoming) { + if (owner.phase !== 'live') { + throw new Error('agent_session_ownership_unknown') + } + addUniqueEvidence(incomingByKey, owner) + } + + const evidenceByKey = new Map() + const existing = [...args.live.values(), ...args.conflicts.values()].flat() + for (const owner of existing) { + if (!args.isInAuthoritativeScope(owner)) { + addUniqueEvidence(evidenceByKey, owner) + } + } + for (const incoming of incomingByKey.values()) { + for (const owner of incoming) { + addUniqueEvidence(evidenceByKey, owner) + } + } + + const nextLive = new Map() + const nextConflicts = new Map() + const keys = new Set([...args.live.keys(), ...args.conflicts.keys(), ...evidenceByKey.keys()]) + for (const key of keys) { + if (args.reservedKeys.has(key)) { + const current = args.live.get(key) + if (current) { + nextLive.set(key, current) + } + const conflict = args.conflicts.get(key) + if (conflict) { + nextConflicts.set(key, conflict) + } + continue + } + const evidence = evidenceByKey.get(key) ?? [] + if (evidence.length === 1) { + nextLive.set(key, evidence[0]) + } else if (evidence.length > 1) { + nextConflicts.set(key, evidence) + } + } + return { live: nextLive, conflicts: nextConflicts } +} diff --git a/src/shared/claimed-agent-pty-owner.test.ts b/src/shared/claimed-agent-pty-owner.test.ts new file mode 100644 index 00000000000..1ba4edc1361 --- /dev/null +++ b/src/shared/claimed-agent-pty-owner.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentSessionExecutionClaim, + AgentSessionSurfaceBinding +} from './agent-session-host-authority' +import { ClaimedAgentPtyOwnerRegistry } from './claimed-agent-pty-owner' + +function claim( + identityDigest = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +): AgentSessionExecutionClaim { + return { + digestVersion: 1, + keyId: 'key', + identityDigest, + worktreeScopeDigest, + agent: 'codex' + } +} + +const surface: AgentSessionSurfaceBinding = { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '12345678-1234-4234-8234-123456789abc', + terminalHandle: 'term_handle' +} + +describe('ClaimedAgentPtyOwnerRegistry', () => { + it('joins concurrent exact ensures and spawns once', async () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + let finish!: (result: { ptyId: string }) => void + const spawn = vi.fn( + () => + new Promise<{ ptyId: string }>((resolve) => { + finish = resolve + }) + ) + + const first = registry.ensure({ claim: claim(), surface, spawn }) + const second = registry.ensure({ claim: claim(), surface, spawn }) + finish({ ptyId: 'pty-1' }) + + await expect(first).resolves.toMatchObject({ disposition: 'created' }) + await expect(second).resolves.toMatchObject({ disposition: 'adopted' }) + expect(spawn).toHaveBeenCalledTimes(1) + }) + + it('conflicts when the same identity is claimed by another worktree', async () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + await registry.ensure({ + claim: claim(), + surface, + spawn: async () => ({ ptyId: 'pty-1' }) + }) + + await expect( + registry.ensure({ + claim: claim( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'ccccccccccccccccccccccccccccccccccccccccccc' + ), + surface: { ...surface, worktreeId: 'other' }, + spawn: async () => ({ ptyId: 'pty-2' }) + }) + ).rejects.toThrow('agent_session_conflict') + }) + + it('does not find an owner through another worktree scope', async () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + await registry.ensure({ + claim: claim(), + surface, + spawn: async () => ({ ptyId: 'pty-1' }) + }) + + expect( + registry.find( + claim( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'ccccccccccccccccccccccccccccccccccccccccccc' + ) + ) + ).toBeNull() + }) + + it('generation-guards release across a replacement owner', async () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + const first = await registry.ensure({ + claim: claim(), + surface, + spawn: async () => ({ ptyId: 'pty-1' }) + }) + + registry.release('pty-1', 'stale-generation') + expect(registry.find(claim())?.generation).toBe(first.owner.generation) + + registry.release('pty-1', first.owner.generation) + expect(registry.find(claim())).toBeNull() + }) + + it('does not retain an owner when the spawned PTY already exited', async () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + + await expect( + registry.ensure({ + claim: claim(), + surface, + spawn: async () => ({ ptyId: 'pty-dead' }), + isLive: () => false + }) + ).rejects.toThrow('agent_session_exited_during_start') + + expect(registry.find(claim())).toBeNull() + await expect( + registry.ensure({ + claim: claim(), + surface, + spawn: async () => ({ ptyId: 'pty-retry' }), + isLive: () => true + }) + ).resolves.toMatchObject({ owner: { ptyId: 'pty-retry' } }) + }) + + it('does not let a late liveness result adopt a released generation', async () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + const created = await registry.ensure({ + claim: claim(), + surface, + spawn: async () => ({ ptyId: 'pty-1' }) + }) + let finishProof!: (live: boolean) => void + const adoption = registry.ensure({ + claim: claim(), + surface, + spawn: async () => ({ ptyId: 'pty-2' }), + isLive: (owner) => + owner.ptyId === 'pty-1' + ? new Promise((resolve) => { + finishProof = resolve + }) + : true + }) + + registry.release('pty-1', created.owner.generation) + finishProof(true) + + await expect(adoption).resolves.toMatchObject({ + disposition: 'created', + owner: { ptyId: 'pty-2' } + }) + }) + + it('rejects a recovered owner that reuses only one half of its generation identity', () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + const owner = { + claim: claim(), + generation: 'generation-1', + phase: 'live' as const, + ptyId: 'pty-1', + surface + } + registry.register(owner) + + expect(() => registry.register({ ...owner, ptyId: 'pty-2' })).toThrow('agent_session_conflict') + expect(() => registry.register({ ...owner, generation: 'generation-2' })).toThrow( + 'agent_session_conflict' + ) + }) + + it('atomically converges from conflicting provider evidence to one owner', async () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + const ownerA = { + claim: claim(), + generation: 'generation-a', + phase: 'live' as const, + ptyId: 'pty-a', + surface + } + const ownerB = { + ...ownerA, + generation: 'generation-b', + ptyId: 'pty-b' + } + + registry.reconcileAuthoritative([ownerA, ownerB]) + await expect( + registry.ensure({ claim: claim(), surface, spawn: async () => ({ ptyId: 'unexpected' }) }) + ).rejects.toThrow('agent_session_conflict') + + registry.reconcileAuthoritative([ownerB]) + await expect( + registry.ensure({ + claim: claim(), + surface, + spawn: async () => ({ ptyId: 'unexpected' }), + isLive: (owner) => owner.generation === ownerB.generation + }) + ).resolves.toMatchObject({ disposition: 'adopted', owner: ownerB }) + }) + + it('prunes an advertised generation when an authoritative snapshot omits it', async () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + const recovered = { + claim: claim(), + generation: 'generation-old', + phase: 'live' as const, + ptyId: 'pty-reused', + surface + } + registry.reconcileAuthoritative([recovered]) + registry.reconcileAuthoritative([]) + + const spawn = vi.fn(async () => ({ ptyId: 'pty-new' })) + await expect(registry.ensure({ claim: claim(), surface, spawn })).resolves.toMatchObject({ + disposition: 'created', + owner: { ptyId: 'pty-new' } + }) + expect(spawn).toHaveBeenCalledOnce() + }) + + it('replaces a reused PTY id with the exact newly advertised generation', async () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + const oldOwner = { + claim: claim(), + generation: 'generation-old', + phase: 'live' as const, + ptyId: 'pty-reused', + surface + } + const newOwner = { ...oldOwner, generation: 'generation-new' } + registry.reconcileAuthoritative([oldOwner]) + registry.reconcileAuthoritative([newOwner]) + const isLive = vi.fn((owner: typeof newOwner) => owner.generation === 'generation-new') + + await expect( + registry.ensure({ + claim: claim(), + surface, + spawn: async () => ({ ptyId: 'unexpected' }), + isLive + }) + ).resolves.toMatchObject({ disposition: 'adopted', owner: newOwner }) + expect(isLive).toHaveBeenCalledWith(expect.objectContaining({ generation: 'generation-new' })) + }) + + it('does not let reconciliation erase an in-flight reservation', async () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + let finish!: (result: { ptyId: string }) => void + const spawn = vi.fn( + () => + new Promise<{ ptyId: string }>((resolve) => { + finish = resolve + }) + ) + const first = registry.ensure({ claim: claim(), surface, spawn }) + + registry.reconcileAuthoritative([]) + const second = registry.ensure({ claim: claim(), surface, spawn }) + finish({ ptyId: 'pty-reserved' }) + + await expect(first).resolves.toMatchObject({ disposition: 'created' }) + await expect(second).resolves.toMatchObject({ disposition: 'adopted' }) + expect(spawn).toHaveBeenCalledOnce() + }) +}) diff --git a/src/shared/claimed-agent-pty-owner.ts b/src/shared/claimed-agent-pty-owner.ts new file mode 100644 index 00000000000..5d7ea36afac --- /dev/null +++ b/src/shared/claimed-agent-pty-owner.ts @@ -0,0 +1,277 @@ +import { randomUUID } from 'node:crypto' +import type { + AgentSessionClaimedSpawnResult, + AgentSessionExecutionClaim, + AgentSessionOwnerBinding, + AgentSessionSurfaceBinding +} from './agent-session-host-authority' +import { + agentSessionClaimKey, + agentSessionClaimsEqual, + agentSessionSurfacesEqual, + buildClaimedAgentPtyOwnerIndex, + cloneAgentSessionOwner, + prepareRegisteredAgentSessionOwner, + reconcileClaimedAgentPtyOwnerSnapshot, + scopedAgentSessionClaimsEqual, + type LiveAgentSessionOwner +} from './claimed-agent-pty-owner-snapshot' + +export { agentSessionOwnerBindingsEqual } from './claimed-agent-pty-owner-snapshot' + +type ReservedOwner = { + claim: AgentSessionExecutionClaim + worktreeScopeDigest: string + generation: string + phase: 'reserved' + promise: Promise +} + +type LiveOwner = LiveAgentSessionOwner + +function cloneClaim(claim: AgentSessionExecutionClaim): AgentSessionExecutionClaim { + return { ...claim } +} + +function cloneSurface(surface: AgentSessionSurfaceBinding): AgentSessionSurfaceBinding { + return { ...surface } +} + +function cloneOwner(owner: LiveOwner): LiveOwner { + return cloneAgentSessionOwner(owner) +} + +export class ClaimedAgentPtyOwnerRegistry { + private readonly reserved = new Map() + private readonly live = new Map() + private readonly conflicts = new Map() + private keysByPtyId = new Map>() + + async ensure(args: { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + spawn: (reservation: { generation: string }) => Promise<{ + ptyId: string + owner?: AgentSessionOwnerBinding + disposition?: AgentSessionClaimedSpawnResult['disposition'] + }> + isLive?: (owner: LiveAgentSessionOwner) => boolean | Promise + }): Promise { + // Why: callers retain their request objects across retries; snapshot them so + // mutation during an awaited liveness/spawn check cannot change registry keys. + const requestedClaim = cloneClaim(args.claim) + const requestedSurface = cloneSurface(args.surface) + const key = agentSessionClaimKey(requestedClaim) + if (this.conflicts.has(key)) { + throw new Error('agent_session_conflict') + } + const live = this.live.get(key) + if (live) { + if (!agentSessionClaimsEqual(live.claim, requestedClaim)) { + throw new Error('agent_session_ownership_unknown') + } + if (live.claim.worktreeScopeDigest !== requestedClaim.worktreeScopeDigest) { + throw new Error('agent_session_conflict') + } + if (!args.isLive || (await args.isLive(cloneOwner(live)))) { + const current = this.live.get(key) + if (current?.ptyId === live.ptyId && current.generation === live.generation) { + return { disposition: 'adopted', owner: cloneOwner(current) } + } + return await this.ensure(args) + } + this.release(live.ptyId, live.generation) + } + + const reserved = this.reserved.get(key) + if (reserved) { + if (reserved.worktreeScopeDigest !== requestedClaim.worktreeScopeDigest) { + throw new Error('agent_session_conflict') + } + const result = await reserved.promise + return { disposition: 'adopted', owner: cloneOwner(result.owner as LiveOwner) } + } + + const generation = randomUUID() + let resolveReservation!: (result: AgentSessionClaimedSpawnResult) => void + let rejectReservation!: (error: unknown) => void + const promise = new Promise((resolve, reject) => { + resolveReservation = resolve + rejectReservation = reject + }) + // Why: the creating caller receives the spawn error directly; keep a + // no-join reservation rejection from becoming an unhandled promise. + void promise.catch(() => {}) + this.reserved.set(key, { + claim: requestedClaim, + worktreeScopeDigest: requestedClaim.worktreeScopeDigest, + generation, + phase: 'reserved', + promise + }) + + let promotedOwner: LiveOwner | null = null + try { + const spawned = await args.spawn({ generation }) + const owner: LiveOwner = spawned.owner + ? { + claim: cloneClaim(spawned.owner.claim), + generation: spawned.owner.generation, + phase: 'live', + ptyId: spawned.owner.ptyId, + surface: cloneSurface(spawned.owner.surface) + } + : { + claim: requestedClaim, + generation, + phase: 'live', + ptyId: spawned.ptyId, + surface: requestedSurface + } + if ( + owner.ptyId !== spawned.ptyId || + !scopedAgentSessionClaimsEqual(owner.claim, requestedClaim) + ) { + throw new Error('agent_session_ownership_unknown') + } + if ( + spawned.disposition !== 'adopted' && + !agentSessionSurfacesEqual(owner.surface, requestedSurface) + ) { + // Why: only an already-reconciled owner may override placement; a fresh + // owner returning another surface would let a lower layer forge authority. + throw new Error('agent_session_ownership_unknown') + } + const reservation = this.reserved.get(key) + if (reservation?.generation !== generation) { + throw new Error('agent_session_ownership_unknown') + } + this.live.set(key, owner) + const keys = this.keysByPtyId.get(owner.ptyId) ?? new Set() + keys.add(key) + this.keysByPtyId.set(owner.ptyId, keys) + promotedOwner = owner + // Why: exit can beat spawn completion. Index before the awaited proof so + // a generation-matched exit can remove this owner instead of being lost. + if (args.isLive && !(await args.isLive(cloneOwner(owner)))) { + throw new Error('agent_session_exited_during_start') + } + const current = this.live.get(key) + if (current?.ptyId !== owner.ptyId || current.generation !== owner.generation) { + throw new Error('agent_session_exited_during_start') + } + const result: AgentSessionClaimedSpawnResult = { + disposition: spawned.disposition ?? 'created', + owner: cloneOwner(owner) + } + resolveReservation(result) + return result + } catch (error) { + if (promotedOwner) { + this.release(promotedOwner.ptyId, promotedOwner.generation) + } + rejectReservation(error) + throw error + } finally { + const current = this.reserved.get(key) + if (current?.generation === generation) { + this.reserved.delete(key) + } + } + } + + register(owner: AgentSessionOwnerBinding): void { + const key = agentSessionClaimKey(owner.claim) + const registered = prepareRegisteredAgentSessionOwner({ + owner, + existing: this.live.get(key), + reserved: this.reserved.has(key), + conflicted: this.conflicts.has(key) + }) + if (!registered) { + return + } + this.live.set(key, registered) + const keys = this.keysByPtyId.get(owner.ptyId) ?? new Set() + keys.add(key) + this.keysByPtyId.set(owner.ptyId, keys) + } + + reconcileAuthoritative( + owners: readonly AgentSessionOwnerBinding[], + opts: { isInAuthoritativeScope?: (owner: AgentSessionOwnerBinding) => boolean } = {} + ): void { + const next = reconcileClaimedAgentPtyOwnerSnapshot({ + live: this.live, + conflicts: this.conflicts, + reservedKeys: new Set(this.reserved.keys()), + incoming: owners, + isInAuthoritativeScope: opts.isInAuthoritativeScope ?? (() => true) + }) + + // Why: recovery decisions must observe one complete provider snapshot; + // mutating only after validation prevents first-provider residue on conflict. + this.live.clear() + this.conflicts.clear() + for (const [key, owner] of next.live) { + this.live.set(key, owner) + } + for (const [key, conflict] of next.conflicts) { + this.conflicts.set(key, conflict) + } + this.rebuildPtyIndex() + } + + release(ptyId: string, generation?: string): void { + const keys = this.keysByPtyId.get(ptyId) + if (!keys) { + return + } + for (const key of keys) { + const owner = this.live.get(key) + if (!owner || (generation !== undefined && owner.generation !== generation)) { + continue + } + this.live.delete(key) + } + for (const [key, conflict] of this.conflicts) { + const remaining = conflict.filter( + (owner) => + owner.ptyId !== ptyId || (generation !== undefined && owner.generation !== generation) + ) + if (remaining.length === 0) { + this.conflicts.delete(key) + } else if (remaining.length === 1) { + this.conflicts.delete(key) + this.live.set(key, remaining[0]) + } else { + this.conflicts.set(key, remaining) + } + } + this.rebuildPtyIndex() + } + + list(): AgentSessionOwnerBinding[] { + return [...this.live.values()].map(cloneOwner) + } + + listForPty(ptyId: string): AgentSessionOwnerBinding[] { + const keys = this.keysByPtyId.get(ptyId) + if (!keys) { + return [] + } + return [...keys] + .map((key) => this.live.get(key)) + .filter((owner): owner is LiveOwner => owner !== undefined) + .map(cloneOwner) + } + + find(claim: AgentSessionExecutionClaim): AgentSessionOwnerBinding | null { + const owner = this.live.get(agentSessionClaimKey(claim)) + return owner && scopedAgentSessionClaimsEqual(owner.claim, claim) ? cloneOwner(owner) : null + } + + private rebuildPtyIndex(): void { + this.keysByPtyId = buildClaimedAgentPtyOwnerIndex(this.live, this.conflicts) + } +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index f8042f79800..ad5d830112a 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -54,6 +54,8 @@ export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = // Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised. export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const +export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = + 'agent-session.host-authority.v1' as const export const RUNTIME_CAPABILITIES = [ 'runtime.status.compat.v1', @@ -73,7 +75,8 @@ export const RUNTIME_CAPABILITIES = [ TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY, TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY, WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, - TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY + TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/pty-incarnation.ts b/src/shared/pty-incarnation.ts new file mode 100644 index 00000000000..a68ccd82e94 --- /dev/null +++ b/src/shared/pty-incarnation.ts @@ -0,0 +1,5 @@ +export type PtyIncarnationId = string + +export function isPtyIncarnationId(value: unknown): value is PtyIncarnationId { + return typeof value === 'string' && value.length > 0 && value.length <= 128 +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 5eec34211d2..2f438828837 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -289,7 +289,7 @@ export type RuntimeMobileSessionTabCloseResult = { // Why: lets the host tell a user's close from a client-lifecycle echo // ('pty-exit'/'cleanup') and adjudicate against its own PTY liveness. -// Absent on the wire for legacy desktop clients, which new hosts conservatively refuse. +// Absent on legacy clients, where the existing close endpoint remains user intent. export type RuntimeSessionTabCloseReason = 'user' | 'pty-exit' | 'cleanup' export type RuntimeMobileSessionTabsSnapshot = { @@ -556,6 +556,8 @@ export type RuntimeTerminalCreate = { title: string | null surface?: 'background' | 'visible' warning?: string + /** Present only for the structured host-authority resume path. */ + agentSessionDisposition?: 'created' | 'adopted' } export type RuntimeTerminalSplit = { diff --git a/src/shared/types.ts b/src/shared/types.ts index 3d0b6ca3f53..437f92d6535 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1136,6 +1136,22 @@ export type WorkspaceSessionState = { defaultTerminalTabsAppliedByWorktreeId?: Record /** Provider-session resume records captured when workspaces sleep. */ sleepingAgentSessionsByPaneKey?: Record + /** Host-issued process incarnation for each durable terminal surface. */ + terminalPtyIncarnationsByPaneKey?: Record + /** Monotonic host authority watermark for terminal membership in each repo. */ + terminalTopologyRevisionByRepoId?: Record + /** Legacy per-surface fences migrated into terminalTopologyRevisionByRepoId on load. */ + terminalSurfaceTombstonesByPaneKey?: Record< + string, + { + worktreeId: string + parentTabId: string + leafId: string + ptyId: string + incarnationId: string + retiredAt: number + } + > } export type WorkspaceSessionPatch = Partial diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index ab5d354e017..2bb832fd9ee 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -295,7 +295,22 @@ export const workspaceSessionStateSchema: z.ZodType = z.o ) .optional(), defaultTerminalTabsAppliedByWorktreeId: z.record(z.string(), z.literal(true)).optional(), - sleepingAgentSessionsByPaneKey: sleepingAgentSessionsByPaneKeySchema + sleepingAgentSessionsByPaneKey: sleepingAgentSessionsByPaneKeySchema, + terminalPtyIncarnationsByPaneKey: z.record(z.string(), z.string().min(1).max(128)).optional(), + terminalTopologyRevisionByRepoId: z.record(z.string(), z.number().int().nonnegative()).optional(), + terminalSurfaceTombstonesByPaneKey: z + .record( + z.string(), + z.object({ + worktreeId: z.string(), + parentTabId: terminalTabIdSchema, + leafId: z.string(), + ptyId: z.string(), + incarnationId: z.string().min(1).max(128), + retiredAt: z.number().finite().nonnegative() + }) + ) + .optional() }) export type ParsedWorkspaceSession = diff --git a/tests/e2e/daemon-generation-reconnect-safety.spec.ts b/tests/e2e/daemon-generation-reconnect-safety.spec.ts index af16a288280..6e57865bbfa 100644 --- a/tests/e2e/daemon-generation-reconnect-safety.spec.ts +++ b/tests/e2e/daemon-generation-reconnect-safety.spec.ts @@ -3,7 +3,11 @@ import { writeFileSync } from 'node:fs' import path from 'node:path' import { expect, test, type TestInfo } from '@playwright/test' import { DaemonClient } from '../../src/main/daemon/client' -import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { + CLEAN_DISCONNECT_PROTOCOL_VERSION, + PROTOCOL_VERSION, + PTY_STARTUP_INGRESS_PROTOCOL_VERSION +} from '../../src/main/daemon/types' import { cleanupDaemonGenerationFixtures, createDaemonGenerationRuntime, @@ -22,9 +26,15 @@ import { type RecordedProcessIdentity } from './helpers/daemon-generation-processes' -const GENERATION_PROTOCOLS = [21, 22, 23] as const const ALL_GENERATION_PROTOCOLS = [ - ...new Set([...GENERATION_PROTOCOLS, PROTOCOL_VERSION - 1, PROTOCOL_VERSION]) + ...new Set([ + 21, + 22, + 23, + CLEAN_DISCONNECT_PROTOCOL_VERSION, + PTY_STARTUP_INGRESS_PROTOCOL_VERSION, + PROTOCOL_VERSION + ]) ] const configuredReconnectBursts = Number.parseInt( process.env.ORCA_DAEMON_GENERATION_RECONNECT_BURSTS ?? '3',