diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9662ed24e7e..c2b597fafbf 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -82,7 +82,7 @@ jobs: name: e2e ${{ matrix.shard_name }} needs: build runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 strategy: fail-fast: false matrix: diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0d1a03f9ee5..f5aa19d93e7 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -253,7 +253,11 @@ "surfaces": [ "terminal input availability", "worktree discovery", - "Source Control status refresh" + "Source Control status refresh", + "direct SSH detected-worktree scheduling", + "direct SSH reconnect telemetry", + "direct SSH host catalog authority", + "direct SSH generation-scope rollover" ], "platforms": [ "macos", @@ -271,14 +275,17 @@ "local", "ssh" ], - "coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, and Source Control active-visible repo filtering. Linux/Windows are covered at the shared poller layer by forcing the non-darwin path; live platform runs remain gaps.", + "coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, Source Control active-visible repo filtering, the direct SSH five-slot fair scheduler, timeout barrier, aggregate privacy schema, coordinator-to-renderer telemetry wiring, host-catalog provenance rejection, and process generation-scope rollover across sibling targets. A macOS Electron client completed a direct SSH disconnect/reconnect against an ephemeral Linux Docker target with exact host/authority hydration and remote proof-file verification. Linux/Windows desktop clients, multi-target live fanout, paired-client, and WSL runs remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/7086" ], - "invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control; external head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout.", - "oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters.", + "invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control; external head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout. Direct SSH reconnect discovery must stay host- and authority-qualified, reject contradictory main-catalog provenance without returning rows, admit at most five locally unsettled provider calls, retain a retrying timeout barrier, and emit one identifier-free aggregate product event per target operation. A process generation-scope rollover revokes every direct SSH target and old-scope provider request, not only the target whose counter exhausted.", + "oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters. For direct SSH, reject catalog rows whose explicit and legacy host provenance contradict, roll one exhausted target into a fresh process generation scope while invalidating sibling target tokens, count locally unsettled attempts and round-robin admissions, keep lineage blocked through the first timeout retry, distinguish timeout/rejection/cancel/stale results, and reject telemetry properties carrying target, repo, host, path, label, user, request, lease, terminal, or raw-error data.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents.test.ts --reporter=dot", + "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/main/ipc/worktree-base-directory-event-filter.test.ts", @@ -287,7 +294,15 @@ "src/main/ipc/worktree-head-identity-reader.test.ts", "src/renderer/src/hooks/worktree-head-identity-apply.test.ts", "src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts", - "src/renderer/src/hooks/useIpcEvents.test.ts" + "src/renderer/src/hooks/useIpcEvents.test.ts", + "src/main/ipc/repos-remote.test.ts", + "src/main/ssh/ssh-connection-generation.test.ts", + "src/main/ssh/ssh-provider-authority.test.ts", + "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts", + "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts", + "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts", + "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", + "tests/e2e/ssh-docker-relay-perf.spec.ts" ], "assertionRefs": [ { @@ -343,7 +358,66 @@ { "file": "src/renderer/src/hooks/useIpcEvents.test.ts", "assertions": [ - "renderer preload API fixtures include the status-metadata and head-identity subscription contracts" + "renderer preload API fixtures include the status-metadata and head-identity subscription contracts", + "direct SSH coordinator telemetry is wired through the fail-soft product adapter" + ] + }, + { + "file": "src/main/ipc/repos-remote.test.ts", + "assertions": [ + "a host-qualified catalog rejects contradictory executionHostId and connectionId provenance without returning rows", + "local, sibling SSH, and runtime rows remain excluded from the exact direct SSH catalog" + ] + }, + { + "file": "src/main/ssh/ssh-connection-generation.test.ts", + "assertions": [ + "one exhausted target rolls the process generation scope and invalidates every sibling target token", + "old-scope mutation expectations fail while the new-scope authority continues rotating" + ] + }, + { + "file": "src/main/ssh/ssh-provider-authority.test.ts", + "assertions": [ + "generation-scope rollover invalidates every target authority before abort callbacks run", + "every registered old-scope provider request aborts exactly once" + ] + }, + { + "file": "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts", + "assertions": [ + "coordinator-owned locally unsettled provider work never exceeds five and target lanes round-robin", + "the first timeout remains retrying and reports queue wait separately from provider execution", + "cancel debt admits at most two replacements and terminally distinguishes budget exhaustion" + ] + }, + { + "file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", + "assertions": [ + "lineage and token creation remain blocked until a timed-out repo retry settles", + "exact overlapping preparation emits one aggregate with a join count", + "telemetry callback failure cannot affect reconnect completion" + ] + }, + { + "file": "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts", + "assertions": [ + "timeout, rejection, cancellation, and stale outcomes have independent fields", + "target, repo, host, path, label, user, request, lease, terminal, and raw-error fields are rejected" + ] + }, + { + "file": "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts", + "assertions": [ + "one coordinator aggregate maps to one typed product event with queue and provider percentiles", + "adapter failure is swallowed before it can reach recovery" + ] + }, + { + "file": "tests/e2e/ssh-docker-relay-perf.spec.ts", + "assertions": [ + "repo and worktree hydration use the exact direct SSH host and complete provider authority", + "terminal input remains live after disconnect/reconnect and writes a proof file visible inside the Linux target" ] } ], @@ -356,23 +430,50 @@ "result": "passed", "durationSeconds": 3.12, "summary": "7 files and 130 tests passed locally, adding head-identity emit-on-change without structural fanout, reflog status triggers, config.worktree structural classification, the in-place index backstop, and the spawn-free head reader with symref traversal rejection and hex-object-id output validation." + }, + { + "date": "2026-07-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 1.61, + "summary": "Three main-process catalog and authority files passed with 118 tests, including contradictory catalog provenance rejection and all-target generation-scope revocation." + }, + { + "date": "2026-07-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 3.45, + "summary": "Five focused direct SSH scheduler, coordinator, telemetry, and hook-wiring files passed with 130 tests." + }, + { + "date": "2026-07-27", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 66, + "summary": "Four Electron Docker SSH tests passed: two typing/performance paths, one concurrent file/Git load path, and exact-authority disconnect/reconnect with a container-visible remote proof file." } ], "runtimeBudget": { - "p95Seconds": 10, - "scope": "focused main/preload/renderer unit and polling tests" + "p95Seconds": 15, + "scope": "focused main/preload/renderer polling and direct SSH scheduler/telemetry tests" }, "flakeHistory": { "status": "unknown", - "evidence": "New experimental deterministic gate with one local macOS run; needs CI soak before promotion." + "evidence": "Three deterministic local macOS runs cover the original watcher lane, main catalog/authority lane, and direct SSH scheduler/telemetry lane; CI soak is still unavailable." }, "redGreenEvidence": { "status": "partial", - "evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. Saved CI red/green artifacts are still needed before blocking promotion." + "evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. The direct SSH tests encode failures for unbounded admission, early lineage release, merged timeout/rejection/cancel/stale results, identifier-bearing telemetry, duplicate joined events, and telemetry exceptions, but no intentional-break artifact was run or claimed; saved red/green artifacts are still needed before blocking promotion." }, "performanceBudget": { "required": true, - "evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping, compares HEAD/gitdir/locked signatures every tick, and gates linked index inspection behind the entry-directory signature. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows; a locked create/delete positive control still caused structural refreshes." + "evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping. Direct SSH coordinator-owned detected-worktree work is capped at five locally unsettled calls with a two-call late-work allowance; terminal finalization precedes provider discovery, and queue wait and provider execution are reported separately. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows; no equivalent live direct SSH fanout benchmark is claimed." }, "promotionCriteria": [ "Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.", @@ -383,7 +484,10 @@ "The live Electron diagnostic and screenshot evidence must remain attached to the motivating PR for durable review.", "Linux and Windows are forced through the shared non-darwin poller in unit tests but are not live-tested here.", "Git loose ref watching remains outside this incident fix by design.", - "SSH watches classify head-move triggers but skip the metadata-read identity diff; remote background-worktree heads still wait on a structural event or activation." + "SSH watches classify head-move triggers but skip the metadata-read identity diff; remote background-worktree heads still wait on a structural event or activation.", + "The Docker/Linux journey covers one direct SSH target; a live multi-target fanout and large-catalog benchmark remains missing.", + "Paired web clients intentionally do not run the desktop direct SSH coordinator, and paired-close non-interference lacks a new live run.", + "WSL direct SSH fanout remains an explicit live-test gap rather than inferred coverage." ], "demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if index-only churn can emit worktrees:changed, or if structural add/remove/HEAD/lock changes fail to converge." }, @@ -3567,6 +3671,10 @@ "layer": "provider-contract", "surfaces": [ "SSH deferred restore", + "direct SSH reconnect finalization", + "direct SSH folder workspace reattach", + "direct SSH split-pane retry ownership", + "same-authority terminal correction", "remote-runtime mirror polling", "remote-runtime network recovery", "terminal create idempotency", @@ -3591,7 +3699,7 @@ "ssh", "remote-runtime" ], - "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, stale cleanup cannot unregister a replacement runtime terminal, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Capability-gated create retries adopt a provider-owned PTY by stable terminal identity after an unknown outcome or runtime-process restart, stop retrying after one minute without a fatal error, and remain manually retryable without accepting stale create completions. A macOS Electron journey covers live SSH restore; a Windows remote-runtime smoke covers reachability and PTY round-trip. A live partition journey using patched Mac and Windows builds remains a gap.", + "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", @@ -3599,14 +3707,18 @@ "https://github.com/stablyai/orca/pull/7009", "https://github.com/stablyai/orca/pull/8597" ], - "invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Every restored remote terminal must preserve its provider PTY identity. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle.", - "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, and manual reconnect. They assert one unsubscribe per epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, one-minute create-retry cutoff, old-runtime no-retry behavior, authenticated client/worktree isolation, cross-process PTY adoption without rerunning startup, unavailable or legacy-incomplete inventory fail-closed behavior, and bounded in-flight coordination. Existing count tests prove concurrent panes share one in-flight inventory request per runtime/worktree and accepted-snapshot listeners are identity-scoped and released after rebind.", + "invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle.", + "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.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-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts src/shared/ssh-types.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/renderer/src/startup/ssh-startup-reconnect.test.ts", @@ -3615,6 +3727,7 @@ "src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts", "src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts", "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport.test.ts", "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts", "src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts", "src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx", @@ -3629,8 +3742,32 @@ "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts", "src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts", "src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", + "src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts", + "src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts", + "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts", + "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts", + "src/renderer/src/store/slices/terminals-hydration.test.ts", + "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts", + "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", + "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts", + "src/renderer/src/hooks/direct-ssh-state-routing.test.ts", + "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", + "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts", + "src/main/ipc/repos-remote.test.ts", + "src/main/ipc/ssh.test.ts", + "src/main/ipc/worktrees.test.ts", + "src/main/runtime/public-ssh-state.test.ts", + "src/main/ssh/ssh-connection-manager.test.ts", + "src/main/ssh/ssh-connection.test.ts", + "src/main/ssh/ssh-provider-authority.test.ts", + "src/preload/ssh-authority-forwarding.test.ts", + "src/renderer/src/runtime/runtime-client-events.test.ts", + "src/renderer/src/runtime/runtime-environment-ssh-state.test.ts", + "src/shared/ssh-retained-payload-admission.test.ts", + "src/shared/ssh-types.test.ts", "tests/e2e/terminal-cold-activation-deferral.spec.ts", - "tests/e2e/ssh-cold-activation-restore.spec.ts" + "tests/e2e/ssh-cold-activation-restore.spec.ts", + "tests/e2e/ssh-docker-relay-perf.spec.ts" ], "assertionRefs": [ { @@ -3720,6 +3857,189 @@ "snapshot-first replacement still migrates stale PTY-indexed state" ] }, + { + "file": "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts", + "assertions": [ + "one atomic patch clears exact-target live PTY indexes while preserving relay identity and null-PTY activation", + "another SSH target, local, WSL, floating, and runtime-owned terminal state remains unchanged" + ] + }, + { + "file": "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts", + "assertions": [ + "Git and folder workspaces resolve only from consistent exact-target provenance", + "ambiguous, contradictory, mixed, and runtime-owned folders fail closed" + ] + }, + { + "file": "src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts", + "assertions": [ + "one authority chain permits at most two automatic attempts even when both timeouts exceed the rolling thirty-second window", + "rejected stale-authority, stale-attempt, or pre-commit success acknowledgements mutate none of the tab, PTY-index, pending, history, or live-binding maps", + "both split-pane siblings bind under one exact attempt while the first PTY remains the tab fallback", + "a sibling failure starts one second tab-wide attempt and stale first-attempt callbacks preserve it", + "an exhausted attempt retains sibling continuation authority and promotes a surviving primary PTY without attempt three", + "primary exit before sibling commit preserves the exact continuation lease and accepts the late sibling" + ] + }, + { + "file": "src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts", + "assertions": [ + "primary and non-primary split detach preserve exact live authority and retry history on both resulting tabs", + "detaching the only bound split while its sibling is still spawning preserves the source continuation lease until that sibling binds", + "detaching during a null-PTY continuation gap projects the exact lease to both pending tabs", + "a pending-only all-null detach preserves the exact lease on both tabs before either leaf binds", + "same-authority invalidation and correction leave both detached live PTYs unchanged" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts", + "assertions": [ + "a detached null-PTY leaf remains marked for pending activation before ownership transfer" + ] + }, + { + "file": "src/main/ipc/repos-remote.test.ts", + "assertions": [ + "host-qualified repo catalogs require one consistent execution host and complete current SSH authority", + "contradictory, partial, mismatched, stale, and runtime-owned catalog requests fail closed" + ] + }, + { + "file": "src/main/ipc/ssh.test.ts", + "assertions": [ + "concurrent same-authority connects share one provider attempt", + "authority rotation starts stale transport cancellation before teardown, concurrent fresh callers share one replacement, and stale completion cannot clobber the fresh session", + "same-turn disconnect and forward-teardown failures across removal, reset, and terminate keep replacement connects and metadata mutation behind complete target cleanup" + ] + }, + { + "file": "src/main/ssh/ssh-connection-manager.test.ts", + "assertions": [ + "disconnect invalidates a pending transport attempt immediately so late rejection or resolution cannot remove the replacement" + ] + }, + { + "file": "src/main/ssh/ssh-connection.test.ts", + "assertions": [ + "late ssh2 ready and startup error events after disconnect cannot resurrect or overwrite disconnected state" + ] + }, + { + "file": "src/main/ipc/worktrees.test.ts", + "assertions": [ + "host-qualified worktree reads reject malformed or contradictory repo executionHostId/connectionId provenance before provider access and after provider awaits without durable mutations", + "host-qualified lineage excludes other SSH and runtime owners and rejects ambiguous or contradictory provenance", + "one lineage request snapshots repo, folder, and group catalogs once and memoizes repeated owner resolution" + ] + }, + { + "file": "src/main/runtime/public-ssh-state.test.ts", + "assertions": [ + "public SSH state preserves the complete provider epoch and connection generation pair" + ] + }, + { + "file": "src/main/ssh/ssh-provider-authority.test.ts", + "assertions": [ + "provider epoch and connection generation rotate as one exact authority pair", + "provider resolution rejects stale or incomplete authority", + "unknown-target currency probes reject without allocating provider authority state" + ] + }, + { + "file": "src/preload/ssh-authority-forwarding.test.ts", + "assertions": [ + "full authority crosses Electron IPC without loss", + "partial authority becomes unknown for bounded reconciliation and malformed full authority is dropped", + "variable-form host-qualified worktree requests retain fail-closed outcomes in their return type" + ] + }, + { + "file": "src/shared/ssh-retained-payload-admission.test.ts", + "assertions": [ + "retained connection states reject partial or malformed authority", + "only partial compatibility authority can normalize to unknown for bounded direct-SSH reconciliation", + "shared direct SSH authority admission requires bounded identifiers and a non-negative safe generation" + ] + }, + { + "file": "src/renderer/src/runtime/runtime-client-events.test.ts", + "assertions": [ + "retained runtime snapshots and live events preserve the full pair", + "partial runtime authority is rejected before it reaches environment state" + ] + }, + { + "file": "src/renderer/src/runtime/runtime-environment-ssh-state.test.ts", + "assertions": [ + "runtime-owned SSH state remains isolated by environment and rejects partial retained authority", + "in-flight hydration cannot resurrect disconnected or removed runtime environments" + ] + }, + { + "file": "src/shared/ssh-types.test.ts", + "assertions": [ + "SSH connection state carries the provider epoch and connection generation authority pair" + ] + }, + { + "file": "src/renderer/src/store/slices/terminals-hydration.test.ts", + "assertions": [ + "target-scoped hydration and reconnect preserve sibling SSH and runtime tabs, PTY indexes, runtime ownership, and active selection", + "authoritative target-tab deletion prunes only that tab's retry, live-binding, and retry-history ledgers", + "a snapshot PTY from another SSH host is rejected from the target scope" + ] + }, + { + "file": "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts", + "assertions": [ + "a provider result becomes stale when same-ID repo ownership turns malformed or contradictory during the await" + ] + }, + { + "file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", + "assertions": [ + "terminal invalidation and retry run synchronously before provider preparation", + "hydrated terminal finalization and same-authority correction are current-authority fenced", + "rapid authority rotation keeps immediate terminal checks while damping full preparation" + ] + }, + { + "file": "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts", + "assertions": [ + "exact-host catalog and lineage hydration preserves sibling SSH, local, runtime, ambiguous, and contradictory rows" + ] + }, + { + "file": "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", + "assertions": [ + "snapshot hydration preserves newer local recovery and keeps imported PTY ids retryable until exact-attempt transport acknowledgement", + "stale operation tokens cannot apply an older snapshot over current authority", + "target snapshot projection and persisted-terminal reconnect are host-qualified and preserve sibling SSH, local, WSL, and runtime state" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "StrictMode remounts join only the same direct SSH retry attempt", + "authority rotation starts a new spawn and rejects then retires a late obsolete-authority fresh PTY", + "late stale rebind and reattach completions, including lease replacement during asynchronous SSH preparation, callback errors, rejected promises, session-expired, empty, and launch-metadata outcomes, cannot clear current state, start replacement recovery, publish errors, or publish metadata", + "both concurrent split-pane spawns commit through the same exact retry attempt", + "both concurrent split-pane reattaches commit through the same exact retry attempt", + "a sibling mounted after first success captures the retained live lease", + "authority rotation rejects and retires a delayed sibling spawned from a retained live lease", + "intentional pane disposal cancels retry settlement while a same-attempt StrictMode remount retains one timeout" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "assertions": [ + "admission rejection precedes buffered final-frame and exit publication", + "abandoning an obsolete reattach drops its data, replay, write-unavailable, and exit handlers without killing the durable PTY", + "a rejected or destroyed fresh session fallback settles retirement before it can publish handlers, shutdown refusal is reported as unknown, and reattach remains non-destructive" + ] + }, { "file": "tests/e2e/ssh-cold-activation-restore.spec.ts", "assertions": [ @@ -3727,6 +4047,13 @@ "all six SSH managers mount eagerly and none is parked", "restored terminal input reaches a proof file on the Linux SSH host" ] + }, + { + "file": "tests/e2e/ssh-docker-relay-perf.spec.ts", + "assertions": [ + "repo and worktree setup fails closed unless the exact direct SSH host and complete authority are returned", + "a reconnected terminal accepts input and writes a proof file visible inside the Linux SSH target" + ] } ], "evidenceRuns": [ @@ -3756,23 +4083,68 @@ "result": "passed", "durationSeconds": 5, "summary": "Eleven fault-injection and recovery-UI files and 127 tests passed, covering suspended heartbeat clocks, stale socket, PTY, and create generations, canonical pre-ready close recovery with one replacement subscription, cold and repeated PTY reattachment, authoritative health, bounded PTY and terminal-create recovery, post-probe timeout clipping, manually retryable create cutoff, accurate capability-probe failures, same-PTY manual reconnect, pane-state cleanup, fatal error deduplication, stable create identity, cross-process PTY adoption, and fail-closed legacy inventory." + }, + { + "date": "2026-07-28", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 15.8, + "summary": "Twelve direct SSH files and 647 tests passed, including exact lease revalidation after asynchronous SSH preparation, primary-exit continuation gaps, pending-only and live null-PTY two-sided split-detach authority, delayed post-success sibling admission, stale-authority provider retirement, late ownership-provenance rejection, and deleted-tab ledger pruning." + }, + { + "date": "2026-07-28", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "result": "passed", + "durationSeconds": 1.48, + "summary": "All 90 transport tests passed, including pre-publication admission rejection, handler-complete non-destructive detach for obsolete SSH reattach transports, settled retirement of rejected or destroyed fresh fallbacks, and reported shutdown refusal." + }, + { + "date": "2026-07-28", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts src/shared/ssh-types.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 2.55, + "summary": "Twelve main, preload, runtime, and shared authority files and 513 tests passed, including fail-closed pre/post-await repo ownership provenance, production retained-payload admission, fenced stale-transport replacement, failure-safe target lifecycle barriers, and real ssh2 late-ready/error rejection." + }, + { + "date": "2026-07-28", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 51.4, + "summary": "Four Electron Docker SSH tests passed on the final implementation, including exact-authority repo/worktree hydration, two concurrent immutable file streams under Git churn, live terminal input before and after disconnect/reconnect, and an independent container-visible remote proof file." + }, + { + "date": "2026-07-28", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 12.5, + "summary": "One Electron journey passed on the final implementation after exact-authority hydration; six restored SSH terminal managers remounted after renderer reload and remote input reached the Linux target." } ], "runtimeBudget": { - "p95Seconds": 45, - "scope": "provider contract plus optional SSH soak" + "p95Seconds": 150, + "scope": "all configured provider-contract, build, Electron, Docker SSH reconnect, and six-terminal cold-restore commands" }, "flakeHistory": { "status": "unknown", - "evidence": "Focused renderer contracts and one clean Docker/Linux SSH Electron run pass locally; the live journey needs CI soak history before promotion." + "evidence": "Focused renderer contracts, one current 627-test direct SSH run, and current Docker/Linux reconnect and six-terminal cold-restore journeys pass locally. One pressure run entered reconnect while waiting for its seventh marker; later runs delivered all markers but exposed an independent disappearing-second-file fixture race. The corrected two-reader single-file load passed the complete 4/4 suite while retaining concurrent stream pressure. Live multi-target fanout still needs CI soak history before promotion." }, "redGreenEvidence": { "status": "partial", - "evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. The bounded-recovery tests additionally failed before the policy change because PTY and terminal-create recovery remained active after one minute and a retry reused the stale epoch. Final review tests failed before lifecycle fencing because create cutoff emitted a fatal error with no manual path and a delayed create completion replaced a newer cross-runtime attachment. The fixed tests pass with resumed snapshot/output/input, one-minute cutoffs, new manual epochs, capability re-probing, stale-create rejection, and cross-process provider PTY adoption without rerunning startup. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, a patched live remote-runtime partition journey, and saved intentional-break artifacts before promotion." + "evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. On exact pre-fix HEAD 939719443, the split-pane store, fresh-spawn, and reattach oracles failed because the first success removed pending authority and the sibling could not bind; the same three oracles pass after live bindings retain the exact attempt ID. On exact committed HEAD d501f2e96, primary-exit-before-sibling and primary/non-primary split-detach oracles failed because live authority was deleted during the empty gap or transferred to only one resulting tab; all three pass after continuation-gap preservation and two-sided detach projection. On exact committed HEAD d44ea382b with test-only oracles, a pending-only all-null detach deleted the attempt ledger, a sibling mounted after first success committed without its lease, and that sibling could bind after authority rotation; all three pass after pending-only projection and retained-live-lease capture with provider retirement. On exact committed HEAD 9fa84dacf, the production-manager in-progress oracle rejected the fresh authority and the forward-removal barrier delayed stale transport cancellation; both pass after replacement starts cancellation immediately, shares concurrent fresh callers, and waits for teardown before connecting. On exact committed HEAD e5ba9a9e5, overlapping disconnect allowed a replacement connect before forward teardown completed and delayed transport cancellation behind that barrier; disconnect, removal, and terminate now share a target lifecycle barrier, start transport cancellation immediately, retain captured-session identity, and admit the replacement only after cleanup. On exact committed HEAD 9a29e7a81, a rejected forward teardown short-circuited the lifecycle while transport disconnect was pending, removal left its captured relay session alive, same-turn connect escaped admission, and reset remained outside the target barrier; the exact failure oracles pass after both cleanup branches settle, captured sessions always retire, admission is authority-fenced, and reset shares the barrier. On exact committed HEAD 47c7198f2, reset's remaining bespoke forward teardown could still reject after authority rotation but before captured-session retirement; reset now uses the same hardened session teardown and a failed reset remains cleanly retryable. Other direct SSH tests encode red conditions for non-atomic binding clear, cross-target retry, folder omission, duplicate same-authority attempts, hydration overwrite, terminal finalization delayed behind provider work, obsolete-authority pending-spawn adoption, Git lineage namespace mismatch, and snapshot PTY hint promotion without exact-attempt acknowledgement. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, paired-close, and a patched live remote-runtime partition journey before promotion." }, "performanceBudget": { "required": true, - "evidence": "Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action; regular PTYs and initial creates do not poll inventory. Each unknown-outcome create attempt performs one bounded provider inventory scan, coordinated per authenticated client/worktree mutation, and the renderer stops issuing attempts after one minute. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch and cutoff tests prove request counts stay fixed for five additional minutes. Client and server liveness each use one interval per socket/transport. At most 4,096 create promises are retained only while in flight, and capacity rejection happens before spawning. Common terminal input/output paths add only constant-time state checks; recovery UI updates only on deduplicated phase transitions." + "evidence": "Direct SSH terminal invalidation and retry each use one exact-target store publication and execute before provider discovery; another target's five occupied provider slots cannot delay terminal finalization. Each split-pane completion or delayed mount adds constant-time pending/live lease lookups and no provider listing, polling, subprocess, cross-tab scan, or new fanout; two mounted leaves still perform exactly their two existing provider operations. The scheduler caps locally unsettled detected-worktree work at five with a two-call late-work allowance. Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch. Common terminal input/output paths add only constant-time state checks. No live large-terminal-map direct SSH timing is claimed." }, "promotionCriteria": [ "Use deterministic fake providers for failure and unknown-liveness cases.", @@ -3782,7 +4154,9 @@ "knownGaps": [ "Current command covers store wake-hint metadata, main-process SSH provider failure semantics, provider attach/expired-attach behavior, and renderer deferred SSH reconnect/transient-failure/expired-relay fallback with mocked transports.", "The live SSH journey is environment-dependent and currently runs from a macOS Electron client against a Linux Docker host.", - "WSL restore remains inferred rather than directly covered.", + "Current Docker/Linux journeys prove one target's reconnect and cold-restore paths; live multi-target fanout, folder-workspace reconnect, and large-terminal-map timing remain untested.", + "No paired-client close/non-interference journey was run for direct SSH reconnect; paired web clients intentionally remain outside coordinator ownership.", + "WSL restore and direct SSH/WSL isolation remain inferred rather than directly covered.", "Linux and Windows desktop-client partition journeys using patched builds are not yet collected; the Windows smoke proves current reachability and PTY round-trip only.", "Terminal-create recovery depends on providers authoritatively listing live terminal handles and worktree ownership; older runtimes do not advertise the capability and are never retried after an unknown outcome." ], diff --git a/docs/reference/plans/2026-07-27-ssh-reconnect-fanout.md b/docs/reference/plans/2026-07-27-ssh-reconnect-fanout.md new file mode 100644 index 00000000000..b3453c0c1d2 --- /dev/null +++ b/docs/reference/plans/2026-07-27-ssh-reconnect-fanout.md @@ -0,0 +1,1009 @@ +# Direct SSH reconnect fan-out + +Status: implemented and validated; ready for maintainer merge + +This design was reviewed in two full rounds, reconciled with current main, and implemented across main, preload, renderer, shared contracts, reliability gates, and Docker SSH fixtures. Review findings are absorbed into the invariants and implementation below. + +Validated against `origin/main` at `21dee21a6d9d398bb332ddf0f85fbb4d5de7cd1b`. Current main includes remote-runtime resume/online recovery (#8255), worktree-owned multi-host routing (#10986), negotiated paired close intent (#10129), fail-closed runtime SSH setup (#10799), terminal-view parking (#11016), host-correct SSH folder adoption (#10818), hydration-loop ID indexes (#10891), runtime output chunking (#10915), consolidated changed-code quality gates (#11117), orchestration migration safety (#11107), and CLI-compatible remote timeout parsing (#11206). None replaces the direct desktop SSH reconnect path, but each constrains its ownership and lifecycle integration below. + +Scope: direct SSH reconnect recovery across main, preload, and renderer + +## Summary + +One direct SSH reconnect currently starts two sequential target-preparation waves, or three when a nonempty remote-workspace snapshot is applied. Each wave refreshes every target repo and then lineage. Because the next preparation starts after the previous one settles, the existing in-flight repo coalescer does not collapse these sequential scans. Simultaneous targets multiply the work, while disconnect handling separately calls the single-tab `clearTabPtyId` action once per live tab. + +Replace this with host-qualified, epoch-fenced provider reads and a renderer coordinator with two distinct modes: + +- reconnect finalization retries the exact target's terminal panes immediately, before optional discovery; +- preparation-only refreshes the exact target's catalog, worktrees, and lineage without remounting panes. + +Main owns one provider-incarnation authority per direct SSH target. It is a strict composition of the existing `connectionGeneration` and a new opaque `providerEpoch`: one helper rotates both atomically on the same transition set, and provider work carries and compares the pair. Existing file-mutation fencing continues to carry `connectionGeneration`, but it now advances on every provider-invalidating transition too; reconnect recovery cannot advance one clock without revoking the other. Every authoritative request carries the exact execution host and captured authority through preload to main; main selects the provider by that complete identity and revalidates the authority before any durable side effect. Renderer preparation deduplicates only overlapping work with identical concrete inputs. A completed preparation is not cached for the lifetime of an authority. + +Direct SSH provider calls use a dedicated fair limiter with five local slots, explicit deadlines, and cancellation. Cancel acknowledgement means local waiter settlement; the existing fire-and-forget `rpc.cancel` does not acknowledge relay-handler completion. A separate cancel-debt allowance bounds replacement admission while relay work may be finishing. The five-slot bound applies only to locally unsettled detected-worktree provider calls submitted by this coordinator; aggregate telemetry reports the late-work allowance and other same-relay traffic separately. Runtime discovery, sidebar refreshes, filesystem events, catalog reads, and lineage reads retain their own concurrency behavior and are measured separately. + +This remains separate from the sidebar fix in `9d3ae3adc7`, which does not own `ssh:state-changed`, remote-workspace preparation, or terminal binding cleanup. + +## Goals + +- Recover terminal panes without waiting for Git discovery on the same or another target. +- Keep coordinator-owned direct SSH provider work bounded and fair across targets. +- Make provider selection correct when repo IDs collide across local, direct SSH, and remote-runtime hosts. +- Reject obsolete provider results before any main or renderer authoritative mutation. +- Preserve final-state convergence for later same-connection remote snapshots and wake refreshes. +- Keep disconnect and retry scope symmetric across Git worktrees and folder workspaces. +- Preserve relay reattach identifiers and terminal/session recovery semantics. +- Make timeout, cancellation, non-authoritative data, and operational failure separately observable without exposing identifiers. + +## Non-goals + +- Changing SSH credentials, backoff policy, or user-facing reconnect controls. +- Moving runtime-owned work into the direct SSH coordinator. +- Changing the component-scoped sidebar refresh queue added by `9d3ae3adc7`; the shared detected-worktree provider coalescer does gain lease accounting. +- Expanding remote-workspace serialization to folder workspaces. +- Changing Git commands, worktree parsing, or Git capability detection. +- Changing the remote-runtime wire protocol. +- Establishing a renderer-wide or application-wide provider-call ceiling. + +## Current flow and root cause + +### Connected path + +`applySshConnectionStateChange` currently: + +1. filters `store.repos` by raw `connectionId`; +2. calls `Promise.all(remoteRepos.map(fetchWorktrees))`; +3. calls `fetchWorktreeLineage`; +4. scans target worktrees and bumps terminal generations one worktree at a time; and +5. calls `syncRemoteWorkspaceAfterConnect`. + +`syncRemoteWorkspaceAfterConnect` calls `prepareRemoteWorkspaceTarget`, repeating repo and lineage refresh. A nonempty snapshot then reaches `applyRemoteWorkspaceSnapshot`, which prepares a third time. + +For a target with `R` repos, the connected path therefore performs `2R` detected-worktree scans and two lineage reads without a snapshot, or `3R` scans and three lineage reads with one. The existing detected-worktree single-flight joins only requests whose provider reads overlap. These preparation waves are sequential in the current call chain, so it does not collapse their scan count. Other overlapping callers can still join a scan; telemetry must measure observed calls rather than infer a global `kR` multiplier. + +### Disconnect path + +Terminal failure states walk repo-derived worktrees and call `clearTabPtyId(tab.id)` for each tab whose `ptyId` is present. The general single-tab action scans workspace buckets, clones global terminal maps, publishes store changes, bumps worktree activity, and can persist metadata on every call. + +For `T` live tabs, `W` workspace buckets, and terminal maps of size `M`, synchronous work is approximately `O(T × (W + M))`, plus repeated Zustand publications and session-persistence debounce resets. Connection loss is not user activity. + +### Boundary and ownership defects + +- Renderer `fetchWorktrees(repoId, { executionHostId })` uses the host to choose and stamp renderer ownership, but the local preload/main request currently carries only `repoId`. +- Main then calls first-match `store.getRepo(repoId)` and cannot enforce the renderer's intended host. The proven producer is renderer-catalog aliasing across hosts, not duplicate UUID rows created by main; same-ID main rows remain a defensive ambiguity case. +- Main can prune lineage and backfill metadata before renderer receives a result, so a renderer-only stale-result fence is insufficient. +- Current main-owned `connectionGeneration` and the renderer-local state-change counter have different sources and advance rules. Only the main-owned value is authoritative. +- Relay replacement can change the provider incarnation without advancing the existing main-owned generation. The correction is to rotate the generation and provider epoch together, not to use the renderer counter. +- Raw repo-ID filtering can select another execution host. Folder workspace keys are omitted entirely. +- Direct SSH catalog and lineage preparation follows focused-runtime ownership in some paths. +- Direct SSH lineage preparation currently calls bare `fetchWorktreeLineage()`, whose ownership can follow focused-runtime settings rather than the SSH target. + +### Deterministic current-main baseline + +The falsifiable current-main invariants are: + +1. one connected direct SSH event must issue at most one detected-worktree scan per exact repo input and one host-qualified lineage read; +2. an authoritative detected-worktree or lineage result must identify one exact execution host and, for direct SSH, the complete provider authority; +3. explicit worktree, repo-derived, runtime-owner, folder, and PTY provenance must not contradict one another; and +4. a first timeout classified as retryable must not release lineage, sync, or token creation before its retry reaches a terminal outcome. + +Current main violates the first two at the smallest deterministic seams: + +- `applySshConnectionStateChange` runs `fetchWorktrees` plus lineage and then calls `syncRemoteWorkspaceAfterConnect`; +- `syncRemoteWorkspaceAfterConnect` calls `prepareRemoteWorkspaceTarget`, and a nonempty snapshot calls `applyRemoteWorkspaceSnapshot`, which calls it again; +- the shared renderer coalescer joins only overlapping promises and deletes the entry when they settle, so these awaited sequential waves remain `2R` scans and two lineage reads without a snapshot or `3R` and three with one; +- renderer refresh selection accepts `executionHostId`, while preload `worktrees.listDetected` and main `worktrees:listDetected` accept only `{ repoId }`; main then uses first-match `store.getRepo(repoId)`. + +Baseline commands run from the rebased worktree: + +```bash +pnpm install --frozen-lockfile +rg -n "prepareRemoteWorkspaceTarget|applyRemoteWorkspaceSnapshot|syncRemoteWorkspaceAfterConnect|store.fetchWorktrees|fetchWorktreeLineage" src/renderer/src/hooks/useIpcEvents.ts +rg -n "listDetected: \\(args: \\{ repoId: string \\}\\)|store.getRepo\\(args.repoId\\)" src/preload/api-types.ts src/main/ipc/worktrees.ts +pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/worktrees.test.ts -t "coalesces concurrent duplicate refreshes for the same repo and host|keeps same-repo refreshes separate for different execution hosts|fetches the requested host when duplicate repo ids exist" +pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktrees.test.ts -t "coalesces concurrent authoritative detected worktree scans" +pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts -t "clears stale remote PTYs when an SSH connection fully disconnects|waits for the remote workspace client id before dropping self notifications" +pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts +pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/renderer/src/runtime/use-remote-runtime-recovery-triggers.test.ts -t "surfaces relay channel loss while the SSH connection remains alive|does not broadcast a premature connected when relay deploy fails|times out after 30s with no response|advances both recovery schedulers" +pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts src/renderer/src/lib/manual-terminal-worktree-parking.test.ts +pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/agent-background-session-launch-host.test.ts src/renderer/src/lib/launch-agent-background-session-remote.test.ts src/renderer/src/hooks/useAutomationDispatchEvents.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts +pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/worktree-by-id-index.test.ts src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts +``` + +Results initially on `79ec57d04`, then rerun after rebasing through `974447175`: 3/3, 1/1, 2/2, 4/4, and 3/3 selected SSH seam tests passed respectively; the three newly relevant parking files passed 39/39. On `1fd0f731f`, the four folder-automation/adoption files passed 516/516. On `694363805`, the hydration-index and output-frame equivalence files passed 25/25. Source-contract inspection confirmed the `2R`/`3R` call graph and the dropped host field; later main changes did not alter those seams. This is the historical baseline evidence used to define the candidate oracles. Candidate runtime and Docker SSH evidence is recorded separately below. + +### Candidate implementation and validation + +The candidate implements the composed authority pair, host-qualified catalog/worktree/lineage reads, separate waiter and provider identities, five-slot fair scheduling with a seven-start provider budget, the first-timeout retry barrier, exact-target terminal recovery, fenced remote-workspace hydration, and privacy-safe aggregate telemetry. Coordinator routing defaults on and can be disabled per build with `VITE_DIRECT_SSH_RECONNECT_COORDINATOR=false` or per renderer session with `orca.directSshReconnectCoordinator.enabled=false`; the fallback retains host/authority fencing, atomic terminal recovery, and bounded preparation. + +Deterministic validation on the final rebased implementation passed all 42 changed unit suites (1,960 tests), including the 11-file direct-SSH terminal gate (637 tests), the 11-file renderer provider/transport gate (763 tests), and the 12-file main/preload/runtime/shared authority gate (513 tests), plus full typecheck, changed-code quality, reliability-gate and max-lines checks, `git diff --check`, and an Electron E2E-mode build. The authority gate proves retained SSH connection and detected-port payload admission is wired into native preload and runtime-client/environment production routes. An earlier full `pnpm test` exercise ran 3,707 files and 39,083 tests; only two assertions in unchanged `agent-exec-handler.test.ts` failed because the managed terminal already injects `GIT_CONFIG_*` prompt settings. The file passed 10/10 with those inherited settings removed. Final exact-head CI evidence is recorded in the PR description. + +Real transport validation used a macOS Electron headless client against an ephemeral Linux Docker SSH/relay target: + +```bash +ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test \ + tests/e2e/ssh-docker-relay-perf.spec.ts \ + --config tests/playwright.config.ts \ + --project electron-headless \ + --workers=1 + +ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test \ + tests/e2e/ssh-cold-activation-restore.spec.ts \ + --config tests/playwright.config.ts \ + --project electron-headless \ + --workers=1 +``` + +The four-test relay suite and the cold-restore journey passed. The relay suite covered streaming, a background ACK-stalled PTY, file/Git pressure, and live terminal input/output before and after SSH disconnect/reconnect; the reconnect case independently read the post-reconnect proof file inside the Linux container. The cold-restore journey proved all six restored SSH terminals remounted and accepted remote input after renderer reload. Repo registration waits on exact renderer catalog ownership and full authority, requires an authoritative host-qualified worktree response, and uses no timing sleep. + +Remaining live gaps are headed paired-Orca-server and headless `orca serve` non-interference, WSL, physical Windows and Linux desktop clients, and a multi-target live fan-out/large-terminal-map benchmark. Docker SSH proves the direct SSH provider/relay path, not paired-runtime parity. + +Current-main reconciliation: + +- #8255 advances paired remote-runtime control and pane backoffs on resume/online. Direct SSH registers its own wake input and must not call or absorb that scheduler; one OS event may wake both ownership domains, but each exact pane finalizes at most once. +- #10986 correctly routes an exact worktree by its own host in multi-host projects. Direct reconnect reuses that worktree-specific precedence only after rejecting any contradictory provenance; it never falls back to project-wide repo ambiguity or focused-runtime ownership. +- #10129 makes capable paired-runtime reasonless close fail closed. Direct SSH cleanup clears transient bindings only: it never emits `session.tabs.close`, retires a tab, or kills a provider PTY. +- #10799 confirms runtime project setup must refuse `ssh:` rather than act locally. The desktop direct-SSH handler likewise rejects `runtime:` hosts, and runtime-owned SSH rows remain under runtime authority. +- #11016 parks terminal views without clearing their PTYs. Direct SSH binding cleanup does not invoke parking, close, or layout mutation; the mounted view observes the atomic PTY-state patch independently. +- #10818 routes folder automation through `getKnownWorktreeById` and ambiguity-aware `getFolderWorkspaceConnectionId`, and publishes agent tabs only after binding the spawned PTY. Direct reconnect uses the same effective folder connection as provenance, rejects mixed ownership, preserves agent state, and never treats an adopted PTY as live under a newer authority without matching binding evidence. +- #10891 preserves legacy first-wins semantics while indexing ID-only hydration lookups. Direct reconnect must not treat `buildWorktreeByIdIndex` as ownership proof: target hydration and reattach resolve a host-qualified worktree first and fail closed on duplicate or contradictory ownership. +- #10915 changes runtime output-frame chunking without changing PTY binding, authority, close intent, or hydration ownership. Direct reconnect does not reset output sequence state or reinterpret runtime frames. + +## Invariants + +### Authoritative provider authority + +1. Main is the sole issuer of `SshProviderEpoch` and `connectionGeneration`. Renderer never increments, orders, parses, or synthesizes either value. +2. Direct SSH state is normalized to `providerEpoch: SshProviderEpoch | null` plus `connectionGeneration?: number`. Main-originated direct SSH state always carries a valid pair. `null` or a missing generation means unknown authority, not zero and not a renderer fallback. +3. A single `rotateSshProviderAuthority(targetId)` helper advances both values atomically before every provider-invalidating transition: new connect ownership, transport replacement or loss, relay/multiplexer replacement or loss, provider disposal, target readoption/reassignment, and permanent target removal. No producer may rotate or publish only one component. +4. `connectionGeneration` remains the existing SSH mutation expectation. Expanding its rotation set closes relay-only mutation races. Provider/reconnect operations compare the full `(providerEpoch, connectionGeneration)` pair; this is the formal composition rule, not two independent clocks. +5. Main registers the provider serving the new pair before broadcasting `connected`. A broadcast-first transition is invalid because it can start a preparation against an unregistered provider with no guaranteed self-heal event. +6. Renderer partial state writers are patches. They preserve both main authority fields and cannot author a `connected` state with a new value. +7. Every boundary that decides equality, copies state, admits retained state, preloads state, reconciles state, or republishes state must preserve both fields. This inventory includes `sshConnectionStatesEqual`, `admitSshConnectionState` and its byte/range checks, public-state projection, `ssh:getState`, `ssh:state-changed`, startup reconnect, runtime-client retained payloads, renderer/runtime SSH state stores, web-file mutation reads, and all fixtures/builders. Allowlisted clones must name both fields; equality treats either field changing as significant. +8. A recovery operation captures one exact `DirectSshAuthority = (targetId, providerEpoch, connectionGeneration)`. Equality is the only permitted authority operation. +9. If a connected event lacks either component, the renderer performs one bounded `ssh:getState` reconciliation with a per-target arrival watermark. The reply may fill authority only if no newer push event arrived and the stored event/status still matches the initiating event; it cannot transition status or resurrect an older `connected` state. If authority remains unknown, authoritative preparation, retry, sync, and snapshot mutation fail closed with `authority-unknown`; disconnect cleanup remains allowed. +10. After every await and inside every authoritative store updater, current state must still name the same connected target and exact authority pair. +11. Supersession is determined by coordinator arrival order and exact equality, never numeric ordering. +12. On any authority change, before new preparation admission, cancel the target's queued work, locally settle obsolete waiter leases, send exactly one cancellation for every affected in-flight provider request ID, mark all late results stale, and retain main-side post-await fences. Terminal finalization for the new authority does not wait for old relay work. +13. Exhausting one target's per-session generation counter rolls the process generation scope and revokes every direct SSH target, not only the target that exhausted its counter. Main invalidates every cached authority and aborts every registered provider request from the old scope before any target can publish or admit work under the new scope. + +### Host-qualified ownership + +1. Direct reconnect work owns only `toSshExecutionHostId(targetId)`. +2. Every coordinator detected-worktree provider invocation carries `(repoId, executionHostId, expectedAuthority, providerRequestId)`. Each renderer consumer separately owns a `waiterLeaseId`; a lease ID never crosses IPC or names provider work. +3. Main resolves a repo by the complete `(repo.id, executionHostId)` identity only after validating all present repo provenance. `executionHostId` and legacy `connectionId` must agree when both are present; a catalog row for which either source names the requested host while the other names another SSH, local, or runtime host makes the host catalog non-authoritative. Zero, contradictory, or multiple matches fail closed. Main never uses explicit-field precedence to hide a contradiction and never falls back to first-match `getRepo(repoId)` for a host-qualified request. +4. A local host request can select only a local repo. A direct SSH host request can select only the matching target/provider. A runtime host is rejected by the desktop handler and must use the existing runtime RPC route. +5. A successful response uses a local or direct-SSH discriminant. The direct-SSH variant cannot be constructed without the resolved execution host and full authority pair. Renderer validates the wire discriminant and rejects a mismatch before any use. +6. Runtime-owned or runtime-transported worktrees remain under the runtime environment scheduler, including SSH execution hosts whose `runtimeOwnerEnvironmentId` names a HUB. +7. Raw repo IDs, paths, UI focus, and unqualified legacy metadata are not ownership evidence. Pre-catalog scope resolution uses explicit worktree/repo provenance and `getExplicitRuntimeEnvironmentIdForWorktree`; it must not use the focused-runtime fallbacks in `getExecutionHostIdForWorktree` or `getRuntimeEnvironmentIdForWorktree`. `runtime:unresolved-owner` and focus-only results are ambiguous, not another host's. Unknown ownership is diagnostic and retryable, but never authoritatively replaced or deleted. +8. All present provenance must agree. Explicit worktree ownership takes precedence only after agreement is proven; repo-derived ownership is a fallback only when explicit worktree ownership is absent. A worktree stamped `ssh:B` with repo-derived `ssh:A`, or any direct-SSH row with an explicit runtime owner, is `contradictory-owner` and is preserved without refresh, merge, retry, or pruning. + +### Merge fencing + +1. Provider results are immutable until all host and authority checks pass. +2. Main revalidates the request host, provider instance, and exact authority after the provider await and before: + - remembering worktree roots; + - pruning persisted lineage; + - stamping or backfilling worktree metadata; or + - returning an authoritative result. +3. Renderer revalidates immediately after the preload/runtime await and before any use of the result, including: + - `routeListingBranchSwitchesThroughGitIdentity`; + - hosted-review sanitation; + - `updateWorktreeGitIdentity`; + - `buildWorktreePurgeState`; + - `worktreesByRepo` or `detectedWorktreesByRepo` merges; and + - best-effort lineage refresh. +4. Host-scoped catalog and lineage snapshots are revalidated before their sole renderer merge. +5. A remote-workspace token and snapshot revision are revalidated after hydration and immediately before session merge/publish. The merge also preserves any terminal-recovery revision newer than the snapshot operation. +6. Stale, superseded, timed-out, and canceled results perform zero authoritative mutations and are not logged as operational errors. + +### Terminal state + +The disconnect action preserves the current call-site predicate: a tab is affected only when `tab.ptyId != null`. A tab without a `ptyId` remains byte-identical even if it has `pendingActivationSpawn` or an inconsistent auxiliary PTY index. + +For each affected tab, the atomic action must: + +- set `tab.ptyId` to `null`; +- empty its `ptyIdsByTabId` entry; +- consume `pendingActivationSpawn`; +- remove pending Codex restart and restart-notice entries for the cleared live PTY; +- preserve `lastKnownRelayPtyIdByTabId` for relay-grace reattach and the `#9911` orphan-safety invariant; and +- leave layouts, deferred SSH sessions, pending reconnect IDs, suppression/shutdown guards, tab IDs, titles, generations, and agent state unchanged. + +The action must not bump activity, sort worktrees, or persist worktree metadata. A repeat after all qualified bindings are clear returns the original store state. +It also must not emit `session.tabs.close`, `session.tabs.closeLifecycle`, provider shutdown, or process signals. Binding loss is not close intent, tab retirement, or proof that a PTY died. + +Reconnect finalization must: + +- use `shouldRetryPaneSpawnOnSshReconnect`; +- include exact direct SSH Git-worktree and folder-workspace keys; +- run synchronously before catalog or provider awaits; +- treat a non-null `ptyId` as live only when transient binding provenance names the current authority and the current tab-wide spawn/reattach attempt has established live authority; +- clear stale binding evidence from a missed disconnect or prior authority before testing retry eligibility; +- keep at most one tab-wide retry attempt in flight per tab and authority; +- join renderer pending-spawn promises only for the same retry attempt; authority or tab-generation advance starts independently, and a late obsolete fresh PTY is rejected and retired; +- accept a spawn or reattach acknowledgement only when its attempt, authority, tab generation, target-qualified PTY, and committed PTY index all match; the first split success establishes the tab fallback and a live continuation lease, and later or post-success-mounted siblings capture and commit through that exact lease without replacing the fallback; +- admit a reattach identity before publishing renderer PTY handlers; revalidate the captured lease after every asynchronous SSH preparation wait and, after the synchronous already-exited delivery case, before error or launch-metadata publication, deferred-state mutation, binding cleanup, or replacement spawn; drop stale owned handlers without killing the durable PTY a current lease may adopt; +- settle retirement of a newly created session-expired fallback when admission rejects it, including after transport destruction, and surface shutdown refusal as unknown, without killing a rejected reattach or cold restore that another current lease may adopt; +- preserve the continuation lease if its primary PTY exits while another split leaf from the same attempt is still activating, then promote the late sibling when it commits; +- on an attempt-one sibling failure or timeout, revoke that attempt and rotate the whole tab once; after attempt two is exhausted, retain its continuation lease for siblings already settling while forbidding attempt three; +- when a split pane detaches to a new tab, project the same exact live or pending authority and retry history to both resulting tabs, including a bound-plus-unbound split or an all-null continuation gap before any leaf binds; the detached null-PTY tab remains activation-pending, and a rejected acknowledgement or detach returns the original relevant maps unchanged; +- permit a tab hydrated, newly discovered, or left unbound after a failed spawn to receive a bounded same-authority corrective bump; +- update all affected workspace buckets in one Zustand publication; and +- leave preparation-only requests, nonqualifying tabs, and every other host unchanged. + +The coordinator keeps separate authority-scoped pending-attempt state and successful-binding state, not a set of bump attempts and not a cached preparation outcome. The live binding carries the exact attempt ID as a continuation lease for every split leaf in that tab generation. A healthy live binding, including a bounded empty-primary activation gap, suppresses correction; an unresolved tab is reconsidered on wake, snapshot completion, and preparation completion with at most one tab-wide attempt in flight. One authority chain preserves its complete attempt history and has a hard limit of two automatic attempts. A timeout taking longer than the former rolling 30-second window cannot age out the first attempt and start a third automatic attempt; later wake, snapshot, and preparation triggers remain exhausted until authority replacement rotates pending, binding, and history state. + +## Design + +### 1. Composed provider authority and host-qualified IPC + +Add `SshProviderEpoch` to the shared SSH types and compose it with the existing main-owned connection generation in `src/main/ssh/ssh-provider-authority.ts`. This module owns atomic rotation and delegates generation storage/assertion to `ssh-connection-generation.ts`; it does not introduce an independently advancing clock. + +The direct SSH state boundary becomes: + +```ts +type SshProviderEpoch = string & { readonly __sshProviderEpoch: unique symbol } +type ProviderRequestId = string & { readonly __providerRequestId: unique symbol } +type WaiterLeaseId = string & { readonly __waiterLeaseId: unique symbol } +type SshExecutionHostId = Extract + +type DirectSshStateAuthority = { + providerEpoch: SshProviderEpoch | null + connectionGeneration?: number +} + +type DirectSshAuthority = { + targetId: string + providerEpoch: SshProviderEpoch + connectionGeneration: number +} +``` + +The epoch wire value is a bounded opaque string. Branding is compile-time only. Main state broadcasts and `ssh:getState` include the pair; renderer state stores it without interpretation. `rotateSshProviderAuthority` is the only transition writer and `assertSshMutationExpectation` observes the generation advanced by that same call. + +Extend preload/main detected-worktree APIs: + +```ts +type LocalDetectedWorktreeRequest = { + providerRequestId: ProviderRequestId + repoId: string + executionHostId: typeof LOCAL_EXECUTION_HOST_ID +} + +type DirectSshDetectedWorktreeRequest = { + providerRequestId: ProviderRequestId + repoId: string + executionHostId: SshExecutionHostId + expectedAuthority: DirectSshAuthority +} + +type ListDetectedWorktreesArgs = LocalDetectedWorktreeRequest | DirectSshDetectedWorktreeRequest + +type AuthoritativeHost = + | { + kind: 'local' + executionHostId: typeof LOCAL_EXECUTION_HOST_ID + } + | ({ + kind: 'direct-ssh' + executionHostId: SshExecutionHostId + } & DirectSshAuthority) + +type HostQualifiedDetectedWorktreeResult = + | { + status: 'complete' | 'non-authoritative' + providerRequestId: ProviderRequestId + repoId: string + authority: AuthoritativeHost + result: DetectedWorktreeListResult + } + | { + providerRequestId: ProviderRequestId + executionHostId: ExecutionHostId + status: + | 'canceled' + | 'timed-out' + | 'stale' + | 'ambiguous-owner' + | 'authority-unknown' + | 'rejected' + } +``` + +`DirectSshDetectedWorktreeRequest` requires the full pair as one `expectedAuthority`; local requests cannot carry it. Construction and runtime admission also require `executionHostId === toSshExecutionHostId(expectedAuthority.targetId)`. Every data-bearing direct-SSH response, including `non-authoritative` metadata fallback, is therefore impossible to construct without both fields. Runtime validation rejects decoded SSH data payloads missing either component even if an untyped or older boundary fabricates one. Main owns the 30-second provider deadline; no renderer-supplied timeout can extend it. The desktop handler rejects runtime hosts. Keep an explicitly unqualified legacy overload only for existing callers during migration; it fails closed when more than one host owns the repo ID and is removed after all callers pass a host. + +Main selects the repo and provider before starting work, captures the provider object and full authority, and checks all three again after `provider.listWorktrees`. A host-qualified SSH response cannot be restamped by renderer as another host. + +Use host-qualified lineage ownership as well: + +```ts +type ListDesktopLineageForHostArgs = + | { executionHostId: typeof LOCAL_EXECUTION_HOST_ID } + | { + executionHostId: SshExecutionHostId + expectedAuthority: DirectSshAuthority + } + +type HostLineageSnapshot = + | { + authoritative: true + authority: AuthoritativeHost + worktreeLineageById: Record + workspaceLineageByChildKey: Record + } + | { + authoritative: false + executionHostId: ExecutionHostId + reason: 'ambiguous-owner' | 'authority-unknown' | 'stale' | 'unavailable' + } +``` + +Main filters the snapshot to the requested host. Renderer replaces only an authoritative discriminated host scope; malformed SSH authority is rejected before merge. Direct SSH preparation replaces the current bare `fetchWorktreeLineage()` call with this host-qualified API; it does not substitute `{ forceLocalOwner: true }`. + +Main destructive pruning must use qualified repo/worktree ownership. An existing row `meta.hostId` must agree with the resolved repo host in every case. Only an absent legacy host may be inferred when exactly one stored repo owns the repo ID, preserving today's path-reuse cleanup. When multiple hosts can own the ID, pruning requires row `meta.hostId`; absence or conflict makes the result non-authoritative and preserves the row. Authoritative scans backfill absent `meta.hostId` so legacy rows self-heal. Never use repo-ID-prefix pruning across hosts. + +The shared renderer store and web preload retain their existing non-direct-SSH callers. Preserve the legacy/runtime overload and its argument/echo shape in `web-preload-api.ts`, but do not broaden the web client into direct-SSH coordination: paired web clients cannot subscribe to desktop `ssh:state-changed`, so the proposed total direct-SSH web break is not reachable. Shared API type changes still receive compatibility tests so a runtime-routed web read is not rejected merely because the wrapper dropped its requested host. + +### 2. Exact direct SSH target scope + +Add `src/renderer/src/lib/direct-ssh-target-scope.ts`: + +```ts +type DirectSshGitRepoRef = { + repoId: string + executionHostId: SshExecutionHostId +} + +type DirectSshTargetScope = { + catalogRevision: number + gitRepos: DirectSshGitRepoRef[] + gitWorktreeIds: Set + terminalWorkspaceKeys: Set + lineageWorkspaceKeys: Set + ambiguousOwnerCount: number + contradictoryOwnerCount: number +} +``` + +Resolution rules: + +- Build the expected host with `toSshExecutionHostId(targetId)`. +- Resolve repos by `(repo.id, executionHostId)`. +- Collect explicit worktree host, exact repo-derived host, projected runtime owner, and any restored host evidence before selecting a row. +- If two present sources disagree, classify the row as `contradictory-owner`; preserve it and exclude it from refresh, merge, terminal retry, snapshot projection, and pruning. +- When explicit worktree host is present and no source contradicts it, require it to equal the expected host. Use exact repo-derived ownership only when the explicit worktree host is absent. +- Require `getExplicitRuntimeEnvironmentIdForWorktree` to be `null`; an explicit runtime owner contradicts direct SSH even when another source names the expected SSH host. +- Do not use focused-runtime fallback ownership during pre-catalog reconnect. Focus-only local/runtime results and `runtime:unresolved-owner` are ambiguous. +- Do not use `buildWorktreeByIdIndex`, raw `getKnownWorktreeById`, or another first-wins ID-only lookup as authoritative direct-SSH ownership. Hydration and reattach use an exact host-qualified row or fail closed. +- Accept a folder workspace only when its effective connection is exactly `targetId`, its execution host is expected, runtime owner is `null`, and all candidate repo/group/workspace provenance agrees. +- Treat mixed/conflicting folder provenance as contradictory; duplicate same-host owners and unresolved legacy rows are ambiguous/unowned. +- A parsed live app-SSH PTY can recover a stale-catalog terminal only when no explicit other-host or runtime ownership contradicts it. + +Git refresh uses `gitRepos`; terminal clear/retry uses raw Git IDs and folder keys from `terminalWorkspaceKeys`; unified lineage uses `worktree:` and folder keys from `lineageWorkspaceKeys`; snapshot projection uses `gitWorktreeIds`. Folder workspaces never enter the path-based remote-workspace schema. + +When exact repo rows are missing, use a new host-scoped desktop catalog read rather than focused `fetchRepos()` or an all-desktop refresh: + +```ts +listReposForExecutionHost({ + executionHostId, + expectedAuthority +}) +``` + +Main validates explicit and legacy ownership before producing the host snapshot. A row with contradictory `executionHostId` and `connectionId` cannot be filtered into one host by precedence or silently omitted from the other; a contradiction touching the requested host returns a non-authoritative catalog with no rows. + +The renderer merges the immutable response into only that host scope after a full-authority fence. This action owns a per-host catalog revision and in-flight entry; it does not share `reposFetchGeneration` with focused-runtime or all-host fetches. Thus a concurrent runtime-focused catalog refresh cannot silently supersede direct SSH hydration. +Its authoritative response uses the same `AuthoritativeHost` discriminant as detected worktrees and lineage, so direct-SSH catalog data also requires the complete pair. + +### 3. Bounded and cancelable direct SSH provider scheduler + +Add `src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts`. It is owned by the `useIpcEvents` effect and used only for direct SSH coordinator scans. + +Required behavior: + +- at most `DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY` (five) locally unsettled coordinator-owned detected-worktree requests in flight; +- owner-aware round-robin selection by target, with repos submitted incrementally rather than flattening one target into a global FIFO; +- no fixed collection window for an idle singleton request; +- an input key of `(repoId, executionHostId, providerEpoch, connectionGeneration, catalogRevision, authoritative requirement)`; +- join only a currently running logical repo task with the exact same key; +- retain the logical key across a first `retrying` timeout and delete it only on a terminal outcome; +- explicit `complete`, `non-authoritative`, `timed-out`, `cancel-budget-exhausted`, `canceled`, `stale`, and `rejected` outcomes; and +- no console error or degraded count for expected cancellation/supersession. + +Each provider invocation has the existing 30-second main-owned deadline. Main creates an `AbortController`, passes its signal to `SshGitProvider.listWorktrees`, and therefore reaches the multiplexer `rpc.cancel` path on timeout. A transient first timeout changes the repo task to `retrying` and requeues it once at the tail of that target's round-robin lane if the full authority remains current. That repo task promise does not settle, and target lineage/token creation does not start, until the retry completes, reaches its second timeout, is invalidated, or retry admission terminates as `cancel-budget-exhausted`. Add cancellation IPC keyed only by `providerRequestId`; authority advance, target invalidation, last-waiter release, and effect teardown abort matching main requests. Queued requests have no provider request ID and cancel without IPC. + +The first timed-out provider invocation and its leases settle before retry admission. The logical repo task remains pending and acquires a fresh provider request ID plus fresh waiter leases for the retry; preparation waiters never reuse an already-canceled provider identity. + +Cancellation uses waiter leases: + +- `waiterLeaseId` and `providerRequestId` are different opaque types generated independently. A provider request ID names one underlying preload/main/provider invocation; every consumer of that shared invocation receives its own renderer-only waiter lease ID. +- The shared detected-worktree coalescer, not the direct coordinator alone, owns the lease registry so sidebar/filesystem consumers also keep a joined provider invocation alive. +- Canceling or superseding one lease settles only that consumer. It does not call cancellation IPC while any other lease remains. +- Releasing the last lease sends exactly one cancellation IPC carrying the provider request ID and captured host/authority. Main never receives, stores, or accepts waiter lease IDs. +- Main aborts the provider request when that cancellation identity matches or when provider authority is invalidated. It does not reconstruct waiter ownership. +- Local cancellation returns after waiter settlement and, for the last lease, main provider-promise settlement. `ssh-channel-multiplexer` rejects its local provider promise when it sends fire-and-forget `rpc.cancel`; no relay response is awaited. +- The scheduler releases the local slot when the underlying provider promise settles locally. The original relay handler may observe abort later, so this metric is not a hard relay-process concurrency claim. +- Track every locally canceled underlying call as conservative cancel debt on its provider instance. Admission requires `locallyUnsettled + cancelDebt <= DIRECT_SSH_PROVIDER_START_BUDGET` (seven), so five canceled calls permit at most two replacements and repeated cancel/retry cannot create unbounded client-originated work. Debt is not cleared by elapsed time or local promise settlement because neither proves relay completion; it clears only when the owning provider/multiplexer is disposed or replaced. A logical task denied admission by this budget settles terminally as `cancel-budget-exhausted`; it never waits for provider replacement and never leaves the preparation barrier pending indefinitely. A hard bound on handlers surviving disposal is impossible without a relay acknowledgement, so telemetry states this as a seven-start per-provider budget rather than a total remote-process guarantee. + +```ts +type DetectedWorktreeRefreshLease = { + waiterLeaseId: WaiterLeaseId + providerRequestId: ProviderRequestId + result: Promise + release(reason: 'superseded' | 'invalidated' | 'stopped'): void +} +``` + +Each lease has its own settlement promise. Normal provider settlement resolves all remaining leases and removes the provider entry automatically; early `release` is idempotent and settles only that lease as canceled. + +Every other preparation await is bounded too: host-scoped catalog and lineage IPC use five-second deadlines, workspace hydration retains its ten-second deadline, and existing remote-workspace RPC deadlines remain in force. Catalog and lineage waiters accept coordinator cancellation; because their main work does not launch a provider process, a late reply is discarded by the renderer fence rather than holding a scheduler slot. + +The existing runtime project scheduler keeps its own five-worker pool, 250 ms debounce, and 5-second minimum interval. Sidebar and filesystem-event refreshes remain unchanged. Cross-subsystem isolation is intentional; the coordinator's bound is not presented as a renderer-wide bound. + +Keep the public `detectedWorktreeRefreshKey` shape unchanged. Its in-flight entry records a provider invocation identity and a lease map; direct-SSH authoritative work may join only when host and full authority also match. An incompatible entry under the same public key gets a separate underlying invocation rather than an unsafe join. The coordinator's authority/revision key wraps this coalescer without fragmenting compatible sidebar or filesystem-event sharing. The shared coalescer owns provider request IDs and waiter leases; the coordinator owns authority/revision fencing, retry state, and scoped metrics. + +### 4. Per-target reconnect coordinator + +Add `src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts`, instantiated once in `useIpcEvents`: + +```ts +type DirectSshAuthority = { + targetId: string + providerEpoch: SshProviderEpoch + connectionGeneration: number +} + +type PreparationInput = DirectSshAuthority & { + catalogRevision: number + repoRefs: DirectSshGitRepoRef[] + authorityRequirement: 'required' | 'allow-metadata-fallback' + snapshotRevision?: number + reason: 'reconnect' | 'initial-hydration' | 'workspace-snapshot' | 'wake-refresh' +} + +type DirectSshReconnectCoordinator = { + requestReconnect(authority: DirectSshAuthority): Promise + prepareOnly(input: PreparationInput): Promise + finalizeHydratedTerminals(authority: DirectSshAuthority): number + correctUnboundTerminals(authority: DirectSshAuthority, reason: CorrectionReason): number + replaceAuthority(authority: DirectSshAuthority): void + invalidate(targetId: string): void + stop(): void +} +``` + +There is no global reconnect wave and no authority-long prepared-outcome cache. Transient per-tab pending/live-binding state is separate and exists only to settle or re-arm terminal recovery. + +`replaceAuthority` compares the complete authority tuple. An exact-equal replacement is a no-op: it does not settle leases, clear pending/live terminal state, cancel provider work, or fragment an overlapping preparation. Only a different tuple performs authority replacement. + +#### Reconnect-finalization flow + +```ts +async function requestReconnect(authority) { + if (!isCurrentConnectedAuthority(authority)) return stale() + + // Authority replacement first settles all obsolete local leases and fences their late work. + coordinator.replaceAuthority(authority) + + // A missed disconnect can leave a non-null PTY from the old provider incarnation. + invalidateStaleDirectSshTargetPtyBindings(authority) + + // Terminal-critical and synchronous: no catalog, Git, lineage, or other target can gate it. + const retried = retryDirectSshTargetPanes(authority) + + // Relay flapping never delays terminal recovery, but it does damp full Git preparation. + if (!hasAuthorityBeenStableFor(authority, RELAY_LOST_STABILIZED_MS)) { + scheduleLatestAuthorityPreparation(authority, RELAY_LOST_STABILIZED_MS) + return terminalOnlyOutcome(retried, 'stabilizing') + } + + const input = await capturePreparationInput(authority, 'reconnect') + if (!input.ok) return terminalOnlyOutcome(retried, input.reason) + + const prepared = await prepare(input.value) + if (!prepared.token || !isCurrentConnectedAuthority(authority)) { + return combine(retried, prepared) + } + + // Catch tabs whose exact ownership or hydration became visible during this target's preparation. + const discoveredRetries = correctUnboundTerminals(authority, 'preparation-complete') + void syncRemoteWorkspaceAfterConnect(prepared.token) + return combine(retried + discoveredRetries, prepared) +} +``` + +The first terminal retry is complete before `capturePreparationInput` performs any await. Target B therefore retries even when target A has five slow provider requests. A same-authority duplicate runs bounded correction: healthy live bindings are no-ops, pending attempts are not duplicated, and failed/unbound tabs can re-arm. When workspace hydration completes, `finalizeHydratedTerminals` reruns against the current authority and handles newly hydrated or still-unbound tabs. + +Preparation for each target progresses independently. While authority remains current, it performs its host-scoped lineage read only after every repo task reaches a terminal state: `complete`, `non-authoritative`, final `timed-out`, `cancel-budget-exhausted`, or `rejected`. Authority-wide `canceled`/`stale` returns without lineage or a token. A first retryable timeout is the nonterminal `retrying` state and cannot release lineage, terminal correction, sync, or token creation. The target never waits for another target's repos or lineage. + +When authority rotates again within `RELAY_LOST_STABILIZED_MS` (currently five seconds), replace the delayed preparation with the latest authority and perform terminal finalization immediately. Only the authority that survives the stabilization window starts catalog/Git/lineage work. A same-authority wake during that window coalesces into the pending latest-authority preparation. This is damping, not an epoch-long result cache. + +#### Preparation-only flow + +`prepareOnly` runs catalog/worktree/lineage preparation and returns a token. It never invokes terminal retry and never starts reconnect sync. An unsolicited snapshot uses this mode, then applies that snapshot with the returned token. + +Reconnect and preparation-only requests may share exact overlapping catalog, repo, or lineage promises through ref-counted waiter leases. Superseding one consumer settles only its lease; it does not abort work still owned by another current consumer. They do not share finalization side effects. + +#### Input-scoped deduplication + +Preparation captures concrete inputs: exact authority pair, catalog revision, sorted repo/host refs, authoritative requirement, and snapshot revision when present. Only currently overlapping operations with identical relevant inputs join. + +Completed entries are removed immediately. Consequently: + +- a later same-authority wake rebroadcast runs another bounded refresh; +- a later snapshot revision runs another bounded preparation; +- a newly discovered repo changes the catalog revision/repo fingerprint and cannot join an older scope; +- a snapshot received during reconnect can share the still-running provider reads; and +- the reconnect call chain can pass its completed token directly to sync/apply without a second preparation. + +The token is an operation result, not a coordinator cache entry: + +```ts +type DirectSshPreparationToken = { + authority: DirectSshAuthority + catalogRevision: number + repoFingerprint: string + authorityRequirement: PreparationInput['authorityRequirement'] + snapshotRevision: number | null + outcome: 'complete' | 'degraded' +} + +type SnapshotApplyToken = DirectSshPreparationToken & { + snapshotRevision: number +} +``` + +An unsolicited snapshot passes its revision into `prepareOnly`, so the returned token is already snapshot-bound. Reconnect preparation returns `snapshotRevision: null`; after `remoteWorkspace.get`, sync revalidates authority and creates a `SnapshotApplyToken` by copying the fetched revision onto that token. `applyRemoteWorkspaceSnapshot` accepts only `SnapshotApplyToken` and requires exact revision equality, so it cannot reuse preparation across incompatible snapshots. + +### 5. Fenced worktree, catalog, and lineage merges + +Refactor detected-worktree listing so provider acquisition and store mutation are separate: + +```ts +const listed = await listDetectedWorktreesForRepoCoalesced(request) +if (!isCurrentHostAuthority(request, listed)) return canceled('stale') + +// No code using listed.result may occur above this fence. +return mergeDetectedWorktrees(listed.result, request) +``` + +`mergeDetectedWorktrees` performs the sole renderer mutation for that result. It owns git-identity routing, review-link sanitation, purge state, and both worktree maps in one fenced path. It must not start an unfenced lineage refresh. + +The existing shared coalesced refresh key already includes `executionHostId` and remains unchanged. Add the full authority and concrete input revision only to the coordinator wrapper key. Do not describe host parsing as new key behavior and do not alter cross-subsystem coalescing. + +Host-scoped catalog and lineage functions likewise return immutable results. Each has one merge entry point that revalidates the current exact authority inside the Zustand updater. A stale result produces zero publications. + +Main uses the same pattern around provider work: + +```ts +const authority = resolveExactProvider(args) +const gitWorktrees = await authority.provider.listWorktrees(repo.path, { signal }) +if (!stillOwnsExactProvider(authority, args.expectedAuthority)) return stale() + +return buildAndCommitAuthoritativeResult(authority, gitWorktrees) +``` + +No prune, root-memory update, or metadata backfill occurs before `stillOwnsExactProvider`. + +### 6. Atomic terminal disconnect and retry + +Add terminal-slice actions: + +```ts +clearDirectSshTargetPtyBindings(targetId: string): number +invalidateStaleDirectSshTargetPtyBindings(authority: DirectSshAuthority): number +retryDirectSshTargetPanes(authority: DirectSshAuthority): number +settleDirectSshPaneRetry(result: DirectSshPaneRetryResult): void +``` + +Put pure projections in `src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts`. + +`clearDirectSshTargetPtyBindings` traverses `tabsByWorktree` once, selects exact target scope, then applies the `tab.ptyId != null` predicate. For every affected tab the same atomic projection sets `tab.ptyId` to `null`, empties its `ptyIdsByTabId` entry, consumes `pendingActivationSpawn`, and removes pending Codex restart/restart-notice entries for the cleared live PTY. It preserves `lastKnownRelayPtyIdByTabId`, layouts, deferred SSH sessions, pending reconnect IDs, shutdown/suppression state, IDs, titles, generations, and agent state. It lazily clones only changed workspace arrays and maps, commits one patch, and triggers no activity, sorting, or metadata persistence. Tabs without a current `ptyId`, including those with `pendingActivationSpawn`, are untouched. + +`invalidateStaleDirectSshTargetPtyBindings` validates the authority inside the updater and applies that complete atomic projection to a non-null `ptyId` when its transient `ptyAuthorityByTabId` does not equal the current authority. Snapshot-imported or legacy bindings without current-authority provenance are wake hints, not live bindings. + +`retryDirectSshTargetPanes` validates the exact authority inside the updater, resolves scope from that same state snapshot, applies `shouldRetryPaneSpawnOnSshReconnect` plus stale-binding evidence, excludes only current live-success and pending-attempt tab IDs, and commits one `tabsByWorktree` patch. It records a unique tab-wide attempt outside the success ledger. `settleDirectSshPaneRetry` records current-authority success only after a live PTY binding is committed. A failed or timed-out first attempt rotates the tab once; an exhausted second attempt retains continuation authority for sibling callbacks but cannot start a third attempt. + +`clearTabPtyId` keeps genuine single-PTY exit semantics, but split recovery adds two exact-authority projections: promote an already-bound surviving PTY under the same lease, or preserve the lease and activation suppression across an empty-primary gap until a same-attempt sibling commits or settles. `syncPaneDetachPtyOwnership` likewise projects one current split lease and history to both resulting tabs without spawning, exiting, or changing authority, even when the pending attempt has not produced its first PTY. A pane mounted after the first sibling succeeds captures the retained live lease; every spawn callback rechecks the full authority pair and retires a stale provider PTY. Permanent target removal continues through `src/renderer/src/store/slices/ssh-target-cleanup.ts`, whose deletion of last-known and deferred liveness is invalid for a reconnectable disconnect. + +`applySshConnectionStateChange` receives an explicit origin and becomes orchestration: + +```ts +type SshStateApplyOrigin = 'push' | 'initial-hydration' + +const applySshConnectionStateChange = (targetId, state, origin: SshStateApplyOrigin) => { + const previous = getSshConnectionState(targetId) + setSshConnectionState(targetId, state) + + if (isTerminalFailure(state.status)) { + coordinator.invalidate(targetId) + clearRemoteDetectedAgents(targetId) + clearPortForwards(targetId) + setDetectedPorts(targetId, []) + clearDirectSshTargetPtyBindings(targetId) + return + } + + if (state.status === 'connected') { + if (!state.providerEpoch || state.connectionGeneration === undefined) { + void reconcileAuthorityOnce({ + targetId, + initiatingEventWatermark: getStateEventWatermark(targetId), + initiatingState: state, + initiatingOrigin: origin + }).then(applyAuthorityPatchIfStillCurrent) + } else { + const authority = { + targetId, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } + + if (origin === 'initial-hydration') { + coordinator.replaceAuthority(authority) + void coordinator.prepareOnly({ + ...authority, + authorityRequirement: 'required', + reason: 'initial-hydration' + }) + } else if ( + previous?.status !== 'connected' || + previous.providerEpoch !== state.providerEpoch || + previous.connectionGeneration !== state.connectionGeneration + ) { + void coordinator.requestReconnect(authority) + } else { + coordinator.correctUnboundTerminals(authority, 'wake-refresh') + void prepareAndSyncWithoutHealthyTerminalRemount({ + ...authority, + reason: 'wake-refresh' + }) + } + } + } +} +``` + +Increment the per-target state-event watermark before applying every push event. `setSshConnectionState` must publish when either authority component changes. Reconciliation may only patch missing authority onto the still-current initiating state; it never enqueues a complete stale state reply. + +The terminal-failure cleanup order remains `clearRemoteDetectedAgents`, `clearPortForwards`, `setDetectedPorts([])`, then atomic PTY binding clear. Preserve the current port-broadcast race defense and detected-agent re-detection behavior. + +An already-connected same-authority rebroadcast is a wake refresh, not a healthy-pane remount. It performs bounded unbound/stale correction, fresh bounded preparation, and sync. Initial hydration always carries `origin: 'initial-hydration'` and uses `prepareOnly`; it is never inferred from the absence of `previous`. A bounded authority reconciliation retains the initiating origin, so filling a retained state's missing pair also cannot become a reconnect transition. Hydrating an already-live state must not remount healthy panes. Workspace session completion invokes `finalizeHydratedTerminals` only for reconnect authorities recorded during that hydration interval, but a failed/unbound attempt remains eligible for later same-authority triggers. + +## Remote-workspace data flows + +### Reconnect sync + +1. Reconnect finalizes qualifying terminals immediately. +2. Its target-scoped preparation returns a token. +3. Before awaiting `remoteWorkspace.get`, `syncRemoteWorkspaceAfterConnect(token)` captures whether the exact target currently has local tabs. This capture-before-await ordering is load-bearing. +4. The function validates the token, obtains the snapshot, and passes the same token to `applyRemoteWorkspaceSnapshot`. +5. Snapshot apply does not prepare again and is preparation-only with respect to coordinator retry; existing snapshot-driven `reconnectPersistedTerminals` behavior remains. +6. For `revision === 0`, mark hydration and publish the current local session only when the pre-await `hasLocalTabs` capture was true. Revalidate authority before upload. Do not recompute that predicate after preparation/hydration, which could overwrite a newer relay snapshot with locally imported state. +7. Snapshot projection and persisted-terminal reconnect receive only host-qualified worktree references from the token's exact target scope. They cannot reset, replace, remove, or reattach sibling SSH, local, WSL, or runtime-owned tabs or their PTY indexes, layouts, active selection, generations, retry state, or live-binding state, even when raw repo/worktree IDs or paths collide. + +### Unsolicited snapshot + +1. Capture current target authority and the incoming snapshot revision. +2. Call `prepareOnly` with those concrete inputs. +3. Wait for existing workspace-session hydration, bounded by the current 10-second deadline. +4. Capture the target terminal-recovery revision and same-ID local tab recovery fields before snapshot projection. +5. Revalidate the token, snapshot revision, arrival order, and recovery revision immediately before merge. If recovery advanced, rebase the projection on the latest local recovery fields rather than applying the stale captured copy. +6. Apply once as preparation-only. For a same-ID tab, preserve any newer local `generation`, pending attempt, successful current-authority binding, and terminal-recovery revision. Remote `generation` is not comparable across clients and cannot overwrite a local retry. Imported `ptyId`/pending reconnect data is a wake hint until `reconnectPersistedTerminals` settles and current-authority binding provenance is recorded. +7. Run the target-scoped, host-qualified `reconnectPersistedTerminals`, then `finalizeHydratedTerminals` for a reconnect authority. A failed reattach clears pending state and re-arms correction; a successful exact reattach atomically retires its pending attempt and records the current binding without an extra bump. +8. If path resolution still reports unknown worktrees, record a degraded result; do not loop unboundedly. + +A later same-authority snapshot always receives a new preparation attempt after earlier work completed. This preserves convergence when another client creates a worktree while the connection stays live. + +Remote snapshot identity is repo-qualified where the schema provides repo identity: resolve by `(executionHostId, repoId, normalizedPath)` and fail closed on ambiguity. For legacy path-only entries, retain the existing resolver only when the path has exactly one candidate in the exact target scope. Two worktrees at the same absolute host path were not proven producible, so this is a robustness invariant rather than a claim that current storage necessarily creates that collision. + +The existing `buildWorktreeByIdIndex` and `reconnectPersistedTerminals` path retains first-wins ID-only compatibility for its current callers. Direct-SSH snapshot apply must pass host-qualified worktree refs through a dedicated overload or pre-resolved map; it cannot hand raw IDs back to an ID-only lookup and recover authority afterward. + +## Failure handling + +- **Unknown authority:** reconcile once behind the per-target arrival watermark; then fail closed with a retryable diagnostic. Do not use a renderer counter or apply the reply's status. +- **Catalog timeout/failure:** retain cached exact-owner scope, record degradation, and continue target preparation and already-completed terminal recovery. +- **One repo is non-authoritative:** keep its safe metadata fallback separate from operational failure and do not authoritatively delete rows. +- **One repo times out:** the provider invocation settles locally and sends best-effort cancel. On the first retryable timeout, keep the logical repo task and target preparation barrier pending in `retrying`, then requeue once at the target lane tail. A second timeout is final and degrades the repo. If current-provider cancel debt denies retry admission, settle the logical task as terminal `cancel-budget-exhausted`; this releases one degraded preparation outcome rather than an early-success token or an indefinite wait. None of these states blocks another target's terminal retry. +- **One repo rejects:** locally settle it without retry unless classified by the existing narrow transient predicate; operational rejection remains distinct from timeout and non-authoritative data. +- **Lineage timeout/failure:** preserve current lineage, mark target preparation degraded, and continue sync with exact cached worktree scope. +- **Authority advance or target invalidation:** synchronously cancel queued work, settle obsolete local waiter leases, send exactly one cancellation for every affected in-flight provider request ID, rotate renderer in-flight inputs and terminal-attempt state, and reject late results at both main and renderer fences. The new authority begins terminal finalization without waiting for relay acknowledgement. +- **Relay flapping:** finalize terminals for every new authority, but replace/defer full preparation until the latest authority survives `RELAY_LOST_STABILIZED_MS`. +- **Missed disconnect/stale binding:** clear only PTY bindings whose transient binding authority is absent or old, retain last-known relay identifiers, then retry under the new authority. +- **Failed terminal spawn/reattach:** settle the attempt as failed, remove it from pending/success state, and allow rate-limited correction on later preparation, hydration, snapshot, or wake triggers. +- **Workspace hydration timeout:** set existing per-target sync error; do not undo terminal or worktree recovery. Snapshot merge never replaces a newer local recovery revision. +- **Coordinator stop:** cancel queued work, locally settle every waiter, send best-effort aborts for unshared provider calls, and return without waiting for relay acknowledgement. Effect cleanup stops coordinator after subscriptions and before disposing its dedicated scheduler. +- **Unknown owner:** preserve state, count it, and retry on later host-qualified catalog input. +- **Contradictory owner:** preserve state, report the conflicting provenance classes without identifiers, and do not retry or mutate that row until a later catalog revision changes the evidence. + +No failure path falls back to unbounded `Promise.all`. + +## Observability + +Emit one aggregate diagnostic per target operation, not per global wave and not per repo/tab. Use `[direct-ssh-reconnect]` for reconnect finalization and `[direct-ssh-prepare]` for preparation-only work. + +Fields: + +- mode and reason; +- terminal panes retried, stale bindings cleared, successful corrections, and terminal-finalization duration; +- catalog outcome and duration; +- repo tasks completed, non-authoritative, retrying, final timed-out, cancel-budget-exhausted, canceled, stale, and rejected; +- direct-scheduler queue-wait and provider-execution duration distributions; +- timeout retry count, local waiter settlements, cancel debt, and replacement admissions delayed by cancel debt; +- peak locally unsettled coordinator-owned detected-worktree concurrency and estimated late-work allowance; +- lineage outcome; +- Git-worktree, folder-workspace, ambiguous-owner, and contradictory-owner counts; +- overlapping request joins; +- authority rotations observed and preparations damped during flapping; +- total target-operation duration. + +Expected supersession/cancellation is debug-level and does not increment degraded/error metrics. Timeout is separate from queue wait and operational rejection. `fetchWorktrees === false` is not used as a failure proxy; the new discriminated result preserves non-authoritative versus rejected outcomes. + +Do not log target IDs, repo IDs, paths, labels, hosts, usernames, credential errors, snapshot content, raw request IDs, or terminal output. A stable per-session opaque target alias may correlate concurrent aggregate events and is discarded at process exit. + +The product event deliberately omits terminal correction failure/re-arm counts, concurrent non-coordinator call counts, and arrival-order discard counts because this implementation has no truthful production observation for them. It does not populate unobserved fields with constant zeroes. + +Typed product telemetry is implemented with one strict identifier-free aggregate event per target operation. Queue and provider percentiles are derived from that operation's real scheduler samples. The seven-day dogfood dashboard/query is operational follow-up outside this repository; this PR does not claim a dashboard artifact. Schema tests reject identifiers and keep queue wait, execution, timeout, operational rejection, cancellation, and stale results distinct. + +The concurrency metric is explicitly `coordinator_owned_direct_ssh_detected_worktree_concurrency`. It measures locally unsettled provider promises and says nothing about runtime lineage RPCs, sidebar's eight-worker pool, filesystem-event calls, catalog/lineage IPC, total application provider concurrency, non-coordinator same-connection calls, or relay handlers finishing after local cancel. + +## Tests + +This implementation registers the worktree scan-count, host/authority, timeout-barrier, and no-cross-host mutation oracles in the existing `git-worktree.refresh-event-semantics` gate. It extends `terminal-provider.ssh-remote-reattach-contract` for direct-SSH binding clear/retry, hydration, folder workspace, paired-close non-interference, and #8255 wake isolation. A new reliability gate is unnecessary because those existing gates own the lifecycle contracts. + +### Main/preload host and authority contract + +- Preload forwards `repoId`, `executionHostId`, the complete expected authority, and `providerRequestId` unchanged; main owns the deadline and never receives `waiterLeaseId`. +- Renderer host intent across local, direct SSH A/B, and runtime-alias catalog rows routes to the exact provider. Fabricated duplicate main-store rows still fail closed as defense in depth. +- Desktop main rejects a runtime execution host. +- Zero or multiple same-host matches fail closed; unqualified legacy calls fail closed when ownership is ambiguous. +- Compile-time fixtures cannot construct a direct-SSH `complete` result or `authoritative: true` lineage snapshot without both authority fields. Runtime admission rejects malformed wire values that omit either field. Local authoritative variants carry neither SSH field. +- A response echoes exact host and complete authority; renderer rejects any host, target, epoch, generation, provider request, or discriminant mismatch. +- Relay loss/replacement, transport loss/replacement, provider disposal, target readoption, and permanent removal rotate epoch and generation in one helper. +- A per-target generation counter exhaustion rolls the process generation scope, revokes every target's old authority and mutation token, and aborts every old-scope provider request before new-scope admission. +- Relay-only replacement rejects both old reconnect/provider work and an old `SshMutationExpectation`. +- A fresh `connected` authority is not broadcast until its provider is registered. +- An old provider result after any rotation performs no root-memory, lineage-prune, or metadata-backfill mutation. +- Cancellation and timeout reach the provider `AbortSignal` and multiplexer `rpc.cancel`. +- Duplicate repo IDs never trigger cross-host repo-prefix lineage pruning. +- Legacy lineage prunes under a unique repo owner, ambiguous legacy rows are preserved, and an authoritative scan backfills `meta.hostId`. +- `sshConnectionStatesEqual`, retained-payload admission, public projection, preload push/get, startup reconnect, runtime retained/client payloads, renderer/runtime stores, and state builders preserve both authority fields. +- Main-originated broadcasts always publish the complete authority pair. Malformed epoch-only or generation-only retained inputs are rejected; compatibility push/get inputs can only remain `authority-unknown` for bounded reconciliation and perform no authoritative mutation. One valid pair change publishes once and an exact duplicate is a no-op. +- Retained-state admission accepts a bounded valid epoch, rejects malformed/oversized epochs, and never strips a valid authority component. +- The web preload compatibility overload preserves requested-host echo for runtime reads; this does not enable direct-SSH coordination in paired web clients. +- A stale `ssh:getState` reply arriving after a disconnect/reconnect push cannot change status or fill authority; a same-watermark reply can fill only missing authority. +- Main host-catalog admission rejects a row whose explicit execution host contradicts its legacy connection host; it returns no authoritative rows instead of selecting or hiding that row by precedence. + +### Renderer worktree/catalog/lineage fences + +- The coordinator in-flight key differs by host, full authority, catalog revision, and authoritative requirement; the shared coalescer key remains unchanged. +- Inputs that differ only by `authorityRequirement` do not join, and the resulting token echoes that exact requirement. +- Exact overlapping requests join; the same key after completion runs again. +- An old result after disconnect/reconnect or relay-only replacement causes zero store publications. +- Git identity, hosted-review links, purge state, both worktree maps, and best-effort lineage remain byte-identical on stale results. +- Local, another SSH target, and runtime owners with the same repo ID remain unchanged. +- Target snapshot hydration and persisted-terminal reconnect use host-qualified worktree references, prune the retry/live/history ledgers of tabs authoritatively deleted from that exact scope, and leave retained or sibling SSH, local, WSL, folder, and runtime tabs, PTY indexes, layouts, active state, generations, and recovery ledgers unchanged. +- A renderer worktree result is admitted only while every current same-ID repo row still has valid, non-contradictory explicit/legacy ownership; malformed or contradictory provenance introduced during the provider await makes the result stale without store mutation. +- A target-scoped catalog fetch cannot be superseded by focused-runtime `fetchRepos`. +- Host-scoped lineage deletes a stale direct SSH row while preserving local, another SSH target, runtime, and unknown-owner rows. +- Runtime-focused UI state cannot redirect direct SSH catalog, worktree, or lineage ownership. +- An ID-only first-wins hydration index containing the same worktree ID under SSH A and SSH B is never authoritative for direct reconnect; a target-B snapshot reattaches only the host-qualified B row or fails closed. +- Pre-catalog direct SSH scope with a focused runtime treats focus fallback as ambiguous and still recovers tabs supported by explicit PTY/host provenance. +- An explicit `ssh:B` worktree with repo-derived `ssh:A` is `contradictory-owner` for both targets and remains byte-identical. The inverse mismatch, an SSH host plus explicit runtime owner, and conflicting folder group/repo connections fail the same way. +- Repo-derived ownership is accepted only when explicit worktree ownership is absent; adding a conflicting explicit owner converts the same row from accepted to preserved/contradictory without cross-host deletion. +- Git 2.25-compatible worktree fallbacks remain unchanged. + +### Direct SSH scheduler/coordinator + +- A singleton reconnect begins terminal finalization immediately without a collection delay. +- Target B terminal finalization completes while all five direct provider slots are occupied by target A. +- Target B's first repo starts after at most one bounded provider deadline when it arrives behind five A calls. +- Round-robin admission prevents a large target from continually reoccupying every released slot. +- Peak coordinator-owned detected-worktree calls is five; runtime/sidebar activity can raise total app concurrency without failing this assertion. +- Joined consumers receive distinct waiter lease IDs and one shared provider request ID. Canceling one settles only that lease while the other completes from the original call; no provider cancel is sent. +- Last-waiter release sends exactly one cancellation for that shared provider request ID. Authority invalidation sends exactly one cancellation for each affected in-flight provider request, including multi-repo preparation; no cancellation is sent merely because one lease leaves while another current-authority lease still owns that invocation. A waiter lease ID presented to main is rejected and cannot abort provider work. +- A coordinator lease joining sidebar/filesystem work cannot abort that work when the coordinator is superseded; the remaining non-coordinator lease keeps the provider request alive. +- A timeout sends `rpc.cancel`, settles locally, and releases its local slot without a relay acknowledgement. +- Repeated timeout replacements never exceed the two-call cancel-debt allowance; denied work settles as terminal `cancel-budget-exhausted` and is reported separately. +- After a first retryable timeout, the repo task is `retrying`: lineage calls, preparation tokens, sync, and snapshot apply remain at zero until the retry settles. +- A successful retry then releases one lineage read and one token; a second timeout or cancel-budget exhaustion releases a degraded result without starving another target. +- An exact-equal `replaceAuthority` call preserves every pending lease, live binding, and overlapping preparation and sends no cancellation. +- A same-authority reconnect rechecks terminals but does not rebump a healthy current-authority binding. +- A newly hydrated tab receives one retry later under the same authority. +- A tab whose exact ownership becomes visible during that target's preparation receives one retry without delaying the first terminal finalization. +- An authority advance with five old slow calls cancels queued work, settles/aborts old leases, starts new terminal finalization immediately, admits new work under cancel-debt rules, and permits zero old main/renderer mutations. +- A changed authority supersedes old target work by arrival, without numeric comparison. +- A same-authority connected rebroadcast performs bounded correction plus preparation/sync without retrying healthy terminals. +- A failed or timed-out pane on attempt one rotates the tab once; attempt-two failure cannot start attempt three or revoke continuation authority from siblings already settling. +- Three authority rotations inside the stabilization window perform three immediate terminal checks but only one full preparation for the final stable authority. +- `prepareOnly` never invokes terminal retry or reconnect sync. +- A preparation-only request shares exact in-flight repo work with reconnect preparation but not reconnect finalization. +- A completed preparation never suppresses a later same-authority wake or snapshot preparation. +- Stop locally settles queued/in-flight waiters without waiting for relay acknowledgement and prevents post-stop finalization. +- One system resume/browser-online event advances #8255 remote-runtime backoffs and direct-SSH wake preparation independently; runtime-owned SSH rows never enter the direct coordinator, and a direct tab already live under current authority is not double-bumped. + +### Atomic terminal recovery + +- Many live tabs/worktrees clear in one store publication; a second clear is a true no-op. +- Only tabs with `tab.ptyId != null` are changed. +- A null-PTY tab with `pendingActivationSpawn` remains byte-identical. +- A live-PTY tab consumes `pendingActivationSpawn` in the same patch that clears its tab and split-pane PTY indexes. +- `lastKnownRelayPtyIdByTabId` survives and the `#9911` orphan predicate remains reconnectable. +- Live split-pane PTY indexes and Codex restart metadata clear. +- Layouts, deferred sessions, pending reconnect IDs, shutdown/suppression state, titles, and agent state remain unchanged. +- No worktree activity, sorting, or metadata persistence occurs. +- Exact target A clear/retry leaves target B, local, WSL, floating, and runtime-owned tabs unchanged even with duplicate repo IDs. +- Exact direct SSH folder workspaces clear/retry; mixed, ambiguous, and runtime folders do not. +- A parsed target PTY recovers a stale-catalog tab only without contradictory ownership. +- A relay/provider replacement after a missed disconnect clears a pre-authority `ptyId` and retries it while preserving last-known relay IDs. +- Binding provenance for another authority or no provenance is stale; a successful current-authority spawn becomes live and suppresses healthy correction. +- One authority chain permits at most two automatic correction attempts even when each timeout exceeds 30 seconds; later same-authority triggers remain exhausted until authority replacement. +- Rejected stale/mismatched acknowledgements preserve every store map and publish nothing. Concurrent split spawn or reattach acknowledgements share one exact attempt; the first establishes the fallback/live lease, and both already-mounted and post-success-mounted siblings join it. +- Primary exit before a sibling binds preserves the exact continuation lease and activation suppression; the sibling then becomes the fallback without a corrective remount. +- Primary and non-primary split detach both preserve exact authority and retry history on the surviving source and detached destination; an all-null pending-only detach preserves the lease before either side binds, and a same-authority correction leaves both live. +- Disconnect retains exact existing ordering and effects for `clearRemoteDetectedAgents`, `clearPortForwards`, `setDetectedPorts([])`, and atomic PTY clear. +- Disconnect and reconnect emit zero paired `session.tabs.close`/`closeLifecycle`, provider shutdowns, or process signals. +- A manually parked direct-SSH worktree follows the same store patch and retry eligibility without invoking parking, close, or layout mutation. + +### Remote-workspace and hydration integration + +- Connect plus sync performs one logical preparation and passes its token through a nonempty snapshot apply without preparing again. +- An unsolicited snapshot joins exact in-flight preparation but starts a new preparation after that work settles. +- A later same-authority snapshot referencing a newly created worktree resolves and imports its tabs. +- Preparation-only snapshot handling never bumps terminal generations. +- Preparation-only means no coordinator generation bump; existing snapshot-driven terminal reattach remains and is finalized afterward. +- Initial hydration with no previous renderer state follows the explicit `initial-hydration` origin into `prepareOnly` and performs zero reconnect retries; reconciliation retains that origin. +- Reconnect finalized before session hydration retries newly hydrated tabs exactly once afterward. +- Immediate finalization bumps a tab, then a snapshot with the same stable tab ID and an older/absent generation cannot reduce the local generation or suppress correction. +- Snapshot hydration that lands after a newer local retry preserves the newer terminal-recovery revision, pending attempt, and current binding provenance. +- Snapshot-imported `ptyId` without current-authority live evidence remains retry-eligible; successful exact target-scoped `reconnectPersistedTerminals` retires the pending attempt and records it live, while failure re-arms it. +- Snapshot projection and reconnect are host-qualified end to end; another SSH target, local, WSL, and runtime-owned state remain byte-identical despite colliding raw IDs or paths. +- Same-authority wake rebroadcast performs bounded fresh discovery. +- Revision-zero sync captures `hasLocalTabs` before `remoteWorkspace.get`, uploads only on that capture, and revalidates authority before publish. +- Snapshot apply rejects a token whose `snapshotRevision` differs, and reconnect sync can create a `SnapshotApplyToken` only from the snapshot fetched by that same fenced operation. +- Repo-qualified snapshot mapping keeps same-named repo paths isolated; a legacy path-only entry applies only with one exact-target candidate and otherwise fails closed. +- Folder workspace keys never enter snapshot projection. +- Hydration timeout does not undo terminal recovery. + +### Performance and diagnostics + +Seed direct SSH targets, a runtime environment, sidebar refreshes, worktrees, folder workspaces, split tabs, and large terminal maps. Assert: + +- coordinator-owned direct SSH detected-worktree concurrency never exceeds five; +- runtime and sidebar work are excluded from, and may exceed, that scoped count; +- terminal retry is submitted before any provider task and uses one publication; +- disconnect uses one publication and schedules session persistence once; +- queue wait, provider duration, timeout, and cancellation are distinct; +- canceled/stale outcomes do not console-error or increment degraded counts; +- diagnostics contain counts/durations plus only an ephemeral target alias; +- the typed telemetry schema rejects identifiers and emits observed queue/provider distributions, timeout/retry/cancel-debt counts, flapping damping, and successful correction results; and +- the adapter computes bounded p50/p95/p99 values from each operation's real scheduler samples. + +## Implementation map + +- `src/main/ssh/ssh-provider-authority.ts` composes epoch issuance with `ssh-connection-generation.ts`; `src/main/ipc/ssh.ts` rotates on every provider transition, registers before `connected`, and publishes the pair. +- `src/shared/ssh-types.ts`, `ssh-retained-payload-admission.ts`, `runtime-client-events.ts`, and public SSH state projection define, validate, and retain the complete authority. `src/preload/api-types.ts`, `src/preload/index.ts`, startup reconnect, runtime SSH state, and web mutation readers copy it unchanged. +- `src/renderer/src/store/slices/ssh-target-cleanup.ts` and `ssh.ts` make the authority pair equality-significant and patch-preserving. The state-event/reconciliation code in `useIpcEvents` owns arrival watermarks. +- `src/main/ipc/worktrees.ts` and the provider authority module own exact host/provider resolution, the provider-request registry, main deadlines/aborts, post-await fences, qualified lineage pruning, and host metadata backfill. Main has no waiter-lease concept. +- `src/renderer/src/store/slices/detected-worktree-refresh-leases.ts` owns the existing public coalescer key, compatible provider invocation entries, independently generated provider request and waiter lease IDs, ref-counted last-waiter cancellation, and per-lease settlement for every caller. +- `src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts` acquires shared leases and owns the five local slots, target round-robin, authority/revision wrapper key, nonterminal `retrying` state, one timeout retry, and cancel-debt allowance. +- `src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts` owns per-target authority replacement, preparation waiters, stabilization damping, preparation barriers/tokens, pending terminal attempts, success ledger, and bounded corrective triggers. +- `src/renderer/src/lib/direct-ssh-target-scope.ts` uses explicit provenance for Git and folder scope; it never reads focused-runtime ownership. +- Terminal slice actions and `src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts` own atomic clear, stale-binding invalidation, transient PTY authority, attempt settlement, and single-publication retry projection. +- `src/renderer/src/hooks/remote-workspace-target-sync.ts` owns capture-before-await revision-zero push, token/revision fences, repo-qualified legacy-safe resolution, local recovery preservation, snapshot-driven reattach, and post-hydration finalization. +- Typed aggregate telemetry owns the privacy schema, per-operation distributions, and fail-soft histogram emission. External dogfood dashboard/query configuration remains an operational follow-up. + +## Rollout + +The implementation preserves this dependency order: + +1. Add the opaque epoch and `rotateSshProviderAuthority`, expand `connectionGeneration` rotation to the same transition set, and inventory every state equality/copy/preload/retained/reconciliation boundary. Land complete-pair publication, malformed partial-authority rejection, retained-admission, stale-reconciliation, provider-before-broadcast, and old-mutation-expectation tests before any coordinator routing. +2. Add host-qualified detected-worktree and lineage IPC, discriminated authoritative response admission, exact main provider selection, main-owned 30-second deadline, provider-request cancellation, and main pre-mutation fences. Preserve the web/runtime overload without enabling direct-SSH web coordination. Keep the coordinator disabled. +3. Refactor renderer worktree/catalog/lineage reads to immutable results with pre-mutation full-authority fences. Add the host-scoped catalog lane independent of focused-runtime supersession, all-provenance contradiction rejection, legacy lineage host backfill, and shared coalescer leases with distinct waiter/provider IDs. +4. Add exact target scope, atomic disconnect clear, stale-authority binding invalidation, retry-attempt settlement, and transient binding provenance. Switch disconnect and reconnect terminal handling together so Git/folder clear-retry symmetry and detected-agent/port cleanup remain intact in every commit. +5. Add the dedicated fair direct SSH scheduler and per-target coordinator with local-settlement semantics, the first-timeout preparation barrier, one timeout retry, cancel-debt admission, authority-advance invalidation, same-authority correction, and flapping damping. Route connected events through reconnect finalization; leave #8255 remote-runtime recovery and runtime-owned SSH rows unchanged. +6. Extract `prepareRemoteWorkspaceTarget`, `syncRemoteWorkspaceAfterConnect`, and `applyRemoteWorkspaceSnapshot` into `src/renderer/src/hooks/remote-workspace-target-sync.ts`. Split preparation-only from reconnect mode, pass the full operation token through revision-zero push and snapshot apply, preserve newer local recovery state, finalize imported terminal hints, and remove repeated preparation calls. +7. Enable typed aggregate diagnostics/telemetry and dogfood with renderer-catalog owner aliasing, many direct targets, an active focused runtime, sidebar activity, folder workspaces, relay-only flapping, timeout/retry/cancel debt, missed disconnects, failed pane spawns, arrival-order races, system resume/browser online events, and cross-device snapshot changes. + +Stage 5 is gated on stages 2–4. The coordinator must never call catalog or lineage APIs whose ownership depends on `settings.activeRuntimeEnvironmentId`; a partial coordinator-first rollout is forbidden. System-resume/browser-online recovery must not double-bump a tab already live under the direct SSH authority ledger. + +Release checks: + +- zero host/authority mismatch accepted at either main or renderer mutation fences; +- zero authority component dropped at equality, retained, preload, startup, public-state, or reconciliation boundaries; +- zero authoritative direct-SSH result constructible or admitted without the full authority pair; +- no cross-host provider selection in duplicate-ID integration tests; +- no contradictory provenance accepted by OR-matching or repo fallback; +- terminal finalization is scheduled before provider discovery and is not delayed by another target or runtime/sidebar work; +- p95/p99 direct scheduler queue wait and provider duration, plus timeout/retry/cancel-debt rates, are reported separately from same-relay non-coordinator traffic; +- peak coordinator-owned direct SSH detected-worktree concurrency is at most five; +- waiter cancellation is lease-local, last-waiter release sends one cancellation for its provider request, authority invalidation sends exactly one per affected in-flight provider request, provider cancellation settles without relay acknowledgement, and cancel debt remains bounded; +- no lineage read, preparation token, or sync starts while a first timed-out repo remains retrying; +- authority advance invalidates old queued/in-flight work and relay flapping produces only one stable preparation wave; +- missed disconnect, failed spawn, and hydration overwrite cases converge through bounded same-authority correction; +- later same-authority snapshots containing new worktrees converge; +- revision-zero push preserves capture-before-await ordering and stale reconciliation cannot resurrect connected state; +- direct SSH lineage deletion is host-correct; and +- Git and folder terminal overlays clear and retry symmetrically while port and detected-agent cleanup remains intact. + +Rollback disables coordinator routing with build-time `VITE_DIRECT_SSH_RECONNECT_COORDINATOR=false` or session key `orca.directSshReconnectCoordinator.enabled=false` while retaining composed authority rotation, authority-boundary preservation, host-qualified IPC, mutation fences, and atomic terminal actions. The fallback reconnect path uses the dedicated bounded scheduler and preserves port/detected-agent cleanup; it does not restore host-blind or unbounded `Promise.all`. + +## Cross-platform and compatibility + +- Use existing execution-host, folder-workspace, workspace-key, SSH PTY-ID, and path utilities. Do not concatenate execution-host IDs or parse filesystem paths. +- The design introduces no keyboard behavior or platform-specific UI. +- Direct SSH on Windows, macOS, Linux, and WSL follows the same host/authority rules. +- Cancellation uses typed provider request IDs over Electron IPC and existing `AbortSignal` support; waiter lease IDs remain renderer-local, and no OS signal semantics or new cancel-ack protocol crosses the wire. +- No Git command, option, parser, or native dependency changes. Git 2.25 compatibility and capability fallbacks remain authoritative. +- Remote runtimes require no server upgrade. Runtime-host requests continue through the existing runtime RPC route. +- Keep new modules within normal line limits; do not add or widen a `max-lines` disable. + +## Rejected alternatives and overreach + +### Treat in-flight coalescing as collapsing the current sequential scan count + +Rejected. The connected refresh, sync preparation, and snapshot preparation await one another. Their provider reads do not overlap, so the current single-flight map has no live promise for the later wave to join. It can reduce unrelated overlapping calls, but does not invalidate the `2R`/`3R` sequential-path diagnosis. + +### Use either main connection generation or provider epoch as an independent clock + +Rejected. The renderer fallback counter has different ownership and is never authoritative. The existing main generation and new opaque epoch are composed by one rotation helper and transition set: mutation consumers compare the generation, provider/recovery consumers compare the pair, and no producer may advance them independently. + +### Cache completed preparation for an entire authority + +Rejected. Stable connections can receive wake refreshes and later snapshots referencing newly created worktrees. Only exact overlapping input work is shared; completed work never suppresses later inputs. + +### Use one global wave and FIFO for direct SSH plus runtime discovery + +Rejected. It couples terminal recovery and target preparation to unrelated queues and timeouts. Direct terminal finalization is synchronous per target; direct provider work uses a dedicated fair bounded scheduler. + +### Let preparation-only reuse reconnect finalization + +Rejected. Snapshot preparation must never initiate coordinator retry or reconnect sync. Existing snapshot-driven persisted-terminal reattach remains part of apply and is reconciled with current-authority binding evidence afterward. Only immutable catalog/repo/lineage reads may be shared. + +### Fence only before the final worktree-map merge + +Rejected. Main lineage/metadata changes and renderer git-identity routing occur earlier. Every authoritative side effect is downstream of an exact host/authority fence. + +### Use `{ forceLocalOwner: true }` for direct SSH lineage + +Rejected. It cannot authoritatively replace an SSH host scope and leaves deleted SSH lineage behind. The wire response and renderer replacement scope must both name the exact execution host. + +### Reuse the component sidebar single-flight coalescer + +Rejected. It is component-scoped, fire-and-forget, and lacks provider-authority, cancellation, terminal, and preparation semantics. The coordinator instead acquires leases from the shared detected-worktree provider coalescer while preserving its public key shape. + +### Replace direct `Promise.all` with `refreshRuntimeProjectWorktrees` + +Rejected. It bounds only one invocation, remains host-blind at the desktop IPC boundary, and does not remove repeated preparation. + +### Use one scheduler per SSH target + +Rejected. It permits `5 × targetCount` locally unsettled provider calls. One dedicated fair scheduler provides a truthful five-local-call bound without a cross-target completion barrier; cancel debt states the separate late-relay allowance. + +### Wait for a relay cancellation acknowledgement + +Rejected. `rpc.cancel` is a notification and canceled handlers intentionally send no response. Local waiter/provider settlement releases scheduler ownership; late effects remain authority-fenced and replacement admission is bounded by cancel debt. + +### Expand direct SSH coordination to the paired web client + +Rejected. Paired web clients cannot subscribe to the desktop direct-SSH state path. Preserve shared overload compatibility and host echoes, but do not create a new direct-SSH web transport to address an unreachable version of the shim claim. + +### Apply a fixed collection debounce + +Rejected. It taxes the singleton common case and is unnecessary when exact overlapping work already coalesces. Fair incremental admission handles multi-target bursts. + +### Call `fetchAllWorktrees` + +Rejected. It refreshes unrelated hosts, weakens failure isolation, and collides with the separate sidebar surface. + +### Reuse permanent target-removal cleanup + +Rejected. Removal intentionally deletes last-known and deferred liveness. Reconnectable disconnect must preserve them. + +### Batch repeated `clearTabPtyId` calls + +Rejected. React batching does not remove Zustand updater work, global map cloning, activity writes, or session debounce resets. + +### Move reconnect recovery into main + +Rejected. Main owns provider authority and mutation fences; renderer owns tab bindings, session projection, and Zustand updates. The host/authority boundary is the smaller ownership correction. diff --git a/examples/plugins/hostile-panel/panel.html b/examples/plugins/hostile-panel/panel.html index d4f779333c6..a612b1e056c 100644 --- a/examples/plugins/hostile-panel/panel.html +++ b/examples/plugins/hostile-panel/panel.html @@ -31,6 +31,12 @@ results.appendChild(li) document.title = 'probes:' + results.children.length } + function markNavigationInvocation(name) { + var marker = document.createElement('meta') + marker.dataset.navigationProbeInvoked = name + marker.content = 'true' + document.head.appendChild(marker) + } // Probe 1: fetch() exfiltration — must be blocked by connect-src 'none'. var cookieValue = 'x' @@ -73,6 +79,7 @@ // Navigation probes are opt-in so a failed attempt cannot erase the // network and bridge evidence before the harness observes it. document.getElementById('navigate-top').addEventListener('click', function () { + markNavigationInvocation('top-navigation') try { window.top.location.href = 'https://example.com/' setTimeout(function () { @@ -84,6 +91,7 @@ }) document.getElementById('navigate-self').addEventListener('click', function () { + markNavigationInvocation('self-navigation') try { window.location.href = 'https://example.com/self-navigation' setTimeout(function () { @@ -95,6 +103,7 @@ }) document.getElementById('navigate-anchor-form').addEventListener('click', function () { + markNavigationInvocation('anchor-form-navigation') var anchor = document.createElement('a') anchor.href = 'https://example.com/anchor-navigation' anchor.textContent = 'Navigation probe' @@ -111,6 +120,7 @@ }) document.getElementById('navigate-meta').addEventListener('click', function () { + markNavigationInvocation('meta-refresh-navigation') var refresh = document.createElement('meta') refresh.httpEquiv = 'refresh' refresh.content = '0;url=https://example.com/meta-refresh' diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index b67afba7e62..0022589443b 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -149,9 +149,16 @@ vi.mock('./ssh', () => ({ import { registerRepoHandlers } from './repos' import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status' +import { toSshExecutionHostId } from '../../shared/execution-host' +import { + getSshProviderAuthority, + resetSshProviderAuthorities, + rotateSshProviderAuthority +} from '../ssh/ssh-provider-authority' beforeEach(() => { clearGitCapabilityStateForTests() + resetSshProviderAuthorities() }) describe('projectGroups IPC validation', () => { @@ -210,6 +217,128 @@ describe('projectGroups IPC validation', () => { expect(mockStore.createProjectGroup).not.toHaveBeenCalled() }) + it('returns an immutable repo catalog for exactly one execution host', async () => { + const localRepo = { + id: 'duplicate', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { + id: 'duplicate', + path: '/remote/repo', + displayName: 'remote', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + const runtimeRepo = { + id: 'duplicate', + path: '/runtime/repo', + displayName: 'runtime', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'runtime:environment-a' + } + mockStore.getRepos.mockReturnValue([localRepo, sshRepo, runtimeRepo]) + + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: toSshExecutionHostId('conn-1'), + expectedAuthority: getSshProviderAuthority('conn-1') + }) + ).resolves.toMatchObject({ + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: 'ssh:conn-1', + targetId: 'conn-1' + }, + repos: [sshRepo] + }) + + const local = await handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: 'local' + }) + expect(local).toMatchObject({ authoritative: true, repos: [localRepo] }) + expect((local as { repos: object[] }).repos[0]).not.toBe(localRepo) + }) + + it('rejects repo catalogs whose execution host contradicts their SSH connection', async () => { + const baseRepo = { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + mockStore.getRepos.mockReturnValue([ + { + ...baseRepo, + connectionId: 'conn-1', + executionHostId: 'local' + } + ]) + + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: 'local' + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: toSshExecutionHostId('conn-1'), + expectedAuthority: getSshProviderAuthority('conn-1') + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + + mockStore.getRepos.mockReturnValue([ + { + ...baseRepo, + connectionId: 'conn-1', + executionHostId: toSshExecutionHostId('conn-2') + } + ]) + + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: toSshExecutionHostId('conn-1'), + expectedAuthority: getSshProviderAuthority('conn-1') + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + }) + + it('rejects runtime, partial, mismatched, and stale catalog authority', async () => { + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: 'runtime:environment-a' + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: toSshExecutionHostId('conn-1') + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: toSshExecutionHostId('conn-1'), + expectedAuthority: { + ...getSshProviderAuthority('other-target'), + targetId: 'other-target' + } + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + + const expectedAuthority = getSshProviderAuthority('conn-1') + const pending = handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: toSshExecutionHostId('conn-1'), + expectedAuthority + }) + rotateSshProviderAuthority('conn-1') + await expect(pending).resolves.toMatchObject({ authoritative: false, reason: 'stale' }) + }) + it('rejects malformed local project group update arguments before persistence', () => { expect(() => handlers.get('projectGroups:update')!(null, { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 185096e5ff6..1a456e23410 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -83,11 +83,16 @@ import { track } from '../telemetry/client' import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import type { RepoMethod } from '../../shared/telemetry-events' +import type { + HostRepoCatalogSnapshot, + ListReposForExecutionHostArgs +} from '../../shared/host-repo-catalog-contract' import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection' import { getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, parseExecutionHostId, type ExecutionHostId @@ -101,6 +106,8 @@ import { import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { runWithGitReadCacheInvalidation } from '../git/status' +import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' +import { isCurrentSshProviderAuthority } from '../ssh/ssh-provider-authority' // Why: `method` is the IPC entry point the user took, not what they added (never path/URL/name); repos:create → 'folder_picker'. // Why: `isGitRepo` is a non-identifying git-vs-folder signal from the caller's detection; pass undefined when unknown, never default false. @@ -119,6 +126,102 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean, isGitRepo?: track('repo_added', props) } +function hasValidCatalogSshAuthority( + args: ListReposForExecutionHostArgs +): args is Extract { + if (!('expectedAuthority' in args)) { + return false + } + return isAdmissibleDirectSshAuthority(args.expectedAuthority) +} + +function repoHostContradictsConnection(repo: Repo): boolean { + if (!repo.executionHostId || !repo.connectionId) { + return false + } + const explicitHost = parseExecutionHostId(repo.executionHostId) + return explicitHost?.kind !== 'ssh' || explicitHost.targetId !== repo.connectionId +} + +function getConsistentRepoCatalogForHost( + repos: readonly Repo[], + host: NonNullable> +): Repo[] | null { + const hasContradiction = repos.some( + (repo) => + repoHostContradictsConnection(repo) && + (getRepoExecutionHostId(repo) === host.id || + (host.kind === 'ssh' && repo.connectionId === host.targetId)) + ) + return hasContradiction ? null : repos.filter((repo) => getRepoExecutionHostId(repo) === host.id) +} + +async function listReposForExecutionHost( + store: Store, + args: ListReposForExecutionHostArgs +): Promise { + const parsedHost = parseExecutionHostId(args?.executionHostId) + const rejected = ( + reason: Extract['reason'] + ): HostRepoCatalogSnapshot => ({ + authoritative: false, + executionHostId: args.executionHostId, + reason + }) + if (!parsedHost || parsedHost.kind === 'runtime') { + return rejected('rejected') + } + if (parsedHost.kind === 'local') { + if ('expectedAuthority' in args) { + return rejected('rejected') + } + const repos = getConsistentRepoCatalogForHost(store.getRepos(), parsedHost) + if (!repos) { + return rejected('rejected') + } + return { + authoritative: true, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + repos: structuredClone(repos) + } + } + if ( + !hasValidCatalogSshAuthority(args) || + args.expectedAuthority.targetId !== parsedHost.targetId + ) { + return rejected('rejected') + } + const authority = { ...args.expectedAuthority } + if (!isCurrentSshProviderAuthority(authority)) { + return rejected('stale') + } + const provider = getSshGitProvider(parsedHost.targetId) + if (!provider) { + return rejected('unavailable') + } + const matchingRepos = getConsistentRepoCatalogForHost(store.getRepos(), parsedHost) + if (!matchingRepos) { + return rejected('rejected') + } + const repos = structuredClone(matchingRepos) + await Promise.resolve() + if ( + getSshGitProvider(parsedHost.targetId) !== provider || + !isCurrentSshProviderAuthority(authority) + ) { + return rejected('stale') + } + return { + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: parsedHost.id, + ...authority + }, + repos + } +} + function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetupResult { const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo) const project = store.getProjects().find((entry) => entry.id === setup.projectId) @@ -1119,6 +1222,7 @@ async function runNestedRepoScanForIpc( export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void { // Remove previously registered handlers so we can re-register on macOS app re-activation (new window). ipcMain.removeHandler('repos:list') + ipcMain.removeHandler('repos:listForExecutionHost') ipcMain.removeHandler('repos:add') ipcMain.removeHandler('repos:remove') ipcMain.removeHandler('repos:removeForHost') @@ -1175,6 +1279,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v return store.getRepos() }) + ipcMain.handle( + 'repos:listForExecutionHost', + (_event, args: ListReposForExecutionHostArgs): Promise => + listReposForExecutionHost(store, args) + ) + ipcMain.handle('projects:list', () => { enrichMissingRepoGitRemoteIdentities(store, { onChanged: () => notifyReposChanged(mainWindow) diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index f40370572c8..a02e36ca7eb 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -15,6 +15,7 @@ const { mockPtyProvider, mockFsProvider, mockGitProvider, + mockRegisterSshGitProvider, mockPortForwardManager, mockPortScannerCallbacks, mockNextConnectionManagers, @@ -68,6 +69,7 @@ const { }, mockFsProvider: {}, mockGitProvider: {}, + mockRegisterSshGitProvider: vi.fn(), mockPortForwardManager: { addForward: vi.fn(), updateForward: vi.fn(), @@ -182,7 +184,7 @@ vi.mock('../providers/ssh-git-provider', () => ({ })) vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), + registerSshGitProvider: mockRegisterSshGitProvider, unregisterSshGitProvider: vi.fn() })) @@ -335,6 +337,7 @@ describe('SSH IPC handlers', () => { mockPtyProvider.onReplay.mockReset() mockPtyProvider.attachForReconnect.mockReset().mockResolvedValue({}) mockPtyProvider.shutdown.mockReset() + mockRegisterSshGitProvider.mockReset() mockPortForwardManager.addForward.mockReset() mockPortForwardManager.updateForward.mockReset() mockPortForwardManager.removeForward.mockReset() @@ -504,6 +507,45 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) }) + it('registers the provider before broadcasting connected authority', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + const connectedIndex = mockWindow.webContents.send.mock.calls.findIndex( + ([channel, payload]) => + channel === 'ssh:state-changed' && + (payload as { state: SshConnectionState }).state.status === 'connected' + ) + expect(connectedIndex).toBeGreaterThanOrEqual(0) + expect(mockRegisterSshGitProvider.mock.invocationCallOrder[0]).toBeLessThan( + mockWindow.webContents.send.mock.invocationCallOrder[connectedIndex] + ) + expect(mockWindow.webContents.send.mock.calls[connectedIndex]?.[1]).toEqual({ + targetId: 'ssh-1', + state: expect.objectContaining({ + targetId: 'ssh-1', + status: 'connected', + providerEpoch: expect.any(String), + connectionGeneration: 1 + }) + }) + }) + it('ssh:connect exposes the detected remote platform in public state', async () => { const target: SshTarget = { id: 'ssh-1', @@ -539,6 +581,7 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, + providerEpoch: expect.any(String), connectionGeneration: 1, remotePlatform: 'win32' }) @@ -549,6 +592,7 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, + providerEpoch: expect.any(String), connectionGeneration: 1, supportsFolderDownload: true, remotePlatform: 'win32' @@ -584,45 +628,46 @@ describe('SSH IPC handlers', () => { onDispose?.('connection_lost') - expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { - targetId: 'ssh-1', - state: { - targetId: 'ssh-1', - status: 'reconnecting', - error: 'Relay channel lost. Reconnecting...', - reconnectAttempt: 1, - connectionGeneration: 1 - } - }) - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ + const reconnectingState = handlers.get('ssh:getState')!(null, { + targetId: 'ssh-1' + }) as SshConnectionState + expect(reconnectingState).toEqual({ targetId: 'ssh-1', status: 'reconnecting', error: 'Relay channel lost. Reconnecting...', reconnectAttempt: 1, - connectionGeneration: 1 + providerEpoch: expect.any(String), + connectionGeneration: 2 + }) + expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { + targetId: 'ssh-1', + state: reconnectingState }) await vi.advanceTimersByTimeAsync(500) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { - targetId: 'ssh-1', - state: { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - connectionGeneration: 1, - supportsFolderDownload: true - } - }) - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ + const connectedState = handlers.get('ssh:getState')!(null, { + targetId: 'ssh-1' + }) as SshConnectionState + expect(connectedState).toEqual({ targetId: 'ssh-1', status: 'connected', error: null, reconnectAttempt: 0, - connectionGeneration: 1 + providerEpoch: reconnectingState.providerEpoch, + connectionGeneration: reconnectingState.connectionGeneration }) - expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 1)).not.toThrow() + expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { + targetId: 'ssh-1', + state: { + ...connectedState, + supportsFolderDownload: true + } + }) + expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 1)).toThrow( + 'SSH connection changed; refresh and try again' + ) + expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 2)).not.toThrow() } finally { vi.useRealTimers() } @@ -677,6 +722,7 @@ describe('SSH IPC handlers', () => { status: 'reconnecting', error: 'Relay channel reconnecting...', reconnectAttempt: 0, + providerEpoch: expect.any(String), connectionGeneration: 2 }) expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', stagedGeneration)).toThrow( @@ -866,7 +912,8 @@ describe('SSH IPC handlers', () => { status: 'reconnecting', error: 'Relay channel lost. Reconnecting...', reconnectAttempt: 1, - connectionGeneration: 1 + providerEpoch: expect.any(String), + connectionGeneration: 2 }) mockDeployAndLaunchRelay.mockClear() @@ -877,7 +924,8 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, - connectionGeneration: 2 + providerEpoch: expect.any(String), + connectionGeneration: 3 }) expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') @@ -887,7 +935,8 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, - connectionGeneration: 2 + providerEpoch: expect.any(String), + connectionGeneration: 3 }) } finally { vi.useRealTimers() @@ -918,7 +967,7 @@ describe('SSH IPC handlers', () => { try { await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - for (const delayMs of relayReconnectDelaysMs) { + for (const [index, delayMs] of relayReconnectDelaysMs.entries()) { useSlowRelayLaunchOnce(relayLostStabilizedMs + 1) getLatestRelayDisposeCallback()('connection_lost') await vi.advanceTimersByTimeAsync(delayMs + relayLostStabilizedMs + 1) @@ -927,7 +976,8 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, - connectionGeneration: 1 + providerEpoch: expect.any(String), + connectionGeneration: index + 2 }) } @@ -938,7 +988,8 @@ describe('SSH IPC handlers', () => { status: 'error', error: 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.', reconnectAttempt: 0, - connectionGeneration: 1 + providerEpoch: expect.any(String), + connectionGeneration: relayReconnectDelaysMs.length + 2 }) } finally { vi.useRealTimers() @@ -976,7 +1027,8 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, - connectionGeneration: 1 + providerEpoch: expect.any(String), + connectionGeneration: 2 }) await vi.advanceTimersByTimeAsync(relayLostStabilizedMs + 1) @@ -988,7 +1040,8 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, - connectionGeneration: 1 + providerEpoch: expect.any(String), + connectionGeneration: 2 }) expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() @@ -1195,6 +1248,7 @@ describe('SSH IPC handlers', () => { await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ ...connectedState, + providerEpoch: expect.any(String), connectionGeneration: 1 }) expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() @@ -1396,6 +1450,7 @@ describe('SSH IPC handlers', () => { status: 'error', error: 'network down', reconnectAttempt: 0, + providerEpoch: expect.any(String), connectionGeneration: 1 } }) @@ -1451,6 +1506,30 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) + it('lets a same-turn disconnect invalidate connect before transport admission', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.disconnect.mockResolvedValue(undefined) + + const connect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + const disconnect = handlers.get('ssh:disconnect')!(null, { + targetId: 'ssh-1' + }) as Promise + + await disconnect + await expect(connect).rejects.toThrow('SSH connection attempt was cancelled') + expect(mockConnectionManager.connect).not.toHaveBeenCalled() + }) + it('invalidates a pending connect when disconnect wins and allows a fresh connect', async () => { const target: SshTarget = { id: 'ssh-1', @@ -1462,11 +1541,30 @@ describe('SSH IPC handlers', () => { const staleConn = {} const freshConn = {} let resolveStaleConnect!: (connection: unknown) => void + let resolveForwardRemoval!: () => void + let transportConnectPending = false mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockReturnValueOnce( - new Promise((resolve) => { - resolveStaleConnect = resolve + mockConnectionManager.connect + .mockReturnValueOnce( + new Promise((resolve) => { + transportConnectPending = true + resolveStaleConnect = resolve + }) + ) + .mockImplementationOnce(async () => { + if (transportConnectPending) { + throw new Error('Connection to Server is already in progress') + } + return freshConn }) + mockConnectionManager.disconnect.mockImplementationOnce(async () => { + transportConnectPending = false + }) + mockPortForwardManager.removeAllForwards.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveForwardRemoval = resolve + }) ) mockConnectionManager.getState.mockReturnValue({ targetId: 'ssh-1', @@ -1480,17 +1578,224 @@ describe('SSH IPC handlers', () => { }) as Promise await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) - mockConnectionManager.connect.mockResolvedValueOnce(freshConn) + const disconnect = handlers.get('ssh:disconnect')!(null, { + targetId: 'ssh-1' + }) as Promise + await vi.waitFor(() => expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')) const freshConnect = handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) as Promise + resolveStaleConnect(staleConn) + await expect(staleConnect).rejects.toThrow('SSH connection attempt was cancelled') + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + resolveForwardRemoval() + await disconnect await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2)) - resolveStaleConnect(staleConn) + await expect(freshConnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) + expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) + }) + + it('keeps reconnect behind transport disconnect when forward teardown fails', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + let resolveTransportDisconnect!: () => void + let transportDisconnectPending = false + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValueOnce({}).mockImplementationOnce(async () => { + if (transportDisconnectPending) { + throw new Error('Connection to Server is already in progress') + } + return {} + }) + mockConnectionManager.disconnect.mockImplementationOnce( + () => + new Promise((resolve) => { + transportDisconnectPending = true + resolveTransportDisconnect = () => { + transportDisconnectPending = false + resolve() + } + }) + ) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockPortForwardManager.removeAllForwards.mockRejectedValueOnce( + new Error('forward teardown failed') + ) + + const disconnect = handlers.get('ssh:disconnect')!(null, { + targetId: 'ssh-1' + }) as Promise + const disconnectSettled = vi.fn() + void disconnect.then(disconnectSettled, disconnectSettled) + await vi.waitFor(() => + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + ) + const reconnect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + const reconnectResult = reconnect.then( + (state) => ({ ok: true as const, state }), + (error: unknown) => ({ ok: false as const, error }) + ) + await Promise.resolve() + + expect(disconnectSettled).not.toHaveBeenCalled() + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + resolveTransportDisconnect() + + await expect(disconnect).rejects.toThrow('forward teardown failed') + await expect(reconnectResult).resolves.toMatchObject({ + ok: true, + state: { targetId: 'ssh-1', status: 'connected' } + }) + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2) + expect(mockMux.dispose).toHaveBeenCalledWith('connection_lost') + }) + + it('retires a removed target session after forward teardown fails', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + let resolveTransportDisconnect!: () => void + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.disconnect.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveTransportDisconnect = resolve + }) + ) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockPortForwardManager.removeAllForwards.mockRejectedValueOnce( + new Error('forward teardown failed') + ) + + const removal = handlers.get('ssh:removeTarget')!(null, { + id: 'ssh-1' + }) as Promise + await vi.waitFor(() => + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + ) + await Promise.resolve() + + expect(mockSshStore.removeTarget).not.toHaveBeenCalled() + resolveTransportDisconnect() + await removal + + expect(mockMux.dispose).toHaveBeenCalledWith('shutdown') + expect(mockStore.removeSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1') + expect(mockSshStore.removeTarget).toHaveBeenCalledWith('ssh-1') + }) + + it('replaces a stale shared connect after authority rotates without disconnect', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + let resolveStaleConnect!: (connection: unknown) => void + let resolveForwardRemoval!: () => void + let resolveTransportDisconnect!: () => void + let transportConnectPending = false + mockSshStore.getTarget.mockReturnValue(target) + mockSshStore.addTarget.mockReturnValue(target) + mockConnectionManager.connect + .mockReturnValueOnce( + new Promise((resolve) => { + transportConnectPending = true + resolveStaleConnect = resolve + }) + ) + .mockImplementationOnce(async () => { + if (transportConnectPending) { + throw new Error('Connection to Server is already in progress') + } + return {} + }) + mockConnectionManager.disconnect.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveTransportDisconnect = () => { + transportConnectPending = false + resolve() + } + }) + ) + mockPortForwardManager.removeAllForwards.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveForwardRemoval = resolve + }) + ) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + const staleConnect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + const sharedStaleConnect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) + + mockSshStore.lastRepoReadoptions = [ + { oldTargetId: 'ssh-1', newTargetId: 'ssh-new', repoIds: ['repo-1'] } + ] + await handlers.get('ssh:addTarget')!(null, { target }) + const freshConnect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + await vi.waitFor(() => + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + ) + await vi.waitFor(() => expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')) + const sharedFreshConnect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + resolveForwardRemoval() + resolveTransportDisconnect() + await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2)) + + resolveStaleConnect({}) await expect(staleConnect).rejects.toThrow('SSH connection attempt was cancelled') + await expect(sharedStaleConnect).rejects.toThrow('SSH connection attempt was cancelled') await expect(freshConnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) + await expect(sharedFreshConnect).resolves.toMatchObject({ + targetId: 'ssh-1', + status: 'connected' + }) expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) }) @@ -1568,6 +1873,61 @@ describe('SSH IPC handlers', () => { expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-lease', 'terminated') }) + it('keeps reconnect behind the complete terminate-sessions lifecycle', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + let resolveShutdown!: () => void + let resolveForwardRemoval!: () => void + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mockStore.getSshRemotePtyLeases.mockReturnValue([ + { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' } + ]) + vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + mockPtyProvider.shutdown.mockReturnValueOnce( + new Promise((resolve) => { + resolveShutdown = resolve + }) + ) + mockPortForwardManager.removeAllForwards.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveForwardRemoval = resolve + }) + ) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + const terminate = handlers.get('ssh:terminateSessions')!(null, { + targetId: 'ssh-1' + }) as Promise + await vi.waitFor(() => expect(mockPtyProvider.shutdown).toHaveBeenCalledOnce()) + const reconnect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + + resolveShutdown() + await vi.waitFor(() => expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')) + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + resolveForwardRemoval() + await terminate + await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2)) + + await expect(reconnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) + }) + it('ssh:terminateSessions ignores expired leases when disconnected', async () => { mockStore.getSshRemotePtyLeases.mockReturnValue([ { targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' } @@ -1641,6 +2001,43 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) + it('retires the captured session when reset forward teardown fails', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockPortForwardManager.removeAllForwards.mockRejectedValueOnce( + new Error('forward teardown failed') + ) + + await expect(handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' })).rejects.toThrow( + 'forward teardown failed' + ) + expect(mockMux.dispose).toHaveBeenCalledWith('connection_lost') + expect(mockForceStopRelayForTarget).not.toHaveBeenCalled() + + await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) + + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledTimes(1) + expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') + expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + }) + it('ssh:resetRelay waits for an in-flight connect before tearing down the session', async () => { const target: SshTarget = { id: 'ssh-1', @@ -1776,6 +2173,42 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) + it('keeps removal behind an in-flight relay reset', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + let resolveForceStop!: () => void + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.getConnection.mockReturnValue({}) + mockConnectionManager.disconnect.mockResolvedValue(undefined) + mockForceStopRelayForTarget.mockReturnValueOnce( + new Promise((resolve) => { + resolveForceStop = resolve + }) + ) + + const reset = handlers.get('ssh:resetRelay')!(null, { + targetId: 'ssh-1' + }) as Promise + await vi.waitFor(() => expect(mockForceStopRelayForTarget).toHaveBeenCalledOnce()) + const removal = handlers.get('ssh:removeTarget')!(null, { + id: 'ssh-1' + }) as Promise + await Promise.resolve() + + expect(mockSshStore.removeTarget).not.toHaveBeenCalled() + resolveForceStop() + await reset + await removal + + expect(mockConnectionManager.disconnect).toHaveBeenCalledTimes(2) + expect(mockSshStore.removeTarget).toHaveBeenCalledWith('ssh-1') + }) + it('reconnects on system resume when the relay liveness probe fails', async () => { const target: SshTarget = { id: 'ssh-1', @@ -1974,6 +2407,10 @@ describe('SSH IPC handlers', () => { mockConnectionManager.getState.mockReturnValue(state) const result = await handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) - expect(result).toEqual({ ...state, connectionGeneration: 0 }) + expect(result).toEqual({ + ...state, + providerEpoch: expect.any(String), + connectionGeneration: 0 + }) }) }) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 846536fdd3b..7e1a54026df 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -15,7 +15,8 @@ import type { SshRepoReadoption, SshTarget, SshConnectionStatus, - SshConnectionState + SshConnectionState, + DirectSshAuthority } from '../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' @@ -40,11 +41,15 @@ import { } from './pty' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { - advanceSshConnectionGeneration, - getSshConnectionGeneration, initializeSshConnectionGenerationSession, resetSshConnectionGenerations } from '../ssh/ssh-connection-generation' +import { + getSshProviderAuthority, + isCurrentSshProviderAuthority, + resetSshProviderAuthorities, + rotateSshProviderAuthority +} from '../ssh/ssh-provider-authority' let sshStore: SshConnectionStore | null = null let connectionManager: SshConnectionManager | null = null @@ -108,34 +113,35 @@ export function listRegisteredRemovedSshTargetLabels(): Record { export async function disconnectRegisteredSshTarget(targetId: string): Promise { invalidateConnectAttempt(targetId) - if (!connectionManager) { - return - } - await detachActiveSshSession(targetId) - await connectionManager.disconnect(targetId) + await runTargetLifecycle(targetId, () => + teardownSshTargetTransport(targetId, (session) => session.detach()) + ) } export async function removeRegisteredSshTarget(targetId: string): Promise { if (!sshStore) { return } + const store = sshStore invalidateConnectAttempt(targetId) - // Why: removal is destructive; dispose so remote PTYs cannot reattach to a deleted target. - await disposeActiveSshSession(targetId) - try { - await connectionManager?.disconnect(targetId) - } catch (err) { - // Why: a failed disconnect must not block metadata removal, else the target lingers in the store with uncleaned leases. - console.warn( - `[ssh] Failed to disconnect removed target ${targetId}: ${err instanceof Error ? err.message : String(err)}` - ) - } - persistedStore?.removeSshRemotePtyLeases(targetId) - sshStore.removeTarget(targetId) + await runTargetLifecycle(targetId, async () => { + try { + // Why: removal is destructive; dispose so remote PTYs cannot reattach to a deleted target. + await teardownSshTargetTransport(targetId, (session) => session.dispose()) + } catch (err) { + // Why: a failed disconnect must not block metadata removal, else the target lingers in the store with uncleaned leases. + console.warn( + `[ssh] Failed to disconnect removed target ${targetId}: ${err instanceof Error ? err.message : String(err)}` + ) + } + persistedStore?.removeSshRemotePtyLeases(targetId) + store.removeTarget(targetId) + }) } // One session per SSH target owns the whole relay lifecycle (mux, providers, abort controller, state machine). const activeSessions = new Map() +const targetLifecycleInFlight = new Map>() export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null { if (isRuntimeOwnedSshTargetId(targetId)) { @@ -154,12 +160,61 @@ export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] { }) } -async function detachActiveSshSession(targetId: string): Promise { - await teardownActiveSshSession(targetId, (session) => session.detach()) +function runTargetLifecycle(targetId: string, operation: () => Promise): Promise { + const prior = targetLifecycleInFlight.get(targetId) + const operationPromise = (async () => { + if (prior) { + await prior.catch(() => undefined) + } + await operation() + })() + let trackedPromise!: Promise + trackedPromise = operationPromise.finally(() => { + if (targetLifecycleInFlight.get(targetId) === trackedPromise) { + targetLifecycleInFlight.delete(targetId) + } + }) + targetLifecycleInFlight.set(targetId, trackedPromise) + return trackedPromise } -async function disposeActiveSshSession(targetId: string): Promise { - await teardownActiveSshSession(targetId, (session) => session.dispose()) +async function awaitTargetLifecycle(targetId: string): Promise { + while (true) { + const lifecycle = targetLifecycleInFlight.get(targetId) + if (!lifecycle) { + return + } + await lifecycle.catch(() => undefined) + } +} + +async function teardownSshTargetTransport( + targetId: string, + teardown: (session: SshRelaySession) => void +): Promise { + let transportDisconnect: Promise<{ ok: true } | { ok: false; error: unknown }> + try { + transportDisconnect = Promise.resolve(connectionManager?.disconnect(targetId)).then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const + ) + } catch (error) { + transportDisconnect = Promise.resolve({ ok: false, error }) + } + const sessionTeardown = teardownActiveSshSession(targetId, teardown).then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const + ) + const [disconnectResult, teardownResult] = await Promise.all([ + transportDisconnect, + sessionTeardown + ]) + if (!teardownResult.ok) { + throw teardownResult.error + } + if (!disconnectResult.ok) { + throw disconnectResult.error + } } async function teardownActiveSshSession( @@ -170,12 +225,26 @@ async function teardownActiveSshSession( if (!session) { return } - // Why: await port teardown so local listeners are released before disconnect/remove completes, else an immediate reconnect hits EADDRINUSE. - await portForwardManager?.removeAllForwards(targetId) - teardown(session) - activeSessions.delete(targetId) - clearRelayLostBackoff(targetId) - clearRelayStateOverride(targetId) + let teardownError: { error: unknown } | null = null + try { + // Why: await port teardown so local listeners are released before disconnect/remove completes, else an immediate reconnect hits EADDRINUSE. + await portForwardManager?.removeAllForwards(targetId) + } catch (error) { + teardownError = { error } + } + try { + teardown(session) + } catch (error) { + teardownError ??= { error } + } + if (activeSessions.get(targetId) === session) { + activeSessions.delete(targetId) + clearRelayLostBackoff(targetId) + clearRelayStateOverride(targetId) + } + if (teardownError) { + throw teardownError.error + } } function relayGracePeriodForTarget(target: SshTarget | null | undefined): number | undefined { @@ -185,25 +254,22 @@ function relayGracePeriodForTarget(target: SshTarget | null | undefined): number // Why: tabs must share one connect, while a disconnect must invalidate that // attempt so its late continuation cannot clobber a replacement. type ConnectAttempt = { - generation: number + authority: DirectSshAuthority promise: Promise } const connectInFlight = new Map() const pendingTransportReconnects = new Set() -function currentConnectGeneration(targetId: string): number { - return getSshConnectionGeneration(targetId) -} function invalidateConnectAttempt(targetId: string): void { - advanceSshConnectionGeneration(targetId) + rotateSshProviderAuthority(targetId) pendingTransportReconnects.delete(targetId) connectInFlight.delete(targetId) credentialRequestedForTarget.delete(targetId) } -function isCurrentConnectAttempt(targetId: string, generation: number): boolean { - return currentConnectGeneration(targetId) === generation +function isCurrentConnectAttempt(targetId: string, authority: DirectSshAuthority): boolean { + return authority.targetId === targetId && isCurrentSshProviderAuthority(authority) } function connectCancelledError(): Error { @@ -262,9 +328,12 @@ function broadcastSshState( function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState { const remotePlatform = activeSessions.get(targetId)?.getHostPlatform()?.os + const authority = getSshProviderAuthority(targetId) return { ...state, - connectionGeneration: currentConnectGeneration(targetId), + targetId, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration, ...(remotePlatform ? { remotePlatform } : {}) } } @@ -517,10 +586,12 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks { // Why: an SSH reconnect must re-deploy the relay and rebuild providers; the guard below fires only for real reconnects, not an explicit connect's 'deploying'. const session = activeSessions.get(targetId) const sessionState = session?.getState() - if ( + const transportReconnectStarted = state.status === 'reconnecting' && - (sessionState === 'ready' || sessionState === 'reconnecting') - ) { + (sessionState === 'ready' || sessionState === 'reconnecting') && + !pendingTransportReconnects.has(targetId) + if (transportReconnectStarted) { + rotateSshProviderAuthority(targetId) pendingTransportReconnects.add(targetId) } else if ( state.status === 'disconnected' || @@ -532,10 +603,6 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks { } const completedTransportReconnect = state.status === 'connected' && pendingTransportReconnects.delete(targetId) - if (completedTransportReconnect) { - // Why: staged mutations from the replaced SSH transport must fail even if its relay session disappeared before recovery completed. - advanceSshConnectionGeneration(targetId) - } const shouldReconnectRelay = session !== undefined && completedTransportReconnect && @@ -613,6 +680,9 @@ function broadcastDetectedPortsFromCurrentWindow( function configureRelaySessionCallbacks(session: SshRelaySession): void { session.setOnTerminalRelayError((tid, err) => { clearRelayLostBackoff(tid) + if (activeSessions.get(tid)?.getState() !== 'deploying') { + rotateSshProviderAuthority(tid) + } console.warn( `[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.` ) @@ -643,6 +713,7 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void { if (state.reconnectTimer) { return } + rotateSshProviderAuthority(tid) if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { console.warn( `[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.` @@ -781,6 +852,11 @@ export function registerSshHandlers( } const repoReadoptions = sshStore.lastRepoReadoptions sshStore.lastRepoReadoptions = [] + for (const targetId of new Set( + repoReadoptions.flatMap(({ oldTargetId, newTargetId }) => [oldTargetId, newTargetId]) + )) { + rotateSshProviderAuthority(targetId) + } const win = getCurrentMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('repos:changed') @@ -828,7 +904,9 @@ export function registerSshHandlers( appendFileSync(e2eProbePath, `${JSON.stringify(targetId)}\n`) throw new Error('e2e_forbidden_local_ssh_connect') } - const observedGeneration = currentConnectGeneration(targetId) + // Why: fence callers that entered before a same-turn disconnect/reset but resume after its cleanup. + const admissionAuthority = getSshProviderAuthority(targetId) + await awaitTargetLifecycle(targetId) const reset = resetRelayInFlight.get(targetId) if (reset) { await reset @@ -836,16 +914,29 @@ export function registerSshHandlers( // Why: serialize concurrent ssh:connect for the same target; interleaved connects otherwise leak the first session. const existing = connectInFlight.get(targetId) + let replacePendingTransport = false if (existing) { - return existing.promise + if (isCurrentConnectAttempt(targetId, existing.authority)) { + return existing.promise + } } - if (currentConnectGeneration(targetId) !== observedGeneration) { + if (!isCurrentConnectAttempt(targetId, admissionAuthority)) { + throw connectCancelledError() + } + const observedAuthority = admissionAuthority + if (existing) { + if (connectInFlight.get(targetId) === existing) { + connectInFlight.delete(targetId) + replacePendingTransport = true + } + } + if (!isCurrentSshProviderAuthority(observedAuthority)) { throw connectCancelledError() } pendingTransportReconnects.delete(targetId) - const promise = doConnect(targetId) - const attempt = { generation: currentConnectGeneration(targetId), promise } + const promise = doConnect(targetId, replacePendingTransport) + const attempt = { authority: getSshProviderAuthority(targetId), promise } connectInFlight.set(targetId, attempt) try { return await promise @@ -863,7 +954,10 @@ export function registerSshHandlers( return connectTarget(args.targetId) }) - async function doConnect(targetId: string): Promise { + async function doConnect( + targetId: string, + replacePendingTransport = false + ): Promise { const target = sshStore!.getTarget(targetId) if (!target) { throw new Error(`SSH target "${targetId}" not found`) @@ -886,20 +980,38 @@ export function registerSshHandlers( return getPublicSshState(targetId)! } - const generation = advanceSshConnectionGeneration(targetId) + const authority = rotateSshProviderAuthority(targetId) clearRelayStateOverride(targetId) + const pendingTransportDisconnect = replacePendingTransport + ? connectionManager!.disconnect(targetId).then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const + ) + : null let conn // Why: tear down any existing session first to avoid leaking its multiplexer, providers, and timers (double-connect / reconnect-after-error). if (existingSession) { // Why: await port teardown before disposing, else the new session's restorePortForwards can hit EADDRINUSE on not-yet-released ports. await portForwardManager!.removeAllForwards(targetId) - if (!isCurrentConnectAttempt(targetId, generation)) { + if (!isCurrentConnectAttempt(targetId, authority)) { throw connectCancelledError() } existingSession.detach() - activeSessions.delete(targetId) - clearRelayLostBackoff(targetId) - clearRelayStateOverride(targetId) + if (activeSessions.get(targetId) === existingSession) { + activeSessions.delete(targetId) + clearRelayLostBackoff(targetId) + clearRelayStateOverride(targetId) + } + } + + if (pendingTransportDisconnect) { + const disconnectResult = await pendingTransportDisconnect + if (!disconnectResult.ok) { + throw disconnectResult.error + } + if (!isCurrentConnectAttempt(targetId, authority)) { + throw connectCancelledError() + } } // Why: create the session early so onStateChange sees it in 'deploying' and skips reconnect logic. @@ -914,7 +1026,7 @@ export function registerSshHandlers( configureRelaySessionCallbacks(session) activeSessions.set(targetId, session) const ownsSession = (): boolean => - isCurrentConnectAttempt(targetId, generation) && activeSessions.get(targetId) === session + isCurrentConnectAttempt(targetId, authority) && activeSessions.get(targetId) === session try { conn = await connectionManager!.connect(target) @@ -988,66 +1100,60 @@ export function registerSshHandlers( ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => { invalidateConnectAttempt(args.targetId) - const session = activeSessions.get(args.targetId) - const provider = getSshPtyProvider(args.targetId) - const leasedIds = persistedStore! - .getSshRemotePtyLeases(args.targetId) - .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') - .map((lease) => lease.ptyId) - const ptyIdsByRelayId = new Map() - for (const ptyId of getPtyIdsForConnection(args.targetId)) { - const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) - ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(args.targetId, ptyId)) - } - for (const ptyId of leasedIds) { - const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) - ptyIdsByRelayId.set( - relayPtyId, - ptyIdsByRelayId.get(relayPtyId) ?? toAppSshPtyId(args.targetId, ptyId) - ) - } - const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({ - relayPtyId, - appPtyId - })) - - if (ptyIds.length > 0 && !provider) { - throw new Error( - `${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.` - ) - } - const shutdownResults = provider - ? await Promise.allSettled( - ptyIds.map(({ appPtyId }) => - provider.shutdown(appPtyId, { immediate: true, keepHistory: false }) - ) - ) - : [] - const shutdownFailures: string[] = [] - for (const [index, result] of shutdownResults.entries()) { - const { appPtyId, relayPtyId } = ptyIds[index] - if (result.status !== 'fulfilled' && !isSshPtyNotFoundError(result.reason)) { - shutdownFailures.push( - `${relayPtyId}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}` - ) - continue + await runTargetLifecycle(args.targetId, async () => { + const provider = getSshPtyProvider(args.targetId) + const leasedIds = persistedStore! + .getSshRemotePtyLeases(args.targetId) + .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') + .map((lease) => lease.ptyId) + const ptyIdsByRelayId = new Map() + for (const ptyId of getPtyIdsForConnection(args.targetId)) { + const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) + ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(args.targetId, ptyId)) } - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') - } - if (shutdownFailures.length > 0) { - // Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry. - throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`) - } - if (session) { - await portForwardManager!.removeAllForwards(args.targetId) - session.dispose() - activeSessions.delete(args.targetId) - clearRelayLostBackoff(args.targetId) - clearRelayStateOverride(args.targetId) - } - await connectionManager!.disconnect(args.targetId) + for (const ptyId of leasedIds) { + const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) + ptyIdsByRelayId.set( + relayPtyId, + ptyIdsByRelayId.get(relayPtyId) ?? toAppSshPtyId(args.targetId, ptyId) + ) + } + const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({ + relayPtyId, + appPtyId + })) + + if (ptyIds.length > 0 && !provider) { + throw new Error( + `${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.` + ) + } + const shutdownResults = provider + ? await Promise.allSettled( + ptyIds.map(({ appPtyId }) => + provider.shutdown(appPtyId, { immediate: true, keepHistory: false }) + ) + ) + : [] + const shutdownFailures: string[] = [] + for (const [index, result] of shutdownResults.entries()) { + const { appPtyId, relayPtyId } = ptyIds[index] + if (result.status !== 'fulfilled' && !isSshPtyNotFoundError(result.reason)) { + shutdownFailures.push( + `${relayPtyId}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}` + ) + continue + } + clearProviderPtyState(appPtyId) + deletePtyOwnership(appPtyId) + persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') + } + if (shutdownFailures.length > 0) { + // Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry. + throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`) + } + await teardownSshTargetTransport(args.targetId, (session) => session.dispose()) + }) }) async function doResetRelay(targetId: string, target: SshTarget): Promise { @@ -1061,13 +1167,11 @@ export function registerSshHandlers( } } + rotateSshProviderAuthority(targetId) const session = activeSessions.get(targetId) if (session) { - await portForwardManager!.removeAllForwards(targetId) // Why: detach() not dispose() — reset has its own stale-lease semantics below that dispose()'s clean-termination recording would hide. - session.detach() - activeSessions.delete(targetId) - clearRelayLostBackoff(targetId) + await teardownActiveSshSession(targetId, (capturedSession) => capturedSession.detach()) } const existingConn = connectionManager!.getConnection(targetId) @@ -1106,13 +1210,13 @@ export function registerSshHandlers( } let resetPromise: Promise - resetPromise = Promise.resolve() - .then(() => doResetRelay(args.targetId, target)) - .finally(() => { - if (resetRelayInFlight.get(args.targetId) === resetPromise) { - resetRelayInFlight.delete(args.targetId) - } - }) + resetPromise = runTargetLifecycle(args.targetId, () => + doResetRelay(args.targetId, target) + ).finally(() => { + if (resetRelayInFlight.get(args.targetId) === resetPromise) { + resetRelayInFlight.delete(args.targetId) + } + }) resetRelayInFlight.set(args.targetId, resetPromise) return resetPromise }) @@ -1300,8 +1404,10 @@ export async function resetSshHandlerStateForTests(): Promise { } relayStateOverrides.clear() connectInFlight.clear() + targetLifecycleInFlight.clear() pendingTransportReconnects.clear() resetSshConnectionGenerations() + resetSshProviderAuthorities() resetRelayInFlight.clear() testingTargets.clear() credentialRequestedForTarget.clear() diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index b5295570a13..72cad299822 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -4,7 +4,9 @@ import type * as GitUsernameModule from '../git/git-username' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import type { CreateWorktreeResult, GitWorktreeInfo, Worktree } from '../../shared/types' +import type { CreateWorktreeResult, GitWorktreeInfo, Repo, Worktree } from '../../shared/types' +import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' +import { toSshExecutionHostId } from '../../shared/execution-host' import * as localWorktreeFilesystem from '../local-worktree-filesystem' const ORIGINAL_PLATFORM = process.platform @@ -265,10 +267,17 @@ import { const ORIGIN_REMOTE_URL = 'git@github.com:org/repo.git' const ORIGIN_HEAD_COMPONENT = reviewHeadRemoteRefComponent('origin', ORIGIN_REMOTE_URL) import { + DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS, + LINEAGE_HYDRATION_TIMEOUT_MS, __getDetectedWorktreeScanCacheStatsForTests, __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees' +import { + getSshProviderAuthority, + resetSshProviderAuthorities, + rotateSshProviderAuthority +} from '../ssh/ssh-provider-authority' type HandlerMap = Record unknown> @@ -280,6 +289,7 @@ describe('registerWorktreeHandlers', () => { send: vi.fn() } } + const ipcEvent = { sender: { id: 1 } } const store = { getRepos: vi.fn(), getRepo: vi.fn(), @@ -292,7 +302,10 @@ describe('registerWorktreeHandlers', () => { getProjectHostSetups: vi.fn(), removeWorktreeMeta: vi.fn(), getAllWorktreeLineage: vi.fn(), - removeWorktreeLineage: vi.fn() + removeWorktreeLineage: vi.fn(), + getAllWorkspaceLineage: vi.fn(), + getFolderWorkspaces: vi.fn(), + getProjectGroups: vi.fn() } let runtimeStub: { resolveRemoteTrackingBase: ReturnType @@ -310,12 +323,14 @@ describe('registerWorktreeHandlers', () => { notifyWorktreesChangedForRemoteClients: ReturnType closeFileWatchersForRemoval: ReturnType acquireFileWatcherRemoval: ReturnType + hydrateInferredWorktreeLineage: ReturnType } beforeEach(() => { setPlatform(ORIGINAL_PLATFORM) __resetSshWorktreeCreateFetchCacheForTests() __resetDetectedWorktreeScanCacheForTests() + resetSshProviderAuthorities() invalidateAuthorizedRootsCache() for (const m of [ handleMock, @@ -368,6 +383,9 @@ describe('registerWorktreeHandlers', () => { store.removeWorktreeMeta, store.getAllWorktreeLineage, store.removeWorktreeLineage, + store.getAllWorkspaceLineage, + store.getFolderWorkspaces, + store.getProjectGroups, killAllProcessesForWorktreeMock, clearProviderPtyStateMock, getLocalPtyProviderMock, @@ -432,6 +450,9 @@ describe('registerWorktreeHandlers', () => { } ]) store.getAllWorktreeLineage.mockReturnValue({}) + store.getAllWorkspaceLineage.mockReturnValue({}) + store.getFolderWorkspaces.mockReturnValue([]) + store.getProjectGroups.mockReturnValue([]) resolveLocalGitUsernameMock.mockResolvedValue('') getBaseRefDefaultMock.mockResolvedValue('origin/main') resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main') @@ -522,7 +543,8 @@ describe('registerWorktreeHandlers', () => { }), notifyWorktreesChangedForRemoteClients: vi.fn(), closeFileWatchersForRemoval: vi.fn().mockResolvedValue(undefined), - acquireFileWatcherRemoval: vi.fn() + acquireFileWatcherRemoval: vi.fn(), + hydrateInferredWorktreeLineage: vi.fn().mockResolvedValue(undefined) } runtimeStub.acquireFileWatcherRemoval.mockImplementation( async (worktreePath: string, connectionId?: string) => { @@ -2396,6 +2418,963 @@ describe('registerWorktreeHandlers', () => { }) }) + it('selects the exact SSH repo owner when repo IDs collide across hosts', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const localRepo = { + id: 'shared-repo', + path: '/local/repo', + displayName: 'local repo', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { + ...localRepo, + path: '/remote/repo', + displayName: 'remote repo', + connectionId: 'target-a' + } + const provider = { listWorktrees: vi.fn().mockResolvedValue([]) } + store.getRepos.mockImplementation(() => [{ ...localRepo }, { ...sshRepo }]) + getSshGitProviderMock.mockImplementation((targetId) => + targetId === 'target-a' ? provider : undefined + ) + const expectedAuthority = getSshProviderAuthority('target-a') + + const result = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: 'shared-repo', + executionHostId: sshHostId, + expectedAuthority + }) + + expect(provider.listWorktrees).toHaveBeenCalledWith('/remote/repo', { + signal: expect.any(AbortSignal) + }) + expect(result).toEqual({ + status: 'complete', + providerRequestId: 'request-1', + repoId: 'shared-repo', + authority: { + kind: 'direct-ssh', + executionHostId: sshHostId, + ...expectedAuthority + }, + result: { + repoId: 'shared-repo', + authoritative: true, + source: 'git', + worktrees: [] + } + }) + }) + + it('rejects malformed and contradictory repo host provenance', async () => { + const provider = { listWorktrees: vi.fn().mockResolvedValue([]) } + getSshGitProviderMock.mockReturnValue(provider) + const request = { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: 'repo-1', + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + } + const baseRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + + store.getRepos.mockReturnValue([{ ...baseRepo, executionHostId: 'ssh:%' }]) + const malformed = await handlers['worktrees:listDetected'](ipcEvent, request) + + store.getRepos.mockReturnValue([ + { + ...baseRepo, + executionHostId: toSshExecutionHostId('target-b') + } + ]) + const contradictory = await handlers['worktrees:listDetected'](ipcEvent, request) + + expect(malformed).toMatchObject({ + status: 'rejected', + providerRequestId: 'request-1', + executionHostId: 'ssh:target-a' + }) + expect(contradictory).toMatchObject({ + status: 'rejected', + providerRequestId: 'request-1', + executionHostId: 'ssh:target-a' + }) + expect(provider.listWorktrees).not.toHaveBeenCalled() + }) + + it('returns a local discriminant without SSH authority fields', async () => { + listWorktreesMock.mockResolvedValue([]) + + const result = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: 'repo-1', + executionHostId: 'local' + }) + + expect(result).toEqual({ + status: 'complete', + providerRequestId: 'request-1', + repoId: 'repo-1', + authority: { kind: 'local', executionHostId: 'local' }, + result: { + repoId: 'repo-1', + authoritative: true, + source: 'git', + worktrees: [] + } + }) + }) + + it('includes the full SSH authority on non-authoritative data', async () => { + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo]) + getSshGitProviderMock.mockReturnValue(undefined) + const expectedAuthority = getSshProviderAuthority('target-a') + + const result = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority + }) + + expect(result).toEqual({ + status: 'non-authoritative', + providerRequestId: 'request-1', + repoId: 'repo-1', + authority: { + kind: 'direct-ssh', + executionHostId: 'ssh:target-a', + ...expectedAuthority + }, + result: { + repoId: 'repo-1', + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + }) + }) + + it('fails closed for duplicate exact owners and ambiguous legacy repo IDs', async () => { + const sshRepo = { + id: 'shared-repo', + path: '/remote/repo-a', + displayName: 'remote repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + const duplicateSshRepo = { ...sshRepo, path: '/remote/repo-b' } + const localRepo = { ...sshRepo, path: '/local/repo', connectionId: undefined } + store.getRepos.mockReturnValue([sshRepo, duplicateSshRepo, localRepo]) + const expectedAuthority = getSshProviderAuthority('target-a') + + const qualified = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: 'shared-repo', + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority + }) + const legacy = await handlers['worktrees:listDetected'](null, { repoId: 'shared-repo' }) + + expect(qualified).toMatchObject({ + status: 'ambiguous-owner', + providerRequestId: 'request-1', + executionHostId: 'ssh:target-a' + }) + expect(legacy).toEqual({ + repoId: 'shared-repo', + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + }) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('does not prune another host lineage when repo IDs collide', async () => { + const sshARepo = { + id: 'shared-repo', + path: '/remote/repo-a', + displayName: 'remote repo A', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + const sshBRepo = { + ...sshARepo, + path: '/remote/repo-b', + displayName: 'remote repo B', + connectionId: 'target-b' + } + const childId = 'shared-repo::/remote/repo-b/feature' + store.getRepos.mockReturnValue([sshARepo, sshBRepo]) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => + worktreeId === childId + ? makeWorktreeMeta({ hostId: toSshExecutionHostId('target-b') }) + : undefined + ) + store.getAllWorktreeLineage.mockReturnValue({ + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'shared-repo::/remote/repo-b', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 0 + } + }) + getSshGitProviderMock.mockReturnValue({ listWorktrees: vi.fn().mockResolvedValue([]) }) + const expectedAuthority = getSshProviderAuthority('target-a') + + const result = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: 'shared-repo', + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority + }) + + expect(result).toMatchObject({ status: 'complete' }) + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + }) + + it('preserves conflicting host metadata instead of backfilling it', async () => { + const sshARepo = { + id: 'shared-repo', + path: '/remote/repo-a', + displayName: 'remote repo A', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + const sshBRepo = { ...sshARepo, path: '/remote/repo-b', connectionId: 'target-b' } + const worktreePath = '/remote/shared-feature' + store.getRepos.mockReturnValue([sshARepo, sshBRepo]) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => + worktreeId === `shared-repo::${worktreePath}` + ? makeWorktreeMeta({ hostId: toSshExecutionHostId('target-b') }) + : undefined + ) + getSshGitProviderMock.mockReturnValue({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: worktreePath, + head: 'head-a', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + }) + + const result = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: 'shared-repo', + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + }) + + expect(result).toMatchObject({ + status: 'non-authoritative', + result: { authoritative: false, worktrees: [] } + }) + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it('rejects runtime hosts, wrong SSH targets, and missing authority', async () => { + const requestId = 'request-1' as ProviderRequestId + const expectedAuthority = getSshProviderAuthority('target-a') + + const runtimeResult = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: requestId, + repoId: 'repo-1', + executionHostId: 'runtime:runtime-a' + }) + const wrongTargetResult = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: requestId, + repoId: 'repo-1', + executionHostId: toSshExecutionHostId('target-b'), + expectedAuthority + }) + const missingAuthorityResult = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: requestId, + repoId: 'repo-1', + executionHostId: toSshExecutionHostId('target-a') + }) + const zeroOwnerResult = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: requestId, + repoId: 'repo-1', + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority + }) + store.getRepos.mockReturnValue([ + { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-b', + executionHostId: toSshExecutionHostId('target-a') + } + ]) + const wrongProviderOwnerResult = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: requestId, + repoId: 'repo-1', + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority + }) + + expect(runtimeResult).toMatchObject({ status: 'rejected' }) + expect(wrongTargetResult).toMatchObject({ status: 'rejected' }) + expect(missingAuthorityResult).toMatchObject({ status: 'rejected' }) + expect(zeroOwnerResult).toMatchObject({ status: 'ambiguous-owner' }) + expect(wrongProviderOwnerResult).toMatchObject({ status: 'rejected' }) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('rejects a provider replacement during the SSH await without durable mutations', async () => { + let resolveList: (worktrees: GitWorktreeInfo[]) => void = () => {} + const firstProvider = { + listWorktrees: vi.fn( + () => + new Promise((resolve) => { + resolveList = resolve + }) + ) + } + const replacementProvider = { listWorktrees: vi.fn().mockResolvedValue([]) } + let currentProvider = firstProvider + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo]) + getSshGitProviderMock.mockImplementation(() => currentProvider) + const expectedAuthority = getSshProviderAuthority('target-a') + + const pending = handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority + }) + await Promise.resolve() + currentProvider = replacementProvider + resolveList([ + { + path: '/remote/repo', + head: 'stale-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + await expect(pending).resolves.toMatchObject({ status: 'stale' }) + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it.each([ + ['malformed', 'ssh:%'], + ['contradictory', toSshExecutionHostId('target-b')] + ])( + 'rejects %s repo provenance introduced during the SSH await', + async (_caseName, invalidExecutionHostId) => { + let resolveList: (worktrees: GitWorktreeInfo[]) => void = () => {} + const provider = { + listWorktrees: vi.fn( + () => + new Promise((resolve) => { + resolveList = resolve + }) + ) + } + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + let repos: Repo[] = [sshRepo] + store.getRepos.mockImplementation(() => repos) + getSshGitProviderMock.mockReturnValue(provider) + + const pending = handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: `request-${_caseName}` as ProviderRequestId, + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + }) + await Promise.resolve() + repos = [ + sshRepo, + { + ...sshRepo, + path: '/remote/conflicting-repo', + executionHostId: invalidExecutionHostId as Repo['executionHostId'] + } + ] + resolveList([ + { + path: '/remote/repo', + head: 'stale-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + await expect(pending).resolves.toMatchObject({ status: 'stale' }) + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + } + ) + + it('aborts all old-authority SSH calls on rotation with target isolation', async () => { + const repos = [ + { + id: 'repo-a', + path: '/remote/repo-a', + displayName: 'repo A', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + }, + { + id: 'repo-b', + path: '/remote/repo-b', + displayName: 'repo B', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-b' + } + ] + const resolveA: ((worktrees: GitWorktreeInfo[]) => void)[] = [] + let resolveB: (worktrees: GitWorktreeInfo[]) => void = () => {} + const signalsA: AbortSignal[] = [] + let signalB: AbortSignal | undefined + const abortsA = [vi.fn(), vi.fn()] + const abortB = vi.fn() + const providerA = { + listWorktrees: vi.fn((_path: string, options?: { signal?: AbortSignal }) => { + const index = signalsA.length + const signal = options?.signal + if (signal) { + signalsA.push(signal) + signal.addEventListener('abort', abortsA[index]) + } + return new Promise((resolve) => { + resolveA.push(resolve) + }) + }) + } + const providerB = { + listWorktrees: vi.fn((_path: string, options?: { signal?: AbortSignal }) => { + signalB = options?.signal + signalB?.addEventListener('abort', abortB) + return new Promise((resolve) => { + resolveB = resolve + }) + }) + } + store.getRepos.mockReturnValue(repos) + getSshGitProviderMock.mockImplementation((targetId) => + targetId === 'target-a' ? providerA : providerB + ) + const authorityA = getSshProviderAuthority('target-a') + const authorityB = getSshProviderAuthority('target-b') + const request = ( + repo: (typeof repos)[number], + providerRequestId: ProviderRequestId, + expectedAuthority: ReturnType + ) => + handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId, + repoId: repo.id, + executionHostId: toSshExecutionHostId(repo.connectionId), + expectedAuthority + }) + + const pendingA1 = request(repos[0], 'request-a1' as ProviderRequestId, authorityA) + const pendingA2 = request(repos[0], 'request-a2' as ProviderRequestId, authorityA) + const pendingB = request(repos[1], 'request-b' as ProviderRequestId, authorityB) + await Promise.resolve() + + rotateSshProviderAuthority('target-a') + rotateSshProviderAuthority('target-a') + + expect(signalsA).toHaveLength(2) + expect(signalsA.every((signal) => signal.aborted)).toBe(true) + expect(abortsA[0]).toHaveBeenCalledOnce() + expect(abortsA[1]).toHaveBeenCalledOnce() + expect(signalB?.aborted).toBe(false) + expect(abortB).not.toHaveBeenCalled() + await expect(Promise.all([pendingA1, pendingA2])).resolves.toEqual([ + expect.objectContaining({ status: 'canceled', providerRequestId: 'request-a1' }), + expect.objectContaining({ status: 'canceled', providerRequestId: 'request-a2' }) + ]) + + resolveB([]) + await expect(pendingB).resolves.toMatchObject({ + status: 'complete', + providerRequestId: 'request-b' + }) + rotateSshProviderAuthority('target-b') + expect(abortB).not.toHaveBeenCalled() + + store.setWorktreeMeta.mockClear() + store.removeWorktreeLineage.mockClear() + for (const resolve of resolveA) { + resolve([ + { + path: '/remote/repo-a', + head: 'late-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + } + await Promise.resolve() + await Promise.resolve() + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it('cancels an SSH provider request by sender-scoped provider request ID', async () => { + let providerSignal: AbortSignal | undefined + const provider = { + listWorktrees: vi.fn( + (_repoPath: string, options?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + providerSignal = options?.signal + providerSignal?.addEventListener( + 'abort', + () => reject(new DOMException('Canceled', 'AbortError')), + { once: true } + ) + }) + ) + } + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo]) + getSshGitProviderMock.mockReturnValue(provider) + + const pending = handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + }) + await Promise.resolve() + handlers['worktrees:cancelListDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId + }) + + expect(providerSignal?.aborted).toBe(true) + await expect(pending).resolves.toMatchObject({ + status: 'canceled', + providerRequestId: 'request-1' + }) + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it('settles a noncooperative SSH provider at the main-owned deadline and cleans up', async () => { + vi.useFakeTimers() + try { + let providerSignal: AbortSignal | undefined + let rejectLateRequest: (error: Error) => void = () => {} + const provider = { + listWorktrees: vi.fn((_repoPath: string, options?: { signal?: AbortSignal }) => { + if (provider.listWorktrees.mock.calls.length > 1) { + return Promise.resolve([]) + } + return new Promise((_resolve, reject) => { + rejectLateRequest = reject + // Why: this provider intentionally ignores abort to exercise the main-owned deadline. + if (options?.signal) { + providerSignal = options?.signal + } + }) + }) + } + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo]) + getSshGitProviderMock.mockReturnValue(provider) + + const pending = handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + }) + await Promise.resolve() + await vi.advanceTimersByTimeAsync(DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS - 1) + let settled = false + void Promise.resolve(pending).finally(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + + expect(providerSignal?.aborted).toBe(true) + await expect(pending).resolves.toMatchObject({ + status: 'timed-out', + providerRequestId: 'request-1' + }) + expect(vi.getTimerCount()).toBe(0) + + await expect( + handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + }) + ).resolves.toMatchObject({ + status: 'complete', + providerRequestId: 'request-1' + }) + + store.setWorktreeMeta.mockClear() + store.removeWorktreeLineage.mockClear() + rejectLateRequest(new Error('late provider failure')) + await Promise.resolve() + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('filters worktree and folder lineage to one exact SSH host', async () => { + const repos = [ + { + id: 'duplicate', + path: '/a/repo', + displayName: 'a', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a', + projectGroupId: 'group-a' + }, + { + id: 'duplicate', + path: '/b/repo', + displayName: 'b', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-b', + projectGroupId: 'group-b' + } + ] + const aParent = 'duplicate::/a/repo' + const aChild = 'duplicate::/a/child' + const bParent = 'duplicate::/b/repo' + const bChild = 'duplicate::/b/child' + const runtimeParent = 'duplicate::/runtime/parent' + const runtimeChild = 'duplicate::/runtime/child' + const worktreeLineage = { + [aChild]: { + worktreeId: aChild, + worktreeInstanceId: 'a-child', + parentWorktreeId: aParent, + parentWorktreeInstanceId: 'a-parent', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + }, + [bChild]: { + worktreeId: bChild, + worktreeInstanceId: 'b-child', + parentWorktreeId: bParent, + parentWorktreeInstanceId: 'b-parent', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 2 + }, + [runtimeChild]: { + worktreeId: runtimeChild, + worktreeInstanceId: 'runtime-child', + parentWorktreeId: runtimeParent, + parentWorktreeInstanceId: 'runtime-parent', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 3 + } + } + const folderLineage = { + 'folder:folder-a-child': { + childWorkspaceKey: 'folder:folder-a-child', + parentWorkspaceKey: 'folder:folder-a-parent', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 3 + }, + 'folder:folder-b-child': { + childWorkspaceKey: 'folder:folder-b-child', + parentWorkspaceKey: 'folder:folder-b-parent', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 4 + } + } + store.getRepos.mockReturnValue(repos) + store.getWorktreeMeta.mockImplementation((id: string) => + id.includes('/runtime/') + ? { hostId: 'ssh:target-a', runtimeOwnerEnvironmentId: 'environment-a' } + : { + hostId: id.includes('/a/') || id.endsWith('/a/repo') ? 'ssh:target-a' : 'ssh:target-b' + } + ) + store.getAllWorktreeLineage.mockReturnValue(worktreeLineage) + store.getAllWorkspaceLineage.mockReturnValue(folderLineage) + store.getProjectGroups.mockReturnValue([ + { id: 'group-a', connectionId: 'target-a' }, + { id: 'group-b', connectionId: 'target-b' } + ]) + store.getFolderWorkspaces.mockReturnValue([ + { + id: 'folder-a-child', + projectGroupId: 'group-a', + folderPath: '/a/child', + connectionId: 'target-a' + }, + { + id: 'folder-a-parent', + projectGroupId: 'group-a', + folderPath: '/a', + connectionId: 'target-a' + }, + { + id: 'folder-b-child', + projectGroupId: 'group-b', + folderPath: '/b/child', + connectionId: 'target-b' + }, + { + id: 'folder-b-parent', + projectGroupId: 'group-b', + folderPath: '/b', + connectionId: 'target-b' + } + ]) + const provider = { listWorktrees: vi.fn() } + getSshGitProviderMock.mockImplementation((targetId: string) => + targetId === 'target-a' ? provider : undefined + ) + + const result = await handlers['worktrees:listLineageForHost'](ipcEvent, { + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + }) + + expect(result).toMatchObject({ + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: 'ssh:target-a', + targetId: 'target-a' + }, + worktreeLineageById: { [aChild]: worktreeLineage[aChild] }, + workspaceLineageByChildKey: { + 'folder:folder-a-child': folderLineage['folder:folder-a-child'] + } + }) + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it('snapshots lineage catalogs once and memoizes repeated owner resolution', async () => { + const worktreeIds = Array.from( + { length: 101 }, + (_, index) => `repo-1::/workspace/repo-${index}` + ) + const lineage = Object.fromEntries( + worktreeIds.slice(1).map((worktreeId, index) => [ + worktreeId, + { + worktreeId, + worktreeInstanceId: `child-${index}`, + parentWorktreeId: worktreeIds[index], + parentWorktreeInstanceId: `parent-${index}`, + origin: 'cli', + capture: { source: 'cwd-context', confidence: 'inferred' }, + createdAt: index + } + ]) + ) + store.getAllWorktreeLineage.mockReturnValue(lineage) + store.getRepos.mockClear() + store.getFolderWorkspaces.mockClear() + store.getProjectGroups.mockClear() + store.getWorktreeMeta.mockClear() + + const result = await handlers['worktrees:listLineageForHost'](ipcEvent, { + executionHostId: 'local' + }) + + expect(result).toMatchObject({ authoritative: true }) + expect( + Object.keys((result as { worktreeLineageById: Record }).worktreeLineageById) + ).toHaveLength(100) + expect(store.getRepos).toHaveBeenCalledOnce() + expect(store.getFolderWorkspaces).toHaveBeenCalledOnce() + expect(store.getProjectGroups).toHaveBeenCalledOnce() + expect(store.getWorktreeMeta).toHaveBeenCalledTimes(101) + }) + + it('preserves ambiguous legacy lineage instead of guessing among duplicate repo owners', async () => { + const child = 'duplicate::/child' + const parent = 'duplicate::/parent' + store.getRepos.mockReturnValue([ + { + id: 'duplicate', + path: '/local', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + }, + { + id: 'duplicate', + path: '/remote', + displayName: 'remote', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + ]) + store.getAllWorktreeLineage.mockReturnValue({ + [child]: { + worktreeId: child, + worktreeInstanceId: 'child', + parentWorktreeId: parent, + parentWorktreeInstanceId: 'parent', + origin: 'cli', + capture: { source: 'cwd-context', confidence: 'inferred' }, + createdAt: 1 + } + }) + + await expect( + handlers['worktrees:listLineageForHost'](ipcEvent, { executionHostId: 'local' }) + ).resolves.toEqual({ + authoritative: false, + executionHostId: 'local', + reason: 'ambiguous-owner' + }) + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it('rejects runtime lineage reads and stale SSH authority after hydration', async () => { + await expect( + handlers['worktrees:listLineageForHost'](ipcEvent, { + executionHostId: 'runtime:environment-a' + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + expect(runtimeStub.hydrateInferredWorktreeLineage).not.toHaveBeenCalled() + + let finishHydration: () => void = () => {} + runtimeStub.hydrateInferredWorktreeLineage.mockImplementation( + () => + new Promise((resolve) => { + finishHydration = resolve + }) + ) + getSshGitProviderMock.mockReturnValue({ listWorktrees: vi.fn() }) + const authority = getSshProviderAuthority('target-a') + const pending = handlers['worktrees:listLineageForHost'](ipcEvent, { + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: authority + }) + await Promise.resolve() + rotateSshProviderAuthority('target-a') + finishHydration() + + await expect(pending).resolves.toMatchObject({ authoritative: false, reason: 'stale' }) + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it('bounds noncooperative lineage hydration and permits a later same-authority read', async () => { + vi.useFakeTimers() + try { + runtimeStub.hydrateInferredWorktreeLineage.mockReturnValue(new Promise(() => {})) + getSshGitProviderMock.mockReturnValue({ listWorktrees: vi.fn() }) + const authority = getSshProviderAuthority('target-a') + const pending = handlers['worktrees:listLineageForHost'](ipcEvent, { + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: authority + }) + await vi.advanceTimersByTimeAsync(LINEAGE_HYDRATION_TIMEOUT_MS - 1) + let settled = false + void Promise.resolve(pending).finally(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await expect(pending).resolves.toMatchObject({ + authoritative: false, + reason: 'unavailable' + }) + expect(vi.getTimerCount()).toBe(0) + + runtimeStub.hydrateInferredWorktreeLineage.mockResolvedValue(undefined) + await expect( + handlers['worktrees:listLineageForHost'](ipcEvent, { + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: authority + }) + ).resolves.toMatchObject({ authoritative: true }) + } finally { + vi.useRealTimers() + } + }) + it('hydrates detected worktrees with instance-validated legacy lineage after an update', async () => { const parentPath = '/workspace/assigned-issues' const childPath = '/workspace/issue-9276-nested-ssh-runtime-routing' @@ -2957,14 +3936,6 @@ describe('registerWorktreeHandlers', () => { }) it('does not accumulate scan bookkeeping across prolonged repository churn', async () => { - store.getRepo.mockImplementation((repoId: string) => ({ - id: repoId, - path: `/workspace/${repoId}`, - displayName: repoId, - badgeColor: '#000', - addedAt: 0, - worktreeBaseRef: null - })) listWorktreesMock.mockImplementation(async (repoPath: string) => [ { path: repoPath, @@ -2977,6 +3948,16 @@ describe('registerWorktreeHandlers', () => { for (let index = 0; index < 128; index += 1) { const repoId = `repo-${index}` + store.getRepos.mockReturnValue([ + { + id: repoId, + path: `/workspace/${repoId}`, + displayName: repoId, + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + } + ]) await handlers['worktrees:listDetected'](null, { repoId }) notifyWorktreesChanged(mainWindow as never, repoId) } diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 43a0fbfbf55..85560616749 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1,6 +1,5 @@ /* oxlint-disable max-lines */ -import type { BrowserWindow } from 'electron' -import { ipcMain } from 'electron' +import { ipcMain, type BrowserWindow } from 'electron' import { readFile, stat } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import type { Store } from '../persistence' @@ -13,7 +12,9 @@ import { } from '../../shared/workspace-scope' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' +import { getProjectGroupSubtreeIds } from '../../shared/project-groups' import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' +import { isPathInsideOrEqual, isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { deleteWorktreeHistoryDir } from '../terminal-history' import type { AutomationWorkspaceProvenance, @@ -30,10 +31,30 @@ import type { Repo, RemoveWorktreeResult, Worktree, + WorktreeLineage, + WorkspaceLineage, WorktreeMeta } from '../../shared/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' -import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' +import { + PROVIDER_REQUEST_ID_MAX_UTF8_BYTES, + type DirectSshDetectedWorktreeRequest, + type HostQualifiedDetectedWorktreeResult, + type ListDetectedWorktreesArgs, + type ProviderRequestId +} from '../../shared/detected-worktree-provider-contract' +import type { + HostLineageSnapshot, + ListDesktopLineageForHostArgs +} from '../../shared/host-lineage-contract' +import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' import { applyMetadataFallbackVisibility, buildKnownOrcaWorkspaceLayouts, @@ -108,6 +129,12 @@ import { resolveAutomationWorkspaceProvenance } from '../automations/workspace-provenance' import { shouldEmitBoundedWarning } from './bounded-warning-dedupe' +import { + getSshProviderAuthority, + isCurrentSshProviderAuthority, + registerSshProviderRequestAbort +} from '../ssh/ssh-provider-authority' +import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -121,6 +148,8 @@ type RemoveWorktreeArgs = { skipArchive?: boolean } +type DetectedWorktreeRequestArgs = { repoId: string } | ListDetectedWorktreesArgs + async function stopPtysForDestructiveWorktreeRemoval( runtime: OrcaRuntimeService, worktreeId: string, @@ -181,7 +210,6 @@ import { stripOrcaProvenanceMetaUpdates, UNREGISTERED_MISSING_WORKTREE_MESSAGE } from '../worktree-removal-safety' -import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, @@ -250,7 +278,7 @@ function getProjectHostSetupMetaUpdates( ...(sameSetup && existing?.projectId !== ownership.projectId ? { projectId: ownership.projectId } : {}), - ...(sameSetup && existing?.hostId !== ownership.hostId ? { hostId: ownership.hostId } : {}), + ...(sameSetup && existing?.hostId === undefined ? { hostId: ownership.hostId } : {}), ...(existing?.projectHostSetupId === undefined ? { projectHostSetupId: ownership.projectHostSetupId } : {}) @@ -476,6 +504,8 @@ function getPreservedBranchCleanupTarget( const loggedUnavailableSshGitProviders = new Set() const loggedWorktreeListFailures = new Set() const loggedMalformedWorktreeMetaKeys = new Set() +export const DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS = 30_000 +export const LINEAGE_HYDRATION_TIMEOUT_MS = 5_000 // Why: absorb renderer polling bursts while bounding external worktree-change lag to one short refresh window. const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000 @@ -628,11 +658,18 @@ function pruneLineageForMissingRepoWorktrees( } const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) const repoPrefix = `${repo.id}::` + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) + const canMutateWorktree = (worktreeId: string): boolean => { + const hostId = store.getWorktreeMeta(worktreeId)?.hostId + return hostId ? hostId === expectedHostId : repoOwners.length === 1 + } for (const childWorkspaceKey of Object.keys(store.getAllWorkspaceLineage?.() ?? {})) { const childScope = parseWorkspaceKey(childWorkspaceKey) if ( childScope?.type === 'worktree' && childScope.worktreeId.startsWith(repoPrefix) && + canMutateWorktree(childScope.worktreeId) && !liveIds.has(childScope.worktreeId) ) { if (isWorkspaceKey(childWorkspaceKey)) { @@ -641,12 +678,16 @@ function pruneLineageForMissingRepoWorktrees( } } for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { - if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) { + if (childId.startsWith(repoPrefix) && canMutateWorktree(childId) && !liveIds.has(childId)) { // Why: path-derived IDs can be reused; once a scan proves the child is gone, drop its lineage so a future same-path worktree can't inherit it. store.removeWorktreeLineage(childId) store.removeWorkspaceLineage?.(worktreeWorkspaceKey(childId)) } - if (lineage.parentWorktreeId.startsWith(repoPrefix) && !liveIds.has(lineage.parentWorktreeId)) { + if ( + lineage.parentWorktreeId.startsWith(repoPrefix) && + canMutateWorktree(lineage.parentWorktreeId) && + !liveIds.has(lineage.parentWorktreeId) + ) { const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { // Why: keep child lineage for the "Missing parent" UI, but rotate the absent parent's identity once so a path reuse can't inherit it. @@ -708,7 +749,15 @@ function listDisconnectedSshWorktrees( metaIndex: SshWorktreeMetaIndex ): ReturnType[] { const byWorktreeId = new Map>() + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) for (const candidate of metaIndex.get(repo.id) ?? []) { + if ( + (candidate.meta.hostId && candidate.meta.hostId !== expectedHostId) || + (!candidate.meta.hostId && repoOwners.length > 1) + ) { + continue + } const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidate.meta) const meta = Object.keys(ownershipUpdates).length > 0 @@ -984,16 +1033,718 @@ function buildDisconnectedDetectedWorktrees( return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } +function hasConflictingStoredWorktreeOwner( + store: Store, + repo: Repo, + worktreeIds: readonly string[] +): boolean { + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length + return worktreeIds.some((worktreeId) => { + const meta = store.getWorktreeMeta(worktreeId) + return !!meta && (meta.hostId ? meta.hostId !== expectedHostId : repoOwnerCount > 1) + }) +} + +type RepoOwnershipEvidence = + | { status: 'owned'; hostId: ExecutionHostId } + | { status: 'malformed' } + | { status: 'contradictory' } + +function resolveRepoOwnershipEvidence(repo: Repo): RepoOwnershipEvidence { + const hasExplicitHost = repo.executionHostId !== null && repo.executionHostId !== undefined + const explicitHost = hasExplicitHost ? parseExecutionHostId(repo.executionHostId) : null + if (hasExplicitHost && !explicitHost) { + return { status: 'malformed' } + } + const hasConnection = repo.connectionId !== null && repo.connectionId !== undefined + const connectionId = hasConnection ? repo.connectionId?.trim() : null + if (hasConnection && !connectionId) { + return { status: 'malformed' } + } + const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null + if (explicitHost && connectionHostId && explicitHost.id !== connectionHostId) { + return { status: 'contradictory' } + } + return { + status: 'owned', + hostId: explicitHost?.id ?? connectionHostId ?? LOCAL_EXECUTION_HOST_ID + } +} + +function findExactRepoOwner( + store: Store, + repoId: string, + executionHostId?: ExecutionHostId +): Repo | undefined { + const candidates = store.getRepos().filter((repo) => repo.id === repoId) + const evidence = candidates.map(resolveRepoOwnershipEvidence) + if (evidence.some((owner) => owner.status !== 'owned')) { + return undefined + } + const matches = candidates.filter((_, index) => { + const owner = evidence[index] + return ( + owner?.status === 'owned' && + (executionHostId === undefined || owner.hostId === executionHostId) + ) + }) + return matches.length === 1 ? matches[0] : undefined +} + +function isCapturedRepoCurrent( + store: Store, + repo: Repo, + executionHostId?: ExecutionHostId +): boolean { + const current = findExactRepoOwner(store, repo.id, executionHostId) + return ( + current !== undefined && + current.path === repo.path && + (current.connectionId ?? null) === (repo.connectionId ?? null) && + (current.executionHostId ?? null) === (repo.executionHostId ?? null) + ) +} + +async function listDetectedWorktreesForCapturedRepo( + store: Store, + repo: Repo, + isCurrent: () => boolean, + capturedProvider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, + providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } +): Promise { + const abortedResult = () => + providerAbort?.signal.aborted + ? ({ providerAbortStatus: providerAbort.status() } as const) + : undefined + const sshWorktreeMetaIndex = repo.connectionId + ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + : new Map() + + try { + let gitWorktrees: GitWorktreeInfo[] + let freshScan = true + if (isFolderRepo(repo)) { + if (!isCurrent()) { + return null + } + const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + if (hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds)) { + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) + } + } + if (repo.connectionId) { + if (!capturedProvider) { + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) + } + } + gitWorktrees = await capturedProvider.listWorktrees(repo.path, { + signal: providerAbort?.signal + }) + } else { + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + const listedWorktreeIds = gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`) + if (hasConflictingStoredWorktreeOwner(store, repo, listedWorktreeIds)) { + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + } + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees) + } + } catch (err) { + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + if (repo.connectionId) { + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) + } + } + return { repoId: repo.id, authoritative: false, source: 'metadata-fallback', worktrees: [] } + } +} + +function hasValidDirectSshAuthority( + args: DirectSshDetectedWorktreeRequest +): args is DirectSshDetectedWorktreeRequest { + return isAdmissibleDirectSshAuthority(args.expectedAuthority) +} + +function hasValidLineageSshAuthority( + args: ListDesktopLineageForHostArgs +): args is Extract { + if (!('expectedAuthority' in args)) { + return false + } + return isAdmissibleDirectSshAuthority(args.expectedAuthority) +} + +type LineageOwner = + | { status: 'owned'; hostId: ExecutionHostId } + | { status: 'ambiguous' | 'contradictory' | 'runtime' } + +type LineageFolder = ReturnType[number] +type LineageGroup = ReturnType[number] + +type LineageResolutionContext = { + store: Store + repos: Repo[] + groups: LineageGroup[] + reposById: Map + foldersById: Map + groupsById: Map + groupSubtreeIdsByRoot: Map> + worktreeOwners: Map + folderOwners: Map + workspaceOwners: Map +} + +function indexLineageEntriesById( + entries: readonly T[] +): Map { + const index = new Map() + for (const entry of entries) { + const matching = index.get(entry.id) ?? [] + matching.push(entry) + index.set(entry.id, matching) + } + return index +} + +function createLineageResolutionContext(store: Store): LineageResolutionContext { + const repos = store.getRepos() + const folders = store.getFolderWorkspaces() + const groups = store.getProjectGroups() + return { + store, + repos, + groups, + reposById: indexLineageEntriesById(repos), + foldersById: indexLineageEntriesById(folders), + groupsById: indexLineageEntriesById(groups), + groupSubtreeIdsByRoot: new Map(), + worktreeOwners: new Map(), + folderOwners: new Map(), + workspaceOwners: new Map() + } +} + +function resolveRepoLineageOwner(repo: Repo): LineageOwner { + const owner = resolveRepoOwnershipEvidence(repo) + if (owner.status === 'malformed') { + return { status: 'ambiguous' } + } + if (owner.status === 'contradictory') { + return { status: 'contradictory' } + } + return parseExecutionHostId(owner.hostId)?.kind === 'runtime' ? { status: 'runtime' } : owner +} + +function resolveWorktreeLineageOwner( + context: LineageResolutionContext, + worktreeId: string +): LineageOwner { + const cached = context.worktreeOwners.get(worktreeId) + if (cached) { + return cached + } + const remember = (owner: LineageOwner): LineageOwner => { + context.worktreeOwners.set(worktreeId, owner) + return owner + } + let repoId: string + try { + repoId = parseWorktreeId(worktreeId).repoId + } catch { + return remember({ status: 'ambiguous' }) + } + const repos = context.reposById.get(repoId) ?? [] + const meta = context.store.getWorktreeMeta(worktreeId) + const runtimeOwnerEnvironmentId = ( + meta as (WorktreeMeta & { runtimeOwnerEnvironmentId?: string }) | undefined + )?.runtimeOwnerEnvironmentId?.trim() + if (runtimeOwnerEnvironmentId) { + return remember({ status: 'runtime' }) + } + if (meta?.hostId) { + const explicitHost = parseExecutionHostId(meta.hostId) + if (!explicitHost) { + return remember({ status: 'ambiguous' }) + } + if (explicitHost.kind === 'runtime') { + return remember({ status: 'runtime' }) + } + const matchingRepos = repos.filter((repo) => { + const owner = resolveRepoLineageOwner(repo) + return owner.status === 'owned' && owner.hostId === explicitHost.id + }) + if (matchingRepos.length === 1) { + return remember({ status: 'owned', hostId: explicitHost.id }) + } + return remember( + matchingRepos.length > 1 + ? { status: 'ambiguous' } + : { status: repos.length > 0 ? 'contradictory' : 'ambiguous' } + ) + } + if (repos.length !== 1) { + return remember({ status: 'ambiguous' }) + } + return remember(resolveRepoLineageOwner(repos[0])) +} + +function getFolderLineageCandidateRepos( + context: LineageResolutionContext, + folder: LineageFolder +): Repo[] { + let groupIds = context.groupSubtreeIdsByRoot.get(folder.projectGroupId) + if (!groupIds) { + groupIds = getProjectGroupSubtreeIds(context.groups, folder.projectGroupId) + context.groupSubtreeIdsByRoot.set(folder.projectGroupId, groupIds) + } + const grouped = context.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = context.repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + isPathInsideOrEqual(folder.folderPath, repo.path) + ) + const group = context.groupsById.get(folder.projectGroupId)?.[0] + const connectionId = folder.connectionId ?? group?.connectionId ?? null + return connectionId + ? [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] + : grouped.length > 0 + ? [ + ...grouped, + ...pathRepos.filter((repo) => + new Set(grouped.map((candidate) => candidate.connectionId ?? null)).has( + repo.connectionId ?? null + ) + ) + ] + : pathRepos +} + +function resolveFolderLineageOwner( + context: LineageResolutionContext, + folderWorkspaceId: string +): LineageOwner { + const cached = context.folderOwners.get(folderWorkspaceId) + if (cached) { + return cached + } + const remember = (owner: LineageOwner): LineageOwner => { + context.folderOwners.set(folderWorkspaceId, owner) + return owner + } + const folders = context.foldersById.get(folderWorkspaceId) ?? [] + if (folders.length !== 1) { + return remember({ status: 'ambiguous' }) + } + const folder = folders[0] + const groups = context.groupsById.get(folder.projectGroupId) ?? [] + if (groups.length !== 1) { + return remember({ status: 'ambiguous' }) + } + const group = groups[0] + const hosts = new Set() + if (folder.connectionId) { + hosts.add(`ssh:${encodeURIComponent(folder.connectionId)}`) + } + if (group.connectionId) { + hosts.add(`ssh:${encodeURIComponent(group.connectionId)}`) + } + if (group.executionHostId) { + const parsed = parseExecutionHostId(group.executionHostId) + if (!parsed) { + return remember({ status: 'ambiguous' }) + } + hosts.add(parsed.id) + } + for (const repo of getFolderLineageCandidateRepos(context, folder)) { + const owner = resolveRepoLineageOwner(repo) + if (owner.status !== 'owned') { + return remember(owner) + } + hosts.add(owner.hostId) + } + if (hosts.size > 1) { + return remember({ status: 'contradictory' }) + } + const hostId = [...hosts][0] ?? LOCAL_EXECUTION_HOST_ID + return remember( + parseExecutionHostId(hostId)?.kind === 'runtime' + ? { status: 'runtime' } + : { status: 'owned', hostId } + ) +} + +function resolveWorkspaceLineageOwner( + context: LineageResolutionContext, + workspaceKey: string +): LineageOwner { + const cached = context.workspaceOwners.get(workspaceKey) + if (cached) { + return cached + } + const workspace = parseWorkspaceKey(workspaceKey) + const owner = !workspace + ? { status: 'ambiguous' as const } + : workspace.type === 'worktree' + ? resolveWorktreeLineageOwner(context, workspace.worktreeId) + : resolveFolderLineageOwner(context, workspace.folderWorkspaceId) + context.workspaceOwners.set(workspaceKey, owner) + return owner +} + +function filterLineageForHost( + store: Store, + executionHostId: ExecutionHostId +): { + worktreeLineageById: Record + workspaceLineageByChildKey: Record +} | null { + const context = createLineageResolutionContext(store) + const worktreeLineageById: Record = {} + const workspaceLineageByChildKey: Record = {} + for (const [worktreeId, lineage] of Object.entries(store.getAllWorktreeLineage())) { + const child = resolveWorktreeLineageOwner(context, worktreeId) + const parent = resolveWorktreeLineageOwner(context, lineage.parentWorktreeId) + if (child.status === 'ambiguous' || child.status === 'contradictory') { + return null + } + if (parent.status === 'ambiguous' || parent.status === 'contradictory') { + return null + } + if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId === executionHostId && + parent.hostId === executionHostId + ) { + worktreeLineageById[worktreeId] = structuredClone(lineage) + } else if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId !== parent.hostId + ) { + return null + } + } + for (const [childKey, lineage] of Object.entries(store.getAllWorkspaceLineage())) { + const child = resolveWorkspaceLineageOwner(context, childKey) + const parent = resolveWorkspaceLineageOwner(context, lineage.parentWorkspaceKey) + if (child.status === 'ambiguous' || child.status === 'contradictory') { + return null + } + if (parent.status === 'ambiguous' || parent.status === 'contradictory') { + return null + } + if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId === executionHostId && + parent.hostId === executionHostId + ) { + workspaceLineageByChildKey[childKey] = structuredClone(lineage) + } else if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId !== parent.hostId + ) { + return null + } + } + return { worktreeLineageById, workspaceLineageByChildKey } +} + +async function hydrateLineageWithinDeadline(runtime: OrcaRuntimeService): Promise { + let timeout: ReturnType | undefined + const hydration = Promise.resolve() + .then(() => runtime.hydrateInferredWorktreeLineage()) + .then( + () => true, + () => false + ) + const deadline = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), LINEAGE_HYDRATION_TIMEOUT_MS) + }) + try { + return await Promise.race([hydration, deadline]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +async function listDesktopLineageForHost( + store: Store, + runtime: OrcaRuntimeService, + args: ListDesktopLineageForHostArgs +): Promise { + const parsedHost = parseExecutionHostId(args?.executionHostId) + const rejected = ( + reason: Extract['reason'] + ): HostLineageSnapshot => ({ + authoritative: false, + executionHostId: args.executionHostId, + reason + }) + if (!parsedHost || parsedHost.kind === 'runtime') { + return rejected('rejected') + } + let provider: ReturnType | undefined + let authority: + | Extract['expectedAuthority'] + | null = null + if (parsedHost.kind === 'local') { + if ('expectedAuthority' in args) { + return rejected('rejected') + } + } else { + if ( + !hasValidLineageSshAuthority(args) || + args.expectedAuthority.targetId !== parsedHost.targetId + ) { + return rejected('rejected') + } + authority = { ...args.expectedAuthority } + if (!isCurrentSshProviderAuthority(authority)) { + return rejected('stale') + } + provider = getSshGitProvider(parsedHost.targetId) + if (!provider) { + return rejected('unavailable') + } + } + if (!(await hydrateLineageWithinDeadline(runtime))) { + return rejected('unavailable') + } + if ( + parsedHost.kind === 'ssh' && + (!authority || + getSshGitProvider(parsedHost.targetId) !== provider || + !isCurrentSshProviderAuthority(authority)) + ) { + return rejected('stale') + } + const lineage = filterLineageForHost(store, parsedHost.id) + if (!lineage) { + return rejected('ambiguous-owner') + } + if (parsedHost.kind === 'local') { + return { + authoritative: true, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + ...lineage + } + } + if (!authority) { + return rejected('authority-unknown') + } + return { + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: parsedHost.id, + ...authority + }, + ...lineage + } +} + +async function listHostQualifiedDetectedWorktrees( + store: Store, + args: ListDetectedWorktreesArgs, + providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } +): Promise { + const parsedHost = parseExecutionHostId(args.executionHostId) + const rejected = (status: 'rejected' | 'stale' | 'ambiguous-owner') => ({ + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status + }) + if ( + typeof args.providerRequestId !== 'string' || + args.providerRequestId.length === 0 || + Buffer.byteLength(args.providerRequestId, 'utf8') > PROVIDER_REQUEST_ID_MAX_UTF8_BYTES || + !parsedHost || + parsedHost.kind === 'runtime' + ) { + return rejected('rejected') + } + let capturedAuthority: DirectSshDetectedWorktreeRequest['expectedAuthority'] | null = null + if (parsedHost.kind === 'ssh') { + const directArgs = args as DirectSshDetectedWorktreeRequest + if ( + !hasValidDirectSshAuthority(directArgs) || + directArgs.expectedAuthority.targetId !== parsedHost.targetId + ) { + return rejected('rejected') + } + capturedAuthority = { ...directArgs.expectedAuthority } + if (!isCurrentSshProviderAuthority(capturedAuthority)) { + return rejected('stale') + } + } + + const repoCandidates = store.getRepos().filter((candidate) => candidate.id === args.repoId) + if ( + repoCandidates.some((candidate) => resolveRepoOwnershipEvidence(candidate).status !== 'owned') + ) { + return rejected('rejected') + } + const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) + if (!repo) { + return rejected('ambiguous-owner') + } + if ( + (parsedHost.kind === 'local' && repo.connectionId) || + (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) + ) { + return rejected('rejected') + } + const provider = parsedHost.kind === 'ssh' ? getSshGitProvider(parsedHost.targetId) : undefined + const isCurrent = (): boolean => { + if (!isCapturedRepoCurrent(store, repo, args.executionHostId)) { + return false + } + if ( + (parsedHost.kind === 'local' && repo.connectionId) || + (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) + ) { + return false + } + if (parsedHost.kind !== 'ssh') { + return true + } + return ( + capturedAuthority !== null && + getSshGitProvider(parsedHost.targetId) === provider && + isCurrentSshProviderAuthority(capturedAuthority) + ) + } + const result = await listDetectedWorktreesForCapturedRepo( + store, + repo, + isCurrent, + provider, + providerAbort + ) + if (!result) { + return rejected('stale') + } + if ('providerAbortStatus' in result) { + return { + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status: result.providerAbortStatus + } + } + const status = result.authoritative ? 'complete' : 'non-authoritative' + if (parsedHost.kind === 'local') { + return { + status, + providerRequestId: args.providerRequestId, + repoId: repo.id, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + result + } + } + if (!capturedAuthority) { + return rejected('rejected') + } + return { + status, + providerRequestId: args.providerRequestId, + repoId: repo.id, + authority: { + kind: 'direct-ssh', + executionHostId: args.executionHostId as `ssh:${string}`, + ...capturedAuthority + }, + result + } +} + export function registerWorktreeHandlers( mainWindow: BrowserWindow, store: Store, runtime: OrcaRuntimeService, options?: { onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void } ): void { + const detectedWorktreeCancellations = createSenderScopedRequestCancellations() // Remove previously registered handlers so re-register works when macOS re-activates and creates a new window. ipcMain.removeHandler('worktrees:listAll') ipcMain.removeHandler('worktrees:list') ipcMain.removeHandler('worktrees:listDetected') + ipcMain.removeHandler('worktrees:cancelListDetected') ipcMain.removeHandler('worktrees:create') ipcMain.removeHandler('worktrees:prefetchCreateBase') ipcMain.removeHandler('worktrees:resolvePrBase') @@ -1003,6 +1754,7 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:forceDeletePreservedBranch') ipcMain.removeHandler('worktrees:updateMeta') ipcMain.removeHandler('worktrees:listLineage') + ipcMain.removeHandler('worktrees:listLineageForHost') ipcMain.removeHandler('worktrees:updateLineage') ipcMain.removeHandler('worktrees:persistSortOrder') ipcMain.removeHandler('worktrees:getBranchRenameFailureOutput') @@ -1138,8 +1890,69 @@ export function registerWorktreeHandlers( ipcMain.handle( 'worktrees:listDetected', - async (_event, args: { repoId: string }): Promise => { - const repo = store.getRepo(args.repoId) + async ( + event, + args: DetectedWorktreeRequestArgs + ): Promise => { + if ('executionHostId' in args) { + const parsedHost = parseExecutionHostId(args.executionHostId) + const directSshRequest = parsedHost?.kind === 'ssh' + const controller = directSshRequest + ? detectedWorktreeCancellations.begin(event, args.providerRequestId) + : null + const directArgs = args as DirectSshDetectedWorktreeRequest + const removeAuthorityAbort = + controller && + parsedHost?.kind === 'ssh' && + hasValidDirectSshAuthority(directArgs) && + directArgs.expectedAuthority.targetId === parsedHost.targetId + ? registerSshProviderRequestAbort(directArgs.expectedAuthority, controller) + : undefined + let timedOut = false + let removeAbortListener: (() => void) | undefined + const abortedResult = controller + ? new Promise((resolve) => { + const onAbort = (): void => { + resolve({ + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status: timedOut ? 'timed-out' : 'canceled' + }) + } + controller.signal.addEventListener('abort', onAbort, { once: true }) + removeAbortListener = () => controller.signal.removeEventListener('abort', onAbort) + }) + : undefined + const timeout = controller + ? setTimeout(() => { + timedOut = true + controller.abort() + }, DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS) + : undefined + try { + const providerResult = listHostQualifiedDetectedWorktrees( + store, + args, + controller + ? { + signal: controller.signal, + status: () => (timedOut ? 'timed-out' : 'canceled') + } + : undefined + ) + return abortedResult + ? await Promise.race([providerResult, abortedResult]) + : await providerResult + } finally { + if (timeout) { + clearTimeout(timeout) + } + removeAbortListener?.() + removeAuthorityAbort?.() + detectedWorktreeCancellations.finish(event, args.providerRequestId, controller) + } + } + const repo = findExactRepoOwner(store, args.repoId) if (!repo) { return { repoId: args.repoId, @@ -1148,69 +1961,35 @@ export function registerWorktreeHandlers( worktrees: [] } } - const sshWorktreeMetaIndex = repo.connectionId - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) - : new Map() - - try { - let gitWorktrees: GitWorktreeInfo[] - let freshScan = true - if (isFolderRepo(repo)) { - return { - repoId: repo.id, - authoritative: true, - source: 'git', - worktrees: projectResolvedWorktreeLineage( - buildFolderDetectedWorktrees(store, repo), - store.getAllWorktreeLineage?.() ?? {} - ) - } - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) - } - } - gitWorktrees = await provider.listWorktrees(repo.path) - } else { - const scan = await listDetectedGitWorktrees(store, repo) - gitWorktrees = scan.gitWorktrees - freshScan = scan.fresh - } - if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) - } - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return { - repoId: repo.id, - authoritative: true, - source: 'git', - worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees) - } - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - if (repo.connectionId) { - const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - return { + const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined + const authority = repo.connectionId + ? { ...getSshProviderAuthority(repo.connectionId) } + : undefined + const result = await listDetectedWorktreesForCapturedRepo( + store, + repo, + () => + isCapturedRepoCurrent(store, repo) && + (!repo.connectionId || + (getSshGitProvider(repo.connectionId) === provider && + authority !== undefined && + isCurrentSshProviderAuthority(authority))), + provider + ) + return result && !('providerAbortStatus' in result) + ? result + : { repoId: repo.id, authoritative: false, source: 'metadata-fallback', - worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) + worktrees: [] } - } - return { repoId: repo.id, authoritative: false, source: 'metadata-fallback', worktrees: [] } - } + } + ) + ipcMain.handle( + 'worktrees:cancelListDetected', + (event, args: { providerRequestId: ProviderRequestId }): void => { + detectedWorktreeCancellations.cancel(event, args.providerRequestId) } ) @@ -2073,6 +2852,12 @@ export function registerWorktreeHandlers( } }) + ipcMain.handle( + 'worktrees:listLineageForHost', + (_event, args: ListDesktopLineageForHostArgs): Promise => + listDesktopLineageForHost(store, runtime, args) + ) + ipcMain.handle( 'worktrees:updateLineage', async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => { diff --git a/src/main/runtime/public-ssh-state.test.ts b/src/main/runtime/public-ssh-state.test.ts new file mode 100644 index 00000000000..1036b5ade0b --- /dev/null +++ b/src/main/runtime/public-ssh-state.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import type { SshConnectionState, SshProviderEpoch } from '../../shared/ssh-types' +import { getPublicSshState } from './public-ssh-state' + +describe('public SSH state', () => { + it('preserves the complete provider authority pair', () => { + const state: SshConnectionState = { + targetId: 'ssh-a', + status: 'error', + error: 'private detail', + reconnectAttempt: 0, + providerEpoch: 'provider-a' as SshProviderEpoch, + connectionGeneration: 3 + } + + expect(getPublicSshState(state)).toEqual({ + ...state, + error: 'SSH connection unavailable' + }) + }) +}) diff --git a/src/main/ssh/ssh-connection-manager.test.ts b/src/main/ssh/ssh-connection-manager.test.ts index 2667dc28e1f..3a7a73cbcbf 100644 --- a/src/main/ssh/ssh-connection-manager.test.ts +++ b/src/main/ssh/ssh-connection-manager.test.ts @@ -71,4 +71,27 @@ describe('SshConnectionManager', () => { expect(mockState.instances).toHaveLength(2) expect(manager.getConnection(target.id)).toBe(secondConnection) }) + + it('keeps the replacement when the disconnected attempt resolves late', async () => { + let resolveFirst!: () => void + mockState.connectResults.push( + new Promise((resolve) => { + resolveFirst = resolve + }), + Promise.resolve() + ) + const manager = new SshConnectionManager({ + onStateChange: vi.fn() + }) + + const firstConnect = manager.connect(target) + await manager.disconnect(target.id) + const replacement = await manager.connect(target) + resolveFirst() + + await expect(firstConnect).resolves.not.toBe(replacement) + expect(mockState.instances[0].disconnect).toHaveBeenCalledOnce() + expect(await manager.connect(target)).toBe(replacement) + expect(manager.getConnection(target.id)).toBe(replacement) + }) }) diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 3f4d4fbe0ba..94c3f31838e 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -458,6 +458,50 @@ describe('SshConnection', () => { expect(conn.getState().status).toBe('disconnected') }) + it('rejects late ssh2 ready after disconnect without resurrecting the connection', async () => { + const callbacks = createCallbacks() + const conn = new SshConnection(createTarget(), callbacks) + + const connectResult = conn.connect().catch((error: Error) => error) + for (let i = 0; i < 5 && clientInstances.length === 0; i++) { + await Promise.resolve() + } + expect(clientInstances).toHaveLength(1) + await conn.disconnect() + + await expect(connectResult).resolves.toMatchObject({ + message: 'SSH connection attempt was cancelled' + }) + expect(conn.getState()).toMatchObject({ status: 'disconnected', error: null }) + expect(callbacks.onStateChange).not.toHaveBeenCalledWith( + 'target-1', + expect.objectContaining({ status: 'connected' }) + ) + }) + + it('keeps disconnected state when ssh2 reports a late startup error', async () => { + connectBehavior = 'error' + connectErrorMessage = 'Connection lost before handshake' + const callbacks = createCallbacks() + const conn = new SshConnection(createTarget(), callbacks) + + const connectResult = conn.connect().catch((error: Error) => error) + for (let i = 0; i < 5 && clientInstances.length === 0; i++) { + await Promise.resolve() + } + expect(clientInstances).toHaveLength(1) + await conn.disconnect() + + await expect(connectResult).resolves.toMatchObject({ + message: 'Connection lost before handshake' + }) + expect(conn.getState()).toMatchObject({ status: 'disconnected', error: null }) + expect(callbacks.onStateChange).not.toHaveBeenCalledWith( + 'target-1', + expect.objectContaining({ status: 'error' }) + ) + }) + it('getTarget returns a copy of the target', () => { const target = createTarget() const conn = new SshConnection(target, createCallbacks()) diff --git a/src/main/ssh/ssh-provider-authority.test.ts b/src/main/ssh/ssh-provider-authority.test.ts new file mode 100644 index 00000000000..7d960175f76 --- /dev/null +++ b/src/main/ssh/ssh-provider-authority.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + assertSshMutationExpectation, + resetSshConnectionGenerations, + setSshConnectionGeneration +} from './ssh-connection-generation' +import { + getSshProviderAuthority, + isCurrentSshProviderAuthority, + registerSshProviderRequestAbort, + resetSshProviderAuthorities, + rotateSshProviderAuthority +} from './ssh-provider-authority' + +describe('SSH provider authority', () => { + beforeEach(() => { + resetSshConnectionGenerations() + resetSshProviderAuthorities() + }) + + it('rotates provider epoch and connection generation atomically', () => { + const initial = getSshProviderAuthority('ssh-a') + const rotated = rotateSshProviderAuthority('ssh-a') + + expect(rotated).toEqual({ + targetId: 'ssh-a', + providerEpoch: expect.any(String), + connectionGeneration: initial.connectionGeneration + 1 + }) + expect(rotated.providerEpoch).not.toBe(initial.providerEpoch) + expect(getSshProviderAuthority('ssh-a')).toEqual(rotated) + expect(() => + assertSshMutationExpectation('ssh-a', 'ssh-a', initial.connectionGeneration) + ).toThrow('SSH connection changed; refresh and try again') + expect(() => + assertSshMutationExpectation('ssh-a', 'ssh-a', rotated.connectionGeneration) + ).not.toThrow() + }) + + it('rejects a stale authority by full-pair equality', () => { + const stale = getSshProviderAuthority('ssh-a') + rotateSshProviderAuthority('ssh-a') + + expect(isCurrentSshProviderAuthority(stale)).toBe(false) + expect(isCurrentSshProviderAuthority(getSshProviderAuthority('ssh-a'))).toBe(true) + }) + + it('checks unknown authority without allocating provider state', () => { + const sequence = (authority: ReturnType): number => + Number.parseInt(authority.providerEpoch.split('-').at(-1) ?? '', 36) + const before = getSshProviderAuthority('before-probe') + + expect( + isCurrentSshProviderAuthority({ + targetId: 'unknown-target', + providerEpoch: 'untrusted-epoch' as typeof before.providerEpoch, + connectionGeneration: 0 + }) + ).toBe(false) + + const after = getSshProviderAuthority('after-probe') + expect(sequence(after) - sequence(before)).toBe(1) + }) + + it('aborts every old-authority provider request once with target isolation', () => { + const authorityA = getSshProviderAuthority('ssh-a') + const authorityB = getSshProviderAuthority('ssh-b') + const controllersA = [new AbortController(), new AbortController()] + const controllerB = new AbortController() + const abortsA = controllersA.map(() => vi.fn()) + const abortB = vi.fn() + controllersA.forEach((controller, index) => { + controller.signal.addEventListener('abort', abortsA[index]) + registerSshProviderRequestAbort(authorityA, controller) + }) + controllerB.signal.addEventListener('abort', abortB) + registerSshProviderRequestAbort(authorityB, controllerB) + + rotateSshProviderAuthority('ssh-a') + rotateSshProviderAuthority('ssh-a') + + expect(abortsA[0]).toHaveBeenCalledOnce() + expect(abortsA[1]).toHaveBeenCalledOnce() + expect(abortB).not.toHaveBeenCalled() + rotateSshProviderAuthority('ssh-b') + expect(abortB).toHaveBeenCalledOnce() + }) + + it('revokes every target when one target rolls the generation session scope', () => { + setSshConnectionGeneration('ssh-a', 2 ** 13 - 1) + const authorityA = getSshProviderAuthority('ssh-a') + const authorityB = getSshProviderAuthority('ssh-b') + const controllerA = new AbortController() + const controllerB = new AbortController() + const abortA = vi.spyOn(controllerA, 'abort') + const abortB = vi.spyOn(controllerB, 'abort') + let oldAuthoritiesWereCurrent = true + controllerA.signal.addEventListener('abort', () => { + oldAuthoritiesWereCurrent = + isCurrentSshProviderAuthority(authorityA) || isCurrentSshProviderAuthority(authorityB) + }) + registerSshProviderRequestAbort(authorityA, controllerA) + registerSshProviderRequestAbort(authorityB, controllerB) + + const rotated = rotateSshProviderAuthority('ssh-a') + + expect(rotated.connectionGeneration).toBe(2 ** 13 + 1) + expect(abortA).toHaveBeenCalledOnce() + expect(abortB).toHaveBeenCalledOnce() + expect(oldAuthoritiesWereCurrent).toBe(false) + expect(isCurrentSshProviderAuthority(authorityA)).toBe(false) + expect(isCurrentSshProviderAuthority(authorityB)).toBe(false) + }) + + it('rejects old-authority registration reentered from an abort callback', () => { + const oldAuthority = getSshProviderAuthority('ssh-a') + const controllerA = new AbortController() + const controllerB = new AbortController() + const abortA = vi.fn() + let oldAuthorityWasCurrent = true + + controllerA.signal.addEventListener('abort', () => { + abortA() + oldAuthorityWasCurrent = isCurrentSshProviderAuthority(oldAuthority) + registerSshProviderRequestAbort(oldAuthority, controllerB) + }) + registerSshProviderRequestAbort(oldAuthority, controllerA) + + rotateSshProviderAuthority('ssh-a') + rotateSshProviderAuthority('ssh-a') + + expect(abortA).toHaveBeenCalledOnce() + expect(oldAuthorityWasCurrent).toBe(false) + expect(controllerB.signal.aborted).toBe(false) + }) + + it('removes settled and explicitly aborted registrations before later rotation', () => { + const authority = getSshProviderAuthority('ssh-a') + const settled = new AbortController() + const explicitlyAborted = new AbortController() + const settledAbort = vi.spyOn(settled, 'abort') + const explicitAbort = vi.spyOn(explicitlyAborted, 'abort') + const removeSettled = registerSshProviderRequestAbort(authority, settled) + registerSshProviderRequestAbort(authority, explicitlyAborted) + + removeSettled() + explicitlyAborted.abort() + rotateSshProviderAuthority('ssh-a') + + expect(settledAbort).not.toHaveBeenCalled() + expect(explicitAbort).toHaveBeenCalledOnce() + }) + + it('aborts and clears registrations during reset isolation', () => { + const oldAuthority = getSshProviderAuthority('ssh-a') + const oldController = new AbortController() + const oldAbort = vi.spyOn(oldController, 'abort') + registerSshProviderRequestAbort(oldAuthority, oldController) + + resetSshProviderAuthorities() + const newAuthority = getSshProviderAuthority('ssh-a') + const newController = new AbortController() + const newAbort = vi.spyOn(newController, 'abort') + registerSshProviderRequestAbort(newAuthority, newController) + rotateSshProviderAuthority('ssh-a') + + expect(oldAbort).toHaveBeenCalledOnce() + expect(newAbort).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ssh/ssh-provider-authority.ts b/src/main/ssh/ssh-provider-authority.ts new file mode 100644 index 00000000000..78eb3d54962 --- /dev/null +++ b/src/main/ssh/ssh-provider-authority.ts @@ -0,0 +1,148 @@ +import { randomBytes } from 'node:crypto' +import type { DirectSshAuthority, SshProviderEpoch } from '../../shared/ssh-types' +import { + advanceSshConnectionGeneration, + getSshConnectionGeneration +} from './ssh-connection-generation' + +const authorityByTarget = new Map() +type ProviderRequestAbortRegistration = { + authority: DirectSshAuthority + controller: AbortController + onAbort: () => void +} +const providerRequestAbortsByTarget = new Map>() +let providerEpochSequence = 0 + +function issueSshProviderEpoch(): SshProviderEpoch { + if (providerEpochSequence >= Number.MAX_SAFE_INTEGER) { + throw new Error('SSH provider epoch sequence exhausted') + } + providerEpochSequence += 1 + return `${randomBytes(12).toString('hex')}-${providerEpochSequence.toString(36)}` as SshProviderEpoch +} + +function createAuthority(targetId: string, connectionGeneration: number): DirectSshAuthority { + return { + targetId, + providerEpoch: issueSshProviderEpoch(), + connectionGeneration + } +} + +function authoritiesEqual(left: DirectSshAuthority, right: DirectSshAuthority): boolean { + return ( + left.targetId === right.targetId && + left.providerEpoch === right.providerEpoch && + left.connectionGeneration === right.connectionGeneration + ) +} + +function removeProviderRequestAbort(registration: ProviderRequestAbortRegistration): void { + registration.controller.signal.removeEventListener('abort', registration.onAbort) + const registrations = providerRequestAbortsByTarget.get(registration.authority.targetId) + if (!registrations?.delete(registration)) { + return + } + if (registrations.size === 0) { + providerRequestAbortsByTarget.delete(registration.authority.targetId) + } +} + +function abortProviderRequestsForAuthority(authority: DirectSshAuthority): void { + const registrations = providerRequestAbortsByTarget.get(authority.targetId) + if (!registrations) { + return + } + const matching = [...registrations].filter((registration) => + authoritiesEqual(registration.authority, authority) + ) + for (const registration of matching) { + removeProviderRequestAbort(registration) + } + for (const registration of matching) { + if (!registration.controller.signal.aborted) { + registration.controller.abort() + } + } +} + +function abortAllProviderRequests(): void { + const registrations = [...providerRequestAbortsByTarget.values()].flatMap((entries) => [ + ...entries + ]) + providerRequestAbortsByTarget.clear() + for (const registration of registrations) { + registration.controller.signal.removeEventListener('abort', registration.onAbort) + } + for (const registration of registrations) { + if (!registration.controller.signal.aborted) { + registration.controller.abort() + } + } +} + +export function registerSshProviderRequestAbort( + authority: DirectSshAuthority, + controller: AbortController +): () => void { + if (controller.signal.aborted || !isCurrentSshProviderAuthority(authority)) { + return () => {} + } + const capturedAuthority = { ...authority } + const registration: ProviderRequestAbortRegistration = { + authority: capturedAuthority, + controller, + onAbort: () => removeProviderRequestAbort(registration) + } + const registrations = + providerRequestAbortsByTarget.get(authority.targetId) ?? + new Set() + registrations.add(registration) + providerRequestAbortsByTarget.set(authority.targetId, registrations) + controller.signal.addEventListener('abort', registration.onAbort, { once: true }) + return () => removeProviderRequestAbort(registration) +} + +export function getSshProviderAuthority(targetId: string): DirectSshAuthority { + const generation = getSshConnectionGeneration(targetId) + const current = authorityByTarget.get(targetId) + if (current?.connectionGeneration === generation) { + return current + } + const authority = createAuthority(targetId, generation) + authorityByTarget.set(targetId, authority) + return authority +} + +export function rotateSshProviderAuthority(targetId: string): DirectSshAuthority { + const previous = authorityByTarget.get(targetId) + const previousGeneration = getSshConnectionGeneration(targetId) + const nextGeneration = advanceSshConnectionGeneration(targetId) + const authority = createAuthority(targetId, nextGeneration) + if (nextGeneration !== previousGeneration + 1) { + authorityByTarget.clear() + authorityByTarget.set(targetId, authority) + abortAllProviderRequests() + return authority + } + authorityByTarget.set(targetId, authority) + if (previous) { + abortProviderRequestsForAuthority(previous) + } + return authority +} + +export function isCurrentSshProviderAuthority(authority: DirectSshAuthority): boolean { + const current = authorityByTarget.get(authority.targetId) + return ( + current !== undefined && + current.connectionGeneration === getSshConnectionGeneration(authority.targetId) && + authoritiesEqual(current, authority) + ) +} + +export function resetSshProviderAuthorities(): void { + abortAllProviderRequests() + authorityByTarget.clear() +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 245b0d3a1b7..2340cc8a238 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -26,6 +26,20 @@ import type { } from '../shared/local-log-tail-types' import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' +import type { + HostQualifiedDetectedWorktreeResult, + LegacyDetectedWorktreeRequest, + ListDetectedWorktreesArgs, + ProviderRequestId +} from '../shared/detected-worktree-provider-contract' +import type { + HostRepoCatalogSnapshot, + ListReposForExecutionHostArgs +} from '../shared/host-repo-catalog-contract' +import type { + HostLineageSnapshot, + ListDesktopLineageForHostArgs +} from '../shared/host-lineage-contract' import type { WriteTerminalRenderDesyncEvidenceArgs, WriteTerminalRenderDesyncEvidenceResult @@ -1157,6 +1171,7 @@ export type PreloadApi = { } repos: { list: () => Promise + listForExecutionHost?: (args: ListReposForExecutionHostArgs) => Promise // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. add: (args: { path: string @@ -1342,7 +1357,13 @@ export type PreloadApi = { } worktrees: { list: (args: { repoId: string }) => Promise - listDetected: (args: { repoId: string }) => Promise + listDetected: { + ( + args: ListDetectedWorktreesArgs + ): Promise + (args: LegacyDetectedWorktreeRequest): Promise + } + cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise listAll: () => Promise create: (args: CreateWorktreeArgs) => Promise /** Two-phase progress for a background `create`, correlated by `creationId`. The remote/runtime @@ -1392,6 +1413,7 @@ export type PreloadApi = { lineage: Record workspaceLineage?: Record }> + listLineageForHost?: (args: ListDesktopLineageForHostArgs) => Promise updateLineage: (args: { worktreeId: string parentWorktreeId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 3023f7382e9..e70295f7714 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -30,6 +30,18 @@ import type { PortForwardEntry, EnrichedDetectedPort } from '../shared/ssh-types' +import { + admitSshConnectionStateForAuthorityReconciliation, + admitSshDetectedPorts +} from '../shared/ssh-retained-payload-admission' +import type { + HostRepoCatalogSnapshot, + ListReposForExecutionHostArgs +} from '../shared/host-repo-catalog-contract' +import type { + HostLineageSnapshot, + ListDesktopLineageForHostArgs +} from '../shared/host-lineage-contract' import type { PluginPanelActionOutcome, PluginPanelEntry @@ -591,6 +603,9 @@ const api = { repos: { list: () => ipcRenderer.invoke('repos:list'), + listForExecutionHost: (args: ListReposForExecutionHostArgs): Promise => + ipcRenderer.invoke('repos:listForExecutionHost', args), + add: (args) => ipcRenderer.invoke('repos:add', args), addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args), @@ -724,6 +739,8 @@ const api = { listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), + cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), + listAll: () => ipcRenderer.invoke('worktrees:listAll'), create: (args) => ipcRenderer.invoke('worktrees:create', args), @@ -756,6 +773,9 @@ const api = { listLineage: () => ipcRenderer.invoke('worktrees:listLineage'), + listLineageForHost: (args: ListDesktopLineageForHostArgs): Promise => + ipcRenderer.invoke('worktrees:listLineageForHost', args), + updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args), persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args), @@ -4298,8 +4318,10 @@ const api = { importConfig: (args?: { reAdopt?: boolean }): Promise => ipcRenderer.invoke('ssh:importConfig', args), - connect: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:connect', args), + connect: async (args: { targetId: string }): Promise => { + const state: unknown = await ipcRenderer.invoke('ssh:connect', args) + return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null + }, disconnect: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:disconnect', args), @@ -4310,24 +4332,37 @@ const api = { resetRelay: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:resetRelay', args), - getState: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:getState', args), + getState: async (args: { targetId: string }): Promise => { + const state: unknown = await ipcRenderer.invoke('ssh:getState', args) + return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null + }, needsPassphrasePrompt: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:needsPassphrasePrompt', args), - testConnection: (args: { + testConnection: async (args: { targetId: string - }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => - ipcRenderer.invoke('ssh:testConnection', args), + }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => { + const result: { success: boolean; error?: string; state?: unknown } = + await ipcRenderer.invoke('ssh:testConnection', args) + const state = result.state + ? admitSshConnectionStateForAuthorityReconciliation(result.state, args.targetId) + : null + return { ...result, ...(state ? { state } : { state: undefined }) } + }, onStateChanged: ( callback: (data: { targetId: string; state: SshConnectionState }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { targetId: string; state: SshConnectionState } - ) => callback(data) + data: { targetId: string; state: unknown } + ): void => { + const state = admitSshConnectionStateForAuthorityReconciliation(data.state, data.targetId) + if (state) { + callback({ targetId: data.targetId, state }) + } + } ipcRenderer.on('ssh:state-changed', listener) return () => ipcRenderer.removeListener('ssh:state-changed', listener) }, @@ -4355,8 +4390,8 @@ const api = { listPortForwards: (args?: { targetId?: string }): Promise => ipcRenderer.invoke('ssh:listPortForwards', args), - listDetectedPorts: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:listDetectedPorts', args), + listDetectedPorts: async (args: { targetId: string }): Promise => + admitSshDetectedPorts(await ipcRenderer.invoke('ssh:listDetectedPorts', args)), onPortForwardsChanged: ( callback: (data: { targetId: string; forwards: PortForwardEntry[] }) => void @@ -4374,8 +4409,8 @@ const api = { ): (() => void) => { const handler = ( _event: Electron.IpcRendererEvent, - data: { targetId: string; ports: EnrichedDetectedPort[] } - ) => callback(data) + data: { targetId: string; ports: unknown } + ) => callback({ targetId: data.targetId, ports: admitSshDetectedPorts(data.ports) }) ipcRenderer.on('ssh:detected-ports-changed', handler) return () => ipcRenderer.removeListener('ssh:detected-ports-changed', handler) }, diff --git a/src/preload/ssh-authority-forwarding.test.ts b/src/preload/ssh-authority-forwarding.test.ts new file mode 100644 index 00000000000..7a81df602d2 --- /dev/null +++ b/src/preload/ssh-authority-forwarding.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import type { PreloadApi } from './api-types' +import type { SshConnectionState, SshProviderEpoch } from '../shared/ssh-types' +import type { + HostQualifiedDetectedWorktreeResult, + ListDetectedWorktreesArgs +} from '../shared/detected-worktree-provider-contract' +import type { DetectedWorktreeListResult } from '../shared/types' + +function listDetectedVariableTypeProbe(api: PreloadApi, args: ListDetectedWorktreesArgs) { + return api.worktrees.listDetected(args) +} + +const { exposeInMainWorld, invoke, on, removeListener, send, sendSync } = vi.hoisted(() => ({ + exposeInMainWorld: vi.fn(), + invoke: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn(), + sendSync: vi.fn() +})) + +vi.mock('electron', () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener, send, sendSync }, + webFrame: { + getZoomFactor: vi.fn(() => 1), + setZoomFactor: vi.fn(), + setVisualZoomLevelLimits: vi.fn() + }, + webUtils: { getPathForFile: vi.fn(() => '') } +})) + +vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) + +describe('native preload SSH authority forwarding', () => { + const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') + + beforeEach(() => { + vi.resetModules() + exposeInMainWorld.mockReset() + invoke.mockReset() + on.mockReset() + removeListener.mockReset() + send.mockReset() + sendSync.mockReset() + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true }) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + dispatchEvent: vi.fn(), + removeEventListener: vi.fn() + }) + vi.stubGlobal('document', { addEventListener: vi.fn() }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + if (originalContextIsolated) { + Object.defineProperty(process, 'contextIsolated', originalContextIsolated) + } else { + Reflect.deleteProperty(process, 'contextIsolated') + } + }) + + it('retains host-qualified outcomes for variable-form detected-worktree requests', () => { + expectTypeOf(listDetectedVariableTypeProbe).returns.toEqualTypeOf< + Promise + >() + }) + + it('forwards full-pair get and push states without cloning away authority', async () => { + const state: SshConnectionState = { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'native-provider-epoch' as SshProviderEpoch, + connectionGeneration: 29 + } + invoke.mockResolvedValueOnce(state) + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + + await expect(api.ssh.getState({ targetId: 'ssh-1' })).resolves.toEqual(state) + expect(invoke).toHaveBeenCalledWith('ssh:getState', { targetId: 'ssh-1' }) + + const onStateChanged = vi.fn() + api.ssh.onStateChanged(onStateChanged) + const listener = on.mock.calls.find(([channel]) => channel === 'ssh:state-changed')?.[1] as ( + event: unknown, + data: { targetId: string; state: SshConnectionState } + ) => void + listener({}, { targetId: 'ssh-1', state }) + + expect(onStateChanged).toHaveBeenCalledWith({ targetId: 'ssh-1', state }) + expect(onStateChanged.mock.calls[0]?.[0].state).toEqual(state) + }) + + it('normalizes partial compatibility authority to unknown for reconciliation', async () => { + const partialState = { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'partial-provider-epoch' + } as SshConnectionState + invoke.mockResolvedValueOnce(partialState) + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + + const returned = await api.ssh.getState({ targetId: 'ssh-1' }) + expect(returned).toEqual({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: null + }) + + const onStateChanged = vi.fn() + api.ssh.onStateChanged(onStateChanged) + const listener = on.mock.calls.find(([channel]) => channel === 'ssh:state-changed')?.[1] as ( + event: unknown, + data: { targetId: string; state: SshConnectionState } + ) => void + listener({}, { targetId: 'ssh-1', state: partialState }) + + expect(onStateChanged.mock.calls[0]?.[0].state).toEqual({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: null + }) + }) + + it('drops malformed full authority before it reaches the renderer', async () => { + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + const onStateChanged = vi.fn() + api.ssh.onStateChanged(onStateChanged) + const listener = on.mock.calls.find(([channel]) => channel === 'ssh:state-changed')?.[1] as ( + event: unknown, + data: { targetId: string; state: SshConnectionState } + ) => void + + listener( + {}, + { + targetId: 'ssh-1', + state: { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: '' as SshProviderEpoch, + connectionGeneration: 29 + } + } + ) + + expect(onStateChanged).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bb335aafc71..115e1f635b9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -407,8 +407,11 @@ function applyRemoteWorkspacePatchStatus( message: result.message ?? (result.reason === 'stale-revision' - ? 'Workspace changed on another device' - : 'Remote workspace sync unavailable') + ? translate( + 'auto.hooks.useIpcEvents.workspaceChangedOnAnotherDevice', + 'Workspace changed on another device' + ) + : translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable')) }) } diff --git a/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts b/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts index c2dc13a985e..0ee0dfbdd29 100644 --- a/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts +++ b/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { PluginPanelActionOutcome } from '../../../../shared/plugins/plugin-panel-bridge' -import type { PanelMessageBudget } from '../../../../shared/plugins/plugin-panel-message-budget' +import { + createPanelMessageBudget, + type PanelMessageBudget +} from '../../../../shared/plugins/plugin-panel-message-budget' import { createPanelBridgeMessageHandler } from './plugin-panel-bridge-host' type FakePanelWindow = Window & { postMessage: ReturnType } @@ -105,6 +108,58 @@ describe('createPanelBridgeMessageHandler', () => { ) }) + it('refuses an oversized request without relaying it', () => { + const panelWindow = createFakePanelWindow() + const { handler, callPanelAction } = createHandler(panelWindow) + + handler( + messageEvent( + { + ...VALID_DATA, + params: { padding: 'x'.repeat(128 * 1024) } + }, + panelWindow + ) + ) + + expect(callPanelAction).not.toHaveBeenCalled() + expect(panelWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'orca-panel-action-result', + requestId: 'req-1', + ok: false, + errorCode: 'invalid_request' + }), + '*' + ) + }) + + it('refuses a valid request after malformed and pong traffic exhaust the budget', () => { + const panelWindow = createFakePanelWindow() + const callPanelAction = vi.fn() + const handler = createPanelBridgeMessageHandler({ + sessionToken: SESSION_TOKEN, + getPanelWindow: () => panelWindow, + callPanelAction, + budget: createPanelMessageBudget({ maxMessages: 2 }) + }) + + handler(messageEvent({ type: 'invalid-hostile-message' }, panelWindow)) + handler(messageEvent({ type: 'orca-panel-pong', pingId: 7 }, panelWindow)) + handler(messageEvent(VALID_DATA, panelWindow)) + + expect(callPanelAction).not.toHaveBeenCalled() + expect(panelWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'orca-panel-action-result', + requestId: 'req-1', + ok: false, + errorCode: 'rate_limited' + }), + '*' + ) + }) + it('relays a denial outcome (missing manifest permission) back to the panel', async () => { const panelWindow = createFakePanelWindow() const { handler } = createHandler(panelWindow, { 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 bcbae661a6a..50a5946e770 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -14,6 +14,7 @@ import type { 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' +import type { DirectSshPaneRetryAttemptId } from '@/store/slices/direct-ssh-terminal-recovery' export type PtyConnectionDeps = { tabId: string @@ -70,7 +71,12 @@ export type PtyConnectionDeps = { updateTabTitle: (tabId: string, title: string) => void setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void clearRuntimePaneTitle: (tabId: string, paneId: number) => void - updateTabPtyId: (tabId: string, ptyId: string, replacedPtyId?: string) => void + updateTabPtyId: ( + tabId: string, + ptyId: string, + replacedPtyId?: string, + directSshRetryAttemptId?: DirectSshPaneRetryAttemptId + ) => void markWorktreeUnread: (worktreeId: string) => void markTerminalTabUnread: (tabId: string) => void markTerminalPaneUnread: (paneKey: string) => void 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 7f13b6aeb47..1cf66787ac2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -19,6 +19,7 @@ import type * as UseNotificationDispatchModule from './use-notification-dispatch import { getEagerPtyBufferHandle } from './pty-dispatcher' import { makePaneKey } from '../../../../shared/stable-pane-id' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { SshConnectionState } from '../../../../shared/ssh-types' import type { TerminalLayoutSnapshot, TuiAgent } from '../../../../shared/types' import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' @@ -131,6 +132,7 @@ type StoreState = { title?: string launchAgent?: string shellOverride?: string + generation?: number }[] > ptyIdsByTabId?: Record @@ -230,6 +232,33 @@ type StoreState = { markTerminalTabUnread: ReturnType markTerminalPaneUnread: ReturnType markAgentCompletionPaneUnread: ReturnType + directSshPaneRetryByTabId?: Record< + string, + { + attemptId: string + authority: { + targetId: string + providerEpoch: string + connectionGeneration: number + } + tabGeneration: number + startedAt: number + } + > + directSshLivePtyBindingByTabId?: Record< + string, + { + attemptId: string + authority: { + targetId: string + providerEpoch: string + connectionGeneration: number + } + tabGeneration: number + ptyId: string + } + > + settleDirectSshPaneRetry?: ReturnType } type WindowsShiftEnterPaneState = Parameters[0] @@ -267,6 +296,7 @@ type MockTransport = { ) => unknown } disconnect: ReturnType + detach?: ReturnType sendInput: ReturnType sendInputImmediate: ReturnType sendInputAccepted?: ReturnType @@ -597,6 +627,35 @@ function createDeps(overrides: Record = {}) { } } +function createDirectSshSplitRetryCommit() { + return vi.fn( + (tabId: string, ptyId: string, _replacedPtyId?: string, directSshRetryAttemptId?: string) => { + const currentPtyIds = mockStoreState.ptyIdsByTabId?.[tabId] ?? [] + mockStoreState.ptyIdsByTabId = { + ...mockStoreState.ptyIdsByTabId, + [tabId]: currentPtyIds.includes(ptyId) ? currentPtyIds : [...currentPtyIds, ptyId] + } + const pending = mockStoreState.directSshPaneRetryByTabId?.[tabId] + if (!pending || pending.attemptId !== directSshRetryAttemptId) { + return + } + const tab = mockStoreState.tabsByWorktree['wt-1'].find((candidate) => candidate.id === tabId) + if (tab && !tab.ptyId) { + tab.ptyId = ptyId + } + mockStoreState.directSshPaneRetryByTabId = {} + mockStoreState.directSshLivePtyBindingByTabId = { + [tabId]: { + attemptId: pending.attemptId, + authority: pending.authority, + tabGeneration: pending.tabGeneration, + ptyId: tab?.ptyId ?? ptyId + } + } + } + ) +} + function setReattachPaneTitle(title: string): void { mockStoreState = { ...mockStoreState, @@ -702,12 +761,18 @@ function keyEvent(overrides: Partial): KeyboardEvent { } as KeyboardEvent } -function createDeferred(): { promise: Promise; resolve: (value: T) => void } { +function createDeferred(): { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} { let resolveDeferred!: (value: T) => void - const promise = new Promise((resolve) => { + let rejectDeferred!: (reason?: unknown) => void + const promise = new Promise((resolve, reject) => { resolveDeferred = resolve + rejectDeferred = reject }) - return { promise, resolve: resolveDeferred } + return { promise, resolve: resolveDeferred, reject: rejectDeferred } } function createRect(width: number, height: number, left = 0, top = 0): DOMRect { @@ -898,6 +963,7 @@ describe('connectPanePty', () => { needsPassphrasePrompt: vi.fn().mockResolvedValue(false) }, pty: { + kill: vi.fn(), signal: vi.fn(), listSessions: vi.fn().mockResolvedValue([]), hasPty: vi.fn().mockResolvedValue(true), @@ -1000,6 +1066,1038 @@ describe('connectPanePty', () => { logSpy.mockRestore() }, 30_000) + it.each(['rejects', 'resolves empty'] as const)( + 'settles the exact direct SSH retry when its fresh spawn %s', + async (outcome) => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + if (outcome === 'rejects') { + transport.connect.mockRejectedValueOnce(new Error('spawn failed')) + } else { + transport.connect.mockResolvedValueOnce(null) + } + transportFactoryQueue.push(transport) + const settleDirectSshPaneRetry = vi.fn() + const pendingRetry = { + attemptId: 'attempt-1', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(12) + + expect(settleDirectSshPaneRetry).toHaveBeenCalledExactlyOnceWith({ + status: 'failed', + tabId: 'tab-1', + attemptId: pendingRetry.attemptId, + authority: pendingRetry.authority, + tabGeneration: pendingRetry.tabGeneration + }) + } + ) + + it('times out an exact direct SSH retry when a StrictMode-reused spawn stays pending', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) + const { connectPanePty } = await import('./pty-connection') + const pendingSpawn = createDeferred() + const firstTransport = createMockTransport() + firstTransport.connect.mockReturnValueOnce(pendingSpawn.promise) + const remountTransport = createMockTransport() + transportFactoryQueue.push(firstTransport, remountTransport) + const pendingRetry = { + attemptId: 'attempt-timeout', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const settleDirectSshPaneRetry = vi.fn(() => { + mockStoreState.directSshPaneRetryByTabId = {} + }) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry + } + + const firstBinding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks() + firstBinding.dispose() + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + + expect(firstTransport.connect).toHaveBeenCalledOnce() + expect(remountTransport.connect).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(30_999) + expect(settleDirectSshPaneRetry).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(settleDirectSshPaneRetry).toHaveBeenCalledExactlyOnceWith({ + status: 'timed-out', + tabId: 'tab-1', + attemptId: pendingRetry.attemptId, + authority: pendingRetry.authority, + tabGeneration: pendingRetry.tabGeneration + }) + expect(firstTransport.disconnect).not.toHaveBeenCalled() + expect(remountTransport.disconnect).not.toHaveBeenCalled() + + pendingSpawn.resolve(null) + await flushAsyncTicks(12) + }) + + it('cancels exact retry settlement when a hung pane is intentionally disposed', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) + const { connectPanePty } = await import('./pty-connection') + const pendingSpawn = createDeferred() + const transport = createMockTransport() + transport.connect.mockReturnValueOnce(pendingSpawn.promise) + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-disposed', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const settleDirectSshPaneRetry = vi.fn() + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry + } + + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks() + binding.dispose() + await vi.advanceTimersByTimeAsync(31_000) + + expect(settleDirectSshPaneRetry).not.toHaveBeenCalled() + + pendingSpawn.resolve(null) + await flushAsyncTicks(12) + expect(settleDirectSshPaneRetry).not.toHaveBeenCalled() + }) + + it('joins a successful pending spawn across a same-attempt StrictMode remount', async () => { + const { connectPanePty } = await import('./pty-connection') + const pendingSpawn = createDeferred() + const firstTransport = createMockTransport() + firstTransport.connect.mockReturnValueOnce(pendingSpawn.promise) + const remountTransport = createMockTransport() + transportFactoryQueue.push(firstTransport, remountTransport) + const pendingRetry = { + attemptId: 'attempt-strict-mode', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry: vi.fn() + } + const remountDeps = createDeps() + + const firstBinding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks() + firstBinding.dispose() + connectPanePty(createPane(1) as never, createManager(1) as never, remountDeps as never) + await flushAsyncTicks() + + expect(firstTransport.connect).toHaveBeenCalledOnce() + expect(remountTransport.connect).not.toHaveBeenCalled() + + const spawnedPtyId = toAppSshPtyId('target-a', 'pty-strict-mode') + pendingSpawn.resolve(spawnedPtyId) + await flushAsyncTicks(12) + + expect(remountTransport.attach).toHaveBeenCalledWith( + expect.objectContaining({ existingPtyId: spawnedPtyId }) + ) + expect(remountDeps.updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + spawnedPtyId, + undefined, + pendingRetry.attemptId + ) + }) + + it('commits every concurrent split-pane spawn under one exact retry attempt', async () => { + const { connectPanePty } = await import('./pty-connection') + const firstSpawn = createDeferred() + const siblingSpawn = createDeferred() + const firstTransport = createMockTransport() + const siblingTransport = createMockTransport() + firstTransport.connect.mockReturnValueOnce(firstSpawn.promise) + siblingTransport.connect.mockReturnValueOnce(siblingSpawn.promise) + transportFactoryQueue.push(firstTransport, siblingTransport) + const pendingRetry = { + attemptId: 'attempt-split-spawn', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const updateTabPtyId = createDirectSshSplitRetryCommit() + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + directSshLivePtyBindingByTabId: {}, + settleDirectSshPaneRetry: vi.fn() + } + const manager = createManager(2) + + connectPanePty( + createPane(1) as never, + manager as never, + createDeps({ updateTabPtyId }) as never + ) + connectPanePty( + createPane(2) as never, + manager as never, + createDeps({ updateTabPtyId }) as never + ) + await flushAsyncTicks() + + const firstPtyId = toAppSshPtyId('target-a', 'pty-first') + const siblingPtyId = toAppSshPtyId('target-a', 'pty-sibling') + const firstOnPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + const siblingOnPtySpawn = createdTransportOptions[1]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + firstOnPtySpawn?.(firstPtyId) + siblingOnPtySpawn?.(siblingPtyId) + + expect(updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + firstPtyId, + undefined, + pendingRetry.attemptId + ) + expect(updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + siblingPtyId, + undefined, + pendingRetry.attemptId + ) + expect(mockStoreState.ptyIdsByTabId?.['tab-1']).toEqual([firstPtyId, siblingPtyId]) + + firstSpawn.resolve(firstPtyId) + siblingSpawn.resolve(siblingPtyId) + await flushAsyncTicks(12) + }) + + it('captures retained live authority when a sibling mounts after first success', async () => { + const { connectPanePty } = await import('./pty-connection') + const delayedSpawn = createDeferred() + const transport = createMockTransport() + transport.connect.mockReturnValueOnce(delayedSpawn.promise) + transportFactoryQueue.push(transport) + const livePtyId = toAppSshPtyId('target-a', 'pty-live') + const siblingPtyId = toAppSshPtyId('target-a', 'pty-delayed-sibling') + const liveRetry = { + attemptId: 'attempt-live-sibling', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + ptyId: livePtyId + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: livePtyId, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [livePtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: {}, + directSshLivePtyBindingByTabId: { 'tab-1': liveRetry }, + settleDirectSshPaneRetry: vi.fn() + } + const paneTransportsRef = { + current: new Map([[1, createMockTransport(livePtyId)]]) + } + const deps = createDeps({ paneTransportsRef }) + + connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) + await flushAsyncTicks() + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.(siblingPtyId) + + expect(deps.updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + siblingPtyId, + undefined, + liveRetry.attemptId + ) + + delayedSpawn.resolve(siblingPtyId) + await flushAsyncTicks(12) + }) + + it('rejects a delayed live-lease sibling after direct SSH authority rotates', async () => { + const { connectPanePty } = await import('./pty-connection') + const delayedSpawn = createDeferred() + const transport = createMockTransport() + const stalePtyId = toAppSshPtyId('target-a', 'pty-stale-live-sibling') + transport.getPtyId.mockReturnValue(stalePtyId) + transport.connect.mockReturnValueOnce(delayedSpawn.promise) + transportFactoryQueue.push(transport) + const livePtyId = toAppSshPtyId('target-a', 'pty-live') + const liveRetry = { + attemptId: 'attempt-live-stale', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + }, + tabGeneration: 7, + ptyId: livePtyId + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: livePtyId, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [livePtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: {}, + directSshLivePtyBindingByTabId: { 'tab-1': liveRetry }, + settleDirectSshPaneRetry: vi.fn() + } + const paneTransportsRef = { + current: new Map([[1, createMockTransport(livePtyId)]]) + } + const deps = createDeps({ paneTransportsRef }) + + connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) + await flushAsyncTicks() + mockStoreState.sshConnectionStates = new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-new', + connectionGeneration: 4 + } + ] + ]) + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.(stalePtyId) + await flushAsyncTicks() + + expect(deps.updateTabPtyId).not.toHaveBeenCalled() + expect(transport.disconnect).toHaveBeenCalledOnce() + + delayedSpawn.resolve(stalePtyId) + await flushAsyncTicks(12) + }) + + it('starts a new spawn and rejects a late callback after direct SSH authority rotates', async () => { + const { connectPanePty } = await import('./pty-connection') + const oldPendingSpawn = createDeferred() + const oldTransport = createMockTransport() + let oldTransportPtyId: string | null = null + oldTransport.getPtyId.mockImplementation(() => oldTransportPtyId) + oldTransport.disconnect.mockImplementation(() => { + oldTransportPtyId = null + }) + oldTransport.connect.mockReturnValueOnce(oldPendingSpawn.promise) + const newPendingSpawn = createDeferred() + const newTransport = createMockTransport() + newTransport.connect.mockReturnValueOnce(newPendingSpawn.promise) + transportFactoryQueue.push(oldTransport, newTransport) + const oldRetry = { + attemptId: 'attempt-old', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const newRetry = { + attemptId: 'attempt-new', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-new', + connectionGeneration: 4 + }, + tabGeneration: 8, + startedAt: 2 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': oldRetry }, + settleDirectSshPaneRetry: vi.fn() + } + const oldDeps = createDeps() + + const oldBinding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + oldDeps as never + ) + await flushAsyncTicks() + oldBinding.dispose() + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 8 }] }, + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-new', + connectionGeneration: 4 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': newRetry } + } + const newDeps = createDeps() + + connectPanePty(createPane(1) as never, createManager(1) as never, newDeps as never) + await flushAsyncTicks() + + expect(oldTransport.connect).toHaveBeenCalledOnce() + expect(newTransport.connect).toHaveBeenCalledOnce() + + const oldPtyId = toAppSshPtyId('target-a', 'pty-old') + oldTransportPtyId = oldPtyId + const oldOnPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + oldOnPtySpawn?.(oldPtyId) + oldPendingSpawn.resolve(oldPtyId) + await flushAsyncTicks(12) + + expect(oldDeps.updateTabPtyId).not.toHaveBeenCalled() + expect(oldTransport.disconnect).toHaveBeenCalledOnce() + expect(mockStoreState.directSshPaneRetryByTabId).toEqual({ 'tab-1': newRetry }) + + const newPtyId = toAppSshPtyId('target-a', 'pty-new') + const newOnPtySpawn = createdTransportOptions[1]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + newOnPtySpawn?.(newPtyId) + newPendingSpawn.resolve(newPtyId) + await flushAsyncTicks(12) + + expect(newDeps.updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + newPtyId, + undefined, + newRetry.attemptId + ) + }) + + it('times out an exact direct SSH retry when reattach stays pending', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) + const { connectPanePty } = await import('./pty-connection') + const pendingReattach = createDeferred() + const transport = createMockTransport() + transport.connect.mockReturnValueOnce(pendingReattach.promise).mockResolvedValueOnce(null) + transportFactoryQueue.push(transport) + const restoredPtyId = toAppSshPtyId('target-a', 'pty-restored') + const pendingRetry = { + attemptId: 'attempt-reattach-timeout', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const settleDirectSshPaneRetry = vi.fn(() => { + mockStoreState.directSshPaneRetryByTabId = {} + }) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [restoredPtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } + }) as never + ) + await flushAsyncTicks(12) + + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: restoredPtyId }) + ) + await vi.advanceTimersByTimeAsync(31_000) + expect(settleDirectSshPaneRetry).toHaveBeenCalledExactlyOnceWith({ + status: 'timed-out', + tabId: 'tab-1', + attemptId: pendingRetry.attemptId, + authority: pendingRetry.authority, + tabGeneration: pendingRetry.tabGeneration + }) + expect(transport.disconnect).not.toHaveBeenCalled() + + pendingReattach.resolve(null) + await flushAsyncTicks(12) + }) + + it('commits a successful direct SSH reattach with its exact retry lease', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('target-a', 'pty-restored') + const transport = createMockTransport(restoredPtyId) + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-reattach-success', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [restoredPtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry: vi.fn() + } + const deps = createDeps() + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + ...deps, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } + }) as never + ) + await flushAsyncTicks(12) + + expect(deps.updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + restoredPtyId, + undefined, + pendingRetry.attemptId + ) + }) + + it('rejects expired reattach state after its direct SSH retry lease is revoked', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('target-a', 'pty-stale-reattach') + const pendingReattach = createDeferred<{ + id: string + sessionExpired: true + launchAgent: 'codex' + launchConfig: { agentCommand: string; agentArgs: string; agentEnv: Record } + }>() + const transport = createMockTransport(restoredPtyId) + transport.connect.mockReturnValueOnce(pendingReattach.promise) + transport.detach = vi.fn() + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-stale-reattach', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const deps = createDeps() + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [restoredPtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry: vi.fn() + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + ...deps, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } + }) as never + ) + await flushAsyncTicks() + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ admitPtyId: expect.any(Function) }) + ) + mockStoreState.sshConnectionStates = new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-new', + connectionGeneration: 4 + } + ] + ]) + mockStoreState.directSshPaneRetryByTabId = {} + + pendingReattach.resolve({ + id: restoredPtyId, + sessionExpired: true, + launchAgent: 'codex', + launchConfig: { + agentCommand: 'codex --profile stale', + agentArgs: '--profile stale', + agentEnv: {} + } + }) + await flushAsyncTicks(12) + + expect(transport.detach).toHaveBeenCalledExactlyOnceWith({ preserveExitObserver: false }) + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.disconnect).not.toHaveBeenCalled() + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(1, null) + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(deps.updateTabPtyId).not.toHaveBeenCalled() + expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled() + expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalled() + expect(mockStoreState.tabsByWorktree['wt-1']).toEqual([ + { id: 'tab-1', ptyId: restoredPtyId, generation: 7 } + ]) + expect(window.api.pty.kill).not.toHaveBeenCalled() + }) + + it('rejects stale reattach errors after its exact retry lease is replaced', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('target-a', 'pty-stale-empty-reattach') + const pendingReattach = createDeferred() + const transport = createMockTransport(restoredPtyId) + transport.connect.mockImplementationOnce( + (options: { admitPtyId?: (ptyId: string) => boolean; callbacks?: ConnectCallbacks }) => { + expect(options.admitPtyId?.(restoredPtyId)).toBe(true) + return pendingReattach.promise + } + ) + transport.detach = vi.fn() + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-stale-empty-reattach', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const deps = createDeps() + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [restoredPtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry: vi.fn() + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + ...deps, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } + }) as never + ) + await flushAsyncTicks() + mockStoreState.directSshPaneRetryByTabId = { + 'tab-1': { ...pendingRetry, attemptId: 'attempt-current-reattach' } + } + + const connectOptions = transport.connect.mock.calls[0]?.[0] as { + callbacks?: ConnectCallbacks + } + connectOptions.callbacks?.onError?.('stale reattach failure') + connectOptions.callbacks?.onError?.('SSH_SESSION_EXPIRED: stale reattach') + pendingReattach.resolve() + await flushAsyncTicks(12) + + expect(transport.detach).toHaveBeenCalledExactlyOnceWith({ preserveExitObserver: false }) + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.disconnect).not.toHaveBeenCalled() + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(1, null) + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(deps.updateTabPtyId).not.toHaveBeenCalled() + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + expect(window.api.pty.kill).not.toHaveBeenCalled() + }) + + it('rejects a reattach failure after its direct SSH retry lease is revoked', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('target-a', 'pty-stale-rejected-reattach') + const pendingReattach = createDeferred() + const transport = createMockTransport(restoredPtyId) + transport.connect.mockImplementationOnce( + (options: { admitPtyId?: (ptyId: string) => boolean }) => { + expect(options.admitPtyId?.(restoredPtyId)).toBe(true) + return pendingReattach.promise + } + ) + transport.detach = vi.fn() + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-stale-rejected-reattach', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const deps = createDeps() + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [restoredPtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry: vi.fn() + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + ...deps, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } + }) as never + ) + await flushAsyncTicks() + mockStoreState.directSshPaneRetryByTabId = {} + + pendingReattach.reject(new Error('stale reattach rejection')) + await flushAsyncTicks(12) + + expect(transport.detach).toHaveBeenCalledExactlyOnceWith({ preserveExitObserver: false }) + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.disconnect).not.toHaveBeenCalled() + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(1, null) + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(deps.updateTabPtyId).not.toHaveBeenCalled() + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + expect(window.api.pty.kill).not.toHaveBeenCalled() + }) + + it('commits every concurrent split-pane reattach under one exact retry attempt', async () => { + const { connectPanePty } = await import('./pty-connection') + const firstPtyId = toAppSshPtyId('target-a', 'pty-restored-first') + const siblingPtyId = toAppSshPtyId('target-a', 'pty-restored-sibling') + transportFactoryQueue.push(createMockTransport(firstPtyId), createMockTransport(siblingPtyId)) + const pendingRetry = { + attemptId: 'attempt-split-reattach', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const updateTabPtyId = createDirectSshSplitRetryCommit() + const restoredPtyIdByLeafId = { + [LEAF_1]: firstPtyId, + [LEAF_2]: siblingPtyId + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: firstPtyId, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [firstPtyId, siblingPtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + directSshLivePtyBindingByTabId: {}, + settleDirectSshPaneRetry: vi.fn() + } + const manager = createManager(2) + + connectPanePty( + createPane(1) as never, + manager as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId, + updateTabPtyId + }) as never + ) + connectPanePty( + createPane(2) as never, + manager as never, + createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId, + updateTabPtyId + }) as never + ) + await flushAsyncTicks(12) + + expect(updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + firstPtyId, + undefined, + pendingRetry.attemptId + ) + expect(updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + siblingPtyId, + undefined, + pendingRetry.attemptId + ) + }) + // Why: hidden panes (orchestration workers, CLI terminal create) legitimately connect at 0×0 and refit when shown, so the zero-dimensions diagnostic must stay silent. it('does not surface the zero-dimensions diagnostic for a hidden pane', async () => { const { connectPanePty } = await import('./pty-connection') @@ -18809,6 +19907,188 @@ describe('connectPanePty', () => { expect(mockStoreState.removeDeferredSshReconnectTarget).not.toHaveBeenCalled() }) + it('abandons a deferred SSH retry replaced during the passphrase probe', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('conn-1', 'saved-session') + const passphraseProbe = createDeferred() + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-passphrase-probe', + authority: { + targetId: 'conn-1', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([ + [ + 'conn-1', + { + status: 'disconnected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + deferredSshReconnectTargets: ['conn-1'], + deferredSshSessionIdsByTabId: { 'tab-1': restoredPtyId }, + directSshPaneRetryByTabId: { 'tab-1': pendingRetry } + } + vi.mocked(window.api.ssh.needsPassphrasePrompt).mockReturnValue(passphraseProbe.promise) + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(3) + mockStoreState.directSshPaneRetryByTabId = { + 'tab-1': { ...pendingRetry, attemptId: 'attempt-passphrase-probe-new' } + } + + passphraseProbe.resolve(false) + await flushAsyncTicks(12) + + expect(window.api.ssh.connect).not.toHaveBeenCalled() + expect(transport.connect).not.toHaveBeenCalled() + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled() + expect(mockStoreState.removeDeferredSshReconnectTarget).not.toHaveBeenCalled() + }) + + it('drops a failed passphrase wait after its deferred SSH retry is replaced', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('conn-1', 'saved-session') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-passphrase-wait', + authority: { + targetId: 'conn-1', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([ + [ + 'conn-1', + { + status: 'disconnected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + deferredSshReconnectTargets: ['conn-1'], + deferredSshSessionIdsByTabId: { 'tab-1': restoredPtyId }, + directSshPaneRetryByTabId: { 'tab-1': pendingRetry } + } + vi.mocked(window.api.ssh.needsPassphrasePrompt).mockResolvedValue(true) + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(3) + mockStoreState.directSshPaneRetryByTabId = { + 'tab-1': { ...pendingRetry, attemptId: 'attempt-passphrase-wait-new' } + } + mockStoreState.sshConnectionStates = new Map([ + [ + 'conn-1', + { + status: 'auth-failed', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]) + notifyStoreSubscribers() + await flushAsyncTicks(12) + + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + expect(window.api.ssh.connect).not.toHaveBeenCalled() + expect(transport.connect).not.toHaveBeenCalled() + expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled() + expect(mockStoreState.removeDeferredSshReconnectTarget).not.toHaveBeenCalled() + }) + + it('does not mutate deferred SSH reattach state after its connect wait lease is replaced', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('conn-1', 'saved-session') + const sshConnect = createDeferred() + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-connect-wait', + authority: { + targetId: 'conn-1', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([ + [ + 'conn-1', + { + status: 'disconnected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + deferredSshReconnectTargets: ['conn-1'], + deferredSshSessionIdsByTabId: { 'tab-1': restoredPtyId }, + directSshPaneRetryByTabId: { 'tab-1': pendingRetry } + } + vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise) + const paneMode2031Ref = { current: new Map([[1, true]]) } + const paneLastThemeModeRef = { current: new Map([[1, true]]) } + const deps = createDeps({ paneMode2031Ref, paneLastThemeModeRef }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + mockStoreState.directSshPaneRetryByTabId = { + 'tab-1': { ...pendingRetry, attemptId: 'attempt-connect-wait-new' } + } + + sshConnect.resolve({ + targetId: 'conn-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + await flushAsyncTicks(12) + + expect(mockStoreState.removeDeferredSshReconnectTarget).not.toHaveBeenCalled() + expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled() + expect(window.api.pty.declarePendingPaneSerializer).not.toHaveBeenCalled() + expect(paneMode2031Ref.current.get(1)).toBe(true) + expect(paneLastThemeModeRef.current.get(1)).toBe(true) + expect(transport.connect).not.toHaveBeenCalled() + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + }) + it('spawns a fresh PTY when a deferred SSH session expired', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index b2ae121178c..3962b06f890 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -294,9 +294,13 @@ import { registerTerminalSideEffectFactConsumer } from './terminal-side-effect-facts-handler' import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' +import type { DirectSshPaneRetryAttempt } from '@/store/slices/direct-ssh-terminal-recovery' +import { directSshAuthoritiesEqual } from '@/store/slices/direct-ssh-terminal-authority-ledger' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' +// Why: relay requests expire at 30s; leave one second for their fallback before re-arming locally. +const DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS = 31_000 const REMOTE_PTY_ID_PREFIX = 'remote:' const PTY_CONNECT_DIAG_LIMIT = 200 const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 @@ -1262,7 +1266,6 @@ export function connectPanePty( const clearRegisteredStartupLaunchConfig = (): void => { useAppStore.getState().clearAgentLaunchConfig(cacheKey) } - const pendingSpawnKey = cacheKey const neutralTerminalTitle = (): string => { const state = useAppStore.getState() const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( @@ -2839,8 +2842,18 @@ export function connectPanePty( deps.syncPanePtyLayoutBinding(pane.id, ptyId) notifyCodexPaneBoundForStaleSweep(ptyId) const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? [] - if (options.updateTabPtyId !== 'if-missing' || !tabPtyIds.includes(ptyId)) { - if (options.replacePtyId) { + const directSshRetryAttemptId = + capturedDirectSshRetryPtyAccepted && directSshRetryAttempt + ? directSshRetryAttempt.attemptId + : undefined + if ( + directSshRetryAttemptId || + options.updateTabPtyId !== 'if-missing' || + !tabPtyIds.includes(ptyId) + ) { + if (directSshRetryAttemptId) { + deps.updateTabPtyId(deps.tabId, ptyId, options.replacePtyId, directSshRetryAttemptId) + } else if (options.replacePtyId) { deps.updateTabPtyId(deps.tabId, ptyId, options.replacePtyId) } else { deps.updateTabPtyId(deps.tabId, ptyId) @@ -2876,6 +2889,15 @@ export function connectPanePty( } const onPtySpawn = (ptyId: string): void => { + if (!claimCapturedDirectSshRetryPty(ptyId)) { + // Why: this callback proves a fresh process was created, so rejecting its obsolete lease must also retire it. + queueMicrotask(() => { + if (transport.getPtyId() === ptyId) { + transport.disconnect() + } + }) + return + } // Why: record that this exact PTY was freshly spawned (not reattached), so a // newborn shell that dies before any interaction (e.g. failing direnv on a // just-created worktree) can be kept visible rather than tearing down the @@ -2886,6 +2908,9 @@ export function connectPanePty( bindActivePanePty(ptyId, { seedInitialAgentStatus: true }) } const onPtyRebind = (ptyId: string, replacedPtyId: string): void => { + if (!canAdoptCapturedDirectSshRetryPty(ptyId)) { + return + } // Why: provider handle rotation keeps the existing pane/session generation; // replace its stale store identity without fresh-spawn exit semantics. bindActivePanePty(ptyId, { replacePtyId: replacedPtyId }) @@ -3274,6 +3299,126 @@ export function connectPanePty( !terminalOwnerUnresolved && !connectionOwnerHydrating && runtimeEnvironmentId === null ? (worktreeConnectionId ?? null) : null + type DirectSshRetryLease = Pick< + DirectSshPaneRetryAttempt, + 'attemptId' | 'authority' | 'tabGeneration' + > + const directSshRetryAttempt: DirectSshRetryLease | undefined = (() => { + const pendingAttempt = state.directSshPaneRetryByTabId?.[deps.tabId] + const liveBinding = state.directSshLivePtyBindingByTabId?.[deps.tabId] + const attempt = + pendingAttempt?.authority.targetId === connectionId && + pendingAttempt.tabGeneration === (tab?.generation ?? 0) + ? pendingAttempt + : liveBinding?.authority.targetId === connectionId && + liveBinding.tabGeneration === (tab?.generation ?? 0) + ? liveBinding + : undefined + return attempt + })() + const pendingSpawnKey = directSshRetryAttempt + ? JSON.stringify([cacheKey, directSshRetryAttempt.attemptId]) + : cacheKey + let capturedDirectSshRetryPtyAccepted = false + let directSshPaneRetrySettlementCancelled = false + const directSshPaneRetrySettlementTimers = new Set>() + const directSshPaneRetryTimedPromises = new WeakSet() + const capturedDirectSshRetryLeaseMatches = (): boolean => { + if (!directSshRetryAttempt) { + return true + } + const currentState = useAppStore.getState() + const currentConnection = currentState.sshConnectionStates.get( + directSshRetryAttempt.authority.targetId + ) + const currentTab = (currentState.tabsByWorktree[deps.worktreeId] ?? []).find( + (candidate) => candidate.id === deps.tabId + ) + if ( + currentConnection?.providerEpoch !== directSshRetryAttempt.authority.providerEpoch || + currentConnection.connectionGeneration !== + directSshRetryAttempt.authority.connectionGeneration || + (currentTab?.generation ?? 0) !== directSshRetryAttempt.tabGeneration + ) { + return false + } + const pendingAttempt = currentState.directSshPaneRetryByTabId?.[deps.tabId] + const pendingMatches = + pendingAttempt?.attemptId === directSshRetryAttempt.attemptId && + directSshAuthoritiesEqual(pendingAttempt.authority, directSshRetryAttempt.authority) && + pendingAttempt.tabGeneration === directSshRetryAttempt.tabGeneration + const liveBinding = currentState.directSshLivePtyBindingByTabId?.[deps.tabId] + const liveBindingMatchesAttempt = + liveBinding?.attemptId === directSshRetryAttempt.attemptId && + directSshAuthoritiesEqual(liveBinding.authority, directSshRetryAttempt.authority) && + liveBinding.tabGeneration === directSshRetryAttempt.tabGeneration + return pendingMatches || liveBindingMatchesAttempt + } + const capturedDirectSshRetryStateMatches = (ptyId: string): boolean => { + if (!directSshRetryAttempt) { + return true + } + const currentConnection = useAppStore + .getState() + .sshConnectionStates.get(directSshRetryAttempt.authority.targetId) + return ( + parseAppSshPtyId(ptyId)?.connectionId === directSshRetryAttempt.authority.targetId && + currentConnection?.status === 'connected' && + capturedDirectSshRetryLeaseMatches() + ) + } + const claimCapturedDirectSshRetryPty = (ptyId: string): boolean => { + if (!capturedDirectSshRetryStateMatches(ptyId)) { + return false + } + capturedDirectSshRetryPtyAccepted = directSshRetryAttempt !== undefined + return true + } + const canAdoptCapturedDirectSshRetryPty = (ptyId: string): boolean => { + const canAdopt = capturedDirectSshRetryStateMatches(ptyId) + if (canAdopt && directSshRetryAttempt) { + capturedDirectSshRetryPtyAccepted = true + } + return canAdopt + } + const settleDirectSshPaneRetryAttempt = ( + attempt: DirectSshRetryLease | undefined, + status: 'failed' | 'timed-out' + ): void => { + if (!attempt) { + return + } + useAppStore.getState().settleDirectSshPaneRetry?.({ + status, + tabId: deps.tabId, + attemptId: attempt.attemptId, + authority: attempt.authority, + tabGeneration: attempt.tabGeneration + }) + } + const armDirectSshPaneRetryTimeout = ( + promise: Promise, + attempt: DirectSshRetryLease | undefined + ): void => { + if (!attempt || disposed || directSshPaneRetryTimedPromises.has(promise)) { + return + } + directSshPaneRetryTimedPromises.add(promise) + const timer = setTimeout(() => { + directSshPaneRetrySettlementTimers.delete(timer) + if (directSshPaneRetrySettlementCancelled) { + return + } + settleDirectSshPaneRetryAttempt(attempt, 'timed-out') + }, DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS) + directSshPaneRetrySettlementTimers.add(timer) + void promise + .finally(() => { + directSshPaneRetrySettlementTimers.delete(timer) + clearTimeout(timer) + }) + .catch(() => {}) + } const shellOverride = tab?.shellOverride // Why: a serve/remote-runtime pane has no SSH connectionId and a Linux cwd, so // the native-Windows ConPTY heuristic misfires on a Windows client and wrongly @@ -4911,6 +5056,9 @@ export function connectPanePty( : typeof spawnedPtyId === 'string' ? spawnedPtyId : transport.getPtyId() + if (resolvedPtyId && !claimCapturedDirectSshRetryPty(resolvedPtyId)) { + return null + } if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), @@ -4997,6 +5145,18 @@ export function connectPanePty( pendingSpawnByPaneKey.delete(pendingSpawnKey) } }) + armDirectSshPaneRetryTimeout(trackedPromise, directSshRetryAttempt) + void trackedPromise.then((spawnedPtyId) => { + if (spawnedPtyId) { + return + } + queueMicrotask(() => { + if (disposed || transport.getPtyId() || pendingSpawnByPaneKey.has(pendingSpawnKey)) { + return + } + settleDirectSshPaneRetryAttempt(directSshRetryAttempt, 'failed') + }) + }) // Why: split panes in the same tab can spawn concurrently. Key by pane // as well as tab so a remount cannot attach to a sibling setup pane's PTY. pendingSpawnByPaneKey.set(pendingSpawnKey, trackedPromise) @@ -7319,6 +7479,16 @@ export function connectPanePty( } } + const isCapturedDirectSshReattachCurrent = (ptyId: string): boolean => + !directSshRetryAttempt || capturedDirectSshRetryStateMatches(ptyId) + const rejectObsoleteDirectSshReattach = (ptyId: string | null | undefined): boolean => { + if (!directSshRetryAttempt || (ptyId && claimCapturedDirectSshRetryPty(ptyId))) { + return false + } + transport.detach?.({ preserveExitObserver: false }) + return true + } + const handleReattachResult = async ( result: PtyConnectResult | string | void, staleSessionId?: string | null, @@ -7339,6 +7509,13 @@ export function connectPanePty( return true } + const retryPtyId = + connectResult?.id ?? + (typeof result === 'string' ? result : (staleSessionId ?? transport.getPtyId())) + if (rejectObsoleteDirectSshReattach(retryPtyId)) { + // Why: an obsolete reattach must stop consuming frames without killing the durable PTY a newer lease may adopt. + return false + } const ptyId = connectResult?.id ?? (typeof result === 'string' ? result : transport.getPtyId()) if (!ptyId) { @@ -7425,7 +7602,11 @@ export function connectPanePty( syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) notifyCodexPaneBoundForStaleSweep(ptyId) - deps.updateTabPtyId(deps.tabId, ptyId) + if (capturedDirectSshRetryPtyAccepted && directSshRetryAttempt) { + deps.updateTabPtyId(deps.tabId, ptyId, undefined, directSshRetryAttempt.attemptId) + } else { + deps.updateTabPtyId(deps.tabId, ptyId) + } agentCompletionCoordinator.startProcessTracking() sampleVisiblePaneForegroundAgent() @@ -7678,7 +7859,7 @@ export function connectPanePty( console.warn('[pty-connection] needsPassphrasePrompt probe failed:', err) // Why: on probe failure fall through to auto-connect rather than stranding the tab — a stuck tab is worse than a surprising prompt. } - if (disposed) { + if (disposed || !capturedDirectSshRetryLeaseMatches()) { return } if (needsPrompt) { @@ -7743,7 +7924,7 @@ export function connectPanePty( finish(currentOutcome) } }) - if (disposed) { + if (disposed || !capturedDirectSshRetryLeaseMatches()) { return } if (outcome === 'cancelled') { @@ -7758,11 +7939,11 @@ export function connectPanePty( // Why: wait for the shared SSH connection (multiple panes/tabs may need it) before PTY reattach, rather than returning early when it's in-flight. const connectResult = await waitForSshConnection(connectionId) - if (!connectResult.connected) { - reportError(`SSH connection failed: ${connectResult.error}`) + if (disposed || !capturedDirectSshRetryLeaseMatches()) { return } - if (disposed) { + if (!connectResult.connected) { + reportError(`SSH connection failed: ${connectResult.error}`) return } useAppStore.getState().removeDeferredSshReconnectTarget(connectionId) @@ -7790,6 +7971,9 @@ export function connectPanePty( expiredReattachError = true return } + if (!isCapturedDirectSshReattachCurrent(pendingSessionId)) { + return + } reportError(message) }) beginReattachLiveDataDeferral(outputCallbacks.generation) @@ -7814,6 +7998,7 @@ export function connectPanePty( : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), + ...(directSshRetryAttempt ? { admitPtyId: claimCapturedDirectSshRetryPty } : {}), callbacks: outputCallbacks.callbacks }) void Promise.resolve(reattachPromise) @@ -7821,7 +8006,7 @@ export function connectPanePty( .finally(() => { transportConnectInFlightSince = null }) - void Promise.resolve(reattachPromise) + const trackedReattachPromise = Promise.resolve(reattachPromise) .then(async (result) => { if (outputCallbacks.generation !== transportStreamGeneration) { finishReattachLiveDataDeferral(false, outputCallbacks.generation) @@ -7849,6 +8034,9 @@ export function connectPanePty( if (disposed) { return } + if (rejectObsoleteDirectSshReattach(pendingSessionId)) { + return + } deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId) deps.clearTabPtyId(deps.tabId, pendingSessionId) startFreshColdRestoreAgentResume(coldRestoreStartup, { @@ -7892,6 +8080,9 @@ export function connectPanePty( if (disposed || outputCallbacks.generation !== transportStreamGeneration) { return } + if (rejectObsoleteDirectSshReattach(pendingSessionId)) { + return + } if (isSshSessionExpiredError(err)) { deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId) deps.clearTabPtyId(deps.tabId, pendingSessionId) @@ -7904,6 +8095,7 @@ export function connectPanePty( forceBlankRestoredViewport: true }) }) + armDirectSshPaneRetryTimeout(trackedReattachPromise, directSshRetryAttempt) } else { startFreshColdRestoreAgentResume() } @@ -8013,6 +8205,9 @@ export function connectPanePty( expiredReattachError = true return } + if (!isCapturedDirectSshReattachCurrent(deferredReattachSessionId)) { + return + } reportError(message) }) beginReattachLiveDataDeferral(outputCallbacks.generation) @@ -8035,6 +8230,7 @@ export function connectPanePty( ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), + ...(directSshRetryAttempt ? { admitPtyId: claimCapturedDirectSshRetryPty } : {}), callbacks: outputCallbacks.callbacks }) @@ -8043,7 +8239,7 @@ export function connectPanePty( .finally(() => { transportConnectInFlightSince = null }) - void Promise.resolve(reattachPromise) + const trackedReattachPromise = Promise.resolve(reattachPromise) .then(async (result) => { if (outputCallbacks.generation !== transportStreamGeneration) { finishReattachLiveDataDeferral(false, outputCallbacks.generation) @@ -8062,6 +8258,9 @@ export function connectPanePty( if (disposed) { return } + if (rejectObsoleteDirectSshReattach(deferredReattachSessionId)) { + return + } deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId) deps.clearTabPtyId(deps.tabId, deferredReattachSessionId) startFreshColdRestoreAgentResume(coldRestoreStartup, { @@ -8105,6 +8304,9 @@ export function connectPanePty( if (outputCallbacks.generation !== transportStreamGeneration) { return } + if (rejectObsoleteDirectSshReattach(deferredReattachSessionId)) { + return + } warnTerminalLifecycleAnomaly('restored PTY reattach threw', { tabId: deps.tabId, worktreeId: deps.worktreeId, @@ -8126,6 +8328,7 @@ export function connectPanePty( forceBlankRestoredViewport: true }) }) + armDirectSshPaneRetryTimeout(trackedReattachPromise, directSshRetryAttempt) } else if (detachedRemoteLeafPtyId || detachedLivePtyId || eagerLivePtyId) { // Why: mirrored web-leaf panes must attach to their exact remote PTY, not spawn a replacement host tab. // eagerLivePtyId covers a still-live background PTY (e.g. an automation agent) with a live eager buffer to adopt. @@ -8162,6 +8365,7 @@ export function connectPanePty( const pendingSpawn = pendingSpawnByPaneKey.get(pendingSpawnKey) if (pendingSpawn) { recordPtyConnectDiagnostic(`pane=${pane.id} -> PENDING SPAWN`) + armDirectSshPaneRetryTimeout(pendingSpawn, directSshRetryAttempt) void pendingSpawn .then((spawnedPtyId) => { if (disposed) { @@ -8185,6 +8389,9 @@ export function connectPanePty( } return } + if (!canAdoptCapturedDirectSshRetryPty(spawnedPtyId)) { + return + } clearPaneMode2031State() clearHiddenOutputRestoreState() const outputCallbacks = captureTransportOutputCallbacks(reportError) @@ -8366,6 +8573,11 @@ export function connectPanePty( reconcileIfSessionMissing, dispose() { disposed = true + directSshPaneRetrySettlementCancelled = true + for (const timer of directSshPaneRetrySettlementTimers) { + clearTimeout(timer) + } + directSshPaneRetrySettlementTimers.clear() // Why: a stalled xterm replay may never reach its finally; release live-frame credit when this renderer no longer owns the stream. for (const chunk of deferredReattachLiveData ?? []) { chunk.ackCredit?.() 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 804655edbdf..ef7d9974464 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -119,6 +119,8 @@ export type PtyTransport = { launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery + /** Reject a stale restored identity before this transport can publish global PTY handlers. */ + admitPtyId?: (ptyId: string) => boolean callbacks: PtyCallbacks }) => void | Promise attach: (options: { @@ -170,7 +172,7 @@ export type PtyTransport = { resetCrossChunkParserState?: () => void serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise preserve?: () => void - detach?: () => void + detach?: (options?: { preserveExitObserver?: boolean }) => void destroy?: () => void | Promise } 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 a0bc22e229c..3a71a811bc5 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -277,6 +277,109 @@ describe('createIpcPtyTransport', () => { expect(exitSeenByNewPane).toHaveBeenCalledWith(0) }) + it('rejects a stale reattach before it can replace newer PTY handlers', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + let resolveStale!: (value: { id: string; isReattach: boolean }) => void + spawn.mockReturnValueOnce( + new Promise((resolve) => { + resolveStale = resolve + }) + ) + const staleData = vi.fn() + const staleExit = vi.fn() + const stalePane = createIpcPtyTransport({}) + const staleConnect = stalePane.connect({ + url: '', + sessionId: 'pty-1', + admitPtyId: () => false, + callbacks: { onData: staleData, onExit: staleExit } + }) + const currentData = vi.fn() + const currentExit = vi.fn() + const currentPane = createIpcPtyTransport({}) + currentPane.attach({ + existingPtyId: 'pty-1', + callbacks: { onData: currentData, onExit: currentExit } + }) + + resolveStale({ id: 'pty-1', isReattach: true }) + await staleConnect + onData?.({ id: 'pty-1', data: 'current output' }) + onExit?.({ id: 'pty-1', code: 0 }) + + expect(currentData).toHaveBeenCalledWith('current output') + expect(currentExit).toHaveBeenCalledWith(0) + expect(staleData).not.toHaveBeenCalled() + expect(staleExit).not.toHaveBeenCalled() + expect(window.api.pty.kill).not.toHaveBeenCalled() + }) + + it('retires a rejected fresh fallback before it can publish PTY handlers', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const kill = window.api.pty.kill as unknown as ReturnType + const onPtySpawn = vi.fn() + const onDataCallback = vi.fn() + const onExitCallback = vi.fn() + spawn.mockResolvedValueOnce({ id: 'pty-fresh-fallback', sessionExpired: true }) + const transport = createIpcPtyTransport({ onPtySpawn }) + + const result = await transport.connect({ + url: '', + sessionId: 'pty-missing', + admitPtyId: () => false, + callbacks: { onData: onDataCallback, onExit: onExitCallback } + }) + onData?.({ id: 'pty-fresh-fallback', data: 'orphaned output' }) + onExit?.({ id: 'pty-fresh-fallback', code: 0 }) + + expect(result).toEqual({ id: 'pty-fresh-fallback', sessionExpired: true }) + expect(kill).toHaveBeenCalledExactlyOnceWith('pty-fresh-fallback') + expect(onPtySpawn).not.toHaveBeenCalled() + expect(onDataCallback).not.toHaveBeenCalled() + expect(onExitCallback).not.toHaveBeenCalled() + expect(transport.getPtyId()).toBeNull() + expect(transport.isConnected()).toBe(false) + }) + + it('surfaces rejected fresh fallback retirement without publishing PTY handlers', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const kill = window.api.pty.kill as unknown as ReturnType + const onPtySpawn = vi.fn() + const onDataCallback = vi.fn() + const onExitCallback = vi.fn() + const onErrorCallback = vi.fn() + const retirementError = new Error('provider shutdown refused') + spawn.mockResolvedValueOnce({ id: 'pty-fresh-fallback', sessionExpired: true }) + kill.mockRejectedValueOnce(retirementError) + const transport = createIpcPtyTransport({ onPtySpawn }) + + await expect( + transport.connect({ + url: '', + sessionId: 'pty-missing', + admitPtyId: () => false, + callbacks: { + onData: onDataCallback, + onExit: onExitCallback, + onError: onErrorCallback + } + }) + ).resolves.toBeUndefined() + onData?.({ id: 'pty-fresh-fallback', data: 'orphaned output' }) + onExit?.({ id: 'pty-fresh-fallback', code: 0 }) + + expect(kill).toHaveBeenCalledExactlyOnceWith('pty-fresh-fallback') + expect(onPtySpawn).not.toHaveBeenCalled() + expect(onDataCallback).not.toHaveBeenCalled() + expect(onExitCallback).not.toHaveBeenCalled() + expect(onErrorCallback).toHaveBeenCalledExactlyOnceWith(retirementError.message) + expect(transport.getPtyId()).toBeNull() + expect(transport.isConnected()).toBe(false) + }) + it('buffers data across a normal detach-then-attach gap and drains it to the next pane', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const receivedByNewPane = vi.fn() @@ -463,6 +566,38 @@ describe('createIpcPtyTransport', () => { expect(transport.isConnected()).toBe(false) }) + it('rejects a buffered dead-session exit before publishing its final frame', async () => { + const { bufferPreHandlerPtyData, bufferPreHandlerPtyExit, clearPreHandlerPtyState } = + await import('./pty-pre-handler-buffer') + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const onDataCallback = vi.fn() + const onExitCallback = vi.fn() + const onDisconnect = vi.fn() + const onPtyExit = vi.fn() + const sessionId = 'stale-dead-parked-session' + bufferPreHandlerPtyData(sessionId, 'stale final output') + bufferPreHandlerPtyExit(sessionId, 17) + + const transport = createIpcPtyTransport({ onPtyExit }) + const result = await transport.connect({ + url: '', + sessionId, + admitPtyId: () => false, + callbacks: { onData: onDataCallback, onExit: onExitCallback, onDisconnect } + }) + + expect(result).toEqual({ id: sessionId }) + expect(spawn).not.toHaveBeenCalled() + expect(onDataCallback).not.toHaveBeenCalled() + expect(onExitCallback).not.toHaveBeenCalled() + expect(onDisconnect).not.toHaveBeenCalled() + expect(onPtyExit).not.toHaveBeenCalled() + expect(window.api.pty.kill).not.toHaveBeenCalled() + expect(transport.isConnected()).toBe(false) + clearPreHandlerPtyState(sessionId) + }) + it('returns startup cwd fallback metadata to the connection layer', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType @@ -1527,8 +1662,10 @@ describe('createIpcPtyTransport', () => { it('does not kill a pre-existing session when a reattach resolves after destroy', async () => { const { createIpcPtyTransport } = await import('./pty-transport') - const spawnControls: { resolve: ((value: { id: string }) => void) | null } = { resolve: null } - const spawnPromise = new Promise<{ id: string }>((resolve) => { + const spawnControls: { + resolve: ((value: { id: string; isReattach: true }) => void) | null + } = { resolve: null } + const spawnPromise = new Promise<{ id: string; isReattach: true }>((resolve) => { spawnControls.resolve = resolve }) const spawnMock = vi.fn().mockReturnValue(spawnPromise) @@ -1563,12 +1700,41 @@ describe('createIpcPtyTransport', () => { if (!spawnControls.resolve) { throw new Error('Expected spawn resolver to be captured') } - spawnControls.resolve({ id: 'pty-preexisting' }) + spawnControls.resolve({ id: 'pty-preexisting', isReattach: true }) await connectPromise expect(killMock).not.toHaveBeenCalledWith('pty-preexisting') }) + it('kills a fresh session fallback that resolves after the transport was destroyed', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawnControls: { + resolve: ((value: { id: string; sessionExpired: true }) => void) | null + } = { resolve: null } + const spawnPromise = new Promise<{ id: string; sessionExpired: true }>((resolve) => { + spawnControls.resolve = resolve + }) + const spawn = window.api.pty.spawn as unknown as ReturnType + const kill = window.api.pty.kill as unknown as ReturnType + spawn.mockReturnValueOnce(spawnPromise) + const transport = createIpcPtyTransport({}) + const connectPromise = transport.connect({ + url: '', + sessionId: 'pty-missing', + callbacks: {} + }) + + transport.destroy?.() + if (!spawnControls.resolve) { + throw new Error('Expected spawn resolver to be captured') + } + spawnControls.resolve({ id: 'pty-fresh-fallback', sessionExpired: true }) + await connectPromise + + expect(kill).toHaveBeenCalledExactlyOnceWith('pty-fresh-fallback') + expect(transport.getPtyId()).toBeNull() + }) + it('kills a PTY that finishes spawning after the transport was destroyed', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawnControls: { resolve: ((value: { id: string }) => void) | null } = { resolve: null } @@ -2025,6 +2191,42 @@ describe('createIpcPtyTransport', () => { expect(onPtyExit).toHaveBeenCalledWith('pty-detached') expect(transport.getPtyId()).toBeNull() }) + + it('drops the exit observer when abandoning an obsolete reattach without killing it', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const onPtyExit = vi.fn() + const kill = window.api.pty.kill as unknown as ReturnType + const transport = createIpcPtyTransport({ onPtyExit }) + const onDataCallback = vi.fn() + const onReplayData = vi.fn() + const onWriteUnavailableCallback = vi.fn() + const onExitCallback = vi.fn() + const onDisconnect = vi.fn() + + transport.attach({ + existingPtyId: 'pty-obsolete', + callbacks: { + onData: onDataCallback, + onReplayData, + onWriteUnavailable: onWriteUnavailableCallback, + onExit: onExitCallback, + onDisconnect + } + }) + transport.detach?.({ preserveExitObserver: false }) + onData?.({ id: 'pty-obsolete', data: 'stale data' }) + onReplay?.({ id: 'pty-obsolete', data: 'stale replay' }) + onWriteUnavailable?.({ id: 'pty-obsolete' }) + onExit?.({ id: 'pty-obsolete', code: 0 }) + + expect(onDataCallback).not.toHaveBeenCalled() + expect(onReplayData).not.toHaveBeenCalled() + expect(onWriteUnavailableCallback).not.toHaveBeenCalled() + expect(onExitCallback).not.toHaveBeenCalled() + expect(onDisconnect).not.toHaveBeenCalled() + expect(onPtyExit).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + }) }) describe('createRemoteRuntimePtyTransport', () => { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 1e3054f7050..7df7fc18ab5 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -696,6 +696,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } if (options.sessionId && hasPreHandlerPtyExit(options.sessionId)) { + if (options.admitPtyId && !options.admitPtyId(options.sessionId)) { + return { id: options.sessionId } satisfies PtyConnectResult + } // Why: deliver the exited parked session's buffered final frame/exit before spawn, so the dead incarnation can't orphan a fresh shell reusing its id. ptyId = options.sessionId connected = true @@ -761,15 +764,24 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra const resultLaunchAgent = isTuiAgent(spawnResult.launchAgent) ? spawnResult.launchAgent : undefined + const retireFreshSpawn = async (): Promise => { + if (!spawnResult.isReattach && !spawnResult.coldRestore) { + await window.api.pty.kill(spawnResult.id) + } + } // Why: on destroy mid-connect, kill only a fresh spawn — killing a reattached session (owned by the tab lifecycle) loses a live shell. if (destroyed) { - if (!options.sessionId) { - window.api.pty.kill(spawnResult.id) - } + await retireFreshSpawn() return } + if (options.admitPtyId && !options.admitPtyId(spawnResult.id)) { + // Why: a rejected session-expired fallback has no owner to retire its newly created process. + await retireFreshSpawn() + return spawnResult + } + ptyId = spawnResult.id connected = true @@ -933,12 +945,16 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } }, - detach() { + detach(options) { clearAccumulatedState() inputWriteQueue.clear() if (ptyId) { // Why: on remount keep the exit observer alive so a shell dying in the gap still clears stale tab/leaf bindings before reattach. - unregisterPtyDataAndStatusHandlers(ptyId) + if (options?.preserveExitObserver === false) { + unregisterPtyHandlers(ptyId) + } else { + unregisterPtyDataAndStatusHandlers(ptyId) + } } connected = false ptyId = null diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts index 3d5108da618..8cfbc886925 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -416,4 +416,39 @@ describe('detachTerminalPaneToTab', () => { ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' } }) }) + + it('keeps a detached null-PTY leaf eligible to finish its pending activation', () => { + const store = createStore({ + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null + }) + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn(() => LEAF_2), + detachPaneForExternalMove: vi.fn(() => true) + } + + const result = detachTerminalPaneToTab({ + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(result?.ptyId).toBeNull() + expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, 'powershell.exe', { + activate: true, + pendingActivationSpawn: true, + recordInteraction: true + }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts index 7cbc9c095f1..f6cac5c5aca 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -285,6 +285,7 @@ export function detachTerminalPaneToTab(args: { const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, sourceShellOverride, { activate: true, initialPtyId: ptyId ?? undefined, + ...(!ptyId ? { pendingActivationSpawn: true } : {}), recordInteraction: true }) const afterCreateStore = args.getStore() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 48ced74b061..66e91ee7fdb 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -25,6 +25,7 @@ import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows- import { buildTerminalKeyboardProtocolOptions } from '@/lib/pane-manager/terminal-keyboard-protocol' import { resolvePaneKeyboardProtocolAgent } from './terminal-keyboard-protocol-pane-agent' import { useAppStore } from '@/store' +import type { DirectSshPaneRetryAttemptId } from '@/store/slices/direct-ssh-terminal-recovery' import { createFilePathLinkProvider, getTerminalFileOpenHint, @@ -276,7 +277,12 @@ type UseTerminalPaneLifecycleDeps = { updateTabTitle: (tabId: string, title: string) => void setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void clearRuntimePaneTitle: (tabId: string, paneId: number) => void - updateTabPtyId: (tabId: string, ptyId: string, replacedPtyId?: string) => void + updateTabPtyId: ( + tabId: string, + ptyId: string, + replacedPtyId?: string, + directSshRetryAttemptId?: DirectSshPaneRetryAttemptId + ) => void markWorktreeUnread: (worktreeId: string) => void markTerminalTabUnread: (tabId: string) => void markTerminalPaneUnread: (paneKey: string) => void diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index e07b5d6a32b..390ebfe36c5 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -79,6 +79,7 @@ declare global { // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface interface ImportMetaEnv { + readonly VITE_DIRECT_SSH_RECONNECT_COORDINATOR?: string readonly VITE_EXPOSE_STORE?: boolean } diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration-scope.ts b/src/renderer/src/hooks/direct-ssh-host-hydration-scope.ts new file mode 100644 index 00000000000..7bb5e6cfd53 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-host-hydration-scope.ts @@ -0,0 +1,20 @@ +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope' +import type { AppState } from '../store/types' + +export function directSshHostHydrationScope( + state: AppState, + authority: DirectSshAuthority, + catalogRevision: number +) { + return resolveDirectSshTargetScope({ + targetId: authority.targetId, + catalogRevision, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + }) +} diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration-telemetry.ts b/src/renderer/src/hooks/direct-ssh-host-hydration-telemetry.ts new file mode 100644 index 00000000000..7e1e776674a --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-host-hydration-telemetry.ts @@ -0,0 +1,17 @@ +import type { DirectSshTargetScope } from '../lib/direct-ssh-target-scope' +import type { DirectSshPreparationInputTelemetry } from './direct-ssh-reconnect-coordinator' + +export function directSshHostHydrationTelemetry( + scope: DirectSshTargetScope, + catalogOutcome: DirectSshPreparationInputTelemetry['catalogOutcome'], + catalogDurationMs: number +): DirectSshPreparationInputTelemetry { + return { + catalogOutcome, + catalogDurationMs, + gitWorktreeCount: scope.gitWorktreeIds.size, + folderWorkspaceCount: Math.max(0, scope.terminalWorkspaceKeys.size - scope.gitWorktreeIds.size), + ambiguousOwnerCount: scope.ambiguousOwnerCount, + contradictoryOwnerCount: scope.contradictoryOwnerCount + } +} diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts new file mode 100644 index 00000000000..71d29e256e6 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts @@ -0,0 +1,390 @@ +import { createStore } from 'zustand/vanilla' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { HostLineageSnapshot } from '../../../shared/host-lineage-contract' +import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-contract' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import type { Repo } from '../../../shared/types' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { AppState } from '../store/types' +import { createDirectSshHostHydration } from './direct-ssh-host-hydration' + +function authority(targetId = 'target-a', epoch = 'epoch-a'): DirectSshAuthority { + return { + targetId, + providerEpoch: epoch as SshProviderEpoch, + connectionGeneration: 1 + } +} + +function repo(id: string, targetId: string | null): Repo { + return { + id, + path: targetId ? `/${targetId}/${id}` : `/local/${id}`, + projectGroupId: null, + connectionId: targetId, + executionHostId: targetId ? (`ssh:${targetId}` as const) : ('local' as const) + } as Repo +} + +function state(overrides: Record = {}): AppState { + return { + repos: [], + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + worktreeLineageById: {}, + workspaceLineageByChildKey: {}, + ...overrides + } as unknown as AppState +} + +function hostSnapshot( + owner: DirectSshAuthority, + repos: ReturnType[] +): HostRepoCatalogSnapshot { + return { + authoritative: true as const, + authority: { + kind: 'direct-ssh' as const, + executionHostId: `ssh:${owner.targetId}` as const, + ...owner + }, + repos + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('createDirectSshHostHydration', () => { + it('replaces only the exact SSH host catalog when repo IDs collide', async () => { + const owner = authority() + const store = createStore(() => + state({ + repos: [repo('shared', null), repo('shared', 'target-a'), repo('shared', 'target-b')] + }) + ) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(async () => hostSnapshot(owner, [repo('shared', 'target-a')])), + listLineage: vi.fn(), + isCurrentAuthority: () => true + }) + + const input = await hydration.capturePreparationInput(owner, 'reconnect') + + expect(input?.repoRefs).toEqual([{ repoId: 'shared', executionHostId: 'ssh:target-a' }]) + expect(store.getState().repos).toEqual([ + repo('shared', null), + repo('shared', 'target-b'), + repo('shared', 'target-a') + ]) + }) + + it('rejects a mismatched host response without publishing', async () => { + const owner = authority() + const store = createStore(() => state({ repos: [repo('cached', 'target-a')] })) + const before = store.getState().repos + let publications = 0 + store.subscribe(() => { + publications += 1 + }) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(async () => + hostSnapshot(authority('target-b', 'epoch-b'), [repo('wrong', 'target-b')]) + ), + listLineage: vi.fn(), + isCurrentAuthority: () => true + }) + + await expect(hydration.capturePreparationInput(owner, 'wake-refresh')).resolves.toBeNull() + expect(store.getState().repos).toBe(before) + expect(publications).toBe(0) + }) + + it('settles a non-cooperative catalog read at five seconds and clears its timer', async () => { + vi.useFakeTimers() + const owner = authority() + const store = createStore(() => state({ repos: [repo('cached', 'target-a')] })) + let resolveLate!: (value: HostRepoCatalogSnapshot) => void + const lateSnapshot = new Promise((resolve) => { + resolveLate = resolve + }) + let publications = 0 + store.subscribe(() => { + publications += 1 + }) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(() => lateSnapshot), + listLineage: vi.fn(), + isCurrentAuthority: () => true + }) + + const pending = hydration.capturePreparationInput(owner, 'wake-refresh') + let settled = false + void pending.then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(4_999) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await expect(pending).resolves.toMatchObject({ + catalogRevision: 0, + repoRefs: [{ repoId: 'cached', executionHostId: 'ssh:target-a' }] + }) + expect(vi.getTimerCount()).toBe(0) + resolveLate(hostSnapshot(owner, [repo('late', 'target-a')])) + await Promise.resolve() + await Promise.resolve() + expect(store.getState().repos).toEqual([repo('cached', 'target-a')]) + expect(publications).toBe(0) + }) + + it('discards a catalog reply after authority changes with zero mutation', async () => { + const owner = authority() + const store = createStore(() => state({ repos: [repo('cached', 'target-a')] })) + let current = true + let resolve!: (value: ReturnType) => void + const pendingSnapshot = new Promise((settle) => { + resolve = settle + }) + let publications = 0 + store.subscribe(() => { + publications += 1 + }) + const hydration = createDirectSshHostHydration({ + store, + listRepos: () => pendingSnapshot, + listLineage: vi.fn(), + isCurrentAuthority: () => current + }) + + const pending = hydration.capturePreparationInput(owner, 'reconnect') + current = false + resolve(hostSnapshot(owner, [repo('new', 'target-a')])) + + await expect(pending).resolves.toBeNull() + expect(store.getState().repos).toEqual([repo('cached', 'target-a')]) + expect(publications).toBe(0) + }) + + it('settles pending host reads and clears their timers on stop', async () => { + vi.useFakeTimers() + const owner = authority() + const store = createStore(() => state()) + const hydration = createDirectSshHostHydration({ + store, + listRepos: () => new Promise(() => {}), + listLineage: vi.fn(), + isCurrentAuthority: () => true + }) + + const pending = hydration.capturePreparationInput(owner, 'wake-refresh') + await Promise.resolve() + expect(vi.getTimerCount()).toBe(1) + hydration.stop() + + await expect(pending).resolves.toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('replaces only exact-host Git and folder lineage across both key namespaces', async () => { + const owner = authority() + const store = createStore(() => + state({ + repos: [ + repo('a', 'target-a'), + repo('b', 'target-b'), + repo('ambiguous', 'target-a'), + repo('ambiguous', 'target-a'), + repo('contradictory', 'target-a') + ], + worktreesByRepo: { + a: [{ id: 'a::/work', repoId: 'a', hostId: 'ssh:target-a' }], + b: [{ id: 'b::/work', repoId: 'b', hostId: 'ssh:target-b' }], + ambiguous: [ + { + id: 'ambiguous::/work', + repoId: 'ambiguous', + hostId: 'ssh:target-a' + } + ], + contradictory: [ + { + id: 'contradictory::/work', + repoId: 'contradictory', + hostId: 'ssh:target-b' + } + ] + }, + projectGroups: [ + { + id: 'group-a', + parentGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + }, + { + id: 'group-b', + parentGroupId: null, + connectionId: 'target-b', + executionHostId: 'ssh:target-b' + } + ], + folderWorkspaces: [ + { + id: 'folder-a', + projectGroupId: 'group-a', + folderPath: '/target-a/folder', + connectionId: 'target-a' + }, + { + id: 'folder-b', + projectGroupId: 'group-b', + folderPath: '/target-b/folder', + connectionId: 'target-b' + } + ], + worktreeLineageById: { + 'a::/work': { parentWorktreeId: 'stale-a' }, + 'b::/work': { parentWorktreeId: 'keep-b' }, + 'ambiguous::/work': { parentWorktreeId: 'keep-ambiguous' }, + 'contradictory::/work': { parentWorktreeId: 'keep-contradictory' } + }, + workspaceLineageByChildKey: { + [worktreeWorkspaceKey('a::/work')]: { parentWorkspaceKey: 'stale-a-workspace' }, + [worktreeWorkspaceKey('b::/work')]: { parentWorkspaceKey: 'keep-b-workspace' }, + [worktreeWorkspaceKey('ambiguous::/work')]: { + parentWorkspaceKey: 'keep-ambiguous-workspace' + }, + [worktreeWorkspaceKey('contradictory::/work')]: { + parentWorkspaceKey: 'keep-contradictory-workspace' + }, + [folderWorkspaceKey('folder-a')]: { parentWorkspaceKey: 'stale-folder' }, + [folderWorkspaceKey('folder-b')]: { parentWorkspaceKey: 'keep-folder-b' } + } + }) + ) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(), + listLineage: vi.fn( + async () => + ({ + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: 'ssh:target-a', + ...owner + }, + worktreeLineageById: { + 'a::/work': { parentWorktreeId: 'fresh-a' }, + 'b::/work': { parentWorktreeId: 'foreign-overwrite' }, + 'ambiguous::/work': { parentWorktreeId: 'ambiguous-overwrite' }, + 'contradictory::/work': { parentWorktreeId: 'contradictory-overwrite' } + }, + workspaceLineageByChildKey: { + 'a::/work': { parentWorkspaceKey: 'raw-terminal-key-must-not-enter-lineage' }, + [worktreeWorkspaceKey('a::/work')]: { + parentWorkspaceKey: 'fresh-a-workspace' + }, + [worktreeWorkspaceKey('b::/work')]: { + parentWorkspaceKey: 'foreign-workspace-overwrite' + }, + [worktreeWorkspaceKey('ambiguous::/work')]: { + parentWorkspaceKey: 'ambiguous-workspace-overwrite' + }, + [worktreeWorkspaceKey('contradictory::/work')]: { + parentWorkspaceKey: 'contradictory-workspace-overwrite' + }, + [folderWorkspaceKey('folder-a')]: { parentWorkspaceKey: 'fresh-folder' }, + [folderWorkspaceKey('folder-b')]: { + parentWorkspaceKey: 'foreign-folder-overwrite' + } + } + }) as unknown as HostLineageSnapshot + ), + isCurrentAuthority: () => true + }) + + await expect( + hydration.readHostScopedLineage({ + ...owner, + catalogRevision: 0, + repoRefs: [{ repoId: 'a', executionHostId: 'ssh:target-a' }], + authorityRequirement: 'required', + reason: 'reconnect' + }) + ).resolves.toBe('complete') + expect(store.getState().worktreeLineageById).toEqual({ + 'a::/work': { parentWorktreeId: 'fresh-a' }, + 'b::/work': { parentWorktreeId: 'keep-b' }, + 'ambiguous::/work': { parentWorktreeId: 'keep-ambiguous' }, + 'contradictory::/work': { parentWorktreeId: 'keep-contradictory' } + }) + expect(store.getState().workspaceLineageByChildKey).toEqual({ + [worktreeWorkspaceKey('b::/work')]: { parentWorkspaceKey: 'keep-b-workspace' }, + [worktreeWorkspaceKey('ambiguous::/work')]: { + parentWorkspaceKey: 'keep-ambiguous-workspace' + }, + [worktreeWorkspaceKey('contradictory::/work')]: { + parentWorkspaceKey: 'keep-contradictory-workspace' + }, + [folderWorkspaceKey('folder-b')]: { parentWorkspaceKey: 'keep-folder-b' }, + [worktreeWorkspaceKey('a::/work')]: { parentWorkspaceKey: 'fresh-a-workspace' }, + [folderWorkspaceKey('folder-a')]: { parentWorkspaceKey: 'fresh-folder' } + }) + }) + + it('rejects lineage captured before a newer same-authority catalog revision', async () => { + const owner = authority() + const store = createStore(() => + state({ + repos: [repo('a', 'target-a')], + worktreesByRepo: { + a: [{ id: 'a::/work', repoId: 'a', hostId: 'ssh:target-a' }] + }, + worktreeLineageById: { + 'a::/work': { parentWorktreeId: 'keep-current' } + } + }) + ) + let resolveLineage!: (value: HostLineageSnapshot) => void + const pendingLineage = new Promise((resolve) => { + resolveLineage = resolve + }) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(async () => hostSnapshot(owner, [repo('a', 'target-a')])), + listLineage: () => pendingLineage, + isCurrentAuthority: () => true + }) + const firstInput = await hydration.capturePreparationInput(owner, 'reconnect') + if (!firstInput) { + throw new Error('Expected first preparation input') + } + const pending = hydration.readHostScopedLineage(firstInput) + await hydration.capturePreparationInput(owner, 'wake-refresh') + const beforeLateLineage = store.getState().worktreeLineageById + resolveLineage({ + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: 'ssh:target-a', + ...owner + }, + worktreeLineageById: { + 'a::/work': { parentWorktreeId: 'stale-overwrite' } + }, + workspaceLineageByChildKey: {} + } as unknown as HostLineageSnapshot) + + await expect(pending).resolves.toBe('stale') + expect(store.getState().worktreeLineageById).toBe(beforeLateLineage) + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.ts new file mode 100644 index 00000000000..d3a5a2bc366 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-host-hydration.ts @@ -0,0 +1,311 @@ +import type { StoreApi } from 'zustand' +import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' +import type { HostLineageSnapshot } from '../../../shared/host-lineage-contract' +import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-contract' +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import { isWorkspaceKey } from '../../../shared/workspace-scope' +import type { AppState } from '../store/types' +import type { + DirectSshLineageOutcome, + DirectSshPreparationInput, + DirectSshPreparationReason +} from './direct-ssh-reconnect-coordinator' +import { directSshHostHydrationTelemetry } from './direct-ssh-host-hydration-telemetry' +import { directSshHostHydrationScope } from './direct-ssh-host-hydration-scope' +import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' + +export const DIRECT_SSH_HOST_READ_TIMEOUT_MS = 5_000 + +type HostReadTimer = unknown + +export type DirectSshHostHydrationDeps = { + store: Pick, 'getState' | 'setState'> + listRepos: (authority: DirectSshAuthority) => Promise + listLineage: (authority: DirectSshAuthority) => Promise + isCurrentAuthority: (authority: DirectSshAuthority) => boolean + setTimer?: (callback: () => void, delayMs: number) => HostReadTimer + clearTimer?: (timer: HostReadTimer) => void +} + +export type DirectSshHostHydration = { + capturePreparationInput: ( + authority: DirectSshAuthority, + reason: DirectSshPreparationReason, + snapshotRevision?: number + ) => Promise + readHostScopedLineage: (input: DirectSshPreparationInput) => Promise + isPreparationTokenCurrent: (token: { + authority: DirectSshAuthority + catalogRevision: number + repoFingerprint: string + }) => boolean + stop: () => void +} + +type BoundedResult = { status: 'complete'; value: T } | { status: 'timed-out' | 'unavailable' } + +function isAuthoritativeHost( + authority: HostRepoCatalogSnapshot | HostLineageSnapshot, + expected: DirectSshAuthority +): authority is Extract { + if (!authority.authoritative || authority.authority.kind !== 'direct-ssh') { + return false + } + return ( + authority.authority.executionHostId === toSshExecutionHostId(expected.targetId) && + directSshAuthoritiesEqual(authority.authority, expected) + ) +} + +function mergeExactHostCatalog(state: AppState, snapshot: HostRepoCatalogSnapshot): AppState { + if (!snapshot.authoritative || snapshot.authority.kind !== 'direct-ssh') { + return state + } + const hostId = snapshot.authority.executionHostId + return { + ...state, + repos: [ + ...state.repos.filter((repo) => getRepoExecutionHostId(repo) !== hostId), + ...snapshot.repos + ] + } +} + +function mergeExactHostLineage( + state: AppState, + snapshot: Extract, + authority: DirectSshAuthority, + catalogRevision: number +): AppState { + const scope = directSshHostHydrationScope(state, authority, catalogRevision) + const worktreeLineageById = Object.fromEntries( + Object.entries(state.worktreeLineageById).filter( + ([worktreeId]) => !scope.gitWorktreeIds.has(worktreeId) + ) + ) + const workspaceLineageByChildKey = Object.fromEntries( + Object.entries(state.workspaceLineageByChildKey).filter( + ([childKey]) => !isWorkspaceKey(childKey) || !scope.lineageWorkspaceKeys.has(childKey) + ) + ) + const incomingWorktreeLineage = Object.fromEntries( + Object.entries(snapshot.worktreeLineageById).filter(([worktreeId]) => + scope.gitWorktreeIds.has(worktreeId) + ) + ) + const incomingWorkspaceLineage = Object.fromEntries( + Object.entries(snapshot.workspaceLineageByChildKey).filter( + ([workspaceKey]) => + isWorkspaceKey(workspaceKey) && scope.lineageWorkspaceKeys.has(workspaceKey) + ) + ) + return { + ...state, + worktreeLineageById: { + ...worktreeLineageById, + ...incomingWorktreeLineage + }, + workspaceLineageByChildKey: { + ...workspaceLineageByChildKey, + ...incomingWorkspaceLineage + } + } +} + +export function createDirectSshHostHydration( + deps: DirectSshHostHydrationDeps +): DirectSshHostHydration { + const setTimer: NonNullable = + deps.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)) + const clearTimer: NonNullable = + deps.clearTimer ?? ((timer) => clearTimeout(timer as ReturnType)) + const catalogRevisionByTarget = new Map() + const catalogInFlight = new Map>() + const pendingDeadlines = new Set<{ timer: HostReadTimer; settle: () => void }>() + let stopped = false + + const bounded = async (operation: Promise): Promise> => { + let pendingDeadline: { timer: HostReadTimer; settle: () => void } | undefined + const deadline = new Promise>((resolve) => { + const settle = (): void => resolve({ status: 'timed-out' }) + const timer = setTimer(settle, DIRECT_SSH_HOST_READ_TIMEOUT_MS) + pendingDeadline = { timer, settle } + pendingDeadlines.add(pendingDeadline) + }) + try { + return await Promise.race([ + operation.then, BoundedResult>( + (value) => ({ status: 'complete', value }), + () => ({ status: 'unavailable' }) + ), + deadline + ]) + } finally { + if (pendingDeadline) { + clearTimer(pendingDeadline.timer) + pendingDeadlines.delete(pendingDeadline) + } + } + } + + const refreshCatalog = ( + authority: DirectSshAuthority + ): Promise<'complete' | 'degraded' | 'stale'> => { + const key = JSON.stringify([ + authority.targetId, + authority.providerEpoch, + authority.connectionGeneration + ]) + const existing = catalogInFlight.get(key) + if (existing) { + return existing + } + const operation = (async (): Promise<'complete' | 'degraded' | 'stale'> => { + const boundedResult = await bounded(Promise.resolve().then(() => deps.listRepos(authority))) + if (stopped || !deps.isCurrentAuthority(authority)) { + return 'stale' + } + if (boundedResult.status !== 'complete') { + return 'degraded' + } + const snapshot = boundedResult.value + if (!snapshot.authoritative) { + return snapshot.reason === 'stale' || snapshot.reason === 'rejected' ? 'stale' : 'degraded' + } + if ( + !isAuthoritativeHost(snapshot, authority) || + snapshot.repos.some( + (repo) => + getRepoExecutionHostId(repo) !== toSshExecutionHostId(authority.targetId) || + repo.connectionId !== authority.targetId + ) + ) { + return 'stale' + } + let admitted = false + deps.store.setState((state) => { + if (stopped || !deps.isCurrentAuthority(authority)) { + return state + } + admitted = true + return mergeExactHostCatalog(state, snapshot) + }) + if (!admitted) { + return 'stale' + } + catalogRevisionByTarget.set( + authority.targetId, + (catalogRevisionByTarget.get(authority.targetId) ?? 0) + 1 + ) + return 'complete' + })().finally(() => { + if (catalogInFlight.get(key) === operation) { + catalogInFlight.delete(key) + } + }) + catalogInFlight.set(key, operation) + return operation + } + + const capturePreparationInput = async ( + authority: DirectSshAuthority, + reason: DirectSshPreparationReason, + snapshotRevision?: number + ): Promise => { + const catalogStartedAt = Date.now() + const catalogOutcome = await refreshCatalog(authority) + const catalogDurationMs = Math.max(0, Date.now() - catalogStartedAt) + if (catalogOutcome === 'stale' || stopped || !deps.isCurrentAuthority(authority)) { + return null + } + const catalogRevision = catalogRevisionByTarget.get(authority.targetId) ?? 0 + const scope = directSshHostHydrationScope(deps.store.getState(), authority, catalogRevision) + return { + ...authority, + catalogRevision, + repoRefs: scope.gitRepos, + authorityRequirement: 'required', + ...(snapshotRevision === undefined ? {} : { snapshotRevision }), + reason, + telemetry: directSshHostHydrationTelemetry(scope, catalogOutcome, catalogDurationMs) + } + } + + const readHostScopedLineage = async ( + input: DirectSshPreparationInput + ): Promise => { + const authority = { + targetId: input.targetId, + providerEpoch: input.providerEpoch, + connectionGeneration: input.connectionGeneration + } + const boundedResult = await bounded(Promise.resolve().then(() => deps.listLineage(authority))) + if ( + stopped || + !deps.isCurrentAuthority(authority) || + (catalogRevisionByTarget.get(authority.targetId) ?? 0) !== input.catalogRevision + ) { + return 'stale' + } + if (boundedResult.status !== 'complete') { + return 'degraded' + } + const snapshot = boundedResult.value + if (!snapshot.authoritative) { + return snapshot.reason === 'stale' || snapshot.reason === 'rejected' ? 'stale' : 'degraded' + } + if (!isAuthoritativeHost(snapshot, authority)) { + return 'stale' + } + let admitted = false + deps.store.setState((state) => { + if ( + stopped || + !deps.isCurrentAuthority(authority) || + (catalogRevisionByTarget.get(authority.targetId) ?? 0) !== input.catalogRevision + ) { + return state + } + admitted = true + return mergeExactHostLineage(state, snapshot, authority, input.catalogRevision) + }) + return admitted ? 'complete' : 'stale' + } + + return { + capturePreparationInput, + readHostScopedLineage, + isPreparationTokenCurrent: (token) => { + if ( + stopped || + !deps.isCurrentAuthority(token.authority) || + (catalogRevisionByTarget.get(token.authority.targetId) ?? 0) !== token.catalogRevision + ) { + return false + } + const scope = directSshHostHydrationScope( + deps.store.getState(), + token.authority, + token.catalogRevision + ) + const sortedRepos = [...scope.gitRepos].sort((left, right) => { + const leftKey = `${left.executionHostId}\0${left.repoId}` + const rightKey = `${right.executionHostId}\0${right.repoId}` + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0 + }) + return ( + JSON.stringify(sortedRepos.map((repo) => [repo.executionHostId, repo.repoId])) === + token.repoFingerprint + ) + }, + stop: () => { + stopped = true + for (const deadline of pendingDeadlines) { + clearTimer(deadline.timer) + deadline.settle() + } + pendingDeadlines.clear() + catalogInFlight.clear() + } + } +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-outcomes.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-outcomes.ts new file mode 100644 index 00000000000..8e5a411eded --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-outcomes.ts @@ -0,0 +1,66 @@ +import type { + DirectSshPreparationMetrics, + DirectSshPreparationOutcome, + DirectSshReconnectOutcome, + DirectSshRepoOutcomeCounts +} from './direct-ssh-reconnect-coordinator-types' + +export function createEmptyDirectSshRepoOutcomeCounts(): DirectSshRepoOutcomeCounts { + return { + complete: 0, + 'non-authoritative': 0, + 'timed-out': 0, + 'cancel-budget-exhausted': 0, + canceled: 0, + stale: 0, + rejected: 0 + } +} + +export function createEmptyDirectSshPreparationMetrics(): DirectSshPreparationMetrics { + return { + queueWaitDurationsMs: [], + providerExecutionDurationsMs: [], + timeoutRetryCount: 0, + locallySettledWaiterCount: 0, + cancelDebtCount: 0, + replacementAdmissionDelayedCount: 0, + schedulerOverlappingJoinCount: 0, + peakLocallyUnsettled: 0, + estimatedLateWorkAllowanceCount: 0, + lineageDurationMs: 0 + } +} + +export function combineDirectSshReconnectOutcome( + prepared: DirectSshPreparationOutcome, + staleBindingsCleared: number, + retriedTerminals: number, + correctedTerminals: number +): DirectSshReconnectOutcome { + return { + ...prepared, + staleBindingsCleared, + retriedTerminals, + correctedTerminals, + stabilizing: false + } +} + +export function createTerminalOnlyDirectSshReconnectOutcome( + status: DirectSshReconnectOutcome['status'], + staleBindingsCleared = 0, + retriedTerminals = 0 +): DirectSshReconnectOutcome { + return { + status, + token: null, + repoOutcomes: createEmptyDirectSshRepoOutcomeCounts(), + lineageOutcome: 'not-started', + metrics: createEmptyDirectSshPreparationMetrics(), + staleBindingsCleared, + retriedTerminals, + correctedTerminals: 0, + stabilizing: status === 'stabilizing' + } +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts new file mode 100644 index 00000000000..9924e55d70b --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts @@ -0,0 +1,25 @@ +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import type { DirectSshReconnectTimer } from './direct-ssh-reconnect-coordinator-types' + +export type DirectSshReconnectTargetState = { + authority: DirectSshAuthority + installedAt: number + dampUntil: number | null + timer: DirectSshReconnectTimer | null +} + +export function createDirectSshReconnectTargetState( + authority: DirectSshAuthority, + previous: DirectSshReconnectTargetState | undefined, + installedAt: number, + stabilizationMs: number +): DirectSshReconnectTargetState { + const rotatedRapidly = + previous !== undefined && installedAt - previous.installedAt < stabilizationMs + return { + authority, + installedAt, + dampUntil: rotatedRapidly ? installedAt + stabilizationMs : null, + timer: null + } +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-telemetry.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-telemetry.ts new file mode 100644 index 00000000000..14bc881ced4 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-telemetry.ts @@ -0,0 +1,118 @@ +import type { + DirectSshCoordinatorTelemetry, + DirectSshPreparationInput, + DirectSshPreparationOutcome, + DirectSshPreparationReason, + DirectSshReconnectOutcome +} from './direct-ssh-reconnect-coordinator-types' +import { createTerminalOnlyDirectSshReconnectOutcome } from './direct-ssh-reconnect-coordinator-outcomes' + +type TelemetryOutcome = DirectSshPreparationOutcome | DirectSshReconnectOutcome + +type TelemetryOptions = { + terminalFinalizationDurationMs?: number + staleBindingsCleared?: number + retriedTerminals?: number + correctedTerminals?: number + damped?: boolean + authorityRotationCount?: number + catalogOutcome?: DirectSshCoordinatorTelemetry['catalogOutcome'] + catalogDurationMs?: number +} + +type DirectSshCoordinatorTelemetryReporter = { + report: ( + mode: DirectSshCoordinatorTelemetry['mode'], + input: DirectSshPreparationInput, + outcome: TelemetryOutcome, + startedAt: number, + options?: TelemetryOptions + ) => void + reportWithoutInput: ( + mode: DirectSshCoordinatorTelemetry['mode'], + reason: DirectSshPreparationReason, + outcome: TelemetryOutcome, + startedAt: number, + options?: TelemetryOptions + ) => void +} + +export function createDirectSshCoordinatorTelemetryReporter(args: { + onTelemetry?: (event: DirectSshCoordinatorTelemetry) => void + now: () => number +}): DirectSshCoordinatorTelemetryReporter { + const emit = (event: DirectSshCoordinatorTelemetry): void => { + try { + args.onTelemetry?.(event) + } catch { + // Recovery cannot depend on diagnostics. + } + } + + const report = ( + mode: DirectSshCoordinatorTelemetry['mode'], + input: DirectSshPreparationInput, + outcome: TelemetryOutcome, + startedAt: number, + options: TelemetryOptions = {} + ): void => { + const inputMetrics = input.telemetry + const outcomeMetrics = + outcome.metrics ?? createTerminalOnlyDirectSshReconnectOutcome('stale').metrics! + emit({ + mode, + reason: input.reason, + outcome: outcome.status, + durationMs: Math.max(0, args.now() - startedAt), + staleBindingsCleared: options.staleBindingsCleared ?? 0, + retriedTerminals: options.retriedTerminals ?? 0, + correctedTerminals: options.correctedTerminals ?? 0, + terminalFinalizationDurationMs: options.terminalFinalizationDurationMs ?? 0, + catalogOutcome: options.catalogOutcome ?? inputMetrics?.catalogOutcome ?? 'complete', + catalogDurationMs: options.catalogDurationMs ?? inputMetrics?.catalogDurationMs ?? 0, + gitWorktreeCount: inputMetrics?.gitWorktreeCount ?? 0, + folderWorkspaceCount: inputMetrics?.folderWorkspaceCount ?? 0, + ambiguousOwnerCount: inputMetrics?.ambiguousOwnerCount ?? 0, + contradictoryOwnerCount: inputMetrics?.contradictoryOwnerCount ?? 0, + repoOutcomes: { ...outcome.repoOutcomes }, + lineageOutcome: outcome.lineageOutcome, + queueWaitDurationsMs: [...outcomeMetrics.queueWaitDurationsMs], + providerExecutionDurationsMs: [...outcomeMetrics.providerExecutionDurationsMs], + timeoutRetryCount: outcomeMetrics.timeoutRetryCount, + locallySettledWaiterCount: outcomeMetrics.locallySettledWaiterCount, + cancelDebtCount: outcomeMetrics.cancelDebtCount, + replacementAdmissionDelayedCount: outcomeMetrics.replacementAdmissionDelayedCount, + overlappingJoinCount: outcomeMetrics.schedulerOverlappingJoinCount, + peakLocallyUnsettled: outcomeMetrics.peakLocallyUnsettled, + estimatedLateWorkAllowanceCount: outcomeMetrics.estimatedLateWorkAllowanceCount, + authorityRotationCount: options.authorityRotationCount ?? 0, + damped: options.damped ?? false + }) + } + + const reportWithoutInput: DirectSshCoordinatorTelemetryReporter['reportWithoutInput'] = ( + mode, + reason, + outcome, + startedAt, + options = {} + ) => { + report( + mode, + { + targetId: '', + providerEpoch: '' as DirectSshPreparationInput['providerEpoch'], + connectionGeneration: 0, + catalogRevision: 0, + repoRefs: [], + authorityRequirement: 'required', + reason + }, + outcome, + startedAt, + options + ) + } + + return { report, reportWithoutInput } +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts new file mode 100644 index 00000000000..6cc76174ed1 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts @@ -0,0 +1,152 @@ +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import type { DirectSshGitRepoRef } from '../lib/direct-ssh-target-scope-types' +import type { + DirectSshWorktreeRefreshScheduler, + DirectSshWorktreeRefreshTerminalStatus +} from './direct-ssh-worktree-refresh-scheduler' + +export type DirectSshPreparationReason = + | 'reconnect' + | 'initial-hydration' + | 'workspace-snapshot' + | 'wake-refresh' + +export type DirectSshAuthorityRequirement = 'required' | 'allow-metadata-fallback' + +export type DirectSshPreparationInput = DirectSshAuthority & { + catalogRevision: number + repoRefs: readonly DirectSshGitRepoRef[] + authorityRequirement: DirectSshAuthorityRequirement + snapshotRevision?: number + reason: DirectSshPreparationReason + telemetry?: DirectSshPreparationInputTelemetry +} + +export type DirectSshPreparationInputTelemetry = { + catalogOutcome: 'complete' | 'degraded' + catalogDurationMs: number + gitWorktreeCount: number + folderWorkspaceCount: number + ambiguousOwnerCount: number + contradictoryOwnerCount: number +} + +export type DirectSshPreparationToken = { + authority: DirectSshAuthority + catalogRevision: number + repoFingerprint: string + authorityRequirement: DirectSshAuthorityRequirement + snapshotRevision: number | null + outcome: 'complete' | 'degraded' +} + +export type DirectSshSnapshotApplyToken = DirectSshPreparationToken & { + snapshotRevision: number +} + +export type DirectSshLineageOutcome = 'complete' | 'degraded' | 'canceled' | 'stale' + +export type DirectSshRepoOutcomeCounts = Record + +export type DirectSshPreparationOutcome = { + status: 'complete' | 'degraded' | 'canceled' | 'stale' | 'stopped' + token: DirectSshPreparationToken | null + repoOutcomes: DirectSshRepoOutcomeCounts + lineageOutcome: DirectSshLineageOutcome | 'not-started' + metrics?: DirectSshPreparationMetrics +} + +export type DirectSshPreparationMetrics = { + queueWaitDurationsMs: readonly number[] + providerExecutionDurationsMs: readonly number[] + timeoutRetryCount: number + locallySettledWaiterCount: number + cancelDebtCount: number + replacementAdmissionDelayedCount: number + schedulerOverlappingJoinCount: number + peakLocallyUnsettled: number + estimatedLateWorkAllowanceCount: number + lineageDurationMs: number +} + +export type DirectSshReconnectOutcome = Omit & { + status: DirectSshPreparationOutcome['status'] | 'stabilizing' + staleBindingsCleared: number + retriedTerminals: number + correctedTerminals: number + stabilizing: boolean +} + +export type DirectSshCorrectionReason = + | 'preparation-complete' + | 'wake-refresh' + | 'workspace-hydrated' + | 'snapshot-applied' + +export type DirectSshCoordinatorTelemetry = { + mode: 'reconnect' | 'prepare-only' + reason: DirectSshPreparationReason + outcome: DirectSshReconnectOutcome['status'] + durationMs: number + staleBindingsCleared: number + retriedTerminals: number + correctedTerminals: number + terminalFinalizationDurationMs: number + catalogOutcome: 'complete' | 'degraded' | 'stale' + catalogDurationMs: number + gitWorktreeCount: number + folderWorkspaceCount: number + ambiguousOwnerCount: number + contradictoryOwnerCount: number + repoOutcomes: DirectSshRepoOutcomeCounts + lineageOutcome: DirectSshPreparationOutcome['lineageOutcome'] + queueWaitDurationsMs: readonly number[] + providerExecutionDurationsMs: readonly number[] + timeoutRetryCount: number + locallySettledWaiterCount: number + cancelDebtCount: number + replacementAdmissionDelayedCount: number + overlappingJoinCount: number + peakLocallyUnsettled: number + estimatedLateWorkAllowanceCount: number + authorityRotationCount: number + damped: boolean +} + +export type DirectSshReconnectTimer = unknown + +export type DirectSshReconnectCoordinatorDeps = { + scheduler: DirectSshWorktreeRefreshScheduler + isCurrentConnectedAuthority: (authority: DirectSshAuthority) => boolean + capturePreparationInput: ( + authority: DirectSshAuthority, + reason: DirectSshPreparationReason + ) => Promise + readHostScopedLineage: (input: DirectSshPreparationInput) => Promise + invalidateStaleTerminalBindings: (authority: DirectSshAuthority) => number + retryTargetPanes: (authority: DirectSshAuthority) => number + finalizeHydratedTerminalPanes: (authority: DirectSshAuthority) => number + correctUnboundTerminalPanes: ( + authority: DirectSshAuthority, + reason: DirectSshCorrectionReason + ) => number + syncRemoteWorkspaceAfterConnect: (token: DirectSshPreparationToken) => void | Promise + onTelemetry?: (event: DirectSshCoordinatorTelemetry) => void + now?: () => number + setTimer?: (callback: () => void, delayMs: number) => DirectSshReconnectTimer + clearTimer?: (timer: DirectSshReconnectTimer) => void + stabilizationMs?: number +} + +export type DirectSshReconnectCoordinator = { + requestReconnect: (authority: DirectSshAuthority) => Promise + prepareOnly: (input: DirectSshPreparationInput) => Promise + finalizeHydratedTerminals: (authority: DirectSshAuthority) => number + correctUnboundTerminals: ( + authority: DirectSshAuthority, + reason: DirectSshCorrectionReason + ) => number + replaceAuthority: (authority: DirectSshAuthority) => void + invalidate: (targetId: string) => void + stop: () => void +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts new file mode 100644 index 00000000000..e2f007e43b7 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts @@ -0,0 +1,566 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + HostQualifiedDetectedWorktreeResult, + ProviderRequestId +} from '../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { + createDirectSshWorktreeRefreshScheduler, + type DirectSshWorktreeRefreshKey, + type DirectSshWorktreeRefreshLease, + type DirectSshWorktreeRefreshOutcome, + type DirectSshWorktreeRefreshScheduler, + type WaiterLeaseId +} from './direct-ssh-worktree-refresh-scheduler' +import { + admitDirectSshSnapshotApplyToken, + buildDirectSshSnapshotApplyToken, + createDirectSshReconnectCoordinator, + type DirectSshLineageOutcome, + type DirectSshPreparationInput, + type DirectSshReconnectCoordinatorDeps +} from './direct-ssh-reconnect-coordinator' + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function epoch(value: string): SshProviderEpoch { + return value as SshProviderEpoch +} + +function authority(targetId: string, epochId = `epoch-${targetId}`): DirectSshAuthority { + return { + targetId, + providerEpoch: epoch(epochId), + connectionGeneration: 1 + } +} + +function preparationInput( + owner: DirectSshAuthority, + repoIds: readonly string[] = ['repo'], + overrides: Partial = {} +): DirectSshPreparationInput { + return { + ...owner, + catalogRevision: 1, + repoRefs: repoIds.map((repoId) => ({ + repoId, + executionHostId: `ssh:${owner.targetId}` + })), + authorityRequirement: 'required', + reason: 'workspace-snapshot', + ...overrides + } +} + +type FakeLease = { + key: DirectSshWorktreeRefreshKey + deferred: Deferred + lease: DirectSshWorktreeRefreshLease +} + +function createFakeScheduler(): { + scheduler: DirectSshWorktreeRefreshScheduler + leases: FakeLease[] + complete: (index: number, status?: DirectSshWorktreeRefreshOutcome['status']) => void +} { + const leases: FakeLease[] = [] + const request = vi.fn((key: DirectSshWorktreeRefreshKey) => { + const leaseDeferred = deferred() + let released = false + const lease: DirectSshWorktreeRefreshLease = { + waiterLeaseId: `lease-${leases.length + 1}` as WaiterLeaseId, + result: leaseDeferred.promise, + release: vi.fn(() => { + if (!released) { + released = true + leaseDeferred.resolve({ status: 'canceled' }) + } + }) + } + leases.push({ key, deferred: leaseDeferred, lease }) + return lease + }) + const scheduler: DirectSshWorktreeRefreshScheduler = { + request, + invalidateAuthority: vi.fn(), + invalidateTarget: vi.fn(), + disposeProvider: vi.fn(), + getSnapshot: vi.fn(() => ({ + locallyUnsettled: 0, + queued: 0, + retrying: 0, + logicalTasks: leases.length, + waiters: leases.length, + cancelDebtByAuthority: new Map() + })), + stop: vi.fn() + } + return { + scheduler, + leases, + complete: (index, status = 'complete') => leases[index].deferred.resolve({ status }) + } +} + +function createCoordinatorHarness(fakeScheduler = createFakeScheduler()) { + const current = new Map() + const events: string[] = [] + const capturePreparationInput = vi.fn( + async (owner: DirectSshAuthority, reason: DirectSshPreparationInput['reason']) => { + events.push(`capture:${owner.targetId}`) + return preparationInput(owner, ['repo'], { reason }) + } + ) + const deps: DirectSshReconnectCoordinatorDeps = { + scheduler: fakeScheduler.scheduler, + isCurrentConnectedAuthority: (owner) => { + const active = current.get(owner.targetId) + return ( + active?.providerEpoch === owner.providerEpoch && + active.connectionGeneration === owner.connectionGeneration + ) + }, + capturePreparationInput, + readHostScopedLineage: vi.fn(async (): Promise => 'complete'), + invalidateStaleTerminalBindings: vi.fn((owner) => { + events.push(`invalidate:${owner.targetId}`) + return 1 + }), + retryTargetPanes: vi.fn((owner) => { + events.push(`retry:${owner.targetId}`) + return 2 + }), + finalizeHydratedTerminalPanes: vi.fn(() => 1), + correctUnboundTerminalPanes: vi.fn(() => 1), + syncRemoteWorkspaceAfterConnect: vi.fn(), + onTelemetry: vi.fn() + } + const coordinator = createDirectSshReconnectCoordinator(deps) + return { coordinator, deps, current, events, ...fakeScheduler } +} + +async function flush(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('createDirectSshReconnectCoordinator', () => { + it('runs terminal invalidation and retry synchronously before preparation awaits', () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + + void harness.coordinator.requestReconnect(owner) + + expect(harness.events).toEqual(['invalidate:target-a', 'retry:target-a', 'capture:target-a']) + expect(harness.leases).toHaveLength(0) + }) + + it('prepares the first observed authority immediately', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + + const pending = harness.coordinator.requestReconnect(owner) + await flush() + + expect(harness.deps.capturePreparationInput).toHaveBeenCalledWith(owner, 'reconnect') + expect(harness.leases).toHaveLength(1) + harness.complete(0) + await expect(pending).resolves.toMatchObject({ status: 'complete', stabilizing: false }) + }) + + it('keeps target B independent while target A preparation is blocked', async () => { + const harness = createCoordinatorHarness() + const targetA = authority('target-a') + const targetB = authority('target-b') + harness.current.set(targetA.targetId, targetA) + harness.current.set(targetB.targetId, targetB) + + const pendingA = harness.coordinator.requestReconnect(targetA) + await flush() + const pendingB = harness.coordinator.requestReconnect(targetB) + await flush() + expect(harness.leases).toHaveLength(2) + + harness.complete(1) + await expect(pendingB).resolves.toMatchObject({ status: 'complete' }) + expect(harness.deps.syncRemoteWorkspaceAfterConnect).toHaveBeenCalledTimes(1) + + let targetASettled = false + void pendingA.then(() => { + targetASettled = true + }) + await flush() + expect(targetASettled).toBe(false) + harness.coordinator.stop() + }) + + it('treats exact-equal authority replacement as a true timing no-op', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const harness = createCoordinatorHarness() + const owner = authority('target-a') + + harness.coordinator.replaceAuthority(owner) + vi.setSystemTime(4_000) + harness.coordinator.replaceAuthority({ ...owner }) + + expect(harness.scheduler.disposeProvider).not.toHaveBeenCalled() + expect(harness.scheduler.invalidateTarget).not.toHaveBeenCalled() + + vi.setSystemTime(5_001) + const next = authority('target-a', 'next') + harness.current.set(next.targetId, next) + const pending = harness.coordinator.requestReconnect(next) + await flush() + + expect(harness.deps.capturePreparationInput).toHaveBeenCalledWith(next, 'reconnect') + harness.complete(0) + await expect(pending).resolves.toMatchObject({ status: 'complete', stabilizing: false }) + }) + + it('cancels obsolete work on changed authority without ordering epochs', async () => { + const harness = createCoordinatorHarness() + const oldOwner = authority('target-a', 'z-old') + const newOwner = authority('target-a', 'a-new') + harness.current.set(oldOwner.targetId, oldOwner) + harness.coordinator.replaceAuthority(oldOwner) + const pending = harness.coordinator.prepareOnly(preparationInput(oldOwner)) + await flush() + + harness.current.set(newOwner.targetId, newOwner) + harness.coordinator.replaceAuthority(newOwner) + + await expect(pending).resolves.toMatchObject({ status: 'stale', token: null }) + expect(harness.leases[0].lease.release).toHaveBeenCalledWith('invalidated') + expect(harness.scheduler.disposeProvider).toHaveBeenCalledWith(oldOwner) + }) + + it('settles invalidation locally while a late lineage read remains pending', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + const lineage = deferred<'complete'>() + vi.mocked(harness.deps.readHostScopedLineage).mockReturnValue(lineage.promise) + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const pending = harness.coordinator.prepareOnly(preparationInput(owner)) + harness.complete(0) + await flush() + expect(harness.deps.readHostScopedLineage).toHaveBeenCalledOnce() + + harness.coordinator.invalidate(owner.targetId) + await expect(pending).resolves.toMatchObject({ status: 'stale', token: null }) + + lineage.resolve('complete') + await flush() + expect(harness.deps.syncRemoteWorkspaceAfterConnect).not.toHaveBeenCalled() + }) + + it('joins exact overlapping preparation and reruns after completion', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const input = preparationInput(owner) + + const first = harness.coordinator.prepareOnly(input) + const joined = harness.coordinator.prepareOnly({ + ...input, + repoRefs: input.repoRefs.toReversed() + }) + expect(joined).toBe(first) + expect(harness.leases).toHaveLength(1) + + harness.complete(0) + await expect(first).resolves.toMatchObject({ status: 'complete' }) + const later = harness.coordinator.prepareOnly(input) + expect(later).not.toBe(first) + expect(harness.leases).toHaveLength(2) + harness.coordinator.stop() + }) + + it('keeps prepare-only isolated from terminal retry and reconnect sync', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const pending = harness.coordinator.prepareOnly(preparationInput(owner)) + harness.complete(0, 'non-authoritative') + + await expect(pending).resolves.toMatchObject({ + status: 'degraded', + token: { outcome: 'degraded' } + }) + expect(harness.deps.invalidateStaleTerminalBindings).not.toHaveBeenCalled() + expect(harness.deps.retryTargetPanes).not.toHaveBeenCalled() + expect(harness.deps.correctUnboundTerminalPanes).not.toHaveBeenCalled() + expect(harness.deps.syncRemoteWorkspaceAfterConnect).not.toHaveBeenCalled() + }) + + it('rejects repo refs for another SSH host before scheduler admission', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + + await expect( + harness.coordinator.prepareOnly( + preparationInput(owner, ['repo'], { + repoRefs: [{ repoId: 'repo', executionHostId: 'ssh:target-b' }] + }) + ) + ).resolves.toMatchObject({ status: 'stale', token: null }) + + expect(harness.leases).toHaveLength(0) + expect(harness.deps.readHostScopedLineage).not.toHaveBeenCalled() + }) + + it('holds lineage and token creation through the scheduler timeout retry barrier', async () => { + const attempts: { + id: ProviderRequestId + deferred: Deferred + key: DirectSshWorktreeRefreshKey + }[] = [] + const scheduler = createDirectSshWorktreeRefreshScheduler({ + startAttempt: (attemptKey) => { + const result = deferred() + const id = `provider-${attempts.length + 1}` as ProviderRequestId + attempts.push({ id, deferred: result, key: attemptKey }) + return { providerRequestId: id, result: result.promise, cancel: vi.fn() } + } + }) + const harness = createCoordinatorHarness({ + scheduler, + leases: [], + complete: () => {} + }) + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const pending = harness.coordinator.prepareOnly(preparationInput(owner)) + + attempts[0].deferred.resolve({ + status: 'timed-out', + providerRequestId: attempts[0].id, + executionHostId: attempts[0].key.executionHostId + }) + await flush() + expect(attempts).toHaveLength(2) + expect(harness.deps.readHostScopedLineage).not.toHaveBeenCalled() + + attempts[1].deferred.resolve({ + status: 'complete', + providerRequestId: attempts[1].id, + repoId: 'repo', + authority: { + kind: 'direct-ssh', + executionHostId: attempts[1].key.executionHostId, + ...owner + }, + result: { repoId: 'repo', authoritative: true, source: 'git', worktrees: [] } + }) + await expect(pending).resolves.toMatchObject({ status: 'complete', token: {} }) + expect(harness.deps.readHostScopedLineage).toHaveBeenCalledOnce() + }) + + it('starts lineage only after every repo is terminal and returns a fenced token', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const pending = harness.coordinator.prepareOnly( + preparationInput(owner, ['repo-b', 'repo-a'], { + catalogRevision: 7, + snapshotRevision: 11 + }) + ) + expect(harness.leases.map((lease) => lease.key.repoId)).toEqual(['repo-a', 'repo-b']) + + harness.complete(0) + await flush() + expect(harness.deps.readHostScopedLineage).not.toHaveBeenCalled() + harness.complete(1) + + await expect(pending).resolves.toMatchObject({ + status: 'complete', + token: { + authority: owner, + catalogRevision: 7, + snapshotRevision: 11, + outcome: 'complete' + } + }) + expect(harness.deps.readHostScopedLineage).toHaveBeenCalledOnce() + }) + + it('requires exact snapshot revision and authority admission', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const pending = harness.coordinator.prepareOnly( + preparationInput(owner, [], { snapshotRevision: 5 }) + ) + const prepared = await pending + const token = prepared.token! + + expect(buildDirectSshSnapshotApplyToken(token, 6)).toBeNull() + const exact = buildDirectSshSnapshotApplyToken(token, 5)! + expect(admitDirectSshSnapshotApplyToken(exact, owner, 5)).toBe(true) + expect(admitDirectSshSnapshotApplyToken(exact, owner, 6)).toBe(false) + expect(admitDirectSshSnapshotApplyToken(exact, authority('target-a', 'other'), 5)).toBe(false) + }) + + it('damps rapid rotations to the latest authority while terminal checks stay immediate', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const harness = createCoordinatorHarness() + const initial = authority('target-a', 'initial') + const middle = authority('target-a', 'middle') + const flapping = authority('target-a', 'flapping') + const latest = authority('target-a', 'latest') + harness.current.set(initial.targetId, initial) + harness.coordinator.replaceAuthority(initial) + + vi.setSystemTime(5_001) + harness.current.set(middle.targetId, middle) + const stableReplacement = harness.coordinator.requestReconnect(middle) + await flush() + harness.complete(0) + await expect(stableReplacement).resolves.toMatchObject({ + status: 'complete', + stabilizing: false + }) + + vi.setSystemTime(6_000) + harness.current.set(flapping.targetId, flapping) + await expect(harness.coordinator.requestReconnect(flapping)).resolves.toMatchObject({ + status: 'stabilizing' + }) + vi.setSystemTime(7_000) + harness.current.set(latest.targetId, latest) + await expect(harness.coordinator.requestReconnect(latest)).resolves.toMatchObject({ + status: 'stabilizing' + }) + + expect(harness.deps.retryTargetPanes).toHaveBeenCalledTimes(3) + expect(harness.deps.capturePreparationInput).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(4_999) + expect(harness.deps.capturePreparationInput).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(harness.deps.capturePreparationInput).toHaveBeenCalledTimes(2) + expect(harness.deps.capturePreparationInput).toHaveBeenCalledWith(latest, 'reconnect') + expect(harness.deps.capturePreparationInput).not.toHaveBeenCalledWith(flapping, 'reconnect') + harness.complete(1) + }) + + it('corrects only current authority and cleans pending work and timers on stop', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const harness = createCoordinatorHarness() + const initial = authority('target-a', 'initial') + const latest = authority('target-a', 'latest') + harness.current.set(initial.targetId, initial) + harness.coordinator.replaceAuthority(initial) + harness.current.set(latest.targetId, latest) + void harness.coordinator.requestReconnect(latest) + + expect(harness.coordinator.correctUnboundTerminals(latest, 'wake-refresh')).toBe(1) + expect(harness.coordinator.finalizeHydratedTerminals(initial)).toBe(0) + harness.coordinator.stop() + await vi.advanceTimersByTimeAsync(5_000) + + expect(harness.deps.capturePreparationInput).not.toHaveBeenCalled() + expect(harness.scheduler.stop).toHaveBeenCalledOnce() + expect(harness.coordinator.correctUnboundTerminals(latest, 'wake-refresh')).toBe(0) + }) + + it('emits one aggregate for joined work with scheduler and scope metrics', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const input = preparationInput(owner, ['repo'], { + telemetry: { + catalogOutcome: 'degraded', + catalogDurationMs: 12, + gitWorktreeCount: 3, + folderWorkspaceCount: 2, + ambiguousOwnerCount: 1, + contradictoryOwnerCount: 1 + } + }) + + const first = harness.coordinator.prepareOnly(input) + const joined = harness.coordinator.prepareOnly(input) + harness.leases[0].deferred.resolve({ + status: 'complete', + metrics: { + queueWaitDurationsMs: [8], + providerExecutionDurationsMs: [13], + timeoutRetryCount: 1, + locallySettledWaiterCount: 1, + cancelDebtCount: 1, + replacementAdmissionDelayedCount: 1, + overlappingJoinCount: 2, + peakLocallyUnsettled: 4, + estimatedLateWorkAllowanceCount: 1 + } + }) + await Promise.all([first, joined]) + + expect(harness.deps.onTelemetry).toHaveBeenCalledOnce() + expect(harness.deps.onTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'prepare-only', + catalogOutcome: 'degraded', + catalogDurationMs: 12, + gitWorktreeCount: 3, + folderWorkspaceCount: 2, + queueWaitDurationsMs: [8], + providerExecutionDurationsMs: [13], + timeoutRetryCount: 1, + cancelDebtCount: 1, + replacementAdmissionDelayedCount: 1, + overlappingJoinCount: 3, + peakLocallyUnsettled: 4 + }) + ) + }) + + it('does not let a telemetry callback failure affect recovery', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + vi.mocked(harness.deps.onTelemetry!).mockImplementation(() => { + throw new Error('telemetry unavailable') + }) + + const pending = harness.coordinator.requestReconnect(owner) + await flush() + harness.complete(0) + + await expect(pending).resolves.toMatchObject({ status: 'complete' }) + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts new file mode 100644 index 00000000000..7ddf3892ff8 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts @@ -0,0 +1,312 @@ +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import { + createDirectSshPreparationCoordinator, + type DirectSshPreparationCoordinator +} from './direct-ssh-reconnect-preparation' +import { + combineDirectSshReconnectOutcome, + createEmptyDirectSshRepoOutcomeCounts, + createTerminalOnlyDirectSshReconnectOutcome +} from './direct-ssh-reconnect-coordinator-outcomes' +import { + createDirectSshReconnectTargetState, + type DirectSshReconnectTargetState +} from './direct-ssh-reconnect-coordinator-stabilization' +import { createDirectSshCoordinatorTelemetryReporter } from './direct-ssh-reconnect-coordinator-telemetry' +import type { + DirectSshCorrectionReason, + DirectSshPreparationInput, + DirectSshPreparationOutcome, + DirectSshPreparationReason, + DirectSshReconnectCoordinator, + DirectSshReconnectCoordinatorDeps, + DirectSshReconnectOutcome, + DirectSshReconnectTimer +} from './direct-ssh-reconnect-coordinator-types' +import { + directSshAuthoritiesEqual, + isDirectSshPreparationInputHostConsistent, + normalizeDirectSshPreparationInput +} from './direct-ssh-reconnect-tokens' +export type * from './direct-ssh-reconnect-coordinator-types' +export { + admitDirectSshSnapshotApplyToken, + buildDirectSshSnapshotApplyToken +} from './direct-ssh-reconnect-tokens' + +export const DIRECT_SSH_RELAY_STABILIZATION_MS = 5_000 + +export function createDirectSshReconnectCoordinator( + deps: DirectSshReconnectCoordinatorDeps +): DirectSshReconnectCoordinator { + const now = deps.now ?? Date.now + const setTimer = + deps.setTimer ?? ((callback: () => void, delayMs: number) => setTimeout(callback, delayMs)) + const clearTimer = + deps.clearTimer ?? + ((timer: DirectSshReconnectTimer) => clearTimeout(timer as ReturnType)) + const stabilizationMs = deps.stabilizationMs ?? DIRECT_SSH_RELAY_STABILIZATION_MS + const targets = new Map() + let stopped = false + + const isCurrent = (authority: DirectSshAuthority): boolean => { + const state = targets.get(authority.targetId) + return ( + !stopped && + directSshAuthoritiesEqual(state?.authority, authority) && + deps.isCurrentConnectedAuthority(authority) + ) + } + + const preparation: DirectSshPreparationCoordinator = createDirectSshPreparationCoordinator({ + scheduler: deps.scheduler, + isCurrentAuthority: isCurrent, + readLineage: deps.readHostScopedLineage, + now + }) + const telemetry = createDirectSshCoordinatorTelemetryReporter({ + onTelemetry: deps.onTelemetry, + now + }) + + const replaceAuthority = (authority: DirectSshAuthority): boolean => { + if (stopped) { + return false + } + const previous = targets.get(authority.targetId) + if (directSshAuthoritiesEqual(previous?.authority, authority)) { + return false + } + if (previous) { + if (previous.timer) { + clearTimer(previous.timer) + } + preparation.invalidateAuthority(previous.authority) + deps.scheduler.disposeProvider(previous.authority) + } + const installedAt = now() + targets.set( + authority.targetId, + createDirectSshReconnectTargetState(authority, previous, installedAt, stabilizationMs) + ) + return previous !== undefined + } + + const captureInput = async ( + authority: DirectSshAuthority, + reason: DirectSshPreparationReason + ): Promise => { + const captured = await deps.capturePreparationInput(authority, reason) + if ( + !captured || + !directSshAuthoritiesEqual(captured, authority) || + !isDirectSshPreparationInputHostConsistent(captured) || + !isCurrent(authority) + ) { + return null + } + return normalizeDirectSshPreparationInput({ ...captured, reason }) + } + + const startSync = (token: NonNullable): void => { + try { + void Promise.resolve(deps.syncRemoteWorkspaceAfterConnect(token)).catch(() => {}) + } catch { + // Sync failure is reported by its owning boundary. + } + } + + const runPreparedReconnect = async ( + authority: DirectSshAuthority, + staleBindingsCleared: number, + retriedTerminals: number, + damped: boolean, + operationStartedAt = now(), + terminalFinalizationDurationMs = 0, + authorityRotationCount = 0 + ): Promise => { + const input = await captureInput(authority, 'reconnect') + if (!input) { + const outcome = createTerminalOnlyDirectSshReconnectOutcome( + 'stale', + staleBindingsCleared, + retriedTerminals + ) + telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, operationStartedAt, { + staleBindingsCleared, + retriedTerminals, + terminalFinalizationDurationMs, + catalogOutcome: 'stale', + catalogDurationMs: Math.max(0, now() - operationStartedAt), + authorityRotationCount, + damped + }) + return outcome + } + const acquired = preparation.acquire(input) + const prepared = await acquired.promise + let correctedTerminals = 0 + if (prepared.token && isCurrent(authority)) { + correctedTerminals = deps.correctUnboundTerminalPanes(authority, 'preparation-complete') + if (isCurrent(authority)) { + startSync(prepared.token) + } + } + const outcome = combineDirectSshReconnectOutcome( + prepared, + staleBindingsCleared, + retriedTerminals, + correctedTerminals + ) + if (!acquired.joined) { + telemetry.report('reconnect', input, prepared, operationStartedAt, { + terminalFinalizationDurationMs, + staleBindingsCleared, + retriedTerminals, + correctedTerminals, + damped, + authorityRotationCount + }) + } + return outcome + } + + const runDelayedPreparation = async (authority: DirectSshAuthority): Promise => { + if (!isCurrent(authority)) { + return + } + await runPreparedReconnect(authority, 0, 0, true, now(), 0, 1) + } + + const scheduleLatestPreparation = (state: DirectSshReconnectTargetState): void => { + if (state.timer || state.dampUntil === null) { + return + } + const delayMs = Math.max(0, state.dampUntil - now()) + state.timer = setTimer(() => { + state.timer = null + state.dampUntil = null + void runDelayedPreparation(state.authority) + }, delayMs) + } + + const requestReconnect = async ( + authority: DirectSshAuthority + ): Promise => { + const startedAt = now() + if (stopped || !deps.isCurrentConnectedAuthority(authority)) { + const outcome = createTerminalOnlyDirectSshReconnectOutcome(stopped ? 'stopped' : 'stale') + telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, startedAt, { + catalogOutcome: 'degraded' + }) + return outcome + } + const rotated = replaceAuthority(authority) + if (!isCurrent(authority)) { + const outcome = createTerminalOnlyDirectSshReconnectOutcome('stale') + telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, startedAt, { + authorityRotationCount: rotated ? 1 : 0 + }) + return outcome + } + const terminalStartedAt = now() + const staleBindingsCleared = deps.invalidateStaleTerminalBindings(authority) + const retriedTerminals = deps.retryTargetPanes(authority) + const terminalFinalizationDurationMs = Math.max(0, now() - terminalStartedAt) + const state = targets.get(authority.targetId)! + if (state.dampUntil !== null && now() < state.dampUntil) { + scheduleLatestPreparation(state) + const outcome = createTerminalOnlyDirectSshReconnectOutcome( + 'stabilizing', + staleBindingsCleared, + retriedTerminals + ) + telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, startedAt, { + staleBindingsCleared, + retriedTerminals, + terminalFinalizationDurationMs, + catalogOutcome: 'degraded', + authorityRotationCount: rotated ? 1 : 0, + damped: true + }) + return outcome + } + return runPreparedReconnect( + authority, + staleBindingsCleared, + retriedTerminals, + false, + startedAt, + terminalFinalizationDurationMs, + rotated ? 1 : 0 + ) + } + + const prepareOnly = ( + rawInput: DirectSshPreparationInput + ): Promise => { + const input = normalizeDirectSshPreparationInput(rawInput) + if (stopped || !isCurrent(input) || !isDirectSshPreparationInputHostConsistent(input)) { + const outcome: DirectSshPreparationOutcome = { + status: stopped ? 'stopped' : 'stale', + token: null, + repoOutcomes: createEmptyDirectSshRepoOutcomeCounts(), + lineageOutcome: 'not-started', + metrics: createTerminalOnlyDirectSshReconnectOutcome('stale').metrics + } + telemetry.report('prepare-only', input, outcome, now()) + return Promise.resolve(outcome) + } + const startedAt = now() + const acquired = preparation.acquire(input) + if (!acquired.joined) { + void acquired.promise.then((outcome) => { + telemetry.report('prepare-only', input, outcome, startedAt) + }) + } + return acquired.promise + } + + const finalizeHydratedTerminals = (authority: DirectSshAuthority): number => + isCurrent(authority) ? deps.finalizeHydratedTerminalPanes(authority) : 0 + + const correctUnboundTerminals = ( + authority: DirectSshAuthority, + reason: DirectSshCorrectionReason + ): number => (isCurrent(authority) ? deps.correctUnboundTerminalPanes(authority, reason) : 0) + + const invalidate = (targetId: string): void => { + const state = targets.get(targetId) + if (state?.timer) { + clearTimer(state.timer) + } + preparation.invalidateTarget(targetId) + deps.scheduler.invalidateTarget(targetId) + targets.delete(targetId) + } + + const stop = (): void => { + if (stopped) { + return + } + stopped = true + for (const state of targets.values()) { + if (state.timer) { + clearTimer(state.timer) + } + } + preparation.stop() + deps.scheduler.stop() + targets.clear() + } + + return { + requestReconnect, + prepareOnly, + finalizeHydratedTerminals, + correctUnboundTerminals, + replaceAuthority, + invalidate, + stop + } +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-preparation-metrics.ts b/src/renderer/src/hooks/direct-ssh-reconnect-preparation-metrics.ts new file mode 100644 index 00000000000..6068319fc32 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-preparation-metrics.ts @@ -0,0 +1,39 @@ +import type { DirectSshWorktreeRefreshOutcome } from './direct-ssh-worktree-refresh-scheduler' +import type { DirectSshPreparationMetrics } from './direct-ssh-reconnect-coordinator-types' +import { createEmptyDirectSshPreparationMetrics } from './direct-ssh-reconnect-coordinator-outcomes' + +export function aggregateDirectSshPreparationMetrics( + outcomes: readonly DirectSshWorktreeRefreshOutcome[], + joinCount: number +): DirectSshPreparationMetrics { + const aggregate = createEmptyDirectSshPreparationMetrics() + for (const outcome of outcomes) { + const metrics = outcome.metrics + if (!metrics) { + continue + } + aggregate.queueWaitDurationsMs = [ + ...aggregate.queueWaitDurationsMs, + ...metrics.queueWaitDurationsMs + ] + aggregate.providerExecutionDurationsMs = [ + ...aggregate.providerExecutionDurationsMs, + ...metrics.providerExecutionDurationsMs + ] + aggregate.timeoutRetryCount += metrics.timeoutRetryCount + aggregate.locallySettledWaiterCount += metrics.locallySettledWaiterCount + aggregate.cancelDebtCount += metrics.cancelDebtCount + aggregate.replacementAdmissionDelayedCount += metrics.replacementAdmissionDelayedCount + aggregate.schedulerOverlappingJoinCount += metrics.overlappingJoinCount + aggregate.peakLocallyUnsettled = Math.max( + aggregate.peakLocallyUnsettled, + metrics.peakLocallyUnsettled + ) + aggregate.estimatedLateWorkAllowanceCount = Math.max( + aggregate.estimatedLateWorkAllowanceCount, + metrics.estimatedLateWorkAllowanceCount + ) + } + aggregate.schedulerOverlappingJoinCount += joinCount + return aggregate +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-preparation.ts b/src/renderer/src/hooks/direct-ssh-reconnect-preparation.ts new file mode 100644 index 00000000000..e18dced3112 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-preparation.ts @@ -0,0 +1,291 @@ +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import type { + DirectSshWorktreeRefreshLease, + DirectSshWorktreeRefreshOutcome, + DirectSshWorktreeRefreshScheduler, + DirectSshWorktreeRefreshTerminalStatus +} from './direct-ssh-worktree-refresh-scheduler' +import type { + DirectSshLineageOutcome, + DirectSshPreparationInput, + DirectSshPreparationOutcome, + DirectSshPreparationToken, + DirectSshRepoOutcomeCounts +} from './direct-ssh-reconnect-coordinator-types' +import { + createEmptyDirectSshPreparationMetrics, + createEmptyDirectSshRepoOutcomeCounts +} from './direct-ssh-reconnect-coordinator-outcomes' +import { aggregateDirectSshPreparationMetrics } from './direct-ssh-reconnect-preparation-metrics' +import { + directSshAuthoritiesEqual, + directSshPreparationOperationKey, + directSshRepoFingerprint, + normalizeDirectSshPreparationInput +} from './direct-ssh-reconnect-tokens' + +type PreparationOperation = { + key: string + input: DirectSshPreparationInput + leases: DirectSshWorktreeRefreshLease[] + joinCount: number + invalidatedAs: 'stale' | 'stopped' | null + settleInvalidation: (reason: 'stale' | 'stopped') => void + invalidation: Promise<'stale' | 'stopped'> + promise: Promise +} + +export type DirectSshPreparationCoordinator = { + acquire: (input: DirectSshPreparationInput) => { + promise: Promise + joined: boolean + } + invalidateAuthority: (authority: DirectSshAuthority) => void + invalidateTarget: (targetId: string) => void + stop: () => void +} + +type PreparationCoordinatorDeps = { + scheduler: DirectSshWorktreeRefreshScheduler + isCurrentAuthority: (authority: DirectSshAuthority) => boolean + readLineage: (input: DirectSshPreparationInput) => Promise + now?: () => number +} + +const TERMINAL_STATUSES: readonly DirectSshWorktreeRefreshTerminalStatus[] = [ + 'complete', + 'non-authoritative', + 'timed-out', + 'cancel-budget-exhausted', + 'canceled', + 'stale', + 'rejected' +] + +function countRepoOutcomes( + outcomes: readonly DirectSshWorktreeRefreshOutcome[] +): DirectSshRepoOutcomeCounts { + const counts = createEmptyDirectSshRepoOutcomeCounts() + for (const outcome of outcomes) { + counts[outcome.status]++ + } + return counts +} + +function awaitRepoLeases( + leases: readonly DirectSshWorktreeRefreshLease[] +): Promise { + if (leases.length === 0) { + return Promise.resolve([]) + } + return new Promise((resolve) => { + const outcomes: DirectSshWorktreeRefreshOutcome[] = [] + let remaining = leases.length + leases.forEach((lease, index) => { + void lease.result.then( + (outcome) => { + outcomes[index] = outcome + remaining-- + if (remaining === 0) { + resolve(outcomes) + } + }, + () => { + outcomes[index] = { status: 'rejected' } + remaining-- + if (remaining === 0) { + resolve(outcomes) + } + } + ) + }) + }) +} + +function terminalPreparationOutcome( + status: 'canceled' | 'stale' | 'stopped', + repoOutcomes: DirectSshRepoOutcomeCounts, + metrics = createEmptyDirectSshPreparationMetrics() +): DirectSshPreparationOutcome { + return { + status, + token: null, + repoOutcomes, + lineageOutcome: 'not-started', + metrics + } +} + +function buildPreparationToken( + input: DirectSshPreparationInput, + outcome: 'complete' | 'degraded' +): DirectSshPreparationToken { + return { + authority: { + targetId: input.targetId, + providerEpoch: input.providerEpoch, + connectionGeneration: input.connectionGeneration + }, + catalogRevision: input.catalogRevision, + repoFingerprint: directSshRepoFingerprint(input), + authorityRequirement: input.authorityRequirement, + snapshotRevision: input.snapshotRevision ?? null, + outcome + } +} + +function hasOutcome( + counts: DirectSshRepoOutcomeCounts, + statuses: readonly DirectSshWorktreeRefreshTerminalStatus[] +): boolean { + return statuses.some((status) => counts[status] > 0) +} + +export function createDirectSshPreparationCoordinator( + deps: PreparationCoordinatorDeps +): DirectSshPreparationCoordinator { + const now = deps.now ?? Date.now + const operations = new Map() + let stopped = false + + const run = async (operation: PreparationOperation): Promise => { + const { input } = operation + operation.leases = input.repoRefs.map((repo) => + deps.scheduler.request({ + targetId: input.targetId, + providerEpoch: input.providerEpoch, + connectionGeneration: input.connectionGeneration, + repoId: repo.repoId, + executionHostId: repo.executionHostId, + catalogRevision: input.catalogRevision, + authorityRequirement: input.authorityRequirement + }) + ) + const outcomes = await awaitRepoLeases(operation.leases) + const repoOutcomes = countRepoOutcomes(outcomes) + const metrics = aggregateDirectSshPreparationMetrics(outcomes, operation.joinCount) + if (operation.invalidatedAs) { + return terminalPreparationOutcome(operation.invalidatedAs, repoOutcomes, metrics) + } + if (!deps.isCurrentAuthority(input)) { + return terminalPreparationOutcome('stale', repoOutcomes, metrics) + } + if (hasOutcome(repoOutcomes, ['canceled', 'stale'])) { + const status = repoOutcomes.stale > 0 ? 'stale' : 'canceled' + return terminalPreparationOutcome(status, repoOutcomes, metrics) + } + + let lineageOutcome: DirectSshLineageOutcome + const lineageStartedAt = now() + try { + lineageOutcome = await deps.readLineage(input) + } catch { + lineageOutcome = 'degraded' + } + metrics.lineageDurationMs = Math.max(0, now() - lineageStartedAt) + if (operation.invalidatedAs) { + return terminalPreparationOutcome(operation.invalidatedAs, repoOutcomes, metrics) + } + if (!deps.isCurrentAuthority(input) || lineageOutcome === 'stale') { + return terminalPreparationOutcome('stale', repoOutcomes, metrics) + } + if (lineageOutcome === 'canceled') { + return terminalPreparationOutcome('canceled', repoOutcomes, metrics) + } + const degraded = + lineageOutcome === 'degraded' || + TERMINAL_STATUSES.some((status) => status !== 'complete' && repoOutcomes[status] > 0) + const status = degraded ? 'degraded' : 'complete' + return { + status, + token: buildPreparationToken(input, status), + repoOutcomes, + lineageOutcome, + metrics + } + } + + const acquire = ( + rawInput: DirectSshPreparationInput + ): { promise: Promise; joined: boolean } => { + const input = normalizeDirectSshPreparationInput(rawInput) + const key = directSshPreparationOperationKey(input) + const current = operations.get(key) + if (current) { + current.joinCount++ + return { promise: current.promise, joined: true } + } + if (stopped) { + return { + promise: Promise.resolve( + terminalPreparationOutcome('stopped', createEmptyDirectSshRepoOutcomeCounts()) + ), + joined: false + } + } + const operation: PreparationOperation = { + key, + input, + leases: [], + joinCount: 0, + invalidatedAs: null, + settleInvalidation: () => {}, + invalidation: Promise.resolve('stopped'), + promise: Promise.resolve( + terminalPreparationOutcome('stopped', createEmptyDirectSshRepoOutcomeCounts()) + ) + } + operation.invalidation = new Promise((resolve) => { + operation.settleInvalidation = resolve + }) + operations.set(key, operation) + operation.promise = Promise.race([ + run(operation), + operation.invalidation.then((reason) => + terminalPreparationOutcome(reason, createEmptyDirectSshRepoOutcomeCounts()) + ) + ]).finally(() => { + if (operations.get(key) === operation) { + operations.delete(key) + } + }) + return { promise: operation.promise, joined: false } + } + + const invalidateMatching = ( + predicate: (operation: PreparationOperation) => boolean, + reason: 'stale' | 'stopped' + ): void => { + for (const operation of operations.values()) { + if (!predicate(operation)) { + continue + } + operation.invalidatedAs = reason + operation.settleInvalidation(reason) + for (const lease of operation.leases) { + lease.release(reason === 'stopped' ? 'stopped' : 'invalidated') + } + } + } + + const invalidateAuthority = (authority: DirectSshAuthority): void => { + invalidateMatching( + (operation) => directSshAuthoritiesEqual(operation.input, authority), + 'stale' + ) + } + + const invalidateTarget = (targetId: string): void => { + invalidateMatching((operation) => operation.input.targetId === targetId, 'stale') + } + + const stop = (): void => { + if (stopped) { + return + } + stopped = true + invalidateMatching(() => true, 'stopped') + } + + return { acquire, invalidateAuthority, invalidateTarget, stop } +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts new file mode 100644 index 00000000000..a835ea81856 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY, + isDirectSshReconnectCoordinatorRoutingEnabled, + resolveDirectSshReconnectCoordinatorRouting +} from './direct-ssh-reconnect-rollout' + +describe('direct SSH reconnect rollout', () => { + it('defaults coordinator routing on', () => { + expect(resolveDirectSshReconnectCoordinatorRouting({})).toBe(true) + expect(resolveDirectSshReconnectCoordinatorRouting({ buildValue: 'true' })).toBe(true) + }) + + it('supports build and session disable routes without session re-enablement', () => { + expect( + resolveDirectSshReconnectCoordinatorRouting({ + buildValue: 'false', + sessionValue: 'true' + }) + ).toBe(false) + expect( + resolveDirectSshReconnectCoordinatorRouting({ + buildValue: 'true', + sessionValue: 'false' + }) + ).toBe(false) + }) + + it('reads the current session route and fails open when storage is unavailable', () => { + const getItem = vi.fn(() => 'false') + vi.stubGlobal('sessionStorage', { getItem }) + + expect(isDirectSshReconnectCoordinatorRoutingEnabled()).toBe(false) + expect(getItem).toHaveBeenCalledWith(DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY) + + getItem.mockImplementation(() => { + throw new Error('blocked') + }) + expect(isDirectSshReconnectCoordinatorRoutingEnabled()).toBe(true) + vi.unstubAllGlobals() + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-rollout.ts b/src/renderer/src/hooks/direct-ssh-reconnect-rollout.ts new file mode 100644 index 00000000000..aa8645cb7ba --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-rollout.ts @@ -0,0 +1,25 @@ +export const DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY = 'orca.directSshReconnectCoordinator.enabled' + +export function resolveDirectSshReconnectCoordinatorRouting(args: { + buildValue?: string + sessionValue?: string | null +}): boolean { + if (args.buildValue?.trim().toLowerCase() === 'false') { + return false + } + return args.sessionValue?.trim().toLowerCase() !== 'false' +} + +export function isDirectSshReconnectCoordinatorRoutingEnabled(): boolean { + let sessionValue: string | null = null + try { + sessionValue = + globalThis.sessionStorage?.getItem(DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY) ?? null + } catch { + // Storage can be unavailable in privacy-restricted renderer sessions. + } + return resolveDirectSshReconnectCoordinatorRouting({ + buildValue: import.meta.env.VITE_DIRECT_SSH_RECONNECT_COORDINATOR, + sessionValue + }) +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts new file mode 100644 index 00000000000..98790b7362e --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' + +const authority: DirectSshAuthority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 +} + +describe('directSshAuthoritiesEqual', () => { + it('fails closed when either authority is missing', () => { + expect(directSshAuthoritiesEqual(null, null)).toBe(false) + expect(directSshAuthoritiesEqual(authority, null)).toBe(false) + expect(directSshAuthoritiesEqual(undefined, authority)).toBe(false) + }) + + it('requires the complete authority tuple', () => { + expect(directSshAuthoritiesEqual(authority, { ...authority })).toBe(true) + expect( + directSshAuthoritiesEqual(authority, { + ...authority, + connectionGeneration: authority.connectionGeneration + 1 + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts new file mode 100644 index 00000000000..f9627268425 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts @@ -0,0 +1,83 @@ +import { toSshExecutionHostId } from '../../../shared/execution-host' +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import type { + DirectSshPreparationInput, + DirectSshPreparationToken, + DirectSshSnapshotApplyToken +} from './direct-ssh-reconnect-coordinator-types' + +export function directSshAuthoritiesEqual( + left: DirectSshAuthority | null | undefined, + right: DirectSshAuthority | null | undefined +): boolean { + if (!left || !right) { + return false + } + return ( + left.targetId === right.targetId && + left.providerEpoch === right.providerEpoch && + left.connectionGeneration === right.connectionGeneration + ) +} + +export function normalizeDirectSshPreparationInput( + input: DirectSshPreparationInput +): DirectSshPreparationInput { + return { + ...input, + repoRefs: [...input.repoRefs].sort( + (left, right) => + compareText(left.executionHostId, right.executionHostId) || + compareText(left.repoId, right.repoId) + ) + } +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +export function isDirectSshPreparationInputHostConsistent( + input: DirectSshPreparationInput +): boolean { + const expectedHost = toSshExecutionHostId(input.targetId) + return input.repoRefs.every((repo) => repo.executionHostId === expectedHost) +} + +export function directSshRepoFingerprint(input: DirectSshPreparationInput): string { + return JSON.stringify(input.repoRefs.map((repo) => [repo.executionHostId, repo.repoId])) +} + +export function directSshPreparationOperationKey(input: DirectSshPreparationInput): string { + return JSON.stringify([ + input.targetId, + input.providerEpoch, + input.connectionGeneration, + input.catalogRevision, + directSshRepoFingerprint(input), + input.authorityRequirement, + input.snapshotRevision ?? null, + input.reason + ]) +} + +export function buildDirectSshSnapshotApplyToken( + token: DirectSshPreparationToken, + snapshotRevision: number +): DirectSshSnapshotApplyToken | null { + if (token.snapshotRevision !== null && token.snapshotRevision !== snapshotRevision) { + return null + } + return { ...token, snapshotRevision } +} + +export function admitDirectSshSnapshotApplyToken( + token: DirectSshSnapshotApplyToken, + authority: DirectSshAuthority, + snapshotRevision: number +): boolean { + return ( + token.snapshotRevision === snapshotRevision && + directSshAuthoritiesEqual(token.authority, authority) + ) +} diff --git a/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx b/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx new file mode 100644 index 00000000000..75c26fe627c --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { createTestStore, makeTab, makeWorktree } from '@/store/slices/store-test-helpers' +import { useRemoteRuntimeRecoveryTriggers } from '@/runtime/use-remote-runtime-recovery-triggers' +import { registerDirectSshWakeRouting } from './direct-ssh-state-routing' + +const { retryAllRemoteRuntimePtyRecoveriesNowMock } = vi.hoisted(() => ({ + retryAllRemoteRuntimePtyRecoveriesNowMock: vi.fn() +})) + +vi.mock('@/components/terminal-pane/remote-runtime-pty-recovery-state', () => ({ + retryAllRemoteRuntimePtyRecoveriesNow: retryAllRemoteRuntimePtyRecoveriesNowMock +})) + +function authority(): DirectSshAuthority { + return { + targetId: 'direct-target', + providerEpoch: 'direct-epoch' as SshProviderEpoch, + connectionGeneration: 7 + } +} + +describe('runtime and direct SSH wake isolation', () => { + const retryConnectionsNow = vi.fn(() => Promise.resolve()) + const resumeCallbacks = new Set<() => void>() + const onSystemResumed = vi.fn((callback: () => void) => { + resumeCallbacks.add(callback) + return () => resumeCallbacks.delete(callback) + }) + + beforeEach(() => { + retryConnectionsNow.mockClear() + retryAllRemoteRuntimePtyRecoveriesNowMock.mockClear() + onSystemResumed.mockClear() + resumeCallbacks.clear() + ;(window as unknown as { api: unknown }).api = { + runtimeEnvironments: { retryConnectionsNow }, + ui: { onSystemResumed } + } + }) + + afterEach(() => { + delete (window as unknown as { api?: unknown }).api + }) + + it('advances both runtime backoffs and one exact direct wake without rebumping a healthy pane', () => { + const currentAuthority = authority() + const directWorktreeId = 'repo-direct::/work/direct' + const runtimeWorktreeId = 'repo-runtime::/work/runtime' + const directPtyId = 'ssh:direct-target@@pty-live' + const store = createTestStore() + store.setState({ + repos: [ + { + id: 'repo-direct', + path: '/work/direct', + displayName: 'direct', + badgeColor: '#000', + addedAt: 1, + connectionId: 'direct-target', + executionHostId: 'ssh:direct-target' + }, + { + id: 'repo-runtime', + path: '/work/runtime', + displayName: 'runtime', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'runtime:env-1' + } + ], + worktreesByRepo: { + 'repo-direct': [ + makeWorktree({ + id: directWorktreeId, + repoId: 'repo-direct', + path: '/work/direct', + hostId: 'ssh:direct-target' + }) + ], + 'repo-runtime': [ + makeWorktree({ + id: runtimeWorktreeId, + repoId: 'repo-runtime', + path: '/work/runtime', + hostId: 'ssh:runtime-owned-target', + runtimeOwnerEnvironmentId: 'env-1' + }) + ] + }, + tabsByWorktree: { + [directWorktreeId]: [ + makeTab({ + id: 'tab-direct', + worktreeId: directWorktreeId, + ptyId: directPtyId, + generation: 0 + }) + ], + [runtimeWorktreeId]: [ + makeTab({ id: 'tab-runtime', worktreeId: runtimeWorktreeId, ptyId: null }) + ] + }, + ptyIdsByTabId: { 'tab-direct': [directPtyId], 'tab-runtime': [] }, + directSshLivePtyBindingByTabId: { + 'tab-direct': { + attemptId: 'direct-attempt' as never, + authority: currentAuthority, + tabGeneration: 0, + ptyId: directPtyId + } + }, + sshConnectionStates: new Map([ + [ + 'direct-target', + { + targetId: 'direct-target', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: currentAuthority.providerEpoch, + connectionGeneration: currentAuthority.connectionGeneration + } + ] + ]), + sshStateByEnvironment: new Map([ + [ + 'env-1', + { + connectionStates: new Map([ + [ + 'runtime-owned-target', + { + targetId: 'runtime-owned-target', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'runtime-epoch' as SshProviderEpoch, + connectionGeneration: 3 + } + ] + ]), + targetLabels: new Map([['runtime-owned-target', 'runtime target']]), + removedTargetLabels: new Map(), + targetsHydrated: true + } + ] + ]) + }) + const wakePreparation = vi.fn() + const correctedCounts: number[] = [] + const unregisterDirectWake = registerDirectSshWakeRouting({ + getConnectionStates: () => store.getState().sshConnectionStates, + wakeAuthority: (nextAuthority) => { + correctedCounts.push(store.getState().retryDirectSshTargetPanes(nextAuthority, 1_000)) + wakePreparation(nextAuthority) + }, + onSystemResumed + }) + const { unmount } = renderHook(() => useRemoteRuntimeRecoveryTriggers()) + + window.dispatchEvent(new Event('online')) + + expect(retryConnectionsNow).toHaveBeenCalledTimes(1) + expect(retryAllRemoteRuntimePtyRecoveriesNowMock).toHaveBeenCalledTimes(1) + expect(wakePreparation).toHaveBeenCalledOnce() + expect(wakePreparation).toHaveBeenCalledWith(currentAuthority) + expect(correctedCounts).toEqual([0]) + expect(store.getState().tabsByWorktree[directWorktreeId][0]).toMatchObject({ + generation: 0, + ptyId: directPtyId + }) + expect(store.getState().tabsByWorktree[runtimeWorktreeId][0].generation ?? 0).toBe(0) + expect(store.getState().directSshPaneRetryByTabId).toEqual({}) + + unregisterDirectWake() + unmount() + window.dispatchEvent(new Event('online')) + expect(retryConnectionsNow).toHaveBeenCalledTimes(1) + expect(wakePreparation).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-state-routing.test.ts b/src/renderer/src/hooks/direct-ssh-state-routing.test.ts new file mode 100644 index 00000000000..dbf7eb951b9 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-state-routing.test.ts @@ -0,0 +1,200 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { + directSshAuthorityFromConnectionState, + registerDirectSshWakeRouting, + routeDirectSshConnectedState +} from './direct-ssh-state-routing' + +function authority(epoch = 'epoch-a'): DirectSshAuthority { + return { + targetId: 'target-a', + providerEpoch: epoch as SshProviderEpoch, + connectionGeneration: 1 + } +} + +function harness() { + return { + coordinatorRoutingEnabled: true, + coordinator: { + requestReconnect: vi.fn(async () => ({ status: 'complete' })), + correctUnboundTerminals: vi.fn(() => 0), + replaceAuthority: vi.fn() + }, + invalidateStaleTerminalBindings: vi.fn(() => 0), + retryTargetPanes: vi.fn(() => 0), + prepareAndSync: vi.fn(), + rememberReconnectAuthority: vi.fn() + } +} + +describe('routeDirectSshConnectedState', () => { + it('keeps initial hydration preparation-only', () => { + const deps = harness() + const current = authority() + + expect( + routeDirectSshConnectedState(deps, { + authority: current, + previousAuthority: null, + origin: 'initial-hydration' + }) + ).toBe('initial-hydration') + expect(deps.prepareAndSync).toHaveBeenCalledWith(current, 'initial-hydration') + expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() + expect(deps.coordinator.correctUnboundTerminals).not.toHaveBeenCalled() + }) + + it('requests reconnect only when the complete pushed authority changes', () => { + const deps = harness() + const previous = authority('epoch-old') + const current = authority('epoch-new') + + expect( + routeDirectSshConnectedState(deps, { + authority: current, + previousAuthority: previous, + origin: 'push' + }) + ).toBe('changed-authority') + expect(deps.rememberReconnectAuthority).toHaveBeenCalledWith(current) + expect(deps.coordinator.requestReconnect).toHaveBeenCalledWith(current) + expect(deps.coordinator.replaceAuthority).not.toHaveBeenCalled() + expect(deps.prepareAndSync).not.toHaveBeenCalled() + }) + + it('runs the disabled fallback terminal actions synchronously before bounded preparation', () => { + const order: string[] = [] + const deps = harness() + deps.coordinatorRoutingEnabled = false + deps.coordinator.replaceAuthority.mockImplementation(() => order.push('replace')) + deps.invalidateStaleTerminalBindings.mockImplementation(() => { + order.push('invalidate') + return 1 + }) + deps.retryTargetPanes.mockImplementation(() => { + order.push('retry') + return 1 + }) + deps.prepareAndSync.mockImplementation(() => { + order.push('prepare') + return new Promise(() => {}) + }) + const previous = authority('epoch-old') + const current = authority('epoch-new') + + expect( + routeDirectSshConnectedState(deps, { + authority: current, + previousAuthority: previous, + origin: 'push' + }) + ).toBe('changed-authority-fallback') + + expect(order).toEqual(['replace', 'invalidate', 'retry', 'prepare']) + expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() + expect(deps.prepareAndSync).toHaveBeenCalledWith(current, 'reconnect', { + authorityAlreadyReplaced: true + }) + expect(deps.rememberReconnectAuthority).toHaveBeenCalledWith(current) + }) + + it('routes an exact-equal push through correction and fresh preparation', () => { + const deps = harness() + const current = authority() + + expect( + routeDirectSshConnectedState(deps, { + authority: current, + previousAuthority: { ...current }, + origin: 'push' + }) + ).toBe('same-authority-wake') + expect(deps.coordinator.correctUnboundTerminals).toHaveBeenCalledWith(current, 'wake-refresh') + expect(deps.prepareAndSync).toHaveBeenCalledWith(current, 'wake-refresh') + expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() + }) +}) + +describe('registerDirectSshWakeRouting', () => { + it('wakes only complete connected direct authorities and removes both listeners', () => { + const wakeAuthority = vi.fn() + let resume: (() => void) | undefined + const unsubscribeSystemResumed = vi.fn() + const stop = registerDirectSshWakeRouting({ + getConnectionStates: () => + new Map([ + [ + 'target-a', + { + ...authority(), + status: 'connected' as const, + error: null, + reconnectAttempt: 0 + } + ], + [ + 'target-partial', + { + targetId: 'target-partial', + status: 'connected' as const, + error: null, + reconnectAttempt: 0, + providerEpoch: 'partial' as SshProviderEpoch + } + ], + [ + 'target-disconnected', + { + ...authority('disconnected'), + targetId: 'target-disconnected', + status: 'disconnected' as const, + error: null, + reconnectAttempt: 0 + } + ] + ]), + wakeAuthority, + onSystemResumed: (callback) => { + resume = callback + return unsubscribeSystemResumed + } + }) + + window.dispatchEvent(new Event('online')) + resume?.() + + expect(wakeAuthority).toHaveBeenCalledTimes(2) + expect(wakeAuthority).toHaveBeenNthCalledWith(1, authority()) + expect(wakeAuthority).toHaveBeenNthCalledWith(2, authority()) + + stop() + stop() + window.dispatchEvent(new Event('online')) + resume?.() + expect(unsubscribeSystemResumed).toHaveBeenCalledOnce() + expect(wakeAuthority).toHaveBeenCalledTimes(2) + }) + + it('rejects mismatched keys and malformed partial authority', () => { + expect( + directSshAuthorityFromConnectionState('other-target', { + ...authority(), + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + ).toBeNull() + expect( + directSshAuthorityFromConnectionState('target-a', { + targetId: 'target-a', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-state-routing.ts b/src/renderer/src/hooks/direct-ssh-state-routing.ts new file mode 100644 index 00000000000..7b3f263a5a7 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-state-routing.ts @@ -0,0 +1,110 @@ +import type { DirectSshAuthority, SshConnectionState } from '../../../shared/ssh-types' +import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' + +export type DirectSshConnectedStateOrigin = 'push' | 'initial-hydration' + +export type DirectSshConnectedStateRoutingDeps = { + coordinator: { + requestReconnect: (authority: DirectSshAuthority) => Promise + correctUnboundTerminals: (authority: DirectSshAuthority, reason: 'wake-refresh') => number + replaceAuthority: (authority: DirectSshAuthority) => void + } + coordinatorRoutingEnabled: boolean + invalidateStaleTerminalBindings: (authority: DirectSshAuthority) => number + retryTargetPanes: (authority: DirectSshAuthority) => number + prepareAndSync: ( + authority: DirectSshAuthority, + reason: 'reconnect' | 'initial-hydration' | 'wake-refresh', + options?: { authorityAlreadyReplaced?: boolean } + ) => void | Promise + rememberReconnectAuthority: (authority: DirectSshAuthority | null) => void +} + +export type DirectSshConnectedStateRoute = + | 'initial-hydration' + | 'changed-authority' + | 'changed-authority-fallback' + | 'same-authority-wake' + +export function routeDirectSshConnectedState( + deps: DirectSshConnectedStateRoutingDeps, + input: { + authority: DirectSshAuthority + previousAuthority: DirectSshAuthority | null + origin: DirectSshConnectedStateOrigin + } +): DirectSshConnectedStateRoute { + const { authority } = input + if (input.origin === 'initial-hydration') { + deps.rememberReconnectAuthority(null) + void deps.prepareAndSync(authority, 'initial-hydration') + return 'initial-hydration' + } + if (!directSshAuthoritiesEqual(input.previousAuthority, authority)) { + deps.rememberReconnectAuthority(authority) + if (deps.coordinatorRoutingEnabled) { + void deps.coordinator.requestReconnect(authority) + return 'changed-authority' + } + deps.coordinator.replaceAuthority(authority) + deps.invalidateStaleTerminalBindings(authority) + deps.retryTargetPanes(authority) + void deps.prepareAndSync(authority, 'reconnect', { authorityAlreadyReplaced: true }) + return 'changed-authority-fallback' + } + deps.coordinator.correctUnboundTerminals(authority, 'wake-refresh') + void deps.prepareAndSync(authority, 'wake-refresh') + return 'same-authority-wake' +} + +export function directSshAuthorityFromConnectionState( + targetId: string, + state: SshConnectionState +): DirectSshAuthority | null { + if ( + state.targetId !== targetId || + state.status !== 'connected' || + !state.providerEpoch || + state.connectionGeneration === undefined + ) { + return null + } + return { + targetId, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } +} + +export function registerDirectSshWakeRouting(deps: { + getConnectionStates: () => Iterable + wakeAuthority: (authority: DirectSshAuthority) => void + onSystemResumed?: (callback: () => void) => () => void +}): () => void { + let stopped = false + const wake = (): void => { + if (stopped) { + return + } + for (const [targetId, state] of deps.getConnectionStates()) { + const authority = directSshAuthorityFromConnectionState(targetId, state) + if (authority) { + deps.wakeAuthority(authority) + } + } + } + if (typeof window.addEventListener === 'function') { + window.addEventListener('online', wake) + } + const unsubscribeSystemResumed = deps.onSystemResumed?.(wake) + return () => { + if (stopped) { + return + } + stopped = true + if (typeof window.removeEventListener === 'function') { + window.removeEventListener('online', wake) + } + unsubscribeSystemResumed?.() + } +} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-cancellation.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-cancellation.ts new file mode 100644 index 00000000000..b7c75454220 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-cancellation.ts @@ -0,0 +1,16 @@ +import type { + DirectSshWorktreeRefreshAttempt, + DirectSshWorktreeRefreshReleaseReason +} from './direct-ssh-worktree-refresh-scheduler-types' + +export function cancelDirectSshWorktreeRefreshAttempt( + attempt: DirectSshWorktreeRefreshAttempt, + reason: DirectSshWorktreeRefreshReleaseReason +): boolean { + try { + const outcome = attempt.cancel(reason) + return outcome !== false && outcome !== 'retained' && outcome !== 'already-settled' + } catch { + return true + } +} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-metrics.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-metrics.ts new file mode 100644 index 00000000000..52064521dd7 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-metrics.ts @@ -0,0 +1,95 @@ +import type { + DirectSshWorktreeRefreshLogicalTask, + DirectSshWorktreeRefreshMetrics, + DirectSshWorktreeRefreshSchedulerSnapshot +} from './direct-ssh-worktree-refresh-scheduler-types' + +export function createDirectSshWorktreeRefreshMetrics(): DirectSshWorktreeRefreshMetrics { + return { + queueWaitDurationsMs: [], + providerExecutionDurationsMs: [], + timeoutRetryCount: 0, + locallySettledWaiterCount: 0, + cancelDebtCount: 0, + replacementAdmissionDelayedCount: 0, + overlappingJoinCount: 0, + peakLocallyUnsettled: 0, + estimatedLateWorkAllowanceCount: 0 + } +} + +export function adjustDirectSshAuthorityUnsettled( + unsettledByAuthority: Map, + authorityId: string, + delta: number +): void { + const next = (unsettledByAuthority.get(authorityId) ?? 0) + delta + if (next > 0) { + unsettledByAuthority.set(authorityId, next) + } else { + unsettledByAuthority.delete(authorityId) + } +} + +export function copyDirectSshWorktreeRefreshMetrics( + metrics: DirectSshWorktreeRefreshMetrics +): DirectSshWorktreeRefreshMetrics { + return { + ...metrics, + queueWaitDurationsMs: [...metrics.queueWaitDurationsMs], + providerExecutionDurationsMs: [...metrics.providerExecutionDurationsMs] + } +} + +export function recordDirectSshCancelDebt( + metrics: DirectSshWorktreeRefreshMetrics, + lateWorkLimit: number +): void { + metrics.cancelDebtCount++ + metrics.estimatedLateWorkAllowanceCount = Math.min( + lateWorkLimit, + metrics.estimatedLateWorkAllowanceCount + 1 + ) +} + +export function recordDirectSshQueueAdmission( + metrics: DirectSshWorktreeRefreshMetrics, + queueWaitDurationMs: number, + locallyUnsettled: number +): void { + metrics.queueWaitDurationsMs = [...metrics.queueWaitDurationsMs, queueWaitDurationMs] + metrics.peakLocallyUnsettled = Math.max(metrics.peakLocallyUnsettled, locallyUnsettled) +} + +export function recordDirectSshProviderExecution( + metrics: DirectSshWorktreeRefreshMetrics, + providerExecutionDurationMs: number +): void { + metrics.providerExecutionDurationsMs = [ + ...metrics.providerExecutionDurationsMs, + providerExecutionDurationMs + ] +} + +export function directSshWorktreeRefreshSchedulerSnapshot( + tasks: ReadonlyMap, + locallyUnsettled: number, + cancelDebtByAuthority: ReadonlyMap +): DirectSshWorktreeRefreshSchedulerSnapshot { + let queued = 0 + let retrying = 0 + let waiters = 0 + for (const task of tasks.values()) { + queued += task.state === 'queued' ? 1 : 0 + retrying += task.state === 'retrying' ? 1 : 0 + waiters += task.waiters.size + } + return { + locallyUnsettled, + queued, + retrying, + logicalTasks: tasks.size, + waiters, + cancelDebtByAuthority: new Map(cancelDebtByAuthority) + } +} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-types.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-types.ts new file mode 100644 index 00000000000..1240304f8cd --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-types.ts @@ -0,0 +1,128 @@ +import type { + HostQualifiedDetectedWorktreeResult, + ProviderRequestId, + SshExecutionHostId +} from '../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import type { + DetectedWorktreeRefreshReleaseOutcome, + WaiterLeaseId +} from '../store/slices/detected-worktree-refresh-leases' + +export type { WaiterLeaseId } from '../store/slices/detected-worktree-refresh-leases' + +export type DirectSshWorktreeRefreshAuthorityRequirement = 'required' | 'allow-metadata-fallback' + +export type DirectSshWorktreeRefreshKey = DirectSshAuthority & { + repoId: string + executionHostId: SshExecutionHostId + catalogRevision: number + authorityRequirement: DirectSshWorktreeRefreshAuthorityRequirement +} + +export type DirectSshWorktreeRefreshTerminalStatus = + | 'complete' + | 'non-authoritative' + | 'timed-out' + | 'cancel-budget-exhausted' + | 'canceled' + | 'stale' + | 'rejected' + +export type DirectSshWorktreeRefreshOutcome = { + status: DirectSshWorktreeRefreshTerminalStatus + providerRequestId?: ProviderRequestId + providerResult?: HostQualifiedDetectedWorktreeResult + metrics?: DirectSshWorktreeRefreshMetrics +} + +export type DirectSshWorktreeRefreshMetrics = { + queueWaitDurationsMs: readonly number[] + providerExecutionDurationsMs: readonly number[] + timeoutRetryCount: number + locallySettledWaiterCount: number + cancelDebtCount: number + replacementAdmissionDelayedCount: number + overlappingJoinCount: number + peakLocallyUnsettled: number + estimatedLateWorkAllowanceCount: number +} + +export type DirectSshWorktreeRefreshReleaseReason = 'superseded' | 'invalidated' | 'stopped' + +export type DirectSshWorktreeRefreshLease = { + waiterLeaseId: WaiterLeaseId + result: Promise + release: (reason: DirectSshWorktreeRefreshReleaseReason) => void +} + +export type DirectSshWorktreeRefreshAttempt = { + providerRequestId: ProviderRequestId + result: Promise + cancel: ( + reason: DirectSshWorktreeRefreshReleaseReason + ) => boolean | void | DetectedWorktreeRefreshReleaseOutcome +} + +export type DirectSshWorktreeRefreshSchedulerDeps = { + startAttempt: (key: DirectSshWorktreeRefreshKey) => DirectSshWorktreeRefreshAttempt + createWaiterLeaseId?: () => WaiterLeaseId + onUnexpectedError?: (error: unknown) => void + now?: () => number +} + +export type DirectSshWorktreeRefreshSchedulerSnapshot = { + locallyUnsettled: number + queued: number + retrying: number + logicalTasks: number + waiters: number + cancelDebtByAuthority: ReadonlyMap +} + +export type DirectSshWorktreeRefreshScheduler = { + request: (key: DirectSshWorktreeRefreshKey) => DirectSshWorktreeRefreshLease + invalidateAuthority: (authority: DirectSshAuthority) => void + invalidateTarget: (targetId: string) => void + disposeProvider: (authority: DirectSshAuthority) => void + getSnapshot: () => DirectSshWorktreeRefreshSchedulerSnapshot + stop: () => void +} + +export type DirectSshWorktreeRefreshWaiter = { + resolve: (outcome: DirectSshWorktreeRefreshOutcome) => void +} + +export type DirectSshWorktreeRefreshLogicalTask = { + key: DirectSshWorktreeRefreshKey + keyId: string + authorityId: string + state: 'queued' | 'running' | 'retrying' | 'terminal' + attemptCount: number + attempt: DirectSshWorktreeRefreshAttempt | null + attemptCanceled: boolean + attemptStartedAt: number | null + queuedAt: number + metrics: DirectSshWorktreeRefreshMetrics + waiters: Map +} + +export function directSshProviderAuthorityKey(authority: DirectSshAuthority): string { + return JSON.stringify([ + authority.targetId, + authority.providerEpoch, + authority.connectionGeneration + ]) +} + +export function directSshWorktreeRefreshKey(key: DirectSshWorktreeRefreshKey): string { + return JSON.stringify([ + key.targetId, + key.repoId, + key.executionHostId, + key.providerEpoch, + key.connectionGeneration, + key.catalogRevision, + key.authorityRequirement + ]) +} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts new file mode 100644 index 00000000000..800938a1a67 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts @@ -0,0 +1,435 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + HostQualifiedDetectedWorktreeResult, + ProviderRequestId +} from '../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { + createDirectSshWorktreeRefreshScheduler, + DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY, + type DirectSshWorktreeRefreshAttempt, + type DirectSshWorktreeRefreshKey, + type DirectSshWorktreeRefreshReleaseReason +} from './direct-ssh-worktree-refresh-scheduler' + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} + +type ControlledAttempt = DirectSshWorktreeRefreshAttempt & { + key: DirectSshWorktreeRefreshKey + deferred: Deferred + cancel: ReturnType> +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function epoch(value: string): SshProviderEpoch { + return value as SshProviderEpoch +} + +function requestId(value: string): ProviderRequestId { + return value as ProviderRequestId +} + +function key( + targetId: string, + repoId: string, + overrides: Partial = {} +): DirectSshWorktreeRefreshKey { + return { + targetId, + repoId, + executionHostId: `ssh:${targetId}`, + providerEpoch: epoch(`epoch-${targetId}`), + connectionGeneration: 1, + catalogRevision: 1, + authorityRequirement: 'required', + ...overrides + } +} + +function terminalResult( + attempt: ControlledAttempt, + status: 'timed-out' | 'canceled' | 'stale' | 'rejected' +): HostQualifiedDetectedWorktreeResult { + return { + status, + providerRequestId: attempt.providerRequestId, + executionHostId: attempt.key.executionHostId + } +} + +function completeResult( + attempt: ControlledAttempt, + status: 'complete' | 'non-authoritative' = 'complete' +): HostQualifiedDetectedWorktreeResult { + return { + status, + providerRequestId: attempt.providerRequestId, + repoId: attempt.key.repoId, + authority: { + kind: 'direct-ssh', + executionHostId: attempt.key.executionHostId, + targetId: attempt.key.targetId, + providerEpoch: attempt.key.providerEpoch, + connectionGeneration: attempt.key.connectionGeneration + }, + result: { + repoId: attempt.key.repoId, + authoritative: status === 'complete', + source: status === 'complete' ? 'git' : 'metadata-fallback', + worktrees: [] + } + } +} + +function createHarness(cancelOutcome?: 'cancel-failed', now?: () => number) { + const attempts: ControlledAttempt[] = [] + const onUnexpectedError = vi.fn() + const startAttempt = vi.fn((attemptKey: DirectSshWorktreeRefreshKey) => { + const providerDeferred = deferred() + const waiterDeferred = deferred() + const providerRequestId = requestId(`provider-${attempts.length + 1}`) + const attempt: ControlledAttempt = { + key: attemptKey, + providerRequestId, + result: waiterDeferred.promise, + deferred: providerDeferred, + cancel: vi.fn((_reason: DirectSshWorktreeRefreshReleaseReason) => { + waiterDeferred.resolve({ + providerRequestId, + executionHostId: attemptKey.executionHostId, + status: 'canceled' + }) + return cancelOutcome + }) + } + void providerDeferred.promise.then(waiterDeferred.resolve, waiterDeferred.reject) + attempts.push(attempt) + return attempt + }) + const scheduler = createDirectSshWorktreeRefreshScheduler({ + startAttempt, + onUnexpectedError, + now + }) + return { scheduler, attempts, startAttempt, onUnexpectedError } +} + +async function flushAttempt(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('createDirectSshWorktreeRefreshScheduler', () => { + it('admits an idle singleton immediately and cleans it up on completion', async () => { + const { scheduler, attempts, startAttempt } = createHarness() + const lease = scheduler.request(key('target-a', 'repo-a')) + + expect(startAttempt).toHaveBeenCalledTimes(1) + expect(scheduler.getSnapshot().locallyUnsettled).toBe(1) + + attempts[0].deferred.resolve(completeResult(attempts[0])) + await expect(lease.result).resolves.toMatchObject({ status: 'complete' }) + await flushAttempt() + expect(scheduler.getSnapshot()).toMatchObject({ + locallyUnsettled: 0, + logicalTasks: 0, + waiters: 0 + }) + }) + + it('never admits more than five locally unsettled attempts', async () => { + const { scheduler, attempts } = createHarness() + const leases = Array.from({ length: 6 }, (_, index) => + scheduler.request(key('target-a', `repo-${index}`)) + ) + + expect(attempts).toHaveLength(DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY) + expect(scheduler.getSnapshot().queued).toBe(1) + + attempts[0].deferred.resolve(completeResult(attempts[0])) + await leases[0].result + await flushAttempt() + expect(attempts).toHaveLength(6) + expect(scheduler.getSnapshot().locallyUnsettled).toBe(5) + + scheduler.stop() + }) + + it('round-robins target lanes after at most one earlier lane admission', async () => { + const { scheduler, attempts } = createHarness() + const leases = Array.from({ length: 7 }, (_, index) => + scheduler.request(key('target-a', `repo-a-${index}`)) + ) + const targetB = scheduler.request(key('target-b', 'repo-b')) + + attempts[0].deferred.resolve(completeResult(attempts[0])) + await leases[0].result + await flushAttempt() + expect(attempts[5].key.targetId).toBe('target-a') + + attempts[1].deferred.resolve(completeResult(attempts[1])) + await leases[1].result + await flushAttempt() + expect(attempts[6].key.targetId).toBe('target-b') + + scheduler.stop() + await expect(targetB.result).resolves.toMatchObject({ status: 'canceled' }) + }) + + it('joins only exact keys and separates authority, revision, and requirement', () => { + const { scheduler, attempts } = createHarness() + const base = key('target-a', 'repo') + const first = scheduler.request(base) + const joined = scheduler.request({ ...base }) + scheduler.request({ ...base, providerEpoch: epoch('epoch-next') }) + scheduler.request({ ...base, connectionGeneration: 2 }) + scheduler.request({ ...base, catalogRevision: 2 }) + scheduler.request({ ...base, authorityRequirement: 'allow-metadata-fallback' }) + + expect(first.waiterLeaseId).not.toBe(joined.waiterLeaseId) + expect(attempts).toHaveLength(5) + expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 5, waiters: 6 }) + scheduler.stop() + }) + + it('keeps the logical barrier pending through one timeout and uses a fresh request ID', async () => { + const { scheduler, attempts } = createHarness() + const lease = scheduler.request(key('target-a', 'repo')) + let settled = false + void lease.result.then(() => { + settled = true + }) + + attempts[0].deferred.resolve(terminalResult(attempts[0], 'timed-out')) + await flushAttempt() + + expect(settled).toBe(false) + expect(attempts).toHaveLength(2) + expect(attempts[1].providerRequestId).not.toBe(attempts[0].providerRequestId) + + attempts[1].deferred.resolve(completeResult(attempts[1])) + await expect(lease.result).resolves.toMatchObject({ + status: 'complete', + providerRequestId: attempts[1].providerRequestId + }) + }) + + it('requeues a timed-out retry at its target lane tail', async () => { + const { scheduler, attempts } = createHarness() + const retrying = scheduler.request(key('target-a', 'retrying')) + const blockers = Array.from({ length: 4 }, (_, index) => + scheduler.request(key('target-a', `blocker-${index}`)) + ) + const targetB = scheduler.request(key('target-b', 'target-b')) + + attempts[0].deferred.resolve(terminalResult(attempts[0], 'timed-out')) + await flushAttempt() + expect(attempts[5].key.targetId).toBe('target-b') + + attempts[1].deferred.resolve(completeResult(attempts[1])) + await blockers[0].result + await flushAttempt() + expect(attempts[6].key.repoId).toBe('retrying') + + attempts[6].deferred.resolve(completeResult(attempts[6])) + await expect(retrying.result).resolves.toMatchObject({ status: 'complete' }) + scheduler.stop() + await expect(targetB.result).resolves.toMatchObject({ status: 'canceled' }) + }) + + it('makes a second timeout terminal', async () => { + const { scheduler, attempts } = createHarness() + const lease = scheduler.request(key('target-a', 'repo')) + + attempts[0].deferred.resolve(terminalResult(attempts[0], 'timed-out')) + await flushAttempt() + attempts[1].deferred.resolve(terminalResult(attempts[1], 'timed-out')) + + await expect(lease.result).resolves.toMatchObject({ + status: 'timed-out', + providerRequestId: attempts[1].providerRequestId + }) + }) + + it('invalidates queued and current work without reporting cancellation errors', async () => { + const { scheduler, attempts, onUnexpectedError } = createHarness() + const leases = Array.from({ length: 6 }, (_, index) => + scheduler.request(key('target-a', `repo-${index}`)) + ) + + scheduler.invalidateTarget('target-a') + await expect(Promise.all(leases.map((lease) => lease.result))).resolves.toEqual( + Array.from({ length: 6 }, () => expect.objectContaining({ status: 'stale' })) + ) + expect(attempts.map((attempt) => attempt.cancel.mock.calls.length)).toEqual([1, 1, 1, 1, 1]) + expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 0, waiters: 0 }) + + for (const attempt of attempts) { + attempt.deferred.reject(new Error('expected provider cancellation')) + } + await flushAttempt() + expect(onUnexpectedError).not.toHaveBeenCalled() + expect(scheduler.getSnapshot().locallyUnsettled).toBe(0) + }) + + it('bounds five failed cancellations to two replacements while old calls remain pending', async () => { + const { scheduler, attempts } = createHarness('cancel-failed') + const authority: DirectSshAuthority = { + targetId: 'target-a', + providerEpoch: epoch('epoch-target-a'), + connectionGeneration: 1 + } + const initial = Array.from({ length: 5 }, (_, index) => + scheduler.request(key('target-a', `old-${index}`)) + ) + scheduler.invalidateAuthority(authority) + await Promise.all(initial.map((lease) => lease.result)) + await flushAttempt() + expect(scheduler.getSnapshot()).toMatchObject({ + locallyUnsettled: 0, + queued: 0, + logicalTasks: 0 + }) + + const replacements = Array.from({ length: 3 }, (_, index) => + scheduler.request(key('target-a', `replacement-${index}`)) + ) + expect(attempts).toHaveLength(7) + expect(scheduler.getSnapshot().locallyUnsettled).toBe(2) + await expect(replacements[2].result).resolves.toMatchObject({ + status: 'cancel-budget-exhausted' + }) + + attempts[5].deferred.resolve(completeResult(attempts[5])) + attempts[6].deferred.resolve(completeResult(attempts[6])) + await expect( + Promise.all(replacements.slice(0, 2).map((lease) => lease.result)) + ).resolves.toEqual( + Array.from({ length: 2 }, () => expect.objectContaining({ status: 'complete' })) + ) + await flushAttempt() + expect(scheduler.getSnapshot().locallyUnsettled).toBe(0) + + scheduler.disposeProvider(authority) + for (const attempt of attempts.slice(0, 5)) { + attempt.deferred.reject(new Error('late canceled provider')) + } + await flushAttempt() + expect([...scheduler.getSnapshot().cancelDebtByAuthority.values()]).toEqual([]) + + const afterDisposal = scheduler.request(key('target-a', 'after-disposal')) + expect(attempts).toHaveLength(8) + attempts[7].deferred.resolve(completeResult(attempts[7])) + await expect(afterDisposal.result).resolves.toMatchObject({ status: 'complete' }) + }) + + it('settles one released waiter without canceling a joined provider', async () => { + const { scheduler, attempts } = createHarness() + const refreshKey = key('target-a', 'repo') + const released = scheduler.request(refreshKey) + const retained = scheduler.request(refreshKey) + + released.release('superseded') + await expect(released.result).resolves.toMatchObject({ status: 'canceled' }) + expect(attempts[0].cancel).not.toHaveBeenCalled() + expect(scheduler.getSnapshot().waiters).toBe(1) + + attempts[0].deferred.resolve(completeResult(attempts[0])) + await expect(retained.result).resolves.toMatchObject({ status: 'complete' }) + released.release('stopped') + await flushAttempt() + expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 0, waiters: 0 }) + }) + + it('does not charge cancel debt when a shared provider invocation stays active', async () => { + const { scheduler, attempts } = createHarness() + const lease = scheduler.request(key('target-a', 'repo')) + attempts[0].cancel.mockReturnValue(false) + + lease.release('superseded') + + await expect(lease.result).resolves.toMatchObject({ status: 'canceled' }) + expect(attempts[0].cancel).toHaveBeenCalledOnce() + expect([...scheduler.getSnapshot().cancelDebtByAuthority.values()]).toEqual([]) + + attempts[0].deferred.resolve(completeResult(attempts[0])) + await flushAttempt() + }) + + it('stops current and queued waiters and rejects later requests without starting', async () => { + const { scheduler, attempts, startAttempt } = createHarness() + const current = scheduler.request(key('target-a', 'current')) + scheduler.stop() + + await expect(current.result).resolves.toMatchObject({ status: 'canceled' }) + expect(attempts[0].cancel).toHaveBeenCalledOnce() + + const afterStop = scheduler.request(key('target-a', 'after-stop')) + await expect(afterStop.result).resolves.toMatchObject({ status: 'canceled' }) + expect(startAttempt).toHaveBeenCalledOnce() + expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 0, waiters: 0 }) + + attempts[0].deferred.reject(new Error('stopped')) + await flushAttempt() + }) + + it('surfaces non-authoritative and rejected terminal outcomes', async () => { + const { scheduler, attempts } = createHarness() + const metadata = scheduler.request(key('target-a', 'metadata')) + const rejected = scheduler.request(key('target-a', 'rejected')) + + attempts[0].deferred.resolve(completeResult(attempts[0], 'non-authoritative')) + attempts[1].deferred.resolve(terminalResult(attempts[1], 'rejected')) + + await expect(metadata.result).resolves.toMatchObject({ status: 'non-authoritative' }) + await expect(rejected.result).resolves.toMatchObject({ status: 'rejected' }) + }) + + it('reports queue wait, provider execution, retry, joins, and scoped peak metrics', async () => { + let now = 0 + const { scheduler, attempts } = createHarness(undefined, () => now) + const leases = Array.from({ length: 6 }, (_, index) => + scheduler.request(key('target-a', `repo-${index}`)) + ) + scheduler.request(key('target-a', 'repo-5')) + + now = 25 + attempts[0].deferred.resolve(completeResult(attempts[0])) + await leases[0].result + await flushAttempt() + expect(attempts).toHaveLength(6) + + now = 100 + attempts[5].deferred.resolve(terminalResult(attempts[5], 'timed-out')) + await flushAttempt() + now = 140 + attempts[6].deferred.resolve(completeResult(attempts[6])) + + await expect(leases[5].result).resolves.toMatchObject({ + status: 'complete', + metrics: { + queueWaitDurationsMs: [25, 0], + providerExecutionDurationsMs: [75, 40], + timeoutRetryCount: 1, + cancelDebtCount: 1, + overlappingJoinCount: 1, + peakLocallyUnsettled: 5 + } + }) + scheduler.stop() + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts new file mode 100644 index 00000000000..2e95404f1b2 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts @@ -0,0 +1,316 @@ +import type { + HostQualifiedDetectedWorktreeResult, + ProviderRequestId +} from '../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import type { + DirectSshWorktreeRefreshAttempt, + DirectSshWorktreeRefreshKey, + DirectSshWorktreeRefreshLease, + DirectSshWorktreeRefreshLogicalTask as LogicalTask, + DirectSshWorktreeRefreshOutcome, + DirectSshWorktreeRefreshReleaseReason, + DirectSshWorktreeRefreshScheduler, + DirectSshWorktreeRefreshSchedulerDeps, + WaiterLeaseId +} from './direct-ssh-worktree-refresh-scheduler-types' +import { cancelDirectSshWorktreeRefreshAttempt } from './direct-ssh-worktree-refresh-scheduler-cancellation' +import { + adjustDirectSshAuthorityUnsettled, + copyDirectSshWorktreeRefreshMetrics, + createDirectSshWorktreeRefreshMetrics, + directSshWorktreeRefreshSchedulerSnapshot, + recordDirectSshCancelDebt, + recordDirectSshProviderExecution, + recordDirectSshQueueAdmission +} from './direct-ssh-worktree-refresh-scheduler-metrics' +import { DirectSshWorktreeRefreshTargetQueue } from './direct-ssh-worktree-refresh-target-queue' +import { + directSshProviderAuthorityKey, + directSshWorktreeRefreshKey +} from './direct-ssh-worktree-refresh-scheduler-types' +export type * from './direct-ssh-worktree-refresh-scheduler-types' + +export const DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY = 5 +export const DIRECT_SSH_PROVIDER_START_BUDGET = 7 + +export function createDirectSshWorktreeRefreshScheduler( + deps: DirectSshWorktreeRefreshSchedulerDeps +): DirectSshWorktreeRefreshScheduler { + const now = deps.now ?? Date.now + const tasksByKey = new Map() + const targetQueue = new DirectSshWorktreeRefreshTargetQueue() + const unsettledByAuthority = new Map() + const cancelDebtByAuthority = new Map() + let locallyUnsettled = 0 + let leaseSequence = 0 + let stopped = false + + const createLeaseId = (): WaiterLeaseId => + deps.createWaiterLeaseId?.() ?? (`direct-ssh-waiter-${++leaseSequence}` as WaiterLeaseId) + + const metricsFor = ( + task: LogicalTask, + locallySettledWaiterCount = task.metrics.locallySettledWaiterCount + ): DirectSshWorktreeRefreshOutcome['metrics'] => { + const metrics = copyDirectSshWorktreeRefreshMetrics(task.metrics) + metrics.locallySettledWaiterCount = locallySettledWaiterCount + return metrics + } + + const finishTask = (task: LogicalTask, outcome: DirectSshWorktreeRefreshOutcome): void => { + if (task.state === 'terminal') { + return + } + task.state = 'terminal' + task.attempt = null + tasksByKey.delete(task.keyId) + task.metrics.locallySettledWaiterCount += task.waiters.size + const settledOutcome = { ...outcome, metrics: metricsFor(task) } + for (const waiter of task.waiters.values()) { + waiter.resolve(settledOutcome) + } + task.waiters.clear() + } + + const addCancelDebt = (task: LogicalTask): void => { + recordDirectSshCancelDebt( + task.metrics, + DIRECT_SSH_PROVIDER_START_BUDGET - DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY + ) + cancelDebtByAuthority.set( + task.authorityId, + (cancelDebtByAuthority.get(task.authorityId) ?? 0) + 1 + ) + } + + const cancelAttempt = ( + task: LogicalTask, + reason: DirectSshWorktreeRefreshReleaseReason + ): void => { + if (!task.attempt || task.attemptCanceled) { + return + } + task.attemptCanceled = true + if (cancelDirectSshWorktreeRefreshAttempt(task.attempt, reason)) { + addCancelDebt(task) + } + } + + const canStart = (task: LogicalTask): boolean => { + const unsettled = unsettledByAuthority.get(task.authorityId) ?? 0 + const debt = cancelDebtByAuthority.get(task.authorityId) ?? 0 + return unsettled + debt + 1 <= DIRECT_SSH_PROVIDER_START_BUDGET + } + + const handleAttemptResult = ( + task: LogicalTask, + requestId: ProviderRequestId, + result: HostQualifiedDetectedWorktreeResult + ): void => { + if (task.state === 'terminal' || task.attempt?.providerRequestId !== requestId) { + return + } + if (result.providerRequestId !== requestId) { + finishTask(task, { status: 'rejected', providerRequestId: requestId }) + return + } + if (result.status === 'timed-out') { + addCancelDebt(task) + if (task.attemptCount === 1) { + task.metrics.timeoutRetryCount++ + task.attempt = null + task.attemptCanceled = true + targetQueue.enqueue(task, true, now()) + return + } + } + const status = + result.status === 'authority-unknown' || result.status === 'ambiguous-owner' + ? 'non-authoritative' + : result.status + finishTask(task, { + status, + providerRequestId: result.providerRequestId, + providerResult: result + }) + } + + function drain(): void { + while (!stopped && locallyUnsettled < DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY) { + const task = targetQueue.takeNext() + if (!task) { + return + } + if (!canStart(task)) { + task.metrics.replacementAdmissionDelayedCount++ + finishTask(task, { status: 'cancel-budget-exhausted' }) + continue + } + task.state = 'running' + task.attemptCount++ + locallyUnsettled++ + recordDirectSshQueueAdmission( + task.metrics, + Math.max(0, now() - task.queuedAt), + locallyUnsettled + ) + adjustDirectSshAuthorityUnsettled(unsettledByAuthority, task.authorityId, 1) + let attempt: DirectSshWorktreeRefreshAttempt + try { + attempt = deps.startAttempt(task.key) + task.attempt = attempt + task.attemptCanceled = false + task.attemptStartedAt = now() + } catch (error) { + locallyUnsettled-- + adjustDirectSshAuthorityUnsettled(unsettledByAuthority, task.authorityId, -1) + deps.onUnexpectedError?.(error) + finishTask(task, { status: 'rejected' }) + continue + } + void attempt.result + .then((result) => { + if (task.attemptStartedAt !== null) { + recordDirectSshProviderExecution( + task.metrics, + Math.max(0, now() - task.attemptStartedAt) + ) + task.attemptStartedAt = null + } + handleAttemptResult(task, attempt.providerRequestId, result) + }) + .catch((error) => { + if (task.attemptStartedAt !== null) { + recordDirectSshProviderExecution( + task.metrics, + Math.max(0, now() - task.attemptStartedAt) + ) + task.attemptStartedAt = null + } + if (task.state !== 'terminal' && !task.attemptCanceled) { + deps.onUnexpectedError?.(error) + finishTask(task, { + status: 'rejected', + providerRequestId: attempt.providerRequestId + }) + } + }) + .finally(() => { + locallyUnsettled-- + adjustDirectSshAuthorityUnsettled(unsettledByAuthority, task.authorityId, -1) + drain() + }) + } + } + + const releaseWaiter = ( + task: LogicalTask, + waiterLeaseId: WaiterLeaseId, + reason: DirectSshWorktreeRefreshReleaseReason + ): void => { + const waiter = task.waiters.get(waiterLeaseId) + if (!waiter) { + return + } + task.waiters.delete(waiterLeaseId) + task.metrics.locallySettledWaiterCount++ + waiter.resolve({ status: 'canceled', metrics: metricsFor(task) }) + if (task.waiters.size > 0) { + return + } + cancelAttempt(task, reason) + finishTask(task, { status: 'canceled' }) + } + + const request = (key: DirectSshWorktreeRefreshKey): DirectSshWorktreeRefreshLease => { + const keyId = directSshWorktreeRefreshKey(key) + let task = tasksByKey.get(keyId) + if (!task) { + const requestedAt = now() + task = { + key, + keyId, + authorityId: directSshProviderAuthorityKey(key), + state: 'queued', + attemptCount: 0, + attempt: null, + attemptCanceled: false, + attemptStartedAt: null, + queuedAt: requestedAt, + metrics: createDirectSshWorktreeRefreshMetrics(), + waiters: new Map() + } + tasksByKey.set(keyId, task) + targetQueue.enqueue(task, false, now()) + } else { + task.metrics.overlappingJoinCount++ + } + const waiterLeaseId = createLeaseId() + let resolve!: (outcome: DirectSshWorktreeRefreshOutcome) => void + const result = new Promise((settle) => { + resolve = settle + }) + task.waiters.set(waiterLeaseId, { resolve }) + if (stopped) { + releaseWaiter(task, waiterLeaseId, 'stopped') + targetQueue.clear() + } else { + drain() + } + return { + waiterLeaseId, + result, + release: (reason) => releaseWaiter(task!, waiterLeaseId, reason) + } + } + + const invalidateMatching = (predicate: (task: LogicalTask) => boolean): void => { + for (const task of tasksByKey.values()) { + if (!predicate(task)) { + continue + } + cancelAttempt(task, 'invalidated') + finishTask(task, { status: 'stale', providerRequestId: task.attempt?.providerRequestId }) + } + drain() + } + + const invalidateAuthority = (authority: DirectSshAuthority): void => { + const id = directSshProviderAuthorityKey(authority) + invalidateMatching((task) => task.authorityId === id) + } + + const invalidateTarget = (targetId: string): void => { + invalidateMatching((task) => task.key.targetId === targetId) + } + + const disposeProvider = (authority: DirectSshAuthority): void => { + invalidateAuthority(authority) + cancelDebtByAuthority.delete(directSshProviderAuthorityKey(authority)) + } + + const getSnapshot = () => + directSshWorktreeRefreshSchedulerSnapshot(tasksByKey, locallyUnsettled, cancelDebtByAuthority) + + const stop = (): void => { + if (stopped) { + return + } + stopped = true + for (const task of tasksByKey.values()) { + cancelAttempt(task, 'stopped') + finishTask(task, { status: 'canceled', providerRequestId: task.attempt?.providerRequestId }) + } + targetQueue.clear() + } + + return { + request, + invalidateAuthority, + invalidateTarget, + disposeProvider, + getSnapshot, + stop + } +} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-target-queue.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-target-queue.ts new file mode 100644 index 00000000000..4d549b4b437 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-worktree-refresh-target-queue.ts @@ -0,0 +1,44 @@ +import type { DirectSshWorktreeRefreshLogicalTask } from './direct-ssh-worktree-refresh-scheduler-types' + +export class DirectSshWorktreeRefreshTargetQueue { + private readonly queuedByTarget = new Map() + private readonly targetOrder: string[] = [] + + enqueue(task: DirectSshWorktreeRefreshLogicalTask, retrying: boolean, now: number): void { + task.state = retrying ? 'retrying' : 'queued' + task.queuedAt = now + const lane = this.queuedByTarget.get(task.key.targetId) + if (lane) { + lane.push(task) + return + } + this.queuedByTarget.set(task.key.targetId, [task]) + this.targetOrder.push(task.key.targetId) + } + + takeNext(): DirectSshWorktreeRefreshLogicalTask | null { + while (this.targetOrder.length > 0) { + const targetId = this.targetOrder.shift()! + const lane = this.queuedByTarget.get(targetId) + const task = lane?.shift() + if (!lane || !task) { + this.queuedByTarget.delete(targetId) + continue + } + if (lane.length > 0) { + this.targetOrder.push(targetId) + } else { + this.queuedByTarget.delete(targetId) + } + if (task.state !== 'terminal' && task.waiters.size > 0) { + return task + } + } + return null + } + + clear(): void { + this.queuedByTarget.clear() + this.targetOrder.length = 0 + } +} diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts new file mode 100644 index 00000000000..cc9d47ab6e7 --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -0,0 +1,133 @@ +import type { TerminalTab, WorkspaceSessionState } from '../../../shared/types' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { splitWorktreeId } from '../../../shared/worktree-id' +import type { AppState } from '../store/types' + +function preserveNewerLocalTerminalFields(remote: TerminalTab, local: TerminalTab): TerminalTab { + const preserved = { + ...remote, + generation: local.generation, + ptyId: local.ptyId + } + return local.pendingActivationSpawn + ? { ...preserved, pendingActivationSpawn: local.pendingActivationSpawn } + : preserved +} + +export function mergeDirectSshRemoteWorkspaceSession( + current: WorkspaceSessionState, + remote: WorkspaceSessionState, + replaceWorktreeIds: ReadonlySet, + liveTabsByWorktree: AppState['tabsByWorktree'], + preserveLocalTerminalTabIds: ReadonlySet +): WorkspaceSessionState { + const currentTabsById = new Map( + [...replaceWorktreeIds] + .flatMap((worktreeId) => liveTabsByWorktree[worktreeId] ?? []) + .map((tab) => [tab.id, tab]) + ) + const locallyPreservedTabIds = new Set() + const tabsByWorktree = Object.fromEntries( + Object.entries(remote.tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => { + const local = currentTabsById.get(tab.id) + if ( + !local || + ((local.generation ?? 0) <= (tab.generation ?? 0) && + !local.pendingActivationSpawn && + !preserveLocalTerminalTabIds.has(tab.id)) + ) { + return tab + } + locallyPreservedTabIds.add(tab.id) + return preserveNewerLocalTerminalFields(tab, local) + }) + ]) + ) + const remoteTabIds = new Set( + Object.values(tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)) + ) + const replacedTabIds = new Set([ + ...remoteTabIds, + ...Object.entries(current.tabsByWorktree) + .filter(([worktreeId]) => replaceWorktreeIds.has(worktreeId)) + .flatMap(([, tabs]) => tabs.map((tab) => tab.id)) + ]) + const omitTargetWorktrees = (record: Record | undefined): Record => + Object.fromEntries( + Object.entries(record ?? {}).filter(([worktreeId]) => !replaceWorktreeIds.has(worktreeId)) + ) + const terminalLayoutsByTabId = { + ...Object.fromEntries( + Object.entries(current.terminalLayoutsByTabId).filter( + ([tabId]) => !replacedTabIds.has(tabId) || locallyPreservedTabIds.has(tabId) + ) + ), + ...Object.fromEntries( + Object.entries(remote.terminalLayoutsByTabId).filter( + ([tabId]) => !locallyPreservedTabIds.has(tabId) + ) + ) + } + const activeOutsideTarget = + current.activeWorktreeId != null && !replaceWorktreeIds.has(current.activeWorktreeId) + return { + ...current, + activeRepoId: activeOutsideTarget ? current.activeRepoId : remote.activeRepoId, + activeWorktreeId: activeOutsideTarget ? current.activeWorktreeId : remote.activeWorktreeId, + activeWorkspaceKey: activeOutsideTarget + ? current.activeWorkspaceKey + : remote.activeWorktreeId + ? worktreeWorkspaceKey(remote.activeWorktreeId) + : null, + activeTabId: activeOutsideTarget ? current.activeTabId : remote.activeTabId, + tabsByWorktree: { + ...omitTargetWorktrees(current.tabsByWorktree), + ...tabsByWorktree + }, + terminalLayoutsByTabId, + activeWorktreeIdsOnShutdown: [ + ...(current.activeWorktreeIdsOnShutdown ?? []).filter((id) => !replaceWorktreeIds.has(id)), + ...(remote.activeWorktreeIdsOnShutdown ?? []) + ], + activeTabIdByWorktree: { + ...omitTargetWorktrees(current.activeTabIdByWorktree), + ...remote.activeTabIdByWorktree + }, + remoteSessionIdsByTabId: { + ...Object.fromEntries( + Object.entries(current.remoteSessionIdsByTabId ?? {}).filter( + ([tabId]) => !replacedTabIds.has(tabId) || locallyPreservedTabIds.has(tabId) + ) + ), + ...Object.fromEntries( + Object.entries(remote.remoteSessionIdsByTabId ?? {}).filter( + ([tabId]) => !locallyPreservedTabIds.has(tabId) + ) + ) + }, + lastVisitedAtByWorktreeId: { + ...omitTargetWorktrees(current.lastVisitedAtByWorktreeId), + ...remote.lastVisitedAtByWorktreeId + }, + defaultTerminalTabsAppliedByWorktreeId: { + ...omitTargetWorktrees(current.defaultTerminalTabsAppliedByWorktreeId), + ...remote.defaultTerminalTabsAppliedByWorktreeId + } + } +} + +export function uniqueWorktreeIdByPath( + worktreeIds: ReadonlySet +): (worktreePath: string) => string | null { + const byPath = new Map() + for (const worktreeId of worktreeIds) { + const path = splitWorktreeId(worktreeId)?.worktreePath + if (!path) { + continue + } + byPath.set(path, byPath.has(path) ? null : worktreeId) + } + return (worktreePath) => byPath.get(worktreePath) ?? null +} diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts new file mode 100644 index 00000000000..1ffad891d89 --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -0,0 +1,171 @@ +import type { StoreApi } from 'zustand' +import type { RemoteWorkspaceSnapshot } from '../../../shared/remote-workspace-types' +import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection' +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' +import { buildWorkspaceSessionPayload } from '../lib/workspace-session' +import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope' +import type { AppState } from '../store/types' +import { + admitDirectSshSnapshotApplyToken, + type DirectSshPreparationToken, + type DirectSshSnapshotApplyToken +} from './direct-ssh-reconnect-coordinator' +import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' +import { + mergeDirectSshRemoteWorkspaceSession, + uniqueWorktreeIdByPath +} from './remote-workspace-session-merge' + +const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1_000 +const SNAPSHOT_TERMINAL_RECONNECT_TIMEOUT_MS = 30_000 +let snapshotApplyDepth = 0 +let snapshotWriteSuppressUntil = 0 + +export function isDirectSshRemoteWorkspaceApplyInProgress(): boolean { + return snapshotApplyDepth > 0 || Date.now() < snapshotWriteSuppressUntil +} + +type RemoteWorkspaceSnapshotApplyInput = { + store: Pick, 'getState'> + snapshot: RemoteWorkspaceSnapshot + token: DirectSshSnapshotApplyToken + arrival: number + isArrivalCurrent: (targetId: string, arrival: number) => boolean + isPreparationTokenCurrent: (token: DirectSshPreparationToken) => boolean + waitForWorkspaceSessionReady: () => Promise + finalizeHydratedTerminals: (authority: DirectSshAuthority) => number +} + +function exactTargetWorktreeIds(state: AppState, authority: DirectSshAuthority): Set { + return resolveDirectSshTargetScope({ + targetId: authority.targetId, + catalogRevision: 0, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + }).gitWorktreeIds +} + +function currentRecoveryTabIds( + state: AppState, + authority: DirectSshAuthority, + worktreeIds: ReadonlySet +): Set { + const targetTabIds = new Set( + [...worktreeIds].flatMap((worktreeId) => + (state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) + ) + ) + return new Set( + [ + ...Object.entries(state.directSshPaneRetryByTabId), + ...Object.entries(state.directSshLivePtyBindingByTabId) + ] + .filter( + ([tabId, entry]) => + targetTabIds.has(tabId) && directSshAuthoritiesEqual(entry.authority, authority) + ) + .map(([tabId]) => tabId) + ) +} + +export async function applyDirectSshRemoteWorkspaceSnapshot({ + store, + snapshot, + token, + arrival, + isArrivalCurrent, + isPreparationTokenCurrent, + waitForWorkspaceSessionReady, + finalizeHydratedTerminals +}: RemoteWorkspaceSnapshotApplyInput): Promise { + const { authority } = token + if (!isArrivalCurrent(authority.targetId, arrival)) { + return + } + if ( + !isPreparationTokenCurrent(token) || + !admitDirectSshSnapshotApplyToken(token, authority, snapshot.revision) + ) { + return + } + if (!(await waitForWorkspaceSessionReady())) { + if (isArrivalCurrent(authority.targetId, arrival) && isPreparationTokenCurrent(token)) { + store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'error', + direction: 'pull', + message: translate( + 'auto.hooks.useIpcEvents.88214a785b', + 'Workspace sync waited for local session hydration and timed out' + ) + }) + } + return + } + const state = store.getState() + const worktreeIds = exactTargetWorktreeIds(state, authority) + const remoteSession = importRemoteWorkspaceSession(snapshot.session, { + resolveWorktreeId: uniqueWorktreeIdByPath(worktreeIds) + }) + const merged = mergeDirectSshRemoteWorkspaceSession( + buildWorkspaceSessionPayload(state), + remoteSession, + worktreeIds, + state.tabsByWorktree, + currentRecoveryTabIds(state, authority, worktreeIds) + ) + if (!isArrivalCurrent(authority.targetId, arrival) || !isPreparationTokenCurrent(token)) { + return + } + snapshotApplyDepth += 1 + try { + const currentStore = store.getState() + const replaceWorkspaceKeys = [...worktreeIds] + currentStore.hydrateWorkspaceSession(merged, { + directSshAuthority: authority, + replaceWorkspaceKeys + }) + currentStore.hydrateTabsSession(merged, { replaceWorkspaceKeys }) + // Why: direct SSH snapshots project terminal state only; global editor/browser hydration would reset unrelated hosts. + currentStore.markRemoteWorkspaceHydrated(authority.targetId) + currentStore.setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'synced', + direction: 'pull', + revision: snapshot.revision, + updatedAt: snapshot.updatedAt, + lastSyncedAt: Date.now(), + message: translate('auto.hooks.useIpcEvents.4f78ba5885', 'Workspace synced') + }) + const reconnectAbort = new AbortController() + let reconnectTimer: ReturnType | null = null + await Promise.race([ + Promise.resolve() + .then(() => + store.getState().reconnectPersistedTerminals(reconnectAbort.signal, { + directSshAuthority: authority, + workspaceKeys: replaceWorkspaceKeys + }) + ) + .catch(() => {}), + new Promise((resolve) => { + reconnectTimer = setTimeout(() => { + reconnectAbort.abort() + resolve() + }, SNAPSHOT_TERMINAL_RECONNECT_TIMEOUT_MS) + }) + ]) + if (reconnectTimer) { + clearTimeout(reconnectTimer) + } + if (isArrivalCurrent(authority.targetId, arrival) && isPreparationTokenCurrent(token)) { + finalizeHydratedTerminals(authority) + } + } finally { + snapshotWriteSuppressUntil = Date.now() + REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS + snapshotApplyDepth -= 1 + } +} diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts new file mode 100644 index 00000000000..985f54e535a --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts @@ -0,0 +1,557 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + RemoteWorkspacePatchResult, + RemoteWorkspaceSnapshot +} from '../../../shared/remote-workspace-types' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { i18n } from '@/i18n/i18n' +import { PSEUDO_LOCALIZATION_LOCALE } from '@/i18n/pseudo-localization' +import type { AppState } from '../store/types' +import type { + DirectSshPreparationInput, + DirectSshPreparationToken +} from './direct-ssh-reconnect-coordinator' +import { createRemoteWorkspaceTargetSync } from './remote-workspace-target-sync' + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +const owner: DirectSshAuthority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 +} + +function token(snapshotRevision: number | null = null): DirectSshPreparationToken { + return { + authority: owner, + catalogRevision: 1, + repoFingerprint: JSON.stringify([['ssh:target-a', 'repo-a']]), + authorityRequirement: 'required', + snapshotRevision, + outcome: 'complete' + } +} + +function snapshot( + revision: number, + tabsByWorktreePath: RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'] = {} +): RemoteWorkspaceSnapshot { + return { + namespace: 'workspace', + revision, + updatedAt: revision, + schemaVersion: 1, + session: { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath, + terminalLayoutsByTabId: {} + } + } +} + +function repo(id = 'repo-a') { + return { + id, + path: `/remote/${id}`, + projectGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } +} + +function worktree(id = 'repo-a::/remote/work') { + return { + id, + repoId: id.slice(0, id.indexOf('::')), + hostId: 'ssh:target-a' + } +} + +function appState(overrides: Record = {}): AppState { + return { + workspaceSessionReady: true, + repos: [repo()], + worktreesByRepo: { 'repo-a': [worktree()] }, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + activeRepoId: null, + activeWorkspaceKey: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + lastKnownRelayPtyIdByTabId: {}, + directSshPaneRetryByTabId: {}, + directSshLivePtyBindingByTabId: {}, + terminalLayoutsByTabId: {}, + activeTabIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + markdownFrontmatterVisible: {}, + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + activeBrowserTabIdByWorktree: {}, + browserUrlHistory: [], + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + sshConnectionStates: new Map(), + lastVisitedAtByWorktreeId: {}, + defaultTerminalTabsAppliedByWorktreeId: {}, + hydrateWorkspaceSession: vi.fn(), + hydrateTabsSession: vi.fn(), + hydrateEditorSession: vi.fn(), + hydrateBrowserSession: vi.fn(), + markRemoteWorkspaceHydrated: vi.fn(), + setRemoteWorkspaceSyncStatus: vi.fn(), + reconnectPersistedTerminals: vi.fn(async () => {}), + ...overrides + } as unknown as AppState +} + +function createHarness( + state: AppState, + get: (args: { targetId: string }) => Promise, + patchResult: RemoteWorkspacePatchResult = { ok: true, snapshot: snapshot(1) } +) { + const setForConnectedTargets = vi.fn(async () => [ + { + targetId: owner.targetId, + result: patchResult + } + ]) + let current = true + const capturePreparationInput = vi.fn( + async ( + authority: DirectSshAuthority, + reason: 'workspace-snapshot', + snapshotRevision: number + ): Promise => ({ + ...authority, + catalogRevision: 1, + repoRefs: [{ repoId: 'repo-a', executionHostId: 'ssh:target-a' }], + authorityRequirement: 'required', + reason, + snapshotRevision + }) + ) + const prepareOnly = vi.fn(async (input: DirectSshPreparationInput) => ({ + status: 'complete' as const, + token: token(input.snapshotRevision ?? null), + repoOutcomes: { + complete: 1, + 'non-authoritative': 0, + 'timed-out': 0, + 'cancel-budget-exhausted': 0, + canceled: 0, + stale: 0, + rejected: 0 + }, + lineageOutcome: 'complete' as const + })) + const finalizeHydratedTerminals = vi.fn(() => 1) + const sync = createRemoteWorkspaceTargetSync({ + store: { getState: () => state }, + remoteWorkspace: { get, setForConnectedTargets }, + getCurrentAuthority: () => (current ? owner : null), + isPreparationTokenCurrent: () => current, + capturePreparationInput, + prepareOnly, + finalizeHydratedTerminals + }) + return { + sync, + setForConnectedTargets, + capturePreparationInput, + prepareOnly, + finalizeHydratedTerminals, + makeStale: () => { + current = false + } + } +} + +async function flush(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('createRemoteWorkspaceTargetSync', () => { + it('captures local tabs before get when deciding a revision-zero upload', async () => { + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] + } + }) + const pendingGet = deferred() + const harness = createHarness(state, () => pendingGet.promise) + + const pending = harness.sync.syncAfterConnect(token()) + await flush() + state.tabsByWorktree = {} + pendingGet.resolve(snapshot(0)) + await pending + + expect(harness.setForConnectedTargets).toHaveBeenCalledOnce() + expect(harness.setForConnectedTargets).toHaveBeenCalledWith( + expect.objectContaining({ hydratedTargetIds: ['target-a'] }) + ) + }) + + it.each([ + ['stale-revision', 'Workspace changed on another device'], + ['unavailable', 'Remote workspace sync unavailable'] + ] as const)('localizes the %s upload fallback', async (reason, message) => { + const previousLanguage = i18n.language + await i18n.changeLanguage(PSEUDO_LOCALIZATION_LOCALE) + try { + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] + } + }) + const harness = createHarness(state, async () => snapshot(0), { + ok: false, + reason + }) + + await harness.sync.syncAfterConnect(token()) + + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenLastCalledWith( + 'target-a', + expect.objectContaining({ message: `[${message}]` }) + ) + } finally { + await i18n.changeLanguage(previousLanguage) + } + }) + + it('publishes nothing from a snapshot response after its authority turns stale', async () => { + const state = appState() + const pendingGet = deferred() + const harness = createHarness(state, () => pendingGet.promise) + + const pending = harness.sync.syncAfterConnect(token()) + await flush() + harness.makeStale() + pendingGet.resolve(snapshot(2)) + await pending + + expect(state.hydrateTabsSession).not.toHaveBeenCalled() + expect(state.markRemoteWorkspaceHydrated).not.toHaveBeenCalled() + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenCalledTimes(1) + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenCalledWith('target-a', { + phase: 'pulling', + direction: 'pull' + }) + }) + + it('prepares an unsolicited snapshot once and preserves newer local terminal fields', async () => { + const calls: string[] = [] + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [ + { + id: 'stable-tab', + worktreeId: 'repo-a::/remote/work', + ptyId: 'local-pty', + generation: 7, + pendingActivationSpawn: { requestedAt: 10 } + } + ] + }, + hydrateTabsSession: vi.fn((session) => { + calls.push('hydrate') + expect(session.tabsByWorktree['repo-a::/remote/work'][0]).toMatchObject({ + id: 'stable-tab', + ptyId: 'local-pty', + generation: 7, + pendingActivationSpawn: { requestedAt: 10 } + }) + }), + reconnectPersistedTerminals: vi.fn(async () => { + calls.push('reconnect') + }) + }) + const harness = createHarness(state, async () => null) + harness.finalizeHydratedTerminals.mockImplementation(() => { + calls.push('finalize') + return 1 + }) + const incoming = snapshot(3, { + '/remote/work': [ + { + id: 'stable-tab', + worktreePath: '/remote/work', + ptyId: 'remote-pty', + generation: 99 + } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] + ] + }) + + await harness.sync.applyUnsolicitedSnapshot('target-a', incoming) + + expect(harness.capturePreparationInput).toHaveBeenCalledOnce() + expect(harness.prepareOnly).toHaveBeenCalledOnce() + expect(calls).toEqual(['hydrate', 'reconnect', 'finalize']) + expect(state.hydrateWorkspaceSession).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + directSshAuthority: owner, + replaceWorkspaceKeys: ['repo-a::/remote/work'] + }) + ) + expect(state.hydrateTabsSession).toHaveBeenCalledWith(expect.any(Object), { + replaceWorkspaceKeys: ['repo-a::/remote/work'] + }) + expect(state.hydrateEditorSession).not.toHaveBeenCalled() + expect(state.hydrateBrowserSession).not.toHaveBeenCalled() + }) + + it('preserves a higher local generation from an older remote snapshot', async () => { + const hydrateTabsSession = vi.fn() + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [ + { + id: 'stable-tab', + worktreeId: 'repo-a::/remote/work', + ptyId: 'local-pty', + generation: 7 + } + ] + }, + hydrateTabsSession + }) + const harness = createHarness(state, async () => null) + const incoming = snapshot(5, { + '/remote/work': [ + { + id: 'stable-tab', + worktreePath: '/remote/work', + ptyId: 'old-remote-pty', + generation: 1 + } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] + ] + }) + + await harness.sync.applyUnsolicitedSnapshot('target-a', incoming) + + expect( + hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'][0] + ).toMatchObject({ generation: 7, ptyId: 'local-pty' }) + }) + + it('admits a genuinely newer remote generation without local recovery state', async () => { + const hydrateTabsSession = vi.fn() + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [ + { + id: 'stable-tab', + worktreeId: 'repo-a::/remote/work', + ptyId: 'local-pty', + generation: 1 + } + ] + }, + hydrateTabsSession + }) + const harness = createHarness(state, async () => null) + const incoming = snapshot(6, { + '/remote/work': [ + { + id: 'stable-tab', + worktreePath: '/remote/work', + ptyId: 'new-remote-pty', + generation: 8 + } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] + ] + }) + + await harness.sync.applyUnsolicitedSnapshot('target-a', incoming) + + expect( + hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'][0] + ).toMatchObject({ generation: 8, ptyId: 'new-remote-pty' }) + }) + + it('does not preserve recovery evidence from another authority', async () => { + const hydrateTabsSession = vi.fn() + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [ + { + id: 'stable-tab', + worktreeId: 'repo-a::/remote/work', + ptyId: 'local-pty', + generation: 1 + } + ] + }, + directSshLivePtyBindingByTabId: { + 'stable-tab': { + authority: { + targetId: 'target-b', + providerEpoch: 'epoch-b' as SshProviderEpoch, + connectionGeneration: 2 + }, + tabGeneration: 1, + ptyId: 'local-pty' + } + }, + hydrateTabsSession + }) + const harness = createHarness(state, async () => null) + const incoming = snapshot(7, { + '/remote/work': [ + { + id: 'stable-tab', + worktreePath: '/remote/work', + ptyId: 'new-remote-pty', + generation: 8 + } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] + ] + }) + + await harness.sync.applyUnsolicitedSnapshot('target-a', incoming) + + expect( + hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'][0] + ).toMatchObject({ generation: 8, ptyId: 'new-remote-pty' }) + }) + + it('does not finalize an older snapshot superseded during terminal reattach', async () => { + const firstReattach = deferred() + let reattachCount = 0 + const state = appState({ + reconnectPersistedTerminals: vi.fn(() => { + reattachCount += 1 + return reattachCount === 1 ? firstReattach.promise : Promise.resolve() + }) + }) + const harness = createHarness(state, async () => null) + + const first = harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(7)) + await flush() + expect(state.reconnectPersistedTerminals).toHaveBeenCalledOnce() + const second = harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(8)) + await second + expect(harness.finalizeHydratedTerminals).toHaveBeenCalledOnce() + + firstReattach.resolve() + await first + expect(harness.finalizeHydratedTerminals).toHaveBeenCalledOnce() + }) + + it('keeps staged snapshot PTYs retryable until pane transport acknowledgment', async () => { + const calls: string[] = [] + const recordLiveBindings = vi.fn(() => { + calls.push('record') + return 1 + }) + const state = appState({ + reconnectPersistedTerminals: vi.fn(async () => { + calls.push('reconnect') + }), + recordDirectSshTargetLivePtyBindings: recordLiveBindings + }) + const harness = createHarness(state, async () => null) + harness.finalizeHydratedTerminals.mockImplementation(() => { + calls.push('finalize') + return 0 + }) + + await harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(9)) + + expect(calls).toEqual(['reconnect', 'finalize']) + expect(recordLiveBindings).not.toHaveBeenCalled() + }) + + it('re-arms after a snapshot terminal reconnect failure', async () => { + const state = appState({ + reconnectPersistedTerminals: vi.fn(async () => { + throw new Error('reattach failed') + }) + }) + const harness = createHarness(state, async () => null) + + await harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(10)) + + expect(harness.finalizeHydratedTerminals).toHaveBeenCalledOnce() + }) + + it('times out snapshot terminal reconnect and fences its late result', async () => { + vi.useFakeTimers() + const pendingReattach = deferred() + let reconnectSignal: AbortSignal | undefined + const state = appState({ + reconnectPersistedTerminals: vi.fn((signal?: AbortSignal) => { + reconnectSignal = signal + return pendingReattach.promise + }) + }) + const harness = createHarness(state, async () => null) + + const pending = harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(11)) + await vi.advanceTimersByTimeAsync(30_000) + await pending + expect(reconnectSignal?.aborted).toBe(true) + expect(harness.finalizeHydratedTerminals).toHaveBeenCalledOnce() + + pendingReattach.resolve() + await flush() + expect(harness.finalizeHydratedTerminals).toHaveBeenCalledOnce() + vi.useRealTimers() + }) + + it('fails closed on duplicate target paths and keeps folder workspaces out of projection', async () => { + const hydrateTabsSession = vi.fn() + const state = appState({ + repos: [repo('repo-a'), repo('repo-b')], + worktreesByRepo: { + 'repo-a': [worktree('repo-a::/same')], + 'repo-b': [worktree('repo-b::/same')] + }, + tabsByWorktree: { + 'folder:folder-a': [{ id: 'folder-tab', worktreeId: 'folder:folder-a', ptyId: null }] + }, + hydrateTabsSession + }) + const harness = createHarness(state, async () => null) + const incoming = snapshot(4, { + '/same': [ + { + id: 'ambiguous', + worktreePath: '/same', + ptyId: null + } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] + ] + }) + + await harness.sync.applyUnsolicitedSnapshot('target-a', incoming) + + const merged = hydrateTabsSession.mock.calls[0][0] + expect(merged.tabsByWorktree).toEqual({ + 'folder:folder-a': [{ id: 'folder-tab', worktreeId: 'folder:folder-a', ptyId: null }] + }) + }) +}) diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts new file mode 100644 index 00000000000..79bc3d9690e --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -0,0 +1,256 @@ +import type { StoreApi } from 'zustand' +import type { + RemoteWorkspacePatchResult, + RemoteWorkspaceSnapshot +} from '../../../shared/remote-workspace-types' +import type { WorkspaceSessionState } from '../../../shared/types' +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' +import { buildWorkspaceSessionPayload } from '../lib/workspace-session' +import type { AppState } from '../store/types' +import type { + DirectSshPreparationInput, + DirectSshPreparationOutcome, + DirectSshPreparationToken +} from './direct-ssh-reconnect-coordinator' +import { buildDirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator' +import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope' +import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' +export { isDirectSshRemoteWorkspaceApplyInProgress } from './remote-workspace-snapshot-apply' + +const WORKSPACE_HYDRATION_TIMEOUT_MS = 10_000 + +type RemoteWorkspaceApi = { + get: (args: { targetId: string }) => Promise + setForConnectedTargets: (args: { + session?: WorkspaceSessionState + hydratedTargetIds?: string[] + }) => Promise<{ targetId: string; result: RemoteWorkspacePatchResult }[]> +} + +export type RemoteWorkspaceTargetSyncDeps = { + store: Pick, 'getState'> + remoteWorkspace: RemoteWorkspaceApi + getCurrentAuthority: (targetId: string) => DirectSshAuthority | null + isPreparationTokenCurrent: (token: DirectSshPreparationToken) => boolean + capturePreparationInput: ( + authority: DirectSshAuthority, + reason: 'workspace-snapshot', + snapshotRevision: number + ) => Promise + prepareOnly: (input: DirectSshPreparationInput) => Promise + finalizeHydratedTerminals: (authority: DirectSshAuthority) => number +} + +export type RemoteWorkspaceTargetSync = { + syncAfterConnect: (token: DirectSshPreparationToken) => Promise + applyUnsolicitedSnapshot: (targetId: string, snapshot: RemoteWorkspaceSnapshot) => Promise + stop: () => void +} + +function exactTargetWorktreeIds(state: AppState, authority: DirectSshAuthority): Set { + return resolveDirectSshTargetScope({ + targetId: authority.targetId, + catalogRevision: 0, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + }).gitWorktreeIds +} + +function applyPatchStatus( + store: AppState, + targetId: string, + result: RemoteWorkspacePatchResult | undefined +): void { + if (!result) { + store.setRemoteWorkspaceSyncStatus(targetId, { + phase: 'offline', + direction: 'push', + lastSyncedAt: Date.now(), + message: translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable') + }) + } else if (result.ok) { + store.setRemoteWorkspaceSyncStatus(targetId, { + phase: 'synced', + direction: 'push', + revision: result.snapshot.revision, + updatedAt: result.snapshot.updatedAt, + lastSyncedAt: Date.now(), + message: translate('auto.hooks.useIpcEvents.f8aaf2bde3', 'Workspace uploaded') + }) + } else { + store.setRemoteWorkspaceSyncStatus(targetId, { + phase: result.reason === 'stale-revision' ? 'conflict' : 'offline', + direction: 'push', + revision: result.snapshot?.revision, + updatedAt: result.snapshot?.updatedAt, + lastSyncedAt: Date.now(), + message: + result.message ?? + (result.reason === 'stale-revision' + ? translate( + 'auto.hooks.useIpcEvents.workspaceChangedOnAnotherDevice', + 'Workspace changed on another device' + ) + : translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable')) + }) + } +} + +export function createRemoteWorkspaceTargetSync( + deps: RemoteWorkspaceTargetSyncDeps +): RemoteWorkspaceTargetSync { + const arrivalByTarget = new Map() + let stopped = false + + const beginArrival = (targetId: string): number => { + const arrival = (arrivalByTarget.get(targetId) ?? 0) + 1 + arrivalByTarget.set(targetId, arrival) + return arrival + } + + const isArrivalCurrent = (targetId: string, arrival: number): boolean => + !stopped && arrivalByTarget.get(targetId) === arrival + + const waitForWorkspaceSessionReady = async (): Promise => { + const deadline = Date.now() + WORKSPACE_HYDRATION_TIMEOUT_MS + while (!stopped && Date.now() < deadline) { + if (deps.store.getState().workspaceSessionReady) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + return !stopped && deps.store.getState().workspaceSessionReady + } + + const syncAfterConnect = async (token: DirectSshPreparationToken): Promise => { + const { authority } = token + const arrival = beginArrival(authority.targetId) + const workspaceReady = await waitForWorkspaceSessionReady() + if (!isArrivalCurrent(authority.targetId, arrival) || !deps.isPreparationTokenCurrent(token)) { + return + } + if (!workspaceReady) { + deps.store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'error', + direction: 'pull', + message: translate( + 'auto.hooks.useIpcEvents.88214a785b', + 'Workspace sync waited for local session hydration and timed out' + ) + }) + return + } + const stateBeforeGet = deps.store.getState() + const worktreeIds = exactTargetWorktreeIds(stateBeforeGet, authority) + const hasLocalTabs = [...worktreeIds].some( + (worktreeId) => (stateBeforeGet.tabsByWorktree[worktreeId] ?? []).length > 0 + ) + stateBeforeGet.setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'pulling', + direction: 'pull' + }) + const snapshot = await deps.remoteWorkspace.get({ targetId: authority.targetId }) + if (!isArrivalCurrent(authority.targetId, arrival) || !deps.isPreparationTokenCurrent(token)) { + return + } + if (!snapshot) { + deps.store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'offline', + direction: 'pull', + message: translate( + 'auto.hooks.useIpcEvents.2fe88c2e06', + 'Remote workspace sync unavailable' + ) + }) + return + } + if (snapshot.revision > 0) { + const applyToken = buildDirectSshSnapshotApplyToken(token, snapshot.revision) + if (applyToken) { + await applyDirectSshRemoteWorkspaceSnapshot({ + store: deps.store, + snapshot, + token: applyToken, + arrival, + isArrivalCurrent, + isPreparationTokenCurrent: deps.isPreparationTokenCurrent, + waitForWorkspaceSessionReady, + finalizeHydratedTerminals: deps.finalizeHydratedTerminals + }) + } + return + } + deps.store.getState().markRemoteWorkspaceHydrated(authority.targetId) + if (!hasLocalTabs) { + deps.store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'idle', + revision: snapshot.revision, + updatedAt: snapshot.updatedAt, + message: translate('auto.hooks.useIpcEvents.2ec42e1c52', 'No remote workspace yet') + }) + return + } + if (!deps.isPreparationTokenCurrent(token)) { + return + } + const results = await deps.remoteWorkspace.setForConnectedTargets({ + session: buildWorkspaceSessionPayload(deps.store.getState()), + hydratedTargetIds: [authority.targetId] + }) + if (!deps.isPreparationTokenCurrent(token)) { + return + } + const result = results.find((entry) => entry.targetId === authority.targetId)?.result + applyPatchStatus(deps.store.getState(), authority.targetId, result) + } + + const applyUnsolicitedSnapshot = async ( + targetId: string, + snapshot: RemoteWorkspaceSnapshot + ): Promise => { + const arrival = beginArrival(targetId) + const authority = deps.getCurrentAuthority(targetId) + if (!authority) { + return + } + const input = await deps.capturePreparationInput( + authority, + 'workspace-snapshot', + snapshot.revision + ) + if (!input || !isArrivalCurrent(targetId, arrival)) { + return + } + const prepared = await deps.prepareOnly(input) + if (!prepared.token || !isArrivalCurrent(targetId, arrival)) { + return + } + const applyToken = buildDirectSshSnapshotApplyToken(prepared.token, snapshot.revision) + if (applyToken) { + await applyDirectSshRemoteWorkspaceSnapshot({ + store: deps.store, + snapshot, + token: applyToken, + arrival, + isArrivalCurrent, + isPreparationTokenCurrent: deps.isPreparationTokenCurrent, + waitForWorkspaceSessionReady, + finalizeHydratedTerminals: deps.finalizeHydratedTerminals + }) + } + } + + return { + syncAfterConnect, + applyUnsolicitedSnapshot, + stop: () => { + stopped = true + arrivalByTarget.clear() + } + } +} diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 08c49e1f3e9..fc2a0cbb3aa 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1426,7 +1426,7 @@ describe('useIpcEvents updater integration', () => { }) it('clears stale remote PTYs when an SSH connection fully disconnects', async () => { - const clearTabPtyId = vi.fn() + const clearDirectSshTargetPtyBindings = vi.fn(() => 1) const setSshConnectionState = vi.fn() const setSshTargetsMetadata = vi.fn() const clearRemovedSshTargetState = vi.fn() @@ -1478,7 +1478,7 @@ describe('useIpcEvents updater integration', () => { removeSshCredentialRequest: vi.fn(), clearRemoteDetectedAgents: vi.fn(), clearRemovedSshTargetState, - clearTabPtyId, + clearDirectSshTargetPtyBindings, repos: [{ id: 'repo-1', connectionId: 'conn-1' }], worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] @@ -1663,8 +1663,8 @@ describe('useIpcEvents updater integration', () => { 'conn-1', expect.objectContaining({ status: 'disconnected' }) ) - expect(clearTabPtyId).toHaveBeenCalledWith('tab-1') - expect(clearTabPtyId).not.toHaveBeenCalledWith('tab-2') + expect(clearDirectSshTargetPtyBindings).toHaveBeenCalledOnce() + expect(clearDirectSshTargetPtyBindings).toHaveBeenCalledWith('conn-1') expect(storeState.clearRemoteDetectedAgents).toHaveBeenCalledWith('conn-1') setSshConnectionState.mockClear() @@ -4805,6 +4805,8 @@ describe('useIpcEvents agent status snapshot integration', () => { drop?: (paneKey: string) => void remoteWorkspace?: Record runtime?: Record + ssh?: Record + ui?: Record }): Record { return { api: { @@ -4864,7 +4866,8 @@ describe('useIpcEvents agent status snapshot integration', () => { onFullscreenChanged: () => () => {}, onTerminalZoom: () => () => {}, getZoomLevel: () => 0, - set: vi.fn() + set: vi.fn(), + ...args.ui }, settings: { onChanged: () => () => {} }, updater: { @@ -4901,7 +4904,8 @@ describe('useIpcEvents agent status snapshot integration', () => { onCredentialRequest: () => () => {}, onCredentialResolved: () => () => {}, onPortForwardsChanged: () => () => {}, - onDetectedPortsChanged: () => () => {} + onDetectedPortsChanged: () => () => {}, + ...args.ssh }, agentStatus: { onSet: args.onSet, @@ -4947,11 +4951,321 @@ describe('useIpcEvents agent status snapshot integration', () => { vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() })) } + function buildSshAuthorityReconciliationHarness(args: { + partialAuthority: { providerEpoch?: string; connectionGeneration?: number } + latestAuthority: { providerEpoch: string; connectionGeneration: number } + }): { + emitPartialState: () => void + getState: ReturnType + requestReconnect: ReturnType + setSshConnectionState: ReturnType + storedState: () => Record | undefined + } { + const targetId = 'target-reconciliation' + const baseState = { + targetId, + status: 'connected' as const, + error: null, + reconnectAttempt: 0 + } + const partialState = { ...baseState, ...args.partialAuthority } + const latestState = { ...baseState, ...args.latestAuthority } + const sshConnectionStates = new Map>() + let sshStateListener: ((data: { targetId: string; state: unknown }) => void) | undefined + const getState = vi.fn(() => Promise.resolve(latestState)) + const requestReconnect = vi.fn(async () => ({ status: 'complete' })) + const setSshConnectionState = vi.fn((nextTargetId: string, state: Record) => { + sshConnectionStates.set(nextTargetId, state) + }) + const storeState = buildStoreState({ + sshTargetLabels: new Map([[targetId, 'Reconciliation Target']]), + sshConnectionStates, + setSshConnectionState, + invalidateStaleDirectSshTargetPtyBindings: vi.fn(() => 0), + retryDirectSshTargetPanes: vi.fn(() => 0), + setSshTargetsMetadata: vi.fn(), + setRemovedSshTargetLabels: vi.fn(), + setRemoteWorkspaceSyncStatus: vi.fn(), + clearRemoteDetectedAgents: vi.fn(), + clearDirectSshTargetPtyBindings: vi.fn(), + clearRemovedSshTargetState: vi.fn() + }) + const coordinator = { + requestReconnect, + replaceAuthority: vi.fn(), + prepareOnly: vi.fn(), + correctUnboundTerminals: vi.fn(() => 0), + finalizeHydratedTerminals: vi.fn(() => 0), + invalidate: vi.fn(), + stop: vi.fn() + } + + stubReactSyncEffect() + stubAuxiliaryModules() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./direct-ssh-reconnect-rollout', () => ({ + isDirectSshReconnectCoordinatorRoutingEnabled: () => true + })) + vi.doMock('./direct-ssh-worktree-refresh-scheduler', () => ({ + createDirectSshWorktreeRefreshScheduler: () => ({ + stop: vi.fn(), + disposeProvider: vi.fn() + }) + })) + vi.doMock('./direct-ssh-host-hydration', () => ({ + createDirectSshHostHydration: () => ({ + capturePreparationInput: vi.fn(), + readHostScopedLineage: vi.fn(), + isPreparationTokenCurrent: vi.fn(() => true), + stop: vi.fn() + }) + })) + vi.doMock('./direct-ssh-reconnect-coordinator', () => ({ + createDirectSshReconnectCoordinator: () => coordinator + })) + vi.doMock('@/lib/direct-ssh-reconnect-product-telemetry', () => ({ + createDirectSshReconnectProductTelemetryAdapter: vi.fn() + })) + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: () => () => {}, + ssh: { + getState, + onStateChanged: (listener: (data: { targetId: string; state: unknown }) => void) => { + sshStateListener = listener + return () => {} + } + } + }) + ) + + return { + emitPartialState: () => { + if (!sshStateListener) { + throw new Error('Expected SSH state listener') + } + sshStateListener({ targetId, state: partialState }) + }, + getState, + requestReconnect, + setSshConnectionState, + storedState: () => sshConnectionStates.get(targetId) + } + } + beforeEach(() => { vi.resetModules() vi.unstubAllGlobals() }) + it.each([ + { partialAuthority: { providerEpoch: 'epoch-current' } }, + { partialAuthority: { connectionGeneration: 7 } } + ])( + 'fills a same-watermark partial SSH authority and routes it once', + async ({ partialAuthority }) => { + const harness = buildSshAuthorityReconciliationHarness({ + partialAuthority, + latestAuthority: { + providerEpoch: 'epoch-current', + connectionGeneration: 7 + } + }) + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + harness.emitPartialState() + + await vi.waitFor(() => { + expect(harness.requestReconnect).toHaveBeenCalledOnce() + }) + expect(harness.getState).toHaveBeenCalledOnce() + expect(harness.setSshConnectionState).toHaveBeenCalledTimes(2) + expect(harness.storedState()).toEqual({ + targetId: 'target-reconciliation', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'epoch-current', + connectionGeneration: 7 + }) + } + ) + + it.each([ + { partialAuthority: { providerEpoch: 'epoch-conflict' } }, + { partialAuthority: { connectionGeneration: 6 } } + ])( + 'rejects a reconciliation reply that conflicts with present authority', + async ({ partialAuthority }) => { + const harness = buildSshAuthorityReconciliationHarness({ + partialAuthority, + latestAuthority: { + providerEpoch: 'epoch-current', + connectionGeneration: 7 + } + }) + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + harness.emitPartialState() + + await vi.waitFor(() => { + expect(harness.getState).toHaveBeenCalledOnce() + }) + await Promise.resolve() + expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.setSshConnectionState).toHaveBeenCalledOnce() + expect(harness.storedState()).toEqual( + expect.objectContaining({ + targetId: 'target-reconciliation', + ...partialAuthority + }) + ) + } + ) + + it.each([ + { enabled: true, expectedRoute: ['request'] }, + { + enabled: false, + expectedRoute: ['replace', 'invalidate', 'retry', 'capture', 'prepare'] + } + ])( + 'routes a changed direct SSH authority through the enabled=$enabled path', + async ({ enabled, expectedRoute }) => { + const order: string[] = [] + let sshStateListener: ((data: { targetId: string; state: unknown }) => void) | undefined + const oldState = { + targetId: 'target-a', + status: 'connected' as const, + error: null, + reconnectAttempt: 0, + providerEpoch: 'epoch-old', + connectionGeneration: 1 + } + const nextState = { + ...oldState, + providerEpoch: 'epoch-new', + connectionGeneration: 2 + } + const storeState = buildStoreState({ + sshTargetLabels: new Map([['target-a', 'Target A']]), + sshConnectionStates: new Map([['target-a', oldState]]), + setSshConnectionState: (targetId: string, state: unknown) => { + ;(storeState.sshConnectionStates as Map).set(targetId, state) + }, + invalidateStaleDirectSshTargetPtyBindings: () => { + order.push('invalidate') + return 1 + }, + retryDirectSshTargetPanes: () => { + order.push('retry') + return 1 + }, + setSshTargetsMetadata: vi.fn(), + setRemovedSshTargetLabels: vi.fn(), + setRemoteWorkspaceSyncStatus: vi.fn(), + clearRemoteDetectedAgents: vi.fn(), + clearDirectSshTargetPtyBindings: vi.fn(), + clearRemovedSshTargetState: vi.fn() + }) + const coordinator = { + requestReconnect: vi.fn(async () => { + order.push('request') + return { status: 'complete' } + }), + replaceAuthority: vi.fn(() => { + order.push('replace') + }), + prepareOnly: vi.fn(async () => { + order.push('prepare') + return { token: null } + }), + correctUnboundTerminals: vi.fn(() => 0), + finalizeHydratedTerminals: vi.fn(() => 0), + invalidate: vi.fn(), + stop: vi.fn() + } + const capturePreparationInput = vi.fn(async (authority, reason) => { + order.push('capture') + return { + ...authority, + reason, + catalogRevision: 1, + repoRefs: [], + authorityRequirement: 'required' + } + }) + + stubReactSyncEffect() + stubAuxiliaryModules() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./direct-ssh-reconnect-rollout', () => ({ + isDirectSshReconnectCoordinatorRoutingEnabled: () => enabled + })) + vi.doMock('./direct-ssh-worktree-refresh-scheduler', () => ({ + createDirectSshWorktreeRefreshScheduler: () => ({ + stop: vi.fn(), + disposeProvider: vi.fn() + }) + })) + vi.doMock('./direct-ssh-host-hydration', () => ({ + createDirectSshHostHydration: () => ({ + capturePreparationInput, + readHostScopedLineage: vi.fn(), + isPreparationTokenCurrent: vi.fn(() => true), + stop: vi.fn() + }) + })) + vi.doMock('./direct-ssh-reconnect-coordinator', () => ({ + createDirectSshReconnectCoordinator: () => coordinator + })) + vi.doMock('@/lib/direct-ssh-reconnect-product-telemetry', () => ({ + createDirectSshReconnectProductTelemetryAdapter: vi.fn() + })) + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: () => () => {}, + ssh: { + onStateChanged: (listener: (data: { targetId: string; state: unknown }) => void) => { + sshStateListener = listener + return () => {} + } + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + sshStateListener?.({ targetId: 'target-a', state: nextState }) + await Promise.resolve() + await Promise.resolve() + + expect(order).toEqual(expectedRoute) + if (enabled) { + expect(coordinator.requestReconnect).toHaveBeenCalledOnce() + expect(coordinator.prepareOnly).not.toHaveBeenCalled() + } else { + expect(coordinator.requestReconnect).not.toHaveBeenCalled() + expect(coordinator.replaceAuthority).toHaveBeenCalledOnce() + expect(coordinator.prepareOnly).toHaveBeenCalledOnce() + } + } + ) + it('caps pending mobile state events while startup hydration is unresolved', async () => { const setFitOverride = vi.fn() const hydrateOverrides = vi.fn() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index dea9f82c245..7d562f0fe91 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2,7 +2,6 @@ import { useEffect } from 'react' import { toast } from 'sonner' import { useAppStore } from '../store' -import { shouldRetryPaneSpawnOnSshReconnect } from './ssh-reconnect-pane-retry' import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration' import { applyWorktreeHeadIdentities } from './worktree-head-identity-apply' import { getWorktreeMapFromState, getRepoMapFromState } from '@/store/selectors' @@ -27,22 +26,17 @@ import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-fon import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, - UpdateStatus, - WorkspaceSessionState + UpdateStatus } from '../../../shared/types' -import type { - RemoteWorkspacePatchResult, - RemoteWorkspaceSnapshot -} from '../../../shared/remote-workspace-types' import type { RateLimitState } from '../../../shared/rate-limit-types' -import type { SshConnectionState } from '../../../shared/ssh-types' +import type { DirectSshAuthority, SshConnectionState } from '../../../shared/ssh-types' +import { toSshExecutionHostId } from '../../../shared/execution-host' import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract' import type { RuntimeBrowserDriverState, RuntimeTerminalPresentation, RuntimeTerminalDriverState } from '../../../shared/runtime-types' -import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection' import { zoomLevelToPercent } from '@/components/settings/SettingsConstants' import { stepUIZoomLevel } from '../../../shared/ui-zoom-level' import { dispatchZoomLevelChanged } from '@/lib/zoom-events' @@ -143,6 +137,27 @@ import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { getConnectionIdFromState } from '@/lib/connection-context' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { acquireDirectSshDetectedWorktreeRefresh } from '@/store/slices/worktrees' +import { createDirectSshWorktreeRefreshScheduler } from './direct-ssh-worktree-refresh-scheduler' +import { + createDirectSshReconnectCoordinator, + type DirectSshPreparationInput, + type DirectSshPreparationReason +} from './direct-ssh-reconnect-coordinator' +import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' +import { createDirectSshHostHydration } from './direct-ssh-host-hydration' +import { createDirectSshReconnectProductTelemetryAdapter } from '@/lib/direct-ssh-reconnect-product-telemetry' +import { + createRemoteWorkspaceTargetSync, + isDirectSshRemoteWorkspaceApplyInProgress, + type RemoteWorkspaceTargetSync +} from './remote-workspace-target-sync' +import { + registerDirectSshWakeRouting, + routeDirectSshConnectedState, + type DirectSshConnectedStateOrigin +} from './direct-ssh-state-routing' +import { isDirectSshReconnectCoordinatorRoutingEnabled } from './direct-ssh-reconnect-rollout' function getShortcutPlatform(): NodeJS.Platform { if (navigator.userAgent.includes('Mac')) { @@ -254,9 +269,6 @@ const MAX_PENDING_MOBILE_STATE_EVENTS = 300 // Why: a rename's event burst lags the on-disk move; shield both ids from the deletion diff for a grace window. const WORKTREE_RENAME_PURGE_GRACE_MS = 20_000 const recentlyRenamedWorktreeIdExpiry = new Map() -let remoteWorkspaceSnapshotApplyDepth = 0 -let remoteWorkspaceSnapshotWriteSuppressUntil = 0 -const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1000 function isAgentStatusForRecentlyClosedTab( store: Pick, @@ -415,262 +427,7 @@ function activateExistingLeafInLayout( } export function isRemoteWorkspaceSnapshotApplyInProgress(): boolean { - return ( - remoteWorkspaceSnapshotApplyDepth > 0 || Date.now() < remoteWorkspaceSnapshotWriteSuppressUntil - ) -} - -async function waitForWorkspaceSessionReady(): Promise { - for (let attempt = 0; attempt < 100; attempt += 1) { - if (useAppStore.getState().workspaceSessionReady) { - return true - } - await new Promise((resolve) => window.setTimeout(resolve, 100)) - } - return useAppStore.getState().workspaceSessionReady -} - -async function prepareRemoteWorkspaceTarget(targetId: string): Promise { - if (!(await waitForWorkspaceSessionReady())) { - return false - } - const store = useAppStore.getState() - let repos = store.repos.filter((repo) => repo.connectionId === targetId) - if (repos.length === 0) { - await store.fetchRepos() - repos = useAppStore.getState().repos.filter((repo) => repo.connectionId === targetId) - } - await Promise.all(repos.map((repo) => useAppStore.getState().fetchWorktrees(repo.id))) - await useAppStore.getState().fetchWorktreeLineage() - return true -} - -function targetRepoIds(targetId: string): Set { - return new Set( - useAppStore - .getState() - .repos.filter((repo) => repo.connectionId === targetId) - .map((repo) => repo.id) - ) -} - -function targetWorktreeIds(targetId: string): Set { - const repoIds = targetRepoIds(targetId) - return new Set( - Object.values(useAppStore.getState().worktreesByRepo) - .flat() - .filter((worktree) => repoIds.has(worktree.repoId)) - .map((worktree) => worktree.id) - ) -} - -function mergeRemoteWorkspaceSession( - current: WorkspaceSessionState, - remote: WorkspaceSessionState, - targetId: string -): WorkspaceSessionState { - const replaceWorktreeIds = targetWorktreeIds(targetId) - const remoteTabIds = new Set( - Object.values(remote.tabsByWorktree) - .flat() - .map((tab) => tab.id) - ) - const replacedTabIds = new Set([ - ...remoteTabIds, - ...Object.entries(current.tabsByWorktree) - .filter(([worktreeId]) => replaceWorktreeIds.has(worktreeId)) - .flatMap(([, tabs]) => tabs.map((tab) => tab.id)) - ]) - const omitTargetWorktrees = (record: Record | undefined): Record => - Object.fromEntries( - Object.entries(record ?? {}).filter(([worktreeId]) => !replaceWorktreeIds.has(worktreeId)) - ) - - return { - ...current, - activeRepoId: - remote.activeRepoId ?? - (current.activeWorktreeId && replaceWorktreeIds.has(current.activeWorktreeId) - ? null - : current.activeRepoId), - activeWorktreeId: - remote.activeWorktreeId ?? - (current.activeWorktreeId && replaceWorktreeIds.has(current.activeWorktreeId) - ? null - : current.activeWorktreeId), - activeTabId: - remote.activeTabId ?? - (current.activeTabId && replacedTabIds.has(current.activeTabId) ? null : current.activeTabId), - tabsByWorktree: { - ...omitTargetWorktrees(current.tabsByWorktree), - ...remote.tabsByWorktree - }, - terminalLayoutsByTabId: { - ...Object.fromEntries( - Object.entries(current.terminalLayoutsByTabId).filter( - ([tabId]) => !replacedTabIds.has(tabId) - ) - ), - ...remote.terminalLayoutsByTabId - }, - activeWorktreeIdsOnShutdown: [ - ...(current.activeWorktreeIdsOnShutdown ?? []).filter((id) => !replaceWorktreeIds.has(id)), - ...(remote.activeWorktreeIdsOnShutdown ?? []) - ], - activeTabIdByWorktree: { - ...omitTargetWorktrees(current.activeTabIdByWorktree), - ...remote.activeTabIdByWorktree - }, - remoteSessionIdsByTabId: { - ...Object.fromEntries( - Object.entries(current.remoteSessionIdsByTabId ?? {}).filter( - ([tabId]) => !replacedTabIds.has(tabId) - ) - ), - ...remote.remoteSessionIdsByTabId - }, - lastVisitedAtByWorktreeId: { - ...omitTargetWorktrees(current.lastVisitedAtByWorktreeId), - ...remote.lastVisitedAtByWorktreeId - } - } -} - -async function applyRemoteWorkspaceSnapshot( - targetId: string, - snapshot: RemoteWorkspaceSnapshot -): Promise { - if (!(await prepareRemoteWorkspaceTarget(targetId))) { - throw new Error('Workspace sync waited for local session hydration and timed out') - } - const worktreeIds = targetWorktreeIds(targetId) - const localByPath = new Map( - Array.from(worktreeIds).map((worktreeId) => { - const separator = worktreeId.indexOf('::') - return [separator === -1 ? worktreeId : worktreeId.slice(separator + 2), worktreeId] as const - }) - ) - const remoteSession = importRemoteWorkspaceSession(snapshot.session, { - resolveWorktreeId: (worktreePath) => localByPath.get(worktreePath) ?? null - }) - const current = buildWorkspaceSessionPayload(useAppStore.getState()) - const merged = mergeRemoteWorkspaceSession(current, remoteSession, targetId) - const store = useAppStore.getState() - remoteWorkspaceSnapshotApplyDepth += 1 - try { - store.hydrateWorkspaceSession(merged) - store.hydrateTabsSession(merged) - store.hydrateEditorSession(merged) - store.hydrateBrowserSession(merged) - store.markRemoteWorkspaceHydrated(targetId) - store.setRemoteWorkspaceSyncStatus(targetId, { - phase: 'synced', - direction: 'pull', - revision: snapshot.revision, - updatedAt: snapshot.updatedAt, - lastSyncedAt: Date.now(), - message: translate('auto.hooks.useIpcEvents.4f78ba5885', 'Workspace synced') - }) - await useAppStore.getState().reconnectPersistedTerminals() - } finally { - // Why: reattach updates pty ids/titles after hydration; they came from the snapshot, don't echo back as a new revision. - remoteWorkspaceSnapshotWriteSuppressUntil = - Date.now() + REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS - remoteWorkspaceSnapshotApplyDepth -= 1 - } -} - -async function syncRemoteWorkspaceAfterConnect(targetId: string): Promise { - const store = useAppStore.getState() - if (!(await prepareRemoteWorkspaceTarget(targetId))) { - store.setRemoteWorkspaceSyncStatus(targetId, { - phase: 'error', - direction: 'pull', - message: translate( - 'auto.hooks.useIpcEvents.88214a785b', - 'Workspace sync waited for local session hydration and timed out' - ) - }) - return - } - store.setRemoteWorkspaceSyncStatus(targetId, { phase: 'pulling', direction: 'pull' }) - const worktreeIds = targetWorktreeIds(targetId) - const hasLocalTabs = Array.from(worktreeIds).some( - (worktreeId) => (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).length > 0 - ) - const snapshot = await window.api.remoteWorkspace.get({ targetId }) - if (!snapshot) { - useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { - phase: 'offline', - direction: 'pull', - message: translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable') - }) - return - } - if (snapshot.revision > 0) { - await applyRemoteWorkspaceSnapshot(targetId, snapshot) - return - } - - useAppStore.getState().markRemoteWorkspaceHydrated(targetId) - if (hasLocalTabs) { - // Why: read the relay before publishing local tabs, or a reconnect can overwrite a newer snapshot with stale local state. - const session = buildWorkspaceSessionPayload(useAppStore.getState()) - const results = await window.api.remoteWorkspace.setForConnectedTargets({ - session, - hydratedTargetIds: [targetId] - }) - const result = results.find((entry) => entry.targetId === targetId)?.result - applyRemoteWorkspacePatchStatus(targetId, result) - if (result?.ok) { - useAppStore.getState().markRemoteWorkspaceHydrated(targetId) - } - return - } - useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { - phase: 'idle', - revision: snapshot.revision, - updatedAt: snapshot.updatedAt, - message: translate('auto.hooks.useIpcEvents.2ec42e1c52', 'No remote workspace yet') - }) -} - -function applyRemoteWorkspacePatchStatus( - targetId: string, - result: RemoteWorkspacePatchResult | undefined -): void { - if (!result) { - useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { - phase: 'offline', - direction: 'push', - lastSyncedAt: Date.now(), - message: translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable') - }) - return - } - if (result.ok) { - useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { - phase: 'synced', - direction: 'push', - revision: result.snapshot.revision, - updatedAt: result.snapshot.updatedAt, - lastSyncedAt: Date.now(), - message: translate('auto.hooks.useIpcEvents.f8aaf2bde3', 'Workspace uploaded') - }) - return - } - useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { - phase: result.reason === 'stale-revision' ? 'conflict' : 'offline', - direction: 'push', - revision: result.snapshot?.revision, - updatedAt: result.snapshot?.updatedAt, - lastSyncedAt: Date.now(), - message: - result.message ?? - (result.reason === 'stale-revision' - ? 'Workspace changed on another device' - : 'Remote workspace sync unavailable') - }) + return isDirectSshRemoteWorkspaceApplyInProgress() } type BrowserSessionTabTarget = @@ -850,6 +607,149 @@ function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): export function useIpcEvents(): void { useEffect(() => { const unsubs: (() => void)[] = [] + const reconnectAuthorityByTarget = new Map() + const authorityReconciliationDeadlines = new Set<{ + timer: ReturnType + settle: () => void + }>() + let directSshEffectStopped = false + const currentDirectSshAuthority = (targetId: string): DirectSshAuthority | null => { + const state = useAppStore.getState().sshConnectionStates?.get(targetId) + if ( + state?.status !== 'connected' || + state.targetId !== targetId || + !state.providerEpoch || + state.connectionGeneration === undefined + ) { + return null + } + return { + targetId, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } + } + const scheduler = createDirectSshWorktreeRefreshScheduler({ + startAttempt: (key) => { + const acquired = acquireDirectSshDetectedWorktreeRefresh(useAppStore, { + repoId: key.repoId, + executionHostId: key.executionHostId, + authority: { + targetId: key.targetId, + providerEpoch: key.providerEpoch, + connectionGeneration: key.connectionGeneration + }, + requireAuthoritative: key.authorityRequirement === 'required' + }) + return { + providerRequestId: acquired.providerRequestId, + result: acquired.result.then((result) => acquired.merge(result)), + cancel: acquired.release + } + } + }) + const hostHydration = createDirectSshHostHydration({ + store: useAppStore, + isCurrentAuthority: (authority) => + directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority), + listRepos: (authority) => { + const executionHostId = toSshExecutionHostId(authority.targetId) + return ( + window.api.repos.listForExecutionHost?.({ + executionHostId, + expectedAuthority: authority + }) ?? + Promise.resolve({ + authoritative: false, + executionHostId, + reason: 'unavailable' as const + }) + ) + }, + listLineage: (authority) => { + const executionHostId = toSshExecutionHostId(authority.targetId) + return ( + window.api.worktrees.listLineageForHost?.({ + executionHostId, + expectedAuthority: authority + }) ?? + Promise.resolve({ + authoritative: false, + executionHostId, + reason: 'unavailable' as const + }) + ) + } + }) + type DirectSshTerminalActions = Partial< + Pick + > + const directSshTerminalActions = (): DirectSshTerminalActions => + useAppStore.getState() as DirectSshTerminalActions + let remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null = null + const reconnectCoordinator = createDirectSshReconnectCoordinator({ + scheduler, + isCurrentConnectedAuthority: (authority) => + directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority), + capturePreparationInput: hostHydration.capturePreparationInput, + readHostScopedLineage: hostHydration.readHostScopedLineage, + invalidateStaleTerminalBindings: (authority) => + directSshTerminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) ?? 0, + retryTargetPanes: (authority) => + directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + finalizeHydratedTerminalPanes: (authority) => + directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + correctUnboundTerminalPanes: (authority) => + directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + syncRemoteWorkspaceAfterConnect: (token) => + remoteWorkspaceTargetSync?.syncAfterConnect(token), + onTelemetry: createDirectSshReconnectProductTelemetryAdapter() + }) + const remoteWorkspaceApi = window.api.remoteWorkspace + if (remoteWorkspaceApi) { + remoteWorkspaceTargetSync = createRemoteWorkspaceTargetSync({ + store: useAppStore, + remoteWorkspace: remoteWorkspaceApi, + getCurrentAuthority: currentDirectSshAuthority, + isPreparationTokenCurrent: hostHydration.isPreparationTokenCurrent, + capturePreparationInput: (authority, reason, snapshotRevision) => + hostHydration.capturePreparationInput(authority, reason, snapshotRevision), + prepareOnly: reconnectCoordinator.prepareOnly, + finalizeHydratedTerminals: (authority) => + directSshAuthoritiesEqual(reconnectAuthorityByTarget.get(authority.targetId), authority) + ? reconnectCoordinator.finalizeHydratedTerminals(authority) + : 0 + }) + } + const prepareAndSyncDirectSshTarget = async ( + authority: DirectSshAuthority, + reason: DirectSshPreparationReason, + options?: { authorityAlreadyReplaced?: boolean } + ): Promise => { + try { + if (!options?.authorityAlreadyReplaced) { + reconnectCoordinator.replaceAuthority(authority) + } + const input: DirectSshPreparationInput | null = await hostHydration.capturePreparationInput( + authority, + reason + ) + if (!input) { + return + } + const prepared = await reconnectCoordinator.prepareOnly(input) + if (prepared.token && hostHydration.isPreparationTokenCurrent(prepared.token)) { + await remoteWorkspaceTargetSync?.syncAfterConnect(prepared.token) + } + } catch (error) { + if (directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority)) { + useAppStore.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'error', + message: error instanceof Error ? error.message : 'Workspace sync failed' + }) + } + } + } const backgroundSleepingAgentWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher() unsubs.push(backgroundSleepingAgentWakeDispatcher.dispose) type PendingAgentStatusEvent = { @@ -1091,40 +991,38 @@ export function useIpcEvents(): void { let reachableRuntimeEnvironmentKey = buildRuntimeClientEventEnvironmentKey( reachableRuntimeEnvironmentIds ) - unsubs.push( - useAppStore.subscribe(() => { - const nextEnvironmentIds = getRuntimeClientEventEnvironmentIds() - const nextKey = buildRuntimeClientEventEnvironmentKey(nextEnvironmentIds) - const nextReachableEnvironmentIds = getReachableRuntimeEnvironmentIds() - const nextReachableKey = buildRuntimeClientEventEnvironmentKey(nextReachableEnvironmentIds) - if ( - nextKey === runtimeClientEventEnvironmentKey && - nextReachableKey === reachableRuntimeEnvironmentKey - ) { - return - } - for (const environmentId of getRuntimeProjectRefreshEnvironmentIds({ - previousDesired: runtimeClientEventEnvironmentIds, - nextDesired: nextEnvironmentIds, - previousReachable: reachableRuntimeEnvironmentIds, - nextReachable: nextReachableEnvironmentIds - })) { - runtimeProjectRefreshScheduler.request(environmentId) - } - for (const environmentId of getNewlyDisconnectedRuntimeEnvironmentIds( - reachableRuntimeEnvironmentIds, - nextReachableEnvironmentIds - )) { - // No-op when the environment has no SSH bucket (e.g. web client). - useAppStore.getState().markEnvironmentSshStateStale(environmentId) - } - runtimeClientEventEnvironmentIds = nextEnvironmentIds - runtimeClientEventEnvironmentKey = nextKey - reachableRuntimeEnvironmentIds = nextReachableEnvironmentIds - reachableRuntimeEnvironmentKey = nextReachableKey - runtimeClientEventsSync.sync() - }) - ) + const unsubscribeRuntimeEnvironmentStore = useAppStore.subscribe(() => { + const nextEnvironmentIds = getRuntimeClientEventEnvironmentIds() + const nextKey = buildRuntimeClientEventEnvironmentKey(nextEnvironmentIds) + const nextReachableEnvironmentIds = getReachableRuntimeEnvironmentIds() + const nextReachableKey = buildRuntimeClientEventEnvironmentKey(nextReachableEnvironmentIds) + if ( + nextKey === runtimeClientEventEnvironmentKey && + nextReachableKey === reachableRuntimeEnvironmentKey + ) { + return + } + for (const environmentId of getRuntimeProjectRefreshEnvironmentIds({ + previousDesired: runtimeClientEventEnvironmentIds, + nextDesired: nextEnvironmentIds, + previousReachable: reachableRuntimeEnvironmentIds, + nextReachable: nextReachableEnvironmentIds + })) { + runtimeProjectRefreshScheduler.request(environmentId) + } + for (const environmentId of getNewlyDisconnectedRuntimeEnvironmentIds( + reachableRuntimeEnvironmentIds, + nextReachableEnvironmentIds + )) { + // No-op when the environment has no SSH bucket (e.g. web client). + useAppStore.getState().markEnvironmentSshStateStale(environmentId) + } + runtimeClientEventEnvironmentIds = nextEnvironmentIds + runtimeClientEventEnvironmentKey = nextKey + reachableRuntimeEnvironmentIds = nextReachableEnvironmentIds + reachableRuntimeEnvironmentKey = nextReachableKey + runtimeClientEventsSync.sync() + }) unsubs.push(runtimeClientEventsSync.stop) unsubs.push(runtimeProjectRefreshScheduler.stop) @@ -2664,40 +2562,60 @@ export function useIpcEvents(): void { unsubs.push(unsubscribeWorkspaceSpaceProgress) } + const sshStateWatermarkByTargetId = new Map() + let applySshConnectionStateChange!: ( + targetId: string, + state: SshConnectionState, + origin: DirectSshConnectedStateOrigin + ) => void + // Why: hydrate initial SSH state for all targets so worktree cards show correct connect state on launch. void (async () => { try { const targets = await window.api.ssh.listTargets() + if (directSshEffectStopped) { + return + } useAppStore.getState().setSshTargetsMetadata(targets) // Why: ghost-host UI (removed target still referenced by a workspace) shows a tombstone name instead of the raw id. try { const removedLabels = await window.api.ssh.listRemovedTargetLabels() + if (directSshEffectStopped) { + return + } useAppStore.getState().setRemovedSshTargetLabels(removedLabels) } catch { // Best-effort — a missing map just falls back to the raw target id. } for (const target of targets) { + const hydrationWatermark = sshStateWatermarkByTargetId.get(target.id) ?? 0 const state = await window.api.ssh.getState({ targetId: target.id }) - if (state) { - useAppStore.getState().setSshConnectionState(target.id, state as SshConnectionState) + if ( + !directSshEffectStopped && + state && + (sshStateWatermarkByTargetId.get(target.id) ?? 0) === hydrationWatermark + ) { + applySshConnectionStateChange( + target.id, + state as SshConnectionState, + 'initial-hydration' + ) // Why: ports arrive only via push events; on reattach to a live session fetch snapshots or the Ports panel shows empty. if ((state as SshConnectionState).status === 'connected') { + const authority = currentDirectSshAuthority(target.id) const [forwards, detected] = await Promise.all([ window.api.ssh.listPortForwards({ targetId: target.id }), window.api.ssh.listDetectedPorts({ targetId: target.id }) ]) // Why: if the session disconnected while awaiting the snapshot, applying it would resurrect a dead session's ports. - const currentState = useAppStore.getState().sshConnectionStates.get(target.id) - if (currentState?.status === 'connected') { + if ( + !directSshEffectStopped && + authority && + directSshAuthoritiesEqual(currentDirectSshAuthority(target.id), authority) + ) { useAppStore.getState().setPortForwards(target.id, forwards) useAppStore.getState().setDetectedPorts(target.id, detected) } - void syncRemoteWorkspaceAfterConnect(target.id).catch((err) => { - useAppStore.getState().setRemoteWorkspaceSyncStatus(target.id, { - phase: 'error', - message: err instanceof Error ? err.message : 'Workspace sync failed' - }) - }) } } } @@ -2730,12 +2648,75 @@ export function useIpcEvents(): void { }) ) - const applySshConnectionStateChange = (targetId: string, state: SshConnectionState): void => { + const reconcileSshAuthority = ( + targetId: string, + initiatingState: SshConnectionState, + origin: DirectSshConnectedStateOrigin, + watermark: number + ): void => { + let pendingDeadline: { timer: ReturnType; settle: () => void } | undefined + const deadline = new Promise((resolve) => { + const settle = (): void => resolve(null) + const timer = setTimeout(settle, 5_000) + pendingDeadline = { timer, settle } + authorityReconciliationDeadlines.add(pendingDeadline) + }) + void Promise.race([window.api.ssh.getState({ targetId }).catch(() => null), deadline]) + .then((latest) => { + if ( + directSshEffectStopped || + latest?.targetId !== targetId || + !latest?.providerEpoch || + latest.connectionGeneration === undefined || + sshStateWatermarkByTargetId.get(targetId) !== watermark + ) { + return + } + const current = useAppStore.getState().sshConnectionStates?.get(targetId) + if ( + current?.status !== initiatingState.status || + latest.status !== initiatingState.status || + current.providerEpoch !== initiatingState.providerEpoch || + current.connectionGeneration !== initiatingState.connectionGeneration || + (current.providerEpoch !== undefined && + current.providerEpoch !== null && + current.providerEpoch !== latest.providerEpoch) || + (current.connectionGeneration !== undefined && + current.connectionGeneration !== latest.connectionGeneration) + ) { + return + } + applySshConnectionStateChange( + targetId, + { + ...current, + providerEpoch: latest.providerEpoch, + connectionGeneration: latest.connectionGeneration + }, + origin + ) + }) + .catch(() => undefined) + .finally(() => { + if (pendingDeadline) { + clearTimeout(pendingDeadline.timer) + authorityReconciliationDeadlines.delete(pendingDeadline) + } + }) + } + + applySshConnectionStateChange = ( + targetId: string, + state: SshConnectionState, + origin: DirectSshConnectedStateOrigin + ): void => { const store = useAppStore.getState() + const previous = store.sshConnectionStates?.get(targetId) store.setSshConnectionState(targetId, state) - const remoteRepos = store.repos.filter((r) => r.connectionId === targetId) if (['disconnected', 'auth-failed', 'reconnection-failed', 'error'].includes(state.status)) { + reconnectAuthorityByTarget.delete(targetId) + reconnectCoordinator.invalidate(targetId) // Why: remote agent list is tied to a live relay; clear on disconnect so reconnect re-detects against the new relay. store.clearRemoteDetectedAgents(targetId) @@ -2743,62 +2724,54 @@ export function useIpcEvents(): void { store.clearPortForwards(targetId) store.setDetectedPorts(targetId, []) - // Why: SSH teardown fires no per-PTY exit events; clear stale PTY ids so reconnect remounts rather than reattach a dead PTY. - const remoteWorktreeIds = new Set( - Object.values(store.worktreesByRepo) - .flat() - .filter((w) => remoteRepos.some((r) => r.id === w.repoId)) - .map((w) => w.id) + // SSH teardown has no per-PTY exits; clear only exact-target bindings in one store publication. + store.clearDirectSshTargetPtyBindings(targetId) + return + } + + if (state.status !== 'connected') { + return + } + const authority = currentDirectSshAuthority(targetId) + if (!authority) { + reconcileSshAuthority( + targetId, + state, + origin, + sshStateWatermarkByTargetId.get(targetId) ?? 0 ) - for (const worktreeId of remoteWorktreeIds) { - const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? [] - for (const tab of tabs) { - if (tab.ptyId) { - useAppStore.getState().clearTabPtyId(tab.id) + return + } + const previousAuthority = + previous?.status === 'connected' && + previous.providerEpoch && + previous.connectionGeneration !== undefined + ? { + targetId, + providerEpoch: previous.providerEpoch, + connectionGeneration: previous.connectionGeneration + } + : null + routeDirectSshConnectedState( + { + coordinator: reconnectCoordinator, + coordinatorRoutingEnabled: isDirectSshReconnectCoordinatorRoutingEnabled(), + invalidateStaleTerminalBindings: (nextAuthority) => + directSshTerminalActions().invalidateStaleDirectSshTargetPtyBindings?.(nextAuthority) ?? + 0, + retryTargetPanes: (nextAuthority) => + directSshTerminalActions().retryDirectSshTargetPanes?.(nextAuthority) ?? 0, + prepareAndSync: prepareAndSyncDirectSshTarget, + rememberReconnectAuthority: (nextAuthority) => { + if (nextAuthority) { + reconnectAuthorityByTarget.set(targetId, nextAuthority) + } else { + reconnectAuthorityByTarget.delete(targetId) } } - } - } - - if (state.status === 'connected') { - void Promise.all(remoteRepos.map((r) => store.fetchWorktrees(r.id))).then(async () => { - await useAppStore.getState().fetchWorktreeLineage() - // Why: panes that never spawned (no PTY provider at cold start) or whose deferred reattach never ran sit inert. - // Bumping generation remounts TerminalPane so the deferred-connect gate reattaches or spawns fresh now that the provider exists. - const freshStore = useAppStore.getState() - const remoteRepoIds = new Set(remoteRepos.map((r) => r.id)) - const worktreeIds = Object.values(freshStore.worktreesByRepo) - .flat() - .filter((w) => remoteRepoIds.has(w.repoId)) - .map((w) => w.id) - - for (const worktreeId of worktreeIds) { - const tabs = freshStore.tabsByWorktree[worktreeId] ?? [] - const needsRetry = (t: { id: string; ptyId?: string | null }): boolean => - shouldRetryPaneSpawnOnSshReconnect({ - targetId, - tabPtyId: t.ptyId, - deferredSessionId: freshStore.deferredSshSessionIdsByTabId[t.id] - }) - if (tabs.some(needsRetry)) { - useAppStore.setState((s) => ({ - tabsByWorktree: { - ...s.tabsByWorktree, - [worktreeId]: (s.tabsByWorktree[worktreeId] ?? []).map((t) => - needsRetry(t) ? { ...t, generation: (t.generation ?? 0) + 1 } : t - ) - } - })) - } - } - void syncRemoteWorkspaceAfterConnect(targetId).catch((err) => { - useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { - phase: 'error', - message: err instanceof Error ? err.message : 'Workspace sync failed' - }) - }) - }) - } + }, + { authority, previousAuthority, origin } + ) } let sshTargetStateEventId = 0 @@ -2808,6 +2781,10 @@ export function useIpcEvents(): void { const store = useAppStore.getState() const state = data.state as SshConnectionState const stateEventId = ++sshTargetStateEventId + sshStateWatermarkByTargetId.set( + data.targetId, + (sshStateWatermarkByTargetId.get(data.targetId) ?? 0) + 1 + ) latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId) if (!store.sshTargetLabels.has(data.targetId)) { // Why: unknown target id could be a post-boot add or a removed target racing disconnect; confirm with main first. @@ -2820,6 +2797,9 @@ export function useIpcEvents(): void { return } latestSshTargetStateEventByTargetId.delete(data.targetId) + if (directSshEffectStopped) { + return + } const latestStore = useAppStore.getState() if (!targets.some((target) => target.id === data.targetId)) { // Why: state events can race after target removal; absence from main's target list means deletion, not a new target. @@ -2827,22 +2807,37 @@ export function useIpcEvents(): void { return } latestStore.setSshTargetsMetadata(targets) - applySshConnectionStateChange(data.targetId, state) + applySshConnectionStateChange(data.targetId, state, 'push') }) .catch(() => { - if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) { + if ( + !directSshEffectStopped && + latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId + ) { latestSshTargetStateEventByTargetId.delete(data.targetId) - applySshConnectionStateChange(data.targetId, state) + applySshConnectionStateChange(data.targetId, state, 'push') } }) return } latestSshTargetStateEventByTargetId.delete(data.targetId) - applySshConnectionStateChange(data.targetId, state) + applySshConnectionStateChange(data.targetId, state, 'push') } unsubs.push(window.api.ssh.onStateChanged(handleSshStateChangedEvent)) + unsubs.push( + registerDirectSshWakeRouting({ + getConnectionStates: () => useAppStore.getState().sshConnectionStates ?? [], + wakeAuthority: (authority) => { + reconnectCoordinator.correctUnboundTerminals(authority, 'wake-refresh') + void prepareAndSyncDirectSshTarget(authority, 'wake-refresh') + }, + ...(typeof window.api.ui.onSystemResumed === 'function' + ? { onSystemResumed: (callback: () => void) => window.api.ui.onSystemResumed(callback) } + : {}) + }) + ) let remoteWorkspaceClientId: string | null = null let remoteWorkspaceClientIdPromise: Promise | null = null @@ -2873,13 +2868,15 @@ export function useIpcEvents(): void { if (event.sourceClientId && clientId && event.sourceClientId === clientId) { return } - await applyRemoteWorkspaceSnapshot(event.targetId, event.snapshot).catch((err) => { - useAppStore.getState().setRemoteWorkspaceSyncStatus(event.targetId, { - phase: 'error', - revision: event.snapshot.revision, - message: err instanceof Error ? err.message : 'Failed to apply remote workspace' + await remoteWorkspaceTargetSync + ?.applyUnsolicitedSnapshot(event.targetId, event.snapshot) + .catch((err) => { + useAppStore.getState().setRemoteWorkspaceSyncStatus(event.targetId, { + phase: 'error', + revision: event.snapshot.revision, + message: err instanceof Error ? err.message : 'Failed to apply remote workspace' + }) }) - }) })() }) ) @@ -3318,13 +3315,11 @@ export function useIpcEvents(): void { // Why: main hook server is the durable source of truth; pull the snapshot only after tabs are ready so early startup pushes can be ignored, not buffered. requestAgentStatusSnapshotIfReady() - unsubs.push( - useAppStore.subscribe((state, previousState) => { - requestAgentStatusSnapshotIfReady() - flushPendingAgentStatuses() - syncAgentHookCompletionNotificationsForStoreUpdate(state, previousState) - }) - ) + const unsubscribeAgentStatusStore = useAppStore.subscribe((state, previousState) => { + requestAgentStatusSnapshotIfReady() + flushPendingAgentStatuses() + syncAgentHookCompletionNotificationsForStoreUpdate(state, previousState) + }) let mobileStateHydrated = isRuntimeEnvironmentActive() type PendingMobileStateEvent = @@ -3452,7 +3447,19 @@ export function useIpcEvents(): void { pendingAgentStatusEvents.length = 0 mobileStateHydrationDisposed = true pendingMobileStateEvents.length = 0 + unsubscribeRuntimeEnvironmentStore() + unsubscribeAgentStatusStore() unsubs.forEach((fn) => fn()) + directSshEffectStopped = true + for (const deadline of authorityReconciliationDeadlines) { + clearTimeout(deadline.timer) + deadline.settle() + } + authorityReconciliationDeadlines.clear() + remoteWorkspaceTargetSync?.stop() + hostHydration.stop() + reconnectCoordinator.stop() + reconnectAuthorityByTarget.clear() resetAgentHookCompletionNotificationCoordinators() } }, []) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d47182d69ba..fad974e1f92 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -702,6 +702,7 @@ "60428567b4": "Local terminal reveal is unavailable while a remote runtime is active", "f8aaf2bde3": "Workspace uploaded", "2fe88c2e06": "Remote workspace sync unavailable", + "workspaceChangedOnAnotherDevice": "Workspace changed on another device", "2ec42e1c52": "No remote workspace yet", "88214a785b": "Workspace sync waited for local session hydration and timed out", "4f78ba5885": "Workspace synced", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 498d18c4fde..57c23dd752b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -679,6 +679,7 @@ "60428567b4": "Mostrar terminales locales no está disponible mientras haya un host remoto activo", "f8aaf2bde3": "Espacio de trabajo subido", "2fe88c2e06": "La sincronización del espacio de trabajo remoto no está disponible", + "workspaceChangedOnAnotherDevice": "El espacio de trabajo cambió en otro dispositivo", "2ec42e1c52": "Aún no hay espacio de trabajo remoto", "88214a785b": "La sincronización del espacio de trabajo esperó a que la sesión local terminara de cargarse y agotó el tiempo de espera", "4f78ba5885": "Espacio de trabajo sincronizado", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a0479c2e210..c26379e4087 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -679,6 +679,7 @@ "60428567b4": "リモート ランタイムがアクティブな間はローカル terminal の公開は利用できません", "f8aaf2bde3": "ワークスペースがアップロードされました", "2fe88c2e06": "リモートワークスペースの同期は利用できません", + "workspaceChangedOnAnotherDevice": "ワークスペースが別のデバイスで変更されました", "2ec42e1c52": "リモートワークスペースはまだありません", "88214a785b": "ワークスペースの同期はローカル セッションのハイドレーションを待機し、タイムアウトになりました", "4f78ba5885": "ワークスペースが同期されました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a7cc5e8a093..02fd4cabb07 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -679,6 +679,7 @@ "60428567b4": "원격 런타임이 활성화된 동안에는 로컬 terminal 표시를 사용할 수 없습니다.", "f8aaf2bde3": "워크스페이스가 업로드됨", "2fe88c2e06": "원격 워크스페이스 동기화를 사용할 수 없습니다.", + "workspaceChangedOnAnotherDevice": "다른 기기에서 워크스페이스가 변경되었습니다.", "2ec42e1c52": "아직 원격 워크스페이스가 없습니다.", "88214a785b": "워크스페이스 동기화가 로컬 세션 하이드레이션을 기다렸다가 시간 초과되었습니다.", "4f78ba5885": "워크스페이스가 동기화되었습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0d8bb4b00f2..2b11c4ec972 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -679,6 +679,7 @@ "60428567b4": "当远程运行时处于活动状态时,本地终端显示不可用", "f8aaf2bde3": "工作区已上传", "2fe88c2e06": "远程工作区同步不可用", + "workspaceChangedOnAnotherDevice": "工作区已在另一台设备上更改", "2ec42e1c52": "还没有远程工作区", "88214a785b": "工作区同步等待本地会话恢复(hydration)并超时", "4f78ba5885": "工作区已同步", diff --git a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry-types.ts b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry-types.ts new file mode 100644 index 00000000000..49dcf0e80cb --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry-types.ts @@ -0,0 +1,8 @@ +import type { EventProps } from '../../../shared/telemetry-events' + +export type DirectSshReconnectProductProps = EventProps<'direct_ssh_reconnect_operation'> + +export type DirectSshReconnectProductSink = ( + name: 'direct_ssh_reconnect_operation', + props: DirectSshReconnectProductProps +) => void diff --git a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts new file mode 100644 index 00000000000..6df19c7a78c --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DirectSshCoordinatorTelemetry } from '../hooks/direct-ssh-reconnect-coordinator' +import { + createDirectSshReconnectProductTelemetryAdapter, + toDirectSshReconnectProductProps +} from './direct-ssh-reconnect-product-telemetry' + +function coordinatorEvent( + overrides: Partial = {} +): DirectSshCoordinatorTelemetry { + return { + mode: 'prepare-only', + reason: 'workspace-snapshot', + outcome: 'degraded', + durationMs: 900, + staleBindingsCleared: 1, + retriedTerminals: 2, + correctedTerminals: 3, + terminalFinalizationDurationMs: 4, + catalogOutcome: 'complete', + catalogDurationMs: 20, + gitWorktreeCount: 5, + folderWorkspaceCount: 2, + ambiguousOwnerCount: 1, + contradictoryOwnerCount: 1, + repoOutcomes: { + complete: 2, + 'non-authoritative': 1, + 'timed-out': 1, + 'cancel-budget-exhausted': 1, + canceled: 1, + stale: 1, + rejected: 1 + }, + lineageOutcome: 'degraded', + queueWaitDurationsMs: [30, 10, 20, 40], + providerExecutionDurationsMs: [400, 100, 300, 200], + timeoutRetryCount: 2, + locallySettledWaiterCount: 4, + cancelDebtCount: 2, + replacementAdmissionDelayedCount: 1, + overlappingJoinCount: 3, + peakLocallyUnsettled: 4, + estimatedLateWorkAllowanceCount: 2, + authorityRotationCount: 1, + damped: true, + ...overrides + } +} + +describe('direct SSH reconnect product telemetry', () => { + it('maps one coordinator aggregate to privacy-safe product fields', () => { + const props = toDirectSshReconnectProductProps(coordinatorEvent()) + + expect(props).toMatchObject({ + mode: 'prepare_only', + reason: 'workspace_snapshot', + outcome: 'degraded', + repo_retrying_count: 2, + repo_timed_out_count: 1, + repo_rejected_count: 1, + repo_canceled_count: 1, + repo_stale_count: 1, + lineage_degraded_count: 1, + queue_wait_sample_count: 4, + queue_wait_duration_ms_p50: 20, + queue_wait_duration_ms_p95: 40, + provider_execution_sample_count: 4, + provider_execution_duration_ms_p50: 200, + provider_execution_duration_ms_p99: 400, + cancel_debt_count: 2, + replacement_admission_delayed_count: 1, + overlapping_join_count: 3, + timeout_retry_count: 2, + damped_preparation_count: 1 + }) + expect(Object.keys(props).join(' ')).not.toMatch( + /target_id|repo_id|host|path|label|user|request_id|lease_id|terminal_id|error/ + ) + expect(props).not.toHaveProperty('terminal_correction_failed_count') + expect(props).not.toHaveProperty('terminal_correction_rearmed_count') + expect(props).not.toHaveProperty('concurrent_non_coordinator_call_count') + expect(props).not.toHaveProperty('arrival_order_reply_discarded_count') + }) + + it('emits exactly one typed event per adapter call', () => { + const sink = vi.fn() + const emit = createDirectSshReconnectProductTelemetryAdapter(sink) + + emit(coordinatorEvent()) + + expect(sink).toHaveBeenCalledOnce() + expect(sink).toHaveBeenCalledWith( + 'direct_ssh_reconnect_operation', + expect.objectContaining({ total_duration_ms: 900 }) + ) + }) + + it('does not let telemetry failures escape into recovery', () => { + const emit = createDirectSshReconnectProductTelemetryAdapter(() => { + throw new Error('telemetry unavailable') + }) + + expect(() => emit(coordinatorEvent())).not.toThrow() + }) +}) diff --git a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.ts b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.ts new file mode 100644 index 00000000000..bd798ad4e06 --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.ts @@ -0,0 +1,127 @@ +import type { DirectSshCoordinatorTelemetry } from '../hooks/direct-ssh-reconnect-coordinator' +import { track } from './telemetry' +import type { + DirectSshReconnectProductProps, + DirectSshReconnectProductSink +} from './direct-ssh-reconnect-product-telemetry-types' + +const MAX_COUNT = 1_000_000 +const MAX_DURATION_MS = 86_400_000 + +function boundedInteger(value: number, maximum = MAX_COUNT): number { + return Math.min(maximum, Math.max(0, Math.round(Number.isFinite(value) ? value : 0))) +} + +function durationDistribution(values: readonly number[]): { + count: number + p50: number + p95: number + p99: number + max: number +} { + const sorted = values + .map((value) => boundedInteger(value, MAX_DURATION_MS)) + .toSorted((a, b) => a - b) + const percentile = (quantile: number): number => + sorted.length === 0 ? 0 : sorted[Math.max(0, Math.ceil(sorted.length * quantile) - 1)] + return { + count: boundedInteger(sorted.length), + p50: percentile(0.5), + p95: percentile(0.95), + p99: percentile(0.99), + max: sorted.at(-1) ?? 0 + } +} + +function lineageCounts( + outcome: DirectSshCoordinatorTelemetry['lineageOutcome'] +): Pick< + DirectSshReconnectProductProps, + | 'lineage_complete_count' + | 'lineage_degraded_count' + | 'lineage_canceled_count' + | 'lineage_stale_count' + | 'lineage_not_started_count' +> { + return { + lineage_complete_count: outcome === 'complete' ? 1 : 0, + lineage_degraded_count: outcome === 'degraded' ? 1 : 0, + lineage_canceled_count: outcome === 'canceled' ? 1 : 0, + lineage_stale_count: outcome === 'stale' ? 1 : 0, + lineage_not_started_count: outcome === 'not-started' ? 1 : 0 + } +} + +export function toDirectSshReconnectProductProps( + event: DirectSshCoordinatorTelemetry +): DirectSshReconnectProductProps { + const queueWait = durationDistribution(event.queueWaitDurationsMs) + const providerExecution = durationDistribution(event.providerExecutionDurationsMs) + return { + mode: event.mode === 'prepare-only' ? 'prepare_only' : 'reconnect', + reason: event.reason.replaceAll('-', '_') as DirectSshReconnectProductProps['reason'], + outcome: event.outcome, + terminal_retried_count: boundedInteger(event.retriedTerminals), + terminal_stale_binding_cleared_count: boundedInteger(event.staleBindingsCleared), + terminal_correction_succeeded_count: boundedInteger(event.correctedTerminals), + catalog_complete_count: event.catalogOutcome === 'complete' ? 1 : 0, + catalog_degraded_count: event.catalogOutcome === 'degraded' ? 1 : 0, + catalog_stale_count: event.catalogOutcome === 'stale' ? 1 : 0, + repo_complete_count: boundedInteger(event.repoOutcomes.complete), + repo_non_authoritative_count: boundedInteger(event.repoOutcomes['non-authoritative']), + // One first-timeout transition per repo makes retrying repos equal timeout retries. + repo_retrying_count: boundedInteger(event.timeoutRetryCount), + repo_timed_out_count: boundedInteger(event.repoOutcomes['timed-out']), + repo_cancel_budget_exhausted_count: boundedInteger( + event.repoOutcomes['cancel-budget-exhausted'] + ), + repo_canceled_count: boundedInteger(event.repoOutcomes.canceled), + repo_stale_count: boundedInteger(event.repoOutcomes.stale), + repo_rejected_count: boundedInteger(event.repoOutcomes.rejected), + ...lineageCounts(event.lineageOutcome), + git_worktree_count: boundedInteger(event.gitWorktreeCount), + folder_workspace_count: boundedInteger(event.folderWorkspaceCount), + ambiguous_owner_count: boundedInteger(event.ambiguousOwnerCount), + contradictory_owner_count: boundedInteger(event.contradictoryOwnerCount), + total_duration_ms: boundedInteger(event.durationMs, MAX_DURATION_MS), + terminal_finalization_duration_ms: boundedInteger( + event.terminalFinalizationDurationMs, + MAX_DURATION_MS + ), + catalog_duration_ms: boundedInteger(event.catalogDurationMs, MAX_DURATION_MS), + queue_wait_sample_count: queueWait.count, + queue_wait_duration_ms_p50: queueWait.p50, + queue_wait_duration_ms_p95: queueWait.p95, + queue_wait_duration_ms_p99: queueWait.p99, + queue_wait_duration_ms_max: queueWait.max, + provider_execution_sample_count: providerExecution.count, + provider_execution_duration_ms_p50: providerExecution.p50, + provider_execution_duration_ms_p95: providerExecution.p95, + provider_execution_duration_ms_p99: providerExecution.p99, + provider_execution_duration_ms_max: providerExecution.max, + timeout_retry_count: boundedInteger(event.timeoutRetryCount), + locally_settled_waiter_count: boundedInteger(event.locallySettledWaiterCount), + cancel_debt_count: boundedInteger(event.cancelDebtCount), + replacement_admission_delayed_count: boundedInteger(event.replacementAdmissionDelayedCount), + overlapping_join_count: boundedInteger(event.overlappingJoinCount), + coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: boundedInteger( + event.peakLocallyUnsettled, + 5 + ), + estimated_late_work_allowance_count: boundedInteger(event.estimatedLateWorkAllowanceCount, 2), + authority_rotation_count: boundedInteger(event.authorityRotationCount), + damped_preparation_count: event.damped ? 1 : 0 + } +} + +export function createDirectSshReconnectProductTelemetryAdapter( + sink: DirectSshReconnectProductSink = track +): (event: DirectSshCoordinatorTelemetry) => void { + return (event) => { + try { + sink('direct_ssh_reconnect_operation', toDirectSshReconnectProductProps(event)) + } catch { + // Recovery cannot depend on product telemetry. + } + } +} diff --git a/src/renderer/src/lib/direct-ssh-target-owner-index.ts b/src/renderer/src/lib/direct-ssh-target-owner-index.ts new file mode 100644 index 00000000000..84a91aeeaca --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-target-owner-index.ts @@ -0,0 +1,11 @@ +export function indexDirectSshOwnerRows( + rows: readonly T[] +): Map { + const rowsById = new Map() + for (const row of rows) { + const owners = rowsById.get(row.id) ?? [] + owners.push(row) + rowsById.set(row.id, owners) + } + return rowsById +} diff --git a/src/renderer/src/lib/direct-ssh-target-scope-types.ts b/src/renderer/src/lib/direct-ssh-target-scope-types.ts new file mode 100644 index 00000000000..e063a64c42c --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-target-scope-types.ts @@ -0,0 +1,52 @@ +import type { + FolderWorkspace, + ProjectGroup, + Repo, + WorkspaceKey, + Worktree +} from '../../../shared/types' + +export type DirectSshGitRepoRef = { + repoId: string + executionHostId: `ssh:${string}` +} + +export type DirectSshTargetScope = { + catalogRevision: number + gitRepos: DirectSshGitRepoRef[] + gitWorktreeIds: Set + terminalWorkspaceKeys: Set + lineageWorkspaceKeys: Set + ambiguousOwnerCount: number + contradictoryOwnerCount: number +} + +export type DirectSshRepoOwner = Pick< + Repo, + 'id' | 'path' | 'projectGroupId' | 'connectionId' | 'executionHostId' +> +export type DirectSshWorktreeOwner = Pick< + Worktree, + 'id' | 'repoId' | 'hostId' | 'runtimeOwnerEnvironmentId' +> +export type DirectSshFolderOwner = Pick< + FolderWorkspace, + 'id' | 'projectGroupId' | 'folderPath' | 'connectionId' +> +export type DirectSshGroupOwner = Pick< + ProjectGroup, + 'id' | 'parentGroupId' | 'connectionId' | 'executionHostId' +> + +export type DirectSshTargetScopeInput = { + targetId: string + catalogRevision: number + repos: readonly DirectSshRepoOwner[] + worktreesByRepo?: Readonly> + detectedWorktreesByRepo?: Readonly< + Record + > + folderWorkspaces?: readonly DirectSshFolderOwner[] + projectGroups?: readonly DirectSshGroupOwner[] + restoredRuntimeHostIdByWorkspaceSessionKey?: Readonly> +} diff --git a/src/renderer/src/lib/direct-ssh-target-scope.test.ts b/src/renderer/src/lib/direct-ssh-target-scope.test.ts new file mode 100644 index 00000000000..a010fab8984 --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-target-scope.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from 'vitest' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { + resolveDirectSshTargetScope, + type DirectSshTargetScopeInput +} from './direct-ssh-target-scope' + +const baseInput: DirectSshTargetScopeInput = { + targetId: 'target-a', + catalogRevision: 17, + repos: [] +} + +describe('resolveDirectSshTargetScope', () => { + it('resolves duplicate repo IDs by exact SSH host instead of first wins', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [ + { + id: 'shared', + path: '/local/shared', + projectGroupId: null, + connectionId: null, + executionHostId: 'local' + }, + { + id: 'shared', + path: '/remote/shared', + projectGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + worktreesByRepo: { + shared: [ + { + id: 'shared::remote', + repoId: 'shared', + hostId: 'ssh:target-a' + } + ] + } + }) + + expect(scope.catalogRevision).toBe(17) + expect(scope.gitRepos).toEqual([{ repoId: 'shared', executionHostId: 'ssh:target-a' }]) + expect(scope.gitWorktreeIds).toEqual(new Set(['shared::remote'])) + expect(scope.terminalWorkspaceKeys).toEqual(new Set(['shared::remote'])) + expect(scope.lineageWorkspaceKeys).toEqual(new Set([worktreeWorkspaceKey('shared::remote')])) + expect(scope.ambiguousOwnerCount).toBe(0) + expect(scope.contradictoryOwnerCount).toBe(0) + }) + + it('keeps duplicate same-host repo owners ambiguous', () => { + const duplicate = { + id: 'duplicate', + path: '/remote/duplicate', + projectGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' as const + } + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [duplicate, { ...duplicate }], + worktreesByRepo: { + duplicate: [ + { + id: 'duplicate::worktree', + repoId: 'duplicate', + hostId: 'ssh:target-a' + } + ] + } + }) + + expect(scope.gitRepos).toEqual([]) + expect(scope.gitWorktreeIds.size).toBe(0) + expect(scope.terminalWorkspaceKeys.size).toBe(0) + expect(scope.ambiguousOwnerCount).toBe(2) + }) + + it('gives folder workspaces the same exact target isolation as Git worktrees', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [ + { + id: 'remote-repo', + path: '/srv/project/repo', + projectGroupId: 'remote-group', + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + worktreesByRepo: { + 'remote-repo': [ + { + id: 'remote-repo::worktree', + repoId: 'remote-repo', + hostId: 'ssh:target-a' + } + ] + }, + projectGroups: [ + { + id: 'remote-group', + parentGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + folderWorkspaces: [ + { + id: 'remote-folder', + projectGroupId: 'remote-group', + folderPath: '/srv/project', + connectionId: 'target-a' + } + ] + }) + + expect(scope.gitWorktreeIds).toEqual(new Set(['remote-repo::worktree'])) + expect(scope.terminalWorkspaceKeys).toEqual( + new Set(['remote-repo::worktree', 'folder:remote-folder']) + ) + expect(scope.lineageWorkspaceKeys).toEqual( + new Set([worktreeWorkspaceKey('remote-repo::worktree'), folderWorkspaceKey('remote-folder')]) + ) + }) + + it('keeps runtime-owned SSH work isolated from the direct target', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [ + { + id: 'runtime-ssh', + path: '/runtime/repo', + projectGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + worktreesByRepo: { + 'runtime-ssh': [ + { + id: 'runtime-ssh::worktree', + repoId: 'runtime-ssh', + hostId: 'ssh:target-a', + runtimeOwnerEnvironmentId: 'hub-a' + } + ] + } + }) + + expect(scope.gitWorktreeIds.size).toBe(0) + expect(scope.terminalWorkspaceKeys.size).toBe(0) + expect(scope.contradictoryOwnerCount).toBe(1) + }) + + it('rejects explicit worktree and exact repo ownership contradictions', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [ + { + id: 'repo', + path: '/target-a/repo', + projectGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + worktreesByRepo: { + repo: [{ id: 'repo::wrong-host', repoId: 'repo', hostId: 'ssh:target-b' }] + } + }) + + expect(scope.gitRepos).toEqual([{ repoId: 'repo', executionHostId: 'ssh:target-a' }]) + expect(scope.gitWorktreeIds.size).toBe(0) + expect(scope.contradictoryOwnerCount).toBe(1) + }) + + it('excludes ambiguous legacy and duplicate same-host folder owners', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [], + worktreesByRepo: { + legacy: [{ id: 'legacy::worktree', repoId: 'legacy' }], + unresolved: [ + { + id: 'unresolved::worktree', + repoId: 'unresolved', + hostId: 'runtime:unresolved-owner' + } + ] + }, + projectGroups: [ + { + id: 'group', + parentGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + folderWorkspaces: [ + { + id: 'duplicate-folder', + projectGroupId: 'group', + folderPath: '/srv/project', + connectionId: 'target-a' + }, + { + id: 'duplicate-folder', + projectGroupId: 'group', + folderPath: '/srv/project', + connectionId: 'target-a' + } + ] + }) + + expect(scope.gitWorktreeIds.size).toBe(0) + expect(scope.terminalWorkspaceKeys.size).toBe(0) + expect(scope.ambiguousOwnerCount).toBe(3) + expect(scope.contradictoryOwnerCount).toBe(0) + }) + + it('rejects conflicting folder workspace, group, repo, and restored provenance', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [ + { + id: 'repo-b', + path: '/srv/project/repo', + projectGroupId: 'group', + connectionId: 'target-b', + executionHostId: 'ssh:target-b' + } + ], + projectGroups: [ + { + id: 'group', + parentGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + folderWorkspaces: [ + { + id: 'mixed-folder', + projectGroupId: 'group', + folderPath: '/srv/project', + connectionId: 'target-a' + } + ], + restoredRuntimeHostIdByWorkspaceSessionKey: { + 'folder:mixed-folder': 'runtime:hub-a' + } + }) + + expect(scope.terminalWorkspaceKeys.size).toBe(0) + expect(scope.contradictoryOwnerCount).toBe(1) + }) + + it('does not infer an effective folder connection from an SSH host stamp alone', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [], + projectGroups: [ + { + id: 'legacy-group', + parentGroupId: null, + connectionId: null, + executionHostId: 'ssh:target-a' + } + ], + folderWorkspaces: [ + { + id: 'legacy-folder', + projectGroupId: 'legacy-group', + folderPath: '/srv/legacy', + connectionId: null + } + ] + }) + + expect(scope.terminalWorkspaceKeys.size).toBe(0) + expect(scope.ambiguousOwnerCount).toBe(1) + }) +}) diff --git a/src/renderer/src/lib/direct-ssh-target-scope.ts b/src/renderer/src/lib/direct-ssh-target-scope.ts new file mode 100644 index 00000000000..4ffa6da3bd4 --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-target-scope.ts @@ -0,0 +1,310 @@ +import { + getRepoExecutionHostId, + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' +import { getProjectGroupSubtreeIds } from '../../../shared/project-groups' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { + DirectSshFolderOwner as FolderOwner, + DirectSshGitRepoRef, + DirectSshGroupOwner as GroupOwner, + DirectSshRepoOwner as RepoOwner, + DirectSshTargetScope, + DirectSshTargetScopeInput, + DirectSshWorktreeOwner as WorktreeOwner +} from './direct-ssh-target-scope-types' +import { indexDirectSshOwnerRows } from './direct-ssh-target-owner-index' +export type { + DirectSshGitRepoRef, + DirectSshTargetScope, + DirectSshTargetScopeInput +} from './direct-ssh-target-scope-types' + +type HostEvidence = { + hosts: Set + ambiguous: boolean + contradictory: boolean +} + +function newHostEvidence(): HostEvidence { + return { hosts: new Set(), ambiguous: false, contradictory: false } +} + +function addHostEvidence(evidence: HostEvidence, rawHostId: string | null | undefined): void { + if (!rawHostId?.trim()) { + return + } + const host = parseExecutionHostId(rawHostId) + if (!host) { + evidence.ambiguous = true + return + } + if (host.kind === 'runtime' && host.environmentId === 'unresolved-owner') { + evidence.ambiguous = true + return + } + evidence.hosts.add(host.id) +} + +function resolveRepoEvidence(repo: RepoOwner): HostEvidence { + const evidence = newHostEvidence() + addHostEvidence(evidence, repo.executionHostId) + if (repo.connectionId?.trim()) { + evidence.hosts.add(toSshExecutionHostId(repo.connectionId.trim())) + } + evidence.hosts = + evidence.hosts.size === 0 && !evidence.ambiguous + ? new Set([getRepoExecutionHostId(repo)]) + : evidence.hosts + evidence.contradictory = evidence.hosts.size > 1 + return evidence +} + +function collectWorktreeRows(input: DirectSshTargetScopeInput): Map { + return indexDirectSshOwnerRows([ + ...Object.values(input.worktreesByRepo ?? {}).flat(), + ...Object.values(input.detectedWorktreesByRepo ?? {}).flatMap((result) => result.worktrees) + ]) +} + +function addRepoDerivedEvidence( + evidence: HostEvidence, + repoRows: readonly RepoOwner[], + explicitHosts: ReadonlySet +): void { + const repoHosts = new Set() + const repoHostCounts = new Map() + let hasInvalidRepo = false + for (const repo of repoRows) { + const repoEvidence = resolveRepoEvidence(repo) + hasInvalidRepo ||= repoEvidence.ambiguous + evidence.contradictory ||= repoEvidence.contradictory + for (const host of repoEvidence.hosts) { + repoHosts.add(host) + repoHostCounts.set(host, (repoHostCounts.get(host) ?? 0) + 1) + } + } + if (explicitHosts.size > 0) { + const exactHosts = [...explicitHosts].filter((host) => repoHosts.has(host)) + if (exactHosts.length > 0) { + for (const host of exactHosts) { + evidence.hosts.add(host) + evidence.ambiguous ||= (repoHostCounts.get(host) ?? 0) > 1 + } + } else if (repoHosts.size === 1) { + evidence.hosts.add([...repoHosts][0]) + } else if (repoHosts.size > 1) { + evidence.ambiguous = true + } + } else if (repoHosts.size === 1) { + const repoHost = [...repoHosts][0] + evidence.hosts.add(repoHost) + evidence.ambiguous ||= (repoHostCounts.get(repoHost) ?? 0) > 1 + } else if (repoHosts.size > 1) { + evidence.ambiguous = true + } + evidence.ambiguous ||= hasInvalidRepo +} + +function resolveWorktreeEvidence( + input: DirectSshTargetScopeInput, + rows: readonly WorktreeOwner[], + repoRowsById: ReadonlyMap +): HostEvidence { + const evidence = newHostEvidence() + const repoIds = new Set(rows.map((row) => row.repoId)) + evidence.ambiguous ||= repoIds.size !== 1 + const explicitHosts = new Set() + for (const row of rows) { + const parsedHost = parseExecutionHostId(row.hostId) + if (row.hostId?.trim() && !parsedHost) { + evidence.ambiguous = true + } else if (parsedHost?.kind === 'runtime' && parsedHost.environmentId === 'unresolved-owner') { + evidence.ambiguous = true + } else if (parsedHost) { + explicitHosts.add(parsedHost.id) + evidence.hosts.add(parsedHost.id) + } + const runtimeOwner = row.runtimeOwnerEnvironmentId?.trim() + if (runtimeOwner) { + evidence.hosts.add(toRuntimeExecutionHostId(runtimeOwner)) + } + } + for (const repoId of repoIds) { + const repoRows = repoRowsById.get(repoId) + if (repoRows) { + addRepoDerivedEvidence(evidence, repoRows, explicitHosts) + } else if (explicitHosts.size === 0) { + evidence.ambiguous = true + } + } + const restored = input.restoredRuntimeHostIdByWorkspaceSessionKey + addHostEvidence(evidence, restored?.[rows[0].id]) + addHostEvidence(evidence, restored?.[worktreeWorkspaceKey(rows[0].id)]) + evidence.contradictory ||= evidence.hosts.size > 1 + return evidence +} + +function getFolderCandidateRepos( + folder: FolderOwner, + groups: readonly GroupOwner[], + repos: readonly RepoOwner[], + scopeConnectionId: string | null +): RepoOwner[] { + const groupIds = getProjectGroupSubtreeIds(groups, folder.projectGroupId) + const groupRepos = repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + isPathInsideOrEqual(folder.folderPath, repo.path) + ) + if (scopeConnectionId) { + return [ + ...groupRepos, + ...pathRepos.filter((repo) => (repo.connectionId ?? null) === scopeConnectionId) + ] + } + if (groupRepos.length === 0) { + return pathRepos + } + const groupConnections = new Set(groupRepos.map((repo) => repo.connectionId ?? null)) + return [ + ...groupRepos, + ...pathRepos.filter((repo) => groupConnections.has(repo.connectionId ?? null)) + ] +} + +function resolveFolderEvidence( + input: DirectSshTargetScopeInput, + folder: FolderOwner, + group: GroupOwner | undefined +): HostEvidence { + const evidence = newHostEvidence() + const folderConnection = folder.connectionId?.trim() || null + const groupConnection = group?.connectionId?.trim() || null + if (folderConnection) { + evidence.hosts.add(toSshExecutionHostId(folderConnection)) + } + if (groupConnection) { + evidence.hosts.add(toSshExecutionHostId(groupConnection)) + } + addHostEvidence(evidence, group?.executionHostId) + addHostEvidence( + evidence, + input.restoredRuntimeHostIdByWorkspaceSessionKey?.[folderWorkspaceKey(folder.id)] + ) + + const scopeConnection = folderConnection ?? groupConnection + const candidateRepos = getFolderCandidateRepos( + folder, + input.projectGroups ?? [], + input.repos, + scopeConnection + ) + const repoOwnerKeys = new Set() + for (const repo of candidateRepos) { + const repoEvidence = resolveRepoEvidence(repo) + evidence.ambiguous ||= repoEvidence.ambiguous + evidence.contradictory ||= repoEvidence.contradictory + for (const host of repoEvidence.hosts) { + const ownerKey = JSON.stringify([repo.id, host]) + evidence.ambiguous ||= repoOwnerKeys.has(ownerKey) + repoOwnerKeys.add(ownerKey) + evidence.hosts.add(host) + } + } + const hasSshOwner = [...evidence.hosts].some( + (hostId) => parseExecutionHostId(hostId)?.kind === 'ssh' + ) + const hasConnectionOwner = + Boolean(scopeConnection) || candidateRepos.some((repo) => Boolean(repo.connectionId?.trim())) + evidence.ambiguous ||= !group || (hasSshOwner && !hasConnectionOwner) + evidence.contradictory ||= evidence.hosts.size > 1 + return evidence +} + +export function resolveDirectSshTargetScope( + input: DirectSshTargetScopeInput +): DirectSshTargetScope { + const expectedHost = toSshExecutionHostId(input.targetId) + const repoRowsById = indexDirectSshOwnerRows(input.repos) + const gitRepos: DirectSshGitRepoRef[] = [] + let ambiguousOwnerCount = 0 + let contradictoryOwnerCount = 0 + + for (const [repoId, rows] of repoRowsById) { + const matchingRows = rows.filter((repo) => { + const evidence = resolveRepoEvidence(repo) + if (evidence.contradictory) { + contradictoryOwnerCount++ + return false + } + if (evidence.ambiguous) { + ambiguousOwnerCount++ + return false + } + return evidence.hosts.has(expectedHost) + }) + if (matchingRows.length === 1) { + gitRepos.push({ repoId, executionHostId: expectedHost }) + } else if (matchingRows.length > 1) { + ambiguousOwnerCount++ + } + } + + const gitWorktreeIds = new Set() + const terminalWorkspaceKeys = new Set() + const lineageWorkspaceKeys = new Set>() + for (const [worktreeId, rows] of collectWorktreeRows(input)) { + const evidence = resolveWorktreeEvidence(input, rows, repoRowsById) + if (evidence.contradictory) { + contradictoryOwnerCount++ + } else if (evidence.ambiguous || evidence.hosts.size === 0) { + ambiguousOwnerCount++ + } else if (evidence.hosts.has(expectedHost)) { + gitWorktreeIds.add(worktreeId) + terminalWorkspaceKeys.add(worktreeId) + lineageWorkspaceKeys.add(worktreeWorkspaceKey(worktreeId)) + } + } + + const folderRowsById = indexDirectSshOwnerRows(input.folderWorkspaces ?? []) + const groupRowsById = indexDirectSshOwnerRows(input.projectGroups ?? []) + for (const [folderId, rows] of folderRowsById) { + if (rows.length !== 1) { + ambiguousOwnerCount++ + continue + } + const groupRows = groupRowsById.get(rows[0].projectGroupId) ?? [] + if (groupRows.length > 1) { + ambiguousOwnerCount++ + continue + } + const evidence = resolveFolderEvidence(input, rows[0], groupRows[0]) + if (evidence.contradictory) { + contradictoryOwnerCount++ + } else if (evidence.ambiguous || evidence.hosts.size === 0) { + ambiguousOwnerCount++ + } else if (evidence.hosts.has(expectedHost)) { + const workspaceKey = folderWorkspaceKey(folderId) + terminalWorkspaceKeys.add(workspaceKey) + lineageWorkspaceKeys.add(workspaceKey) + } + } + + return { + catalogRevision: input.catalogRevision, + gitRepos, + gitWorktreeIds, + terminalWorkspaceKeys, + lineageWorkspaceKeys, + ambiguousOwnerCount, + contradictoryOwnerCount + } +} diff --git a/src/renderer/src/lib/workspace-session-hydration-keys.ts b/src/renderer/src/lib/workspace-session-hydration-keys.ts index c4e4ac9d00b..1166d8702a1 100644 --- a/src/renderer/src/lib/workspace-session-hydration-keys.ts +++ b/src/renderer/src/lib/workspace-session-hydration-keys.ts @@ -6,6 +6,7 @@ import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' export type WorkspaceSessionHydrationOptions = { additionalValidWorkspaceKeys?: readonly WorkspaceKey[] + replaceWorkspaceKeys?: readonly string[] } // Worktree-keyed fields carrying restorable chrome — a repo appears here only if it has live diff --git a/src/renderer/src/runtime/runtime-client-events.test.ts b/src/renderer/src/runtime/runtime-client-events.test.ts index acdd17dcb36..b0da9b8b1b4 100644 --- a/src/renderer/src/runtime/runtime-client-events.test.ts +++ b/src/renderer/src/runtime/runtime-client-events.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { SshProviderEpoch } from '../../../shared/ssh-types' import { subscribeRuntimeClientEvents } from './runtime-client-events' import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' @@ -137,7 +138,7 @@ describe('subscribeRuntimeClientEvents', () => { ]) }) - it('applies the redacted SSH snapshot from the ready frame', async () => { + it('preserves full SSH authority in retained snapshots and live client events', async () => { let capturedOnResponse: ((response: unknown) => void) | undefined const subscribe = vi.fn(async (_args, nextCallbacks) => { capturedOnResponse = (nextCallbacks as { onResponse: (response: unknown) => void }).onResponse @@ -160,9 +161,11 @@ describe('subscribeRuntimeClientEvents', () => { targetId: 'ssh-1', state: { targetId: 'ssh-1', - status: 'disconnected', + status: 'connected', error: null, - reconnectAttempt: 0 + reconnectAttempt: 0, + providerEpoch: 'snapshot-provider-epoch' as SshProviderEpoch, + connectionGeneration: 17 } } ] @@ -175,10 +178,74 @@ describe('subscribeRuntimeClientEvents', () => { targetId: 'ssh-1', state: { targetId: 'ssh-1', - status: 'disconnected', + status: 'connected', error: null, - reconnectAttempt: 0 + reconnectAttempt: 0, + providerEpoch: 'snapshot-provider-epoch', + connectionGeneration: 17 + } + }) + + capturedOnResponse({ + ok: true, + result: { + type: 'sshStateChanged', + targetId: 'ssh-1', + state: { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'live-provider-epoch' as SshProviderEpoch, + connectionGeneration: 18 + } + } + }) + + expect(onEvent).toHaveBeenLastCalledWith({ + type: 'sshStateChanged', + targetId: 'ssh-1', + state: { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'live-provider-epoch', + connectionGeneration: 18 } }) }) + + it('rejects a partial runtime authority instead of retaining it', async () => { + let capturedOnResponse: ((response: unknown) => void) | undefined + const subscribe = vi.fn(async (_args, nextCallbacks) => { + capturedOnResponse = (nextCallbacks as { onResponse: (response: unknown) => void }).onResponse + return { subscriptionId: 'sub-1', unsubscribe: vi.fn() } + }) + const onEvent = vi.fn() + const onError = vi.fn() + vi.stubGlobal('window', { api: { runtimeEnvironments: { subscribe } } }) + await subscribeRuntimeClientEvents('env-1', onEvent, onError) + if (!capturedOnResponse) { + throw new Error('Expected subscription callbacks') + } + + capturedOnResponse({ + ok: true, + result: { + type: 'sshStateChanged', + targetId: 'ssh-1', + state: { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'partial-provider-epoch' + } + } + }) + + expect(onEvent).not.toHaveBeenCalled() + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) })) + }) }) diff --git a/src/renderer/src/runtime/runtime-client-events.ts b/src/renderer/src/runtime/runtime-client-events.ts index 0b28e7c822d..0f1b72371a9 100644 --- a/src/renderer/src/runtime/runtime-client-events.ts +++ b/src/renderer/src/runtime/runtime-client-events.ts @@ -4,6 +4,7 @@ import type { } from '../../../shared/runtime-client-events' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay' +import { admitSshConnectionState } from '../../../shared/ssh-retained-payload-admission' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' export type RuntimeClientEventSubscription = { @@ -53,13 +54,27 @@ function handleRuntimeClientEventResponse( const message = response.result as RuntimeClientEventStreamMessage if (message.type === 'ready') { for (const sshState of message.snapshot?.sshStates ?? []) { - onEvent({ type: 'sshStateChanged', ...sshState }) + const state = admitSshConnectionState(sshState.state, sshState.targetId) + if (state) { + onEvent({ type: 'sshStateChanged', targetId: sshState.targetId, state }) + } else { + onError(new Error('Invalid retained SSH connection state')) + } } return } if (message.type === 'end') { return } + if (message.type === 'sshStateChanged') { + const state = admitSshConnectionState(message.state, message.targetId) + if (state) { + onEvent({ type: 'sshStateChanged', targetId: message.targetId, state }) + } else { + onError(new Error('Invalid retained SSH connection state')) + } + return + } if (isRuntimeClientEvent(message)) { onEvent(message) } diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts index 5cecc0961ff..46567c5343c 100644 --- a/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshConnectionState } from '../../../shared/ssh-types' +import type { SshConnectionState, SshProviderEpoch } from '../../../shared/ssh-types' import { useAppStore } from '@/store' import { applyRuntimeEnvironmentSshStateChanged, @@ -20,7 +20,14 @@ function connState( targetId: string, status: SshConnectionState['status'] = 'connected' ): SshConnectionState { - return { targetId, status, error: null, reconnectAttempt: 0 } + return { + targetId, + status, + error: null, + reconnectAttempt: 0, + providerEpoch: `${targetId}-provider-epoch` as SshProviderEpoch, + connectionGeneration: 7 + } } type RpcResponses = { @@ -84,6 +91,10 @@ describe('hydrateRuntimeEnvironmentSshState', () => { expect(bucket?.targetLabels.get('ssh-1')).toBe('devbox') expect(bucket?.removedTargetLabels.get('ssh-old')).toBe('retired box') expect(bucket?.connectionStates.get('ssh-1')?.status).toBe('connected') + expect(bucket?.connectionStates.get('ssh-1')).toMatchObject({ + providerEpoch: 'ssh-1-provider-epoch', + connectionGeneration: 7 + }) // ssh-2 had no live state: absent, so reads fall back to 'disconnected'. expect(bucket?.connectionStates.has('ssh-2')).toBe(false) // Local maps stay untouched. @@ -219,13 +230,36 @@ describe('applyRuntimeEnvironmentSshStateChanged', () => { applyRuntimeEnvironmentSshStateChanged(envId, 'ssh-1', connState('ssh-1', 'disconnected')) expect( - useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-1')?.status - ).toBe('disconnected') + useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-1') + ).toMatchObject({ + status: 'disconnected', + providerEpoch: 'ssh-1-provider-epoch', + connectionGeneration: 7 + }) expect(callRuntimeRpcMock).not.toHaveBeenCalled() // Local map untouched. expect(useAppStore.getState().sshConnectionStates.size).toBe(0) }) + it('rejects partial authority before retaining a runtime-owned state', () => { + const envId = nextEnvId() + useAppStore + .getState() + .setEnvironmentSshTargetsMetadata(envId, [{ id: 'ssh-1', label: 'devbox' }]) + + applyRuntimeEnvironmentSshStateChanged(envId, 'ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'partial-provider-epoch' as SshProviderEpoch + }) + + expect( + useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.has('ssh-1') + ).toBe(false) + }) + it('does not touch another environment bucket or local state (no cross-pollution)', () => { const envA = nextEnvId() const envB = nextEnvId() @@ -300,6 +334,12 @@ describe('connectRuntimeEnvironmentSshTarget', () => { expect( useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-1')?.status ).toBe('connected') + expect( + useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-1') + ).toMatchObject({ + providerEpoch: 'ssh-1-provider-epoch', + connectionGeneration: 7 + }) expect(useAppStore.getState().sshConnectionStates.size).toBe(0) }) diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.ts index c63fb0cf3ba..07051882292 100644 --- a/src/renderer/src/runtime/runtime-environment-ssh-state.ts +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.ts @@ -2,6 +2,7 @@ import { useAppStore } from '@/store' import type { SshConnectionState, SshTargetSummary } from '../../../shared/ssh-types' import { callRuntimeRpc } from './runtime-rpc-client' import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' +import { admitSshConnectionState } from '../../../shared/ssh-retained-payload-admission' /** * Mirrors a remote Orca server's own SSH targets into that environment's @@ -71,10 +72,11 @@ async function fetchEnvironmentSshConnectionStates( { targetId: target.id }, { timeoutMs: SSH_RPC_TIMEOUT_MS } ) - if (state) { + const admittedState = state ? admitSshConnectionState(state, target.id) : null + if (admittedState) { useAppStore .getState() - .setEnvironmentSshConnectionState(environmentId, target.id, state, generation) + .setEnvironmentSshConnectionState(environmentId, target.id, admittedState, generation) } } catch { // Why: a timeout or unsupported RPC is not authoritative evidence that the HUB's SSH link disconnected. @@ -157,10 +159,14 @@ export function applyRuntimeEnvironmentSshStateChanged( if (generation !== getEnvironmentSshStateGeneration(environmentId)) { return } + const admittedState = admitSshConnectionState(state, targetId) + if (!admittedState) { + return + } const store = useAppStore.getState() const bucket = store.sshStateByEnvironment.get(environmentId) if (bucket?.targetsHydrated && bucket.targetLabels.has(targetId)) { - store.setEnvironmentSshConnectionState(environmentId, targetId, state, generation) + store.setEnvironmentSshConnectionState(environmentId, targetId, admittedState, generation) return } void hydrateRuntimeEnvironmentSshState(environmentId, { force: true }).catch(() => {}) @@ -180,12 +186,13 @@ export async function connectRuntimeEnvironmentSshTarget( { targetId }, { timeoutMs: 60_000 } ) - if (state) { + const admittedState = state ? admitSshConnectionState(state, targetId) : null + if (admittedState) { useAppStore .getState() - .setEnvironmentSshConnectionState(environmentId, targetId, state, generation) + .setEnvironmentSshConnectionState(environmentId, targetId, admittedState, generation) } - return state + return admittedState } /** Resyncs the environment's target metadata after a failed connect so a diff --git a/src/renderer/src/startup/ssh-startup-reconnect.test.ts b/src/renderer/src/startup/ssh-startup-reconnect.test.ts index 28d1dea0435..44872a97f6e 100644 --- a/src/renderer/src/startup/ssh-startup-reconnect.test.ts +++ b/src/renderer/src/startup/ssh-startup-reconnect.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SshConnectionState } from '../../../shared/ssh-types' +import type { SshConnectionState, SshProviderEpoch } from '../../../shared/ssh-types' import { reconnectSshTargetForRendererStartup } from './ssh-startup-reconnect' const connectedState: SshConnectionState = { @@ -7,6 +7,8 @@ const connectedState: SshConnectionState = { status: 'connected', error: null, reconnectAttempt: 0, + providerEpoch: 'startup-provider-epoch' as SshProviderEpoch, + connectionGeneration: 41, remotePlatform: 'linux' } @@ -27,6 +29,32 @@ describe('reconnectSshTargetForRendererStartup', () => { expect(result).toEqual({ timedOut: false }) expect(publishState).toHaveBeenCalledWith('ssh-1', connectedState) + expect(publishState.mock.calls[0]?.[1]).toMatchObject({ + providerEpoch: 'startup-provider-epoch', + connectionGeneration: 41 + }) + }) + + it('does not synthesize the missing half of a partial startup authority', async () => { + const publishState = vi.fn() + const partialState = { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'partial-provider-epoch' as SshProviderEpoch + } satisfies SshConnectionState + + await reconnectSshTargetForRendererStartup({ + targetId: 'ssh-1', + timeoutMs: 1_000, + connect: vi.fn().mockResolvedValue(partialState), + publishState, + onFailure: vi.fn() + }) + + expect(publishState).toHaveBeenCalledWith('ssh-1', partialState) + expect(publishState.mock.calls[0]?.[1]).not.toHaveProperty('connectionGeneration') }) it('marks a stalled connect as deferred without publishing stale state', async () => { diff --git a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts index a5c88559ac5..0163aacc4c9 100644 --- a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts +++ b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts @@ -49,6 +49,8 @@ globalThis.window = { api: mockApi } import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import type { SshProviderEpoch } from '../../../../shared/ssh-types' +import type { DirectSshPaneRetryAttemptId } from './direct-ssh-terminal-recovery' const WT1 = 'repo1::/path/wt1' const WT2 = 'repo1::/path/wt2' @@ -62,6 +64,11 @@ const REMOTE_PTY2_SAME_HANDLE = 'remote:env-2@@terminal-wt1' const PTY2 = 'pty-wt2' function seedMaps(store: ReturnType): void { + const authority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 + } seedStore(store, { worktreesByRepo: { repo1: [ @@ -133,6 +140,32 @@ function seedMaps(store: ReturnType): void { [PTY1_SPLIT]: true, [REMOTE_PTY1]: true, [PTY2]: true + }, + directSshPaneRetryByTabId: { + [TAB1]: { + attemptId: 'attempt-1' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 0, + startedAt: 1 + } + }, + directSshLivePtyBindingByTabId: { + [TAB1]: { + attemptId: 'attempt-1' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 0, + ptyId: PTY1 + }, + [TAB2]: { + attemptId: 'attempt-2' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 0, + ptyId: PTY2 + } + }, + directSshPaneRetryHistoryByTabId: { + [TAB1]: { authority, attemptedAt: [1] }, + [TAB2]: { authority, attemptedAt: [2] } } }) } @@ -163,6 +196,9 @@ describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previo expect(s.pendingCodexPaneRestartIds[PTY1]).toBeUndefined() expect(s.pendingCodexPaneRestartIds[PTY1_SPLIT]).toBeUndefined() expect(s.pendingCodexPaneRestartIds[REMOTE_PTY1]).toBeUndefined() + expect(s.directSshPaneRetryByTabId[TAB1]).toBeUndefined() + expect(s.directSshLivePtyBindingByTabId[TAB1]).toBeUndefined() + expect(s.directSshPaneRetryHistoryByTabId[TAB1]).toBeUndefined() // Surviving worktree's tab/pty: every entry retained (no over-eviction). expect(s.lastKnownRelayPtyIdByTabId[TAB2]).toBe(PTY2) @@ -176,6 +212,8 @@ describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previo expect(s.migrationUnsupportedByPtyId[PTY2]).toBeDefined() expect(s.suppressedPtyExitIds[PTY2]).toBe(true) expect(s.pendingCodexPaneRestartIds[PTY2]).toBe(true) + expect(s.directSshLivePtyBindingByTabId[TAB2]).toBeDefined() + expect(s.directSshPaneRetryHistoryByTabId[TAB2]).toBeDefined() }) it('keeps environment-scoped remote guard identities independent', () => { @@ -244,4 +282,21 @@ describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previo // exact identities without parsing aliases or scanning surviving terminals. expect(parseRemotePtyId).not.toHaveBeenCalled() }) + + it('preserves direct SSH ledgers when a surviving worktree reuses the tab id', () => { + const store = createTestStore() + seedMaps(store) + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WT2]: [...state.tabsByWorktree[WT2], makeTab({ id: TAB1, worktreeId: WT2, ptyId: PTY2 })] + } + })) + + store.getState().purgeWorktreeTerminalState([WT1]) + + expect(store.getState().directSshPaneRetryByTabId[TAB1]).toBeDefined() + expect(store.getState().directSshLivePtyBindingByTabId[TAB1]).toBeDefined() + expect(store.getState().directSshPaneRetryHistoryByTabId[TAB1]).toBeDefined() + }) }) diff --git a/src/renderer/src/store/slices/detected-worktree-refresh-leases.test.ts b/src/renderer/src/store/slices/detected-worktree-refresh-leases.test.ts new file mode 100644 index 00000000000..1e0dd54a008 --- /dev/null +++ b/src/renderer/src/store/slices/detected-worktree-refresh-leases.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + HostQualifiedDetectedWorktreeResult, + ListDetectedWorktreesArgs +} from '../../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' +import { + createDetectedWorktreeRefreshLeaseRegistry, + type DetectedWorktreeRefreshProviderInput +} from './detected-worktree-refresh-leases' + +type ControlledPromise = { + promise: Promise + resolve(value: T): void + reject(error: unknown): void +} + +function controlledPromise(): ControlledPromise { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function authority(providerEpoch: string, connectionGeneration = 1): DirectSshAuthority { + return { + targetId: 'ssh-a', + providerEpoch: providerEpoch as SshProviderEpoch, + connectionGeneration + } +} + +function directInput( + expectedAuthority = authority('epoch-a') +): DetectedWorktreeRefreshProviderInput { + return { + repoId: 'repo-a', + executionHostId: 'ssh:ssh-a', + expectedAuthority + } +} + +function completeResult(request: ListDetectedWorktreesArgs): HostQualifiedDetectedWorktreeResult { + if (!('expectedAuthority' in request)) { + throw new Error('Expected direct SSH request') + } + return { + status: 'complete', + providerRequestId: request.providerRequestId, + repoId: request.repoId, + authority: { + kind: 'direct-ssh', + executionHostId: request.executionHostId, + ...request.expectedAuthority + }, + result: { + repoId: request.repoId, + authoritative: true, + source: 'git', + worktrees: [] + } + } +} + +function createHarness() { + const pending: ControlledPromise[] = [] + const starts: ListDetectedWorktreesArgs[] = [] + const cancelProviderRequest = vi.fn() + const registry = createDetectedWorktreeRefreshLeaseRegistry({ + startProviderRequest: (request) => { + starts.push(request) + const provider = controlledPromise() + pending.push(provider) + return provider.promise + }, + cancelProviderRequest + }) + return { cancelProviderRequest, pending, registry, starts } +} + +describe('detected worktree refresh leases', () => { + it('joins exact inputs with distinct waiter and shared provider identities', () => { + const { registry, starts } = createHarness() + + const first = registry.acquire('repo-a:ssh:ssh-a', directInput()) + const second = registry.acquire('repo-a:ssh:ssh-a', directInput()) + + expect(starts).toHaveLength(1) + expect(first.providerRequestId).toBe(second.providerRequestId) + expect(first.waiterLeaseId).not.toBe(second.waiterLeaseId) + expect(first.waiterLeaseId).not.toBe(first.providerRequestId) + expect(starts[0]).not.toHaveProperty('waiterLeaseId') + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 1, + waiterLeases: 2 + }) + }) + + it('settles only the released waiter while another lease remains live', async () => { + const { cancelProviderRequest, pending, registry, starts } = createHarness() + const first = registry.acquire('repo-a:ssh:ssh-a', directInput()) + const second = registry.acquire('repo-a:ssh:ssh-a', directInput()) + + expect(first.release('superseded')).toBe('retained') + await expect(first.result).resolves.toEqual({ + providerRequestId: first.providerRequestId, + executionHostId: 'ssh:ssh-a', + status: 'canceled' + }) + expect(cancelProviderRequest).not.toHaveBeenCalled() + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 1, + waiterLeases: 1 + }) + + const providerResult = completeResult(starts[0]) + pending[0].resolve(providerResult) + await expect(second.result).resolves.toEqual(providerResult) + await expect(first.result).resolves.toMatchObject({ status: 'canceled' }) + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 0, + waiterLeases: 0 + }) + }) + + it('cancels once on last release and waits for local provider settlement', async () => { + const { cancelProviderRequest, pending, registry, starts } = createHarness() + const input = directInput() + const lease = registry.acquire('repo-a:ssh:ssh-a', input) + + expect(lease.release('invalidated')).toBe('cancel-started') + expect(lease.release('stopped')).toBe('already-settled') + + await expect(lease.result).resolves.toMatchObject({ + providerRequestId: lease.providerRequestId, + status: 'canceled' + }) + expect(cancelProviderRequest).toHaveBeenCalledTimes(1) + expect(cancelProviderRequest).toHaveBeenCalledWith(starts[0], 'invalidated') + expect(cancelProviderRequest.mock.calls[0]?.[0]).not.toHaveProperty('waiterLeaseId') + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 1, + waiterLeases: 0 + }) + + pending[0].resolve(completeResult(starts[0])) + await pending[0].promise + await Promise.resolve() + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 0, + waiterLeases: 0 + }) + }) + + it('reports no cancellation start when the invocation throws before send', async () => { + const pending = controlledPromise() + let sends = 0 + const invokeCancellation = vi.fn(() => { + throw new Error('before send') + }) + const startProviderRequest = vi.fn(() => pending.promise) + const registry = createDetectedWorktreeRefreshLeaseRegistry({ + startProviderRequest, + cancelProviderRequest: () => { + invokeCancellation() + sends++ + } + }) + const lease = registry.acquire('repo-a:ssh:ssh-a', directInput()) + + expect(lease.release('invalidated')).toBe('cancel-failed') + expect(invokeCancellation).toHaveBeenCalledOnce() + expect(sends).toBe(0) + await expect(lease.result).resolves.toMatchObject({ status: 'canceled' }) + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 1, + waiterLeases: 0 + }) + const replacement = registry.acquire('repo-a:ssh:ssh-a', directInput()) + expect(replacement.providerRequestId).not.toBe(lease.providerRequestId) + expect(startProviderRequest).toHaveBeenCalledTimes(2) + + pending.resolve({ + providerRequestId: lease.providerRequestId, + executionHostId: 'ssh:ssh-a', + status: 'canceled' + }) + await pending.promise + await Promise.resolve() + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 0, + waiterLeases: 0 + }) + }) + + it('keeps asynchronous cancellation fire-and-forget', async () => { + const pending = controlledPromise() + const registry = createDetectedWorktreeRefreshLeaseRegistry({ + startProviderRequest: () => pending.promise, + cancelProviderRequest: () => Promise.reject(new Error('async cancellation failure')) + }) + const lease = registry.acquire('repo-a:ssh:ssh-a', directInput()) + + expect(lease.release('invalidated')).toBe('cancel-started') + await expect(lease.result).resolves.toMatchObject({ status: 'canceled' }) + await Promise.resolve() + + pending.resolve({ + providerRequestId: lease.providerRequestId, + executionHostId: 'ssh:ssh-a', + status: 'canceled' + }) + await pending.promise + await Promise.resolve() + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 0, + waiterLeases: 0 + }) + }) + + it('does not join incompatible full-authority inputs under one public key', () => { + const { registry, starts } = createHarness() + + const first = registry.acquire('repo-a:ssh:ssh-a', directInput(authority('epoch-a', 1))) + const second = registry.acquire('repo-a:ssh:ssh-a', directInput(authority('epoch-b', 1))) + const third = registry.acquire('repo-a:ssh:ssh-a', directInput(authority('epoch-a', 2))) + + expect(starts).toHaveLength(3) + expect( + new Set([first.providerRequestId, second.providerRequestId, third.providerRequestId]).size + ).toBe(3) + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 3, + waiterLeases: 3 + }) + }) + + it('removes settled invocations so a later acquire starts fresh', async () => { + const { pending, registry, starts } = createHarness() + const first = registry.acquire('repo-a:ssh:ssh-a', directInput()) + + const firstResult = completeResult(starts[0]) + pending[0].resolve(firstResult) + await expect(first.result).resolves.toEqual(firstResult) + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 0, + waiterLeases: 0 + }) + + const second = registry.acquire('repo-a:ssh:ssh-a', directInput()) + expect(starts).toHaveLength(2) + expect(second.providerRequestId).not.toBe(first.providerRequestId) + expect(second.waiterLeaseId).not.toBe(first.waiterLeaseId) + }) + + it('cleans rejected provider invocations without retaining waiters', async () => { + const { pending, registry } = createHarness() + const lease = registry.acquire('repo-a:ssh:ssh-a', directInput()) + const error = new Error('provider failed') + + pending[0].reject(error) + await expect(lease.result).rejects.toBe(error) + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 0, + waiterLeases: 0 + }) + }) +}) diff --git a/src/renderer/src/store/slices/detected-worktree-refresh-leases.ts b/src/renderer/src/store/slices/detected-worktree-refresh-leases.ts new file mode 100644 index 00000000000..44c4f43c9cb --- /dev/null +++ b/src/renderer/src/store/slices/detected-worktree-refresh-leases.ts @@ -0,0 +1,265 @@ +import type { + DirectSshDetectedWorktreeRequest, + HostQualifiedDetectedWorktreeResult, + ListDetectedWorktreesArgs, + LocalDetectedWorktreeRequest, + ProviderRequestId +} from '../../../../shared/detected-worktree-provider-contract' + +export type WaiterLeaseId = string & { + readonly __waiterLeaseId: unique symbol +} + +export type DetectedWorktreeRefreshReleaseReason = 'superseded' | 'invalidated' | 'stopped' + +export type DetectedWorktreeRefreshReleaseOutcome = + | 'retained' + | 'cancel-started' + | 'cancel-failed' + | 'already-settled' + +export type DetectedWorktreeRefreshLease = { + waiterLeaseId: WaiterLeaseId + providerRequestId: ProviderRequestId + result: Promise + release(reason: DetectedWorktreeRefreshReleaseReason): DetectedWorktreeRefreshReleaseOutcome +} + +export type DetectedWorktreeRefreshProviderInput = + | Omit + | Omit + +export type DetectedWorktreeRefreshLeaseRegistryOptions = { + startProviderRequest( + request: ListDetectedWorktreesArgs + ): Promise + cancelProviderRequest( + request: ListDetectedWorktreesArgs, + reason: DetectedWorktreeRefreshReleaseReason + ): void | Promise +} + +type LeaseWaiter = { + settled: boolean + resolve(result: HostQualifiedDetectedWorktreeResult): void + reject(error: unknown): void +} + +type ProviderInvocation = { + publicKey: string + request: ListDetectedWorktreesArgs + waiters: Map + settled: boolean + cancelAttempted: boolean +} + +export type DetectedWorktreeRefreshLeaseRegistry = { + acquire( + publicKey: string, + input: DetectedWorktreeRefreshProviderInput + ): DetectedWorktreeRefreshLease + getActiveCounts(): { providerInvocations: number; waiterLeases: number } +} + +let providerRequestSequence = 0 +let waiterLeaseSequence = 0 + +function mintProviderRequestId(): ProviderRequestId { + providerRequestSequence = nextIdentitySequence(providerRequestSequence) + return `detected-worktree-provider-${providerRequestSequence}` as ProviderRequestId +} + +function mintWaiterLeaseId(): WaiterLeaseId { + waiterLeaseSequence = nextIdentitySequence(waiterLeaseSequence) + return `detected-worktree-waiter-${waiterLeaseSequence}` as WaiterLeaseId +} + +function nextIdentitySequence(current: number): number { + if (current >= Number.MAX_SAFE_INTEGER) { + throw new Error('Detected worktree refresh identity sequence exhausted') + } + return current + 1 +} + +function requestsAreCompatible( + request: ListDetectedWorktreesArgs, + input: DetectedWorktreeRefreshProviderInput +): boolean { + if (request.repoId !== input.repoId || request.executionHostId !== input.executionHostId) { + return false + } + const requestAuthority = 'expectedAuthority' in request ? request.expectedAuthority : undefined + const inputAuthority = 'expectedAuthority' in input ? input.expectedAuthority : undefined + if (!requestAuthority || !inputAuthority) { + return requestAuthority === inputAuthority + } + return ( + requestAuthority.targetId === inputAuthority.targetId && + requestAuthority.providerEpoch === inputAuthority.providerEpoch && + requestAuthority.connectionGeneration === inputAuthority.connectionGeneration + ) +} + +function canceledResult(request: ListDetectedWorktreesArgs): HostQualifiedDetectedWorktreeResult { + return { + providerRequestId: request.providerRequestId, + executionHostId: request.executionHostId, + status: 'canceled' + } +} + +function providerRequestWithId( + input: DetectedWorktreeRefreshProviderInput, + providerRequestId: ProviderRequestId +): ListDetectedWorktreesArgs { + if ('expectedAuthority' in input) { + return { + ...input, + expectedAuthority: { ...input.expectedAuthority }, + providerRequestId + } + } + return { ...input, providerRequestId } +} + +export function createDetectedWorktreeRefreshLeaseRegistry( + options: DetectedWorktreeRefreshLeaseRegistryOptions +): DetectedWorktreeRefreshLeaseRegistry { + const invocationsByPublicKey = new Map>() + + const removeInvocation = (invocation: ProviderInvocation): void => { + const bucket = invocationsByPublicKey.get(invocation.publicKey) + if (!bucket) { + return + } + bucket.delete(invocation) + if (bucket.size === 0) { + invocationsByPublicKey.delete(invocation.publicKey) + } + } + + const settleProviderInvocation = ( + invocation: ProviderInvocation, + settlement: + | { status: 'fulfilled'; result: HostQualifiedDetectedWorktreeResult } + | { status: 'rejected'; error: unknown } + ): void => { + if (invocation.settled) { + return + } + invocation.settled = true + removeInvocation(invocation) + for (const waiter of invocation.waiters.values()) { + if (waiter.settled) { + continue + } + waiter.settled = true + if (settlement.status === 'fulfilled') { + waiter.resolve(settlement.result) + } else { + waiter.reject(settlement.error) + } + } + invocation.waiters.clear() + } + + const startInvocation = ( + publicKey: string, + input: DetectedWorktreeRefreshProviderInput + ): ProviderInvocation => { + const request = providerRequestWithId(input, mintProviderRequestId()) + const invocation: ProviderInvocation = { + publicKey, + request, + waiters: new Map(), + settled: false, + cancelAttempted: false + } + const bucket = invocationsByPublicKey.get(publicKey) ?? new Set() + bucket.add(invocation) + invocationsByPublicKey.set(publicKey, bucket) + + let result: Promise + try { + result = options.startProviderRequest(request) + } catch (error) { + result = Promise.reject(error) + } + void result.then( + (providerResult) => + settleProviderInvocation(invocation, { + status: 'fulfilled', + result: providerResult + }), + (error) => settleProviderInvocation(invocation, { status: 'rejected', error }) + ) + return invocation + } + + const acquire = ( + publicKey: string, + input: DetectedWorktreeRefreshProviderInput + ): DetectedWorktreeRefreshLease => { + const invocation = + [...(invocationsByPublicKey.get(publicKey) ?? [])].find( + (candidate) => + !candidate.settled && + !candidate.cancelAttempted && + requestsAreCompatible(candidate.request, input) + ) ?? startInvocation(publicKey, input) + const waiterLeaseId = mintWaiterLeaseId() + let resolveResult!: (result: HostQualifiedDetectedWorktreeResult) => void + let rejectResult!: (error: unknown) => void + const result = new Promise((resolve, reject) => { + resolveResult = resolve + rejectResult = reject + }) + const waiter: LeaseWaiter = { + settled: false, + resolve: resolveResult, + reject: rejectResult + } + invocation.waiters.set(waiterLeaseId, waiter) + + return { + waiterLeaseId, + providerRequestId: invocation.request.providerRequestId, + result, + release: (reason) => { + if (waiter.settled) { + return 'already-settled' + } + waiter.settled = true + invocation.waiters.delete(waiterLeaseId) + waiter.resolve(canceledResult(invocation.request)) + if (invocation.settled || invocation.waiters.size > 0 || invocation.cancelAttempted) { + return 'retained' + } + invocation.cancelAttempted = true + let cancellation: void | Promise + try { + cancellation = options.cancelProviderRequest(invocation.request, reason) + } catch { + return 'cancel-failed' + } + void Promise.resolve(cancellation).catch(() => undefined) + return 'cancel-started' + } + } + } + + return { + acquire, + getActiveCounts: () => { + let providerInvocations = 0 + let waiterLeases = 0 + for (const bucket of invocationsByPublicKey.values()) { + providerInvocations += bucket.size + for (const invocation of bucket) { + waiterLeases += invocation.waiters.size + } + } + return { providerInvocations, waiterLeases } + } + } +} diff --git a/src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts b/src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts new file mode 100644 index 00000000000..a40e11f2dcd --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from 'vitest' +import type { SshProviderEpoch } from '../../../../shared/ssh-types' +import type { DirectSshPaneRetryAttemptId } from './direct-ssh-terminal-recovery' +import { createTestStore, makeTab, makeWorktree, seedStore } from './store-test-helpers' + +const WORKTREE_ID = 'repo1::/path/wt1' +const SOURCE_TAB_ID = 'tab-source' +const TARGET_TAB_ID = 'tab-target' +const PRIMARY_PTY_ID = 'ssh:target-a@@pty-primary' +const SIBLING_PTY_ID = 'ssh:target-a@@pty-sibling' +const SURVIVOR_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const DETACHED_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +const authority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 +} + +function createSplitDetachStore() { + const store = createTestStore() + seedStore(store, { + repos: [ + { + id: 'repo1', + path: '/path', + displayName: 'repo1', + badgeColor: '#000', + addedAt: 1, + connectionId: authority.targetId, + executionHostId: 'ssh:target-a' + } + ], + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: WORKTREE_ID, + repoId: 'repo1', + path: '/path/wt1', + hostId: 'ssh:target-a' + }) + ] + }, + tabsByWorktree: { + [WORKTREE_ID]: [ + makeTab({ id: SOURCE_TAB_ID, worktreeId: WORKTREE_ID, ptyId: PRIMARY_PTY_ID }), + makeTab({ id: TARGET_TAB_ID, worktreeId: WORKTREE_ID, ptyId: null }) + ] + }, + ptyIdsByTabId: { + [SOURCE_TAB_ID]: [PRIMARY_PTY_ID, SIBLING_PTY_ID], + [TARGET_TAB_ID]: [] + }, + directSshLivePtyBindingByTabId: { + [SOURCE_TAB_ID]: { + attemptId: 'split-detach' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 0, + ptyId: PRIMARY_PTY_ID + } + }, + directSshPaneRetryHistoryByTabId: { + [SOURCE_TAB_ID]: { authority, attemptedAt: [1] } + }, + sshConnectionStates: new Map([ + [ + authority.targetId, + { + targetId: authority.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration + } + ] + ]) + }) + return store +} + +describe('direct SSH split-pane detach ledger', () => { + it.each([ + { + label: 'primary', + detachedPtyId: PRIMARY_PTY_ID, + survivorPtyId: SIBLING_PTY_ID + }, + { + label: 'non-primary sibling', + detachedPtyId: SIBLING_PTY_ID, + survivorPtyId: PRIMARY_PTY_ID + } + ])( + 'preserves exact authority on both sides of a $label detach', + ({ detachedPtyId, survivorPtyId }) => { + const store = createSplitDetachStore() + + store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: DETACHED_LEAF_ID, + detachedPtyId, + sourceLayout: { + root: { type: 'leaf', leafId: SURVIVOR_LEAF_ID }, + activeLeafId: SURVIVOR_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [SURVIVOR_LEAF_ID]: survivorPtyId } + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: TARGET_TAB_ID + }) + + expect(store.getState().directSshLivePtyBindingByTabId[SOURCE_TAB_ID]).toMatchObject({ + attemptId: 'split-detach', + ptyId: survivorPtyId + }) + expect(store.getState().directSshLivePtyBindingByTabId[TARGET_TAB_ID]).toMatchObject({ + attemptId: 'split-detach', + ptyId: detachedPtyId + }) + expect(store.getState().directSshPaneRetryHistoryByTabId[SOURCE_TAB_ID]).toEqual({ + authority, + attemptedAt: [1] + }) + expect(store.getState().directSshPaneRetryHistoryByTabId[TARGET_TAB_ID]).toEqual({ + authority, + attemptedAt: [1] + }) + expect(store.getState().invalidateStaleDirectSshTargetPtyBindings(authority)).toBe(0) + expect(store.getState().retryDirectSshTargetPanes(authority, 2)).toBe(0) + } + ) + + it('preserves source continuation when its sibling has not bound yet', () => { + const store = createSplitDetachStore() + const attemptId = 'split-detach' as DirectSshPaneRetryAttemptId + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => + tab.id === SOURCE_TAB_ID ? { ...tab, pendingActivationSpawn: true } : tab + ) + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + [SOURCE_TAB_ID]: [PRIMARY_PTY_ID] + } + })) + + store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: DETACHED_LEAF_ID, + detachedPtyId: PRIMARY_PTY_ID, + sourceLayout: { + root: { type: 'leaf', leafId: SURVIVOR_LEAF_ID }, + activeLeafId: SURVIVOR_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: {} + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: TARGET_TAB_ID + }) + + expect(store.getState().directSshLivePtyBindingByTabId[SOURCE_TAB_ID]).toMatchObject({ + attemptId + }) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0]).toMatchObject({ + ptyId: null, + pendingActivationSpawn: true + }) + + store.getState().updateTabPtyId(SOURCE_TAB_ID, SIBLING_PTY_ID, undefined, attemptId) + + expect(store.getState().directSshLivePtyBindingByTabId[SOURCE_TAB_ID]).toMatchObject({ + attemptId, + ptyId: SIBLING_PTY_ID + }) + expect(store.getState().ptyIdsByTabId[SOURCE_TAB_ID]).toEqual([SIBLING_PTY_ID]) + }) + + it('projects an empty continuation gap to both unbound detach sides', () => { + const store = createSplitDetachStore() + const attemptId = 'split-detach' as DirectSshPaneRetryAttemptId + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => ({ + ...tab, + ptyId: null, + pendingActivationSpawn: true + })) + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + [SOURCE_TAB_ID]: [] + } + })) + + store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: DETACHED_LEAF_ID, + detachedPtyId: null, + sourceLayout: { + root: { type: 'leaf', leafId: SURVIVOR_LEAF_ID }, + activeLeafId: SURVIVOR_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: {} + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: TARGET_TAB_ID + }) + + expect(store.getState().directSshLivePtyBindingByTabId[SOURCE_TAB_ID]).toMatchObject({ + attemptId + }) + expect(store.getState().directSshLivePtyBindingByTabId[TARGET_TAB_ID]).toMatchObject({ + attemptId + }) + + store.getState().updateTabPtyId(SOURCE_TAB_ID, SIBLING_PTY_ID, undefined, attemptId) + store.getState().updateTabPtyId(TARGET_TAB_ID, PRIMARY_PTY_ID, undefined, attemptId) + + expect(store.getState().ptyIdsByTabId[SOURCE_TAB_ID]).toEqual([SIBLING_PTY_ID]) + expect(store.getState().ptyIdsByTabId[TARGET_TAB_ID]).toEqual([PRIMARY_PTY_ID]) + }) + + it('preserves a pending-only lease when both detach sides are still unbound', () => { + const store = createSplitDetachStore() + const attemptId = 'split-pending-only' as DirectSshPaneRetryAttemptId + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => ({ + ...tab, + ptyId: null, + pendingActivationSpawn: true + })) + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + [SOURCE_TAB_ID]: [] + }, + directSshLivePtyBindingByTabId: {}, + directSshPaneRetryByTabId: { + [SOURCE_TAB_ID]: { + attemptId, + authority, + tabGeneration: 0, + startedAt: 1 + } + } + })) + + store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: DETACHED_LEAF_ID, + detachedPtyId: null, + sourceLayout: { + root: { type: 'leaf', leafId: SURVIVOR_LEAF_ID }, + activeLeafId: SURVIVOR_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: {} + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: TARGET_TAB_ID + }) + + expect(store.getState().directSshPaneRetryByTabId).toMatchObject({ + [SOURCE_TAB_ID]: { attemptId, authority, tabGeneration: 0 }, + [TARGET_TAB_ID]: { attemptId, authority, tabGeneration: 0 } + }) + + store.getState().updateTabPtyId(SOURCE_TAB_ID, SIBLING_PTY_ID, undefined, attemptId) + store.getState().updateTabPtyId(TARGET_TAB_ID, PRIMARY_PTY_ID, undefined, attemptId) + + expect(store.getState().ptyIdsByTabId[SOURCE_TAB_ID]).toEqual([SIBLING_PTY_ID]) + expect(store.getState().ptyIdsByTabId[TARGET_TAB_ID]).toEqual([PRIMARY_PTY_ID]) + }) +}) diff --git a/src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts b/src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts new file mode 100644 index 00000000000..51730cf70a8 --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts @@ -0,0 +1,164 @@ +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { shouldRetryPaneSpawnOnSshReconnect } from '../../hooks/ssh-reconnect-pane-retry' +import { + directSshAuthoritiesEqual, + liveBindingMatches, + pruneObsoleteAuthorityState +} from './direct-ssh-terminal-authority-ledger' +import { invalidateStaleDirectSshTerminalBindings } from './direct-ssh-terminal-recovery' +import { getTerminalActivationSpawnSuppression } from './terminal-activation-spawn-suppression' +import type { + DirectSshPaneRetryAttemptId, + DirectSshTerminalBindingState, + DirectSshTerminalRetryResult +} from './direct-ssh-terminal-recovery-types' + +const AUTOMATIC_RETRY_LIMIT = 2 + +type DirectSshTerminalRetryOptions = { + qualifiedTabIds?: ReadonlySet +} + +function createAttemptId( + authority: DirectSshAuthority, + tabId: string, + tabGeneration: number, + now: number +): DirectSshPaneRetryAttemptId { + return JSON.stringify([ + authority.targetId, + authority.providerEpoch, + authority.connectionGeneration, + tabId, + tabGeneration, + now + ]) as DirectSshPaneRetryAttemptId +} + +export function retryDirectSshTerminalPanes( + state: DirectSshTerminalBindingState & { + deferredSshSessionIdsByTabId: Record + terminalLayoutsByTabId?: Record + }, + terminalWorkspaceKeys: ReadonlySet, + authority: DirectSshAuthority, + now: number, + options: DirectSshTerminalRetryOptions = {} +): DirectSshTerminalRetryResult { + const authorityState = pruneObsoleteAuthorityState(state, authority) + const invalidated = invalidateStaleDirectSshTerminalBindings( + { ...state, ...authorityState }, + terminalWorkspaceKeys, + authority, + options.qualifiedTabIds + ) + const working = { ...state, ...authorityState, ...invalidated.patch } + let tabsByWorktree = working.tabsByWorktree + let pending = working.directSshPaneRetryByTabId + let history = working.directSshPaneRetryHistoryByTabId + let retriedCount = 0 + + for (const workspaceKey of terminalWorkspaceKeys) { + const tabs = working.tabsByWorktree[workspaceKey] ?? [] + let nextTabs = tabs + for (const [index, tab] of tabs.entries()) { + if (options.qualifiedTabIds && !options.qualifiedTabIds.has(tab.id)) { + continue + } + const currentPending = pending[tab.id] + if ( + currentPending && + directSshAuthoritiesEqual(currentPending.authority, authority) && + currentPending.tabGeneration === (tab.generation ?? 0) + ) { + continue + } + if ( + liveBindingMatches(tab, working.directSshLivePtyBindingByTabId[tab.id], authority) || + !shouldRetryPaneSpawnOnSshReconnect({ + targetId: authority.targetId, + tabPtyId: tab.ptyId, + deferredSessionId: state.deferredSshSessionIdsByTabId[tab.id] + }) + ) { + continue + } + const previousHistory = history[tab.id] + const sameAuthorityAttempts = + previousHistory && directSshAuthoritiesEqual(previousHistory.authority, authority) + ? previousHistory.attemptedAt + : [] + // Why: a 31s PTY timeout outlives the old rolling window; authority rotation is the reset boundary for this automatic chain. + const recentAttempts = sameAuthorityAttempts + if (recentAttempts.length >= AUTOMATIC_RETRY_LIMIT) { + continue + } + const tabGeneration = (tab.generation ?? 0) + 1 + if (nextTabs === tabs) { + nextTabs = [...tabs] + } + nextTabs[index] = { + ...tab, + generation: tabGeneration, + pendingActivationSpawn: getTerminalActivationSpawnSuppression( + state.terminalLayoutsByTabId?.[tab.id] + ) + } + if (pending === working.directSshPaneRetryByTabId) { + pending = { ...working.directSshPaneRetryByTabId } + } + pending[tab.id] = { + attemptId: createAttemptId(authority, tab.id, tabGeneration, now), + authority, + tabGeneration, + startedAt: now + } + if (history === working.directSshPaneRetryHistoryByTabId) { + history = { ...working.directSshPaneRetryHistoryByTabId } + } + history[tab.id] = { authority, attemptedAt: [...recentAttempts, now] } + retriedCount += 1 + } + if (nextTabs !== tabs) { + if (tabsByWorktree === working.tabsByWorktree) { + tabsByWorktree = { ...working.tabsByWorktree } + } + tabsByWorktree[workspaceKey] = nextTabs + } + } + + const recoveryChanged = + Object.keys(authorityState).some( + (key) => + authorityState[key as keyof typeof authorityState] !== + state[key as keyof typeof authorityState] + ) || invalidated.patch != null + if (retriedCount === 0 && !recoveryChanged) { + return { retriedCount, patch: null } + } + return { + retriedCount, + patch: { + ...authorityState, + ...invalidated.patch, + tabsByWorktree, + directSshPaneRetryByTabId: pending, + directSshPaneRetryHistoryByTabId: history + } + } +} + +export function retrySettledDirectSshTerminalPane( + state: DirectSshTerminalBindingState & { + deferredSshSessionIdsByTabId: Record + }, + terminalWorkspaceKeys: ReadonlySet, + authority: DirectSshAuthority, + tabId: string, + now: number +): DirectSshTerminalRetryResult { + return retryDirectSshTerminalPanes(state, terminalWorkspaceKeys, authority, now, { + qualifiedTabIds: new Set([tabId]) + }) +} diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-authority-ledger.ts b/src/renderer/src/store/slices/direct-ssh-terminal-authority-ledger.ts new file mode 100644 index 00000000000..b0e5d5731c1 --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-authority-ledger.ts @@ -0,0 +1,252 @@ +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { TerminalTab } from '../../../../shared/types' +import type { + DirectSshLivePtyBinding, + DirectSshPaneRetryResult, + DirectSshTerminalBindingState +} from './direct-ssh-terminal-recovery-types' + +export function directSshAuthoritiesEqual( + left: DirectSshAuthority, + right: DirectSshAuthority +): boolean { + return ( + left.targetId === right.targetId && + left.providerEpoch === right.providerEpoch && + left.connectionGeneration === right.connectionGeneration + ) +} + +export function withoutTabIds( + source: Record, + tabIds: ReadonlySet +): Record { + let next = source + for (const tabId of tabIds) { + if (!(tabId in next)) { + continue + } + if (next === source) { + next = { ...source } + } + delete next[tabId] + } + return next +} + +export function pruneObsoleteAuthorityState( + state: DirectSshTerminalBindingState, + authority: DirectSshAuthority +): Pick< + DirectSshTerminalBindingState, + | 'directSshPaneRetryByTabId' + | 'directSshLivePtyBindingByTabId' + | 'directSshPaneRetryHistoryByTabId' +> { + const prune = ( + source: Record + ): Record => { + const obsoleteIds = Object.entries(source) + .filter( + ([, value]) => + value.authority.targetId === authority.targetId && + !directSshAuthoritiesEqual(value.authority, authority) + ) + .map(([tabId]) => tabId) + return withoutTabIds(source, new Set(obsoleteIds)) + } + return { + directSshPaneRetryByTabId: prune(state.directSshPaneRetryByTabId), + directSshLivePtyBindingByTabId: prune(state.directSshLivePtyBindingByTabId), + directSshPaneRetryHistoryByTabId: prune(state.directSshPaneRetryHistoryByTabId) + } +} + +export function liveBindingMatches( + tab: TerminalTab, + binding: DirectSshLivePtyBinding | undefined, + authority: DirectSshAuthority +): boolean { + return Boolean( + binding && + directSshAuthoritiesEqual(binding.authority, authority) && + binding.tabGeneration === (tab.generation ?? 0) && + (binding.ptyId === tab.ptyId || (tab.ptyId == null && Boolean(tab.pendingActivationSpawn))) + ) +} + +export function settleDirectSshPaneRetryState( + state: DirectSshTerminalBindingState, + result: DirectSshPaneRetryResult +): Partial | null { + const pending = state.directSshPaneRetryByTabId[result.tabId] + const live = state.directSshLivePtyBindingByTabId[result.tabId] + const pendingMatches = Boolean( + pending && + pending.attemptId === result.attemptId && + directSshAuthoritiesEqual(pending.authority, result.authority) && + pending.tabGeneration === result.tabGeneration + ) + const liveMatches = Boolean( + live && + live.attemptId === result.attemptId && + directSshAuthoritiesEqual(live.authority, result.authority) && + live.tabGeneration === result.tabGeneration + ) + if (!pendingMatches && !liveMatches) { + return null + } + const tabIds = new Set([result.tabId]) + const nextPending = pendingMatches + ? withoutTabIds(state.directSshPaneRetryByTabId, tabIds) + : state.directSshPaneRetryByTabId + if (result.status !== 'success') { + return { + directSshPaneRetryByTabId: nextPending, + directSshLivePtyBindingByTabId: liveMatches + ? withoutTabIds(state.directSshLivePtyBindingByTabId, tabIds) + : state.directSshLivePtyBindingByTabId + } + } + const tab = Object.values(state.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === result.tabId) + if ( + !tab || + (tab.generation ?? 0) !== result.tabGeneration || + !tab.ptyId || + !(state.ptyIdsByTabId[result.tabId] ?? []).includes(result.ptyId) + ) { + return null + } + return { + directSshPaneRetryByTabId: nextPending, + directSshLivePtyBindingByTabId: { + ...state.directSshLivePtyBindingByTabId, + [result.tabId]: { + attemptId: result.attemptId, + authority: result.authority, + tabGeneration: result.tabGeneration, + ptyId: liveMatches && live ? live.ptyId : tab.ptyId + } + } + } +} + +export function transferDirectSshPaneDetachLedger( + state: DirectSshTerminalBindingState, + args: { + detachedPtyId: string | null + sourcePtyId: string | null + sourceTabId: string + targetTabId: string + isAuthorityCurrent: (authority: DirectSshAuthority) => boolean + } +): Pick< + DirectSshTerminalBindingState, + | 'directSshPaneRetryByTabId' + | 'directSshLivePtyBindingByTabId' + | 'directSshPaneRetryHistoryByTabId' +> { + const tabIds = new Set([args.sourceTabId, args.targetTabId]) + let directSshPaneRetryByTabId = withoutTabIds(state.directSshPaneRetryByTabId, tabIds) + let directSshLivePtyBindingByTabId = withoutTabIds(state.directSshLivePtyBindingByTabId, tabIds) + let directSshPaneRetryHistoryByTabId = withoutTabIds( + state.directSshPaneRetryHistoryByTabId, + tabIds + ) + const tabs = Object.values(state.tabsByWorktree).flat() + const sourceTab = tabs.find((tab) => tab.id === args.sourceTabId) + const targetTab = tabs.find((tab) => tab.id === args.targetTabId) + const live = state.directSshLivePtyBindingByTabId[args.sourceTabId] + const pending = state.directSshPaneRetryByTabId[args.sourceTabId] + const sourceHasPendingContinuation = Boolean( + sourceTab && !args.sourcePtyId && sourceTab.pendingActivationSpawn + ) + const targetHasPendingContinuation = Boolean( + targetTab && !args.detachedPtyId && targetTab.pendingActivationSpawn + ) + const hasEmptyPendingContinuation = Boolean( + pending && + sourceHasPendingContinuation && + targetHasPendingContinuation && + pending.tabGeneration === (sourceTab?.generation ?? 0) && + args.isAuthorityCurrent(pending.authority) + ) + const liveLease = + live && sourceTab && liveBindingMatches(sourceTab, live, live.authority) ? live : null + const pendingLease = + pending && + sourceTab && + pending.tabGeneration === (sourceTab.generation ?? 0) && + ([args.detachedPtyId, args.sourcePtyId].some( + (ptyId) => parseAppSshPtyId(ptyId ?? '')?.connectionId === pending.authority.targetId + ) || + hasEmptyPendingContinuation) + ? pending + : null + const sourceLease = liveLease ?? pendingLease + const authority = sourceLease?.authority + if ( + sourceLease && + authority && + targetTab && + (args.detachedPtyId || targetHasPendingContinuation) && + args.isAuthorityCurrent(authority) + ) { + const nextLiveBindings = { ...directSshLivePtyBindingByTabId } + if ( + sourceTab && + ((args.sourcePtyId && + parseAppSshPtyId(args.sourcePtyId)?.connectionId === authority.targetId) || + sourceHasPendingContinuation) + ) { + if (liveLease) { + nextLiveBindings[args.sourceTabId] = { + attemptId: sourceLease.attemptId, + authority, + tabGeneration: sourceTab.generation ?? 0, + ptyId: args.sourcePtyId ?? liveLease.ptyId + } + } else if (pendingLease) { + directSshPaneRetryByTabId = { + ...directSshPaneRetryByTabId, + [args.sourceTabId]: pendingLease + } + } + } + if (args.detachedPtyId || liveLease) { + nextLiveBindings[args.targetTabId] = { + attemptId: sourceLease.attemptId, + authority, + tabGeneration: targetTab.generation ?? 0, + ptyId: args.detachedPtyId ?? liveLease!.ptyId + } + } else if (pendingLease) { + directSshPaneRetryByTabId = { + ...directSshPaneRetryByTabId, + [args.targetTabId]: { + ...pendingLease, + tabGeneration: targetTab.generation ?? 0 + } + } + } + directSshLivePtyBindingByTabId = nextLiveBindings + const history = state.directSshPaneRetryHistoryByTabId[args.sourceTabId] + if (history && directSshAuthoritiesEqual(history.authority, authority)) { + directSshPaneRetryHistoryByTabId = { + ...directSshPaneRetryHistoryByTabId, + ...(args.sourcePtyId || sourceHasPendingContinuation + ? { [args.sourceTabId]: history } + : {}), + [args.targetTabId]: history + } + } + } + return { + directSshPaneRetryByTabId, + directSshLivePtyBindingByTabId, + directSshPaneRetryHistoryByTabId + } +} diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts b/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts new file mode 100644 index 00000000000..2784559f201 --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts @@ -0,0 +1,64 @@ +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import type { TerminalTab } from '../../../../shared/types' +import type { CodexRestartNotice } from './terminals' + +export type DirectSshPaneRetryAttemptId = string & { + readonly __directSshPaneRetryAttemptId: unique symbol +} + +export type DirectSshPaneRetryAttempt = { + attemptId: DirectSshPaneRetryAttemptId + authority: DirectSshAuthority + tabGeneration: number + startedAt: number +} + +export type DirectSshLivePtyBinding = { + attemptId: DirectSshPaneRetryAttemptId + authority: DirectSshAuthority + tabGeneration: number + ptyId: string +} + +export type DirectSshPaneRetryHistory = { + authority: DirectSshAuthority + attemptedAt: number[] +} + +export type DirectSshPaneRetryResult = + | { + status: 'success' + tabId: string + attemptId: DirectSshPaneRetryAttemptId + authority: DirectSshAuthority + tabGeneration: number + ptyId: string + } + | { + status: 'failed' | 'timed-out' | 'superseded' + tabId: string + attemptId: DirectSshPaneRetryAttemptId + authority: DirectSshAuthority + tabGeneration: number + } + +export type DirectSshTerminalBindingState = { + tabsByWorktree: Record + ptyIdsByTabId: Record + lastKnownRelayPtyIdByTabId: Record + pendingCodexPaneRestartIds: Record + codexRestartNoticeByPtyId: Record + directSshPaneRetryByTabId: Record + directSshLivePtyBindingByTabId: Record + directSshPaneRetryHistoryByTabId: Record +} + +export type DirectSshTerminalBindingClearResult = { + clearedCount: number + patch: Partial | null +} + +export type DirectSshTerminalRetryResult = { + retriedCount: number + patch: Partial | null +} diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts new file mode 100644 index 00000000000..acc1db0ec72 --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalTab } from '../../../../shared/types' +import { + clearDirectSshTerminalBindings, + type DirectSshTerminalBindingState +} from './direct-ssh-terminal-recovery' +import { createTestStore, makeWorktree } from './store-test-helpers' + +function makeTab(id: string, ptyId: string | null, pendingActivationSpawn?: true): TerminalTab { + return { + id, + ptyId, + worktreeId: 'repo::/work', + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...(pendingActivationSpawn ? { pendingActivationSpawn } : {}) + } +} + +function makeState(): DirectSshTerminalBindingState { + return { + tabsByWorktree: { + 'repo::/ssh-work': [ + makeTab('ssh-live', 'ssh-target@@pty-1', true), + makeTab('ssh-unbound', null, true) + ], + 'folder::ssh-work': [makeTab('folder-live', 'ssh-target@@pty-2')], + 'repo::/other-host': [makeTab('other-live', 'ssh-other@@pty-3', true)] + }, + ptyIdsByTabId: { + 'ssh-live': ['ssh-target@@pty-1', 'ssh-target@@pty-split'], + 'ssh-unbound': [], + 'folder-live': ['ssh-target@@pty-2'], + 'other-live': ['ssh-other@@pty-3'] + }, + lastKnownRelayPtyIdByTabId: { + 'ssh-live': 'ssh-target@@pty-1', + 'folder-live': 'ssh-target@@pty-2', + 'other-live': 'ssh-other@@pty-3' + }, + pendingCodexPaneRestartIds: { + 'ssh-target@@pty-1': true, + 'ssh-target@@pty-split': true, + 'ssh-other@@pty-3': true + }, + codexRestartNoticeByPtyId: { + 'ssh-target@@pty-1': { + previousAccountLabel: 'old', + nextAccountLabel: 'new' + }, + 'ssh-other@@pty-3': { + previousAccountLabel: 'old', + nextAccountLabel: 'new' + } + }, + directSshPaneRetryByTabId: {}, + directSshLivePtyBindingByTabId: {}, + directSshPaneRetryHistoryByTabId: {} + } +} + +describe('clearDirectSshTerminalBindings', () => { + it('clears exact Git and folder workspace bindings in one projection', () => { + const state = makeState() + const result = clearDirectSshTerminalBindings( + state, + new Set(['repo::/ssh-work', 'folder::ssh-work']) + ) + + expect(result.clearedCount).toBe(2) + expect(result.patch?.tabsByWorktree?.['repo::/ssh-work']).toEqual([ + expect.objectContaining({ id: 'ssh-live', ptyId: null }), + state.tabsByWorktree['repo::/ssh-work'][1] + ]) + expect(result.patch?.tabsByWorktree?.['repo::/ssh-work'][0]).not.toHaveProperty( + 'pendingActivationSpawn' + ) + expect(result.patch?.tabsByWorktree?.['repo::/ssh-work'][1]).toBe( + state.tabsByWorktree['repo::/ssh-work'][1] + ) + expect(result.patch?.tabsByWorktree?.['folder::ssh-work'][0].ptyId).toBeNull() + expect(result.patch?.ptyIdsByTabId).toMatchObject({ + 'ssh-live': [], + 'folder-live': [], + 'other-live': ['ssh-other@@pty-3'] + }) + }) + + it('preserves relay reattach ids and every other host', () => { + const state = makeState() + const result = clearDirectSshTerminalBindings(state, new Set(['repo::/ssh-work'])) + + expect(result.patch).not.toHaveProperty('lastKnownRelayPtyIdByTabId') + expect(state.lastKnownRelayPtyIdByTabId['ssh-live']).toBe('ssh-target@@pty-1') + expect(result.patch?.tabsByWorktree?.['repo::/other-host']).toBe( + state.tabsByWorktree['repo::/other-host'] + ) + expect(result.patch?.pendingCodexPaneRestartIds).toEqual({ + 'ssh-other@@pty-3': true + }) + expect(result.patch?.codexRestartNoticeByPtyId).toEqual({ + 'ssh-other@@pty-3': { + previousAccountLabel: 'old', + nextAccountLabel: 'new' + } + }) + }) + + it('re-arms reconnectable disconnects without erasing retry history', () => { + const state = makeState() + const authority = { + targetId: 'target', + providerEpoch: 'epoch-1', + connectionGeneration: 1 + } + state.directSshPaneRetryByTabId['ssh-live'] = { + attemptId: 'attempt-1', + authority, + tabGeneration: 1, + startedAt: 10 + } as never + state.directSshLivePtyBindingByTabId['folder-live'] = { + authority, + tabGeneration: 0, + ptyId: 'ssh-target@@pty-2' + } as never + state.directSshPaneRetryHistoryByTabId['ssh-live'] = { + authority, + attemptedAt: [10] + } as never + + const result = clearDirectSshTerminalBindings( + state, + new Set(['repo::/ssh-work', 'folder::ssh-work']) + ) + + expect(result.patch?.directSshPaneRetryByTabId).toEqual({}) + expect(result.patch?.directSshLivePtyBindingByTabId).toEqual({}) + expect(result.patch).not.toHaveProperty('directSshPaneRetryHistoryByTabId') + expect(state.directSshPaneRetryHistoryByTabId['ssh-live']?.attemptedAt).toEqual([10]) + }) + + it('clears an exact target through one Zustand publication without activity changes', () => { + const store = createTestStore() + const worktreeId = 'repo-ssh::/work/demo' + const tab = { ...makeTab('ssh-live', 'ssh-target@@pty-1', true), worktreeId } + store.setState({ + repos: [ + { + id: 'repo-ssh', + path: '/work/demo', + displayName: 'demo', + badgeColor: '#000', + addedAt: 1, + connectionId: 'target' + } + ], + worktreesByRepo: { + 'repo-ssh': [ + makeWorktree({ + id: worktreeId, + repoId: 'repo-ssh', + path: '/work/demo', + hostId: 'ssh:target' + }) + ] + }, + tabsByWorktree: { [worktreeId]: [tab] }, + ptyIdsByTabId: { [tab.id]: ['ssh-target@@pty-1'] }, + lastKnownRelayPtyIdByTabId: { [tab.id]: 'ssh-target@@pty-1' }, + sortEpoch: 9 + }) + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + expect(store.getState().clearDirectSshTargetPtyBindings('target')).toBe(1) + unsubscribe() + + const state = store.getState() + expect(publications).toBe(1) + expect(state.tabsByWorktree[worktreeId][0]).toMatchObject({ ptyId: null }) + expect(state.ptyIdsByTabId[tab.id]).toEqual([]) + expect(state.lastKnownRelayPtyIdByTabId[tab.id]).toBe('ssh-target@@pty-1') + expect(state.sortEpoch).toBe(9) + }) + + it('leaves unbound tabs byte-identical and repeats as a no-op', () => { + const state = makeState() + const first = clearDirectSshTerminalBindings(state, new Set(['repo::/ssh-work'])) + const afterFirst = { ...state, ...first.patch } + const second = clearDirectSshTerminalBindings(afterFirst, new Set(['repo::/ssh-work'])) + + expect(first.patch?.tabsByWorktree?.['repo::/ssh-work'][1]).toBe( + state.tabsByWorktree['repo::/ssh-work'][1] + ) + expect(second).toEqual({ clearedCount: 0, patch: null }) + }) +}) diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts new file mode 100644 index 00000000000..8cc2978379b --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts @@ -0,0 +1,173 @@ +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import type { TerminalTab } from '../../../../shared/types' +import type { CodexRestartNotice } from './terminals' +import { + directSshAuthoritiesEqual, + liveBindingMatches, + pruneObsoleteAuthorityState, + withoutTabIds +} from './direct-ssh-terminal-authority-ledger' +import type { + DirectSshTerminalBindingClearResult, + DirectSshTerminalBindingState +} from './direct-ssh-terminal-recovery-types' +export type { + DirectSshLivePtyBinding, + DirectSshPaneRetryAttempt, + DirectSshPaneRetryAttemptId, + DirectSshPaneRetryHistory, + DirectSshPaneRetryResult, + DirectSshTerminalBindingState +} from './direct-ssh-terminal-recovery-types' + +function clearPtyBinding( + state: DirectSshTerminalBindingState, + tab: TerminalTab, + next: { + ptyIdsByTabId: Record + pendingCodexPaneRestartIds: Record + codexRestartNoticeByPtyId: Record + } +): void { + const clearedPtyIds = new Set([tab.ptyId, ...(state.ptyIdsByTabId[tab.id] ?? [])]) + next.ptyIdsByTabId[tab.id] = [] + for (const ptyId of clearedPtyIds) { + if (!ptyId) { + continue + } + delete next.pendingCodexPaneRestartIds[ptyId] + delete next.codexRestartNoticeByPtyId[ptyId] + } +} + +export function clearDirectSshTerminalBindings( + state: DirectSshTerminalBindingState, + terminalWorkspaceKeys: ReadonlySet, + qualifiedTabIds?: ReadonlySet +): DirectSshTerminalBindingClearResult { + let tabsByWorktree = state.tabsByWorktree + const ptyIdsByTabId = { ...state.ptyIdsByTabId } + const pendingCodexPaneRestartIds = { ...state.pendingCodexPaneRestartIds } + const codexRestartNoticeByPtyId = { ...state.codexRestartNoticeByPtyId } + const scopedTabIds = new Set() + let clearedCount = 0 + + for (const workspaceKey of terminalWorkspaceKeys) { + const tabs = state.tabsByWorktree[workspaceKey] + if (!tabs) { + continue + } + let nextTabs = tabs + for (const [index, tab] of tabs.entries()) { + if (qualifiedTabIds && !qualifiedTabIds.has(tab.id)) { + continue + } + scopedTabIds.add(tab.id) + if (tab.ptyId == null) { + continue + } + const { pendingActivationSpawn: _pendingActivationSpawn, ...tabWithoutActivationSpawn } = tab + void _pendingActivationSpawn + if (nextTabs === tabs) { + nextTabs = [...tabs] + } + nextTabs[index] = { ...tabWithoutActivationSpawn, ptyId: null } + clearPtyBinding(state, tab, { + ptyIdsByTabId, + pendingCodexPaneRestartIds, + codexRestartNoticeByPtyId + }) + clearedCount += 1 + } + if (nextTabs !== tabs) { + if (tabsByWorktree === state.tabsByWorktree) { + tabsByWorktree = { ...state.tabsByWorktree } + } + tabsByWorktree[workspaceKey] = nextTabs + } + } + + const directSshPaneRetryByTabId = withoutTabIds(state.directSshPaneRetryByTabId, scopedTabIds) + const directSshLivePtyBindingByTabId = withoutTabIds( + state.directSshLivePtyBindingByTabId, + scopedTabIds + ) + const recoveryChanged = + directSshPaneRetryByTabId !== state.directSshPaneRetryByTabId || + directSshLivePtyBindingByTabId !== state.directSshLivePtyBindingByTabId + if (clearedCount === 0 && !recoveryChanged) { + return { clearedCount, patch: null } + } + return { + clearedCount, + patch: { + tabsByWorktree, + ptyIdsByTabId, + pendingCodexPaneRestartIds, + codexRestartNoticeByPtyId, + directSshPaneRetryByTabId, + directSshLivePtyBindingByTabId + } + } +} + +export function invalidateStaleDirectSshTerminalBindings( + state: DirectSshTerminalBindingState, + terminalWorkspaceKeys: ReadonlySet, + authority: DirectSshAuthority, + qualifiedTabIds?: ReadonlySet +): DirectSshTerminalBindingClearResult { + const authorityState = pruneObsoleteAuthorityState(state, authority) + const staleTabIds = new Set() + const preservedPendingByTabId: DirectSshTerminalBindingState['directSshPaneRetryByTabId'] = {} + for (const workspaceKey of terminalWorkspaceKeys) { + for (const tab of state.tabsByWorktree[workspaceKey] ?? []) { + if (qualifiedTabIds && !qualifiedTabIds.has(tab.id)) { + continue + } + const liveBinding = authorityState.directSshLivePtyBindingByTabId[tab.id] + const pending = authorityState.directSshPaneRetryByTabId[tab.id] + const hasCurrentLiveBinding = liveBindingMatches(tab, liveBinding, authority) + const hasCurrentPending = + pending != null && + directSshAuthoritiesEqual(pending.authority, authority) && + pending.tabGeneration === (tab.generation ?? 0) + if ( + (tab.ptyId != null && !hasCurrentLiveBinding) || + (liveBinding != null && !hasCurrentLiveBinding) || + (pending != null && !hasCurrentPending) + ) { + staleTabIds.add(tab.id) + if (hasCurrentPending) { + preservedPendingByTabId[tab.id] = pending + } + } + } + } + const cleared = clearDirectSshTerminalBindings( + { ...state, ...authorityState }, + terminalWorkspaceKeys, + staleTabIds + ) + const authorityChanged = Object.keys(authorityState).some( + (key) => + authorityState[key as keyof typeof authorityState] !== + state[key as keyof typeof authorityState] + ) + if (!cleared.patch && !authorityChanged) { + return cleared + } + const preservedPending = + cleared.patch && Object.keys(preservedPendingByTabId).length > 0 + ? { + directSshPaneRetryByTabId: { + ...cleared.patch.directSshPaneRetryByTabId, + ...preservedPendingByTabId + } + } + : {} + return { + clearedCount: cleared.clearedCount, + patch: { ...authorityState, ...cleared.patch, ...preservedPending } + } +} diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts b/src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts new file mode 100644 index 00000000000..0b82798bac3 --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts @@ -0,0 +1,647 @@ +import { describe, expect, it } from 'vitest' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' +import type { DirectSshPaneRetryAttemptId } from './direct-ssh-terminal-recovery' +import { createTestStore, makeTab, makeWorktree } from './store-test-helpers' + +const WORKTREE_ID = 'repo-ssh::/work/demo' +const TAB_ID = 'tab-ssh' + +function authority(epoch = 'epoch-1', generation = 1): DirectSshAuthority { + return { + targetId: 'target', + providerEpoch: epoch as SshProviderEpoch, + connectionGeneration: generation + } +} + +function seedStore(ptyId: string | null = null) { + const store = createTestStore() + const currentAuthority = authority() + store.setState({ + repos: [ + { + id: 'repo-ssh', + path: '/work/demo', + displayName: 'demo', + badgeColor: '#000', + addedAt: 1, + connectionId: 'target', + executionHostId: 'ssh:target' + } + ], + worktreesByRepo: { + 'repo-ssh': [ + makeWorktree({ + id: WORKTREE_ID, + repoId: 'repo-ssh', + path: '/work/demo', + hostId: 'ssh:target' + }) + ] + }, + tabsByWorktree: { + [WORKTREE_ID]: [makeTab({ id: TAB_ID, worktreeId: WORKTREE_ID, ptyId })] + }, + ptyIdsByTabId: { [TAB_ID]: ptyId ? [ptyId] : [] }, + lastKnownRelayPtyIdByTabId: ptyId ? { [TAB_ID]: ptyId } : {}, + sshConnectionStates: new Map([ + [ + 'target', + { + targetId: 'target', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: currentAuthority.providerEpoch, + connectionGeneration: currentAuthority.connectionGeneration + } + ] + ]) + }) + return store +} + +describe('direct SSH terminal retry ledger', () => { + it('invalidates a non-null binding without current-authority evidence atomically', () => { + const ptyId = 'ssh:target@@pty-old' + const store = seedStore(ptyId) + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + expect(store.getState().invalidateStaleDirectSshTargetPtyBindings(authority())).toBe(1) + unsubscribe() + + expect(publications).toBe(1) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].ptyId).toBeNull() + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([]) + expect(store.getState().lastKnownRelayPtyIdByTabId[TAB_ID]).toBe(ptyId) + }) + + it('preserves a healthy current-authority sibling in the same workspace', () => { + const store = seedStore('ssh:target@@pty-stale') + const sibling = makeTab({ + id: 'tab-healthy', + worktreeId: WORKTREE_ID, + ptyId: 'ssh:target@@pty-healthy' + }) + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: [...state.tabsByWorktree[WORKTREE_ID], sibling] + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + 'tab-healthy': ['ssh:target@@pty-healthy'] + }, + directSshLivePtyBindingByTabId: { + 'tab-healthy': { + attemptId: 'healthy-attempt' as DirectSshPaneRetryAttemptId, + authority: authority(), + tabGeneration: 0, + ptyId: 'ssh:target@@pty-healthy' + } + } + })) + + expect(store.getState().invalidateStaleDirectSshTargetPtyBindings(authority())).toBe(1) + + const tabs = store.getState().tabsByWorktree[WORKTREE_ID] + expect(tabs.find((tab) => tab.id === TAB_ID)?.ptyId).toBeNull() + expect(tabs.find((tab) => tab.id === 'tab-healthy')).toBe(sibling) + expect(store.getState().ptyIdsByTabId['tab-healthy']).toEqual(['ssh:target@@pty-healthy']) + expect(store.getState().directSshLivePtyBindingByTabId['tab-healthy']).toBeDefined() + }) + + it('keeps one pending attempt and acknowledges success after the live commit', () => { + const store = seedStore() + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + unsubscribe() + expect(publications).toBe(1) + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_001)).toBe(0) + const retried = store.getState().tabsByWorktree[WORKTREE_ID][0] + expect(retried).toMatchObject({ generation: 1, pendingActivationSpawn: true }) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toMatchObject({ + tabGeneration: 1, + startedAt: 1_000 + }) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + const pendingBeforeStaleSettlement = store.getState().directSshPaneRetryByTabId + store.getState().settleDirectSshPaneRetry({ + status: 'failed', + tabId: TAB_ID, + attemptId: attempt.attemptId, + authority: attempt.authority, + tabGeneration: attempt.tabGeneration - 1 + }) + expect(store.getState().directSshPaneRetryByTabId).toBe(pendingBeforeStaleSettlement) + + store.getState().settleDirectSshPaneRetry( + { + status: 'failed', + tabId: TAB_ID, + attemptId: attempt.attemptId, + authority: authority('stale-epoch', 0), + tabGeneration: attempt.tabGeneration + }, + 1_001 + ) + expect(store.getState().directSshPaneRetryByTabId).toBe(pendingBeforeStaleSettlement) + + store.getState().settleDirectSshPaneRetry({ + status: 'success', + tabId: TAB_ID, + attemptId: attempt.attemptId, + authority: attempt.authority, + tabGeneration: attempt.tabGeneration, + ptyId: 'ssh:target@@pty-new' + }) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeDefined() + + store + .getState() + .updateTabPtyId( + TAB_ID, + 'ssh:target@@pty-new', + undefined, + 'wrong-attempt' as DirectSshPaneRetryAttemptId + ) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeDefined() + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].ptyId).toBeNull() + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([]) + expect(store.getState().lastKnownRelayPtyIdByTabId[TAB_ID]).toBeUndefined() + + store.getState().updateTabPtyId(TAB_ID, 'ssh:target@@pty-new', undefined, attempt.attemptId) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toMatchObject({ + tabGeneration: 1, + ptyId: 'ssh:target@@pty-new' + }) + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_002)).toBe(0) + }) + + it('re-arms one failure and exhausts the authority chain after two attempts', () => { + const store = seedStore() + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + const firstAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.getState().settleDirectSshPaneRetry( + { + status: 'failed', + tabId: TAB_ID, + attemptId: firstAttempt.attemptId, + authority: firstAttempt.authority, + tabGeneration: firstAttempt.tabGeneration + }, + 2_000 + ) + const secondAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + expect(secondAttempt.tabGeneration).toBe(2) + store.getState().settleDirectSshPaneRetry( + { + status: 'timed-out', + tabId: TAB_ID, + attemptId: secondAttempt.attemptId, + authority: secondAttempt.authority, + tabGeneration: secondAttempt.tabGeneration + }, + 3_000 + ) + + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBe(secondAttempt) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generation).toBe(2) + expect(store.getState().retryDirectSshTargetPanes(authority(), 30_999)).toBe(0) + expect(store.getState().retryDirectSshTargetPanes(authority(), 31_000)).toBe(0) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generation).toBe(2) + }) + + it('keeps one split-pane attempt valid until every sibling settles', () => { + const store = seedStore() + store.setState({ + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' }, + second: { type: 'leaf', leafId: '22222222-2222-4222-8222-222222222222' } + }, + activeLeafId: '11111111-1111-4111-8111-111111111111', + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + }) + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].pendingActivationSpawn).toBe(2) + const firstAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + const firstPtyId = 'ssh:target@@pty-first' + const siblingPtyId = 'ssh:target@@pty-sibling' + + store.getState().updateTabPtyId(TAB_ID, firstPtyId, undefined, firstAttempt.attemptId) + store.getState().updateTabPtyId(TAB_ID, siblingPtyId, undefined, firstAttempt.attemptId) + + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([firstPtyId, siblingPtyId]) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].ptyId).toBe(firstPtyId) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].pendingActivationSpawn).toBeUndefined() + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toMatchObject({ + attemptId: firstAttempt.attemptId, + ptyId: firstPtyId + }) + + store.getState().settleDirectSshPaneRetry( + { + status: 'failed', + tabId: TAB_ID, + attemptId: firstAttempt.attemptId, + authority: firstAttempt.authority, + tabGeneration: firstAttempt.tabGeneration + }, + 2_000 + ) + const secondAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + expect(secondAttempt.tabGeneration).toBe(2) + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([]) + + const beforeStaleCallback = store.getState() + store + .getState() + .updateTabPtyId(TAB_ID, 'ssh:target@@pty-stale', undefined, firstAttempt.attemptId) + store.getState().settleDirectSshPaneRetry( + { + status: 'failed', + tabId: TAB_ID, + attemptId: firstAttempt.attemptId, + authority: firstAttempt.authority, + tabGeneration: firstAttempt.tabGeneration + }, + 2_001 + ) + + expect(store.getState().tabsByWorktree).toBe(beforeStaleCallback.tabsByWorktree) + expect(store.getState().ptyIdsByTabId).toBe(beforeStaleCallback.ptyIdsByTabId) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBe(secondAttempt) + expect(store.getState().directSshPaneRetryHistoryByTabId[TAB_ID].attemptedAt).toEqual([ + 1_000, 2_000 + ]) + + const secondFirstPtyId = 'ssh:target@@pty-second-first' + const secondSiblingPtyId = 'ssh:target@@pty-second-sibling' + store.getState().updateTabPtyId(TAB_ID, secondFirstPtyId, undefined, secondAttempt.attemptId) + store.getState().settleDirectSshPaneRetry( + { + status: 'failed', + tabId: TAB_ID, + attemptId: secondAttempt.attemptId, + authority: secondAttempt.authority, + tabGeneration: secondAttempt.tabGeneration + }, + 3_000 + ) + store.getState().updateTabPtyId(TAB_ID, secondSiblingPtyId, undefined, secondAttempt.attemptId) + + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([secondFirstPtyId, secondSiblingPtyId]) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().retryDirectSshTargetPanes(authority(), 3_001)).toBe(0) + }) + + it('promotes a surviving split PTY without revoking its exact retry lease', () => { + const store = seedStore() + store.setState({ activeWorktreeId: null }) + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + const firstPtyId = 'ssh:target@@pty-first' + const siblingPtyId = 'ssh:target@@pty-sibling' + store.getState().updateTabPtyId(TAB_ID, firstPtyId, undefined, attempt.attemptId) + store.getState().updateTabPtyId(TAB_ID, siblingPtyId, undefined, attempt.attemptId) + + store.getState().clearTabPtyId(TAB_ID, firstPtyId) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].ptyId).toBe(siblingPtyId) + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([siblingPtyId]) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toMatchObject({ + attemptId: attempt.attemptId, + ptyId: siblingPtyId + }) + expect(store.getState().retryDirectSshTargetPanes(authority(), 2_000)).toBe(0) + }) + + it('keeps exact retry authority through a primary-exit gap before a sibling binds', () => { + const store = seedStore() + store.setState({ + activeWorktreeId: null, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' }, + second: { type: 'leaf', leafId: '22222222-2222-4222-8222-222222222222' } + }, + activeLeafId: '11111111-1111-4111-8111-111111111111', + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + }) + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + const firstPtyId = 'ssh:target@@pty-first' + const siblingPtyId = 'ssh:target@@pty-sibling' + + store.getState().updateTabPtyId(TAB_ID, firstPtyId, undefined, attempt.attemptId) + store.getState().clearTabPtyId(TAB_ID, firstPtyId) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0]).toMatchObject({ + ptyId: null, + pendingActivationSpawn: true + }) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toMatchObject({ + attemptId: attempt.attemptId, + ptyId: firstPtyId + }) + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_001)).toBe(0) + + store.getState().updateTabPtyId(TAB_ID, siblingPtyId, undefined, attempt.attemptId) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].ptyId).toBe(siblingPtyId) + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([siblingPtyId]) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toMatchObject({ + attemptId: attempt.attemptId, + ptyId: siblingPtyId + }) + }) + + it('does not turn two thirty-one-second timeouts into an unbounded retry chain', () => { + const store = seedStore() + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + const firstAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.getState().settleDirectSshPaneRetry( + { + status: 'timed-out', + tabId: TAB_ID, + attemptId: firstAttempt.attemptId, + authority: firstAttempt.authority, + tabGeneration: firstAttempt.tabGeneration + }, + 32_000 + ) + const secondAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + expect(secondAttempt.tabGeneration).toBe(2) + + store.getState().settleDirectSshPaneRetry( + { + status: 'timed-out', + tabId: TAB_ID, + attemptId: secondAttempt.attemptId, + authority: secondAttempt.authority, + tabGeneration: secondAttempt.tabGeneration + }, + 63_000 + ) + + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBe(secondAttempt) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generation).toBe(2) + expect(store.getState().retryDirectSshTargetPanes(authority(), 63_000)).toBe(0) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generation).toBe(2) + }) + + it('re-arms only the exact failed tab', () => { + const store = seedStore() + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + const firstAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + const sibling = makeTab({ + id: 'tab-unrelated', + worktreeId: WORKTREE_ID, + ptyId: null + }) + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: [...state.tabsByWorktree[WORKTREE_ID], sibling] + }, + ptyIdsByTabId: { ...state.ptyIdsByTabId, [sibling.id]: [] } + })) + + store.getState().settleDirectSshPaneRetry( + { + status: 'failed', + tabId: TAB_ID, + attemptId: firstAttempt.attemptId, + authority: firstAttempt.authority, + tabGeneration: firstAttempt.tabGeneration + }, + 2_000 + ) + + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]?.tabGeneration).toBe(2) + expect(store.getState().directSshPaneRetryByTabId[sibling.id]).toBeUndefined() + expect( + store.getState().tabsByWorktree[WORKTREE_ID].find((tab) => tab.id === sibling.id) + ?.generation ?? 0 + ).toBe(0) + }) + + it('re-arms a pending attempt when its bound SSH PTY exits', () => { + const store = seedStore() + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => ({ + ...tab, + ptyId: 'ssh:target@@pty-failed' + })) + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + [TAB_ID]: ['ssh:target@@pty-failed'] + } + })) + + store.getState().clearTabPtyId(TAB_ID, 'ssh:target@@pty-failed') + + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().retryDirectSshTargetPanes(authority(), 2_000)).toBe(1) + }) + + it('clearing a successful binding re-arms it without discarding relay identity', () => { + const store = seedStore() + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.getState().updateTabPtyId(TAB_ID, 'ssh:target@@pty-new', undefined, attempt.attemptId) + + store.getState().clearTabPtyId(TAB_ID) + + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().lastKnownRelayPtyIdByTabId[TAB_ID]).toBe('ssh:target@@pty-new') + expect(store.getState().retryDirectSshTargetPanes(authority(), 2_000)).toBe(1) + }) + + it('removes successful evidence when a snapshot overwrites the live binding', () => { + const store = seedStore() + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.getState().updateTabPtyId(TAB_ID, 'ssh:target@@pty-new', undefined, attempt.attemptId) + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => ({ + ...tab, + ptyId: null + })) + }, + ptyIdsByTabId: { ...state.ptyIdsByTabId, [TAB_ID]: [] } + })) + + expect(store.getState().retryDirectSshTargetPanes(authority(), 2_000)).toBe(1) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeDefined() + }) + + it('keeps snapshot-restored PTY hints eligible for corrective retry', () => { + const store = seedStore() + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => ({ + ...tab, + ptyId: 'ssh:target@@pty-snapshot' + })) + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + [TAB_ID]: ['ssh:target@@pty-snapshot'] + } + })) + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toMatchObject({ + authority: authority(), + tabGeneration: 1 + }) + }) + + it('clears a snapshot hint without superseding its current pending attempt', () => { + const store = seedStore() + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + const pending = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => ({ + ...tab, + ptyId: 'ssh:target@@pty-snapshot' + })) + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + [TAB_ID]: ['ssh:target@@pty-snapshot'] + } + })) + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_001)).toBe(0) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0]).toMatchObject({ + generation: 1, + ptyId: null + }) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBe(pending) + expect(store.getState().directSshPaneRetryHistoryByTabId[TAB_ID].attemptedAt).toEqual([1_000]) + }) + + it('rotates obsolete pending, success, and retry history by exact authority', () => { + const store = seedStore() + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.getState().updateTabPtyId(TAB_ID, 'ssh:target@@pty-new', undefined, attempt.attemptId) + const nextAuthority = authority('epoch-2', 2) + store.setState({ + sshConnectionStates: new Map([ + [ + 'target', + { + targetId: 'target', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: nextAuthority.providerEpoch, + connectionGeneration: nextAuthority.connectionGeneration + } + ] + ]) + }) + + expect(store.getState().retryDirectSshTargetPanes(nextAuthority, 1_001)).toBe(1) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]?.authority).toEqual(nextAuthority) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().directSshPaneRetryHistoryByTabId[TAB_ID]).toEqual({ + authority: nextAuthority, + attemptedAt: [1_001] + }) + }) + + it('fails closed when the store no longer names the exact authority', () => { + const store = seedStore() + const stale = authority('stale', 0) + const before = store.getState() + + expect(store.getState().retryDirectSshTargetPanes(stale, 1_000)).toBe(0) + expect(store.getState().invalidateStaleDirectSshTargetPtyBindings(stale)).toBe(0) + expect(store.getState().tabsByWorktree).toBe(before.tabsByWorktree) + expect(store.getState().directSshPaneRetryByTabId).toBe(before.directSshPaneRetryByTabId) + }) + + it('retries a stale-catalog tab from retained SSH ownership exactly once', () => { + const store = seedStore() + store.setState({ + repos: [], + worktreesByRepo: {}, + lastKnownRelayPtyIdByTabId: { [TAB_ID]: 'ssh:target@@pty-retained' }, + tabsByWorktree: { + [WORKTREE_ID]: [makeTab({ id: TAB_ID, worktreeId: WORKTREE_ID, ptyId: null })], + local: [makeTab({ id: 'tab-local', worktreeId: 'local', ptyId: null })] + } + }) + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_001)).toBe(0) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generation).toBe(1) + expect(store.getState().tabsByWorktree.local[0].generation ?? 0).toBe(0) + }) + + it('removes all direct SSH retry ledgers when a tab closes', () => { + const store = seedStore() + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.getState().updateTabPtyId(TAB_ID, 'ssh:target@@pty-live', undefined, attempt.attemptId) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeDefined() + store.setState((state) => ({ + directSshPaneRetryByTabId: { + ...state.directSshPaneRetryByTabId, + [TAB_ID]: { + attemptId: 'pending-close' as DirectSshPaneRetryAttemptId, + authority: authority(), + tabGeneration: 1, + startedAt: 2_000 + } + } + })) + + store.getState().closeTab(TAB_ID, { reason: 'pty-exit' }) + + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().directSshPaneRetryHistoryByTabId[TAB_ID]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts b/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts new file mode 100644 index 00000000000..063a773e1fa --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalTab } from '../../../../shared/types' +import { resolveDirectSshTerminalWorkspaceKeys } from './direct-ssh-terminal-workspace-scope' + +function tab(worktreeId: string, ptyId: string): TerminalTab { + return { + id: `tab-${worktreeId}`, + worktreeId, + ptyId, + title: worktreeId, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +describe('resolveDirectSshTerminalWorkspaceKeys', () => { + it('uses a parsed live target PTY when the catalog row is stale', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { targetId: 'target-a', catalogRevision: 1, repos: [] }, + { 'stale-worktree': [tab('stale-worktree', 'ssh:target-a@@pty-1')] } + ) + ).toEqual(new Set(['stale-worktree'])) + }) + + it('uses a retained target PTY after disconnect clears the live binding', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { targetId: 'target-a', catalogRevision: 1, repos: [] }, + { 'stale-worktree': [tab('stale-worktree', '')] }, + { 'tab-stale-worktree': 'ssh:target-a@@pty-1' } + ) + ).toEqual(new Set(['stale-worktree'])) + }) + + it('refuses retained PTY fallback when explicit ownership names another host', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [], + worktreesByRepo: { + repo: [ + { + id: 'contradictory-worktree', + repoId: 'repo', + hostId: 'ssh:target-b' + } + ] + } + }, + { 'contradictory-worktree': [tab('contradictory-worktree', '')] }, + { 'tab-contradictory-worktree': 'ssh:target-a@@pty-1' } + ) + ).toEqual(new Set()) + }) + + it('refuses retained PTY fallback when a repo id is owned by two SSH hosts', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [ + { + id: 'shared', + path: '/srv/a/shared', + projectGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + }, + { + id: 'shared', + path: '/srv/b/shared', + projectGroupId: null, + connectionId: 'target-b', + executionHostId: 'ssh:target-b' + } + ], + worktreesByRepo: { + shared: [{ id: 'shared::/work', repoId: 'shared' }] + } + }, + { 'shared::/work': [tab('shared::/work', '')] }, + { 'tab-shared::/work': 'ssh:target-a@@pty-retained' } + ) + ).toEqual(new Set()) + }) + + it('refuses a retained target when the current PTY names another SSH host', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { targetId: 'target-a', catalogRevision: 1, repos: [] }, + { + 'stale-worktree': [ + tab('stale-worktree', 'ssh:target-b@@pty-live'), + tab('stale-worktree', '') + ] + }, + { 'tab-stale-worktree': 'ssh:target-a@@pty-retained' } + ) + ).toEqual(new Set()) + }) + + it('refuses parsed PTY fallback when explicit worktree ownership names another host', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [], + worktreesByRepo: { + repo: [ + { + id: 'contradictory-worktree', + repoId: 'repo', + hostId: 'ssh:target-b' + } + ] + } + }, + { + 'contradictory-worktree': [tab('contradictory-worktree', 'ssh:target-a@@pty-1')] + } + ) + ).toEqual(new Set()) + }) + + it('refuses parsed PTY fallback when explicit host provenance is malformed', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [], + worktreesByRepo: { + repo: [ + { + id: 'malformed-worktree', + repoId: 'repo', + hostId: 'ssh:%' + } + ] + } + }, + { + 'malformed-worktree': [tab('malformed-worktree', 'ssh:target-a@@pty-1')] + } + ) + ).toEqual(new Set()) + }) + + it('refuses folder PTY fallback when project host provenance is malformed', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [], + folderWorkspaces: [ + { + id: 'folder-a', + projectGroupId: 'group-a', + folderPath: '/srv/project', + connectionId: 'target-a' + } + ], + projectGroups: [ + { + id: 'group-a', + parentGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:%' + } + ] + }, + { + 'folder:folder-a': [tab('folder:folder-a', 'ssh:target-a@@pty-1')] + } + ) + ).toEqual(new Set()) + }) + + it('refuses fallback when explicit worktree and repo-derived ownership disagree', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [ + { + id: 'repo', + path: '/srv/repo', + projectGroupId: null, + connectionId: 'target-b', + executionHostId: 'ssh:target-b' + } + ], + worktreesByRepo: { + repo: [ + { + id: 'contradictory-worktree', + repoId: 'repo', + hostId: 'ssh:target-a' + } + ] + } + }, + { + 'contradictory-worktree': [tab('contradictory-worktree', 'ssh:target-a@@pty-1')] + } + ) + ).toEqual(new Set()) + }) + + it('refuses runtime-owned and contradictory folder provenance', () => { + const keys = resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [], + worktreesByRepo: { + repo: [ + { + id: 'runtime-worktree', + repoId: 'repo', + hostId: 'ssh:target-a', + runtimeOwnerEnvironmentId: 'runtime-1' + } + ] + }, + folderWorkspaces: [ + { + id: 'folder-b', + projectGroupId: 'group-b', + folderPath: '/srv/project', + connectionId: 'target-b' + } + ], + projectGroups: [ + { + id: 'group-b', + parentGroupId: null, + connectionId: 'target-b', + executionHostId: 'ssh:target-b' + } + ] + }, + { + 'runtime-worktree': [tab('runtime-worktree', 'ssh:target-a@@pty-1')], + 'folder:folder-b': [tab('folder:folder-b', 'ssh:target-a@@pty-2')] + } + ) + + expect(keys).toEqual(new Set()) + }) +}) diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.ts b/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.ts new file mode 100644 index 00000000000..c970e4fe451 --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.ts @@ -0,0 +1,130 @@ +import { + getRepoExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId +} from '../../../../shared/execution-host' +import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' +import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { TerminalTab } from '../../../../shared/types' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' +import { + resolveDirectSshTargetScope, + type DirectSshTargetScopeInput +} from '../../lib/direct-ssh-target-scope' + +function isExplicitContradictoryHost( + rawHostId: string | null | undefined, + expectedHostId: string +): boolean { + if (!rawHostId?.trim()) { + return false + } + const parsed = parseExecutionHostId(rawHostId) + return parsed == null || parsed.id !== expectedHostId +} + +function worktreeHasContradictoryOwner( + input: DirectSshTargetScopeInput, + worktreeId: string +): boolean { + const rows = [ + ...Object.values(input.worktreesByRepo ?? {}).flat(), + ...Object.values(input.detectedWorktreesByRepo ?? {}).flatMap((entry) => entry.worktrees) + ].filter((worktree) => worktree.id === worktreeId) + const expectedHostId = toSshExecutionHostId(input.targetId) + for (const row of rows) { + if ( + row.runtimeOwnerEnvironmentId?.trim() || + isExplicitContradictoryHost(row.hostId, expectedHostId) + ) { + return true + } + const repos = input.repos.filter((repo) => repo.id === row.repoId) + if (repos.some((repo) => getRepoExecutionHostId(repo) !== expectedHostId)) { + return true + } + } + const restored = input.restoredRuntimeHostIdByWorkspaceSessionKey + return ( + isExplicitContradictoryHost(restored?.[worktreeId], expectedHostId) || + isExplicitContradictoryHost(restored?.[worktreeWorkspaceKey(worktreeId)], expectedHostId) + ) +} + +function folderHasContradictoryOwner( + input: DirectSshTargetScopeInput, + folderWorkspaceId: string +): boolean { + const expectedHostId = toSshExecutionHostId(input.targetId) + const folders = (input.folderWorkspaces ?? []).filter((folder) => folder.id === folderWorkspaceId) + for (const folder of folders) { + if (folder.connectionId?.trim() && folder.connectionId.trim() !== input.targetId) { + return true + } + const groups = (input.projectGroups ?? []).filter((group) => group.id === folder.projectGroupId) + if ( + groups.some( + (group) => + (group.connectionId?.trim() && group.connectionId.trim() !== input.targetId) || + isExplicitContradictoryHost(group.executionHostId, expectedHostId) + ) + ) { + return true + } + const groupIds = getProjectGroupSubtreeIds(input.projectGroups ?? [], folder.projectGroupId) + const candidateRepos = input.repos.filter( + (repo) => + (repo.projectGroupId != null && groupIds.has(repo.projectGroupId)) || + isPathInsideOrEqual(folder.folderPath, repo.path) + ) + if (candidateRepos.some((repo) => getRepoExecutionHostId(repo) !== expectedHostId)) { + return true + } + } + return isExplicitContradictoryHost( + input.restoredRuntimeHostIdByWorkspaceSessionKey?.[folderWorkspaceKey(folderWorkspaceId)], + expectedHostId + ) +} + +function workspaceHasContradictoryOwner( + input: DirectSshTargetScopeInput, + workspaceKey: string +): boolean { + if (workspaceKey.startsWith('folder:')) { + return folderHasContradictoryOwner(input, workspaceKey.slice('folder:'.length)) + } + return worktreeHasContradictoryOwner(input, workspaceKey) +} + +export function resolveDirectSshTerminalWorkspaceKeys( + input: DirectSshTargetScopeInput, + tabsByWorktree: Readonly>, + lastKnownRelayPtyIdByTabId: Readonly> = {} +): Set { + const keys = new Set(resolveDirectSshTargetScope(input).terminalWorkspaceKeys) + for (const [workspaceKey, tabs] of Object.entries(tabsByWorktree)) { + const liveSshTargets = new Set( + tabs + .map((tab) => parseAppSshPtyId(tab.ptyId ?? '')?.connectionId) + .filter((targetId): targetId is string => Boolean(targetId)) + ) + const retainedSshTargets = new Set( + tabs + .map((tab) => parseAppSshPtyId(lastKnownRelayPtyIdByTabId[tab.id] ?? '')?.connectionId) + .filter((targetId): targetId is string => Boolean(targetId)) + ) + const ptyTargets = liveSshTargets.size > 0 ? liveSshTargets : retainedSshTargets + if ( + keys.has(workspaceKey) || + workspaceHasContradictoryOwner(input, workspaceKey) || + ptyTargets.size !== 1 || + !ptyTargets.has(input.targetId) + ) { + continue + } + keys.add(workspaceKey) + } + return keys +} diff --git a/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts b/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts index f53f3342072..e32f2da3699 100644 --- a/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts +++ b/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts @@ -1,4 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + HostQualifiedDetectedWorktreeResult, + ListDetectedWorktreesArgs +} from '../../../../shared/detected-worktree-provider-contract' +import type { + DirectSshAuthority, + SshConnectionState, + SshProviderEpoch +} from '../../../../shared/ssh-types' import type { Project, ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' import { createTestStore } from './store-test-helpers' @@ -59,6 +68,52 @@ function directSshWorktree(targetId: string, displayName = 'main'): Worktree { } } +function directSshAuthority(targetId: string): DirectSshAuthority { + return { + targetId, + providerEpoch: `epoch-${targetId}` as SshProviderEpoch, + connectionGeneration: 1 + } +} + +function connectedSshState(authority: DirectSshAuthority): SshConnectionState { + return { + targetId: authority.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration + } +} + +function qualifiedWorktreeResult( + request: ListDetectedWorktreesArgs, + worktree: Worktree +): HostQualifiedDetectedWorktreeResult { + if (!('expectedAuthority' in request)) { + throw new Error('Expected a direct SSH provider request') + } + return { + status: 'complete', + providerRequestId: request.providerRequestId, + repoId: request.repoId, + authority: { + kind: 'direct-ssh', + executionHostId: request.executionHostId, + ...request.expectedAuthority + }, + result: { + repoId: request.repoId, + authoritative: true, + source: 'git', + worktrees: [ + { ...worktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } + ] + } + } +} + const project: Project = { id: projectId, displayName: 'Re-adopted repo', @@ -188,17 +243,23 @@ describe('SSH repo host reconciliation', () => { it('rejects an old-host worktree response that resolves after re-adoption', async () => { const staleWorktree = directSshWorktree('ssh-old', 'stale') - let resolveOldWorktrees!: (value: unknown) => void - const oldWorktrees = new Promise((resolve) => { + const oldAuthority = directSshAuthority('ssh-old') + let providerRequest!: ListDetectedWorktreesArgs + let resolveOldWorktrees!: (value: HostQualifiedDetectedWorktreeResult) => void + const oldWorktrees = new Promise((resolve) => { resolveOldWorktrees = resolve }) - worktreesListDetected.mockReturnValueOnce(oldWorktrees) + worktreesListDetected.mockImplementationOnce((request: ListDetectedWorktreesArgs) => { + providerRequest = request + return oldWorktrees + }) reposList.mockResolvedValue([directSshRepo('ssh-new')]) projectsList.mockResolvedValue([project]) setupsList.mockResolvedValue([directSshSetup('ssh-new')]) const store = createTestStore() store.setState({ repos: [directSshRepo('ssh-old')], + sshConnectionStates: new Map([['ssh-old', connectedSshState(oldAuthority)]]), worktreesByRepo: { [repoId]: [staleWorktree] }, detectedWorktreesByRepo: { [repoId]: { @@ -214,17 +275,20 @@ describe('SSH repo host reconciliation', () => { const staleFetch = store.getState().fetchWorktrees(repoId) await vi.waitFor(() => expect(worktreesListDetected).toHaveBeenCalled()) + expect(worktreesListDetected).toHaveBeenCalledOnce() + expect(providerRequest).toMatchObject({ + repoId, + executionHostId: 'ssh:ssh-old', + expectedAuthority: oldAuthority + }) store.getState().recordSshRepoReadoptions([readoption]) await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) - resolveOldWorktrees({ - repoId, - authoritative: true, - source: 'git', - worktrees: [ - { ...staleWorktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } - ] + const newAuthority = directSshAuthority('ssh-new') + store.setState({ + sshConnectionStates: new Map([['ssh-new', connectedSshState(newAuthority)]]) }) - await staleFetch + resolveOldWorktrees(qualifiedWorktreeResult(providerRequest, staleWorktree)) + await expect(staleFetch).resolves.toBe(false) expect(store.getState().worktreesByRepo[repoId]).toEqual([ directSshWorktree('ssh-new', 'stale') @@ -241,26 +305,69 @@ describe('SSH repo host reconciliation', () => { it('rejects a worktree response after its final repo owner is removed', async () => { const staleWorktree = directSshWorktree('ssh-old', 'stale') - let resolveOldWorktrees!: (value: unknown) => void - const oldWorktrees = new Promise((resolve) => { + const oldAuthority = directSshAuthority('ssh-old') + let providerRequest!: ListDetectedWorktreesArgs + let resolveOldWorktrees!: (value: HostQualifiedDetectedWorktreeResult) => void + const oldWorktrees = new Promise((resolve) => { resolveOldWorktrees = resolve }) - worktreesListDetected.mockReturnValueOnce(oldWorktrees) + worktreesListDetected.mockImplementationOnce((request: ListDetectedWorktreesArgs) => { + providerRequest = request + return oldWorktrees + }) const store = createTestStore() - store.setState({ repos: [directSshRepo('ssh-old')] }) + store.setState({ + repos: [directSshRepo('ssh-old')], + sshConnectionStates: new Map([['ssh-old', connectedSshState(oldAuthority)]]) + }) const staleFetch = store.getState().fetchWorktrees(repoId) await vi.waitFor(() => expect(worktreesListDetected).toHaveBeenCalled()) - store.setState({ repos: [], worktreesByRepo: {}, detectedWorktreesByRepo: {} }) - resolveOldWorktrees({ + expect(worktreesListDetected).toHaveBeenCalledOnce() + expect(providerRequest).toMatchObject({ repoId, - authoritative: true, - source: 'git', - worktrees: [ - { ...staleWorktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } - ] + executionHostId: 'ssh:ssh-old', + expectedAuthority: oldAuthority }) - await staleFetch + store.setState({ repos: [], worktreesByRepo: {}, detectedWorktreesByRepo: {} }) + resolveOldWorktrees(qualifiedWorktreeResult(providerRequest, staleWorktree)) + await expect(staleFetch).resolves.toBe(false) + + expect(store.getState().worktreesByRepo[repoId]).toBeUndefined() + expect(store.getState().detectedWorktreesByRepo[repoId]).toBeUndefined() + }) + + it.each([ + ['contradictory', { ...directSshRepo('ssh-old'), connectionId: 'ssh-new' }], + [ + 'malformed', + { ...directSshRepo('ssh-old'), executionHostId: 'ssh:%' as Repo['executionHostId'] } + ], + ['empty explicit host', { ...directSshRepo('ssh-old'), executionHostId: '' }], + ['empty connection', { ...directSshRepo('ssh-old'), connectionId: ' ' }] + ])('rejects a late worktree response when repo owner provenance becomes %s', async (_, owner) => { + const staleWorktree = directSshWorktree('ssh-old', 'stale') + const oldAuthority = directSshAuthority('ssh-old') + let providerRequest!: ListDetectedWorktreesArgs + let resolveOldWorktrees!: (value: HostQualifiedDetectedWorktreeResult) => void + const oldWorktrees = new Promise((resolve) => { + resolveOldWorktrees = resolve + }) + worktreesListDetected.mockImplementationOnce((request: ListDetectedWorktreesArgs) => { + providerRequest = request + return oldWorktrees + }) + const store = createTestStore() + store.setState({ + repos: [directSshRepo('ssh-old')], + sshConnectionStates: new Map([['ssh-old', connectedSshState(oldAuthority)]]) + }) + + const staleFetch = store.getState().fetchWorktrees(repoId) + await vi.waitFor(() => expect(worktreesListDetected).toHaveBeenCalledOnce()) + store.setState({ repos: [owner as Repo] }) + resolveOldWorktrees(qualifiedWorktreeResult(providerRequest, staleWorktree)) + await expect(staleFetch).resolves.toBe(false) expect(store.getState().worktreesByRepo[repoId]).toBeUndefined() expect(store.getState().detectedWorktreesByRepo[repoId]).toBeUndefined() diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.test.ts b/src/renderer/src/store/slices/runtime-environment-ssh.test.ts index f620793730a..acc807073cd 100644 --- a/src/renderer/src/store/slices/runtime-environment-ssh.test.ts +++ b/src/renderer/src/store/slices/runtime-environment-ssh.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { SshConnectionState, SshProviderEpoch } from '../../../../shared/ssh-types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import { createTestStore } from './store-test-helpers' import { @@ -15,7 +15,14 @@ function connState( targetId: string, status: SshConnectionState['status'] = 'connected' ): SshConnectionState { - return { targetId, status, error: null, reconnectAttempt: 0 } + return { + targetId, + status, + error: null, + reconnectAttempt: 0, + providerEpoch: `${targetId}-provider-epoch` as SshProviderEpoch, + connectionGeneration: 7 + } } function markReachable(store: ReturnType, environmentId: string): void { @@ -48,6 +55,10 @@ describe('runtime-environment-ssh slice', () => { // Environment A's bucket: populated. const bucketA = state.sshStateByEnvironment.get(ENV_A) expect(bucketA?.connectionStates.get('ssh-a')?.status).toBe('connected') + expect(bucketA?.connectionStates.get('ssh-a')).toMatchObject({ + providerEpoch: 'ssh-a-provider-epoch', + connectionGeneration: 7 + }) expect(bucketA?.targetLabels.get('ssh-a')).toBe('a-box') expect(bucketA?.removedTargetLabels.get('ssh-dead')).toBe('old box') expect(bucketA?.targetsHydrated).toBe(true) @@ -80,7 +91,7 @@ describe('runtime-environment-ssh slice', () => { expect(store.getState().sshStateByEnvironment.get(ENV_A)).toBe(bucketBefore) }) - it('publishes an authoritative connection generation change in the owning bucket', () => { + it('publishes a generation-only authority change without dropping the provider epoch', () => { const store = createTestStore() store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', { ...connState('ssh-a'), @@ -95,7 +106,33 @@ describe('runtime-environment-ssh slice', () => { const bucketAfter = store.getState().sshStateByEnvironment.get(ENV_A) expect(bucketAfter).not.toBe(bucketBefore) - expect(bucketAfter?.connectionStates.get('ssh-a')?.connectionGeneration).toBe(2) + expect(bucketAfter?.connectionStates.get('ssh-a')).toMatchObject({ + providerEpoch: 'ssh-a-provider-epoch', + connectionGeneration: 2 + }) + }) + + it('publishes an epoch-only authority change without dropping the connection generation', () => { + const store = createTestStore() + store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', { + ...connState('ssh-a'), + providerEpoch: 'provider-a' as never, + connectionGeneration: 1 + }) + const bucketBefore = store.getState().sshStateByEnvironment.get(ENV_A) + + store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', { + ...connState('ssh-a'), + providerEpoch: 'provider-b' as never, + connectionGeneration: 1 + }) + + const bucketAfter = store.getState().sshStateByEnvironment.get(ENV_A) + expect(bucketAfter).not.toBe(bucketBefore) + expect(bucketAfter?.connectionStates.get('ssh-a')).toMatchObject({ + providerEpoch: 'provider-b', + connectionGeneration: 1 + }) }) it('flips the hydrated flag on the first fetch of an empty target list', () => { diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index 3a92d6a7ad2..ba2f5211efc 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -1,6 +1,7 @@ import type { AppState } from '../types' import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { resolveDirectSshTargetScope } from '../../lib/direct-ssh-target-scope' export function sshConnectionStatesEqual( a: SshConnectionState | undefined, @@ -11,6 +12,7 @@ export function sshConnectionStatesEqual( a?.status === b.status && a?.error === b.error && a?.reconnectAttempt === b.reconnectAttempt && + a?.providerEpoch === b.providerEpoch && a?.connectionGeneration === b.connectionGeneration && a?.supportsFolderDownload === b.supportsFolderDownload && a?.remotePlatform === b.remotePlatform @@ -28,15 +30,20 @@ export function sshTargetLabelsEqual( } function collectSshTargetTerminalTabIds(state: AppState, targetId: string): Set { - const repoIds = new Set( - state.repos.filter((repo) => repo.connectionId === targetId).map((repo) => repo.id) - ) + const targetWorktreeIds = resolveDirectSshTargetScope({ + targetId, + catalogRevision: 0, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + }).gitWorktreeIds const tabIds = new Set() - for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { - if (!repoIds.has(repoId)) { - continue - } + for (const worktrees of Object.values(state.worktreesByRepo)) { for (const worktree of worktrees) { + if (!targetWorktreeIds.has(worktree.id)) { + continue + } for (const tab of state.tabsByWorktree[worktree.id] ?? []) { tabIds.add(tab.id) } @@ -80,6 +87,19 @@ function omitRemovedSshTargetTabSessions( return { next, removed } } +function omitRemovedSshTargetRecovery( + entries: Record, + targetId: string, + targetTabIds: ReadonlySet +): { next: Record; removed: boolean } { + const next = Object.fromEntries( + Object.entries(entries).filter( + ([tabId, entry]) => !targetTabIds.has(tabId) && entry.authority.targetId !== targetId + ) + ) + return { next, removed: Object.keys(next).length !== Object.keys(entries).length } +} + function clearSshTargetTabPtyState( state: AppState, targetId: string, @@ -160,6 +180,21 @@ export function buildRemovedSshTargetCleanupPatch( // sweep now reads it as liveness, so clear it here too (#9911). const { next: nextPendingReconnect, removed: removedPendingReconnect } = omitRemovedSshTargetTabSessions(state.pendingReconnectPtyIdByTabId, targetId, targetTabIds) + const { next: nextPaneRetries, removed: removedPaneRetries } = omitRemovedSshTargetRecovery( + state.directSshPaneRetryByTabId, + targetId, + targetTabIds + ) + const { next: nextLiveBindings, removed: removedLiveBindings } = omitRemovedSshTargetRecovery( + state.directSshLivePtyBindingByTabId, + targetId, + targetTabIds + ) + const { next: nextRetryHistory, removed: removedRetryHistory } = omitRemovedSshTargetRecovery( + state.directSshPaneRetryHistoryByTabId, + targetId, + targetTabIds + ) const nextDeferredTargets = state.deferredSshReconnectTargets.filter((id) => id !== targetId) const nextTransientClearedConnections = { @@ -210,7 +245,10 @@ export function buildRemovedSshTargetCleanupPatch( removedCredentialRequest || removedDeferredTarget || removedDeferredSession || - removedPendingReconnect + removedPendingReconnect || + removedPaneRetries || + removedLiveBindings || + removedRetryHistory if (!changed) { return null } @@ -237,6 +275,9 @@ export function buildRemovedSshTargetCleanupPatch( ...(removedCredentialRequest ? { sshCredentialQueue: nextCredentialQueue } : {}), ...(removedDeferredTarget ? { deferredSshReconnectTargets: nextDeferredTargets } : {}), ...(removedDeferredSession ? { deferredSshSessionIdsByTabId: nextDeferredSessions } : {}), - ...(removedPendingReconnect ? { pendingReconnectPtyIdByTabId: nextPendingReconnect } : {}) + ...(removedPendingReconnect ? { pendingReconnectPtyIdByTabId: nextPendingReconnect } : {}), + ...(removedPaneRetries ? { directSshPaneRetryByTabId: nextPaneRetries } : {}), + ...(removedLiveBindings ? { directSshLivePtyBindingByTabId: nextLiveBindings } : {}), + ...(removedRetryHistory ? { directSshPaneRetryHistoryByTabId: nextRetryHistory } : {}) } } diff --git a/src/renderer/src/store/slices/ssh.test.ts b/src/renderer/src/store/slices/ssh.test.ts index f12f12bca82..00ef5532140 100644 --- a/src/renderer/src/store/slices/ssh.test.ts +++ b/src/renderer/src/store/slices/ssh.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { SshProviderEpoch } from '../../../../shared/ssh-types' import { createTestStore, makeTab, makeWorktree, TEST_REPO } from './store-test-helpers' describe('createSshSlice', () => { @@ -12,6 +13,16 @@ describe('createSshSlice', () => { const removedPtyId = toAppSshPtyId(targetId, 'pty-live') const staleLastKnownPtyId = toAppSshPtyId(targetId, 'pty-last-known') const otherPtyId = toAppSshPtyId(otherTargetId, 'pty-other') + const removedAuthority = { + targetId, + providerEpoch: 'removed-epoch' as SshProviderEpoch, + connectionGeneration: 1 + } as const + const otherAuthority = { + targetId: otherTargetId, + providerEpoch: 'other-epoch' as SshProviderEpoch, + connectionGeneration: 2 + } as const store.setState({ repos: [ @@ -114,7 +125,44 @@ describe('createSshSlice', () => { 'tab-ssh': toAppSshPtyId(targetId, 'pty-1'), 'tab-stale-encoded': toAppSshPtyId(targetId, 'pty-9'), 'tab-other': toAppSshPtyId(otherTargetId, 'pty-2') - } + }, + directSshPaneRetryByTabId: { + 'tab-ssh': { + attemptId: 'removed-attempt', + authority: removedAuthority, + tabGeneration: 1, + startedAt: 10 + }, + 'tab-stale-retry': { + attemptId: 'stale-removed-attempt', + authority: removedAuthority, + tabGeneration: 2, + startedAt: 11 + }, + 'tab-other': { + attemptId: 'other-attempt', + authority: otherAuthority, + tabGeneration: 3, + startedAt: 12 + } + } as never, + directSshLivePtyBindingByTabId: { + 'tab-ssh': { + authority: removedAuthority, + tabGeneration: 1, + ptyId: removedPtyId + }, + 'tab-other': { + authority: otherAuthority, + tabGeneration: 3, + ptyId: otherPtyId + } + } as never, + directSshPaneRetryHistoryByTabId: { + 'tab-ssh': { authority: removedAuthority, attemptedAt: [10] }, + 'tab-stale-retry': { authority: removedAuthority, attemptedAt: [11] }, + 'tab-other': { authority: otherAuthority, attemptedAt: [12] } + } as never }) store.getState().clearRemovedSshTargetState(targetId) @@ -139,6 +187,12 @@ describe('createSshSlice', () => { expect(state.pendingReconnectPtyIdByTabId).toEqual({ 'tab-other': toAppSshPtyId(otherTargetId, 'pty-2') }) + expect(Object.keys(state.directSshPaneRetryByTabId)).toEqual(['tab-other']) + expect(Object.keys(state.directSshLivePtyBindingByTabId)).toEqual(['tab-other']) + expect(Object.keys(state.directSshPaneRetryHistoryByTabId)).toEqual(['tab-other']) + expect(state.directSshPaneRetryByTabId['tab-other']?.authority.targetId).toBe(otherTargetId) + expect(state.directSshLivePtyBindingByTabId['tab-other']?.ptyId).toBe(otherPtyId) + expect(state.directSshPaneRetryHistoryByTabId['tab-other']?.attemptedAt).toEqual([12]) expect(state.tabsByWorktree[worktreeId][0]).toMatchObject({ id: 'tab-ssh', ptyId: null }) expect('pendingActivationSpawn' in state.tabsByWorktree[worktreeId][0]).toBe(false) expect(state.ptyIdsByTabId['tab-ssh']).toEqual([]) @@ -163,6 +217,107 @@ describe('createSshSlice', () => { }) }) + it("preserves another SSH target's terminal ledgers when repo ids collide", () => { + const store = createTestStore() + const removedTargetId = 'target-a' + const survivingTargetId = 'target-b' + const removedWorktreeId = 'shared::/srv/a/work' + const survivingWorktreeId = 'shared::/srv/b/work' + const removedPtyId = toAppSshPtyId(removedTargetId, 'pty-a') + const survivingPtyId = toAppSshPtyId(survivingTargetId, 'pty-b') + const removedAuthority = { + targetId: removedTargetId, + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 + } + const survivingAuthority = { + targetId: survivingTargetId, + providerEpoch: 'epoch-b' as SshProviderEpoch, + connectionGeneration: 2 + } + const survivingAttempt = { + attemptId: 'attempt-b', + authority: survivingAuthority, + tabGeneration: 4, + startedAt: 20 + } + const survivingBinding = { + authority: survivingAuthority, + tabGeneration: 4, + ptyId: survivingPtyId + } + const survivingHistory = { authority: survivingAuthority, attemptedAt: [20] } + + store.setState({ + repos: [ + { + ...TEST_REPO, + id: 'shared', + connectionId: removedTargetId, + executionHostId: 'ssh:target-a' + }, + { + ...TEST_REPO, + id: 'shared', + connectionId: survivingTargetId, + executionHostId: 'ssh:target-b' + } + ], + worktreesByRepo: { + shared: [ + makeWorktree({ + id: removedWorktreeId, + repoId: 'shared', + hostId: 'ssh:target-a' + }), + makeWorktree({ + id: survivingWorktreeId, + repoId: 'shared', + hostId: 'ssh:target-b' + }) + ] + }, + tabsByWorktree: { + [removedWorktreeId]: [ + makeTab({ id: 'tab-a', worktreeId: removedWorktreeId, ptyId: removedPtyId }) + ], + [survivingWorktreeId]: [ + makeTab({ id: 'tab-b', worktreeId: survivingWorktreeId, ptyId: survivingPtyId }) + ] + }, + ptyIdsByTabId: { 'tab-a': [removedPtyId], 'tab-b': [survivingPtyId] }, + lastKnownRelayPtyIdByTabId: { 'tab-a': removedPtyId, 'tab-b': survivingPtyId }, + directSshPaneRetryByTabId: { + 'tab-a': { + attemptId: 'attempt-a', + authority: removedAuthority, + tabGeneration: 3, + startedAt: 10 + }, + 'tab-b': survivingAttempt + } as never, + directSshLivePtyBindingByTabId: { + 'tab-a': { authority: removedAuthority, tabGeneration: 3, ptyId: removedPtyId }, + 'tab-b': survivingBinding + } as never, + directSshPaneRetryHistoryByTabId: { + 'tab-a': { authority: removedAuthority, attemptedAt: [10] }, + 'tab-b': survivingHistory + } as never + }) + + store.getState().clearRemovedSshTargetState(removedTargetId) + + const state = store.getState() + expect(state.tabsByWorktree[removedWorktreeId][0]?.ptyId).toBeNull() + expect(state.tabsByWorktree[survivingWorktreeId][0]?.ptyId).toBe(survivingPtyId) + expect(state.ptyIdsByTabId['tab-b']).toEqual([survivingPtyId]) + expect(state.lastKnownRelayPtyIdByTabId['tab-b']).toBe(survivingPtyId) + expect(state.directSshPaneRetryByTabId['tab-b']).toBe(survivingAttempt) + expect(state.directSshLivePtyBindingByTabId['tab-b']).toBe(survivingBinding) + expect(state.directSshPaneRetryHistoryByTabId['tab-b']).toBe(survivingHistory) + }) + it('keeps SSH target label references stable when refreshed metadata is unchanged', () => { const store = createTestStore() const labels = new Map([['ssh-1', 'Remote']]) @@ -233,6 +388,31 @@ describe('createSshSlice', () => { expect(store.getState().sshConnectionStates.get('ssh-1')?.connectionGeneration).toBe(2) }) + it('publishes an authoritative SSH provider epoch change', () => { + const store = createTestStore() + store.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'provider-a' as never, + connectionGeneration: 1 + }) + const previousState = store.getState() + + store.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'provider-b' as never, + connectionGeneration: 1 + }) + + expect(store.getState()).not.toBe(previousState) + expect(store.getState().sshConnectionStates.get('ssh-1')?.providerEpoch).toBe('provider-b') + }) + it('publishes a connected-state folder capability change', () => { const store = createTestStore() store.getState().setSshConnectionState('ssh-1', { diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 0bcedd99a8a..57bf015adec 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -3,6 +3,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { buildWorktreeComparator } from '@/components/sidebar/smart-sort' import type * as AgentStatusModule from '@/lib/agent-status' import { getDefaultSettings } from '../../../../shared/constants' +import type { SshProviderEpoch } from '../../../../shared/ssh-types' +import type { DirectSshPaneRetryAttemptId } from './direct-ssh-terminal-recovery' import { createCompatibleRuntimeStatusResponseIfNeeded } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests, @@ -1302,6 +1304,140 @@ describe('setActiveWorktree', () => { ) }) + it('moves current direct SSH binding evidence when detaching a live pane', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const sourceTabId = 'tab-source' + const targetTabId = 'tab-target' + const authority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 + } + const detachedPtyId = 'ssh:target-a@@pty-detached' + seedStore(store, { + tabsByWorktree: { + [wt]: [ + makeTab({ id: sourceTabId, worktreeId: wt, ptyId: detachedPtyId }), + makeTab({ id: targetTabId, worktreeId: wt, ptyId: null }) + ] + }, + ptyIdsByTabId: { [sourceTabId]: [detachedPtyId], [targetTabId]: [] }, + directSshLivePtyBindingByTabId: { + [sourceTabId]: { + attemptId: 'live-detach' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 0, + ptyId: detachedPtyId + } + }, + directSshPaneRetryHistoryByTabId: { + [sourceTabId]: { authority, attemptedAt: [1] } + }, + sshConnectionStates: new Map([ + [ + authority.targetId, + { + targetId: authority.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration + } + ] + ]) + }) + + store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: '11111111-1111-4111-8111-111111111111', + detachedPtyId, + sourceLayout: makeLayout(), + sourceTabId, + targetTabId + }) + + const state = store.getState() + expect(state.directSshPaneRetryByTabId[sourceTabId]).toBeUndefined() + expect(state.directSshLivePtyBindingByTabId[sourceTabId]).toBeUndefined() + expect(state.directSshPaneRetryHistoryByTabId[sourceTabId]).toBeUndefined() + expect(state.directSshLivePtyBindingByTabId[targetTabId]).toEqual({ + attemptId: 'live-detach', + authority, + tabGeneration: 0, + ptyId: detachedPtyId + }) + expect(state.directSshPaneRetryHistoryByTabId[targetTabId]).toEqual({ + authority, + attemptedAt: [1] + }) + }) + + it('rearms a current pending SSH detach as live destination evidence', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const sourceTabId = 'tab-source' + const targetTabId = 'tab-target' + const authority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 + } + const detachedPtyId = 'ssh:target-a@@pty-detached' + seedStore(store, { + tabsByWorktree: { + [wt]: [ + makeTab({ id: sourceTabId, worktreeId: wt, ptyId: detachedPtyId, generation: 2 }), + makeTab({ id: targetTabId, worktreeId: wt, ptyId: null }) + ] + }, + ptyIdsByTabId: { [sourceTabId]: [detachedPtyId], [targetTabId]: [] }, + directSshPaneRetryByTabId: { + [sourceTabId]: { + attemptId: 'pending-detach' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 2, + startedAt: 1 + } + }, + sshConnectionStates: new Map([ + [ + authority.targetId, + { + targetId: authority.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration + } + ] + ]) + }) + + store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: '11111111-1111-4111-8111-111111111111', + detachedPtyId, + sourceLayout: makeLayout(), + sourceTabId, + targetTabId + }) + + const state = store.getState() + expect(state.directSshPaneRetryByTabId[sourceTabId]).toBeUndefined() + expect(state.directSshLivePtyBindingByTabId[sourceTabId]).toBeUndefined() + expect(state.directSshPaneRetryHistoryByTabId[sourceTabId]).toBeUndefined() + expect(state.directSshLivePtyBindingByTabId[targetTabId]).toEqual({ + attemptId: 'pending-detach', + authority, + tabGeneration: 0, + ptyId: detachedPtyId + }) + expect(state.tabsByWorktree[wt].find((tab) => tab.id === targetTabId)?.ptyId).toBe( + detachedPtyId + ) + }) + // Regression for #9911: a split SSH tab's single relay slot points at the // last-bound pane; when it exits, clearTabPtyId must promote a surviving pane // instead of clearing, or a later relay-drop bulk-clear leaves the survivor diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 2d521a43b1e..30bcdb24925 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -104,7 +104,13 @@ const mockApi = { // @ts-expect-error -- mock globalThis.window = { api: mockApi } -import { createTestStore, makeOpenFile, makeTabGroup, makeUnifiedTab } from './store-test-helpers' +import { + createTestStore, + makeOpenFile, + makeTabGroup, + makeUnifiedTab, + makeWorktree +} from './store-test-helpers' const WT = 'repo1::/tmp/feature' @@ -1783,6 +1789,158 @@ describe('TabsSlice', () => { expect(store.getState().unifiedTabsByWorktree).toEqual({}) }) + + it('replaces only explicitly scoped worktree tab chrome', () => { + const siblingWorktreeId = 'repo2::/tmp/sibling' + const targetGroup = makeTabGroup({ + id: 'group-target', + worktreeId: WT, + activeTabId: 'target-old', + tabOrder: ['target-old'] + }) + const siblingGroup = makeTabGroup({ + id: 'group-sibling', + worktreeId: siblingWorktreeId, + activeTabId: 'sibling-tab', + tabOrder: ['sibling-tab'] + }) + const siblingTabs = [ + makeUnifiedTab({ + id: 'sibling-tab', + worktreeId: siblingWorktreeId, + groupId: siblingGroup.id + }) + ] + const siblingGroups = [siblingGroup] + store.setState({ + worktreesByRepo: { + repo1: [makeWorktree({ id: WT, repoId: 'repo1' })], + repo2: [makeWorktree({ id: siblingWorktreeId, repoId: 'repo2' })] + }, + unifiedTabsByWorktree: { + [WT]: [makeUnifiedTab({ id: 'target-old', worktreeId: WT, groupId: targetGroup.id })], + [siblingWorktreeId]: siblingTabs + }, + groupsByWorktree: { + [WT]: [targetGroup], + [siblingWorktreeId]: siblingGroups + }, + activeGroupIdByWorktree: { + [WT]: targetGroup.id, + [siblingWorktreeId]: siblingGroup.id + }, + layoutByWorktree: { + [WT]: { type: 'leaf', groupId: targetGroup.id }, + [siblingWorktreeId]: { type: 'leaf', groupId: siblingGroup.id } + } + }) + const targetNew = makeUnifiedTab({ + id: 'target-new', + worktreeId: WT, + groupId: targetGroup.id, + label: 'Remote target' + }) + + store.getState().hydrateTabsSession( + { + activeRepoId: 'repo1', + activeWorktreeId: WT, + activeTabId: targetNew.id, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + [WT]: [targetNew], + [siblingWorktreeId]: [ + makeUnifiedTab({ + id: 'sibling-replaced', + worktreeId: siblingWorktreeId, + groupId: siblingGroup.id + }) + ] + }, + tabGroups: { + [WT]: [{ ...targetGroup, activeTabId: targetNew.id, tabOrder: [targetNew.id] }], + [siblingWorktreeId]: [ + { ...siblingGroup, activeTabId: 'sibling-replaced', tabOrder: ['sibling-replaced'] } + ] + } + }, + { replaceWorkspaceKeys: [WT] } + ) + + expect(store.getState().unifiedTabsByWorktree[WT]).toEqual([targetNew]) + expect(store.getState().unifiedTabsByWorktree[siblingWorktreeId]).toBe(siblingTabs) + expect(store.getState().groupsByWorktree[siblingWorktreeId]).toBe(siblingGroups) + }) + + it('deletes omitted target chrome while preserving sibling references', () => { + const siblingWorktreeId = 'repo2::/tmp/sibling' + const targetGroup = makeTabGroup({ + id: 'group-target', + worktreeId: WT, + activeTabId: 'target-tab', + tabOrder: ['target-tab'] + }) + const siblingGroup = makeTabGroup({ + id: 'group-sibling', + worktreeId: siblingWorktreeId, + activeTabId: 'sibling-tab', + tabOrder: ['sibling-tab'] + }) + const siblingTabs = [ + makeUnifiedTab({ + id: 'sibling-tab', + worktreeId: siblingWorktreeId, + groupId: siblingGroup.id + }) + ] + const siblingGroups = [siblingGroup] + const siblingLayout = { type: 'leaf' as const, groupId: siblingGroup.id } + store.setState({ + worktreesByRepo: { + repo1: [makeWorktree({ id: WT, repoId: 'repo1' })], + repo2: [makeWorktree({ id: siblingWorktreeId, repoId: 'repo2' })] + }, + unifiedTabsByWorktree: { + [WT]: [makeUnifiedTab({ id: 'target-tab', worktreeId: WT, groupId: targetGroup.id })], + [siblingWorktreeId]: siblingTabs + }, + groupsByWorktree: { + [WT]: [targetGroup], + [siblingWorktreeId]: siblingGroups + }, + activeGroupIdByWorktree: { + [WT]: targetGroup.id, + [siblingWorktreeId]: siblingGroup.id + }, + layoutByWorktree: { + [WT]: { type: 'leaf', groupId: targetGroup.id }, + [siblingWorktreeId]: siblingLayout + } + }) + + store.getState().hydrateTabsSession( + { + activeRepoId: 'repo1', + activeWorktreeId: WT, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: {}, + tabGroups: {} + }, + { replaceWorkspaceKeys: [WT] } + ) + + const state = store.getState() + expect(state.unifiedTabsByWorktree).not.toHaveProperty(WT) + expect(state.groupsByWorktree).not.toHaveProperty(WT) + expect(state.activeGroupIdByWorktree).not.toHaveProperty(WT) + expect(state.layoutByWorktree).not.toHaveProperty(WT) + expect(state.unifiedTabsByWorktree[siblingWorktreeId]).toBe(siblingTabs) + expect(state.groupsByWorktree[siblingWorktreeId]).toBe(siblingGroups) + expect(state.layoutByWorktree[siblingWorktreeId]).toBe(siblingLayout) + }) }) // ─── Cross-content-type neighbor selection ──────────────────────── diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 3c070f10711..b38aa980017 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -47,6 +47,17 @@ import { export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' +function replaceWorkspaceRecordKeys( + current: Record, + hydrated: Record, + workspaceKeys: ReadonlySet +): Record { + return { + ...Object.fromEntries(Object.entries(current).filter(([key]) => !workspaceKeys.has(key))), + ...Object.fromEntries(Object.entries(hydrated).filter(([key]) => workspaceKeys.has(key))) + } +} + export type TabsSlice = { unifiedTabsByWorktree: Record // Why: id of the tab whose inline title editor should open; shortcut (tab.rename) sets it, the tab clears it on consume. @@ -2011,6 +2022,33 @@ export const createTabsSlice: StateCreator = (set, validWorktreeIds.add(folderWorkspaceKey(workspace.id)) } addAdditionalValidWorkspaceKeys(validWorktreeIds, options) - set(buildHydratedTabState(session, validWorktreeIds)) + const hydrated = buildHydratedTabState(session, validWorktreeIds) + if (!options?.replaceWorkspaceKeys) { + set(hydrated) + return + } + const replaceWorkspaceKeys = new Set(options.replaceWorkspaceKeys) + set((current) => ({ + unifiedTabsByWorktree: replaceWorkspaceRecordKeys( + current.unifiedTabsByWorktree, + hydrated.unifiedTabsByWorktree, + replaceWorkspaceKeys + ), + groupsByWorktree: replaceWorkspaceRecordKeys( + current.groupsByWorktree, + hydrated.groupsByWorktree, + replaceWorkspaceKeys + ), + activeGroupIdByWorktree: replaceWorkspaceRecordKeys( + current.activeGroupIdByWorktree, + hydrated.activeGroupIdByWorktree, + replaceWorkspaceKeys + ), + layoutByWorktree: replaceWorkspaceRecordKeys( + current.layoutByWorktree, + hydrated.layoutByWorktree, + replaceWorkspaceKeys + ) + })) } }) diff --git a/src/renderer/src/store/slices/terminal-activation-spawn-suppression.ts b/src/renderer/src/store/slices/terminal-activation-spawn-suppression.ts new file mode 100644 index 00000000000..203736af29d --- /dev/null +++ b/src/renderer/src/store/slices/terminal-activation-spawn-suppression.ts @@ -0,0 +1,22 @@ +import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' + +function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number { + if (!node) { + return 0 + } + if (node.type === 'leaf') { + return 1 + } + return countTerminalLayoutLeaves(node.first) + countTerminalLayoutLeaves(node.second) +} + +export function getTerminalActivationSpawnSuppression( + layout: TerminalLayoutSnapshot | undefined +): true | number { + const paneCount = Math.max( + 1, + countTerminalLayoutLeaves(layout?.root), + Object.keys(layout?.ptyIdsByLeafId ?? {}).length + ) + return paneCount === 1 ? true : paneCount +} diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index d3865eecebf..837fde338fd 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -92,6 +92,8 @@ const mockApi = { globalThis.window = { api: mockApi } import type { WorkspaceSessionState } from '../../../../shared/types' +import type { SshProviderEpoch } from '../../../../shared/ssh-types' +import type { DirectSshPaneRetryAttemptId } from './direct-ssh-terminal-recovery-types' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession @@ -710,6 +712,156 @@ describe('hydrateWorkspaceSession', () => { expect(store.getState().worktreeNavHistoryIndex).toBe(1) expect(canGoBackWorktreeHistory(store.getState())).toBe(true) }) + + it('hydrates and reconnects one SSH target without mutating unrelated state', async () => { + const store = createTestStore() + const targetWorktreeId = 'repo-a::/target' + const siblingWorktreeId = 'repo-b::/sibling' + const localWorktreeId = 'repo-local::/local' + const runtimeWorktreeId = 'repo-runtime::/runtime' + const folderWorktreeId = folderWorkspaceKey('folder-1') + const targetTab = makeTab({ id: 'tab-target', worktreeId: targetWorktreeId, ptyId: null }) + const deletedTargetTab = makeTab({ id: 'tab-target-deleted', worktreeId: targetWorktreeId }) + const siblingTab = makeTab({ + id: 'tab-sibling', + worktreeId: siblingWorktreeId, + ptyId: 'ssh:target-b@@pty-b' + }) + const runtimeTab = makeTab({ + id: 'tab-runtime', + worktreeId: runtimeWorktreeId, + ptyId: 'runtime:env@@pty-runtime' + }) + const localTab = makeTab({ id: 'tab-local', worktreeId: localWorktreeId, ptyId: null }) + const folderTab = makeTab({ id: 'tab-folder', worktreeId: folderWorktreeId, ptyId: null }) + const siblingTabs = [siblingTab] + const runtimeTabs = [runtimeTab] + const [localTabs, folderTabs] = [[localTab], [folderTab]] + const runtimeOwners = { [runtimeWorktreeId]: 'runtime:env' as const } + const authority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 7 + } + const ledgerTabs = [targetTab, deletedTargetTab, siblingTab] + const retry = { + attemptId: 'attempt' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 0, + startedAt: 0 + } + const retryByTabId = Object.fromEntries(ledgerTabs.map((tab) => [tab.id, retry])) + const liveBinding = { ...retry, ptyId: 'ssh:target-a@@pty-ledger' } + const liveByTabId = Object.fromEntries(ledgerTabs.map((tab) => [tab.id, liveBinding])) + const historyByTabId = Object.fromEntries( + ledgerTabs.map((tab) => [tab.id, { authority, attemptedAt: [0] }]) + ) + seedStore(store, { + workspaceSessionReady: true, + repos: [ + { ...TEST_REPO, id: 'repo-a', connectionId: 'target-a' }, + { ...TEST_REPO, id: 'repo-b', connectionId: 'target-b' } + ], + worktreesByRepo: { + 'repo-a': [makeWorktree({ id: targetWorktreeId, repoId: 'repo-a', path: '/target' })], + 'repo-b': [makeWorktree({ id: siblingWorktreeId, repoId: 'repo-b', path: '/sibling' })] + }, + tabsByWorktree: { + [targetWorktreeId]: [targetTab, deletedTargetTab], + [siblingWorktreeId]: siblingTabs, + [localWorktreeId]: localTabs, + [runtimeWorktreeId]: runtimeTabs, + [folderWorktreeId]: folderTabs + }, + ptyIdsByTabId: { + [targetTab.id]: [], + [siblingTab.id]: [siblingTab.ptyId!], + [runtimeTab.id]: [runtimeTab.ptyId!] + }, + activeRepoId: 'repo-b', + activeWorktreeId: siblingWorktreeId, + activeTabId: siblingTab.id, + restoredRuntimeHostIdByWorkspaceSessionKey: runtimeOwners, + directSshPaneRetryByTabId: retryByTabId, + directSshLivePtyBindingByTabId: liveByTabId, + directSshPaneRetryHistoryByTabId: historyByTabId, + sshConnectionStates: new Map([ + [ + authority.targetId, + { + targetId: authority.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration + } + ] + ]) + }) + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-a', + activeWorktreeId: targetWorktreeId, + activeTabId: targetTab.id, + tabsByWorktree: { + [targetWorktreeId]: [ + { ...targetTab, ptyId: 'ssh:target-a@@pty-a' }, + makeTab({ + id: 'tab-wrong-host', + worktreeId: targetWorktreeId, + ptyId: 'ssh:target-b@@pty-wrong' + }) + ], + [siblingWorktreeId]: siblingTabs, + [runtimeWorktreeId]: runtimeTabs + }, + activeWorktreeIdsOnShutdown: [targetWorktreeId], + terminalLayoutsByTabId: {} + } + + store.getState().hydrateWorkspaceSession(session, { + directSshAuthority: authority, + replaceWorkspaceKeys: [targetWorktreeId] + }) + + expect(store.getState().tabsByWorktree[siblingWorktreeId]).toBe(siblingTabs) + expect(store.getState().tabsByWorktree[localWorktreeId]).toBe(localTabs) + expect(store.getState().tabsByWorktree[runtimeWorktreeId]).toBe(runtimeTabs) + expect(store.getState().tabsByWorktree[folderWorktreeId]).toBe(folderTabs) + expect(store.getState().ptyIdsByTabId[siblingTab.id]).toEqual([siblingTab.ptyId]) + expect(store.getState().ptyIdsByTabId[runtimeTab.id]).toEqual([runtimeTab.ptyId]) + expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toBe(runtimeOwners) + expect(store.getState().activeWorktreeId).toBe(siblingWorktreeId) + expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({ + [targetTab.id]: 'ssh:target-a@@pty-a' + }) + const retainedLedgerTabIds = ledgerTabs + .filter((tab) => tab.id !== deletedTargetTab.id) + .map((tab) => tab.id) + .sort() + expect(Object.keys(store.getState().directSshPaneRetryByTabId).sort()).toEqual( + retainedLedgerTabIds + ) + expect(Object.keys(store.getState().directSshLivePtyBindingByTabId).sort()).toEqual( + retainedLedgerTabIds + ) + expect(Object.keys(store.getState().directSshPaneRetryHistoryByTabId).sort()).toEqual( + retainedLedgerTabIds + ) + + await store.getState().reconnectPersistedTerminals(undefined, { + directSshAuthority: authority, + workspaceKeys: [targetWorktreeId] + }) + + const targetTabs = store.getState().tabsByWorktree[targetWorktreeId] + expect(targetTabs.find((tab) => tab.id === targetTab.id)?.ptyId).toBe('ssh:target-a@@pty-a') + expect(targetTabs.find((tab) => tab.id === 'tab-wrong-host')?.ptyId).toBeNull() + expect(store.getState().tabsByWorktree[siblingWorktreeId]).toBe(siblingTabs) + expect(store.getState().tabsByWorktree[runtimeWorktreeId]).toBe(runtimeTabs) + expect(store.getState().workspaceSessionReady).toBe(true) + }) }) describe('hydrationSucceeded flag (issue #1158)', () => { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 2720ce9a520..33f41609c31 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -16,6 +16,8 @@ import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { DEFAULT_REPO_BADGE_COLOR, FLOATING_TERMINAL_WORKTREE_ID @@ -91,6 +93,25 @@ import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { requestRemoteWorktreeSleep } from '@/runtime/remote-worktree-sleep' import { createBrowserUuid } from '@/lib/browser-uuid' import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' +import { + clearDirectSshTerminalBindings, + invalidateStaleDirectSshTerminalBindings, + type DirectSshLivePtyBinding, + type DirectSshPaneRetryAttempt, + type DirectSshPaneRetryAttemptId, + type DirectSshPaneRetryHistory, + type DirectSshPaneRetryResult +} from './direct-ssh-terminal-recovery' +import { + retryDirectSshTerminalPanes, + retrySettledDirectSshTerminalPane +} from './direct-ssh-pane-retry-ledger' +import { + directSshAuthoritiesEqual, + settleDirectSshPaneRetryState, + transferDirectSshPaneDetachLedger +} from './direct-ssh-terminal-authority-ledger' +import { resolveDirectSshTerminalWorkspaceKeys } from './direct-ssh-terminal-workspace-scope' import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' @@ -143,6 +164,32 @@ function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null } +function isCurrentDirectSshAuthority(state: AppState, authority: DirectSshAuthority): boolean { + const current = state.sshConnectionStates.get(authority.targetId) + return Boolean( + current?.status === 'connected' && + current.providerEpoch === authority.providerEpoch && + current.connectionGeneration === authority.connectionGeneration + ) +} + +function resolveDirectSshTerminalKeys(state: AppState, targetId: string): Set { + return resolveDirectSshTerminalWorkspaceKeys( + { + targetId, + catalogRevision: 0, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + }, + state.tabsByWorktree, + state.lastKnownRelayPtyIdByTabId + ) +} + function getPendingActivationSpawnCount(value: boolean | number | undefined): number { if (value === true) { return 1 @@ -495,6 +542,9 @@ export type TerminalSlice = { pendingPtyShutdownIds: Record pendingCodexPaneRestartIds: Record codexRestartNoticeByPtyId: Record + directSshPaneRetryByTabId: Record + directSshLivePtyBindingByTabId: Record + directSshPaneRetryHistoryByTabId: Record expandedPaneByTabId: Record canExpandPaneByTabId: Record terminalLayoutsByTabId: Record @@ -624,8 +674,17 @@ export type TerminalSlice = { opts?: { recordInteraction?: boolean } ) => void setTabColor: (tabId: string, color: string | null) => void - updateTabPtyId: (tabId: string, ptyId: string, replacedPtyId?: string) => void + updateTabPtyId: ( + tabId: string, + ptyId: string, + replacedPtyId?: string, + directSshRetryAttemptId?: DirectSshPaneRetryAttemptId + ) => void clearTabPtyId: (tabId: string, ptyId?: string) => void + clearDirectSshTargetPtyBindings: (targetId: string) => number + invalidateStaleDirectSshTargetPtyBindings: (authority: DirectSshAuthority) => number + retryDirectSshTargetPanes: (authority: DirectSshAuthority, now?: number) => number + settleDirectSshPaneRetry: (result: DirectSshPaneRetryResult, now?: number) => void shutdownWorktreeTerminals: ( worktreeId: string, opts?: { @@ -737,13 +796,186 @@ export type TerminalSlice = { session: WorkspaceSessionState, options?: HydrateWorkspaceSessionOptions ) => void - reconnectPersistedTerminals: (signal?: AbortSignal) => Promise + reconnectPersistedTerminals: ( + signal?: AbortSignal, + options?: ReconnectPersistedTerminalsOptions + ) => Promise } export type HydrateWorkspaceSessionOptions = { + directSshAuthority?: DirectSshAuthority runtimeHostIdByWorkspaceSessionKey?: Record } & WorkspaceSessionHydrationOptions +export type ReconnectPersistedTerminalsOptions = { + directSshAuthority: DirectSshAuthority + workspaceKeys: readonly string[] +} + +type WorkspaceHydrationPatch = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'activeWorkspaceKey' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'restoredRuntimeHostIdByWorkspaceSessionKey' + | 'repos' + | 'tabsByWorktree' + | 'worktreesByRepo' + | 'lastVisitedAtByWorktreeId' + | 'defaultTerminalTabsAppliedByWorktreeId' + | 'automaticAgentResumeClaimsByTabId' + | 'sleepingAgentSessionsByPaneKey' + | 'pendingReconnectWorktreeIds' + | 'pendingReconnectTabByWorktree' + | 'pendingReconnectPtyIdByTabId' + | 'everActivatedWorktreeIds' + | 'worktreeNavHistory' + | 'worktreeNavHistoryIndex' + | 'ptyIdsByTabId' + | 'terminalLayoutsByTabId' +> + +function replaceHydratedRecordKeys( + current: Record, + hydrated: Record, + replaceKeys: ReadonlySet +): Record { + return { + ...Object.fromEntries(Object.entries(current).filter(([key]) => !replaceKeys.has(key))), + ...Object.fromEntries(Object.entries(hydrated).filter(([key]) => replaceKeys.has(key))) + } +} + +function targetScopedWorkspaceHydrationPatch( + state: AppState, + hydrated: WorkspaceHydrationPatch, + session: WorkspaceSessionState, + options: HydrateWorkspaceSessionOptions +): Partial { + const workspaceKeys = new Set(options.replaceWorkspaceKeys) + const targetTabIds = new Set( + [...workspaceKeys].flatMap((workspaceKey) => [ + ...(state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id), + ...(session.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ]) + ) + const retainedTargetTabIds = new Set( + [...workspaceKeys].flatMap((workspaceKey) => + (hydrated.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ) + ) + const deletedTargetTabIds = new Set( + [...workspaceKeys] + .flatMap((workspaceKey) => (state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id)) + .filter((tabId) => !retainedTargetTabIds.has(tabId)) + ) + const pendingReconnectPtyIdByTabId = replaceHydratedRecordKeys( + state.pendingReconnectPtyIdByTabId, + {}, + targetTabIds + ) + const authority = options.directSshAuthority + if (authority) { + for (const workspaceKey of hydrated.pendingReconnectWorktreeIds) { + if (!workspaceKeys.has(workspaceKey)) { + continue + } + for (const tab of session.tabsByWorktree[workspaceKey] ?? []) { + const ptyId = session.remoteSessionIdsByTabId?.[tab.id] ?? tab.ptyId + if (ptyId && parseAppSshPtyId(ptyId)?.connectionId === authority.targetId) { + pendingReconnectPtyIdByTabId[tab.id] = ptyId + } + } + } + } + const activeOutsideScope = + state.activeWorktreeId != null && !workspaceKeys.has(state.activeWorktreeId) + const sleepingAgentSessionsByPaneKey = Object.fromEntries([ + ...Object.entries(state.sleepingAgentSessionsByPaneKey).filter( + ([, record]) => !workspaceKeys.has(record.worktreeId) + ), + ...Object.entries(hydrated.sleepingAgentSessionsByPaneKey).filter(([, record]) => + workspaceKeys.has(record.worktreeId) + ) + ]) + const everActivatedWorktreeIds = new Set(state.everActivatedWorktreeIds) + for (const workspaceKey of hydrated.everActivatedWorktreeIds) { + if (workspaceKeys.has(workspaceKey)) { + everActivatedWorktreeIds.add(workspaceKey) + } + } + return { + activeRepoId: activeOutsideScope ? state.activeRepoId : hydrated.activeRepoId, + activeWorktreeId: activeOutsideScope ? state.activeWorktreeId : hydrated.activeWorktreeId, + activeWorkspaceKey: activeOutsideScope ? state.activeWorkspaceKey : hydrated.activeWorkspaceKey, + activeTabId: activeOutsideScope ? state.activeTabId : hydrated.activeTabId, + activeTabIdByWorktree: replaceHydratedRecordKeys( + state.activeTabIdByWorktree, + hydrated.activeTabIdByWorktree, + workspaceKeys + ), + tabsByWorktree: replaceHydratedRecordKeys( + state.tabsByWorktree, + hydrated.tabsByWorktree, + workspaceKeys + ), + lastVisitedAtByWorktreeId: replaceHydratedRecordKeys( + state.lastVisitedAtByWorktreeId, + hydrated.lastVisitedAtByWorktreeId, + workspaceKeys + ), + defaultTerminalTabsAppliedByWorktreeId: replaceHydratedRecordKeys( + state.defaultTerminalTabsAppliedByWorktreeId, + hydrated.defaultTerminalTabsAppliedByWorktreeId, + workspaceKeys + ), + automaticAgentResumeClaimsByTabId: replaceHydratedRecordKeys( + state.automaticAgentResumeClaimsByTabId, + hydrated.automaticAgentResumeClaimsByTabId, + targetTabIds + ), + sleepingAgentSessionsByPaneKey, + pendingReconnectWorktreeIds: [ + ...state.pendingReconnectWorktreeIds.filter((key) => !workspaceKeys.has(key)), + ...hydrated.pendingReconnectWorktreeIds.filter((key) => workspaceKeys.has(key)) + ], + pendingReconnectTabByWorktree: replaceHydratedRecordKeys( + state.pendingReconnectTabByWorktree, + hydrated.pendingReconnectTabByWorktree, + workspaceKeys + ), + pendingReconnectPtyIdByTabId, + everActivatedWorktreeIds, + directSshPaneRetryByTabId: replaceHydratedRecordKeys( + state.directSshPaneRetryByTabId, + {}, + deletedTargetTabIds + ), + directSshLivePtyBindingByTabId: replaceHydratedRecordKeys( + state.directSshLivePtyBindingByTabId, + {}, + deletedTargetTabIds + ), + directSshPaneRetryHistoryByTabId: replaceHydratedRecordKeys( + state.directSshPaneRetryHistoryByTabId, + {}, + deletedTargetTabIds + ), + ptyIdsByTabId: replaceHydratedRecordKeys( + state.ptyIdsByTabId, + hydrated.ptyIdsByTabId, + targetTabIds + ), + terminalLayoutsByTabId: replaceHydratedRecordKeys( + state.terminalLayoutsByTabId, + hydrated.terminalLayoutsByTabId, + targetTabIds + ) + } +} + export const createTerminalSlice: StateCreator = (set, get) => ({ tabsByWorktree: {}, activeTabId: null, @@ -757,6 +989,9 @@ export const createTerminalSlice: StateCreator pendingPtyShutdownIds: {}, pendingCodexPaneRestartIds: {}, codexRestartNoticeByPtyId: {}, + directSshPaneRetryByTabId: {}, + directSshLivePtyBindingByTabId: {}, + directSshPaneRetryHistoryByTabId: {}, expandedPaneByTabId: {}, canExpandPaneByTabId: {}, terminalLayoutsByTabId: {}, @@ -1300,6 +1535,16 @@ export const createTerminalSlice: StateCreator delete nextPendingReconnectPtyIdByTabId[tabId] const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } delete nextRuntimePaneTitlesByTabId[tabId] + const nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } + delete nextDirectSshPaneRetryByTabId[tabId] + const nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId + } + delete nextDirectSshLivePtyBindingByTabId[tabId] + const nextDirectSshPaneRetryHistoryByTabId = { + ...s.directSshPaneRetryHistoryByTabId + } + delete nextDirectSshPaneRetryHistoryByTabId[tabId] // Why: keep the same reference when the closing tab had no unread flag, so unrelated closes don't force full-state selector re-eval. let nextUnreadTerminalTabs = s.unreadTerminalTabs if (s.unreadTerminalTabs[tabId]) { @@ -1400,6 +1645,9 @@ export const createTerminalSlice: StateCreator deferredSshSessionIdsByTabId: nextDeferredSshSessionIdsByTabId, pendingReconnectPtyIdByTabId: nextPendingReconnectPtyIdByTabId, runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, + directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, + directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, + directSshPaneRetryHistoryByTabId: nextDirectSshPaneRetryHistoryByTabId, ...(nextSleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey ? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessionsByPaneKey } : {}), @@ -1906,7 +2154,7 @@ export const createTerminalSlice: StateCreator } }, - updateTabPtyId: (tabId, ptyId, replacedPtyId) => { + updateTabPtyId: (tabId, ptyId, replacedPtyId, directSshRetryAttemptId) => { // Why: final guard preventing a late caller from recreating retired tab maps (async spawn owners still do their own provider teardown). if (!isTerminalTabPresent(get(), tabId)) { return @@ -1915,6 +2163,28 @@ export const createTerminalSlice: StateCreator let wasActivationSpawn = false const isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) set((s) => { + if (directSshRetryAttemptId) { + const pendingRetry = s.directSshPaneRetryByTabId[tabId] + const liveRetry = s.directSshLivePtyBindingByTabId[tabId] + const retryLease = + pendingRetry?.attemptId === directSshRetryAttemptId + ? pendingRetry + : liveRetry?.attemptId === directSshRetryAttemptId + ? liveRetry + : undefined + const boundTab = Object.values(s.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === tabId) + if ( + !retryLease || + !boundTab || + parseAppSshPtyId(ptyId)?.connectionId !== retryLease.authority.targetId || + !isCurrentDirectSshAuthority(s, retryLease.authority) || + (boundTab.generation ?? 0) !== retryLease.tabGeneration + ) { + return s + } + } const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] const remote = parseRemoteRuntimePtyId(ptyId) const legacyRemotePtyId = remote?.environmentId ? toRemoteRuntimePtyId(remote.handle) : null @@ -2017,6 +2287,67 @@ export const createTerminalSlice: StateCreator delete nextMigrationUnsupportedByPtyId[replacementPtyId] } } + const pendingRetry = s.directSshPaneRetryByTabId[tabId] + const liveRetry = s.directSshLivePtyBindingByTabId[tabId] + const retryLease = + pendingRetry?.attemptId === directSshRetryAttemptId + ? pendingRetry + : liveRetry?.attemptId === directSshRetryAttemptId + ? liveRetry + : undefined + const boundTab = worktreeId + ? nextTabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + : undefined + const parsedSshPty = parseAppSshPtyId(ptyId) + const acknowledgesDirectSshRetry = Boolean( + retryLease && + boundTab && + parsedSshPty?.connectionId === retryLease.authority.targetId && + isCurrentDirectSshAuthority(s, retryLease.authority) && + (boundTab.generation ?? 0) === retryLease.tabGeneration && + nextPtyIds.includes(ptyId) + ) + let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId + let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId + if (acknowledgesDirectSshRetry && retryLease && boundTab?.ptyId) { + if (pendingRetry?.attemptId === directSshRetryAttemptId) { + nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } + delete nextDirectSshPaneRetryByTabId[tabId] + } + if (!liveRetry || liveRetry.attemptId !== directSshRetryAttemptId) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId, + [tabId]: { + attemptId: retryLease.attemptId, + authority: retryLease.authority, + tabGeneration: retryLease.tabGeneration, + ptyId: boundTab.ptyId + } + } + } else if ( + (replacementPtyId === liveRetry.ptyId && boundTab.ptyId === ptyId) || + !nextPtyIds.includes(liveRetry.ptyId) + ) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId, + [tabId]: { ...liveRetry, ptyId: boundTab.ptyId } + } + } + } else { + const liveBinding = s.directSshLivePtyBindingByTabId[tabId] + if (liveBinding) { + if ( + replacementPtyId === liveBinding.ptyId && + boundTab?.ptyId === ptyId && + isCurrentDirectSshAuthority(s, liveBinding.authority) + ) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId, + [tabId]: { ...liveBinding, ptyId } + } + } + } + } return { ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), ptyIdsByTabId: { @@ -2031,6 +2362,8 @@ export const createTerminalSlice: StateCreator pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, migrationUnsupportedByPtyId: nextMigrationUnsupportedByPtyId, + directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, + directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) } }) @@ -2048,10 +2381,12 @@ export const createTerminalSlice: StateCreator } let worktreeId: string | null = null let wasActivationSpawn = false + let preservesDirectSshContinuationGap = false let isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) set((s) => { const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] const remainingPtyIds = ptyId ? existingPtyIds.filter((id) => id !== ptyId) : [] + const liveBinding = s.directSshLivePtyBindingByTabId[tabId] let nextTabsByWorktree = s.tabsByWorktree for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { const index = tabs.findIndex((t) => t.id === tabId) @@ -2071,8 +2406,16 @@ export const createTerminalSlice: StateCreator const { pendingActivationSpawn: _unused, ...rest } = tab void _unused const nextTabPtyId = remainingPtyIds.at(-1) ?? null + preservesDirectSshContinuationGap = Boolean( + ptyId && + remainingPtyIds.length === 0 && + liveBinding?.ptyId === ptyId && + getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0 && + isCurrentDirectSshAuthority(s, liveBinding.authority) + ) const shouldRetainActivationSpawn = - wasActivationSpawn && ptyId != null && !existingPtyIds.includes(ptyId) + preservesDirectSshContinuationGap || + (wasActivationSpawn && ptyId != null && !existingPtyIds.includes(ptyId)) const nextPendingActivationSpawn = shouldRetainActivationSpawn ? tab.pendingActivationSpawn : consumePendingActivationSpawn(tab.pendingActivationSpawn) @@ -2122,13 +2465,45 @@ export const createTerminalSlice: StateCreator delete nextLastKnownRelay[tabId] } } + let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId + const pendingRetry = s.directSshPaneRetryByTabId[tabId] + if ( + pendingRetry && + (!ptyId || + (existingPtyIds.includes(ptyId) && + parseAppSshPtyId(ptyId)?.connectionId === pendingRetry.authority.targetId)) + ) { + nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } + delete nextDirectSshPaneRetryByTabId[tabId] + } + let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId + if (liveBinding && (!ptyId || liveBinding.ptyId === ptyId)) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId + } + const promotedPtyId = ptyId ? remainingPtyIds.at(-1) : undefined + if ( + promotedPtyId && + parseAppSshPtyId(promotedPtyId)?.connectionId === liveBinding.authority.targetId && + isCurrentDirectSshAuthority(s, liveBinding.authority) + ) { + nextDirectSshLivePtyBindingByTabId[tabId] = { + ...liveBinding, + ptyId: promotedPtyId + } + } else if (!preservesDirectSshContinuationGap) { + delete nextDirectSshLivePtyBindingByTabId[tabId] + } + } return { ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), ptyIdsByTabId: nextPtyIdsByTabId, lastKnownRelayPtyIdByTabId: nextLastKnownRelay, pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, - codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, + directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, + directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId } }) @@ -2376,6 +2751,84 @@ export const createTerminalSlice: StateCreator }) }, + clearDirectSshTargetPtyBindings: (targetId) => { + let clearedCount = 0 + set((s) => { + const result = clearDirectSshTerminalBindings(s, resolveDirectSshTerminalKeys(s, targetId)) + clearedCount = result.clearedCount + return result.patch ?? s + }) + return clearedCount + }, + + invalidateStaleDirectSshTargetPtyBindings: (authority) => { + let clearedCount = 0 + set((s) => { + if (!isCurrentDirectSshAuthority(s, authority)) { + return s + } + const result = invalidateStaleDirectSshTerminalBindings( + s, + resolveDirectSshTerminalKeys(s, authority.targetId), + authority + ) + clearedCount = result.clearedCount + return result.patch ?? s + }) + return clearedCount + }, + + retryDirectSshTargetPanes: (authority, now = Date.now()) => { + let retriedCount = 0 + set((s) => { + if (!isCurrentDirectSshAuthority(s, authority)) { + return s + } + const result = retryDirectSshTerminalPanes( + s, + resolveDirectSshTerminalKeys(s, authority.targetId), + authority, + now + ) + retriedCount = result.retriedCount + return result.patch ?? s + }) + return retriedCount + }, + + settleDirectSshPaneRetry: (result, now = Date.now()) => { + set((s) => { + if (!isCurrentDirectSshAuthority(s, result.authority)) { + return s + } + const history = s.directSshPaneRetryHistoryByTabId[result.tabId] + const preservesExhaustedSplitAttempt = + (result.status === 'failed' || result.status === 'timed-out') && + history != null && + directSshAuthoritiesEqual(history.authority, result.authority) && + history.attemptedAt.length >= 2 + if (preservesExhaustedSplitAttempt) { + return s + } + const settlement = settleDirectSshPaneRetryState(s, result) + if (!settlement) { + return s + } + const settledState = { ...s, ...settlement } + if (result.status !== 'failed' && result.status !== 'timed-out') { + return settledState + } + const retry = retrySettledDirectSshTerminalPane( + settledState, + resolveDirectSshTerminalKeys(settledState, result.authority.targetId), + result.authority, + result.tabId, + now + ) + return retry.patch ? { ...settledState, ...retry.patch } : settledState + }) + }, + shutdownWorktreeTerminals: async (worktreeId, opts) => { const keepIdentifiers = opts?.keepIdentifiers ?? false const shutdownReason: AgentStatusWorktreeShutdownReason = @@ -3015,10 +3468,18 @@ export const createTerminalSlice: StateCreator const nextTabsByWorktree = detachedPtyId ? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId) : sourceTabsByWorktree + const directSshLedger = transferDirectSshPaneDetachLedger(s, { + detachedPtyId, + sourcePtyId: sourcePrimaryPtyId, + sourceTabId, + targetTabId, + isAuthorityCurrent: (authority) => isCurrentDirectSshAuthority(s, authority) + }) return { ptyIdsByTabId: nextPtyIdsByTabId, lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId, + ...directSshLedger, ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}) } }) @@ -3377,7 +3838,7 @@ export const createTerminalSlice: StateCreator const allTabs = Object.values(tabsByWorktree).flat() const tabById = buildByIdIndex(allTabs) - return { + const hydrated: WorkspaceHydrationPatch = { activeRepoId, activeWorktreeId, activeWorkspaceKey, @@ -3422,10 +3883,19 @@ export const createTerminalSlice: StateCreator }) ) } + return options?.replaceWorkspaceKeys + ? targetScopedWorkspaceHydrationPatch(s, hydrated, session, options) + : hydrated }) }, - reconnectPersistedTerminals: async (_signal) => { + reconnectPersistedTerminals: async (signal, options) => { + if ( + signal?.aborted || + (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) + ) { + return + } const { pendingReconnectWorktreeIds, pendingReconnectTabByWorktree, @@ -3434,9 +3904,15 @@ export const createTerminalSlice: StateCreator tabsByWorktree, ptyIdsByTabId } = get() - const ids = pendingReconnectWorktreeIds ?? [] + const scopedWorkspaceKeys = options ? new Set(options.workspaceKeys) : null + const ids = (pendingReconnectWorktreeIds ?? []).filter( + (id) => !scopedWorkspaceKeys || scopedWorkspaceKeys.has(id) + ) if (ids.length === 0) { + if (options) { + return + } set({ workspaceSessionReady: true, pendingReconnectWorktreeIds: [], @@ -3458,9 +3934,10 @@ export const createTerminalSlice: StateCreator const worktree = worktreeById.get(worktreeId) const repo = worktree ? (repoById.get(worktree.repoId) ?? null) : null // Why: only allow deferred reattach when the SSH connection is active; reattaching to a not-yet-connected relay (deferred/passphrase targets) would fail. - const sshState = repo?.connectionId ? get().sshConnectionStates.get(repo.connectionId) : null - const sshConnected = repo?.connectionId != null && sshState?.status === 'connected' - const supportsDeferredReattach = !repo?.connectionId || sshConnected + const sshTargetId = options?.directSshAuthority.targetId ?? repo?.connectionId ?? null + const sshState = sshTargetId ? get().sshConnectionStates.get(sshTargetId) : null + const sshConnected = sshTargetId != null && sshState?.status === 'connected' + const supportsDeferredReattach = options ? sshConnected : !repo?.connectionId || sshConnected console.debug( `[reconnect-terminals] worktree=${worktreeId} connectionId=${repo?.connectionId} sshStatus=${sshState?.status} supportsDeferredReattach=${supportsDeferredReattach}` ) @@ -3479,7 +3956,12 @@ export const createTerminalSlice: StateCreator const tabId = tab.id const layout = terminalLayoutsByTabId[tabId] const leafPtyMap = layout?.ptyIdsByLeafId ?? {} - const tabLevelPtyId = pendingReconnectPtyIdByTabId[tabId] + const pendingPtyId = pendingReconnectPtyIdByTabId[tabId] + const tabLevelPtyId = + options && + parseAppSshPtyId(pendingPtyId ?? '')?.connectionId !== options.directSshAuthority.targetId + ? undefined + : pendingPtyId const hasLeafMappings = Object.keys(leafPtyMap).length > 0 // Why: set the wake-hint (tab.ptyId) and live-pty map (ptyIdsByTabId) so the worktree dot goes green before the pane mounts; actual reattach happens later in pty-connection.ts. @@ -3508,13 +3990,25 @@ export const createTerminalSlice: StateCreator } // Why: deferred SSH targets haven't connected yet, so their ptyIds weren't restored above; stash session IDs in a map that survives cleanup for pty-connection.ts's deferred reconnect. - const deferredSshSessionIdsByTabId: Record = {} + const scopedTabIds = new Set( + [...(scopedWorkspaceKeys ?? ids)].flatMap((workspaceKey) => + (tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ) + ) + const deferredSshSessionIdsByTabId: Record = options + ? Object.fromEntries( + Object.entries(get().deferredSshSessionIdsByTabId).filter( + ([tabId]) => !scopedTabIds.has(tabId) + ) + ) + : {} for (const worktreeId of ids) { const worktree = worktreeById.get(worktreeId) // Why: SSH worktrees aren't in worktreesByRepo at cold start; fall back to the repo id in the composite worktree id so sessions still reach the deferred map. const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) const repo = repoId ? (repoById.get(repoId) ?? null) : null - if (!repo?.connectionId) { + const connectionId = options?.directSshAuthority.targetId ?? repo?.connectionId + if (!connectionId) { continue } // Why: a repo can outlive its SSH target when the target was removed out of @@ -3523,29 +4017,50 @@ export const createTerminalSlice: StateCreator // a target it no longer lists — a stranded deferred id reads as liveness and // the orphan sweep could never remove the dead tab. Defer while the list is // still unknown so a normal cold-start reconnect isn't dropped (#9911). - if (get().sshTargetsHydrated && !get().sshTargetLabels.has(repo.connectionId)) { + if (get().sshTargetsHydrated && !get().sshTargetLabels.has(connectionId)) { continue } - const sshConnected = get().sshConnectionStates.get(repo.connectionId)?.status === 'connected' + const sshConnected = get().sshConnectionStates.get(connectionId)?.status === 'connected' if (sshConnected) { continue } const tabs = tabsByWorktree[worktreeId] ?? [] for (const tab of tabs) { const sessionId = pendingReconnectPtyIdByTabId[tab.id] - if (sessionId) { + if (sessionId && (!options || parseAppSshPtyId(sessionId)?.connectionId === connectionId)) { deferredSshSessionIdsByTabId[tab.id] = sessionId } } } + if ( + signal?.aborted || + (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) + ) { + return + } + const remainingReconnectWorktreeIds = options + ? pendingReconnectWorktreeIds.filter((id) => !scopedWorkspaceKeys?.has(id)) + : [] + const remainingReconnectTabByWorktree = options + ? Object.fromEntries( + Object.entries(pendingReconnectTabByWorktree).filter( + ([workspaceKey]) => !scopedWorkspaceKeys?.has(workspaceKey) + ) + ) + : {} + const remainingReconnectPtyIdByTabId = options + ? Object.fromEntries( + Object.entries(pendingReconnectPtyIdByTabId).filter(([tabId]) => !scopedTabIds.has(tabId)) + ) + : {} set({ ...(reconnectedTabsByWorktree ? { tabsByWorktree: reconnectedTabsByWorktree } : {}), ...(reconnectedPtyIdsByTabId ? { ptyIdsByTabId: reconnectedPtyIdsByTabId } : {}), - workspaceSessionReady: true, - pendingReconnectWorktreeIds: [], - pendingReconnectTabByWorktree: {}, - pendingReconnectPtyIdByTabId: {}, + ...(options ? {} : { workspaceSessionReady: true }), + pendingReconnectWorktreeIds: remainingReconnectWorktreeIds, + pendingReconnectTabByWorktree: remainingReconnectTabByWorktree, + pendingReconnectPtyIdByTabId: remainingReconnectPtyIdByTabId, deferredSshSessionIdsByTabId }) } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index d1d60ed2ed9..fee8e479709 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -23,6 +23,11 @@ import type { import type { WorktreeForceDeleteReason } from '../../../../shared/worktree-removal' import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { + HostQualifiedDetectedWorktreeResult, + SshExecutionHostId +} from '../../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' import type { PendingWorktreeCreation, WorktreeCreationPhase @@ -39,6 +44,17 @@ export type WorktreeDeleteState = { lockReason?: string | null } +export type WorktreeFetchOptions = { + requireAuthoritative?: boolean + executionHostId?: ExecutionHostId + forceLocalOwner?: boolean +} + +export type DirectSshWorktreeFetchOptions = WorktreeFetchOptions & { + executionHostId: SshExecutionHostId + directSshAuthority: DirectSshAuthority +} + export type WorktreeMetaUpdateGuard = (worktree: Worktree | DetectedWorktree | undefined) => boolean export type WorktreeMetaUpdateOptions = { @@ -120,14 +136,13 @@ export type WorktreeSlice = { /** Startup owns the initial all-host refresh; sidebar repo-change refreshes stay gated until it finishes. */ startupWorktreeRefreshCompleted: boolean fetchDetectedWorktrees: (repoId: string) => Promise - fetchWorktrees: ( - repoId: string, - options?: { - requireAuthoritative?: boolean - executionHostId?: ExecutionHostId - forceLocalOwner?: boolean - } - ) => Promise + fetchWorktrees: { + ( + repoId: string, + options: DirectSshWorktreeFetchOptions + ): Promise + (repoId: string, options?: WorktreeFetchOptions): Promise + } fetchAllWorktrees: (options?: { hydrationPurge?: 'allow' | 'defer' }) => Promise fetchWorktreeLineage: (options?: { forceLocalOwner?: boolean }) => Promise updateWorktreeLineage: ( diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index bc8da63bb42..f83cbe4372d 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -18,6 +18,11 @@ import { } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import type { + HostQualifiedDetectedWorktreeResult, + ListDetectedWorktreesArgs +} from '../../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' import { beginHugeRepoWarningProbe, clearHugeRepoWarningDismissalsForTests, @@ -58,14 +63,48 @@ function makeDetectedResult( } } +const TEST_SSH_AUTHORITY: DirectSshAuthority = { + targetId: 'ssh-1', + providerEpoch: 'provider-ssh-1' as SshProviderEpoch, + connectionGeneration: 1 +} + +function qualifyDetectedResult( + args: ListDetectedWorktreesArgs, + result: DetectedWorktreeListResult +): HostQualifiedDetectedWorktreeResult { + return { + status: result.authoritative ? 'complete' : 'non-authoritative', + providerRequestId: args.providerRequestId, + repoId: args.repoId, + authority: + args.executionHostId === LOCAL_EXECUTION_HOST_ID + ? { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID } + : { + kind: 'direct-ssh', + executionHostId: args.executionHostId, + ...args.expectedAuthority + }, + result + } +} + +const listDetectedMock = vi.fn< + ( + args: ListDetectedWorktreesArgs + ) => Promise +>(async (args) => { + const result = makeDetectedResult(args.repoId, await worktreeListMock({ repoId: args.repoId })) + return qualifyDetectedResult(args, result) +}) + const mockApi = { worktrees: { create: vi.fn(), prefetchCreateBase: vi.fn().mockResolvedValue(undefined), list: worktreeListMock, - listDetected: vi.fn(async ({ repoId }: { repoId: string }) => - makeDetectedResult(repoId, await worktreeListMock({ repoId })) - ), + listDetected: listDetectedMock, + cancelListDetected: vi.fn().mockResolvedValue(undefined), listLineage: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue(undefined), forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), @@ -95,6 +134,7 @@ globalThis.window = { api: mockApi } import { WORKTREE_REFRESH_CONCURRENCY, + acquireDirectSshDetectedWorktreeRefresh, createWorktreeSlice, getHostedReviewLinkMutationGenerationForTests, getHostedReviewLinkWorktreeAliasCountForTests, @@ -128,6 +168,19 @@ function createTestStore() { // Why: this test isolates the worktree slice, so it provides only the state surface createWorktreeSlice touches. ...createWorktreeSlice(...a), trustedOrcaHooks: {}, + sshConnectionStates: new Map([ + [ + TEST_SSH_AUTHORITY.targetId, + { + targetId: TEST_SSH_AUTHORITY.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: TEST_SSH_AUTHORITY.providerEpoch, + connectionGeneration: TEST_SSH_AUTHORITY.connectionGeneration + } + ] + ]), repos: [], projectHostSetups: [], deleteProjectHostSetup: vi.fn().mockResolvedValue(null), @@ -758,23 +811,23 @@ describe('fetchWorktrees', () => { let releaseSsh!: () => void const localStarted = new Promise((resolve) => { mockApi.worktrees.listDetected.mockImplementationOnce( - async ({ repoId }: { repoId: string }) => { + async (args: ListDetectedWorktreesArgs) => { resolve() await new Promise((release) => { releaseLocal = release }) - return makeDetectedResult(repoId, [localWorktree]) + return qualifyDetectedResult(args, makeDetectedResult(args.repoId, [localWorktree])) } ) }) const sshStarted = new Promise((resolve) => { mockApi.worktrees.listDetected.mockImplementationOnce( - async ({ repoId }: { repoId: string }) => { + async (args: ListDetectedWorktreesArgs) => { resolve() await new Promise((release) => { releaseSsh = release }) - return makeDetectedResult(repoId, [sshWorktree]) + return qualifyDetectedResult(args, makeDetectedResult(args.repoId, [sshWorktree])) } ) }) @@ -826,12 +879,12 @@ describe('fetchWorktrees', () => { let releaseScan!: () => void const scanStarted = new Promise((resolve) => { mockApi.worktrees.listDetected.mockImplementationOnce( - async ({ repoId }: { repoId: string }) => { + async (args: ListDetectedWorktreesArgs) => { resolve() await new Promise((release) => { releaseScan = release }) - return makeDetectedResult(repoId, [sshWorktree]) + return qualifyDetectedResult(args, makeDetectedResult(args.repoId, [sshWorktree])) } ) }) @@ -878,12 +931,12 @@ describe('fetchWorktrees', () => { let releaseScan!: () => void const scanStarted = new Promise((resolve) => { mockApi.worktrees.listDetected.mockImplementationOnce( - async ({ repoId }: { repoId: string }) => { + async (args: ListDetectedWorktreesArgs) => { resolve() await new Promise((release) => { releaseScan = release }) - return makeDetectedResult(repoId, [sshWorktree]) + return qualifyDetectedResult(args, makeDetectedResult(args.repoId, [sshWorktree])) } ) }) @@ -920,6 +973,414 @@ describe('fetchWorktrees', () => { expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) }) + it('exposes shared direct leases and reports cancellation only for the last waiter', async () => { + const store = createTestStore() + let request!: ListDetectedWorktreesArgs + let resolveProvider!: (result: HostQualifiedDetectedWorktreeResult) => void + const provider = new Promise((resolve) => { + resolveProvider = resolve + }) + mockApi.worktrees.listDetected.mockImplementationOnce( + async (args: ListDetectedWorktreesArgs) => { + request = args + return provider + } + ) + + const input = { + repoId: 'repo-ssh', + executionHostId: 'ssh:ssh-1' as const, + authority: TEST_SSH_AUTHORITY + } + const first = acquireDirectSshDetectedWorktreeRefresh(store, input) + const second = acquireDirectSshDetectedWorktreeRefresh(store, input) + + expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) + expect(first.providerRequestId).toBe(second.providerRequestId) + expect(first.waiterLeaseId).not.toBe(second.waiterLeaseId) + expect(first.release('superseded')).toBe('retained') + expect(mockApi.worktrees.cancelListDetected).not.toHaveBeenCalled() + expect(second.release('invalidated')).toBe('cancel-started') + expect(second.release('stopped')).toBe('already-settled') + expect(mockApi.worktrees.cancelListDetected).toHaveBeenCalledWith({ + providerRequestId: first.providerRequestId + }) + + resolveProvider(qualifyDetectedResult(request, makeDetectedResult('repo-ssh', []))) + await provider + await Promise.resolve() + }) + + it('merges one exact direct provider result once without a second scan', async () => { + const store = createTestStore() + const worktree = makeWorktree({ + id: 'repo-ssh::/home/orca/feature', + repoId: 'repo-ssh', + path: '/home/orca/feature' + }) + store.setState({ + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ] + } as Partial) + mockApi.worktrees.listDetected.mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => + qualifyDetectedResult(args, makeDetectedResult(args.repoId, [worktree])) + ) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) + + const lease = acquireDirectSshDetectedWorktreeRefresh(store, { + repoId: 'repo-ssh', + executionHostId: 'ssh:ssh-1', + authority: TEST_SSH_AUTHORITY + }) + const providerResult = await lease.result + const firstMerge = lease.merge(providerResult) + const secondMerge = lease.merge(providerResult) + + expect(firstMerge).toBe(providerResult) + expect(secondMerge).toBe(providerResult) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) + expect(subscriber).toHaveBeenCalledTimes(1) + expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ + { ...worktree, hostId: 'ssh:ssh-1' } + ]) + unsubscribe() + }) + + it('rejects a late duplicate exact-host owner with zero mutation publications', async () => { + const store = createTestStore() + const existing = makeWorktree({ + id: 'repo-ssh::/home/orca/existing', + repoId: 'repo-ssh', + path: '/home/orca/existing', + branch: 'refs/heads/old', + hostId: 'ssh:ssh-1' + }) + store.setState({ + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + worktreesByRepo: { 'repo-ssh': [existing] }, + detectedWorktreesByRepo: { + 'repo-ssh': makeDetectedResult('repo-ssh', [existing]) + } + } as Partial) + let request!: ListDetectedWorktreesArgs + let resolveProvider!: (result: HostQualifiedDetectedWorktreeResult) => void + mockApi.worktrees.listDetected.mockImplementationOnce( + (args: ListDetectedWorktreesArgs) => + new Promise((resolve) => { + request = args + resolveProvider = resolve + }) + ) + const lease = acquireDirectSshDetectedWorktreeRefresh(store, { + repoId: 'repo-ssh', + executionHostId: 'ssh:ssh-1', + authority: TEST_SSH_AUTHORITY + }) + + store.setState((state) => ({ + repos: [ + ...state.repos, + { + id: 'repo-ssh', + path: '/home/orca/duplicate', + displayName: 'Duplicate SSH Repo', + badgeColor: '#111', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + })) + const beforeWorktrees = store.getState().worktreesByRepo + const beforeDetected = store.getState().detectedWorktreesByRepo + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) + resolveProvider( + qualifyDetectedResult( + request, + makeDetectedResult('repo-ssh', [ + { + ...existing, + branch: 'refs/heads/new' + } + ]) + ) + ) + + expect(lease.merge(await lease.result)).toMatchObject({ status: 'stale' }) + expect(store.getState().worktreesByRepo).toBe(beforeWorktrees) + expect(store.getState().detectedWorktreesByRepo).toBe(beforeDetected) + expect(subscriber).not.toHaveBeenCalled() + unsubscribe() + }) + + it('fails closed before provider acquisition when direct authority is partial', async () => { + const store = createTestStore() + const worktreesByRepo = store.getState().worktreesByRepo + const detectedWorktreesByRepo = store.getState().detectedWorktreesByRepo + store.setState({ + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: TEST_SSH_AUTHORITY.providerEpoch + } + ] + ]) + } as Partial) + + await expect(store.getState().fetchWorktrees('repo-ssh')).resolves.toBe(false) + expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo).toBe(worktreesByRepo) + expect(store.getState().detectedWorktreesByRepo).toBe(detectedWorktreesByRepo) + }) + + it('keeps worktree maps byte-identical for stale and malformed direct results', async () => { + const store = createTestStore() + const existing = makeWorktree({ + id: 'repo-ssh::/home/orca/existing', + repoId: 'repo-ssh', + path: '/home/orca/existing', + hostId: 'ssh:ssh-1' + }) + store.setState({ + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + worktreesByRepo: { 'repo-ssh': [existing] }, + detectedWorktreesByRepo: { + 'repo-ssh': makeDetectedResult('repo-ssh', [existing]) + } + } as Partial) + const beforeWorktrees = store.getState().worktreesByRepo + const beforeDetected = store.getState().detectedWorktreesByRepo + const beforeBytes = JSON.stringify([beforeWorktrees, beforeDetected]) + let request!: ListDetectedWorktreesArgs + let resolveProvider!: (result: HostQualifiedDetectedWorktreeResult) => void + mockApi.worktrees.listDetected.mockImplementationOnce( + async (args: ListDetectedWorktreesArgs) => { + request = args + return new Promise((resolve) => { + resolveProvider = resolve + }) + } + ) + const lease = acquireDirectSshDetectedWorktreeRefresh(store, { + repoId: 'repo-ssh', + executionHostId: 'ssh:ssh-1', + authority: TEST_SSH_AUTHORITY + }) + const nextAuthority = { + ...TEST_SSH_AUTHORITY, + providerEpoch: 'provider-ssh-2' as SshProviderEpoch, + connectionGeneration: 2 + } + store.setState({ + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: nextAuthority.providerEpoch, + connectionGeneration: nextAuthority.connectionGeneration + } + ] + ]) + } as Partial) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) + resolveProvider( + qualifyDetectedResult( + request, + makeDetectedResult('repo-ssh', [ + makeWorktree({ + id: 'repo-ssh::/home/orca/stale', + repoId: 'repo-ssh', + path: '/home/orca/stale' + }) + ]) + ) + ) + const staleResult = await lease.result + + expect(lease.merge(staleResult)).toMatchObject({ status: 'stale' }) + expect(store.getState().worktreesByRepo).toBe(beforeWorktrees) + expect(store.getState().detectedWorktreesByRepo).toBe(beforeDetected) + expect(JSON.stringify([beforeWorktrees, beforeDetected])).toBe(beforeBytes) + expect(subscriber).not.toHaveBeenCalled() + unsubscribe() + + store.setState({ + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: nextAuthority.providerEpoch, + connectionGeneration: nextAuthority.connectionGeneration + } + ] + ]) + } as Partial) + mockApi.worktrees.listDetected.mockImplementationOnce( + async (args: ListDetectedWorktreesArgs) => ({ + ...qualifyDetectedResult(args, makeDetectedResult(args.repoId, [])), + repoId: 'wrong-repo' + }) + ) + const malformed = acquireDirectSshDetectedWorktreeRefresh(store, { + repoId: 'repo-ssh', + executionHostId: 'ssh:ssh-1', + authority: nextAuthority + }) + const malformedResult = await malformed.result + + expect(malformed.merge(malformedResult)).toMatchObject({ + status: 'rejected' + }) + expect(store.getState().worktreesByRepo).toBe(beforeWorktrees) + expect(store.getState().detectedWorktreesByRepo).toBe(beforeDetected) + }) + + it('keeps duplicate repo IDs isolated across direct SSH hosts', async () => { + const store = createTestStore() + const authorityA = { + targetId: 'ssh-a', + providerEpoch: 'provider-a' as SshProviderEpoch, + connectionGeneration: 1 + } + const authorityB = { + targetId: 'ssh-b', + providerEpoch: 'provider-b' as SshProviderEpoch, + connectionGeneration: 4 + } + const worktreeA = makeWorktree({ + id: 'same-repo::/srv/a', + repoId: 'same-repo', + path: '/srv/a' + }) + const worktreeB = makeWorktree({ + id: 'same-repo::/srv/b', + repoId: 'same-repo', + path: '/srv/b' + }) + store.setState({ + repos: [ + { + id: 'same-repo', + path: '/srv/repo-a', + displayName: 'Repo A', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-a' + }, + { + id: 'same-repo', + path: '/srv/repo-b', + displayName: 'Repo B', + badgeColor: '#111', + addedAt: 1, + connectionId: 'ssh-b' + } + ], + sshConnectionStates: new Map([ + [ + 'ssh-a', + { + targetId: 'ssh-a', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authorityA.providerEpoch, + connectionGeneration: authorityA.connectionGeneration + } + ], + [ + 'ssh-b', + { + targetId: 'ssh-b', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authorityB.providerEpoch, + connectionGeneration: authorityB.connectionGeneration + } + ] + ]) + } as Partial) + mockApi.worktrees.listDetected + .mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => + qualifyDetectedResult(args, makeDetectedResult(args.repoId, [worktreeA])) + ) + .mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => + qualifyDetectedResult(args, makeDetectedResult(args.repoId, [worktreeB])) + ) + + const refreshA = acquireDirectSshDetectedWorktreeRefresh(store, { + repoId: 'same-repo', + executionHostId: 'ssh:ssh-a', + authority: authorityA + }) + const refreshB = acquireDirectSshDetectedWorktreeRefresh(store, { + repoId: 'same-repo', + executionHostId: 'ssh:ssh-b', + authority: authorityB + }) + refreshA.merge(await refreshA.result) + refreshB.merge(await refreshB.result) + + expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(2) + expect(refreshA.providerRequestId).not.toBe(refreshB.providerRequestId) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([ + { ...worktreeA, hostId: 'ssh:ssh-a' }, + { ...worktreeB, hostId: 'ssh:ssh-b' } + ]) + }) + it('purges remembered right sidebar tabs for worktrees removed by a committed refresh', async () => { const store = createTestStore() const removed = makeWorktree({ @@ -1305,7 +1766,12 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees('same-repo', { forceLocalOwner: true }) - expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith( + expect.objectContaining({ + repoId: 'same-repo', + executionHostId: 'local' + }) + ) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() expect(store.getState().worktreesByRepo['same-repo']).toEqual([remote, local]) expect(store.getState().detectedWorktreesByRepo['same-repo']?.worktrees).toEqual( @@ -1337,13 +1803,19 @@ describe('fetchWorktrees', () => { } ] } as Partial) - mockApi.worktrees.listDetected.mockResolvedValueOnce( - makeDetectedResult('repo-ssh', [sshWorktree], { source: 'git' }) + mockApi.worktrees.listDetected.mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => + qualifyDetectedResult(args, makeDetectedResult('repo-ssh', [sshWorktree], { source: 'git' })) ) await store.getState().fetchWorktrees('repo-ssh', { forceLocalOwner: true }) - expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'repo-ssh' }) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith( + expect.objectContaining({ + repoId: 'repo-ssh', + executionHostId: 'ssh:ssh-1', + expectedAuthority: TEST_SSH_AUTHORITY + }) + ) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() // Why: SSH worktrees are fetched via local IPC but belong to the SSH host, so they carry the repo's ssh host id. expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ @@ -1385,7 +1857,12 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees('same-repo', { executionHostId: 'local' }) - expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith( + expect.objectContaining({ + repoId: 'same-repo', + executionHostId: 'local' + }) + ) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree]) }) @@ -6679,7 +7156,12 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect.objectContaining({ id: refreshedRemoteWorktree.id, hostId: 'runtime:env-1' }) ]) ) - expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith( + expect.objectContaining({ + repoId: 'same-repo', + executionHostId: 'local' + }) + ) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'worktree.detectedList', diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 02711db36f0..234db3dccad 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1,10 +1,8 @@ /* eslint-disable max-lines */ -import type { StateCreator } from 'zustand' +import type { StateCreator, StoreApi } from 'zustand' import type { AppState } from '../types' import type { DetectedWorktreeListResult, - TerminalLayoutSnapshot, - TerminalPaneLayoutNode, LocalBaseRefRefreshResult, ForceDeleteWorktreeBranchResult, FolderWorkspace, @@ -23,6 +21,8 @@ import { findWorktreeById, applyWorktreeUpdates, getRepoIdFromWorktreeId, + type DirectSshWorktreeFetchOptions, + type WorktreeFetchOptions, type WorktreeSlice } from './worktree-helpers' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' @@ -101,6 +101,18 @@ import { isLockedWorktreeRemovalError } from '../../../../shared/worktree-removal' import { FolderWorkspaceActivityPersistence } from './folder-workspace-activity-persistence' +import { + createDetectedWorktreeRefreshLeaseRegistry, + type DetectedWorktreeRefreshLease +} from './detected-worktree-refresh-leases' +import { getTerminalActivationSpawnSuppression } from './terminal-activation-spawn-suppression' +import type { + HostQualifiedDetectedWorktreeResult, + ListDetectedWorktreesArgs, + ProviderRequestId, + SshExecutionHostId +} from '../../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' // Why: old runtime servers only have `worktree.list`; preserve the large-list UI hydration parity used before `worktree.detectedList` existed. @@ -117,7 +129,10 @@ const pendingActivationTerminalPrepCancels = new Map void>() const detachedHeadAutoDerivedDisplayNames = new Map() const folderWorkspaceWorktreeCache = new WeakMap() const hostedReviewPushTargetLookupsInFlight = new Set() -const detectedWorktreeRefreshesInFlight = new Map>() +const runtimeDetectedWorktreeRefreshesInFlight = new Map< + string, + Promise +>() type WorktreeSliceGet = Parameters>[1] const folderWorkspaceActivityPersistenceByStore = new WeakMap< WorktreeSliceGet, @@ -144,6 +159,34 @@ type BackgroundRuntimeRefreshOptions = { reuseRecentCompatibilityFailure?: boolean } +type DetectedWorktreeRefreshOptions = BackgroundRuntimeRefreshOptions & { + executionHostId: ExecutionHostId + requireAuthoritative?: boolean + directSshAuthority?: DirectSshAuthority +} + +type AdmittedDetectedWorktreeRefresh = { + status: 'admitted' + result: DetectedWorktreeListResult + providerResult?: HostQualifiedDetectedWorktreeResult + executionHostId: ExecutionHostId + directSshAuthority?: DirectSshAuthority + runtimeAuthority?: { + environmentId: string + connectionGeneration: number + runtimeConnectionGeneration: number + } +} + +type DetectedWorktreeRefreshOutcome = + | AdmittedDetectedWorktreeRefresh + | { + status: 'not-admitted' + providerResult: HostQualifiedDetectedWorktreeResult + executionHostId: ExecutionHostId + directSshAuthority?: DirectSshAuthority + } + async function mapReposForWorktreeRefresh( repos: readonly TRepo[], mapper: (repo: TRepo) => Promise @@ -166,25 +209,6 @@ async function mapReposForWorktreeRefresh return results } -function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number { - if (!node) { - return 0 - } - if (node.type === 'leaf') { - return 1 - } - return countTerminalLayoutLeaves(node.first) + countTerminalLayoutLeaves(node.second) -} - -function getActivationSpawnSuppression(layout: TerminalLayoutSnapshot | undefined): true | number { - const paneCount = Math.max( - 1, - countTerminalLayoutLeaves(layout?.root), - Object.keys(layout?.ptyIdsByLeafId ?? {}).length - ) - return paneCount === 1 ? true : paneCount -} - function shouldDeferActivationTerminalPrep(): boolean { return typeof window !== 'undefined' && import.meta.env.MODE !== 'test' } @@ -408,17 +432,39 @@ function repoHostId( return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID } -function repoHasExecutionHost( +function repoHasExactlyOneExecutionHostOwner( state: Pick, repoId: string, hostId: ExecutionHostId, ownerWasMissingAtStart: boolean ): boolean { const repoOwners = state.repos.filter((repo) => repo.id === repoId) - // Why: worktrees can load before the repo catalog at startup; only reject a missing owner when this request previously observed an owned repo. + if (repoOwners.length === 0) { + return ownerWasMissingAtStart + } + const ownerHostIds = repoOwners.map((repo) => { + const hasExplicitHost = repo.executionHostId !== null && repo.executionHostId !== undefined + const explicitHost = hasExplicitHost ? parseExecutionHostId(repo.executionHostId) : null + if (hasExplicitHost && !explicitHost) { + return null + } + const rawConnectionId = repo.connectionId + const hasConnection = rawConnectionId !== null && rawConnectionId !== undefined + const connectionId = hasConnection ? rawConnectionId.trim() : null + if (hasConnection && !connectionId) { + return null + } + if (!connectionId || explicitHost?.kind === 'runtime') { + return explicitHost?.id ?? LOCAL_EXECUTION_HOST_ID + } + if (explicitHost && explicitHost.id !== toSshExecutionHostId(connectionId)) { + return null + } + return explicitHost?.id ?? toSshExecutionHostId(connectionId) + }) return ( - (repoOwners.length === 0 && ownerWasMissingAtStart) || - repoOwners.some((repo) => getRepoExecutionHostId(repo) === hostId) + ownerHostIds.every((ownerHostId) => ownerHostId !== null) && + ownerHostIds.filter((ownerHostId) => ownerHostId === hostId).length === 1 ) } @@ -962,14 +1008,7 @@ async function listDetectedWorktreesForRepo( ): Promise { const target = getActiveRuntimeTarget(settings) if (target.kind === 'local') { - const worktreesApi = window.api.worktrees as typeof window.api.worktrees & { - listDetected?: typeof window.api.worktrees.listDetected - } - if (typeof worktreesApi.listDetected === 'function') { - return worktreesApi.listDetected({ repoId }) - } - const legacyWorktrees = await worktreesApi.list({ repoId }) - return toLegacyDetectedWorktreeResult(repoId, { worktrees: legacyWorktrees }) + throw new Error('Local detected-worktree reads require a provider lease') } try { return await callRuntimeRpc( @@ -1001,11 +1040,7 @@ async function listDetectedWorktreesForRepo( function detectedWorktreeRefreshKey( settings: AppState['settings'], repoId: string, - options: { - executionHostId: ExecutionHostId - requireAuthoritative?: boolean - reuseRecentCompatibilityFailure?: boolean - } + options: DetectedWorktreeRefreshOptions ): string { const target = getActiveRuntimeTarget(settings) const targetKey = target.kind === 'local' ? 'local' : `runtime:${target.environmentId}` @@ -1024,48 +1059,247 @@ function detectedWorktreeRefreshKey( return parts.join('\n') } +function isDetectedWorktreeListResult(value: unknown): value is DetectedWorktreeListResult { + if (!value || typeof value !== 'object') { + return false + } + const result = value as Partial + return ( + typeof result.repoId === 'string' && + typeof result.authoritative === 'boolean' && + (result.source === 'git' || + result.source === 'metadata-fallback' || + result.source === 'session-fallback') && + Array.isArray(result.worktrees) + ) +} + +function rejectedDetectedWorktreeProviderResult( + request: ListDetectedWorktreesArgs +): HostQualifiedDetectedWorktreeResult { + return { + providerRequestId: request.providerRequestId, + executionHostId: request.executionHostId, + status: 'rejected' + } +} + +async function startDetectedWorktreeProviderRequest( + request: ListDetectedWorktreesArgs +): Promise { + const worktreesApi = window.api.worktrees as typeof window.api.worktrees & { + listDetected?: typeof window.api.worktrees.listDetected + } + if (typeof worktreesApi.listDetected !== 'function') { + if (request.executionHostId !== LOCAL_EXECUTION_HOST_ID) { + return rejectedDetectedWorktreeProviderResult(request) + } + const worktrees = await worktreesApi.list({ repoId: request.repoId }) + return { + status: 'complete', + providerRequestId: request.providerRequestId, + repoId: request.repoId, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + result: toLegacyDetectedWorktreeResult(request.repoId, { worktrees }) + } + } + const result = await worktreesApi.listDetected(request) + if (result && typeof result === 'object' && 'status' in result && 'providerRequestId' in result) { + return result as unknown as HostQualifiedDetectedWorktreeResult + } + // Why: web and older preload implementations return the legacy local shape. + if (request.executionHostId === LOCAL_EXECUTION_HOST_ID && isDetectedWorktreeListResult(result)) { + return { + status: result.authoritative ? 'complete' : 'non-authoritative', + providerRequestId: request.providerRequestId, + repoId: request.repoId, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + result + } + } + return rejectedDetectedWorktreeProviderResult(request) +} + +const detectedWorktreeRefreshLeaseRegistry = createDetectedWorktreeRefreshLeaseRegistry({ + startProviderRequest: startDetectedWorktreeProviderRequest, + cancelProviderRequest: async (request) => { + await window.api.worktrees.cancelListDetected?.({ + providerRequestId: request.providerRequestId + }) + } +}) + +export function acquireDetectedWorktreeRefreshLeaseForRepo( + settings: AppState['settings'], + repoId: string, + options: DetectedWorktreeRefreshOptions +): DetectedWorktreeRefreshLease { + const parsedHost = parseExecutionHostId(options.executionHostId) + if (!parsedHost || parsedHost.kind === 'runtime') { + throw new Error('Provider leases require a local or direct SSH execution host') + } + const publicKey = detectedWorktreeRefreshKey(settings, repoId, options) + if (parsedHost.kind === 'local') { + return detectedWorktreeRefreshLeaseRegistry.acquire(publicKey, { + repoId, + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + } + if ( + !options.directSshAuthority || + !directSshAuthorityIsComplete(options.directSshAuthority, parsedHost.targetId) + ) { + throw new Error('Direct SSH provider leases require exact target authority') + } + return detectedWorktreeRefreshLeaseRegistry.acquire(publicKey, { + repoId, + executionHostId: options.executionHostId as SshExecutionHostId, + expectedAuthority: { ...options.directSshAuthority } + }) +} + +function qualifiedProviderResultIsAdmitted( + result: HostQualifiedDetectedWorktreeResult, + providerRequestId: ProviderRequestId, + repoId: string, + options: DetectedWorktreeRefreshOptions +): result is Extract< + HostQualifiedDetectedWorktreeResult, + { status: 'complete' | 'non-authoritative' } +> { + if ( + result.providerRequestId !== providerRequestId || + (result.status !== 'complete' && result.status !== 'non-authoritative') || + !isDetectedWorktreeListResult(result.result) || + !result.authority || + result.repoId !== repoId || + result.result.repoId !== repoId || + result.result.authoritative !== (result.status === 'complete') + ) { + return false + } + const parsedHost = parseExecutionHostId(options.executionHostId) + if (parsedHost?.kind === 'local') { + return ( + result.authority.kind === 'local' && + result.authority.executionHostId === LOCAL_EXECUTION_HOST_ID + ) + } + const expected = options.directSshAuthority + return ( + parsedHost?.kind === 'ssh' && + expected !== undefined && + result.authority.kind === 'direct-ssh' && + result.authority.executionHostId === options.executionHostId && + result.authority.targetId === expected.targetId && + result.authority.providerEpoch === expected.providerEpoch && + result.authority.connectionGeneration === expected.connectionGeneration + ) +} + +function normalizeNotAdmittedProviderResult( + result: HostQualifiedDetectedWorktreeResult, + providerRequestId: ProviderRequestId, + executionHostId: ExecutionHostId +): HostQualifiedDetectedWorktreeResult { + if ( + result.providerRequestId === providerRequestId && + result.status !== 'complete' && + result.status !== 'non-authoritative' && + 'executionHostId' in result && + result.executionHostId === executionHostId + ) { + return result + } + return { + providerRequestId, + executionHostId, + status: 'rejected' + } +} + async function listDetectedWorktreesForRepoCoalesced( settings: AppState['settings'], repoId: string, - options: { - executionHostId: ExecutionHostId - requireAuthoritative?: boolean - reuseRecentCompatibilityFailure?: boolean - } -): Promise { + options: DetectedWorktreeRefreshOptions +): Promise { const key = detectedWorktreeRefreshKey(settings, repoId, options) const target = getActiveRuntimeTarget(settings) - const connectionGeneration = - target.kind === 'environment' ? getEnvironmentSshStateGeneration(target.environmentId) : null - const runtimeConnectionGeneration = - target.kind === 'environment' - ? getRuntimeEnvironmentConnectionGeneration(target.environmentId) - : null - const existing = detectedWorktreeRefreshesInFlight.get(key) - if (existing) { - return existing - } - // Why: startup/event fan-out can request the same repo/host refresh many times at once; share only the scan promise so state-merge semantics stay local. - const refresh = listDetectedWorktreesForRepo(settings, repoId, { - reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure - }) - detectedWorktreeRefreshesInFlight.set(key, refresh) - try { - const result = await refresh - if ( - target.kind === 'environment' && - (getEnvironmentSshStateGeneration(target.environmentId) !== connectionGeneration || + if (target.kind === 'environment') { + const connectionGeneration = getEnvironmentSshStateGeneration(target.environmentId) + const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration( + target.environmentId + ) + let refresh = runtimeDetectedWorktreeRefreshesInFlight.get(key) + if (!refresh) { + refresh = listDetectedWorktreesForRepo(settings, repoId, { + reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure + }) + runtimeDetectedWorktreeRefreshesInFlight.set(key, refresh) + } + try { + const result = await refresh + if ( + getEnvironmentSshStateGeneration(target.environmentId) !== connectionGeneration || getRuntimeEnvironmentConnectionGeneration(target.environmentId) !== - runtimeConnectionGeneration) - ) { - throw new Error('runtime_environment_generation_changed') + runtimeConnectionGeneration + ) { + throw new Error('runtime_environment_generation_changed') + } + return { + status: 'admitted', + result, + executionHostId: options.executionHostId, + runtimeAuthority: { + environmentId: target.environmentId, + connectionGeneration, + runtimeConnectionGeneration + } + } + } finally { + if (runtimeDetectedWorktreeRefreshesInFlight.get(key) === refresh) { + runtimeDetectedWorktreeRefreshesInFlight.delete(key) + } } - return result - } finally { - if (detectedWorktreeRefreshesInFlight.get(key) === refresh) { - detectedWorktreeRefreshesInFlight.delete(key) + } + + const lease = acquireDetectedWorktreeRefreshLeaseForRepo(settings, repoId, options) + let providerResult: HostQualifiedDetectedWorktreeResult + try { + providerResult = await lease.result + } catch { + return { + status: 'not-admitted', + providerResult: { + providerRequestId: lease.providerRequestId, + executionHostId: options.executionHostId, + status: 'rejected' + }, + executionHostId: options.executionHostId, + directSshAuthority: options.directSshAuthority } } + if ( + !qualifiedProviderResultIsAdmitted(providerResult, lease.providerRequestId, repoId, options) + ) { + return { + status: 'not-admitted', + providerResult: normalizeNotAdmittedProviderResult( + providerResult, + lease.providerRequestId, + options.executionHostId + ), + executionHostId: options.executionHostId, + directSshAuthority: options.directSshAuthority + } + } + return { + status: 'admitted', + result: providerResult.result, + providerResult, + executionHostId: options.executionHostId, + directSshAuthority: options.directSshAuthority + } } async function listWorktreeLineageForRuntime( @@ -2130,6 +2364,22 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial !worktreeIdSet.has(worktreeId)) + .flatMap(([, tabs]) => tabs.map((tab) => tab.id)) + ) + const omitRetiredDirectSshLedgerByTabId = (obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const tabId of doomedTabIds) { + if (!survivingTabIds.has(tabId) && tabId in out) { + delete out[tabId] + changed = true + } + } + return changed ? out : obj + } const omitByPtyId = (obj: Record): Record => { let changed = false const out = { ...obj } @@ -2241,6 +2491,13 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial, + hostId: ExecutionHostId +): DirectSshAuthority | null { + const parsedHost = parseExecutionHostId(hostId) + if (parsedHost?.kind !== 'ssh') { + return null + } + const connection = state.sshConnectionStates?.get(parsedHost.targetId) + if (connection?.status !== 'connected') { + return null + } + const authority = { + targetId: parsedHost.targetId, + providerEpoch: connection.providerEpoch, + connectionGeneration: connection.connectionGeneration + } as DirectSshAuthority + if (!directSshAuthorityIsComplete(authority, parsedHost.targetId)) { + return null + } + return { + ...authority + } +} + +function directSshAuthoritiesEqual( + left: DirectSshAuthority | null | undefined, + right: DirectSshAuthority | null | undefined +): boolean { + if (!left || !right) { + return false + } + return ( + left.targetId === right.targetId && + left.providerEpoch === right.providerEpoch && + left.connectionGeneration === right.connectionGeneration + ) +} + +function isCurrentDetectedWorktreeRefresh( + state: Pick, + refresh: AdmittedDetectedWorktreeRefresh +): boolean { + if (refresh.directSshAuthority) { + return directSshAuthoritiesEqual( + getCurrentDirectSshAuthority(state, refresh.executionHostId), + refresh.directSshAuthority + ) + } + if (refresh.runtimeAuthority) { + return ( + getEnvironmentSshStateGeneration(refresh.runtimeAuthority.environmentId) === + refresh.runtimeAuthority.connectionGeneration && + getRuntimeEnvironmentConnectionGeneration(refresh.runtimeAuthority.environmentId) === + refresh.runtimeAuthority.runtimeConnectionGeneration + ) + } + return true +} + +function staleDetectedWorktreeProviderResult( + refresh: AdmittedDetectedWorktreeRefresh +): HostQualifiedDetectedWorktreeResult | undefined { + return refresh.providerResult + ? { + providerRequestId: refresh.providerResult.providerRequestId, + executionHostId: refresh.executionHostId, + status: 'stale' + } + : undefined +} + +type FencedWorktreeMergeArgs = { + repoId: string + hostId: ExecutionHostId + ownerWasMissingAtStart: boolean + requestStartedWorktrees: readonly Worktree[] | undefined + setup?: ProjectHostSetup + refresh: AdmittedDetectedWorktreeRefresh + purgeRemovedWorktrees?: boolean +} + +function mergeFetchedWorktrees( + set: Parameters>[0], + args: FencedWorktreeMergeArgs +): boolean { + let admitted = false + set((s) => { + if ( + !isCurrentDetectedWorktreeRefresh(s, args.refresh) || + !repoHasExactlyOneExecutionHostOwner( + s, + args.repoId, + args.hostId, + args.ownerWasMissingAtStart && !args.refresh.directSshAuthority + ) + ) { + return s + } + admitted = true + const matchOptions = worktreeHostMatchOptions(s, args.repoId, args.hostId) + let incoming = toVisibleWorktrees(args.refresh.result, args.hostId, args.setup) + incoming = routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: args.requestStartedWorktrees, + current: s.worktreesByRepo[args.repoId], + incoming, + matchesRefreshHost: (worktree) => worktreeMatchesHost(worktree, args.hostId, matchOptions), + hasBranchScopedReviewContext: hasBranchScopedHostedReviewContext, + updateWorktreeGitIdentity: s.updateWorktreeGitIdentity + }) + const worktrees = sanitizeHostedReviewLinksForBranchClears( + incoming, + s.worktreesByRepo[args.repoId] + ) + const currentForHost = (s.worktreesByRepo[args.repoId] ?? []).filter((worktree) => + worktreeMatchesHost(worktree, args.hostId, matchOptions) + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[args.repoId], + args.refresh.result, + args.hostId, + args.setup, + matchOptions + ) + if (!args.refresh.result.authoritative && worktrees.length === 0 && currentForHost.length > 0) { + return areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[args.repoId], mergedDetected) + ? s + : { + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [args.repoId]: mergedDetected + } + } + } + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[args.repoId], + worktrees, + args.hostId, + matchOptions + ) + const removedIds = + args.purgeRemovedWorktrees === false + ? [] + : getRemovedWorktreeIdsAfterAuthoritativeScan( + s, + args.repoId, + args.refresh.result, + args.hostId + ) + const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[args.repoId], mergedWorktrees) + const detectedChanged = !areDetectedWorktreeResultsEqual( + s.detectedWorktreesByRepo[args.repoId], + mergedDetected + ) + if (!worktreesChanged && !detectedChanged && removedIds.length === 0) { + return s + } + return { + ...(worktreesChanged + ? { + worktreesByRepo: { + ...s.worktreesByRepo, + [args.repoId]: mergedWorktrees + }, + sortEpoch: s.sortEpoch + 1 + } + : {}), + ...(detectedChanged + ? { + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [args.repoId]: mergedDetected + } + } + : {}), + ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) + } + }) + return admitted +} + +export type DirectSshDetectedWorktreeRefresh = { + waiterLeaseId: DetectedWorktreeRefreshLease['waiterLeaseId'] + providerRequestId: ProviderRequestId + result: Promise + release: DetectedWorktreeRefreshLease['release'] + merge(result: HostQualifiedDetectedWorktreeResult): HostQualifiedDetectedWorktreeResult +} + +export function acquireDirectSshDetectedWorktreeRefresh( + store: Pick, 'getState' | 'setState'>, + request: { + repoId: string + executionHostId: SshExecutionHostId + authority: DirectSshAuthority + requireAuthoritative?: boolean + } +): DirectSshDetectedWorktreeRefresh { + const requestStartedState = store.getState() + const requestStartedWorktrees = requestStartedState.worktreesByRepo[request.repoId] + const ownerWasMissingAtStart = !requestStartedState.repos.some( + (repo) => repo.id === request.repoId + ) + const setup = getProjectHostSetupForRepoHost( + requestStartedState, + request.repoId, + request.executionHostId + ) + const settings = settingsForRepoOwner( + requestStartedState, + request.repoId, + request.executionHostId + ) + const options: DetectedWorktreeRefreshOptions = { + executionHostId: request.executionHostId, + directSshAuthority: request.authority, + requireAuthoritative: request.requireAuthoritative + } + const lease = acquireDetectedWorktreeRefreshLeaseForRepo(settings, request.repoId, options) + let mergedResult: HostQualifiedDetectedWorktreeResult | undefined + + return { + waiterLeaseId: lease.waiterLeaseId, + providerRequestId: lease.providerRequestId, + result: lease.result, + release: lease.release, + merge: (providerResult) => { + if (mergedResult) { + return mergedResult + } + if ( + !qualifiedProviderResultIsAdmitted( + providerResult, + lease.providerRequestId, + request.repoId, + options + ) + ) { + mergedResult = normalizeNotAdmittedProviderResult( + providerResult, + lease.providerRequestId, + request.executionHostId + ) + return mergedResult + } + if (request.requireAuthoritative && providerResult.status !== 'complete') { + mergedResult = providerResult + return mergedResult + } + const refresh: AdmittedDetectedWorktreeRefresh = { + status: 'admitted', + result: providerResult.result, + providerResult, + executionHostId: request.executionHostId, + directSshAuthority: request.authority + } + const admitted = mergeFetchedWorktrees( + store.setState as Parameters>[0], + { + repoId: request.repoId, + hostId: request.executionHostId, + ownerWasMissingAtStart, + requestStartedWorktrees, + setup, + refresh + } + ) + mergedResult = admitted + ? providerResult + : (staleDetectedWorktreeProviderResult(refresh) ?? providerResult) + return mergedResult + } + } +} + export const createWorktreeSlice: StateCreator = (set, get) => ({ worktreesByRepo: {}, detectedWorktreesByRepo: {}, @@ -2373,28 +2921,54 @@ export const createWorktreeSlice: StateCreator const hostId = repoHostId(ownerState, repoId) const ownerWasMissingAtStart = !ownerState.repos.some((repo) => repo.id === repoId) const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId) - const result = await listDetectedWorktreesForRepoCoalesced( + const parsedHost = parseExecutionHostId(hostId) + const directSshAuthority = + parsedHost?.kind === 'ssh' + ? (getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) + : undefined + if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + return null + } + const refresh = await listDetectedWorktreesForRepoCoalesced( settingsForRepoOwner(ownerState, repoId, hostId), repoId, - { executionHostId: hostId } + { executionHostId: hostId, directSshAuthority } ) + if (refresh.status !== 'admitted') { + return null + } + let admitted = false set((s) => { - if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) { + if ( + !isCurrentDetectedWorktreeRefresh(s, refresh) || + !repoHasExactlyOneExecutionHostOwner( + s, + repoId, + hostId, + ownerWasMissingAtStart && !refresh.directSshAuthority + ) + ) { return s } + admitted = true // Why: detected-only refreshes can overlap host-scoped visible refreshes; merge detected state so SSH/runtime rows aren't clobbered. const mergedDetected = mergeDetectedWorktreesForHost( s.detectedWorktreesByRepo[repoId], - result, + refresh.result, hostId, setup, worktreeHostMatchOptions(s, repoId, hostId) ) return areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], mergedDetected) ? s - : { detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: mergedDetected } } + : { + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [repoId]: mergedDetected + } + } }) - return result + return admitted ? refresh.result : null } catch (err) { if (notifyRuntimeScopeForbiddenIfNeeded(err)) { return null @@ -2404,11 +2978,17 @@ export const createWorktreeSlice: StateCreator } }, - fetchWorktrees: async (repoId, options) => { + fetchWorktrees: (async ( + repoId: string, + options?: WorktreeFetchOptions | DirectSshWorktreeFetchOptions + ) => { + const directCallerAuthority = + options && 'directSshAuthority' in options ? options.directSshAuthority : undefined try { const ownerState = get() const requestStartedWorktrees = ownerState.worktreesByRepo[repoId] const repoOwners = ownerState.repos.filter((repo) => repo.id === repoId) + const ownerWasMissingAtStart = repoOwners.length === 0 const hasLocalOwner = repoOwners.some( (repo) => getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID ) @@ -2419,142 +2999,49 @@ export const createWorktreeSlice: StateCreator const hostId = useLocalOwner ? LOCAL_EXECUTION_HOST_ID : repoHostId(ownerState, repoId, options?.executionHostId) - const ownerWasMissingAtStart = repoOwners.length === 0 const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId) const ownerSettings = settingsForRepoOwner(ownerState, repoId, hostId) const settings = useLocalOwner && ownerSettings?.activeRuntimeEnvironmentId ? { ...ownerSettings, activeRuntimeEnvironmentId: null } : ownerSettings - const detected = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { + const parsedHost = parseExecutionHostId(hostId) + const directSshAuthority = + parsedHost?.kind === 'ssh' + ? (directCallerAuthority ?? getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) + : undefined + if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + return false + } + const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { executionHostId: hostId, - requireAuthoritative: options?.requireAuthoritative + requireAuthoritative: options?.requireAuthoritative, + directSshAuthority }) - if (options?.requireAuthoritative && !detected.authoritative) { - return false + if (refresh.status !== 'admitted') { + return directCallerAuthority ? refresh.providerResult : false } - let incoming = toVisibleWorktrees(detected, hostId, setup) - const latestState = get() - if (repoHasExecutionHost(latestState, repoId, hostId, ownerWasMissingAtStart)) { - const matchOptions = worktreeHostMatchOptions(latestState, repoId, hostId) - incoming = routeListingBranchSwitchesThroughGitIdentity({ - requestStarted: requestStartedWorktrees, - current: latestState.worktreesByRepo[repoId], - incoming, - matchesRefreshHost: (worktree) => worktreeMatchesHost(worktree, hostId, matchOptions), - hasBranchScopedReviewContext: hasBranchScopedHostedReviewContext, - updateWorktreeGitIdentity: latestState.updateWorktreeGitIdentity - }) + if (options?.requireAuthoritative && !refresh.result.authoritative) { + return directCallerAuthority ? refresh.providerResult : false } - const current = get().worktreesByRepo[repoId] - const worktrees = sanitizeHostedReviewLinksForBranchClears(incoming, current) - const currentMatchOptions = worktreeHostMatchOptions(get(), repoId, hostId) - const currentForHost = (current ?? []).filter((worktree) => - worktreeMatchesHost(worktree, hostId, currentMatchOptions) - ) - if (areWorktreesEqual(currentForHost, worktrees)) { - set((s) => { - if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) { - return s - } - const matchOptions = worktreeHostMatchOptions(s, repoId, hostId) - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan( - s, - repoId, - detected, - hostId - ) - const mergedDetected = mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[repoId], - detected, - hostId, - setup, - matchOptions - ) - const mergedWorktrees = mergeWorktreesForHost( - s.worktreesByRepo[repoId], - worktrees, - hostId, - matchOptions - ) - const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[repoId], mergedWorktrees) - if ( - !worktreesChanged && - areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], mergedDetected) && - removedIds.length === 0 - ) { - return s - } - return { - worktreesByRepo: { - ...s.worktreesByRepo, - [repoId]: mergedWorktrees - }, - detectedWorktreesByRepo: { - ...s.detectedWorktreesByRepo, - [repoId]: mergedDetected - }, - ...(worktreesChanged ? { sortEpoch: s.sortEpoch + 1 } : {}), - ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) - } - }) + const admitted = mergeFetchedWorktrees(set, { + repoId, + hostId, + ownerWasMissingAtStart, + requestStartedWorktrees, + setup, + refresh + }) + if (!admitted) { + return directCallerAuthority + ? (staleDetectedWorktreeProviderResult(refresh) ?? false) + : false + } + // Direct SSH lineage requires its own qualified authority result. + if (!directSshAuthority) { await refreshRemoteWorktreeLineageBestEffort(settings, set) - return detected.authoritative } - - // Why: a transient `git worktree list` failure returns []; replacing a known-good list with [] orphans tabsByWorktree state, so keep stale-but-correct data until the next successful refresh. - if (!detected.authoritative && worktrees.length === 0 && currentForHost.length > 0) { - set((s) => { - if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) { - return s - } - return { - detectedWorktreesByRepo: { - ...s.detectedWorktreesByRepo, - [repoId]: mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[repoId], - detected, - hostId, - setup, - worktreeHostMatchOptions(s, repoId, hostId) - ) - } - } - }) - return false - } - - set((s) => { - if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) { - return s - } - // Why: hidden worktrees aren't in worktreesByRepo; diff purges against the prior authoritative detected list so hiding keeps state but deleting a hidden worktree still purges. - const matchOptions = worktreeHostMatchOptions(s, repoId, hostId) - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected, hostId) - const mergedWorktrees = mergeWorktreesForHost( - s.worktreesByRepo[repoId], - worktrees, - hostId, - matchOptions - ) - const mergedDetected = mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[repoId], - detected, - hostId, - setup, - matchOptions - ) - - return { - // Why: a terminal can switch an active worktree's branch; refresh that live git identity but only bump sortEpoch when the payload actually changed. - worktreesByRepo: { ...s.worktreesByRepo, [repoId]: mergedWorktrees }, - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: mergedDetected }, - sortEpoch: s.sortEpoch + 1, - ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) - } - }) - await refreshRemoteWorktreeLineageBestEffort(settings, set) - return detected.authoritative + return directCallerAuthority ? refresh.providerResult! : refresh.result.authoritative } catch (err) { if (notifyRuntimeScopeForbiddenIfNeeded(err)) { return false @@ -2562,7 +3049,7 @@ export const createWorktreeSlice: StateCreator console.error(`Failed to fetch worktrees for repo ${repoId}:`, err) return false } - }, + }) as WorktreeSlice['fetchWorktrees'], fetchAllWorktrees: async (options) => { const { repos } = get() @@ -2576,64 +3063,29 @@ export const createWorktreeSlice: StateCreator const hostId = getRepoExecutionHostId(r) const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) const settings = settingsForKnownRepoOwner(requestStartedState.settings, r) - const detected = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { - executionHostId: hostId, - reuseRecentCompatibilityFailure: true - }) - let incoming = toVisibleWorktrees(detected, hostId, setup) - const latestState = get() - if (repoHasExecutionHost(latestState, r.id, hostId, false)) { - const matchOptions = worktreeHostMatchOptions(latestState, r.id, hostId) - incoming = routeListingBranchSwitchesThroughGitIdentity({ - requestStarted: requestStartedWorktrees, - current: latestState.worktreesByRepo[r.id], - incoming, - matchesRefreshHost: (worktree) => worktreeMatchesHost(worktree, hostId, matchOptions), - hasBranchScopedReviewContext: hasBranchScopedHostedReviewContext, - updateWorktreeGitIdentity: latestState.updateWorktreeGitIdentity - }) + const parsedHost = parseExecutionHostId(hostId) + const directSshAuthority = + parsedHost?.kind === 'ssh' + ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) + : undefined + if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + return } - const worktrees = sanitizeHostedReviewLinksForBranchClears( - incoming, - get().worktreesByRepo[r.id] - ) - set((s) => { - if (!repoHasExecutionHost(s, r.id, hostId, false)) { - return s - } - const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan( - s, - r.id, - detected, - hostId - ) - const mergedWorktrees = mergeWorktreesForHost( - s.worktreesByRepo[r.id], - worktrees, - hostId, - matchOptions - ) - const mergedDetected = mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[r.id], - detected, - hostId, - setup, - matchOptions - ) - if ( - areWorktreesEqual(s.worktreesByRepo[r.id], mergedWorktrees) && - areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[r.id], mergedDetected) && - removedIds.length === 0 - ) { - return s - } - return { - worktreesByRepo: { ...s.worktreesByRepo, [r.id]: mergedWorktrees }, - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: mergedDetected }, - sortEpoch: s.sortEpoch + 1, - ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) - } + const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { + executionHostId: hostId, + reuseRecentCompatibilityFailure: true, + directSshAuthority + }) + if (refresh.status !== 'admitted') { + return + } + mergeFetchedWorktrees(set, { + repoId: r.id, + hostId, + ownerWasMissingAtStart: false, + requestStartedWorktrees, + setup, + refresh }) } catch (err) { if (notifyRuntimeScopeForbiddenIfNeeded(err)) { @@ -2660,77 +3112,43 @@ export const createWorktreeSlice: StateCreator const requestStartedWorktrees = requestStartedState.worktreesByRepo[r.id] const hostId = getRepoExecutionHostId(r) const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) - const detected = await listDetectedWorktreesForRepoCoalesced( + const parsedHost = parseExecutionHostId(hostId) + const directSshAuthority = + parsedHost?.kind === 'ssh' + ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) + : undefined + if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + return { repoId: r.id, ok: false as const } + } + const refresh = await listDetectedWorktreesForRepoCoalesced( settingsForKnownRepoOwner(requestStartedState.settings, r), r.id, - { executionHostId: hostId, reuseRecentCompatibilityFailure: true } + { + executionHostId: hostId, + reuseRecentCompatibilityFailure: true, + directSshAuthority + } ) - let incoming = toVisibleWorktrees(detected, hostId, setup) - const latestState = get() - if (repoHasExecutionHost(latestState, r.id, hostId, false)) { - const matchOptions = worktreeHostMatchOptions(latestState, r.id, hostId) - incoming = routeListingBranchSwitchesThroughGitIdentity({ - requestStarted: requestStartedWorktrees, - current: latestState.worktreesByRepo[r.id], - incoming, - matchesRefreshHost: (worktree) => worktreeMatchesHost(worktree, hostId, matchOptions), - hasBranchScopedReviewContext: hasBranchScopedHostedReviewContext, - updateWorktreeGitIdentity: latestState.updateWorktreeGitIdentity - }) + if (refresh.status !== 'admitted') { + return { repoId: r.id, ok: false as const } } - const current = get().worktreesByRepo[r.id] - const list = sanitizeHostedReviewLinksForBranchClears(incoming, current) - const currentMatchOptions = worktreeHostMatchOptions(get(), r.id, hostId) - const currentForHost = (current ?? []).filter((worktree) => - worktreeMatchesHost(worktree, hostId, currentMatchOptions) - ) - if ( - !areWorktreesEqual(currentForHost, list) && - !(list.length === 0 && currentForHost.length > 0 && !detected.authoritative) - ) { - set((s) => { - if (!repoHasExecutionHost(s, r.id, hostId, false)) { - return s - } - const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) - return { - worktreesByRepo: { - ...s.worktreesByRepo, - [r.id]: mergeWorktreesForHost(s.worktreesByRepo[r.id], list, hostId, matchOptions) - }, - detectedWorktreesByRepo: { - ...s.detectedWorktreesByRepo, - [r.id]: mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[r.id], - detected, - hostId, - setup, - matchOptions - ) - }, - sortEpoch: s.sortEpoch + 1 - } - }) - } else { - set((s) => { - if (!repoHasExecutionHost(s, r.id, hostId, false)) { - return s - } - return { - detectedWorktreesByRepo: { - ...s.detectedWorktreesByRepo, - [r.id]: mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[r.id], - detected, - hostId, - setup, - worktreeHostMatchOptions(s, r.id, hostId) - ) - } - } - }) + const admitted = mergeFetchedWorktrees(set, { + repoId: r.id, + hostId, + ownerWasMissingAtStart: false, + requestStartedWorktrees, + setup, + refresh, + purgeRemovedWorktrees: false + }) + if (!admitted) { + return { repoId: r.id, ok: false as const } + } + return { + repoId: r.id, + ok: refresh.result.authoritative, + detected: refresh.result } - return { repoId: r.id, ok: detected.authoritative, detected } } catch (err) { console.error(`Failed to fetch worktrees for repo ${r.id}:`, err) return { repoId: r.id, ok: false as const } @@ -4531,7 +4949,9 @@ export const createWorktreeSlice: StateCreator // Why: bump generation to remount a pane whose renderer died while its PTY stayed alive, so it reattaches, not spawns. generation: (tab.generation ?? 0) + 1, // Why: recovery isn't a user interaction — suppress its PTY updates from reshuffling Recent, like activation remounts. - pendingActivationSpawn: getActivationSpawnSuppression(s.terminalLayoutsByTabId[tab.id]) + pendingActivationSpawn: getTerminalActivationSpawnSuppression( + s.terminalLayoutsByTabId[tab.id] + ) } remounted = true return { @@ -4724,7 +5144,7 @@ export const createWorktreeSlice: StateCreator [worktreeId]: tabs.map((tab) => ({ ...tab, generation: (tab.generation ?? 0) + 1, - pendingActivationSpawn: getActivationSpawnSuppression( + pendingActivationSpawn: getTerminalActivationSpawnSuppression( s.terminalLayoutsByTabId[tab.id] ) })) @@ -4803,7 +5223,7 @@ export const createWorktreeSlice: StateCreator ...tab, ...(allDead ? { generation: (tab.generation ?? 0) + 1 } : {}), // Why: slept terminal remount/spawn is click-driven wake work; tag its PTY updates so they don't reshuffle Recent. - pendingActivationSpawn: getActivationSpawnSuppression( + pendingActivationSpawn: getTerminalActivationSpawnSuppression( s.terminalLayoutsByTabId[tab.id] ) })) diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 105071637ca..cfe578f7b06 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -2657,6 +2657,57 @@ describe('web worktree preload API', () => { ]) }) + it('preserves runtime-routed detected-worktree host ownership in the compatibility shape', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(): Promise> { + return Promise.resolve({ + id: 'detected-list', + ok: true, + result: { + repoId: 'repo-1', + authoritative: true, + source: 'git', + worktrees: [ + { id: 'repo-1::/srv/repo', repoId: 'repo-1', path: '/srv/repo', hostId: 'local' }, + { + id: 'repo-1::/ssh/repo', + repoId: 'repo-1', + path: '/ssh/repo', + hostId: 'ssh:hub-private-target' + } + ] + }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-env-1') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.worktrees.listDetected({ repoId: 'repo-1' }) + ).resolves.toMatchObject({ + repoId: 'repo-1', + authoritative: true, + worktrees: [ + { + hostId: 'local', + runtimeOwnerEnvironmentId: 'web-env-1' + }, + { + hostId: 'ssh:hub-private-target', + runtimeOwnerEnvironmentId: 'web-env-1' + } + ] + }) + }) + it('falls back to legacy worktree.list when detectedList is unavailable', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] const worktree = { @@ -2936,6 +2987,71 @@ describe('web worktree preload API', () => { }) }) +describe('web SSH preload API', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('preserves full and partial authority states from the paired runtime', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + const state = + method === 'ssh.connect' + ? { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'web-provider-epoch', + connectionGeneration: 23 + } + : { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'partial-provider-epoch' + } + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { state }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect(globals.window.api.ssh.connect({ targetId: 'ssh-1' })).resolves.toMatchObject({ + providerEpoch: 'web-provider-epoch', + connectionGeneration: 23 + }) + const partial = await globals.window.api.ssh.getState({ targetId: 'ssh-1' }) + + expect(partial).toMatchObject({ providerEpoch: 'partial-provider-epoch' }) + expect(partial).not.toHaveProperty('connectionGeneration') + expect(runtimeCalls).toEqual([ + { method: 'ssh.connect', params: { targetId: 'ssh-1' } }, + { method: 'ssh.getState', params: { targetId: 'ssh-1' } } + ]) + }) +}) + describe('web file preload API', () => { beforeEach(() => { vi.resetModules() diff --git a/src/shared/detected-worktree-provider-contract.ts b/src/shared/detected-worktree-provider-contract.ts new file mode 100644 index 00000000000..4c5289db8fa --- /dev/null +++ b/src/shared/detected-worktree-provider-contract.ts @@ -0,0 +1,56 @@ +import type { ExecutionHostId, LOCAL_EXECUTION_HOST_ID } from './execution-host' +import type { DirectSshAuthority } from './ssh-types' +import type { DetectedWorktreeListResult } from './types' + +export const PROVIDER_REQUEST_ID_MAX_UTF8_BYTES = 128 +export type ProviderRequestId = string & { readonly __providerRequestId: unique symbol } +export type SshExecutionHostId = Extract + +export type LocalDetectedWorktreeRequest = { + providerRequestId: ProviderRequestId + repoId: string + executionHostId: typeof LOCAL_EXECUTION_HOST_ID +} + +export type DirectSshDetectedWorktreeRequest = { + providerRequestId: ProviderRequestId + repoId: string + executionHostId: SshExecutionHostId + expectedAuthority: DirectSshAuthority +} + +export type ListDetectedWorktreesArgs = + | LocalDetectedWorktreeRequest + | DirectSshDetectedWorktreeRequest + +export type AuthoritativeDetectedWorktreeHost = + | { + kind: 'local' + executionHostId: typeof LOCAL_EXECUTION_HOST_ID + } + | ({ + kind: 'direct-ssh' + executionHostId: SshExecutionHostId + } & DirectSshAuthority) + +export type HostQualifiedDetectedWorktreeResult = + | { + status: 'complete' | 'non-authoritative' + providerRequestId: ProviderRequestId + repoId: string + authority: AuthoritativeDetectedWorktreeHost + result: DetectedWorktreeListResult + } + | { + providerRequestId: ProviderRequestId + executionHostId: ExecutionHostId + status: + | 'canceled' + | 'timed-out' + | 'stale' + | 'ambiguous-owner' + | 'authority-unknown' + | 'rejected' + } + +export type LegacyDetectedWorktreeRequest = { repoId: string } diff --git a/src/shared/direct-ssh-reconnect-telemetry-schema.test.ts b/src/shared/direct-ssh-reconnect-telemetry-schema.test.ts new file mode 100644 index 00000000000..10d71e95a24 --- /dev/null +++ b/src/shared/direct-ssh-reconnect-telemetry-schema.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { eventSchemas, type EventProps } from './telemetry-events' + +const validEvent = { + mode: 'reconnect', + reason: 'wake_refresh', + outcome: 'degraded', + terminal_retried_count: 3, + terminal_stale_binding_cleared_count: 1, + terminal_correction_succeeded_count: 2, + catalog_complete_count: 1, + catalog_degraded_count: 0, + catalog_stale_count: 0, + repo_complete_count: 2, + repo_non_authoritative_count: 1, + repo_retrying_count: 1, + repo_timed_out_count: 1, + repo_cancel_budget_exhausted_count: 0, + repo_canceled_count: 0, + repo_stale_count: 0, + repo_rejected_count: 1, + lineage_complete_count: 0, + lineage_degraded_count: 1, + lineage_canceled_count: 0, + lineage_stale_count: 0, + lineage_not_started_count: 0, + git_worktree_count: 4, + folder_workspace_count: 2, + ambiguous_owner_count: 1, + contradictory_owner_count: 1, + total_duration_ms: 750, + terminal_finalization_duration_ms: 5, + catalog_duration_ms: 25, + queue_wait_sample_count: 3, + queue_wait_duration_ms_p50: 10, + queue_wait_duration_ms_p95: 30, + queue_wait_duration_ms_p99: 30, + queue_wait_duration_ms_max: 30, + provider_execution_sample_count: 3, + provider_execution_duration_ms_p50: 100, + provider_execution_duration_ms_p95: 300, + provider_execution_duration_ms_p99: 300, + provider_execution_duration_ms_max: 300, + timeout_retry_count: 1, + locally_settled_waiter_count: 3, + cancel_debt_count: 1, + replacement_admission_delayed_count: 0, + overlapping_join_count: 1, + coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: 3, + estimated_late_work_allowance_count: 1, + authority_rotation_count: 1, + damped_preparation_count: 0 +} satisfies EventProps<'direct_ssh_reconnect_operation'> + +describe('direct SSH reconnect telemetry schema', () => { + it('accepts the aggregate count and duration distribution', () => { + expect(eventSchemas.direct_ssh_reconnect_operation.safeParse(validEvent).success).toBe(true) + }) + + it.each([ + 'target_id', + 'repo_id', + 'host', + 'path', + 'label', + 'username', + 'request_id', + 'lease_id', + 'terminal_id', + 'error', + 'raw_error' + ])('rejects identifier or raw failure field %s', (field) => { + expect( + eventSchemas.direct_ssh_reconnect_operation.safeParse({ + ...validEvent, + [field]: 'sensitive-value' + }).success + ).toBe(false) + }) + + it('keeps timeout, rejection, cancellation, and stale counts independent', () => { + const parsed = eventSchemas.direct_ssh_reconnect_operation.parse({ + ...validEvent, + repo_timed_out_count: 2, + repo_rejected_count: 3, + repo_canceled_count: 4, + repo_stale_count: 5 + }) + + expect([ + parsed.repo_timed_out_count, + parsed.repo_rejected_count, + parsed.repo_canceled_count, + parsed.repo_stale_count + ]).toEqual([2, 3, 4, 5]) + }) + + it('rejects concurrency and late-work values beyond scheduler policy', () => { + expect( + eventSchemas.direct_ssh_reconnect_operation.safeParse({ + ...validEvent, + coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: 6 + }).success + ).toBe(false) + expect( + eventSchemas.direct_ssh_reconnect_operation.safeParse({ + ...validEvent, + estimated_late_work_allowance_count: 3 + }).success + ).toBe(false) + }) +}) diff --git a/src/shared/host-lineage-contract.ts b/src/shared/host-lineage-contract.ts new file mode 100644 index 00000000000..6541b84abac --- /dev/null +++ b/src/shared/host-lineage-contract.ts @@ -0,0 +1,29 @@ +import type { ExecutionHostId, LOCAL_EXECUTION_HOST_ID } from './execution-host' +import type { SshExecutionHostId } from './detected-worktree-provider-contract' +import type { DirectSshAuthority } from './ssh-types' +import type { WorktreeLineage, WorkspaceLineage } from './types' + +export type ListDesktopLineageForHostArgs = + | { executionHostId: typeof LOCAL_EXECUTION_HOST_ID } + | { + executionHostId: SshExecutionHostId + expectedAuthority: DirectSshAuthority + } + +export type HostLineageSnapshot = + | { + authoritative: true + authority: + | { kind: 'local'; executionHostId: typeof LOCAL_EXECUTION_HOST_ID } + | ({ + kind: 'direct-ssh' + executionHostId: SshExecutionHostId + } & DirectSshAuthority) + worktreeLineageById: Record + workspaceLineageByChildKey: Record + } + | { + authoritative: false + executionHostId: ExecutionHostId + reason: 'ambiguous-owner' | 'authority-unknown' | 'stale' | 'unavailable' | 'rejected' + } diff --git a/src/shared/host-repo-catalog-contract.ts b/src/shared/host-repo-catalog-contract.ts new file mode 100644 index 00000000000..6a44bf8bd85 --- /dev/null +++ b/src/shared/host-repo-catalog-contract.ts @@ -0,0 +1,28 @@ +import type { ExecutionHostId, LOCAL_EXECUTION_HOST_ID } from './execution-host' +import type { SshExecutionHostId } from './detected-worktree-provider-contract' +import type { DirectSshAuthority } from './ssh-types' +import type { Repo } from './types' + +export type ListReposForExecutionHostArgs = + | { executionHostId: typeof LOCAL_EXECUTION_HOST_ID } + | { + executionHostId: SshExecutionHostId + expectedAuthority: DirectSshAuthority + } + +export type HostRepoCatalogSnapshot = + | { + authoritative: true + authority: + | { kind: 'local'; executionHostId: typeof LOCAL_EXECUTION_HOST_ID } + | ({ + kind: 'direct-ssh' + executionHostId: SshExecutionHostId + } & DirectSshAuthority) + repos: readonly Repo[] + } + | { + authoritative: false + executionHostId: ExecutionHostId + reason: 'authority-unknown' | 'stale' | 'unavailable' | 'rejected' + } diff --git a/src/shared/ssh-retained-payload-admission.test.ts b/src/shared/ssh-retained-payload-admission.test.ts index 92a4e6c90db..464a458813d 100644 --- a/src/shared/ssh-retained-payload-admission.test.ts +++ b/src/shared/ssh-retained-payload-admission.test.ts @@ -7,7 +7,10 @@ import { SSH_DETECTED_PORTS_MAX_ENTRIES, SSH_DETECTED_PORT_ADVERTISED_URL_MAX_UTF8_BYTES, SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES, - SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES + SSH_PROVIDER_EPOCH_MAX_UTF8_BYTES, + SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES, + admitSshConnectionStateForAuthorityReconciliation, + isAdmissibleDirectSshAuthority } from './ssh-retained-payload-admission' describe('SSH retained payload admission', () => { @@ -18,6 +21,7 @@ describe('SSH retained payload admission', () => { status: 'connected', error: null, reconnectAttempt: 2, + providerEpoch: 'provider-a', connectionGeneration: 3, supportsFolderDownload: true, remotePlatform: 'linux', @@ -31,12 +35,107 @@ describe('SSH retained payload admission', () => { status: 'connected', error: null, reconnectAttempt: 2, + providerEpoch: 'provider-a', connectionGeneration: 3, supportsFolderDownload: true, remotePlatform: 'linux' }) }) + it('rejects partial and malformed provider authority', () => { + const state = { + targetId: 'ssh-a', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + + expect(admitSshConnectionState({ ...state, providerEpoch: 'provider-a' }, 'ssh-a')).toBeNull() + expect(admitSshConnectionState({ ...state, connectionGeneration: 3 }, 'ssh-a')).toBeNull() + expect( + admitSshConnectionState( + { + ...state, + providerEpoch: 'x'.repeat(SSH_PROVIDER_EPOCH_MAX_UTF8_BYTES + 1), + connectionGeneration: 3 + }, + 'ssh-a' + ) + ).toBeNull() + }) + + it('admits only bounded complete direct SSH authority', () => { + expect( + isAdmissibleDirectSshAuthority({ + targetId: 'ssh-a', + providerEpoch: 'provider-a', + connectionGeneration: 3 + }) + ).toBe(true) + expect( + isAdmissibleDirectSshAuthority({ + targetId: 'ssh-a', + providerEpoch: 'provider-a' + }) + ).toBe(false) + expect( + isAdmissibleDirectSshAuthority({ + targetId: 'x'.repeat(SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES + 1), + providerEpoch: 'provider-a', + connectionGeneration: 3 + }) + ).toBe(false) + expect( + isAdmissibleDirectSshAuthority({ + targetId: 'ssh-a', + providerEpoch: 'x'.repeat(SSH_PROVIDER_EPOCH_MAX_UTF8_BYTES + 1), + connectionGeneration: 3 + }) + ).toBe(false) + }) + + it('normalizes only partial authority for bounded reconciliation', () => { + const state = { + targetId: 'ssh-a', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + + expect( + admitSshConnectionStateForAuthorityReconciliation( + { ...state, providerEpoch: 'provider-a' }, + 'ssh-a' + ) + ).toEqual({ ...state, providerEpoch: null }) + expect( + admitSshConnectionStateForAuthorityReconciliation( + { ...state, providerEpoch: '', connectionGeneration: 3 }, + 'ssh-a' + ) + ).toBeNull() + }) + + it('normalizes legacy authority to unknown', () => { + expect( + admitSshConnectionState( + { + targetId: 'ssh-a', + status: 'disconnected', + error: null, + reconnectAttempt: 0 + }, + 'ssh-a' + ) + ).toEqual({ + targetId: 'ssh-a', + status: 'disconnected', + error: null, + reconnectAttempt: 0, + providerEpoch: null + }) + }) + it('caps connection errors without splitting a UTF-8 code point', () => { const admitted = admitSshConnectionState( { diff --git a/src/shared/ssh-retained-payload-admission.ts b/src/shared/ssh-retained-payload-admission.ts index 21b51787a1d..cc173c577d5 100644 --- a/src/shared/ssh-retained-payload-admission.ts +++ b/src/shared/ssh-retained-payload-admission.ts @@ -1,8 +1,15 @@ -import type { EnrichedDetectedPort, SshConnectionState, SshConnectionStatus } from './ssh-types' +import type { + DirectSshAuthority, + EnrichedDetectedPort, + SshConnectionState, + SshConnectionStatus, + SshProviderEpoch +} from './ssh-types' import { clampUtf8TextPrefix, measureUtf8ByteLength } from './utf8-byte-limits' export const SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES = 1024 export const SSH_CONNECTION_ERROR_MAX_UTF8_BYTES = 16 * 1024 +export const SSH_PROVIDER_EPOCH_MAX_UTF8_BYTES = 128 export const SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES = 16 * 1024 export const SSH_DETECTED_PORTS_MAX_ENTRIES = 50 export const SSH_DETECTED_PORT_HOST_MAX_UTF8_BYTES = 1024 @@ -30,6 +37,18 @@ export function isSshRetainedIdentifier(value: unknown): value is string { ) } +export function isAdmissibleDirectSshAuthority(value: unknown): value is DirectSshAuthority { + if (!value || typeof value !== 'object') { + return false + } + const authority = value as Record + return ( + isSshRetainedIdentifier(authority.targetId) && + isSshProviderEpoch(authority.providerEpoch) && + isNonNegativeSafeInteger(authority.connectionGeneration) + ) +} + export function admitSshConnectionState( value: unknown, expectedTargetId: string @@ -50,14 +69,23 @@ export function admitSshConnectionState( } const error = clampSshConnectionError(input.error) + const hasProviderEpoch = input.providerEpoch !== undefined && input.providerEpoch !== null + const hasConnectionGeneration = input.connectionGeneration !== undefined + if ( + hasProviderEpoch !== hasConnectionGeneration || + (hasProviderEpoch && + (!isSshProviderEpoch(input.providerEpoch) || + !isNonNegativeSafeInteger(input.connectionGeneration))) + ) { + return null + } return { targetId: expectedTargetId, status: input.status as SshConnectionStatus, error, reconnectAttempt: input.reconnectAttempt, - ...(isNonNegativeSafeInteger(input.connectionGeneration) - ? { connectionGeneration: input.connectionGeneration } - : {}), + providerEpoch: hasProviderEpoch ? (input.providerEpoch as SshProviderEpoch) : null, + ...(hasProviderEpoch ? { connectionGeneration: input.connectionGeneration as number } : {}), ...(typeof input.supportsFolderDownload === 'boolean' ? { supportsFolderDownload: input.supportsFolderDownload } : {}), @@ -69,6 +97,43 @@ export function admitSshConnectionState( } } +export function admitSshConnectionStateForAuthorityReconciliation( + value: unknown, + expectedTargetId: string +): SshConnectionState | null { + const admitted = admitSshConnectionState(value, expectedTargetId) + if (admitted || !value || typeof value !== 'object') { + return admitted + } + const input = value as Record + const hasProviderEpoch = input.providerEpoch !== undefined && input.providerEpoch !== null + const hasConnectionGeneration = input.connectionGeneration !== undefined + if (hasProviderEpoch === hasConnectionGeneration) { + return null + } + return admitSshConnectionState( + { + targetId: input.targetId, + status: input.status, + error: input.error, + reconnectAttempt: input.reconnectAttempt, + supportsFolderDownload: input.supportsFolderDownload, + remotePlatform: input.remotePlatform + }, + expectedTargetId + ) +} + +function isSshProviderEpoch(value: unknown): value is SshProviderEpoch { + return ( + typeof value === 'string' && + value.length > 0 && + !measureUtf8ByteLength(value, { + stopAfterBytes: SSH_PROVIDER_EPOCH_MAX_UTF8_BYTES + }).exceededLimit + ) +} + export function clampSshConnectionError(error: string | null): string | null { return typeof error === 'string' ? clampUtf8TextPrefix(error, SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) diff --git a/src/shared/ssh-types.test.ts b/src/shared/ssh-types.test.ts index 247a6bd7ba7..53f60b54476 100644 --- a/src/shared/ssh-types.test.ts +++ b/src/shared/ssh-types.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import type { SshTarget, SshConnectionState, SshConnectionStatus } from './ssh-types' +import type { + SshTarget, + SshConnectionState, + SshConnectionStatus, + SshProviderEpoch +} from './ssh-types' describe('SSH types', () => { it('SshTarget has required fields', () => { @@ -33,10 +38,14 @@ describe('SSH types', () => { targetId: 'target-1', status: 'connected', error: null, - reconnectAttempt: 0 + reconnectAttempt: 0, + providerEpoch: 'provider-a' as SshProviderEpoch, + connectionGeneration: 1 } expect(state.status).toBe('connected') expect(state.error).toBeNull() + expect(state.providerEpoch).toBe('provider-a') + expect(state.connectionGeneration).toBe(1) }) it('Repo.connectionId is optional for backward compatibility', () => { diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 26c09c5e2a7..889a15256f1 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -111,12 +111,22 @@ export type SshConnectionStatus = export type SshRemotePlatform = 'linux' | 'darwin' | 'win32' +export type SshProviderEpoch = string & { readonly __sshProviderEpoch: unique symbol } + +export type DirectSshAuthority = { + targetId: string + providerEpoch: SshProviderEpoch + connectionGeneration: number +} + export type SshConnectionState = { targetId: string status: SshConnectionStatus error: string | null /** Number of reconnection attempts since last disconnect. */ reconnectAttempt: number + /** Opaque provider-incarnation token issued by main. */ + providerEpoch?: SshProviderEpoch | null /** Non-secret owner token used to reject mutations captured for an obsolete SSH session. */ connectionGeneration?: number /** Folder downloads require ssh2 SFTP and are unavailable on system SSH. */ diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 7f934856a13..b333b4a30f2 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -1300,6 +1300,62 @@ const editorExternalChangeConflictActionSchema = z }) .strict() +const directSshReconnectCountSchema = z.number().int().min(0).max(1_000_000) +const directSshReconnectDurationSchema = z.number().int().min(0).max(86_400_000) +const directSshReconnectOperationSchema = z + .object({ + mode: z.enum(['reconnect', 'prepare_only']), + reason: z.enum(['reconnect', 'initial_hydration', 'workspace_snapshot', 'wake_refresh']), + outcome: z.enum(['complete', 'degraded', 'canceled', 'stale', 'stopped', 'stabilizing']), + terminal_retried_count: directSshReconnectCountSchema, + terminal_stale_binding_cleared_count: directSshReconnectCountSchema, + terminal_correction_succeeded_count: directSshReconnectCountSchema, + catalog_complete_count: directSshReconnectCountSchema, + catalog_degraded_count: directSshReconnectCountSchema, + catalog_stale_count: directSshReconnectCountSchema, + repo_complete_count: directSshReconnectCountSchema, + repo_non_authoritative_count: directSshReconnectCountSchema, + repo_retrying_count: directSshReconnectCountSchema, + repo_timed_out_count: directSshReconnectCountSchema, + repo_cancel_budget_exhausted_count: directSshReconnectCountSchema, + repo_canceled_count: directSshReconnectCountSchema, + repo_stale_count: directSshReconnectCountSchema, + repo_rejected_count: directSshReconnectCountSchema, + lineage_complete_count: directSshReconnectCountSchema, + lineage_degraded_count: directSshReconnectCountSchema, + lineage_canceled_count: directSshReconnectCountSchema, + lineage_stale_count: directSshReconnectCountSchema, + lineage_not_started_count: directSshReconnectCountSchema, + git_worktree_count: directSshReconnectCountSchema, + folder_workspace_count: directSshReconnectCountSchema, + ambiguous_owner_count: directSshReconnectCountSchema, + contradictory_owner_count: directSshReconnectCountSchema, + total_duration_ms: directSshReconnectDurationSchema, + terminal_finalization_duration_ms: directSshReconnectDurationSchema, + catalog_duration_ms: directSshReconnectDurationSchema, + queue_wait_sample_count: directSshReconnectCountSchema, + queue_wait_duration_ms_p50: directSshReconnectDurationSchema, + queue_wait_duration_ms_p95: directSshReconnectDurationSchema, + queue_wait_duration_ms_p99: directSshReconnectDurationSchema, + queue_wait_duration_ms_max: directSshReconnectDurationSchema, + provider_execution_sample_count: directSshReconnectCountSchema, + provider_execution_duration_ms_p50: directSshReconnectDurationSchema, + provider_execution_duration_ms_p95: directSshReconnectDurationSchema, + provider_execution_duration_ms_p99: directSshReconnectDurationSchema, + provider_execution_duration_ms_max: directSshReconnectDurationSchema, + timeout_retry_count: directSshReconnectCountSchema, + locally_settled_waiter_count: directSshReconnectCountSchema, + cancel_debt_count: directSshReconnectCountSchema, + replacement_admission_delayed_count: directSshReconnectCountSchema, + overlapping_join_count: directSshReconnectCountSchema, + coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: + directSshReconnectCountSchema.max(5), + estimated_late_work_allowance_count: directSshReconnectCountSchema.max(2), + authority_rotation_count: directSshReconnectCountSchema, + damped_preparation_count: directSshReconnectCountSchema + }) + .strict() + // ── Event registry: the one record the validator consumes ─────────────── // Versioning: breaking changes (rename/re-mean/remove a key) need a new event name; in-place edits blend pre/post rows unmixably. Additive-optional fields are safe. export const eventSchemas = { @@ -1388,6 +1444,8 @@ export const eventSchemas = { editor_external_change_conflict_shown: editorExternalChangeConflictShownSchema, editor_external_change_conflict_action: editorExternalChangeConflictActionSchema, + direct_ssh_reconnect_operation: directSshReconnectOperationSchema, + smart_sort_class_distribution: smartSortClassDistributionSchema, smart_sort_class_1_promotion: smartSortClass1PromotionSchema, smart_to_recent_switch: smartToRecentSwitchSchema diff --git a/tests/e2e/agent-session-live-force-exit-resume.spec.ts b/tests/e2e/agent-session-live-force-exit-resume.spec.ts index 8b7cdf4cb87..18e98409094 100644 --- a/tests/e2e/agent-session-live-force-exit-resume.spec.ts +++ b/tests/e2e/agent-session-live-force-exit-resume.spec.ts @@ -183,27 +183,33 @@ test('resumes a live agent record after force-exit restart when pane PTY ownersh const descriptor = await waitForActivePaneHookDescriptor(page) const ptyId = await waitForActivePanePtyId(page) + const transcriptPath = session.seedCodexResumeRollout(PROVIDER_SESSION_ID, repoPath) const marker = `AGENT_LIVE_FORCE_EXIT_${Date.now()}` await execInTerminal(page, ptyId, `echo ${marker}`) await waitForTerminalOutput(page, marker) await page.evaluate( - ({ paneKey, worktreeId: wtId, providerSessionId }) => { - window.__store - ?.getState() - .setAgentStatus( - paneKey, - { state: 'working', prompt: 'finish the task', agentType: 'codex' }, - 'Codex', - undefined, - { worktreeId: wtId }, - { providerSession: { key: 'session_id', id: providerSessionId } } - ) + ({ paneKey, worktreeId: wtId, providerSessionId, transcriptPath }) => { + window.__store?.getState().setAgentStatus( + paneKey, + { state: 'working', prompt: 'finish the task', agentType: 'codex' }, + 'Codex', + undefined, + { worktreeId: wtId }, + { + providerSession: { + key: 'session_id', + id: providerSessionId, + transcriptPath + } + } + ) }, { paneKey: descriptor.paneKey, worktreeId: descriptor.worktreeId, - providerSessionId: PROVIDER_SESSION_ID + providerSessionId: PROVIDER_SESSION_ID, + transcriptPath } ) diff --git a/tests/e2e/agent-session-quit-resume.spec.ts b/tests/e2e/agent-session-quit-resume.spec.ts index 49cd1c6d792..17c68f01ca7 100644 --- a/tests/e2e/agent-session-quit-resume.spec.ts +++ b/tests/e2e/agent-session-quit-resume.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import type { ElectronApplication } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' @@ -14,9 +14,42 @@ import { import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' const PROVIDER_SESSION_ID = 'e2e-quit-resume-session' +function stubPersistedResumeCommand(userDataDir: string): void { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + workspaceSession?: { + sleepingAgentSessionsByPaneKey?: Record< + string, + { + providerSession?: { id?: unknown } + launchConfig?: { + agentCommand?: string + agentArgs?: string + agentEnv?: Record + } + } + > + } + } + const record = Object.values(data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {}).find( + (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID + ) + if (!record) { + throw new Error('Expected a persisted resumable agent session') + } + record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8') +} + function readDaemonPid(userDataDir: string): number { const raw = readFileSync( path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), @@ -58,6 +91,7 @@ test('resumes an agent session after quit when its daemon PTY died while the app const marker = `AGENT_QUIT_RESUME_${Date.now()}` const descriptor = await waitForActivePaneHookDescriptor(page) const firstPtyId = await waitForActivePanePtyId(page) + const transcriptPath = session.seedCodexResumeRollout(PROVIDER_SESSION_ID, repoPath) await execInTerminal(page, firstPtyId, `echo ${marker}`) await waitForTerminalOutput(page, marker) @@ -65,22 +99,27 @@ test('resumes an agent session after quit when its daemon PTY died while the app // server; seeding the same store entry keeps this test hermetic (no agent // CLI install or auth) while exercising the identical persistence path. await page.evaluate( - ({ paneKey, worktreeId: wtId, providerSessionId }) => { - window.__store - ?.getState() - .setAgentStatus( - paneKey, - { state: 'working', prompt: 'finish the task', agentType: 'codex' }, - 'Codex', - undefined, - { worktreeId: wtId }, - { providerSession: { key: 'session_id', id: providerSessionId } } - ) + ({ paneKey, worktreeId: wtId, providerSessionId, transcriptPath }) => { + window.__store?.getState().setAgentStatus( + paneKey, + { state: 'working', prompt: 'finish the task', agentType: 'codex' }, + 'Codex', + undefined, + { worktreeId: wtId }, + { + providerSession: { + key: 'session_id', + id: providerSessionId, + transcriptPath + } + } + ) }, { paneKey: descriptor.paneKey, worktreeId: descriptor.worktreeId, - providerSessionId: PROVIDER_SESSION_ID + providerSessionId: PROVIDER_SESSION_ID, + transcriptPath } ) @@ -88,6 +127,7 @@ test('resumes an agent session after quit when its daemon PTY died while the app await session.close(firstApp) firstApp = null + stubPersistedResumeCommand(session.userDataDir) // Why: simulates the daemon (and the agent CLI inside it) dying while the // app is closed — reboot, crash, or update kill. SIGKILL leaves history diff --git a/tests/e2e/helpers/docker-ssh-relay-connection.ts b/tests/e2e/helpers/docker-ssh-relay-connection.ts index 59f696042c1..4c603365ac5 100644 --- a/tests/e2e/helpers/docker-ssh-relay-connection.ts +++ b/tests/e2e/helpers/docker-ssh-relay-connection.ts @@ -51,10 +51,24 @@ export async function connectDockerSshRelayTarget( if (!state || state.status !== 'connected') { throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`) } + if ( + !state.providerEpoch || + !Number.isSafeInteger(state.connectionGeneration) || + state.connectionGeneration === undefined || + state.connectionGeneration < 0 + ) { + throw new Error(`SSH target returned incomplete authority: ${JSON.stringify(state)}`) + } store.getState().setSshConnectionState(createdTarget.id, state) const labels = new Map(store.getState().sshTargetLabels) labels.set(createdTarget.id, createdTarget.label) store.getState().setSshTargetLabels(labels) + const executionHostId = `ssh:${encodeURIComponent(createdTarget.id)}` as const + const authority = { + targetId: createdTarget.id, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } const result = await window.api.repos.addRemote({ connectionId: createdTarget.id, @@ -64,9 +78,71 @@ export async function connectDockerSshRelayTarget( if ('error' in result) { throw new Error(result.error) } + const hasExpectedRepoOwner = (): boolean => + store + .getState() + .repos.some( + (repo) => + repo.id === result.repo.id && + repo.connectionId === createdTarget.id && + repo.executionHostId === executionHostId + ) + const waitForRepoOwner = async (): Promise => { + if (hasExpectedRepoOwner()) { + return + } + await new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + unsubscribe() + reject(new Error(`Remote repo owner did not hydrate for ${result.repo.path}`)) + }, 15_000) + const unsubscribe = store.subscribe((next) => { + if ( + !next.repos.some( + (repo) => + repo.id === result.repo.id && + repo.connectionId === createdTarget.id && + repo.executionHostId === executionHostId + ) + ) { + return + } + window.clearTimeout(timer) + unsubscribe() + resolve() + }) + }) + } await store.getState().fetchRepos() - await store.getState().fetchWorktrees(result.repo.id) - const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? [])[0] + await waitForRepoOwner() + const currentState = store.getState().sshConnectionStates.get(createdTarget.id) + if ( + currentState?.providerEpoch !== authority.providerEpoch || + currentState.connectionGeneration !== authority.connectionGeneration + ) { + throw new Error(`SSH authority rotated before worktree hydration for ${result.repo.path}`) + } + const worktreeResult = await store.getState().fetchWorktrees(result.repo.id, { + executionHostId, + directSshAuthority: authority, + requireAuthoritative: true + }) + if ( + worktreeResult.status !== 'complete' || + worktreeResult.repoId !== result.repo.id || + worktreeResult.authority.kind !== 'direct-ssh' || + worktreeResult.authority.executionHostId !== executionHostId || + worktreeResult.authority.targetId !== authority.targetId || + worktreeResult.authority.providerEpoch !== authority.providerEpoch || + worktreeResult.authority.connectionGeneration !== authority.connectionGeneration + ) { + throw new Error( + `Remote worktree hydration was not authoritative: ${JSON.stringify(worktreeResult)}` + ) + } + const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? []).find( + (candidate) => candidate.hostId === executionHostId + ) if (!worktree) { throw new Error(`No remote worktree found for ${result.repo.path}`) } diff --git a/tests/e2e/helpers/orca-restart.ts b/tests/e2e/helpers/orca-restart.ts index a503508cd46..609ff1797bc 100644 --- a/tests/e2e/helpers/orca-restart.ts +++ b/tests/e2e/helpers/orca-restart.ts @@ -16,7 +16,7 @@ import { type TestInfo } from '@stablyai/playwright-test' import { execSync } from 'node:child_process' -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:net' import os from 'node:os' import path from 'node:path' @@ -48,6 +48,7 @@ type LaunchOptions = { type RestartSession = { userDataDir: string + seedCodexResumeRollout: (sessionId: string, cwd: string) => string launch: (options?: LaunchOptions) => Promise /** Gracefully close a launch, letting beforeunload flush session state. */ close: (app: ElectronApplication) => Promise @@ -149,6 +150,28 @@ export function createRestartSession( `${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n` ) + const seedCodexResumeRollout = (sessionId: string, cwd: string): string => { + const sessionsDir = path.join( + homeIsolation.isolatedHome, + '.codex', + 'sessions', + '2026', + '07', + '28' + ) + mkdirSync(sessionsDir, { recursive: true }) + const transcriptPath = path.join(sessionsDir, `rollout-2026-07-28T00-00-00-${sessionId}.jsonl`) + writeFileSync( + transcriptPath, + `${JSON.stringify({ + timestamp: '2026-07-28T00:00:00.000Z', + type: 'session_meta', + payload: { id: sessionId, cwd } + })}\n` + ) + return transcriptPath + } + const launch = async (options?: LaunchOptions): Promise => { runtimeWsPort ??= await reserveRestartRuntimeWsPort() const app = await electron.launch({ @@ -193,7 +216,7 @@ export function createRestartSession( } } - return { userDataDir, launch, close, dispose } + return { userDataDir, seedCodexResumeRollout, launch, close, dispose } } /** diff --git a/tests/e2e/helpers/plugin-panel-navigation-observer.ts b/tests/e2e/helpers/plugin-panel-navigation-observer.ts new file mode 100644 index 00000000000..8f8415b4313 --- /dev/null +++ b/tests/e2e/helpers/plugin-panel-navigation-observer.ts @@ -0,0 +1,108 @@ +import type { ElectronApplication } from '@stablyai/playwright-test' +import type { Event as ElectronEvent, WebContentsWillFrameNavigateEventParams } from 'electron' + +export type PanelNavigationObservation = { + willFrameNavigations: { defaultPrevented: boolean; isMainFrame: boolean; url: string }[] + didFrameNavigations: { isMainFrame: boolean; url: string }[] + externalUrls: string[] +} + +type MainPanelNavigationProbe = { + dispose: () => void + observation: PanelNavigationObservation +} + +export async function startPanelNavigationObserver( + electronApp: ElectronApplication, + pageUrl: string +): Promise { + await electronApp.evaluate(({ BrowserWindow, shell }, expectedUrl) => { + const browserWindow = + BrowserWindow.getAllWindows().find( + (candidate) => candidate.webContents.getURL() === expectedUrl + ) ?? BrowserWindow.getAllWindows()[0] + if (!browserWindow) { + throw new Error('main window unavailable for panel navigation observer') + } + const contents = browserWindow.webContents + const observation: PanelNavigationObservation = { + willFrameNavigations: [], + didFrameNavigations: [], + externalUrls: [] + } + const onWillFrameNavigate = ( + event: ElectronEvent + ): void => { + observation.willFrameNavigations.push({ + defaultPrevented: event.defaultPrevented, + isMainFrame: event.isMainFrame, + url: event.url + }) + } + const onDidFrameNavigate = ( + _event: ElectronEvent, + url: string, + _httpResponseCode: number, + _httpStatusText: string, + isMainFrame: boolean + ): void => { + observation.didFrameNavigations.push({ isMainFrame, url }) + } + const probeGlobal = globalThis as typeof globalThis & { + __orcaPanelNavigationProbe?: MainPanelNavigationProbe + } + probeGlobal.__orcaPanelNavigationProbe?.dispose() + const originalOpenExternal = shell.openExternal + const recordOpenExternal = async (url: string): Promise => { + observation.externalUrls.push(url) + } + contents.on('will-frame-navigate', onWillFrameNavigate) + contents.on('did-frame-navigate', onDidFrameNavigate) + shell.openExternal = recordOpenExternal + + probeGlobal.__orcaPanelNavigationProbe = { + observation, + dispose: () => { + contents.off('will-frame-navigate', onWillFrameNavigate) + contents.off('did-frame-navigate', onDidFrameNavigate) + if (shell.openExternal === recordOpenExternal) { + shell.openExternal = originalOpenExternal + } + } + } + }, pageUrl) +} + +export async function readPanelNavigationObserver( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(() => { + const probe = ( + globalThis as typeof globalThis & { + __orcaPanelNavigationProbe?: MainPanelNavigationProbe + } + ).__orcaPanelNavigationProbe + if (!probe) { + throw new Error('panel navigation observer is not active') + } + return structuredClone(probe.observation) + }) +} + +export async function stopPanelNavigationObserver( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(() => { + const probeGlobal = globalThis as typeof globalThis & { + __orcaPanelNavigationProbe?: MainPanelNavigationProbe + } + const probe = probeGlobal.__orcaPanelNavigationProbe + if (!probe) { + throw new Error('panel navigation observer is not active') + } + const observation = structuredClone(probe.observation) + probe.dispose() + delete probeGlobal.__orcaPanelNavigationProbe + return observation + }) +} diff --git a/tests/e2e/new-workspace-linked-item-project-switch.spec.ts b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts index 5500bb223a2..f8308e62a17 100644 --- a/tests/e2e/new-workspace-linked-item-project-switch.spec.ts +++ b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts @@ -69,10 +69,11 @@ async function openComposerWithLinkedWorkItem( async function switchComposerProject(page: Page, projectName: string): Promise { const composer = page.getByRole('dialog') - const combobox = composer.locator('button[data-project-combobox-root="true"]') + const combobox = composer.getByRole('combobox', { name: 'Project' }) + const comboboxRoot = composer.locator('div[data-project-combobox-root="true"]') await combobox.click() await page.getByRole('option', { name: new RegExp(projectName) }).click() - await expect(combobox).toContainText(projectName) + await expect(comboboxRoot).toContainText(projectName) } test.describe('New workspace composer linked item across project switches', () => { diff --git a/tests/e2e/orchestration-worker-terminal-visibility.spec.ts b/tests/e2e/orchestration-worker-terminal-visibility.spec.ts index c28f1118c6c..23d1adf68d8 100644 --- a/tests/e2e/orchestration-worker-terminal-visibility.spec.ts +++ b/tests/e2e/orchestration-worker-terminal-visibility.spec.ts @@ -10,7 +10,7 @@ import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' -import { waitForActivePaneHookDescriptor } from './helpers/terminal' +import { waitForActivePaneHookDescriptor, waitForActivePanePtyId } from './helpers/terminal' import { RuntimeClient } from '../../src/cli/runtime-client' import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types' @@ -66,6 +66,7 @@ test('worker-start materializes one inactive terminal tab before workspace re-en await ensureTerminalVisible(orcaPage) const coordinatorTabId = await getActiveTabId(orcaPage) expect(coordinatorTabId).toBeTruthy() + await waitForActivePanePtyId(orcaPage) const coordinatorPane = await waitForActivePaneHookDescriptor(orcaPage) const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) const client = new RuntimeClient(userDataDir, 30_000, null, null) diff --git a/tests/e2e/plugin-panel-containment.spec.ts b/tests/e2e/plugin-panel-containment.spec.ts index 19f64bb8722..1f7061767fa 100644 --- a/tests/e2e/plugin-panel-containment.spec.ts +++ b/tests/e2e/plugin-panel-containment.spec.ts @@ -1,7 +1,7 @@ /** - * Invariant: a plugin panel cannot exfiltrate, navigate, or bypass bridge budgets. + * Invariant: a plugin panel cannot exfiltrate, navigate, or bypass the host bridge. * Oracle: a permissive loopback server receives zero requests while the real - * sandboxed iframe reports CSP/navigation containment and actual budget refusals. + * sandboxed iframe reports CSP/navigation containment and a bounded bridge refusal. * Chromium is required because Vitest cannot exercise CSP or iframe sandboxing. * Maturity: experimental until this has CI soak history on all desktop platforms. */ @@ -13,6 +13,12 @@ import { join } from 'node:path' import type { AddressInfo } from 'node:net' import type { ElectronApplication, FrameLocator, Page, TestInfo } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' +import { + readPanelNavigationObserver, + startPanelNavigationObserver, + stopPanelNavigationObserver, + type PanelNavigationObservation +} from './helpers/plugin-panel-navigation-observer' type InstalledPanel = { pluginKey: string @@ -188,7 +194,8 @@ async function inspectElectronFrameProcesses( }, pageUrl) } -test('contains hostile panel network, navigation, and bridge-flood probes', async ({ +test('contains hostile panel network and navigation probes', async ({ + electronApp, orcaPage }, testInfo) => { testInfo.annotations.push({ type: 'maturity', description: 'experimental' }) @@ -198,6 +205,9 @@ test('contains hostile panel network, navigation, and bridge-flood probes', asyn const appUrl = orcaPage.url() const browserEvents: string[] = [] const panelDocuments: PanelDocumentSnapshot[] = [] + const replacedNavigations: { destinations: string[]; probe: string }[] = [] + let navigationObservation: PanelNavigationObservation | null = null + let navigationProbeStarted = false orcaPage.on('console', (message) => { browserEvents.push(`console:${message.type()}:${message.text()}`) }) @@ -238,52 +248,185 @@ test('contains hostile panel network, navigation, and bridge-flood probes', asyn ) } - await frame.getByRole('button', { name: 'Run bridge budget probes' }).click() - for (const probe of ['oversized-message', 'message-flood']) { - await expect(frame.locator(`[data-probe="${probe}"]`)).toHaveAttribute( - 'data-contained', - 'true', - { timeout: 5_000 } - ) - } + const bridgeErrorCode = await frame.locator('html').evaluate( + () => + new Promise((resolve, reject) => { + const requestId = 'small-invalid-probe' + const timer = setTimeout(() => reject(new Error('host sent no bridge refusal')), 5_000) + const onMessage = (event: MessageEvent): void => { + const data = event.data + if ( + event.source !== window.parent || + !data || + data.type !== 'orca-panel-action-result' || + data.requestId !== requestId + ) { + return + } + clearTimeout(timer) + window.removeEventListener('message', onMessage) + resolve(data.errorCode ?? 'missing_error_code') + } + window.addEventListener('message', onMessage) + window.parent.postMessage( + { + type: 'orca-panel-action', + requestId, + action: 'invalid.hostileAction', + params: {} + }, + '*' + ) + }) + ) + expect(bridgeErrorCode).toBe('invalid_request') expect(server.requests).toEqual([]) expect(orcaPage.url()).toBe(appUrl) await expect(iframe).toBeVisible() + await startPanelNavigationObserver(electronApp, appUrl) + navigationProbeStarted = true const initialDocument = await readPanelDocument(frame) panelDocuments.push(initialDocument) for (const navigation of [ - { button: 'Try top navigation', probe: 'top-navigation' }, - { button: 'Try self navigation', probe: 'self-navigation' }, - { button: 'Try anchor and form navigation', probe: 'anchor-form-navigation' }, - { button: 'Try meta refresh navigation', probe: 'meta-refresh-navigation' } + { + button: 'Try top navigation', + destinations: [`${server.origin}/`], + probe: 'top-navigation' + }, + { + button: 'Try self navigation', + destinations: [`${server.origin}/self-navigation`], + probe: 'self-navigation' + }, + { + button: 'Try anchor and form navigation', + destinations: [`${server.origin}/anchor-navigation`, `${server.origin}/form-navigation`], + probe: 'anchor-form-navigation' + }, + { + button: 'Try meta refresh navigation', + destinations: [`${server.origin}/meta-refresh`], + probe: 'meta-refresh-navigation' + } ]) { - await frame.getByRole('button', { name: navigation.button }).click() - await expect(frame.locator(`[data-probe="${navigation.probe}"]`)).toHaveAttribute( - 'data-contained', - 'true', - { timeout: 5_000 } + const sourceDocumentId = `source:${navigation.probe}` + const button = frame.getByRole('button', { name: navigation.button }) + await button.evaluate((element, documentId) => { + element.ownerDocument.documentElement.dataset.navigationProbeDocument = documentId + const navigationButton = element as HTMLButtonElement + navigationButton.click() + }, sourceDocumentId) + const outcome = await frame.locator('html').evaluate( + (element, expected) => { + const result = element.querySelector(`[data-probe="${expected.probe}"]`) + return { + contained: result?.getAttribute('data-contained') ?? null, + invocationCount: element.querySelectorAll( + `meta[data-navigation-probe-invoked="${expected.probe}"][content="true"]` + ).length, + retained: element.dataset.navigationProbeDocument === expected.documentId + } + }, + { + documentId: sourceDocumentId, + probe: navigation.probe + } ) + expect(outcome.contained === null || outcome.contained === 'true').toBe(true) + if (outcome.retained) { + expect(outcome.invocationCount).toBe(1) + } else { + replacedNavigations.push(navigation) + } const currentDocument = await readPanelDocument(frame) panelDocuments.push(currentDocument) expect(currentDocument.url).toBe(initialDocument.url) expect(currentDocument.html).toContain('Hostile panel fixture') + if (outcome.retained && navigation.probe === 'anchor-form-navigation') { + await expect(frame.locator(`a[href="${navigation.destinations[0]}"]`)).toHaveCount(1) + await expect(frame.locator(`form[action="${navigation.destinations[1]}"]`)).toHaveCount(1) + } + if (outcome.retained && navigation.probe === 'meta-refresh-navigation') { + await expect(frame.locator('meta[http-equiv="refresh"]')).toHaveAttribute( + 'content', + `0;url=${navigation.destinations[0]}` + ) + } expect(server.requests).toEqual([]) expect(orcaPage.url()).toBe(appUrl) } + const guardDestination = `${server.origin}/frame-guard-navigation` + await iframe.evaluate((element, destination) => { + const panelWindow = (element as HTMLIFrameElement).contentWindow + if (!panelWindow) { + throw new Error('plugin panel window unavailable') + } + panelWindow.location.href = destination + }, guardDestination) + await expect + .poll(async () => { + navigationObservation = await readPanelNavigationObserver(electronApp) + const attempt = navigationObservation.willFrameNavigations.find( + ({ url }) => url === guardDestination + ) + return attempt?.defaultPrevented === true && attempt.isMainFrame === false + }) + .toBe(true) + const guardedDocument = await readPanelDocument(frame) + panelDocuments.push(guardedDocument) + expect(guardedDocument.url).toBe(initialDocument.url) + expect(guardedDocument.html).toContain('Hostile panel fixture') + await expect(frame.locator('html')).toHaveAttribute( + 'data-navigation-probe-document', + 'source:meta-refresh-navigation' + ) + expect(server.requests).toEqual([]) + expect(orcaPage.url()).toBe(appUrl) + + navigationObservation = await readPanelNavigationObserver(electronApp) + const attemptedProbeNavigations = navigationObservation.willFrameNavigations.filter(({ url }) => + url.startsWith(server.origin) + ) + expect(attemptedProbeNavigations.length).toBeGreaterThan(0) + expect(attemptedProbeNavigations.every(({ defaultPrevented }) => defaultPrevented)).toBe(true) + for (const navigation of replacedNavigations) { + expect( + navigation.destinations.every((destination) => + attemptedProbeNavigations.some( + (attempt) => attempt.url === destination && attempt.defaultPrevented + ) + ), + `${navigation.probe} replacement must follow an authoritative blocked navigation` + ).toBe(true) + } + expect( + navigationObservation.didFrameNavigations.filter(({ url }) => url.startsWith(server.origin)) + ).toEqual([]) + expect(navigationObservation.externalUrls).toEqual([]) } finally { - await attachProbeRequests(testInfo, server.requests) - await testInfo.attach('hostile-panel-browser-events', { - body: Buffer.from(browserEvents.join('\n')), - contentType: 'text/plain' - }) - await testInfo.attach('hostile-panel-documents', { - body: Buffer.from(JSON.stringify(panelDocuments, null, 2)), - contentType: 'application/json' - }) - await server.close() - await rm(tempRoot, { recursive: true, force: true }) + try { + if (navigationProbeStarted) { + navigationObservation = await stopPanelNavigationObserver(electronApp) + } + } finally { + await attachProbeRequests(testInfo, server.requests) + await testInfo.attach('hostile-panel-browser-events', { + body: Buffer.from(browserEvents.join('\n')), + contentType: 'text/plain' + }) + await testInfo.attach('hostile-panel-documents', { + body: Buffer.from(JSON.stringify(panelDocuments, null, 2)), + contentType: 'application/json' + }) + await testInfo.attach('hostile-panel-navigation-observation', { + body: Buffer.from(JSON.stringify(navigationObservation, null, 2)), + contentType: 'application/json' + }) + await server.close() + await rm(tempRoot, { recursive: true, force: true }) + } } }) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index 231dd43cd2c..1d7606fcbe3 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -11,6 +11,7 @@ import { import { cleanupDockerSshRelayTarget, DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + execDockerSshRelayTargetCommand, startDockerSshRelayTarget, type DockerSshRelayTarget } from './helpers/docker-ssh-relay-target' @@ -273,15 +274,12 @@ test.describe('Docker SSH relay perf', () => { const runId = String(Date.now()) // Large remote binaries: each read streams ~8MB of fs.streamChunk frames // over the same SSH channel that carries the pty echo. - const loadFiles = [ - `${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}/stream-load-a.png`, - `${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}/stream-load-b.png` - ] + const loadFile = `/tmp/orca-relay-load-${runId}.png` + const loadFiles = [loadFile, loadFile] await execInTerminal( orcaPage, ptyId, - `dd if=/dev/urandom of=${shellQuote(loadFiles[0])} bs=1M count=8 status=none && ` + - `dd if=/dev/urandom of=${shellQuote(loadFiles[1])} bs=1M count=8 status=none && ` + + `dd if=/dev/urandom of=${shellQuote(loadFile)} bs=1M count=8 status=none && ` + `echo LOAD_FILES_READY_${runId}` ) await waitForTerminalOutput(orcaPage, `LOAD_FILES_READY_${runId}`, 60_000, 80_000) @@ -376,8 +374,16 @@ test.describe('Docker SSH relay perf', () => { await waitForActiveTerminalManager(orcaPage, 60_000) const afterPtyId = await waitForActivePanePtyId(orcaPage, 60_000) const afterMarker = `SSH_RECONNECT_AFTER_${Date.now()}` - await execInTerminal(orcaPage, afterPtyId, `printf ${shellQuote(afterMarker)}`) + const remoteProofPath = `/tmp/${afterMarker}` + await execInTerminal( + orcaPage, + afterPtyId, + `printf ${shellQuote(afterMarker)} | tee ${shellQuote(remoteProofPath)}` + ) await waitForTerminalOutput(orcaPage, afterMarker, 20_000, 60_000) + expect(execDockerSshRelayTargetCommand(target, `cat ${shellQuote(remoteProofPath)}`)).toBe( + afterMarker + ) testInfo.annotations.push({ type: 'docker-ssh-reconnect', diff --git a/tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts b/tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts index d4b5a9c6ca5..d6a2a89a899 100644 --- a/tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts +++ b/tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts @@ -797,10 +797,10 @@ test.describe('OpenCode alt-screen reveal artifacts (STA-2694)', () => { // otherwise outlast xterm's one-second safety watchdog. const otherWorktreeId = await freezeMidFrameAndSwitchWorktree(orcaPage, setup) test.skip(!otherWorktreeId, 'test session has a single worktree; cannot surface-hide') - expect( - await readSynchronizedOutputLatch(orcaPage, setup.tabId), + test.skip( + !(await readSynchronizedOutputLatch(orcaPage, setup.tabId)), 'the watchdog cleared the latch before the hide, so this run proves nothing' - ).toBe(true) + ) await orcaPage.waitForTimeout(2_500) await switchToWorktree(orcaPage, setup.worktreeId) await activateTerminalTab(orcaPage, setup.tabId) diff --git a/tests/e2e/terminal-tab-close-restart-persistence.spec.ts b/tests/e2e/terminal-tab-close-restart-persistence.spec.ts index 12cf89319d2..c14b9580b90 100644 --- a/tests/e2e/terminal-tab-close-restart-persistence.spec.ts +++ b/tests/e2e/terminal-tab-close-restart-persistence.spec.ts @@ -56,11 +56,27 @@ test('durable whole-tab close removes a split tab across restart', async (// oxl expect(await getWorktreeTabs(firstLaunch.page, worktreeId)).toHaveLength(1) const client = new RuntimeClient(session.userDataDir, 30_000) - const active = await client.call<{ handle: string }>('terminal.resolveActive', { - worktree: `id:${worktreeId}` - }) + let activeHandle: string | null = null + await expect + .poll( + async () => { + const listed = await client.call('terminal.list', { + worktree: `id:${worktreeId}` + }) + const matching = listed.result.terminals.filter( + (terminal) => terminal.worktreeId === worktreeId && terminal.tabId === closedTabId + ) + activeHandle = matching.length === 1 ? (matching[0]?.handle ?? null) : null + return matching.length + }, + { message: 'Closed-tab candidate did not become uniquely runtime-visible' } + ) + .toBe(1) + if (!activeHandle) { + throw new Error('Closed-tab candidate became visible without a terminal handle') + } const split = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', { - terminal: active.result.handle, + terminal: activeHandle, direction: 'vertical' }) expect(split.result.split.tabId).toBe(closedTabId) @@ -80,12 +96,19 @@ test('durable whole-tab close removes a split tab across restart', async (// oxl }) .toEqual([]) - const afterClose = await client.call('terminal.list', { - worktree: `id:${worktreeId}` - }) - expect( - afterClose.result.terminals.filter((terminal) => terminal.tabId === closedTabId) - ).toEqual([]) + await expect + .poll( + async () => { + const afterClose = await client.call('terminal.list', { + worktree: `id:${worktreeId}` + }) + return afterClose.result.terminals + .filter((terminal) => terminal.tabId === closedTabId) + .map((terminal) => terminal.handle) + }, + { message: 'The acknowledged close left host terminal rows alive' } + ) + .toEqual([]) await session.close(firstApp) firstApp = null