fix(terminal): prevent reconnect from killing live daemon sessions (#9804)
This commit is contained in:
parent
96df5f5715
commit
f9f3cd2fbe
|
|
@ -903,6 +903,125 @@
|
|||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if exact PTY disappearance flakes, a sibling/detached pane is retired, or late hooks can recreate closed authority."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.daemon-generation-reconnect-safety",
|
||||
"title": "Reconnect lifecycle echoes cannot kill live daemon-generation terminals",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "renderer-runtime-rpc-windows-daemon-contract",
|
||||
"surfaces": [
|
||||
"runtime session reconnect",
|
||||
"legacy daemon adoption",
|
||||
"terminal lifecycle close",
|
||||
"app relaunch and profile reconnect"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["daemon", "runtime", "ssh", "wsl"],
|
||||
"coveredPlatforms": ["windows"],
|
||||
"coveredProviders": ["daemon", "runtime"],
|
||||
"coverageNotes": "A native Windows v21/v22/v23/v24/v25 named-pipe harness calls the production desktop legacy-adapter scanner and covers warm reattach, current-to-next upgrade, parallel clients, repeated reconnects, client relaunch, exact process identity, bounded failed disposal, and PTY-root/descendant survival. Deterministic host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, missing intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. Docker is unavailable and WSL is not installed on this runner, so live SSH/WSL remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/9749",
|
||||
"https://github.com/stablyai/orca/issues/8871",
|
||||
"https://github.com/stablyai/orca/issues/9138",
|
||||
"https://github.com/stablyai/orca/issues/9229"
|
||||
],
|
||||
"invariant": "Reconnect, replay, or lifecycle observations must never kill a live PTY. Destructive close requires explicit user intent; lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Missing or incompatible evidence keeps and audits. Legacy daemon hello and warm reattachment remain non-destructive.",
|
||||
"oracle": "Start five isolated native-Windows daemon generations on distinct versioned named pipes, let the production desktop scanner discover v21-v24 from a v25 client, attach live and stale-mirror canaries with exact root/descendant PID-start identities, reconnect and relaunch the production router path, issue repeated desktop and remote-profile lifecycle closes, and require every daemon, root, and descendant to remain alive with zero session-killed events. Unit contracts require unknown/stale/reused/cross-profile claims and live PTYs to refuse without kill or renderer-close calls, old servers to return method_not_found with no destructive fallback, explicit user close to remain destructive, and dead whole-headless state to retire without signalling its retained PTY id.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/rpc/methods/session-tabs.test.ts",
|
||||
"src/main/runtime/rpc/methods/session-tabs-schemas.test.ts",
|
||||
"src/renderer/src/runtime/web-runtime-session.test.ts",
|
||||
"src/renderer/src/runtime/web-session-close-intent.test.ts",
|
||||
"src/renderer/src/runtime/web-session-tabs-sync.test.ts",
|
||||
"src/renderer/src/components/terminal/terminal-tab-actions.test.ts",
|
||||
"src/renderer/src/components/terminal/terminal-close-incarnation.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"tests/e2e/daemon-generation-reconnect-safety.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/daemon-generation-reconnect-safety.spec.ts",
|
||||
"assertions": [
|
||||
"the production scanner discovers v21/v22/v23/v24 from v25 and every generation accepts repeated client hellos while every exact daemon, PTY-root, and descendant incarnation remains alive",
|
||||
"desktop and two remote profiles repeat lifecycle closes before and after client relaunch with zero session-killed events",
|
||||
"shutdown-dispose-failed drops named-pipe authority within the deadline and exact fixture cleanup leaves no process tree"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime.test.ts",
|
||||
"assertions": [
|
||||
"live, unknown, stale, missing-intent, and non-owner lifecycle closes invoke neither PTY kill nor renderer close",
|
||||
"dead whole-headless retirement removes stale state without signalling a retained PTY id",
|
||||
"explicit user close remains destructive"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/web-runtime-session.test.ts",
|
||||
"assertions": [
|
||||
"lifecycle close uses the additive method with publication and terminal evidence",
|
||||
"old-server method_not_found never falls back to destructive legacy close"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/web-session-close-intent.test.ts",
|
||||
"assertions": [
|
||||
"identical worktree and tab ids in another runtime cannot suppress, reconcile, or clear this profile's intent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"assertions": [
|
||||
"parked lifecycle closes carry the exact exiting PTY and cannot borrow a replacement or sibling incarnation"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-21",
|
||||
"runner": "local",
|
||||
"platform": "windows",
|
||||
"command": "pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 137.1,
|
||||
"summary": "The full command, including a fresh Electron E2E build, passed. Production desktop discovery found v21/v22/v23/v24 from v25; all five daemons and all ten exact PTY-root/descendant canaries survived six repeated lifecycle attempts per stale mirror with zero session-killed events. Bounded shutdown-dispose-failed dropped pipe authority while its refusing daemon/root/descendant remained alive until exact fixture cleanup; no fixture directory remained."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 180,
|
||||
"scope": "isolated native-Windows five-generation production discovery/reconnect/relaunch plus bounded disposal failure and fresh E2E build"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Local deterministic Windows evidence includes the final two-scenario pass and a separate 25-burst stress pass; CI and 14-day soak history are absent."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The pre-fix native run kept daemon/client processes alive but emitted repeated real session-killed events in v21/v22/v23 and terminated every stale-mirror root and descendant. The fixed production-scanner run preserves every exact v21/v22/v23/v24/v25 process incarnation with zero kill events."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Production adds no polling, subprocess, PowerShell/CIM, or process-per-session work. Concurrent lifecycle closes share one bounded host controller inventory, scan only addressed parent leaves, and reuse the environment/worktree client snapshot deduper. Test-only Windows inventory is capped at 5 seconds and 8 MiB per query; all waits and cleanup are bounded."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 clean native-Windows runs over 14 days with zero unexplained flakes.",
|
||||
"Add packaged Electron update/relaunch evidence with the same exact PTY survival oracle.",
|
||||
"Add live Linux SSH-relay and Windows WSL reconnect artifacts without weakening keep-on-unknown."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The strongest proof is Electron-as-Node over native Windows, not a packaged UI-driven update journey.",
|
||||
"Live macOS/Linux adoption and Linux SSH relay are not exercised because Docker is unavailable; Windows WSL reconnect is not exercised because WSL is not installed.",
|
||||
"Cross-profile daemon inventory and generation handoff/retirement remain the separate #9138/#9229 design.",
|
||||
"A dead split leaf stays with its authoritative owner rather than being remotely pruned."
|
||||
],
|
||||
"demotionRule": "Keep experimental or quarantine if reconnect emits session-killed for a live canary, an exact root/descendant dies, an old server receives fallback destructive close, cleanup leaks a fixture process/pipe, or the gate flakes without a proven harness defect."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.startup-cwd-missing-dir-recovery",
|
||||
"title": "Fresh local terminal creation cannot be bricked by a deleted startup cwd",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,619 @@
|
|||
# Windows daemon-generation safety investigation (#9749)
|
||||
|
||||
Investigation and completion snapshot: 2026-07-21 16:59 PDT
|
||||
(2026-07-21 23:59 UTC)
|
||||
|
||||
Branch baseline: `OrcaWin/issue-9749-windows-daemon-generation-safety` at
|
||||
`937a2015e`, 40 commits after `v1.4.148-rc.1`. This document is the hard gate
|
||||
before a reproduction harness or production change. GitHub access during the
|
||||
investigation was read-only, and no installed Orca daemon, pipe, token, process,
|
||||
or terminal session was contacted or changed.
|
||||
|
||||
## Findings
|
||||
|
||||
The reported incident is a composition of three lifecycle paths, not one:
|
||||
|
||||
1. **Survival and adoption:** normal app quit deliberately disconnects from
|
||||
daemon clients without shutting down their PTYs. Protocol-specific named
|
||||
pipes let multiple generations coexist, and legacy adapters intentionally
|
||||
reconnect to them so live PTYs remain warm and reattachable across upgrades.
|
||||
2. **Broad current-generation replacement:**
|
||||
`cleanupDaemonForProtocol` sends the only production
|
||||
`shutdown { killSessions: true }` request found in the repository. Its
|
||||
production callers replace or explicitly restart the current generation;
|
||||
legacy discovery does not call it. A failed `listSessions` is currently
|
||||
converted to an empty list before this broad shutdown, which makes its
|
||||
reported kill count untrustworthy but does not suppress the shutdown.
|
||||
3. **Reconnect-triggered per-session destruction:** #8871 provides the strongest
|
||||
causal evidence. A reconnecting renderer restores stale remote handles,
|
||||
synthesizes `pty-exit`, drops that reason at `session.tabs.close`, and the
|
||||
authoritative host interprets the close as user intent. Its PTY router then
|
||||
forwards individual `kill` requests to whichever current or legacy adapter
|
||||
claimed each reusable session ID during discovery.
|
||||
|
||||
`client-hello` is necessary to discover and route legacy sessions, but neither
|
||||
the hello handler nor legacy discovery sends a kill or shutdown request. The
|
||||
~3.3-second renderer-bootstrap-to-kill evidence in #8871, repeated renderer
|
||||
spawn/burst correlations, and the untyped close path are substantially stronger
|
||||
than #9749's inference that greeting an old pipe causes that daemon to kill its
|
||||
table autonomously.
|
||||
|
||||
The immediate safety boundary is therefore destructive request authority and
|
||||
provenance. Cross-profile, sleeping-session, and generation-retirement policy
|
||||
remains the larger #9138/#9229 problem.
|
||||
|
||||
## Deterministic native-Windows reproduction
|
||||
|
||||
Reproduction snapshot: 2026-07-21 14:36 PDT (2026-07-21 21:36 UTC).
|
||||
|
||||
`tests/e2e/daemon-generation-reconnect-safety.spec.ts` now constructs an
|
||||
isolated `orca-9749-dg-*` runtime under the Windows temporary directory. It
|
||||
starts three real daemon-server processes on versioned v21/v22/v23 named
|
||||
pipes, with a live canary and a stale-mirror canary in each generation. Each
|
||||
canary records the PTY-root and descendant PID/start identity independently.
|
||||
The fixture refuses cleanup outside its exact temporary root and terminates
|
||||
only recorded fixture process incarnations; it never enumerates or connects to
|
||||
installed Orca endpoints.
|
||||
|
||||
The Electron-as-Node reconnect client performs three production
|
||||
`DaemonPtyAdapter`/`DaemonPtyRouter.discoverLegacySessions` bursts, reattaches
|
||||
every original PID, pings every canary, and opens a simultaneous second client
|
||||
to each generation. It then sends duplicate `session.tabs.close` RPCs carrying
|
||||
`reason: 'pty-exit'` through the real schema, `OrcaRuntimeService`, and routed
|
||||
daemon adapters while the reconnect-client process remains alive.
|
||||
|
||||
The current-main run failed at the intended external invariant:
|
||||
|
||||
- before the close, all three daemons, six PTY roots, and six descendants were
|
||||
alive;
|
||||
- each daemon accepted eight new control/stream hellos across the reconnect and
|
||||
parallel-client cycles;
|
||||
- v21, v22, and v23 each logged two `session-killed` requests for the same
|
||||
stale-mirror session ID within 1–4 ms;
|
||||
- all three stale-mirror PTY roots and descendants exited, while all three
|
||||
unrelated live canaries, all daemon processes, and reconnect client PID 26876
|
||||
remained alive;
|
||||
- the final assertion requires those stale-mirror roots to remain alive, so it
|
||||
is RED before the fix and will become GREEN only when lifecycle-originated
|
||||
closes are adjudicated non-destructively at the host.
|
||||
|
||||
Focused command:
|
||||
|
||||
```text
|
||||
pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1
|
||||
```
|
||||
|
||||
The captured per-generation timestamp/PID/hello/kill/liveness report is written
|
||||
to the Playwright test output as `daemon-generation-reconnect-events.json`.
|
||||
|
||||
## Release and protocol chronology
|
||||
|
||||
| Release or branch | Date | Daemon protocol | Relevant lifecycle behavior |
|
||||
| --------------------- | ---------- | --------------: | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `v1.4.141` | 2026-07-14 | 21 | Legacy adoption already intentional; #8871 observed here. |
|
||||
| `v1.4.142` | 2026-07-15 | 22 | New versioned endpoint; v21 can remain reattachable. |
|
||||
| `v1.4.143` | 2026-07-16 | 22 | #9138 macOS accumulation reported. |
|
||||
| `v1.4.144` | 2026-07-17 | 22 | #9195 Windows survival reported. |
|
||||
| `v1.4.145` | 2026-07-18 | 22 | No generation-retirement change. |
|
||||
| `v1.4.146` | 2026-07-19 | 23 | #9749's old current generation; already contains #8661's continue-shutdown-on-dispose-failure behavior. |
|
||||
| `v1.4.147` | 2026-07-20 | 24 | First stable release containing #9277: authenticated identity and atomic empty-daemon retirement. |
|
||||
| `v1.4.148` | 2026-07-21 | 24 | Reporter current release; legacy v23 and older remain intentionally adoptable. |
|
||||
| `main` at `937a2015e` | 2026-07-21 | 25 | Protocol bumped by #9651 (`cc44acaaa`) for PTY startup ingress; v24 is now legacy. No stable tag contains this commit yet. |
|
||||
|
||||
`PREVIOUS_DAEMON_PROTOCOL_VERSIONS` is cumulative rather than one-version-only.
|
||||
At the investigated `main`, it contains versions 1 through 24.
|
||||
|
||||
## Investigation matrix
|
||||
|
||||
Confidence is **proven** when the issue evidence and source/diff establish the
|
||||
mechanism, **supported** when multiple observations fit a reachable source path,
|
||||
and **suspected** where attribution is missing.
|
||||
|
||||
| Item | State; dates; releases/protocols | Exact symptom | Mechanism and relevant code/commits | Shipped status; unresolved #9749 relevance; scope boundary |
|
||||
| --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [#9749](https://github.com/stablyai/orca/issues/9749) | Open; 2026-07-21; Windows 11; daemon-host 1.4.146→1.4.148; old v21/v22/v23 and current v24 observed | Three surviving daemons were greeted at `16:41:32.277Z`–`.286Z`; later bursts logged 55/30/65/60/60 v22 kills and 10/32 v23 kills, including repeated IDs, while 191,266 main-process spans showed no coincident app death. One v23 broad `shutdown reason:rpc killSessions:true` and repeated `shutdown-dispose-failed` were present. | **Supported composition, reporter causality not proven.** Versioned legacy adoption explains hellos; `cleanupDaemonForProtocol` explains broad shutdown; #8871's stale-mirror close echo best explains reconnect-time individual kills. `daemon-server.ts` logs `session-killed` before awaiting `host.kill`, so repeated records prove repeated requests, not repeated physical death. | No fix shipped for the destructive initiator. #9277 mitigates empty v24+ accumulation only. Skipping all legacy hello or killing legacy generations on sight would destroy the promised warm-reattach behavior and is unsafe scope creep. |
|
||||
| [#9138](https://github.com/stablyai/orca/issues/9138) | Open; 2026-07-17; macOS 1.4.143/v22; observed v18/v20/v21/v22 | UI showed 8 sessions while about 46 Claude processes remained; removing stale trees removed about 370 processes and reduced swap from 25 GB to under 5 GB. | **Proven accumulation, ownership policy deliberately conservative.** Normal disconnect, versioned endpoints, incomplete cross-profile ownership, sleeping/cold restore, and legacy wildcard claims prevent safe blind retirement. Prototype commit `7c915909b` implements an audit/journal/incarnation design but is unmerged. | No complete fix shipped. #9277 safely handles only empty current-generation v24+ daemons. The broader ownership journal, grace period, all-profile evidence, and enforcement rollout belong here, not the immediate #9749 close-authority patch. |
|
||||
| [#9211](https://github.com/stablyai/orca/issues/9211) | Closed duplicate 2026-07-17; v21/v22 | A v21 daemon remained four days beside v22, and each app launch greeted both. | **Proven intentional legacy discovery with missing retirement.** `createLegacyDaemonAdapters` plus `discoverLegacySessions`. | Consolidated into #9138; no separate shipped fix. Same accumulation input as #9749, but not its destructive initiator. |
|
||||
| [#9229](https://github.com/stablyai/orca/issues/9229) | Open P1; 2026-07-17; Linux headless | Failed shutdown left a 15.55 GiB/81-process old runtime beside a 1.21 GiB/4-process replacement. | **Supported reconciliation design.** Requires provenance, one destructive authority, exact daemon/session PID+start incarnations, complete evidence, and fail-closed missing reads. | Unshipped. Supplies invariants for later reconciliation. Implementing its full profile migration and journal in #9749 would be unsafe scope expansion. |
|
||||
| [#9195](https://github.com/stablyai/orca/issues/9195) | Open; 2026-07-17; Windows 1.4.144/v22; comments through #9277 release read | `orca-terminal-daemon.exe` remains after app exit; later comments observed v21/v22/v23 and invisible sessions. | **Proven intentional for non-empty daemons.** Normal quit calls `disconnectDaemon`, not `shutdownDaemon`; the child is detached/unref'd. Empty accumulation was an unhandled gap before #9277. | #9277 shipped in 1.4.147 for empty v24+ only. Treating every survivor as a bug would regress warm reattachment. |
|
||||
| [PR #9277](https://github.com/stablyai/orca/pull/9277) | Merged 2026-07-20 as `7adda25b0`; first stable `v1.4.147`; v23→v24 | Empty current-generation daemons accumulated after clean app disconnect or failed adoption. | **Proven by diff/tests.** Hello returns PID/start-time/launch-nonce identity; control/stream identity must match. `shutdownIfIdle` atomically checks one complete client, no unknown transport, no sessions, and no admission in flight. Shutdown fences admission before disposal. A two-minute watchdog covers only never-adopted startup. | Shipped in `v1.4.147-rc.4`, `v1.4.147`, `v1.4.148-rc.1`, and `v1.4.148`. It explicitly does not perform startup reaping, retire legacy daemons, infer ownership, or kill reattachable PTYs. Strong identity/capability foundation; partial mitigation only. |
|
||||
| [#8871](https://github.com/stablyai/orca/issues/8871) | Open P0; 2026-07-15; first observed 1.4.141/v21; all corrections/comments read | First reconnect kill followed renderer bootstrap by 3.348 s. Three later fresh renderer processes correlated within about 1 s with bursts killing 4, 7, and 7 worktrees while app and daemon PIDs remained stable. | **Supported-to-proven request path.** Persisted remote handle → subscribe returns `no_connected_pty` → synthetic `pty-exit` → `closeTerminalTab(reason:'pty-exit')` → reason dropped from `session.tabs.close` → `closeMobileSessionTab` kills host PTY. Early pre-connect kills were correctly downgraded to unattributed. Close propagation intersects landed #8628 and later #8958. | Not fixed on main. This is the strongest immediate #9749 foundation. Trigger repair alone is insufficient; the authoritative host must adjudicate intent. |
|
||||
| [PR #8872](https://github.com/stablyai/orca/pull/8872) | Open; 2026-07-15; head `bb69fac775`; rebased 2026-07-21 | Prevents mirror `pty-exit`/cleanup evidence from killing a host PTY while retaining real user close and dead-tab retirement. | **Actual diff/tests inspected.** Threads typed `user`/`pty-exit`/`cleanup`; host refuses non-user closes if any parent leaf has a connected PTY, republishes an unchanged snapshot with a guarded replay marker, and handles dead-leaf/live-sibling loops. Adds an optional daemon `kill intent:'auto'` guard, but intentionally sends no auto intent until protocol/capability negotiation exists. | Unshipped. The earlier Windows/WSL live matrix passed on an older head and was explicitly invalidated by the rebase, so native validation must be rerun. This is the best narrow safety foundation, but reasonless old clients remain destructive on a new host. |
|
||||
| [PR #8888](https://github.com/stablyai/orca/pull/8888) | Open; 2026-07-15; head `b13235211`; reviews/follow-ups read | Ambiguous paired-runtime close RPCs could destroy host tabs with no requester attribution. | **Actual diff/tests inspected.** Default-denies intent-less paired-runtime `session.tabs.close`, `terminal.close`, and `terminal.closeTab`; validates user source/target, dedupes request IDs per device across reconnects, rate-limits, restricts create rollback to its connection, and traces device/connection/source/decision without bearer tokens. Adds local daemon-control client ID to `session-killed`. | Unshipped. Useful defense-in-depth and logging foundation. Its “old paired client gets successful no-op” behavior is safely conservative but needs explicit compatibility/product acceptance; taking the entire policy is broader than the smallest #9749 fix. |
|
||||
| [#9414](https://github.com/stablyai/orca/issues/9414) | Open tracking issue; 2026-07-19 | Recognition-dependent terminal lifecycle actions can destroy or strand arbitrary processes. | **Proven design boundary.** Host owns adjudication; stale client transport state and agent-name recognition are not kill authority. Tracks #8872/#8888 and lifecycle-state work. | Unshipped tracker. Aligns with #9749 invariants; generic agent recognition work is separate scope. |
|
||||
| [PR #8628](https://github.com/stablyai/orca/pull/8628) | Merged 2026-07-14 as `36cd8a334`; releases after that date | Tab close did not durably retire all associated terminal/session state. | **Actual merge diff inspected.** Centralized terminal retirement/ownership and ensured remote-owned closes reach the authoritative host. The remote call still carried no close reason, so lifecycle and user closes were indistinguishable. | Shipped and important context, not a regression to revert wholesale. #9749 must preserve its durable explicit-user close while adding authority. |
|
||||
| [#8878](https://github.com/stablyai/orca/issues/8878) / [PR #9098](https://github.com/stablyai/orca/pull/9098) | Issue and PR open; 2026-07-15/17; PR head `9f3abd778` | A reconnecting paired client resumes a provider session still live on the host, producing duplicate TUIs. | **Proven companion feedback loop.** Client-local resume runs before authoritative mirror arrival; a #8871 kill also leaves a resume record. PR gates resume for runtime-owned worktrees while preserving the record. | Unshipped. Relevant reconnect stress case, but provider-session resume dedupe is not needed to stop #9749 kills. |
|
||||
| [#9352](https://github.com/stablyai/orca/issues/9352) / [#9585](https://github.com/stablyai/orca/issues/9585) | Open; 2026-07-18/20; remote macOS and Windows | Closed or killed remote tabs return as `ptyId:null` phantoms; host snapshots keep dead terminal surfaces, and repeated host restarts accumulate them. | **Proven stale mirror/snapshot state.** Host `touchMobileSessionSnapshotsForPty` republishes the same tab; viewer mirrors every terminal surface. Remote transport also leaks the explicit-kill error. | Unshipped. Explains repeated stale close inputs and must be in stress coverage, but pruning dead surfaces is a separate lifecycle fix requiring sleeping-session care. |
|
||||
| [#9217](https://github.com/stablyai/orca/issues/9217) / [#8970](https://github.com/stablyai/orca/issues/8970) | #9217 closed duplicate 2026-07-19; #8970 open; v1.4.143 | Agent/sidebar rows remain after local close or SSH relay connection loss. | **Proven UI/status lifecycle gaps.** SSH teardown intentionally preserves PTY ownership for reattach but omitted status clearing; renderer lacks a sweep. | Separate non-destructive roster cleanup. Combining sidebar cleanup with #9749 would be scope creep. |
|
||||
| [#8851](https://github.com/stablyai/orca/issues/8851) / [PR #8825](https://github.com/stablyai/orca/pull/8825) | Issue closed; PR merged 2026-07-15 as `c8986ca52`; 1.4.143 notes | Finished named Claude children remained as idle sidebar rows. | **Proven Claude roster mechanism, not PTY lifecycle.** Working-only roster and hydration cleanup. | Shipped, but #8970 proves top-level/session residuals. Unrelated to destructive daemon authority. |
|
||||
| [#8275](https://github.com/stablyai/orca/issues/8275) / [#8276](https://github.com/stablyai/orca/issues/8276) | #8275 open `cannot_repro`; #8276 closed duplicate; 2026-07-11; v20→v22 attempts | Rapid worktree removal was followed by daemon death and unrelated split panes exiting `-1`; no shutdown record. | **Different mechanism.** Shared daemon process dies during PTY teardown; current v22 attempt did not reproduce. | Not the #9749 initiator: #9749 daemons stay alive and log explicit shutdown/kill requests. Keep daemon-death regression coverage, but do not merge root causes. |
|
||||
| [PR #8140](https://github.com/stablyai/orca/pull/8140) | Merged 2026-07-10 as `03a673708`/PR head `1710325e0`; fixes #8048 | Graceful then immediate Windows PTY teardown double-closed the same ConPTY handle and killed the shared daemon. | **Proven by actual native harness/diff.** `nodePtyKillIssued` makes later Windows force a no-op. Harness uses unique pipe/temp state, 25 victims, and a witness PTY/daemon survival assertion. | Shipped. Prevents one daemon-death path; does not authorize reconnect closes and does not retire descendants. Reuse its isolation/witness patterns only. |
|
||||
| [PR #8284](https://github.com/stablyai/orca/pull/8284) | Closed unmerged 2026-07-16; head `ebe0b6523` | Proposed serial worktree PTY admission and verified fail-closed teardown across Windows/POSIX/SSH. | **Actual 50-file diff/tests inspected.** Retained shutdown ownership, admission fences, physical-exit proof, and Windows relay ConPTY ownership. | Contrary to the issue prompt, it did **not** merge. Landed work was split/superseded by #8661 and #8706. Its teardown patterns are useful, but adopting its broad branch is unsafe. |
|
||||
| [PR #8661](https://github.com/stablyai/orca/pull/8661) | Merged before 1.4.146; key commit `a635ff9a7` | Disposal failure could prevent orderly runtime/daemon termination. | **Proven current source behavior.** Shutdown RPC catches `host.dispose`, logs `shutdown-dispose-failed`, then continues fencing, client destruction, and server close; resource disposal is retried. | Shipped before #9749. Corrects the report's inference that the failure necessarily leaves the endpoint authoritative forever. A native handle may still keep a process alive, but the server intends to close. |
|
||||
| [#9045](https://github.com/stablyai/orca/issues/9045) | Open; 2026-07-16; Windows | Worktree deletion fails because agent descendants retain filesystem handles after PTY teardown. | **Supported descendant ownership gap.** Root PTY exit is not full tree exit on Windows. | Unresolved. It concerns cleanup after a legitimate kill, not who may initiate it. |
|
||||
| [#9704](https://github.com/stablyai/orca/issues/9704) | Open; 2026-07-21; Windows 1.4.147/v24 | Killed PTY descendants survive (six trees/18 processes/~1.1 GB, later ~2 GB); runtime lists dead sessions as connected. | **Proven descendant leakage plus stale registry.** Explicitly asks for initiator attribution rather than assuming the kill was valid. | Unresolved. Different from #9749 because the root session was killed; #9704 concerns what survives afterward. |
|
||||
| [PR #9752](https://github.com/stablyai/orca/pull/9752) | Open; 2026-07-21; head `6be947317` | Windows agent descendants survive explicit or natural PTY-root exit. | **Actual native patch and tests inspected.** Suspends recognized native-Windows agent ConPTY roots, creates/configures/assigns a kill-on-close Job Object before resume, owns the handle atomically, and falls back to direct-root termination. Plain terminals, WSL, POSIX, SSH relay, and unrelated sessions are excluded; Windows process-table commands are removed. | Unshipped. Correct post-authorization cleanup, explicitly not an initiator fix. Combining it into the #9749 authority patch would obscure causality; compose/test separately if it lands first. |
|
||||
| [PR #9266](https://github.com/stablyai/orca/pull/9266) / [PR #9612](https://github.com/stablyai/orca/pull/9612) | Both open; 2026-07-18/20 | Alternative Windows descendant tree termination using process enumeration/taskkill-style sweeps. | **Competing cleanup approaches.** Carry PID-reuse, access-denied, cost, and partial-tree risks that native Job ownership avoids. | Unshipped. Do not duplicate inside #9749. |
|
||||
| [PR #8706](https://github.com/stablyai/orca/pull/8706) | Merged 2026-07-15 as `40d015992`/merge `6dbeeda3e`; stable thereafter | POSIX agent descendants survived root teardown. | **Proven POSIX snapshot-before-root-kill.** Windows intentionally returns no snapshot. | Shipped for macOS/Linux only. No #9749 authority effect. |
|
||||
| [#9193](https://github.com/stablyai/orca/issues/9193) / [PR #9288](https://github.com/stablyai/orca/pull/9288) | Both open; 2026-07-17/18 | `terminal close --tab` cannot address live floating/tabless PTYs; pane close can kill, and Windows may retain a stale entry. | **Proven addressing/registry gap.** PR routes tabless PTY through existing pane close. | Unshipped. Diagnostic and stale-registry relevance only; making more sessions closeable is not authority fencing. |
|
||||
| [#9563](https://github.com/stablyai/orca/issues/9563) / [PR #9634](https://github.com/stablyai/orca/pull/9634) | Both open; 2026-07-20/21; headless macOS | LaunchAgent host invokes update, disconnects clients, old binary respawns, and ShipIt reports “App Still Running.” | **Supported updater/headless ownership race.** PR defers installation while serving headlessly. | Unshipped. Must be a relaunch scenario in the harness, but updater policy is separate from close authority. |
|
||||
| [#8261](https://github.com/stablyai/orca/issues/8261) | Open; 2026-07-11; macOS/Linux headless comments | Silent update installation kills active PTYs; one headless log has `shutdown reason:rpc killSessions:true`. | **Supported broad shutdown during update.** | Unresolved adjacent caller/lifecycle context. Updater UX and install policy are scope creep; broad shutdown attribution is relevant. |
|
||||
| [#8459](https://github.com/stablyai/orca/issues/8459) | Open; 2026-07-13 | Resource Manager labels live daemon sessions orphan from renderer-only evidence and bulk-kills them. | **Proven ownership-safety precedent.** Absence from one renderer is not authority. | Unshipped. Same invariant as #9749, different UI initiator. Do not couple UI resource-manager redesign. |
|
||||
| [#8585](https://github.com/stablyai/orca/issues/8585) | Open; 2026-07-13; SSH relay | Failed relay `--connect` unlinks a socket while the old relay and PTYs remain alive. | **Different namespace/transport ownership leak.** | Unresolved. SSH regression consideration, not a native daemon-generation fix. |
|
||||
| [#7783](https://github.com/stablyai/orca/issues/7783) | Open; 2026-07-08; macOS | Helper survives app quit with roughly 189 descendants. | **Supported historical survival/descendant leak.** | Unresolved adjacent accumulation evidence; no reconnect-kill attribution. |
|
||||
| [#8457](https://github.com/stablyai/orca/issues/8457) | Open; 2026-07-13 | Headless serve and GUI relaunch ownership collide, interrupting or duplicating live agents. | **Supported multi-owner lifecycle conflict.** | Reinforces exactly-one reconciliation authority. Full headless lifecycle redesign is separate. |
|
||||
| [#8362](https://github.com/stablyai/orca/issues/8362) | Open; 2026-07-12; remote relay | PTY master FDs leak across relay children. | **Different mechanism: missing close-on-exec/inherited descriptors.** | Unresolved, but unrelated to named-pipe discovery or destructive requests. |
|
||||
| [#9569](https://github.com/stablyai/orca/issues/9569) / [PR #9587](https://github.com/stablyai/orca/pull/9587) | Issue closed 2026-07-20; PR open | Worktree removal dials a dead legacy v22 socket after v23 upgrade and fails ENOENT. | **Proven stale adapter routing.** PR tolerates dead legacy adapter teardown. | Unshipped. Demonstrates adapter lifecycle staleness; no authority fix. |
|
||||
| [#8689](https://github.com/stablyai/orca/issues/8689) / [PR #8697](https://github.com/stablyai/orca/pull/8697) | Closed/merged 2026-07-14; merge `840d3277d` | Daemon accepts a connection but never answers hello, wedging startup. | **Proven bounded handshake/replacement path.** | Shipped. Harness must bound hello and avoid reconnect storms; not a session-kill mechanism. |
|
||||
| [PR #7538](https://github.com/stablyai/orca/pull/7538) | Merged 2026-07-07 as `03cfc5bd1` | Windows update moved daemon code while live daemon/PTYs should survive. | **Proven historical compatibility intent.** Relocated host preserves same-protocol daemon across update. | Shipped. Later legacy adapters extended preservation across protocol bumps; blanket reaping would regress this contract. |
|
||||
| [PR #2974](https://github.com/stablyai/orca/pull/2974) | Merged 2026-05-28 as `5a852415a` | Resolver refresh risked killing live PTYs. | **Proven preserve-live policy.** Protocol bump/legacy routing is preferred to broad cleanup. | Shipped. Strong evidence against “kill old generation on sight.” |
|
||||
| [PR #7836](https://github.com/stablyai/orca/pull/7836) | Merged 2026-07-18 as `5f6728c1b` | Shutdown/provider selection race could clear a binding while the daemon PTY survived. | **Proven ownership race fix.** Retains provider/shutdown ownership until outcome. | Shipped. Preserve in regression tests; not close-intent adjudication. |
|
||||
| [PR #1343](https://github.com/stablyai/orca/pull/1343) | Merged 2026-05-03 as `df1fefcc2` | Users lacked session visibility; stale PID files risked PID-reuse mistakes. | **Proven management/PID-start-time guard.** | Shipped. Useful diagnostic/incarnation precedent, but observability alone cannot prevent #9749. |
|
||||
| [PR #9516](https://github.com/stablyai/orca/pull/9516) | Merged 2026-07-20 as `2e67af82d`/`de86f482c` | Windows worktree teardown RPCs could hang indefinitely. | **Proven bounded-deadline change.** | Shipped. Bounds cleanup but does not decide whether cleanup is authorized. |
|
||||
| [PR #8768](https://github.com/stablyai/orca/pull/8768) / [PR #8817](https://github.com/stablyai/orca/pull/8817) | Merged 2026-07-14/15 as `02de3c565` and `f7926c11f` | Restored/legacy PTYs could render blank or be unmounted while still live. | **Proven adoption compatibility.** Keep legacy daemon PTYs mounted and defer snapshots correctly. | Shipped. Direct reason that refusing all legacy discovery is unsafe. |
|
||||
| [#9441](https://github.com/stablyai/orca/issues/9441) / [PR #9446](https://github.com/stablyai/orca/pull/9446) | Open; 2026-07-19; macOS 1.4.146 | Large persisted profile drives high CPU/RSS and exits during startup restoration; clean user-data does not reproduce. | **Supported restore-load ordering issue.** PR defers full worktree scan. | Unshipped. Profile-switch/load stress case only; not destructive daemon authority. |
|
||||
|
||||
## Timestamped incident and call-flow reconstruction
|
||||
|
||||
### A. How generations survive
|
||||
|
||||
1. **2026-07-15 13:50 local:** #9749's v21 daemon starts.
|
||||
2. An app quit/update runs `disconnectDaemon`, whose adapter `disconnectOnly`
|
||||
closes client sockets and leaves live PTYs/history reattachable. The daemon is
|
||||
detached/unref'd, so parent death is not daemon death.
|
||||
3. **2026-07-18 14:26 local:** a v22 daemon starts on a different versioned
|
||||
endpoint while v21 retains its PTYs.
|
||||
4. **2026-07-20 14:50 local:** v23 starts while both prior endpoints remain.
|
||||
5. Windows endpoints are generated by
|
||||
`getDaemonSocketPath(runtimeDir, protocolVersion)` as
|
||||
`\\?\pipe\orca-terminal-host-v<protocol>-<sha256(runtimeDir)[0..12]>`.
|
||||
Tokens and PID records are likewise protocol-specific files. No endpoint
|
||||
collision forces an old generation out.
|
||||
6. Before protocol 24, a non-empty daemon had no generation-retirement
|
||||
protocol. Since #9277, an empty v24+ daemon can atomically self-retire, but a
|
||||
live session intentionally blocks it and v23-or-older behavior is unchanged.
|
||||
7. #9749's claim that `shutdown-dispose-failed` necessarily leaves the pipe open
|
||||
is not source-proven for 1.4.146. Commit `a635ff9a7` catches disposal failure
|
||||
and continues ordinary shutdown; #9277 additionally closes admission first.
|
||||
A stuck native handle may keep a process alive, but the endpoint is meant to
|
||||
stop being authoritative.
|
||||
|
||||
### B. Why startup greets every surviving generation
|
||||
|
||||
1. Current startup establishes the v25 (v24 in the reporter build) adapter and
|
||||
its complete control/stream lifecycle lease.
|
||||
2. `createLegacyDaemonAdapters(runtimeDir)` loops every value in
|
||||
`PREVIOUS_DAEMON_PROTOCOL_VERSIONS`, derives that version's pipe/token/PID
|
||||
paths, and probes each endpoint.
|
||||
3. A responsive endpoint receives a `DaemonPtyAdapter` configured with that
|
||||
exact old protocol. It has no respawn callback, because new code must not
|
||||
recreate old environment semantics.
|
||||
4. `DaemonPtyRouter.discoverLegacySessions()` calls `adapter.listProcesses()`
|
||||
for each legacy adapter.
|
||||
5. `listProcesses()` calls `ensureConnected()`. `DaemonClient.doConnect()` opens
|
||||
a control socket, sends hello, then opens a stream socket and sends hello
|
||||
using one client UUID. This is the precise source of each control/stream pair
|
||||
in #9749 at `16:41:32.277Z` through `.286Z`.
|
||||
6. v24+ hellos return PID/start-time/launch-nonce and require both sockets to
|
||||
match. Old protocols return no identity, so successful token+protocol hello
|
||||
authenticates the endpoint but cannot prove a process incarnation.
|
||||
7. `listSessions` results populate `sessionAdapters: Map<sessionId, adapter>`.
|
||||
That map is keyed only by reusable session ID, not daemon/session
|
||||
incarnation; a later generation can overwrite an earlier claim.
|
||||
8. No code in hello acceptance, adapter construction, or discovery sends
|
||||
`kill`, `shutdown`, or `shutdownIfIdle` to a legacy daemon.
|
||||
|
||||
### C. Exact broad shutdown caller
|
||||
|
||||
1. Current-daemon replacement/manual restart/full cleanup calls
|
||||
`cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)` (or the explicitly
|
||||
supplied current handle protocol).
|
||||
2. It probes that version's endpoint and creates a `DaemonClient` for the same
|
||||
protocol.
|
||||
3. It connects with the same control/stream hello sequence.
|
||||
4. It requests `listSessions`; any error is converted to `{ sessions: [] }`.
|
||||
5. It then unconditionally sends `shutdown { killSessions: true }` and treats a
|
||||
reply race with daemon exit as success.
|
||||
6. `daemon-server.ts` logs `shutdown reason:'rpc' killSessions:true`, begins the
|
||||
ordinary shutdown admission fence, awaits `host.dispose`, logs but catches
|
||||
`shutdown-dispose-failed`, writes the reply if possible, disposes resources,
|
||||
destroys clients/transports, unlinks owned identity artifacts, and closes
|
||||
the server.
|
||||
7. Repository search found no other direct production sender of
|
||||
`shutdown { killSessions: true }`. Legacy adapter discovery is not a caller.
|
||||
8. Unsafe residual: list failure cannot be treated as proof of emptiness for
|
||||
any future generation reaper, and broad shutdown still lacks an origin,
|
||||
daemon-incarnation, ownership, and intent audit record.
|
||||
|
||||
### D. Supported reconnect-to-mass-kill path
|
||||
|
||||
1. **Renderer bootstrap T+0:** a paired desktop/runtime renderer hydrates
|
||||
persisted mirrors containing process-lifetime remote terminal handles.
|
||||
2. **T+milliseconds:** before the fresh authoritative session snapshot fully
|
||||
reconciles, remote transport subscribes using a stale handle.
|
||||
3. The host cannot resolve a connected PTY for that handle and returns a gone
|
||||
condition; remote transport synthesizes `pty-exit`.
|
||||
4. `Terminal.tsx` calls `closeTerminalTab(tabId, { reason: 'pty-exit' })`.
|
||||
5. Current main uses that reason for local retirement behavior but
|
||||
`closeWebRuntimeSessionTab` sends only `{ worktree, tabId }`.
|
||||
6. Runtime RPC `session.tabs.close` has no origin/intent in its schema or
|
||||
handler. `OrcaRuntimeService.closeMobileSessionTab` treats it like an
|
||||
explicit user request and invokes either direct `ptyController.kill`, a
|
||||
whole-parent renderer close, or headless teardown.
|
||||
7. For daemon-backed terminals, the PTY controller is the `DaemonPtyRouter`.
|
||||
`adapterFor(sessionId)` uses the discovery map to select a current or legacy
|
||||
generation and its adapter sends `kill { sessionId, immediate }`.
|
||||
8. The owning daemon logs `session-killed` before awaiting `host.kill`. This is
|
||||
why app and daemon PIDs remain alive while real terminal sessions disappear.
|
||||
9. **#8871 observed T+3.348 s** for the first bootstrap-to-kill incident. Three
|
||||
other new renderer processes aligned within about one second with 4-, 7-,
|
||||
and 7-worktree kill bursts.
|
||||
10. Stale host tab surfaces (#9352/#9585), persisted client mirrors, reconnect
|
||||
retries, and republished snapshots can invoke the same close again. If the
|
||||
daemon still retains the session entry or physical exit is unresolved, a
|
||||
reconnect can rediscover and target the same ID again. Because logging
|
||||
precedes the awaited kill, repeated `session-killed` records can also be
|
||||
repeated failed/not-yet-settled attempts; they do not prove a dead process
|
||||
was resurrected and killed twice.
|
||||
|
||||
### E. Why adjacent bugs are different
|
||||
|
||||
- **#8275/#8048/#8140:** PTY teardown double-closes a Windows ConPTY native
|
||||
handle and the shared daemon dies. There is no broad shutdown event. All
|
||||
sessions disappear because their owner process died.
|
||||
- **#9704/#9045/#9752:** a valid or invalid kill has already targeted the PTY
|
||||
root, but Windows descendants remain and retain memory/files. The owner stays
|
||||
alive or the registry stays stale. This is cleanup completeness after a kill,
|
||||
not destructive authority.
|
||||
- **#9749/#8871:** app and daemons remain alive. Explicit RPCs reach live daemon
|
||||
sessions because stale client lifecycle evidence is interpreted as intent.
|
||||
|
||||
## Compatibility behavior that must remain
|
||||
|
||||
- App quit is a disconnect, not terminal shutdown.
|
||||
- Same-protocol daemon survival across packaged Windows updates (#7538) avoids
|
||||
terminating live work.
|
||||
- Old-protocol adapters remain addressable after an upgrade so mounted and
|
||||
sleeping PTYs can reattach (#2974, #8768, #8817).
|
||||
- Legacy daemons cannot be judged empty from one current profile or renderer.
|
||||
Missing ownership data, inactive profiles, sleeping sessions, remote/SSH
|
||||
routes, and legacy claims all mean keep/audit.
|
||||
- Current protocol 24+ can retire only after its own atomic server-side idle
|
||||
predicate proves there is nothing to preserve.
|
||||
- Older clients/servers must not infer support from an ignored additive field.
|
||||
In particular, no caller may send daemon `kill intent:'auto'` until a
|
||||
protocol bump or negotiated capability proves the daemon will enforce it.
|
||||
|
||||
## Direction comparison
|
||||
|
||||
| Direction | Immediate safety | Compatibility and failure mode | Decision |
|
||||
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| Skip `client-hello` for every old protocol | Avoids routing kills to legacy sessions, but also makes their live PTYs invisible/unattachable. | Directly violates warm legacy reattachment and converts preservation into inaccessible leaks. | Reject. Hello is not the authority bug. |
|
||||
| Force-exit on `shutdown-dispose-failed` | Bounds a half-shutdown process if endpoint fencing and handle cleanup are correct. | Can silently destroy promised live PTYs; does not stop reconnect close RPCs; overlaps #9752 descendant semantics. Current source already continues server shutdown after disposal error. | Not the immediate fix. Test bounded endpoint loss separately. |
|
||||
| Startup reaping | Can reduce accumulated attack surface. | Unsafe without #9138/#9229 all-profile evidence, exact incarnations, grace observations, barriers, and one authority. | Audit-only follow-up, not #9749 patch. |
|
||||
| Explicit generation handoff/retirement | Correct long-term lifecycle model. | Requires old/new capability negotiation and ownership persistence across profiles, SSH, WSL, and sleeping sessions. | Broader #9138/#9229 work. |
|
||||
| Daemon-side fencing | Prevents a retiring daemon from accepting new work or acting after retirement begins. | v24 #9277 already fences ordinary/idle shutdown admission; older protocols cannot be retrofitted. | Preserve and extend only with negotiated capability. |
|
||||
| Host close-intent adjudication (#8872) | Stops the demonstrated stale `pty-exit` echo at the component that owns the live PTY, while real user close and genuinely dead tab retirement still work. | Old clients remain ambiguous unless paired with a default-deny compatibility policy. | Best narrow foundation. |
|
||||
| Connection/device provenance policy (#8888) | Default-denies ambiguous paired-runtime destruction, adds dedupe/rate bounds and strong attribution. | Older paired clients receive a safe successful no-op; policy is broader than one close reason and must preserve mobile/CLI/local/SSH semantics. | Reuse focused policy/logging pieces; assess full policy after the failing harness. |
|
||||
| Job Objects (#9752) | Reaps descendants after an authorized native-Windows agent PTY kill. | Cannot determine intent; plain terminal and WSL exclusions are semantically important. | Compose separately, never substitute for authority. |
|
||||
|
||||
The smallest robust immediate change is expected to combine typed close intent
|
||||
with host-side liveness adjudication and non-secret requester logging. The
|
||||
three-generation harness must decide whether #8888's default-deny/dedupe layer
|
||||
is also required for old-client and repeated-reconnect safety. Full generation
|
||||
retirement stays in #9138/#9229.
|
||||
|
||||
## Harness gate for the next phase
|
||||
|
||||
The first executable artifact will be an isolated native-Windows harness, not a
|
||||
production edit. It must:
|
||||
|
||||
- allocate a temporary runtime/user-data root and unique pipe namespace;
|
||||
- launch three disposable protocol fixtures (for example v21/v22/v23) with
|
||||
distinct live canary children plus stale/terminating table entries;
|
||||
- use the real `DaemonClient`, legacy adapter discovery, router, and runtime
|
||||
session-close path where feasible instead of calling a proposed helper;
|
||||
- prove hello/discovery alone is non-destructive;
|
||||
- separately trigger the exact broad current-protocol shutdown caller and the
|
||||
reconnect stale-mirror close path;
|
||||
- distinguish daemon/app liveness, PTY-root liveness, and descendant liveness
|
||||
with independent counters and process handles;
|
||||
- repeat reconnect bursts and record duplicate requests for a retained ID;
|
||||
- model a PTY owner whose disposal never proves physical exit and assert a
|
||||
bounded, non-authoritative endpoint state;
|
||||
- cover current→next protocol upgrade, same-version quit/relaunch, profile
|
||||
switch, remote-server paired client, multiple simultaneous clients, mixed
|
||||
client/server versions, and WSL/SSH routing boundaries;
|
||||
- validate PID/start identity, dead parent, access-denied identity probes, no
|
||||
listener/pipe/handle leaks, and no CIM/PowerShell per-session hot path;
|
||||
- guarantee cleanup in `finally` using only fixture-owned exact PIDs, handles,
|
||||
files, and pipe names.
|
||||
|
||||
Externally visible RED invariants are: reconnect kills one or more canary PTYs
|
||||
while the app/fixture processes remain alive; repeated stale input can issue
|
||||
another destructive request for the same retained incarnation; and a refused
|
||||
disposal does not reach a bounded terminal endpoint state. The post-fix GREEN
|
||||
invariants are survival of every unrelated canary, one exact kill for an
|
||||
explicit user target, legacy warm reattachment, bounded retirement fencing, and
|
||||
zero leaked fixture processes/handles/pipes.
|
||||
|
||||
## Implemented immediate safety boundary
|
||||
|
||||
Implementation snapshot: 2026-07-21 15:26 PDT (2026-07-21 22:26 UTC).
|
||||
|
||||
The initial implementation used a 30-second capability cache keyed only by
|
||||
runtime-environment ID. That was rejected before finalization: a positive
|
||||
`status.get` result could outlive a server replacement under the same
|
||||
environment, and the later destructive request had no proof that it reached the
|
||||
generation that supplied the capability.
|
||||
|
||||
The implemented boundary makes compatibility and identity validation atomic
|
||||
with the lifecycle request:
|
||||
|
||||
1. Explicit user closes remain on `session.tabs.close`, preserving the durable
|
||||
host-owned close behavior shipped by #8628 and compatibility with old
|
||||
servers that ignore the additive `reason:'user'` field.
|
||||
2. Renderer-originated `pty-exit` and `cleanup` echoes use the additive
|
||||
`session.tabs.closeLifecycle` method. An old server returns
|
||||
`method_not_found` before entering its legacy destructive close handler. The
|
||||
client clears optimistic close suppression and requests an authoritative
|
||||
snapshot; it never falls back to `session.tabs.close`.
|
||||
3. The lifecycle method requires both the `publicationEpoch` observed in the
|
||||
host snapshot and the exact terminal handle observed for that tab. Missing
|
||||
evidence means keep, refresh, and audit rather than kill.
|
||||
4. The host refreshes its PTY records, rejects a different publication epoch,
|
||||
rejects a terminal handle that no longer belongs to the addressed parent,
|
||||
rejects when provider liveness is unavailable, and rejects while any parent
|
||||
leaf still has a connected PTY. Refusals carry a bounded reason
|
||||
(`stale-publication`, `stale-terminal`, `unknown-liveness`,
|
||||
`live-host-pty`, or `retirement-owner`) and republish only when doing so
|
||||
cannot create the known dead-leaf/live-sibling echo loop.
|
||||
5. A lifecycle close never signals a PTY or relays a renderer close. A dead
|
||||
whole headless parent can be retired from persisted/runtime state with
|
||||
`killPtys:false`; a renderer-owned parent or partial split remains with its
|
||||
authoritative owner. Thus a reusable tab ID or incomplete provider read
|
||||
cannot become destructive authority.
|
||||
6. Reasonless legacy-client closes on a new host are successful conservative
|
||||
refusals with an authoritative replay. Older clients and servers therefore
|
||||
degrade to a visible retained tab, not terminal loss.
|
||||
7. Renderer close intents are scoped by runtime environment and worktree, and
|
||||
terminal-incarnation evidence must match that exact runtime environment.
|
||||
Identical tab/worktree IDs in another profile cannot suppress or authorize
|
||||
this profile's retirement.
|
||||
|
||||
The RPC span records origin/client kind, close reason, connection ID, request
|
||||
ID, publication epoch, and allow/refusal decision. It does not record terminal
|
||||
contents, authentication tokens, or environment secrets. The legacy adapter
|
||||
hello/discovery path, warm PTY adoption, daemon shutdown protocol, and Job
|
||||
Object descendant cleanup remain unchanged.
|
||||
|
||||
This is the smallest immediate #9749 fix. Cross-profile generation inventory,
|
||||
all-profile ownership evidence, sleeping-session policy, and explicit daemon
|
||||
handoff/retirement remain #9138/#9229. Windows descendant reaping after an
|
||||
authorized kill remains #9704/#9752.
|
||||
|
||||
## GREEN evidence
|
||||
|
||||
The post-fix native-Windows run completed at 2026-07-21 15:25 PDT:
|
||||
|
||||
- v21/v22/v23 used distinct versioned named pipes under a disposable runtime
|
||||
root, with six independent PTY-root/descendant canaries;
|
||||
- three reconnect discovery bursts plus simultaneous clients opened the real
|
||||
control/stream hello pairs without destructive side effects;
|
||||
- desktop and two remote/profile connection identities attempted each stale
|
||||
mirror retirement three times, then a full reconnect-client process
|
||||
exit/relaunch repeated the same persisted IDs for six attempts total;
|
||||
- the lifecycle requests traversed the production schema, dispatcher,
|
||||
`OrcaRuntimeService`, and `DaemonPtyRouter` using publication/terminal
|
||||
incarnation claims;
|
||||
- every daemon, PTY root, and descendant remained alive while the app/client
|
||||
process remained alive;
|
||||
- the contained refusal-to-exit PTY produced `shutdown-dispose-failed`, lost
|
||||
named-pipe authority within the bounded deadline, and was cleaned by exact
|
||||
fixture-owned process identity;
|
||||
- the reconnect/relaunch test passed in 42.9 seconds and the bounded-disposal
|
||||
test passed in 15.2 seconds.
|
||||
|
||||
Focused verification at this checkpoint: node and web typechecks passed;
|
||||
changed-file `oxlint` and `git diff --check` passed; 181 focused renderer/RPC
|
||||
tests passed; and 12 host close-adjudication tests passed (784 unrelated tests
|
||||
filtered out). No PowerShell/CIM process-per-session path, polling loop, broad
|
||||
installed-daemon discovery, or Job Object implementation was added.
|
||||
|
||||
Additional validation completed at 2026-07-21 15:48 PDT:
|
||||
|
||||
- the native generation harness passed 25 reconnect bursts in 52.1 seconds;
|
||||
every v21/v22/v23 daemon and all six PTY-root/descendant canaries survived;
|
||||
- the mixed-version daemon-lifecycle E2E kept the non-empty v22 daemon
|
||||
reattachable while the empty current v24 daemon retired through #9277;
|
||||
- a fresh `pnpm build:electron-vite` and a fresh paired-web-client build both
|
||||
completed successfully;
|
||||
- 182 remote-runtime, multi-client, remote-server parity, SSH-provider, WSL
|
||||
host-context, and remote PTY transport tests passed;
|
||||
- the complete runtime service/RPC group passed 884 of 885 tests. The lone
|
||||
failure, `preserves existing badgeColor on runtime createRepo dedupe`, is an
|
||||
existing Windows-only POSIX path expectation (`/tmp/...` versus
|
||||
`\\tmp\\...`) in code untouched by this change; every close, remote, SSH,
|
||||
and WSL case in that run passed;
|
||||
- CLI typecheck, switch-independent changed-file lint, reliability gates,
|
||||
max-lines ratchet, and `git diff --check` passed. The repository-wide
|
||||
switch-exhaustiveness command is blocked by the pre-existing unmatched
|
||||
`undefined | 'current' | 'duplicate'` cases in
|
||||
`skill-freshness-group.tsx`, outside this diff.
|
||||
|
||||
Two practical Electron runs exposed setup/teardown limitations without
|
||||
contradicting the close-safety result:
|
||||
|
||||
- `restart-restore-terminal-input.spec.ts` completed the clean-restart, live
|
||||
daemon, restored-output, keyboard-input, and direct-input assertions, then
|
||||
failed only in `RestartSession.dispose()` with `EPERM` deleting its isolated
|
||||
profile. Restart Manager and Sysinternals Handle found no surviving file
|
||||
lock after teardown; the exact fixture root deleted successfully later
|
||||
without terminating a process. The same failure reproduced with the temp
|
||||
root inside this worktree and with the production diff to
|
||||
`orca-restart.ts` empty, so it is recorded as a fixture cleanup limitation,
|
||||
not a session-liveness failure;
|
||||
- the paired-browser navigation E2E built the web client and launched the
|
||||
isolated desktop, but timed out before pairing because its host fixture
|
||||
displayed `No workspaces found`. The deterministic multi-client runtime
|
||||
integration passed; no terminal-close assertion failed in this E2E.
|
||||
|
||||
All diagnostic downloads and worktree-local E2E temp roots were removed by
|
||||
their exact verified paths. No installed Orca daemon pipe or real user terminal
|
||||
was discovered, greeted, stopped, or mutated during these runs.
|
||||
|
||||
## Internal review-until-clean and final native evidence
|
||||
|
||||
The requested `$internal-review-until-clean` loop ran against merge base
|
||||
`937a2015eaf85144d02848c5b6d4c09ecd423830`. Round 1 found and fixed four
|
||||
in-scope safety defects:
|
||||
|
||||
- lifecycle retirement could still relay a destructive renderer close or kill
|
||||
retained/disconnected headless IDs; lifecycle requests now never signal a
|
||||
process and only state-retire a dead whole headless parent;
|
||||
- an unavailable/access-denied PTY inventory was treated like authoritative
|
||||
absence; it now returns `unknown-liveness` and keeps/audits;
|
||||
- pending close intent was keyed only by worktree and could cross-contaminate
|
||||
two runtime profiles; it is now scoped and cleaned by environment/worktree;
|
||||
- the harness accepted a two-second PID start-time tolerance; capture and
|
||||
revalidation now compare the same CIM `CreationDate` exactly.
|
||||
|
||||
The elegance pass also removed an unnecessary cached capability probe. The
|
||||
additive method dispatch on the exact connection is the atomic compatibility
|
||||
boundary; a cached positive result could outlive server replacement. The
|
||||
performance pass found no production polling, process enumeration, listener,
|
||||
or subprocess addition. The existing bounded controller inventory refresh is
|
||||
unchanged in frequency, snapshot refreshes coalesce per environment/worktree,
|
||||
and all added tracking maps have completion or ownership cleanup. Round 2
|
||||
re-interrogated the full diff and found no remaining proven in-scope issue.
|
||||
After strengthening the native harness to invoke production desktop discovery,
|
||||
round 3 found one Windows-only test defect: three scanner tests modeled a live
|
||||
v9 endpoint through POSIX `existsSync` but let every Windows named-pipe probe
|
||||
connect. Their socket mock now accepts only v9 and errors every other version;
|
||||
all 133 daemon lifecycle tests pass. The subsequent full-diff review is clean.
|
||||
|
||||
The latest native Windows run captured its event reconstruction at
|
||||
2026-07-21 16:58 PDT (2026-07-21 23:58 UTC). It launched v21/v22/v23/v24/v25
|
||||
on isolated versioned named pipes, then called the same
|
||||
`createLegacyDaemonAdapters` scanner as desktop startup: the v25 client found
|
||||
exactly v21-v24. Every generation accepted 16 reconnect control/stream hellos;
|
||||
desktop and two remote-profile paths sent six lifecycle attempts for each
|
||||
persisted stale-mirror ID across process relaunch. All five daemons, all ten
|
||||
PTY roots, and all ten descendants were alive afterward, with zero
|
||||
`session-killed` events. This directly covers the v24→v25 current-to-next
|
||||
upgrade boundary while keeping older generations reattachable.
|
||||
|
||||
The bounded `shutdown-dispose-failed` scenario also passed and now separates
|
||||
authority from liveness explicitly: the late connection failed after endpoint
|
||||
fencing while the refusing daemon, PTY root, and descendant remained alive
|
||||
until exact fixture cleanup. The full Playwright command, including a fresh
|
||||
Electron E2E build, exited successfully in 137.1 seconds, and no
|
||||
`orca-9749-dg-*` directory remained. A final no-rebuild rerun passed both
|
||||
scenarios in 99.1 seconds; the practical Electron clean-relaunch check passed
|
||||
again in 24.0 seconds, and mixed-version retirement/live-session preservation
|
||||
passed 2/2 in 23.1 seconds.
|
||||
|
||||
Latest focused verification includes 14/14 host adjudication tests, 948/949
|
||||
focused production/RPC/renderer tests, 133/133 daemon discovery/adoption/
|
||||
retirement/access-failure tests, all three typechecks, and 215
|
||||
remote-runtime, multi-client, remote-server, SSH-provider, WSL-context, remote
|
||||
PTY, and shared-control tests. The sole focused failure remains the untouched
|
||||
Windows `/tmp` normalization baseline documented above. The experimental
|
||||
`terminal-session.daemon-generation-reconnect-safety` reliability gate records
|
||||
the invariant, RED/GREEN oracle, performance budget, promotion criteria, and
|
||||
known platform/provider gaps.
|
||||
|
||||
## Fresh PR review after current-main integration
|
||||
|
||||
The explicitly requested post-PR `$internal-review-until-clean` pass completed
|
||||
at 2026-07-21 18:02 PDT after merging `v1.4.150-rc.0` main, including the
|
||||
remote-runtime network-recovery work from #9774. It found and fixed three
|
||||
additional contract gaps:
|
||||
|
||||
- a stale PTY-exit callback could borrow a replacement or sibling handle from
|
||||
tab-wide state; lifecycle evidence now comes only from the exact callback PTY;
|
||||
- the legacy close endpoint accepted lifecycle reasons without incarnation
|
||||
evidence and refused old mobile close taps; it now accepts only explicit user
|
||||
intent, with reasonless compatibility limited to authenticated mobile clients;
|
||||
- keep-on-unknown preserved the PTY but could leave its client mirror hidden;
|
||||
the host now republishes unchanged authority when inventory is unavailable.
|
||||
|
||||
The PR feedback loop also corrected the bulk-close payload assertion and made
|
||||
the fixture protocol list collision-safe. The final native no-build run passed
|
||||
both scenarios in 98.8 seconds, the fresh-build run passed in 137.1 seconds,
|
||||
mixed-version retirement passed, and practical Electron restart/input passed
|
||||
two scenarios with one intentionally skipped wedge scenario. Current-main
|
||||
remote recovery (112 tests), focused close/reconnect suites, all 14 host
|
||||
adjudication cases, all typechecks, reliability gates, max-lines, changed lint,
|
||||
formatting, and diff checks pass. The full lint command remains blocked only by
|
||||
pre-existing current-main switch-exhaustiveness and localization findings; no
|
||||
remaining in-scope review finding is open.
|
||||
|
||||
## Additional clean review after `v1.4.150-rc.0` integration
|
||||
|
||||
This additional requested `$internal-review-until-clean` pass completed at
|
||||
2026-07-21 18:56 PDT (2026-07-22 01:56 UTC) against merge base
|
||||
`4d0e3f51ce0325a8f4670b4074618494984ab63d`. Round 1 found and fixed four
|
||||
in-scope evidence/performance gaps:
|
||||
|
||||
- the native harness omitted production `DaemonPtyRouter.listProcesses`, so
|
||||
its synthetic lifecycle closes could pass through `unknown-liveness`; it now
|
||||
routes the production inventory, uses worktree-prefixed daemon session IDs,
|
||||
and requires every close to return `live-host-pty` with a republished
|
||||
snapshot;
|
||||
- concurrent reconnect closes each started a full cross-generation PTY
|
||||
inventory; the host now shares one in-flight inventory and a deterministic
|
||||
count test proves two concurrent closes call `listProcesses` once;
|
||||
- the renderer introduced a second snapshot-refresh map; it now reuses the
|
||||
existing environment/worktree remote-session deduper used by PTY reconnect;
|
||||
- the reliability gate omitted the parked-tab callback path; its exact exiting
|
||||
PTY incarnation assertion is now part of the gate.
|
||||
|
||||
The review also added a wire-compatibility assertion proving that the additive
|
||||
`{ reason: 'user' }` field is stripped by the previous `ActivateTab` server
|
||||
schema. The repeated elegance and performance interrogation found no remaining
|
||||
avoidable infrastructure, polling, subprocess churn, listener/handle leak, or
|
||||
unbounded reconnect work. Round 2 re-read the full production and fixture diff,
|
||||
constructed stale-publication, stale-handle, concurrent-close, mixed-client,
|
||||
and old-server failure paths, and found no remaining proven in-scope issue.
|
||||
|
||||
Fresh verification includes all three typechecks; reliability-manifest,
|
||||
max-lines, changed-file lint, formatting, and diff checks; 175 remote snapshot
|
||||
and PTY transport tests; and the isolated lifecycle/reconnect adjudication
|
||||
group. The strengthened native five-generation Windows harness passed after a
|
||||
fresh Electron E2E build in 137.2 seconds, required `live-host-pty` for every
|
||||
synthetic close, left every daemon/root/descendant alive, and left no
|
||||
`orca-9749-dg-*` directory. The full focused gate reached 992/993 passing tests;
|
||||
the only failure remains the untouched Windows `/tmp` versus `\\tmp`
|
||||
`createRepo` baseline. The newly merged headless-update group reached 143/144
|
||||
passing tests plus 25 skips; its only failure is an untouched LF-only
|
||||
source-text assertion that does not match CRLF on Windows.
|
||||
|
||||
## Completion evidence matrix
|
||||
|
||||
| Original acceptance requirement | Authoritative evidence | Status |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| Complete recursive issue/PR/commit/tag investigation before production edits | The 41-row investigation matrix above, release chronology, and timestamped call-flow reconstruction cover every required starting item and every directly discovered related item. GitHub reads were completed before the first production edit. | Proven |
|
||||
| Explain survival, generation discovery/hello, broad shutdown caller, repeated reconnect kills, adjacent-bug distinctions, and compatibility | Sections A–E identify `disconnectDaemon`, `createLegacyDaemonAdapters` → `discoverLegacySessions` → control/stream hello, `cleanupDaemonForProtocol`, the stale-handle `pty-exit` path, #8275 versus #9704, and the warm legacy reattach contract. | Proven |
|
||||
| Isolated native-Windows multi-generation reproduction with external liveness oracle | `daemon-generation-reconnect-safety.spec.ts` uses an exact temporary user-data/runtime root, versioned pipe names, disposable ConPTY children, independent daemon/root/descendant PID-start identities, bounded output, and exact cleanup. The historical RED run above recorded real repeated `session-killed` events while daemon/client witnesses stayed alive. | Proven |
|
||||
| Exercise the desktop discovery path and current→next mixed-version upgrade | The latest native run calls production `createLegacyDaemonAdapters`; v25 discovers exactly v21/v22/v23/v24, reattaches every original process incarnation, and leaves all five generations alive. | Proven |
|
||||
| Reconnect bursts, app quit/relaunch, simultaneous clients, repeated IDs, profile and remote-runtime boundaries | Three router rebuilds, a full reconnect-client process exit/relaunch, parallel direct clients, and six desktop/two-profile lifecycle attempts per persisted ID are native. Environment-scoped close-intent and remote-runtime transport suites supply deterministic profile-switch/remote-server boundary proof. | Proven (native transport plus deterministic profile/provider boundaries) |
|
||||
| `shutdown-dispose-failed` has a bounded non-authoritative state without conflating process death | The native refusal fixture loses pipe authority within the deadline, rejects a late client, logs the failure, proves its daemon/root/descendant still live, then cleans only exact recorded fixture incarnations. | Proven |
|
||||
| Smallest immediate fix preserves legacy adoption and separates broader retirement/descendant cleanup | Additive `session.tabs.closeLifecycle`, host liveness/incarnation adjudication, no destructive fallback, and state-only dead-headless retirement leave hello/adoption, #9138/#9229 retirement, and #9704/#9752 descendant semantics unchanged. | Proven |
|
||||
| Missing evidence keeps/audits; retirement is incarnation/profile safe; one owner has destructive authority | Host tests cover unavailable inventory, stale publication/handle, live split siblings, renderer ownership, and reasonless clients. Renderer tests cover exact environment handles and cross-profile intent isolation. Lifecycle requests never signal a PTY or relay renderer teardown. | Proven |
|
||||
| Windows identity, dead-parent/never-adopted, ACL/access failure, rapid reconnect, and multi-client behavior | Native CIM `CreationDate` identity is exact; the five-generation run covers rapid reconnect and concurrent clients. The 133-test daemon group covers never-adopted retirement, admission fencing, overlapping clients, and EACCES/EPERM process-signal failures. | Proven (native identity/reconnect; deterministic ACL failure) |
|
||||
| No production PowerShell/CIM hot path, polling/listener/handle leak, or reconnect storm | Process enumeration exists only in fixture helpers; production adds no subprocess or timer. Refreshes coalesce by environment/worktree, listener ownership is unchanged, every fixture allocation has bounded cleanup, and 25-burst stress evidence is recorded above. | Proven |
|
||||
| Cross-platform, SSH, WSL, remote-server, and multiple-client compatibility | 215 deterministic tests cover remote runtime/server, shared control, SSH provider, WSL host context, and PTY transport. Platform-specific fixture behavior is runtime-gated. | Deterministic proof complete; live Linux SSH/WSL unavailable |
|
||||
| Practical Electron restart behavior | A real isolated Electron application created a daemon-backed terminal, wrote and restored output across clean app quit/relaunch, preserved the exact daemon PID, and accepted both keyboard and direct terminal input after reattachment. | Proven on native Windows (`electron-headless`, 24.0 s) |
|
||||
| Internal review-until-clean and final gates | The original three review rounds and both additional PR review loops are clean. The latest loop fixed four oracle/performance/gate gaps, then completed a clean full-diff re-review. Typecheck, lint, format, reliability manifest, max-lines ratchet, Electron build, native harness, and focused suites pass apart from the documented untouched baselines. | Proven |
|
||||
| Public GitHub and real daemon safety | No public GitHub mutation occurred. Fixture guards reject non-temporary roots and known Orca user-data paths; no installed pipe/token/session was discovered or contacted. | Proven |
|
||||
|
||||
Live Linux SSH validation remains an explicit gap: the required throwaway
|
||||
Docker target is unavailable on this runner (`docker` is not installed and no
|
||||
Docker Desktop process or standard executable path exists), and `wsl.exe`
|
||||
reports that WSL is not installed. A real remote or localhost was deliberately
|
||||
not substituted. The Orca worktree comment was updated through the scoped
|
||||
`worktree set --comment` CLI with the investigation, RED reproduction,
|
||||
root-cause, implementation, native-validation, and clean-review milestones.
|
||||
No terminal or daemon command was issued through the CLI, and no public GitHub
|
||||
comment was made.
|
||||
|
|
@ -4072,7 +4072,10 @@ export default function SessionScreen() {
|
|||
try {
|
||||
const response = await client.sendRequest('session.tabs.close', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id
|
||||
tabId: tab.id,
|
||||
// Why: a tapped tab close is explicit user intent; older hosts strip
|
||||
// the unknown field and keep their legacy behavior.
|
||||
reason: 'user'
|
||||
})
|
||||
if (response.ok) {
|
||||
if (tab.type === 'terminal' && typeof tab.terminal === 'string') {
|
||||
|
|
|
|||
|
|
@ -441,6 +441,27 @@ function mockConnectedAdoptionClientOnce(): void {
|
|||
})
|
||||
}
|
||||
|
||||
function mockOnlyDaemonSocketAlive(socketSuffix: string): void {
|
||||
netConnectMock.mockImplementation((options?: { path?: string }) => {
|
||||
const live = options?.path?.endsWith(socketSuffix) ?? false
|
||||
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
|
||||
return {
|
||||
on(event: string, callback: () => void) {
|
||||
handlers[event]?.push(callback)
|
||||
if ((live && event === 'connect') || (!live && event === 'error')) {
|
||||
queueMicrotask(() => callback())
|
||||
}
|
||||
return this
|
||||
},
|
||||
removeListener(event: string, callback: () => void) {
|
||||
handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
|
||||
return this
|
||||
},
|
||||
destroy() {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
beforeEach(() => {
|
||||
probeSocketExistsMock.mockReturnValue(false)
|
||||
|
|
@ -575,23 +596,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
|||
it('disconnects uninstalled adapter leases when startup aborts during legacy discovery', async () => {
|
||||
const mod = await importFresh()
|
||||
probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false)
|
||||
netConnectMock.mockImplementation(() => {
|
||||
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
|
||||
return {
|
||||
on(event: string, cb: () => void) {
|
||||
handlers[event]?.push(cb)
|
||||
if (event === 'connect') {
|
||||
queueMicrotask(() => cb())
|
||||
}
|
||||
return this
|
||||
},
|
||||
removeListener(event: string, cb: () => void) {
|
||||
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
||||
return this
|
||||
},
|
||||
destroy() {}
|
||||
}
|
||||
})
|
||||
mockOnlyDaemonSocketAlive('daemon-v9.sock')
|
||||
let resolveDiscovery!: (sessions: { sessionId: string }[]) => void
|
||||
const discovery = new Promise<{ sessionId: string }[]>((resolve) => {
|
||||
resolveDiscovery = resolve
|
||||
|
|
@ -622,23 +627,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
|||
probeSocketExistsMock.mockImplementation(
|
||||
(path?: string) => path?.endsWith('daemon-v9.sock') ?? false
|
||||
)
|
||||
netConnectMock.mockImplementation(() => {
|
||||
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
|
||||
return {
|
||||
on(event: string, callback: () => void) {
|
||||
handlers[event]?.push(callback)
|
||||
if (event === 'connect') {
|
||||
queueMicrotask(() => callback())
|
||||
}
|
||||
return this
|
||||
},
|
||||
removeListener(event: string, callback: () => void) {
|
||||
handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
|
||||
return this
|
||||
},
|
||||
destroy() {}
|
||||
}
|
||||
})
|
||||
mockOnlyDaemonSocketAlive('daemon-v9.sock')
|
||||
const discoveryError = new Error('router subscription failed')
|
||||
const currentCleanupError = new Error('current cleanup failed')
|
||||
const legacyCleanupError = new Error('legacy cleanup failed')
|
||||
|
|
@ -881,23 +870,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
|||
it('routes affected v9 daemon sessions through a legacy adapter on launch', async () => {
|
||||
const mod = await importFresh()
|
||||
probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false)
|
||||
netConnectMock.mockImplementation(() => {
|
||||
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
|
||||
return {
|
||||
on(event: string, cb: () => void) {
|
||||
handlers[event]?.push(cb)
|
||||
if (event === 'connect') {
|
||||
queueMicrotask(() => cb())
|
||||
}
|
||||
return this
|
||||
},
|
||||
removeListener(event: string, cb: () => void) {
|
||||
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
||||
return this
|
||||
},
|
||||
destroy() {}
|
||||
}
|
||||
})
|
||||
mockOnlyDaemonSocketAlive('daemon-v9.sock')
|
||||
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
|
|
|
|||
|
|
@ -1020,7 +1020,11 @@ function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: numb
|
|||
}
|
||||
}
|
||||
|
||||
async function createLegacyDaemonAdapters(runtimeDir: string): Promise<DaemonPtyAdapter[]> {
|
||||
// Why: callers that own an isolated runtime namespace must keep discovery history out of app userData.
|
||||
export async function createLegacyDaemonAdapters(
|
||||
runtimeDir: string,
|
||||
historyPath = getHistoryDir()
|
||||
): Promise<DaemonPtyAdapter[]> {
|
||||
const adapters: DaemonPtyAdapter[] = []
|
||||
for (const protocolVersion of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) {
|
||||
const socketPath = getDaemonSocketPath(runtimeDir, protocolVersion)
|
||||
|
|
@ -1055,7 +1059,7 @@ async function createLegacyDaemonAdapters(runtimeDir: string): Promise<DaemonPty
|
|||
socketPath,
|
||||
tokenPath,
|
||||
protocolVersion,
|
||||
historyPath: getHistoryDir()
|
||||
historyPath
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22122,6 +22122,534 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined()
|
||||
})
|
||||
|
||||
describe('close intent adjudication', () => {
|
||||
// Shared setup: a renderer-adopted tab whose PTY the host sees alive.
|
||||
function makeAdoptedLiveTabRuntime(): {
|
||||
runtime: OrcaRuntimeService
|
||||
getSession: () => WorkspaceSessionState
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
closeTerminal: ReturnType<typeof vi.fn>
|
||||
closeTerminalTab: ReturnType<typeof vi.fn>
|
||||
listProcesses: ReturnType<typeof vi.fn>
|
||||
processes: { id: string; cwd: string; title: string }[]
|
||||
} {
|
||||
const servePtyId = 'serve-live-1'
|
||||
const processes = [{ id: servePtyId, cwd: TEST_WORKTREE_PATH, title: 'Live' }]
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
id: 'host-tab',
|
||||
ptyId: servePtyId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Live Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: servePtyId })
|
||||
}
|
||||
})
|
||||
)
|
||||
const kill = vi.fn(() => true)
|
||||
const closeTerminal = vi.fn()
|
||||
const closeTerminalTab = vi.fn(async () => {})
|
||||
const listProcesses = vi.fn(async () => processes)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses
|
||||
})
|
||||
runtime.setNotifier({ closeTerminal, closeTerminalTab } as never)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'host-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Live Terminal',
|
||||
activeLeafId: HEADLESS_LEAF_ID,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'host-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: servePtyId
|
||||
}
|
||||
]
|
||||
})
|
||||
return {
|
||||
runtime,
|
||||
getSession,
|
||||
kill,
|
||||
closeTerminal,
|
||||
closeTerminalTab,
|
||||
listProcesses,
|
||||
processes
|
||||
}
|
||||
}
|
||||
|
||||
it.each(['pty-exit', 'cleanup'] as const)(
|
||||
'refuses a %s echoed close while the PTY is live and republishes the snapshot',
|
||||
async (reason) => {
|
||||
const { runtime, getSession, kill, closeTerminal, closeTerminalTab } =
|
||||
makeAdoptedLiveTabRuntime()
|
||||
const before = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const events: { worktree: string; snapshotVersion: number; tabs: unknown[] }[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
const result = await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
|
||||
reason
|
||||
})
|
||||
|
||||
unsubscribe()
|
||||
// No destructive branch may run: no PTY kill, no renderer close relay.
|
||||
expect(result).toEqual({
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'live-host-pty',
|
||||
snapshotRepublished: true
|
||||
})
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
expect(closeTerminalTab).not.toHaveBeenCalled()
|
||||
expect(closeTerminal).not.toHaveBeenCalled()
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1)
|
||||
// The snapshot is republished (version bumped) so the echoing client
|
||||
// re-adds and re-attaches the still-live tab.
|
||||
const republished = events.filter((event) => event.worktree === TEST_WORKTREE_ID)
|
||||
expect(republished.length).toBeGreaterThan(0)
|
||||
const last = republished.at(-1)!
|
||||
expect(last.snapshotVersion).toBeGreaterThan(before.snapshotVersion)
|
||||
expect(
|
||||
last.tabs.some((tab) => (tab as { parentTabId?: string }).parentTabId === 'host-tab')
|
||||
).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('coalesces a reconnect close burst onto one authoritative PTY inventory', async () => {
|
||||
const { runtime, listProcesses, processes } = makeAdoptedLiveTabRuntime()
|
||||
const inventory = deferred<typeof processes>()
|
||||
listProcesses.mockImplementation(() => inventory.promise)
|
||||
|
||||
const closes = Promise.all([
|
||||
runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
|
||||
reason: 'pty-exit'
|
||||
}),
|
||||
runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
|
||||
reason: 'cleanup'
|
||||
})
|
||||
])
|
||||
await vi.waitFor(() => expect(listProcesses).toHaveBeenCalledTimes(1))
|
||||
inventory.resolve(processes)
|
||||
|
||||
await expect(closes).resolves.toEqual([
|
||||
expect.objectContaining({ refused: true, refusalReason: 'live-host-pty' }),
|
||||
expect.objectContaining({ refused: true, refusalReason: 'live-host-pty' })
|
||||
])
|
||||
expect(listProcesses).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps an explicit user close destructive while the PTY is live', async () => {
|
||||
const { runtime, closeTerminalTab } = makeAdoptedLiveTabRuntime()
|
||||
|
||||
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
|
||||
reason: 'user'
|
||||
})
|
||||
|
||||
// Legacy whole-parent relay: the renderer close transaction still runs.
|
||||
expect(closeTerminalTab).toHaveBeenCalledWith('host-tab')
|
||||
})
|
||||
|
||||
it('keeps a reasonless legacy close and republishes its live mirror', async () => {
|
||||
const { runtime, kill, closeTerminal, closeTerminalTab } = makeAdoptedLiveTabRuntime()
|
||||
const before = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
const result = await runtime.refuseUnattributedMobileSessionTabClose(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
'host-tab'
|
||||
)
|
||||
const after = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(result).toEqual({
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'missing-intent',
|
||||
snapshotRepublished: true
|
||||
})
|
||||
expect(after.snapshotVersion).toBeGreaterThan(before.snapshotVersion)
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
expect(closeTerminalTab).not.toHaveBeenCalled()
|
||||
expect(closeTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a lifecycle close from a stale host publication', async () => {
|
||||
const { runtime, kill, closeTerminal, closeTerminalTab } = makeAdoptedLiveTabRuntime()
|
||||
const current = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const terminal = current.tabs.find((tab) => tab.type === 'terminal')
|
||||
if (!terminal || terminal.status !== 'ready') {
|
||||
throw new Error('expected a ready terminal fixture')
|
||||
}
|
||||
|
||||
const result = await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
|
||||
reason: 'pty-exit',
|
||||
expectedPublicationEpoch: 'stale-epoch',
|
||||
expectedTerminalHandle: terminal.terminal
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'stale-publication',
|
||||
snapshotRepublished: true
|
||||
})
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
expect(closeTerminalTab).not.toHaveBeenCalled()
|
||||
expect(closeTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a reused tab id that names a different terminal incarnation', async () => {
|
||||
const { runtime, kill, closeTerminal, closeTerminalTab } = makeAdoptedLiveTabRuntime()
|
||||
const current = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
const result = await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
|
||||
reason: 'pty-exit',
|
||||
expectedPublicationEpoch: current.publicationEpoch,
|
||||
expectedTerminalHandle: 'term-from-retired-incarnation'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'stale-terminal',
|
||||
snapshotRepublished: true
|
||||
})
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
expect(closeTerminalTab).not.toHaveBeenCalled()
|
||||
expect(closeTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves dead renderer-owned retirement to the renderer without relaying a close', async () => {
|
||||
const { runtime, processes, kill, closeTerminal, closeTerminalTab } =
|
||||
makeAdoptedLiveTabRuntime()
|
||||
const current = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const terminal = current.tabs.find((tab) => tab.type === 'terminal')
|
||||
if (!terminal || terminal.status !== 'ready') {
|
||||
throw new Error('expected a ready terminal fixture')
|
||||
}
|
||||
runtime.onPtyExit('serve-live-1', 0)
|
||||
processes.length = 0
|
||||
|
||||
const result = await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
|
||||
reason: 'pty-exit',
|
||||
expectedPublicationEpoch: current.publicationEpoch,
|
||||
expectedTerminalHandle: terminal.terminal
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'retirement-owner'
|
||||
})
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
expect(closeTerminalTab).not.toHaveBeenCalled()
|
||||
expect(closeTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
function makeSplitLeafRuntime(): {
|
||||
runtime: OrcaRuntimeService
|
||||
getSession: () => WorkspaceSessionState
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
closeTerminal: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const layout = makeHeadlessTerminalLayout({
|
||||
[HEADLESS_LEAF_ID]: 'serve-left',
|
||||
[HEADLESS_SECOND_LEAF_ID]: 'serve-right'
|
||||
})
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
id: 'host-tab',
|
||||
ptyId: 'serve-left',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Split Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: { 'host-tab': layout }
|
||||
})
|
||||
)
|
||||
const kill = vi.fn(() => true)
|
||||
const closeTerminal = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => []
|
||||
})
|
||||
runtime.setNotifier({ closeTerminal } as never)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'host-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Split Terminal',
|
||||
activeLeafId: HEADLESS_LEAF_ID,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'host-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'serve-left',
|
||||
paneTitle: 'L'
|
||||
},
|
||||
{
|
||||
tabId: 'host-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: HEADLESS_SECOND_LEAF_ID,
|
||||
paneRuntimeId: 2,
|
||||
ptyId: 'serve-right',
|
||||
paneTitle: 'R'
|
||||
}
|
||||
]
|
||||
})
|
||||
return { runtime, getSession, kill, closeTerminal }
|
||||
}
|
||||
|
||||
it('refuses a pty-exit echoed close of a live split leaf (direct-kill branch)', async () => {
|
||||
const { runtime, getSession, kill, closeTerminal } = makeSplitLeafRuntime()
|
||||
|
||||
await runtime.closeMobileSessionTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
`host-tab::${HEADLESS_SECOND_LEAF_ID}`,
|
||||
{ reason: 'pty-exit' }
|
||||
)
|
||||
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
expect(closeTerminal).not.toHaveBeenCalled()
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1)
|
||||
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined()
|
||||
})
|
||||
|
||||
it('still kills a live split leaf for an explicit user close', async () => {
|
||||
const { runtime, kill } = makeSplitLeafRuntime()
|
||||
|
||||
await runtime.closeMobileSessionTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
`host-tab::${HEADLESS_SECOND_LEAF_ID}`,
|
||||
{ reason: 'user' }
|
||||
)
|
||||
|
||||
expect(kill).toHaveBeenCalledWith('serve-right')
|
||||
expect(kill).not.toHaveBeenCalledWith('serve-left')
|
||||
})
|
||||
|
||||
it('refuses without republishing when the echoed leaf is dead but a sibling is live', async () => {
|
||||
// Why: the only reachable close path for a single leaf destroys the whole
|
||||
// parent (live sibling included), so the close must be refused — but a
|
||||
// republish would re-add the dead leaf on the echoing client and feed a
|
||||
// refuse→republish→re-echo loop.
|
||||
const { runtime, getSession, kill, closeTerminal } = makeSplitLeafRuntime()
|
||||
runtime.onPtyExit('serve-right', 0)
|
||||
const events: { worktree: string }[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
const result = await runtime.closeMobileSessionTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
`host-tab::${HEADLESS_SECOND_LEAF_ID}`,
|
||||
{ reason: 'pty-exit' }
|
||||
)
|
||||
|
||||
unsubscribe()
|
||||
expect(result).toEqual({
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'live-host-pty'
|
||||
})
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
expect(closeTerminal).not.toHaveBeenCalled()
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1)
|
||||
expect(events.filter((event) => event.worktree === TEST_WORKTREE_ID)).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses a pty-exit echoed close of a runtime-owned headless tab with a live PTY', async () => {
|
||||
// Why: the headless close path kills every leaf PTY and de-persists the
|
||||
// parent; an echo must not reach it while the host sees the PTY alive.
|
||||
const servePtyId = 'serve-headless-live'
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
id: 'host-tab',
|
||||
ptyId: servePtyId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Serve Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: servePtyId })
|
||||
}
|
||||
})
|
||||
)
|
||||
const kill = vi.fn(() => true)
|
||||
const closeTerminal = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => []
|
||||
})
|
||||
runtime.setNotifier({ closeTerminal } as never)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'host-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Serve Terminal',
|
||||
activeLeafId: HEADLESS_LEAF_ID,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'host-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: servePtyId,
|
||||
paneTitle: 'A'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
|
||||
reason: 'pty-exit'
|
||||
})
|
||||
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
expect(closeTerminal).not.toHaveBeenCalled()
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1)
|
||||
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined()
|
||||
})
|
||||
|
||||
it('retires a dead headless tab on a pty-exit echoed close', async () => {
|
||||
// Why: headless hosts have no renderer pty-exit handling of their own;
|
||||
// they rely on the client echo to retire genuinely dead tab records.
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal()
|
||||
)
|
||||
const kill = vi.fn(() => true)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => []
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
|
||||
reason: 'pty-exit'
|
||||
})
|
||||
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
|
||||
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retires a dead headless tab whose exited PTY still has a retained record', async () => {
|
||||
// Why: onPtyExit keeps the disconnected record in ptysById for status and
|
||||
// exit reads — the production state after a real exit. The gate must not
|
||||
// read record presence as liveness or the dead tab never retires and the
|
||||
// client echo loops.
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal()
|
||||
)
|
||||
const processes: { id: string; cwd: string; title: string }[] = [
|
||||
{ id: 'persisted-pty', cwd: TEST_WORKTREE_PATH, title: 'Shell' }
|
||||
]
|
||||
const kill = vi.fn(() => true)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => processes
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
// Seed the connected PTY record from the controller listing, then let the
|
||||
// process die: the record flips to disconnected but stays retained.
|
||||
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
runtime.onPtyExit('persisted-pty', 0)
|
||||
processes.length = 0
|
||||
|
||||
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
|
||||
reason: 'pty-exit'
|
||||
})
|
||||
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
|
||||
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a headless tab when the provider inventory is unavailable', async () => {
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal()
|
||||
)
|
||||
const kill = vi.fn(() => true)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => {
|
||||
throw new Error('access denied')
|
||||
}
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
const result = await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
|
||||
reason: 'pty-exit'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'unknown-liveness',
|
||||
snapshotRepublished: true
|
||||
})
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1)
|
||||
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined()
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('builds mobile session agent launch commands on the runtime host', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' })
|
||||
const runtime = new OrcaRuntimeService({
|
||||
|
|
|
|||
|
|
@ -356,6 +356,7 @@ import type {
|
|||
RuntimeMarkdownSaveTabResult,
|
||||
RuntimeMobileSessionCreateTerminalResult,
|
||||
RuntimeMobileSessionClientTab,
|
||||
RuntimeMobileSessionTabCloseResult,
|
||||
RuntimeMobileSessionMarkdownTab,
|
||||
RuntimeMobileSessionTabMove,
|
||||
RuntimeMobileSessionTabMoveResult,
|
||||
|
|
@ -366,6 +367,7 @@ import type {
|
|||
RuntimeMobileSessionTabsRemovedResult,
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeMobileSessionTabsSnapshot,
|
||||
RuntimeSessionTabCloseReason,
|
||||
RuntimeBrowserDriverState,
|
||||
RuntimeTerminalDriverState,
|
||||
RuntimeSyncWindowGraph,
|
||||
|
|
@ -2327,6 +2329,7 @@ export class OrcaRuntimeService {
|
|||
createMobileSessionTabsNotifyCoalescer((worktreeId) =>
|
||||
this.notifyMobileSessionTabsChangedNow(worktreeId)
|
||||
)
|
||||
private pendingMobileSessionPtyInventoryRefresh: Promise<Set<string> | null> | null = null
|
||||
private leaves = new Map<string, RuntimeLeafRecord>()
|
||||
// Why: PTY output is a per-keystroke hot path. Looking up affected leaves by
|
||||
// ptyId keeps active TUI redraws independent of the total open terminal count.
|
||||
|
|
@ -4919,14 +4922,35 @@ export class OrcaRuntimeService {
|
|||
|
||||
private async refreshMobileSessionPtyRecords(
|
||||
targetWorktreeId: string | null = null
|
||||
): Promise<void> {
|
||||
): Promise<Set<string> | null> {
|
||||
if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
const pending = this.pendingMobileSessionPtyInventoryRefresh
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
// Why: reconnect exit bursts share one authoritative daemon inventory
|
||||
// instead of multiplying a full cross-generation list RPC per stale tab.
|
||||
const refresh = this.performMobileSessionPtyRecordsRefresh(targetWorktreeId).finally(() => {
|
||||
if (this.pendingMobileSessionPtyInventoryRefresh === refresh) {
|
||||
this.pendingMobileSessionPtyInventoryRefresh = null
|
||||
}
|
||||
})
|
||||
this.pendingMobileSessionPtyInventoryRefresh = refresh
|
||||
return refresh
|
||||
}
|
||||
return await this.performMobileSessionPtyRecordsRefresh(targetWorktreeId)
|
||||
}
|
||||
|
||||
private async performMobileSessionPtyRecordsRefresh(
|
||||
targetWorktreeId: string | null
|
||||
): Promise<Set<string> | null> {
|
||||
if (!this.ptyController?.listProcesses && !this.ptyController?.hasPty) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
// Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree.
|
||||
const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID
|
||||
const resolvedWorktrees = isFloatingWorkspace ? [] : await this.listResolvedWorktrees()
|
||||
await this.refreshPtyWorktreeRecordsFromController(
|
||||
return await this.refreshPtyWorktreeRecordsFromController(
|
||||
resolvedWorktrees,
|
||||
isFloatingWorkspace ? targetWorktreeId : null
|
||||
)
|
||||
|
|
@ -5244,13 +5268,68 @@ export class OrcaRuntimeService {
|
|||
})
|
||||
}
|
||||
|
||||
async closeMobileSessionTab(worktreeSelector: string, tabId: string): Promise<{ closed: true }> {
|
||||
async refuseUnattributedMobileSessionTabClose(
|
||||
worktreeSelector: string,
|
||||
tabId: string
|
||||
): Promise<RuntimeMobileSessionTabCloseResult> {
|
||||
const snapshot = await this.listMobileSessionTabs(worktreeSelector)
|
||||
const tabExists = snapshot.tabs.some(
|
||||
(candidate) =>
|
||||
candidate.id === tabId ||
|
||||
(candidate.type === 'terminal' && candidate.parentTabId === tabId) ||
|
||||
(candidate.type === 'browser' && candidate.browserWorkspaceId === tabId)
|
||||
)
|
||||
if (!tabExists) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
// Why: a legacy client may already have hidden its mirror; a new snapshot
|
||||
// restores it without granting an unattributed request destructive authority.
|
||||
this.republishMobileSessionTabsSnapshot(snapshot.worktree)
|
||||
return {
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'missing-intent',
|
||||
snapshotRepublished: true
|
||||
}
|
||||
}
|
||||
|
||||
async closeMobileSessionTab(
|
||||
worktreeSelector: string,
|
||||
tabId: string,
|
||||
options: {
|
||||
reason?: RuntimeSessionTabCloseReason
|
||||
expectedPublicationEpoch?: string
|
||||
expectedTerminalHandle?: string
|
||||
} = {}
|
||||
): Promise<RuntimeMobileSessionTabCloseResult> {
|
||||
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
|
||||
const worktreeId =
|
||||
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
|
||||
await this.refreshMobileSessionPtyRecords()
|
||||
const observedPtyIds = await this.refreshMobileSessionPtyRecords()
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
if (options.reason !== undefined && options.reason !== 'user' && observedPtyIds === null) {
|
||||
// Why: keep-on-unknown must also restore the mirror the caller already pruned.
|
||||
this.republishMobileSessionTabsSnapshot(worktreeId)
|
||||
return {
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'unknown-liveness',
|
||||
...(snapshot ? { snapshotRepublished: true as const } : {})
|
||||
}
|
||||
}
|
||||
if (
|
||||
options.expectedPublicationEpoch !== undefined &&
|
||||
snapshot?.publicationEpoch !== options.expectedPublicationEpoch
|
||||
) {
|
||||
this.republishMobileSessionTabsSnapshot(worktreeId)
|
||||
return {
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'stale-publication',
|
||||
...(snapshot ? { snapshotRepublished: true as const } : {})
|
||||
}
|
||||
}
|
||||
const tab =
|
||||
snapshot?.tabs.find((candidate) => candidate.id === tabId) ??
|
||||
snapshot?.tabs.find(
|
||||
|
|
@ -5262,18 +5341,86 @@ export class OrcaRuntimeService {
|
|||
if (!tab) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
if (options.expectedTerminalHandle !== undefined) {
|
||||
const terminalIncarnationMatches =
|
||||
tab.type === 'terminal' &&
|
||||
snapshot!.tabs.some(
|
||||
(candidate) =>
|
||||
candidate.type === 'terminal' &&
|
||||
candidate.parentTabId === tab.parentTabId &&
|
||||
this.getMobileSessionTerminalHandle(worktreeId, candidate) ===
|
||||
options.expectedTerminalHandle
|
||||
)
|
||||
if (!terminalIncarnationMatches) {
|
||||
this.republishMobileSessionTabsSnapshot(worktreeId)
|
||||
return {
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'stale-terminal',
|
||||
snapshotRepublished: true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tab.type === 'terminal') {
|
||||
const parentLeafCount = snapshot!.tabs.filter(
|
||||
(candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
|
||||
).length
|
||||
const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1
|
||||
// Why: a non-'user' reason is a client-lifecycle echo ("terminal gone"),
|
||||
// not authorization to kill. Every destructive branch below can take the
|
||||
// whole parent down, so any live PTY under the parent means the echo is a
|
||||
// transport artifact: refuse the close and republish the snapshot so the
|
||||
// echoing client re-syncs and re-attaches. A reasonless close keeps
|
||||
// legacy behavior — old clients send user closes without the field.
|
||||
if (options.reason !== undefined && options.reason !== 'user') {
|
||||
const parentLeaves = snapshot!.tabs.filter(
|
||||
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
|
||||
candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
|
||||
)
|
||||
// Why: exited PTYs keep a disconnected record in ptysById for status
|
||||
// reads (and a still-synced leaf retains its record), so record
|
||||
// presence is not liveness — only `connected` counts, or a genuinely
|
||||
// dead tab never retires and the echo loops forever.
|
||||
const leafHasConnectedPty = (leaf: RuntimeMobileSessionTerminalTab): boolean =>
|
||||
this.findPtyForMobileTerminalTab(worktreeId, leaf)?.connected === true
|
||||
if (parentLeaves.some(leafHasConnectedPty)) {
|
||||
// Why: when the echo addresses a dead leaf under a live sibling we
|
||||
// still refuse (every reachable close path below destroys the whole
|
||||
// parent, live sibling included) but skip the republish — re-adding
|
||||
// the dead leaf on the echoing client would feed an endless
|
||||
// refuse→republish→re-echo cycle.
|
||||
const addressedDeadLeaf = tab.id === tabId && !leafHasConnectedPty(tab)
|
||||
if (!addressedDeadLeaf) {
|
||||
this.republishMobileSessionTabsSnapshot(worktreeId)
|
||||
}
|
||||
// Why: both markers are skew-safe; clients must restore a mirror only
|
||||
// when the host actually republished it, not for a dead leaf.
|
||||
return {
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'live-host-pty',
|
||||
...(!addressedDeadLeaf ? { snapshotRepublished: true as const } : {})
|
||||
}
|
||||
}
|
||||
if (!closingWholeParent || this.tabs.has(tab.parentTabId)) {
|
||||
// Why: only the renderer may retire its own tab or split leaf; a
|
||||
// remote lifecycle echo must never cross that boundary into a kill.
|
||||
return {
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'retirement-owner'
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: a runtime-owned headless tab is absent from renderer state, so the
|
||||
// closeTerminalTab relay below would ack success without killing its PTY,
|
||||
// and syncMobileSessionTabs would republish the "closed" tab. Only bypass
|
||||
// the relay when no renderer owns the parent: an adopted tab needs the
|
||||
// renderer's live pin guard and durable close transaction.
|
||||
if (closingWholeParent && !this.tabs.has(tab.parentTabId)) {
|
||||
this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab)
|
||||
this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab, {
|
||||
killPtys: options.reason === undefined || options.reason === 'user'
|
||||
})
|
||||
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
|
||||
this.store?.flushOrThrow?.()
|
||||
return { closed: true }
|
||||
|
|
@ -5321,6 +5468,31 @@ export class OrcaRuntimeService {
|
|||
return { closed: true }
|
||||
}
|
||||
|
||||
// Why: a refused echoed close means the echoing client already pruned its
|
||||
// local mirror. Bump the version and emit the unchanged snapshot so clients
|
||||
// that dedupe by snapshotVersion re-add and re-attach the still-live tab.
|
||||
private republishMobileSessionTabsSnapshot(worktreeId: string): void {
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
if (snapshot) {
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, {
|
||||
...snapshot,
|
||||
snapshotVersion: snapshot.snapshotVersion + 1
|
||||
})
|
||||
}
|
||||
this.notifyMobileSessionTabsChanged(worktreeId)
|
||||
}
|
||||
|
||||
private getMobileSessionTerminalHandle(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): string | null {
|
||||
const pty = this.findPtyForMobileTerminalTab(worktreeId, tab)
|
||||
if (!pty) {
|
||||
return null
|
||||
}
|
||||
return this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId)
|
||||
}
|
||||
|
||||
private notifyRendererOfHeadlessTerminalClose(parentTabId: string): void {
|
||||
// Why: this relay is advisory after main owns teardown; renderer failure must
|
||||
// not prevent the authoritative session flush or turn the close into failure.
|
||||
|
|
@ -5424,7 +5596,8 @@ export class OrcaRuntimeService {
|
|||
private closeHeadlessMobileTerminalTab(
|
||||
worktreeId: string,
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
tab: RuntimeMobileSessionTerminalTab,
|
||||
options: { killPtys?: boolean } = {}
|
||||
): void {
|
||||
const closedParentTabId = tab.parentTabId
|
||||
const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId)
|
||||
|
|
@ -5451,8 +5624,10 @@ export class OrcaRuntimeService {
|
|||
ptyIdsToKill.add(ptyId)
|
||||
}
|
||||
}
|
||||
for (const ptyId of ptyIdsToKill) {
|
||||
this.ptyController?.kill(ptyId)
|
||||
if (options.killPtys !== false) {
|
||||
for (const ptyId of ptyIdsToKill) {
|
||||
this.ptyController?.kill(ptyId)
|
||||
}
|
||||
}
|
||||
const nextTabs = snapshot.tabs.filter((candidate) => {
|
||||
if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) {
|
||||
|
|
@ -20846,13 +21021,15 @@ export class OrcaRuntimeService {
|
|||
if (!tabId) {
|
||||
throw new Error('terminal_tab_not_found')
|
||||
}
|
||||
await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId)
|
||||
// Why: a handle-addressed CLI/automation close is an explicit intent, so
|
||||
// it must stay destructive under the non-user close adjudication gate.
|
||||
await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { reason: 'user' })
|
||||
this.claudeAgentTeams.removeTeamForLeaderHandle(handle)
|
||||
return { handle, tabId, closeMode: 'tab', ptyKilled: false }
|
||||
}
|
||||
this.assertGraphReady()
|
||||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||||
await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId)
|
||||
await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId, { reason: 'user' })
|
||||
this.claudeAgentTeams.removeTeamForLeaderHandle(handle)
|
||||
return { handle, tabId: leaf.tabId, closeMode: 'tab', ptyKilled: false }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { withSpan } from '../../../observability/tracer'
|
||||
import { defineMethod, type RpcAnyMethod } from '../core'
|
||||
import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas'
|
||||
|
||||
export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'session.tabs.close',
|
||||
params: CloseTab,
|
||||
handler: async (params, context) =>
|
||||
withSpan(
|
||||
'runtime.session-tabs.close',
|
||||
async (span) => {
|
||||
if (!params.reason && context.clientKind !== 'mobile') {
|
||||
const result = await context.runtime.refuseUnattributedMobileSessionTabClose(
|
||||
params.worktree,
|
||||
params.tabId
|
||||
)
|
||||
span.setAttribute('decision', `refused-${result.refusalReason ?? 'missing-intent'}`)
|
||||
return result
|
||||
}
|
||||
const result = await context.runtime.closeMobileSessionTab(
|
||||
params.worktree,
|
||||
params.tabId,
|
||||
{ reason: 'user' }
|
||||
)
|
||||
span.setAttribute(
|
||||
'decision',
|
||||
result.refused ? `refused-${result.refusalReason ?? 'unknown'}` : 'allowed'
|
||||
)
|
||||
return result
|
||||
},
|
||||
{
|
||||
kind: 'client',
|
||||
attributes: {
|
||||
attribution: 'session-tab-close',
|
||||
origin: context.clientKind ?? 'in-process',
|
||||
closeReason:
|
||||
params.reason ?? (context.clientKind === 'mobile' ? 'legacy-mobile-user' : 'missing'),
|
||||
connectionGeneration: context.connectionId ?? 'in-process',
|
||||
requestId: context.requestId ?? 'in-process'
|
||||
}
|
||||
}
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'session.tabs.closeLifecycle',
|
||||
params: CloseLifecycleTab,
|
||||
handler: async (params, context) =>
|
||||
withSpan(
|
||||
'runtime.session-tabs.close-lifecycle',
|
||||
async (span) => {
|
||||
const result = await context.runtime.closeMobileSessionTab(
|
||||
params.worktree,
|
||||
params.tabId,
|
||||
{
|
||||
reason: params.reason,
|
||||
expectedPublicationEpoch: params.publicationEpoch,
|
||||
expectedTerminalHandle: params.terminal
|
||||
}
|
||||
)
|
||||
span.setAttribute(
|
||||
'decision',
|
||||
result.refused ? `refused-${result.refusalReason ?? 'unknown'}` : 'allowed'
|
||||
)
|
||||
return result
|
||||
},
|
||||
{
|
||||
kind: 'client',
|
||||
attributes: {
|
||||
attribution: 'session-tab-lifecycle-close',
|
||||
origin: context.clientKind ?? 'in-process',
|
||||
closeReason: params.reason,
|
||||
connectionGeneration: context.connectionId ?? 'in-process',
|
||||
requestId: context.requestId ?? 'in-process',
|
||||
publicationEpoch: params.publicationEpoch
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
]
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { ActivateTab, UpdatePaneLayout } from './session-tabs-schemas'
|
||||
import { ActivateTab, CloseLifecycleTab, CloseTab, UpdatePaneLayout } from './session-tabs-schemas'
|
||||
|
||||
const WT = 'id:wt'
|
||||
|
||||
|
|
@ -14,6 +14,65 @@ describe('ActivateTab.navigation', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('CloseTab (session.tabs.close params)', () => {
|
||||
it('accepts only explicit user intent on the legacy close method', () => {
|
||||
const parsed = CloseTab.parse({ worktree: WT, tabId: 'tab-1', reason: 'user' })
|
||||
expect(parsed).toMatchObject({ tabId: 'tab-1', reason: 'user' })
|
||||
expect(CloseTab.safeParse({ worktree: WT, tabId: 'tab-1', reason: 'pty-exit' }).success).toBe(
|
||||
false
|
||||
)
|
||||
expect(CloseTab.safeParse({ worktree: WT, tabId: 'tab-1', reason: 'cleanup' }).success).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a reasonless payload from legacy clients', () => {
|
||||
// Why: parsing remains compatible; the RPC policy, not the schema, refuses missing intent.
|
||||
const parsed = CloseTab.parse({ worktree: WT, tabId: 'tab-1' })
|
||||
expect(parsed.tabId).toBe('tab-1')
|
||||
expect(parsed.reason).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a new explicit-user payload parseable by the previous server schema', () => {
|
||||
// Why: old hosts use ActivateTab here and must strip the additive field,
|
||||
// not reject a manual close from an updated client.
|
||||
const parsed = ActivateTab.parse({ worktree: WT, tabId: 'tab-1', reason: 'user' })
|
||||
expect(parsed).toEqual({ worktree: WT, tabId: 'tab-1' })
|
||||
})
|
||||
|
||||
it('rejects an unknown close reason', () => {
|
||||
expect(() =>
|
||||
CloseTab.parse({ worktree: WT, tabId: 'tab-1', reason: 'transport-glitch' })
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CloseLifecycleTab (session.tabs.closeLifecycle params)', () => {
|
||||
it('requires lifecycle intent and incarnation evidence', () => {
|
||||
expect(
|
||||
CloseLifecycleTab.parse({
|
||||
worktree: WT,
|
||||
tabId: 'tab-1',
|
||||
reason: 'pty-exit',
|
||||
publicationEpoch: 'epoch-1',
|
||||
terminal: 'term-1'
|
||||
})
|
||||
).toMatchObject({ reason: 'pty-exit', publicationEpoch: 'epoch-1', terminal: 'term-1' })
|
||||
expect(
|
||||
CloseLifecycleTab.safeParse({ worktree: WT, tabId: 'tab-1', reason: 'pty-exit' }).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
CloseLifecycleTab.safeParse({
|
||||
worktree: WT,
|
||||
tabId: 'tab-1',
|
||||
reason: 'user',
|
||||
publicationEpoch: 'epoch-1',
|
||||
terminal: 'term-1'
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UpdatePaneLayout.root (untrusted remote pane-layout tree)', () => {
|
||||
it('accepts a valid split tree', () => {
|
||||
const parsed = UpdatePaneLayout.parse({
|
||||
|
|
|
|||
|
|
@ -27,6 +27,18 @@ export const ActivateTab = WorktreeTabSelector.extend({
|
|||
navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional()
|
||||
})
|
||||
|
||||
export const CloseTab = ActivateTab.extend({
|
||||
// Why: optional so legacy clients still validate; policy conservatively
|
||||
// attributes only authenticated mobile callers as user intent.
|
||||
reason: z.literal('user').optional()
|
||||
})
|
||||
|
||||
export const CloseLifecycleTab = ActivateTab.extend({
|
||||
reason: z.enum(['pty-exit', 'cleanup']),
|
||||
publicationEpoch: z.string().min(1).max(128),
|
||||
terminal: z.string().min(1).max(256)
|
||||
})
|
||||
|
||||
export type TerminalPaneLayoutNodeInput =
|
||||
| { type: 'leaf'; leafId: string }
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,125 @@ describe('session tab RPC methods', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('refuses a reasonless close without invoking destructive runtime logic', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
refuseUnattributedMobileSessionTabClose: vi.fn().mockResolvedValue({
|
||||
closed: true,
|
||||
refused: true,
|
||||
snapshotRepublished: true
|
||||
}),
|
||||
closeMobileSessionTab: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('session.tabs.close', { worktree: 'id:wt-1', tabId: 'tab-1' })
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { closed: true, refused: true, snapshotRepublished: true }
|
||||
})
|
||||
expect(runtime.refuseUnattributedMobileSessionTabClose).toHaveBeenCalledWith('id:wt-1', 'tab-1')
|
||||
expect(runtime.closeMobileSessionTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes explicit user intent to host adjudication', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
refuseUnattributedMobileSessionTabClose: vi.fn(),
|
||||
closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('session.tabs.close', {
|
||||
worktree: 'id:wt-1',
|
||||
tabId: 'tab-1',
|
||||
reason: 'user'
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', {
|
||||
reason: 'user'
|
||||
})
|
||||
expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves reasonless explicit closes from authenticated legacy mobile clients', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
refuseUnattributedMobileSessionTabClose: vi.fn(),
|
||||
closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
const replies: string[] = []
|
||||
|
||||
await dispatcher.dispatchStreaming(
|
||||
makeRequest('session.tabs.close', { worktree: 'id:wt-1', tabId: 'tab-1' }),
|
||||
(response) => replies.push(response),
|
||||
{ clientKind: 'mobile' }
|
||||
)
|
||||
|
||||
expect(replies).toHaveLength(1)
|
||||
expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', {
|
||||
reason: 'user'
|
||||
})
|
||||
expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['pty-exit', 'cleanup'] as const)(
|
||||
'rejects %s on the legacy close endpoint before host adjudication',
|
||||
async (reason) => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
closeMobileSessionTab: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('session.tabs.close', {
|
||||
worktree: 'id:wt-1',
|
||||
tabId: 'tab-1',
|
||||
reason
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
expect(runtime.closeMobileSessionTab).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['pty-exit', 'cleanup'] as const)(
|
||||
'binds a %s lifecycle close to the observed publication and terminal',
|
||||
async (reason) => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('session.tabs.closeLifecycle', {
|
||||
worktree: 'id:wt-1',
|
||||
tabId: 'tab-1',
|
||||
reason,
|
||||
publicationEpoch: 'epoch-1',
|
||||
terminal: 'term-1'
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', {
|
||||
reason,
|
||||
expectedPublicationEpoch: 'epoch-1',
|
||||
expectedTerminalHandle: 'term-1'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('dispatches tab moves through the runtime', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
UpdatePaneLayout,
|
||||
WorktreeTabSelector
|
||||
} from './session-tabs-schemas'
|
||||
import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods'
|
||||
|
||||
export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
|
|
@ -40,12 +41,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
|||
})
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'session.tabs.close',
|
||||
params: ActivateTab,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.closeMobileSessionTab(params.worktree, params.tabId)
|
||||
}),
|
||||
...SESSION_TAB_CLOSE_METHODS,
|
||||
defineMethod({
|
||||
name: 'session.tabs.createTerminal',
|
||||
params: CreateTerminalTab,
|
||||
|
|
|
|||
|
|
@ -320,6 +320,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
|||
'runtime.clientEvents.unsubscribe',
|
||||
'session.tabs.activate',
|
||||
'session.tabs.close',
|
||||
'session.tabs.closeLifecycle',
|
||||
'session.tabs.createTerminal',
|
||||
'session.tabs.list',
|
||||
'session.tabs.listAll',
|
||||
|
|
|
|||
|
|
@ -1354,7 +1354,8 @@ function Terminal(): React.JSX.Element | null {
|
|||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: owningWorktreeId,
|
||||
tabId,
|
||||
environmentId: runtimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
@ -1408,7 +1409,7 @@ function Terminal(): React.JSX.Element | null {
|
|||
if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) {
|
||||
return
|
||||
}
|
||||
closeTerminalTab(tabId, { reason: 'pty-exit' })
|
||||
closeTerminalTab(tabId, { reason: 'pty-exit', lifecyclePtyId: ptyId })
|
||||
},
|
||||
[consumeSuppressedPtyExit]
|
||||
)
|
||||
|
|
@ -1445,7 +1446,8 @@ function Terminal(): React.JSX.Element | null {
|
|||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: unifiedTab.id,
|
||||
environmentId: runtimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
}
|
||||
continue
|
||||
|
|
@ -1509,7 +1511,8 @@ function Terminal(): React.JSX.Element | null {
|
|||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: unifiedTab.id,
|
||||
environmentId: runtimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
}
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -296,7 +296,8 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => {
|
|||
expect(mocks.closeWebRuntimeSessionTab).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'terminal-2',
|
||||
environmentId: 'env-1'
|
||||
environmentId: 'env-1',
|
||||
reason: 'user'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -253,7 +253,8 @@ export function useTabGroupWorkspaceModel({
|
|||
void closeWebRuntimeSessionTab({
|
||||
worktreeId,
|
||||
tabId: item.id,
|
||||
environmentId: runtimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
}
|
||||
destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId)
|
||||
|
|
@ -311,7 +312,8 @@ export function useTabGroupWorkspaceModel({
|
|||
void closeWebRuntimeSessionTab({
|
||||
worktreeId,
|
||||
tabId: item.id,
|
||||
environmentId: runtimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
}
|
||||
destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId)
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
|||
}
|
||||
closeTerminalTab(terminalTabId, {
|
||||
reason: 'pty-exit',
|
||||
lifecyclePtyId: ptyId,
|
||||
onClosed: leaveWorktreeIfEmpty
|
||||
})
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ vi.mock('./pty-pre-handler-buffer', () => ({
|
|||
|
||||
type CloseTerminalTabOptions = {
|
||||
captureRecentlyClosed?: boolean
|
||||
hostCloseReason?: string
|
||||
lifecyclePtyId?: string
|
||||
onClosed?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
|
@ -317,6 +319,10 @@ describe('terminal-parked-tab-watchers', () => {
|
|||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
const options = closeTerminalTab.mock.calls[0]?.[1] as CloseTerminalTabOptions
|
||||
expect(options.captureRecentlyClosed).toBe(false)
|
||||
// Why: the wire must carry the pty-exit intent so a paired host can refuse
|
||||
// the echo while its PTY is live, without skipping the pinned guard here.
|
||||
expect(options.hostCloseReason).toBe('pty-exit')
|
||||
expect(options.lifecyclePtyId).toBe(PTY_ID)
|
||||
options.onClosed?.()
|
||||
|
||||
expect(consumePreHandlerPtyState).toHaveBeenCalledWith(PTY_ID)
|
||||
|
|
|
|||
|
|
@ -167,6 +167,11 @@ function startParkedTabWatchers(
|
|||
closeTerminalTab(tab.id, {
|
||||
// Why: autonomous PTY exit still needs pinned-tab confirmation but must not enter reopen history.
|
||||
captureRecentlyClosed: false,
|
||||
// Why: same lifecycle echo as the mounted pty-exit handlers — tag the
|
||||
// wire so the host can refuse it while its PTY is live, without
|
||||
// `reason: 'pty-exit'` skipping the pinned confirmation above.
|
||||
hostCloseReason: 'pty-exit',
|
||||
lifecyclePtyId: ptyId,
|
||||
onClosed: () => {
|
||||
discardPreHandlerPtyState(ptyId)
|
||||
const entry = parkedWatchersByTabId.get(tab.id)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getTerminalIncarnationHandle } from './terminal-close-incarnation'
|
||||
|
||||
describe('terminal close incarnation', () => {
|
||||
it('selects only a terminal handle owned by the target runtime environment', () => {
|
||||
expect(getTerminalIncarnationHandle('remote:target-env@@target-terminal', 'target-env')).toBe(
|
||||
'target-terminal'
|
||||
)
|
||||
expect(
|
||||
getTerminalIncarnationHandle('remote:other-env@@other-terminal', 'target-env')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not reinterpret native, WSL, or SSH identities as runtime authority', () => {
|
||||
for (const ptyId of [
|
||||
'remote:legacy-unscoped-handle',
|
||||
'native-pty',
|
||||
'wsl:Ubuntu:pty-1',
|
||||
'ssh:host:pty-2'
|
||||
]) {
|
||||
expect(getTerminalIncarnationHandle(ptyId, 'target-env')).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
||||
export function getTerminalIncarnationHandle(ptyId: string, environmentId: string): string | null {
|
||||
const terminal = parseRemoteRuntimePtyId(ptyId)
|
||||
if (terminal?.handle && terminal.environmentId === environmentId) {
|
||||
return terminal.handle
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ const {
|
|||
activateWebRuntimeSessionTabMock,
|
||||
closeWebRuntimeSessionTabMock,
|
||||
createWebRuntimeSessionTerminalMock,
|
||||
getLatestWebSessionTabsPublicationEpochMock,
|
||||
getStateMock,
|
||||
isWebRuntimeSessionActiveMock,
|
||||
isWebTerminalSurfaceTabIdMock,
|
||||
|
|
@ -13,6 +14,7 @@ const {
|
|||
activateWebRuntimeSessionTabMock: vi.fn(),
|
||||
closeWebRuntimeSessionTabMock: vi.fn(),
|
||||
createWebRuntimeSessionTerminalMock: vi.fn(),
|
||||
getLatestWebSessionTabsPublicationEpochMock: vi.fn(() => 'epoch-1'),
|
||||
getStateMock: vi.fn(),
|
||||
isWebRuntimeSessionActiveMock: vi.fn(),
|
||||
isWebTerminalSurfaceTabIdMock: vi.fn(() => false),
|
||||
|
|
@ -36,6 +38,7 @@ vi.mock('@/runtime/web-runtime-session', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/runtime/web-session-tabs-sync', () => ({
|
||||
getLatestWebSessionTabsPublicationEpoch: getLatestWebSessionTabsPublicationEpochMock,
|
||||
resolveHostSessionTabIdForWebSessionTab: resolveHostSessionTabIdForWebSessionTabMock
|
||||
}))
|
||||
|
||||
|
|
@ -204,7 +207,150 @@ describe('closeTerminalTab', () => {
|
|||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'host-tab-1',
|
||||
environmentId: 'web-runtime'
|
||||
environmentId: 'web-runtime',
|
||||
reason: 'user'
|
||||
})
|
||||
})
|
||||
|
||||
it('binds a pty-exit close to the observed host and terminal incarnation', () => {
|
||||
const closeTab = vi.fn()
|
||||
isWebRuntimeSessionActiveMock.mockReturnValue(true)
|
||||
resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue('host-tab-1')
|
||||
getStateMock.mockReturnValue({
|
||||
settings: { activeRuntimeEnvironmentId: 'web-runtime' },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'local-tab-1' }, { id: 'local-tab-2' }]
|
||||
},
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'local-tab-1',
|
||||
closeTab,
|
||||
setActiveTab: vi.fn()
|
||||
})
|
||||
|
||||
closeTerminalTab('local-tab-1', {
|
||||
reason: 'pty-exit',
|
||||
lifecyclePtyId: 'remote:web-runtime@@term-1'
|
||||
})
|
||||
|
||||
// Local prune behavior is unchanged.
|
||||
expect(closeTab).toHaveBeenCalledWith('local-tab-1', {
|
||||
reason: 'pty-exit',
|
||||
remoteCloseOwnedByHost: true
|
||||
})
|
||||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'host-tab-1',
|
||||
environmentId: 'web-runtime',
|
||||
reason: 'pty-exit',
|
||||
publicationEpoch: 'epoch-1',
|
||||
terminalHandle: 'term-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not borrow a replacement PTY handle for a stale exit callback', () => {
|
||||
const closeTab = vi.fn()
|
||||
isWebRuntimeSessionActiveMock.mockReturnValue(true)
|
||||
resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue('host-tab-1')
|
||||
getStateMock.mockReturnValue({
|
||||
settings: { activeRuntimeEnvironmentId: 'web-runtime' },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'local-tab-1' }, { id: 'local-tab-2' }]
|
||||
},
|
||||
ptyIdsByTabId: { 'local-tab-1': ['remote:web-runtime@@replacement-term'] },
|
||||
terminalLayoutsByTabId: {},
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'local-tab-1',
|
||||
closeTab,
|
||||
setActiveTab: vi.fn()
|
||||
})
|
||||
|
||||
closeTerminalTab('local-tab-1', {
|
||||
reason: 'pty-exit',
|
||||
lifecyclePtyId: 'remote:web-runtime@@retired-term'
|
||||
})
|
||||
|
||||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
terminalHandle: 'retired-term'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('sends hostCloseReason on the wire without tagging the local close reason', () => {
|
||||
// Why: parked-tab lifecycle closes must reach the host as 'pty-exit' so it
|
||||
// can adjudicate them, while local guards keyed off `reason` still apply.
|
||||
const closeTab = vi.fn()
|
||||
isWebRuntimeSessionActiveMock.mockReturnValue(true)
|
||||
resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue('host-tab-1')
|
||||
getStateMock.mockReturnValue({
|
||||
settings: { activeRuntimeEnvironmentId: 'web-runtime' },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'local-tab-1' }, { id: 'local-tab-2' }]
|
||||
},
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'local-tab-1',
|
||||
closeTab,
|
||||
setActiveTab: vi.fn()
|
||||
})
|
||||
|
||||
closeTerminalTab('local-tab-1', {
|
||||
hostCloseReason: 'pty-exit',
|
||||
lifecyclePtyId: 'remote:web-runtime@@term-1'
|
||||
})
|
||||
|
||||
expect(closeTab).toHaveBeenCalledWith('local-tab-1', {
|
||||
reason: undefined,
|
||||
remoteCloseOwnedByHost: true
|
||||
})
|
||||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'host-tab-1',
|
||||
environmentId: 'web-runtime',
|
||||
reason: 'pty-exit',
|
||||
publicationEpoch: 'epoch-1',
|
||||
terminalHandle: 'term-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the pinned confirmation guard for a hostCloseReason pty-exit close', () => {
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
const closeUnifiedTab = vi.fn()
|
||||
getStateMock.mockReturnValue(
|
||||
makePinnedTabState({
|
||||
confirmClosePinnedTab: true,
|
||||
requestPinnedTabCloseConfirm,
|
||||
closeUnifiedTab
|
||||
})
|
||||
)
|
||||
|
||||
closeTerminalTab('pinned-entity-1', { hostCloseReason: 'pty-exit' })
|
||||
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('marks a user action as explicit when no lifecycle reason is present', () => {
|
||||
const closeTab = vi.fn()
|
||||
isWebRuntimeSessionActiveMock.mockReturnValue(true)
|
||||
resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue('host-tab-1')
|
||||
getStateMock.mockReturnValue({
|
||||
settings: { activeRuntimeEnvironmentId: 'web-runtime' },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'local-tab-1' }, { id: 'local-tab-2' }]
|
||||
},
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'local-tab-1',
|
||||
closeTab,
|
||||
setActiveTab: vi.fn()
|
||||
})
|
||||
|
||||
closeTerminalTab('local-tab-1')
|
||||
|
||||
const args = closeWebRuntimeSessionTabMock.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(args).toMatchObject({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'host-tab-1',
|
||||
reason: 'user'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -333,7 +479,8 @@ describe('closeTerminalTab', () => {
|
|||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'plain-uuid-tab',
|
||||
environmentId: 'web-runtime'
|
||||
environmentId: 'web-runtime',
|
||||
reason: 'user'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -592,12 +739,14 @@ describe('closeOtherTerminalTabs', () => {
|
|||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'close-a',
|
||||
environmentId: 'web-runtime'
|
||||
environmentId: 'web-runtime',
|
||||
reason: 'user'
|
||||
})
|
||||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'close-b',
|
||||
environmentId: 'web-runtime'
|
||||
environmentId: 'web-runtime',
|
||||
reason: 'user'
|
||||
})
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
@ -633,12 +782,14 @@ describe('closeTerminalTabsToRight', () => {
|
|||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'term-b',
|
||||
environmentId: 'web-runtime'
|
||||
environmentId: 'web-runtime',
|
||||
reason: 'user'
|
||||
})
|
||||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'term-c',
|
||||
environmentId: 'web-runtime'
|
||||
environmentId: 'web-runtime',
|
||||
reason: 'user'
|
||||
})
|
||||
expect(closeFile).toHaveBeenCalledWith('file-b')
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import {
|
|||
isWebRuntimeSessionActive,
|
||||
toHostSessionTabId
|
||||
} from '@/runtime/web-runtime-session'
|
||||
import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync'
|
||||
import {
|
||||
getLatestWebSessionTabsPublicationEpoch,
|
||||
resolveHostSessionTabIdForWebSessionTab
|
||||
} from '@/runtime/web-session-tabs-sync'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard'
|
||||
import type {
|
||||
|
|
@ -16,6 +19,7 @@ import type {
|
|||
TerminalTabRetirementPlan
|
||||
} from '@/store/slices/terminal-tab-retirement'
|
||||
import { closeLocalTerminalTabState } from './close-local-terminal-tab-state'
|
||||
import { getTerminalIncarnationHandle } from './terminal-close-incarnation'
|
||||
import {
|
||||
getWorktreeTerminalTabIds,
|
||||
resolveTerminalCloseTarget,
|
||||
|
|
@ -51,6 +55,12 @@ export function closeTerminalTab(
|
|||
force?: boolean
|
||||
rejectPinned?: boolean
|
||||
reason?: TerminalTabCloseReason
|
||||
/** Close reason sent to the host only. Unlike `reason`, it does not skip
|
||||
* local guards (pinned confirmation keys off `reason === 'pty-exit'`),
|
||||
* so lifecycle echoes that still need those guards can tag the wire. */
|
||||
hostCloseReason?: TerminalTabCloseReason
|
||||
/** PTY whose lifecycle event initiated the host close. */
|
||||
lifecyclePtyId?: string
|
||||
captureRecentlyClosed?: boolean
|
||||
localPtyTeardownOwnedExternally?: boolean
|
||||
precomputedRetirementPlan?: TerminalTabRetirementPlan
|
||||
|
|
@ -111,6 +121,15 @@ export function closeTerminalTab(
|
|||
worktreeId: owningWorktreeId,
|
||||
tabId: terminalTabId
|
||||
}) ?? toHostSessionTabId(terminalTabId)
|
||||
const wireReason = options?.reason ?? options?.hostCloseReason ?? 'user'
|
||||
const lifecycleTerminalHandle =
|
||||
wireReason === 'user'
|
||||
? null
|
||||
: getTerminalIncarnationHandle(options?.lifecyclePtyId ?? '', runtimeEnvironmentId)
|
||||
const publicationEpoch =
|
||||
wireReason === 'user'
|
||||
? null
|
||||
: getLatestWebSessionTabsPublicationEpoch(runtimeEnvironmentId, owningWorktreeId)
|
||||
// Why: prune local mirrors immediately so close feels responsive while the
|
||||
// host session snapshot catches up.
|
||||
closeLocalTerminalTabState(terminalTabId, {
|
||||
|
|
@ -129,7 +148,16 @@ export function closeTerminalTab(
|
|||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: owningWorktreeId,
|
||||
tabId: hostBackedTabId,
|
||||
environmentId: runtimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId,
|
||||
// Why: lifecycle evidence binds this stale-prone echo to the exact host
|
||||
// publication and terminal incarnation that the renderer observed.
|
||||
reason: wireReason,
|
||||
...(wireReason !== 'user'
|
||||
? {
|
||||
publicationEpoch,
|
||||
terminalHandle: lifecycleTerminalHandle
|
||||
}
|
||||
: {})
|
||||
})
|
||||
options?.onClosed?.()
|
||||
return
|
||||
|
|
@ -218,7 +246,8 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string |
|
|||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: tab.id,
|
||||
environmentId: runtimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
} else {
|
||||
state.closeTab(tab.id)
|
||||
|
|
@ -261,7 +290,8 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string
|
|||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: id,
|
||||
environmentId: runtimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
} else {
|
||||
state.closeTab(id)
|
||||
|
|
|
|||
|
|
@ -2487,7 +2487,8 @@ export function useIpcEvents(): void {
|
|||
void closeWebRuntimeSessionTab({
|
||||
worktreeId,
|
||||
tabId,
|
||||
environmentId
|
||||
environmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,8 @@ describe('notifyHostOfMirroredEditorClose', () => {
|
|||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'host-tab-1',
|
||||
environmentId: 'env-1'
|
||||
environmentId: 'env-1',
|
||||
reason: 'user'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ export function notifyHostOfMirroredEditorClose(
|
|||
closeWebRuntimeSessionTab({
|
||||
worktreeId,
|
||||
tabId: unifiedTab.id,
|
||||
environmentId: runtimeEnvironmentId
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
)
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ import {
|
|||
setWebRuntimeTabProps,
|
||||
splitWebRuntimeTerminal
|
||||
} from './web-runtime-session'
|
||||
import {
|
||||
isWebSessionCloseIntentPending,
|
||||
recordWebSessionCloseIntent,
|
||||
resetWebSessionCloseIntentForTests
|
||||
} from './web-session-close-intent'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getState: vi.fn(),
|
||||
|
|
@ -24,6 +29,7 @@ const mocks = vi.hoisted(() => ({
|
|||
setRemoteBrowserPageHandle: vi.fn(),
|
||||
focusBrowserTabInWorktree: vi.fn(),
|
||||
applyFreshWebSessionTabsSnapshot: vi.fn(),
|
||||
acceptReplayedWebSessionTabsSnapshot: vi.fn(),
|
||||
resolveHostSessionTabIdForWebSessionTab: vi.fn(),
|
||||
trackTerminalPaneSplit: vi.fn(),
|
||||
deliverLaunchPromptToAgentTab: vi.fn(),
|
||||
|
|
@ -38,6 +44,7 @@ vi.mock('../store', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('./web-session-tabs-sync', () => ({
|
||||
acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot,
|
||||
applyFreshWebSessionTabsSnapshot: mocks.applyFreshWebSessionTabsSnapshot,
|
||||
applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) =>
|
||||
mocks.setState(buildPatch),
|
||||
|
|
@ -59,6 +66,8 @@ vi.mock('@/lib/agent-launch-prompt-delivery', () => ({
|
|||
const ENVIRONMENT_ID = 'web-env-1'
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
|
||||
afterEach(() => resetWebSessionCloseIntentForTests())
|
||||
|
||||
function makeSnapshot(): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: WORKTREE_ID,
|
||||
|
|
@ -905,7 +914,8 @@ describe('web runtime session tab actions', () => {
|
|||
await expect(
|
||||
closeWebRuntimeSessionTab({
|
||||
worktreeId: WORKTREE_ID,
|
||||
tabId: 'local-browser-unified'
|
||||
tabId: 'local-browser-unified',
|
||||
reason: 'user'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
|
|
@ -925,7 +935,8 @@ describe('web runtime session tab actions', () => {
|
|||
method: 'session.tabs.close',
|
||||
params: {
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
tabId: 'host-browser-unified'
|
||||
tabId: 'host-browser-unified',
|
||||
reason: 'user'
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
|
|
@ -939,6 +950,228 @@ describe('web runtime session tab actions', () => {
|
|||
})
|
||||
expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends lifecycle and explicit user close reasons on the wire', async () => {
|
||||
const runtimeCall = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'close-1', ok: true, result: {} })
|
||||
.mockResolvedValueOnce({ id: 'list-1', ok: true, result: makeSnapshot() })
|
||||
.mockResolvedValueOnce({ id: 'close-2', ok: true, result: {} })
|
||||
.mockResolvedValueOnce({ id: 'list-2', ok: true, result: makeSnapshot() })
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
call: runtimeCall
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
closeWebRuntimeSessionTab({
|
||||
worktreeId: WORKTREE_ID,
|
||||
tabId: 'local-browser-unified',
|
||||
reason: 'pty-exit',
|
||||
publicationEpoch: 'epoch-1',
|
||||
terminalHandle: 'term-1'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
closeWebRuntimeSessionTab({
|
||||
worktreeId: WORKTREE_ID,
|
||||
tabId: 'local-browser-unified',
|
||||
reason: 'user'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(runtimeCall).toHaveBeenNthCalledWith(1, {
|
||||
selector: ENVIRONMENT_ID,
|
||||
method: 'session.tabs.closeLifecycle',
|
||||
params: {
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
tabId: 'host-browser-unified',
|
||||
reason: 'pty-exit',
|
||||
publicationEpoch: 'epoch-1',
|
||||
terminal: 'term-1'
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeCall).toHaveBeenNthCalledWith(3, {
|
||||
selector: ENVIRONMENT_ID,
|
||||
method: 'session.tabs.close',
|
||||
params: {
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
tabId: 'host-browser-unified',
|
||||
reason: 'user'
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('suppresses lifecycle closes when terminal-incarnation evidence is missing', async () => {
|
||||
const runtimeCall = vi.fn().mockResolvedValueOnce({
|
||||
id: 'list',
|
||||
ok: true,
|
||||
result: makeSnapshot()
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: { runtimeEnvironments: { call: runtimeCall } }
|
||||
})
|
||||
|
||||
await expect(
|
||||
closeWebRuntimeSessionTab({
|
||||
worktreeId: WORKTREE_ID,
|
||||
tabId: 'local-browser-unified',
|
||||
reason: 'pty-exit'
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(runtimeCall).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'session.tabs.list' })
|
||||
)
|
||||
expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith(
|
||||
ENVIRONMENT_ID,
|
||||
WORKTREE_ID
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when reconnect routes a lifecycle close to an older host', async () => {
|
||||
const runtimeCall = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'close',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'method_not_found',
|
||||
message: 'Unknown method: session.tabs.closeLifecycle'
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() })
|
||||
vi.stubGlobal('window', {
|
||||
api: { runtimeEnvironments: { call: runtimeCall } }
|
||||
})
|
||||
|
||||
await expect(
|
||||
closeWebRuntimeSessionTab({
|
||||
worktreeId: WORKTREE_ID,
|
||||
tabId: 'local-browser-unified',
|
||||
reason: 'pty-exit',
|
||||
publicationEpoch: 'epoch-1',
|
||||
terminalHandle: 'term-1'
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(runtimeCall).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ method: 'session.tabs.closeLifecycle' })
|
||||
)
|
||||
expect(runtimeCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'session.tabs.close' })
|
||||
)
|
||||
expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith(
|
||||
ENVIRONMENT_ID,
|
||||
WORKTREE_ID
|
||||
)
|
||||
expect(
|
||||
isWebSessionCloseIntentPending(
|
||||
ENVIRONMENT_ID,
|
||||
WORKTREE_ID,
|
||||
'host-browser-unified',
|
||||
Date.now()
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('restores reconciliation authority when the host refuses a lifecycle close', async () => {
|
||||
const authoritative = makeSnapshot()
|
||||
authoritative.snapshotVersion = 6
|
||||
const runtimeCall = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'close',
|
||||
ok: true,
|
||||
result: { closed: true, refused: true, snapshotRepublished: true }
|
||||
})
|
||||
.mockResolvedValueOnce({ id: 'list', ok: true, result: authoritative })
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
call: runtimeCall
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
closeWebRuntimeSessionTab({
|
||||
worktreeId: WORKTREE_ID,
|
||||
tabId: 'local-browser-unified',
|
||||
reason: 'pty-exit',
|
||||
publicationEpoch: 'epoch-1',
|
||||
terminalHandle: 'term-1'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(
|
||||
isWebSessionCloseIntentPending(
|
||||
ENVIRONMENT_ID,
|
||||
WORKTREE_ID,
|
||||
'host-browser-unified',
|
||||
Date.now()
|
||||
)
|
||||
).toBe(false)
|
||||
expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith(
|
||||
ENVIRONMENT_ID,
|
||||
WORKTREE_ID
|
||||
)
|
||||
expect(mocks.acceptReplayedWebSessionTabsSnapshot.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.applyFreshWebSessionTabsSnapshot.mock.invocationCallOrder[0]!
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the close intent when a refused lifecycle close was not republished', async () => {
|
||||
const runtimeCall = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'close',
|
||||
ok: true,
|
||||
result: { closed: true, refused: true }
|
||||
})
|
||||
.mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() })
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
call: runtimeCall
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
recordWebSessionCloseIntent(ENVIRONMENT_ID, WORKTREE_ID, 'other-host-tab', Date.now())
|
||||
await expect(
|
||||
closeWebRuntimeSessionTab({
|
||||
worktreeId: WORKTREE_ID,
|
||||
tabId: 'local-browser-unified',
|
||||
reason: 'pty-exit',
|
||||
publicationEpoch: 'epoch-1',
|
||||
terminalHandle: 'term-1'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(
|
||||
isWebSessionCloseIntentPending(
|
||||
ENVIRONMENT_ID,
|
||||
WORKTREE_ID,
|
||||
'host-browser-unified',
|
||||
Date.now()
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWebSessionCloseIntentPending(ENVIRONMENT_ID, WORKTREE_ID, 'other-host-tab', Date.now())
|
||||
).toBe(true)
|
||||
expect(mocks.acceptReplayedWebSessionTabsSnapshot).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitWebRuntimeTerminal', () => {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
|||
import type {
|
||||
BrowserTabCreateResult,
|
||||
RuntimeMobileSessionCreateTerminalResult,
|
||||
RuntimeMobileSessionTabCloseResult,
|
||||
RuntimeMobileSessionTabMove,
|
||||
RuntimeMobileSessionTabMoveResult,
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeSessionTabCloseReason,
|
||||
RuntimeTerminalClose,
|
||||
RuntimeTerminalSplit
|
||||
} from '../../../shared/runtime-types'
|
||||
|
|
@ -20,7 +22,7 @@ import { unwrapRuntimeRpcResult } from './runtime-rpc-client'
|
|||
import { parseRemoteRuntimePtyId } from './runtime-terminal-stream'
|
||||
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
|
||||
import { recordWebSessionFocusIntent } from './web-session-focus-intent'
|
||||
import { recordWebSessionCloseIntent } from './web-session-close-intent'
|
||||
import { clearWebSessionCloseIntent, recordWebSessionCloseIntent } from './web-session-close-intent'
|
||||
import { recordWebSessionReorderIntent } from './web-session-reorder-intent'
|
||||
import {
|
||||
isWebTerminalSurfaceTabId,
|
||||
|
|
@ -28,6 +30,7 @@ import {
|
|||
toWebTerminalSurfaceTabId
|
||||
} from './web-terminal-surface-id'
|
||||
import { deliverLaunchPromptToAgentTab } from '../lib/agent-launch-prompt-delivery'
|
||||
import { listRemoteRuntimeSessionTabsDeduped } from './remote-runtime-session-tabs-inflight'
|
||||
|
||||
export {
|
||||
HOST_TERMINAL_SURFACE_SEPARATOR,
|
||||
|
|
@ -292,17 +295,23 @@ async function refreshWebRuntimeSessionTabsSnapshot(
|
|||
worktreeId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await window.api.runtimeEnvironments.call({
|
||||
selector: environmentId,
|
||||
method: 'session.tabs.list',
|
||||
params: {
|
||||
worktree: toRuntimeWorktreeSelector(worktreeId)
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
const snapshot = await listRemoteRuntimeSessionTabsDeduped({
|
||||
environmentId,
|
||||
worktreeId,
|
||||
load: async () => {
|
||||
const response = await window.api.runtimeEnvironments.call({
|
||||
selector: environmentId,
|
||||
method: 'session.tabs.list',
|
||||
params: {
|
||||
worktree: toRuntimeWorktreeSelector(worktreeId)
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
return unwrapRuntimeRpcResult(
|
||||
response as RuntimeRpcResponse<RuntimeMobileSessionTabsResult>
|
||||
)
|
||||
}
|
||||
})
|
||||
const snapshot = unwrapRuntimeRpcResult(
|
||||
response as RuntimeRpcResponse<RuntimeMobileSessionTabsResult>
|
||||
)
|
||||
const { applyFreshWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } =
|
||||
await import('./web-session-tabs-sync')
|
||||
applyWebSessionTabsStorePatch((state) => {
|
||||
|
|
@ -366,6 +375,9 @@ export async function closeWebRuntimeSessionTab(args: {
|
|||
worktreeId: string
|
||||
tabId: string
|
||||
environmentId?: string | null
|
||||
reason: RuntimeSessionTabCloseReason
|
||||
publicationEpoch?: string | null
|
||||
terminalHandle?: string | null
|
||||
}): Promise<boolean> {
|
||||
return callWebRuntimeSessionTabMethod('session.tabs.close', args)
|
||||
}
|
||||
|
|
@ -471,6 +483,9 @@ async function callWebRuntimeSessionTabMethod(
|
|||
worktreeId: string
|
||||
tabId: string
|
||||
environmentId?: string | null
|
||||
reason?: RuntimeSessionTabCloseReason
|
||||
publicationEpoch?: string | null
|
||||
terminalHandle?: string | null
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const environmentId =
|
||||
|
|
@ -481,9 +496,25 @@ async function callWebRuntimeSessionTabMethod(
|
|||
return false
|
||||
}
|
||||
|
||||
if (method === 'session.tabs.close') {
|
||||
// Why: sync best-effort intent before the async id resolution, so a snapshot in that gap can't flash the closed tab back.
|
||||
recordWebSessionCloseIntent(args.worktreeId, toHostSessionTabId(args.tabId), Date.now())
|
||||
const isClose = method === 'session.tabs.close'
|
||||
const isLifecycleClose = isClose && args.reason !== 'user'
|
||||
if (isLifecycleClose && (!args.publicationEpoch || !args.terminalHandle)) {
|
||||
// Why: missing host-generation or terminal-incarnation evidence means keep;
|
||||
// a tab id alone can be stale or reused after reconnect.
|
||||
const { acceptReplayedWebSessionTabsSnapshot } = await import('./web-session-tabs-sync')
|
||||
acceptReplayedWebSessionTabsSnapshot(environmentId, args.worktreeId)
|
||||
await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId)
|
||||
console.warn('[web-runtime-session] suppressed lifecycle close without incarnation evidence', {
|
||||
closeReason: args.reason
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const immediateHostTabId = toHostSessionTabId(args.tabId)
|
||||
let resolvedHostTabId = immediateHostTabId
|
||||
if (isClose) {
|
||||
// Why: record before async id resolution so a stale snapshot cannot flash the closed tab back.
|
||||
recordWebSessionCloseIntent(environmentId, args.worktreeId, immediateHostTabId, Date.now())
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -495,13 +526,16 @@ async function callWebRuntimeSessionTabMethod(
|
|||
worktreeId: args.worktreeId,
|
||||
tabId: args.tabId
|
||||
}) ?? toHostSessionTabId(args.tabId)
|
||||
if (method === 'session.tabs.close') {
|
||||
resolvedHostTabId = hostTabId
|
||||
if (isClose) {
|
||||
// Why: suppress until the host confirms removal, else an in-flight pre-close snapshot flashes the tab back.
|
||||
recordWebSessionCloseIntent(args.worktreeId, hostTabId, Date.now())
|
||||
recordWebSessionCloseIntent(environmentId, args.worktreeId, hostTabId, Date.now())
|
||||
}
|
||||
const response = await window.api.runtimeEnvironments.call({
|
||||
selector: environmentId,
|
||||
method,
|
||||
// Why: old hosts cannot route this additive method, so a generation
|
||||
// cutover fails closed before their destructive legacy close handler.
|
||||
method: isLifecycleClose ? 'session.tabs.closeLifecycle' : method,
|
||||
params: {
|
||||
worktree: toRuntimeWorktreeSelector(args.worktreeId),
|
||||
tabId: hostTabId,
|
||||
|
|
@ -511,18 +545,44 @@ async function callWebRuntimeSessionTabMethod(
|
|||
notifyClients: false,
|
||||
navigation: 'caller' as const
|
||||
}
|
||||
: {})
|
||||
: {}),
|
||||
...(isLifecycleClose
|
||||
? {
|
||||
reason: args.reason,
|
||||
publicationEpoch: args.publicationEpoch,
|
||||
terminal: args.terminalHandle
|
||||
}
|
||||
: isClose
|
||||
? { reason: args.reason }
|
||||
: {})
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<unknown>)
|
||||
if (method === 'session.tabs.close') {
|
||||
const result = unwrapRuntimeRpcResult(
|
||||
response as RuntimeRpcResponse<RuntimeMobileSessionTabCloseResult | undefined>
|
||||
)
|
||||
if (isClose) {
|
||||
if (result?.refused === true && result.snapshotRepublished === true) {
|
||||
// Why: the host kept an authoritative live PTY. Stop hiding its mirror
|
||||
// only when it republished; dead-leaf refusals must stay suppressed.
|
||||
clearWebSessionCloseIntent(environmentId, args.worktreeId, immediateHostTabId)
|
||||
clearWebSessionCloseIntent(environmentId, args.worktreeId, hostTabId)
|
||||
const { acceptReplayedWebSessionTabsSnapshot } = await import('./web-session-tabs-sync')
|
||||
acceptReplayedWebSessionTabsSnapshot(environmentId, args.worktreeId)
|
||||
}
|
||||
await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isLifecycleClose) {
|
||||
clearWebSessionCloseIntent(environmentId, args.worktreeId, immediateHostTabId)
|
||||
clearWebSessionCloseIntent(environmentId, args.worktreeId, resolvedHostTabId)
|
||||
const { acceptReplayedWebSessionTabsSnapshot } = await import('./web-session-tabs-sync')
|
||||
acceptReplayedWebSessionTabsSnapshot(environmentId, args.worktreeId)
|
||||
await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId)
|
||||
}
|
||||
console.warn(
|
||||
`[web-runtime-session] failed to ${method === 'session.tabs.close' ? 'close' : 'activate'} tab:`,
|
||||
`[web-runtime-session] failed to ${isClose ? 'close' : 'activate'} tab:`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearWebSessionCloseIntent,
|
||||
clearWebSessionCloseIntentsForEnvironment,
|
||||
clearWebSessionCloseIntentsForRuntimeWorktree,
|
||||
isWebSessionCloseIntentPending,
|
||||
reconcileWebSessionCloseIntents,
|
||||
recordWebSessionCloseIntent,
|
||||
|
|
@ -7,37 +10,69 @@ import {
|
|||
} from './web-session-close-intent'
|
||||
|
||||
const WT = 'repo::/wt'
|
||||
const ENV = 'runtime-a'
|
||||
|
||||
afterEach(() => resetWebSessionCloseIntentForTests())
|
||||
|
||||
describe('web session close intent', () => {
|
||||
it('marks a closing host tab pending until the host confirms removal', () => {
|
||||
recordWebSessionCloseIntent(WT, 'host-tab-1', 1000)
|
||||
expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000)).toBe(true)
|
||||
recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', 1000)
|
||||
expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(true)
|
||||
|
||||
// A snapshot that still contains the tab keeps the intent (not confirmed).
|
||||
reconcileWebSessionCloseIntents(WT, new Set(['host-tab-1', 'host-tab-2']))
|
||||
expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000)).toBe(true)
|
||||
reconcileWebSessionCloseIntents(ENV, WT, new Set(['host-tab-1', 'host-tab-2']))
|
||||
expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(true)
|
||||
|
||||
// A snapshot WITHOUT the tab confirms removal and clears the intent.
|
||||
reconcileWebSessionCloseIntents(WT, new Set(['host-tab-2']))
|
||||
expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000)).toBe(false)
|
||||
reconcileWebSessionCloseIntents(ENV, WT, new Set(['host-tab-2']))
|
||||
expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(false)
|
||||
})
|
||||
|
||||
it('expires a never-confirmed close so the tab is not hidden forever', () => {
|
||||
recordWebSessionCloseIntent(WT, 'host-tab-1', 1000)
|
||||
expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000)).toBe(true)
|
||||
recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', 1000)
|
||||
expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(true)
|
||||
// Past the TTL with no confirming snapshot — stop suppressing.
|
||||
expect(isWebSessionCloseIntentPending(WT, 'host-tab-1', 1000 + 11_000)).toBe(false)
|
||||
expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000 + 11_000)).toBe(false)
|
||||
})
|
||||
|
||||
it('scopes intents per worktree', () => {
|
||||
recordWebSessionCloseIntent(WT, 'host-tab-1', 1000)
|
||||
expect(isWebSessionCloseIntentPending('other::/wt', 'host-tab-1', 1000)).toBe(false)
|
||||
recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', 1000)
|
||||
expect(isWebSessionCloseIntentPending(ENV, 'other::/wt', 'host-tab-1', 1000)).toBe(false)
|
||||
})
|
||||
|
||||
it('scopes intents per runtime environment', () => {
|
||||
recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', 1000)
|
||||
expect(isWebSessionCloseIntentPending('runtime-b', WT, 'host-tab-1', 1000)).toBe(false)
|
||||
reconcileWebSessionCloseIntents('runtime-b', WT, new Set())
|
||||
clearWebSessionCloseIntent('runtime-b', WT, 'host-tab-1')
|
||||
expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(true)
|
||||
})
|
||||
|
||||
it('clears only the refused host tab intent', () => {
|
||||
recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', 1000)
|
||||
recordWebSessionCloseIntent(ENV, WT, 'host-tab-2', 1000)
|
||||
|
||||
clearWebSessionCloseIntent(ENV, WT, 'host-tab-1')
|
||||
|
||||
expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(false)
|
||||
expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-2', 1000)).toBe(true)
|
||||
})
|
||||
|
||||
it('clears intents when their worktree or runtime owner is removed', () => {
|
||||
recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', 1000)
|
||||
recordWebSessionCloseIntent(ENV, 'other-wt', 'host-tab-2', 1000)
|
||||
recordWebSessionCloseIntent('runtime-b', WT, 'host-tab-3', 1000)
|
||||
|
||||
clearWebSessionCloseIntentsForRuntimeWorktree(ENV, WT)
|
||||
expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-1', 1000)).toBe(false)
|
||||
expect(isWebSessionCloseIntentPending(ENV, 'other-wt', 'host-tab-2', 1000)).toBe(true)
|
||||
clearWebSessionCloseIntentsForEnvironment(ENV)
|
||||
expect(isWebSessionCloseIntentPending(ENV, 'other-wt', 'host-tab-2', 1000)).toBe(false)
|
||||
expect(isWebSessionCloseIntentPending('runtime-b', WT, 'host-tab-3', 1000)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores empty ids', () => {
|
||||
recordWebSessionCloseIntent(WT, ' ', 1000)
|
||||
expect(isWebSessionCloseIntentPending(WT, '', 1000)).toBe(false)
|
||||
recordWebSessionCloseIntent(ENV, WT, ' ', 1000)
|
||||
expect(isWebSessionCloseIntentPending(ENV, WT, '', 1000)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,10 +16,15 @@ const CLOSE_INTENT_TTL_MS = 10_000
|
|||
|
||||
type CloseIntent = { recordedAt: number }
|
||||
|
||||
// worktreeId -> (hostTabId -> intent)
|
||||
const pendingCloseByWorktree = new Map<string, Map<string, CloseIntent>>()
|
||||
// environment/worktree -> (hostTabId -> intent)
|
||||
const pendingCloseByRuntimeWorktree = new Map<string, Map<string, CloseIntent>>()
|
||||
|
||||
function closeIntentScopeKey(environmentId: string, worktreeId: string): string {
|
||||
return `${environmentId}\0${worktreeId}`
|
||||
}
|
||||
|
||||
export function recordWebSessionCloseIntent(
|
||||
environmentId: string,
|
||||
worktreeId: string,
|
||||
hostTabId: string,
|
||||
now: number
|
||||
|
|
@ -28,25 +33,60 @@ export function recordWebSessionCloseIntent(
|
|||
if (!worktreeId || !trimmed) {
|
||||
return
|
||||
}
|
||||
let byTab = pendingCloseByWorktree.get(worktreeId)
|
||||
const scopeKey = closeIntentScopeKey(environmentId, worktreeId)
|
||||
let byTab = pendingCloseByRuntimeWorktree.get(scopeKey)
|
||||
if (!byTab) {
|
||||
byTab = new Map()
|
||||
pendingCloseByWorktree.set(worktreeId, byTab)
|
||||
pendingCloseByRuntimeWorktree.set(scopeKey, byTab)
|
||||
}
|
||||
byTab.set(trimmed, { recordedAt: now })
|
||||
}
|
||||
|
||||
export function clearWebSessionCloseIntent(
|
||||
environmentId: string,
|
||||
worktreeId: string,
|
||||
hostTabId: string
|
||||
): void {
|
||||
const scopeKey = closeIntentScopeKey(environmentId, worktreeId)
|
||||
const byTab = pendingCloseByRuntimeWorktree.get(scopeKey)
|
||||
if (!byTab) {
|
||||
return
|
||||
}
|
||||
byTab.delete(hostTabId.trim())
|
||||
if (byTab.size === 0) {
|
||||
pendingCloseByRuntimeWorktree.delete(scopeKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWebSessionCloseIntentsForRuntimeWorktree(
|
||||
environmentId: string,
|
||||
worktreeId: string
|
||||
): void {
|
||||
pendingCloseByRuntimeWorktree.delete(closeIntentScopeKey(environmentId, worktreeId))
|
||||
}
|
||||
|
||||
export function clearWebSessionCloseIntentsForEnvironment(environmentId: string): void {
|
||||
const prefix = `${environmentId}\0`
|
||||
for (const scopeKey of pendingCloseByRuntimeWorktree.keys()) {
|
||||
if (scopeKey.startsWith(prefix)) {
|
||||
pendingCloseByRuntimeWorktree.delete(scopeKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a host tab should be hidden because the client is closing it. Expired
|
||||
* intents are dropped (the close never confirmed — let the tab reappear rather
|
||||
* than hide it forever).
|
||||
*/
|
||||
export function isWebSessionCloseIntentPending(
|
||||
environmentId: string,
|
||||
worktreeId: string,
|
||||
hostTabId: string,
|
||||
now: number
|
||||
): boolean {
|
||||
const byTab = pendingCloseByWorktree.get(worktreeId)
|
||||
const scopeKey = closeIntentScopeKey(environmentId, worktreeId)
|
||||
const byTab = pendingCloseByRuntimeWorktree.get(scopeKey)
|
||||
const intent = byTab?.get(hostTabId)
|
||||
if (!intent) {
|
||||
return false
|
||||
|
|
@ -54,7 +94,7 @@ export function isWebSessionCloseIntentPending(
|
|||
if (now - intent.recordedAt > CLOSE_INTENT_TTL_MS) {
|
||||
byTab!.delete(hostTabId)
|
||||
if (byTab!.size === 0) {
|
||||
pendingCloseByWorktree.delete(worktreeId)
|
||||
pendingCloseByRuntimeWorktree.delete(scopeKey)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -66,10 +106,12 @@ export function isWebSessionCloseIntentPending(
|
|||
* NOT in `presentHostTabIds` has been removed host-side, so the intent is done.
|
||||
*/
|
||||
export function reconcileWebSessionCloseIntents(
|
||||
environmentId: string,
|
||||
worktreeId: string,
|
||||
presentHostTabIds: ReadonlySet<string>
|
||||
): void {
|
||||
const byTab = pendingCloseByWorktree.get(worktreeId)
|
||||
const scopeKey = closeIntentScopeKey(environmentId, worktreeId)
|
||||
const byTab = pendingCloseByRuntimeWorktree.get(scopeKey)
|
||||
if (!byTab) {
|
||||
return
|
||||
}
|
||||
|
|
@ -83,10 +125,10 @@ export function reconcileWebSessionCloseIntents(
|
|||
byTab.delete(hostTabId)
|
||||
}
|
||||
if (byTab.size === 0) {
|
||||
pendingCloseByWorktree.delete(worktreeId)
|
||||
pendingCloseByRuntimeWorktree.delete(scopeKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function resetWebSessionCloseIntentForTests(): void {
|
||||
pendingCloseByWorktree.clear()
|
||||
pendingCloseByRuntimeWorktree.clear()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
resetWebSessionFocusIntentForTests
|
||||
} from './web-session-focus-intent'
|
||||
import {
|
||||
clearWebSessionCloseIntent,
|
||||
isWebSessionCloseIntentPending,
|
||||
recordWebSessionCloseIntent,
|
||||
resetWebSessionCloseIntentForTests
|
||||
} from './web-session-close-intent'
|
||||
|
|
@ -159,11 +161,12 @@ describe('applyWebSessionTabsSnapshot', () => {
|
|||
expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(false)
|
||||
|
||||
acceptReplayedWebSessionTabsSnapshot(ENV, snapshot.worktree)
|
||||
const older = makeSnapshot([], { snapshotVersion: 4, activeTabType: null })
|
||||
expect(shouldApplyWebSessionTabsSnapshot(older, ENV)).toBe(false)
|
||||
expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(true)
|
||||
|
||||
// The replay reset re-primes tracking: ordering protection resumes for
|
||||
// subsequent frames (an older same-epoch frame is still rejected).
|
||||
const older = makeSnapshot([], { snapshotVersion: 4, activeTabType: null })
|
||||
expect(shouldApplyWebSessionTabsSnapshot(older, ENV)).toBe(false)
|
||||
const newer = makeSnapshot([], { snapshotVersion: 6, activeTabType: null })
|
||||
expect(shouldApplyWebSessionTabsSnapshot(newer, ENV)).toBe(true)
|
||||
|
|
@ -258,7 +261,7 @@ describe('applyWebSessionTabsSnapshot', () => {
|
|||
isActive: true
|
||||
}
|
||||
// Client closed host-tab-1; an in-flight pre-close snapshot still lists it.
|
||||
recordWebSessionCloseIntent(WT, 'host-tab-1', NOW)
|
||||
recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', NOW)
|
||||
const stalePreClose = applyWebSessionTabsSnapshot(
|
||||
makeState(),
|
||||
makeSnapshot([surface]),
|
||||
|
|
@ -283,6 +286,56 @@ describe('applyWebSessionTabsSnapshot', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('reapplies an unchanged host snapshot after a lifecycle close is refused', () => {
|
||||
const surface = {
|
||||
type: 'terminal' as const,
|
||||
id: HOST_SURFACE_ID,
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: LEAF_ID,
|
||||
title: 'Terminal',
|
||||
status: 'ready' as const,
|
||||
terminal: 'term_host',
|
||||
isActive: true
|
||||
}
|
||||
const authoritative = makeSnapshot([surface], { snapshotVersion: 6 })
|
||||
|
||||
const initial = makeState()
|
||||
recordWebSessionCloseIntent(ENV, WT, 'host-tab-1', NOW)
|
||||
const hiddenPatch = applyFreshWebSessionTabsSnapshot(initial, authoritative, ENV, NOW)
|
||||
const hidden = { ...initial, ...(hiddenPatch as Partial<WebSessionTabsSyncState>) }
|
||||
expect((hidden.tabsByWorktree[WT] ?? []).map((tab) => tab.id)).not.toContain(
|
||||
toWebTerminalSurfaceTabId('host-tab-1')
|
||||
)
|
||||
|
||||
// The host vetoed lifecycle cleanup because the PTY is still live. Its
|
||||
// unchanged snapshot must become usable immediately, without a new publish.
|
||||
clearWebSessionCloseIntent(ENV, WT, 'host-tab-1')
|
||||
acceptReplayedWebSessionTabsSnapshot(ENV, WT)
|
||||
const restoredPatch = applyFreshWebSessionTabsSnapshot(hidden, authoritative, ENV, NOW + 1)
|
||||
const restored = { ...hidden, ...(restoredPatch as Partial<WebSessionTabsSyncState>) }
|
||||
expect((restored.tabsByWorktree[WT] ?? []).map((tab) => tab.id)).toContain(
|
||||
toWebTerminalSurfaceTabId('host-tab-1')
|
||||
)
|
||||
})
|
||||
|
||||
it('does not let a replay reset clear another close intent from an older snapshot', () => {
|
||||
const current = makeSnapshot([], { snapshotVersion: 6, activeTabType: null })
|
||||
expect(shouldApplyWebSessionTabsSnapshot(current, ENV)).toBe(true)
|
||||
recordWebSessionCloseIntent(ENV, WT, 'host-tab-2', NOW)
|
||||
|
||||
acceptReplayedWebSessionTabsSnapshot(ENV, WT)
|
||||
const state = makeState()
|
||||
const stalePatch = applyFreshWebSessionTabsSnapshot(
|
||||
state,
|
||||
makeSnapshot([], { snapshotVersion: 5, activeTabType: null }),
|
||||
ENV,
|
||||
NOW + 1
|
||||
)
|
||||
|
||||
expect(stalePatch).toBe(state)
|
||||
expect(isWebSessionCloseIntentPending(ENV, WT, 'host-tab-2', NOW + 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a client reorder until the host echoes it (no order snap-back)', () => {
|
||||
const local1 = toWebTerminalSurfaceTabId('host-tab-1')
|
||||
const local2 = toWebTerminalSurfaceTabId('host-tab-2')
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution'
|
|||
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
|
||||
import { clearWebSessionFocusIntent, peekWebSessionFocusIntent } from './web-session-focus-intent'
|
||||
import {
|
||||
clearWebSessionCloseIntentsForEnvironment,
|
||||
clearWebSessionCloseIntentsForRuntimeWorktree,
|
||||
isWebSessionCloseIntentPending,
|
||||
reconcileWebSessionCloseIntents
|
||||
} from './web-session-close-intent'
|
||||
|
|
@ -86,6 +88,7 @@ type SnapshotFreshness = {
|
|||
}
|
||||
|
||||
const latestSessionTabsSnapshotByWorktree = new Map<string, SnapshotFreshness>()
|
||||
const replayableSessionTabsSnapshotByWorktree = new Map<string, SnapshotFreshness>()
|
||||
const lastHostTerminalTabCountByWorktree = new Map<string, number>()
|
||||
const hostSessionTabIdByLocalKey = new Map<string, string>()
|
||||
|
||||
|
|
@ -176,12 +179,27 @@ export function getLastKnownHostTerminalTabCount(
|
|||
)
|
||||
}
|
||||
|
||||
// Why: a post-reconnect replay re-emits the snapshot with unchanged epoch/version; dropping the freshness entry lets the monotonic gate accept it instead of freezing the mirror (#7718).
|
||||
export function getLatestWebSessionTabsPublicationEpoch(
|
||||
environmentId: string,
|
||||
worktreeId: string
|
||||
): string | null {
|
||||
return (
|
||||
latestSessionTabsSnapshotByWorktree.get(sessionTabsFreshnessKey(environmentId, worktreeId))
|
||||
?.publicationEpoch ?? null
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a replay may repeat the current epoch/version; permit only that exact
|
||||
// identity once so an older concurrent frame cannot bypass monotonic ordering.
|
||||
export function acceptReplayedWebSessionTabsSnapshot(
|
||||
environmentId: string,
|
||||
worktreeId: string
|
||||
): void {
|
||||
latestSessionTabsSnapshotByWorktree.delete(sessionTabsFreshnessKey(environmentId, worktreeId))
|
||||
const key = sessionTabsFreshnessKey(environmentId, worktreeId)
|
||||
const current = latestSessionTabsSnapshotByWorktree.get(key)
|
||||
if (current) {
|
||||
replayableSessionTabsSnapshotByWorktree.set(key, current)
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldApplyWebSessionTabsSnapshot(
|
||||
|
|
@ -201,14 +219,25 @@ export function shouldApplyWebSessionTabsSnapshot(
|
|||
}
|
||||
rememberHostTerminalTabCount(environmentId, snapshot)
|
||||
const current = latestSessionTabsSnapshotByWorktree.get(key)
|
||||
const replayable = replayableSessionTabsSnapshotByWorktree.get(key)
|
||||
const isExactCurrentReplay = Boolean(
|
||||
current &&
|
||||
replayable &&
|
||||
current.publicationEpoch === replayable.publicationEpoch &&
|
||||
current.snapshotVersion === replayable.snapshotVersion &&
|
||||
snapshot.publicationEpoch === replayable.publicationEpoch &&
|
||||
snapshot.snapshotVersion === replayable.snapshotVersion
|
||||
)
|
||||
// Why: snapshotVersion is monotonic only within one publicationEpoch (resets on host restart); reject as stale only within the same epoch, since a different epoch is a new generation and must apply.
|
||||
if (
|
||||
current &&
|
||||
current.publicationEpoch === snapshot.publicationEpoch &&
|
||||
snapshot.snapshotVersion <= current.snapshotVersion
|
||||
snapshot.snapshotVersion <= current.snapshotVersion &&
|
||||
!isExactCurrentReplay
|
||||
) {
|
||||
return false
|
||||
}
|
||||
replayableSessionTabsSnapshotByWorktree.delete(key)
|
||||
latestSessionTabsSnapshotByWorktree.set(key, {
|
||||
publicationEpoch: snapshot.publicationEpoch,
|
||||
snapshotVersion: snapshot.snapshotVersion
|
||||
|
|
@ -283,6 +312,7 @@ export function shouldSyncAllRuntimeSessionTabs(args: {
|
|||
|
||||
export function resetWebSessionTabsSnapshotFreshnessForTests(): void {
|
||||
latestSessionTabsSnapshotByWorktree.clear()
|
||||
replayableSessionTabsSnapshotByWorktree.clear()
|
||||
lastHostTerminalTabCountByWorktree.clear()
|
||||
hostSessionTabIdByLocalKey.clear()
|
||||
}
|
||||
|
|
@ -300,9 +330,11 @@ export function _getWebSessionTabsTrackingCountsForTest(): {
|
|||
function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeId: string): void {
|
||||
const key = sessionTabsFreshnessKey(environmentId, worktreeId)
|
||||
latestSessionTabsSnapshotByWorktree.delete(key)
|
||||
replayableSessionTabsSnapshotByWorktree.delete(key)
|
||||
lastHostTerminalTabCountByWorktree.delete(key)
|
||||
clearWebRuntimeWakeTerminalRespawnForWorktree(worktreeId)
|
||||
clearWebSessionReorderIntentsForWorktree(worktreeId)
|
||||
clearWebSessionCloseIntentsForRuntimeWorktree(environmentId, worktreeId)
|
||||
const keyPrefix = `${environmentId}:${worktreeId}:`
|
||||
for (const key of hostSessionTabIdByLocalKey.keys()) {
|
||||
if (key.startsWith(keyPrefix)) {
|
||||
|
|
@ -322,6 +354,11 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string)
|
|||
latestSessionTabsSnapshotByWorktree.delete(key)
|
||||
}
|
||||
}
|
||||
for (const key of replayableSessionTabsSnapshotByWorktree.keys()) {
|
||||
if (key.startsWith(keyPrefix)) {
|
||||
replayableSessionTabsSnapshotByWorktree.delete(key)
|
||||
}
|
||||
}
|
||||
for (const key of lastHostTerminalTabCountByWorktree.keys()) {
|
||||
if (key.startsWith(keyPrefix)) {
|
||||
lastHostTerminalTabCountByWorktree.delete(key)
|
||||
|
|
@ -333,6 +370,7 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string)
|
|||
}
|
||||
}
|
||||
clearAllWebRuntimeWakeTerminalRespawn()
|
||||
clearWebSessionCloseIntentsForEnvironment(trimmedEnvironmentId)
|
||||
}
|
||||
|
||||
function hostSessionTabMappingKey(args: {
|
||||
|
|
@ -1630,16 +1668,18 @@ export function applyWebSessionTabsSnapshot(
|
|||
const snapshotHostTabId = (tab: RuntimeMobileSessionTabsResult['tabs'][number]): string =>
|
||||
tab.type === 'terminal' ? tab.parentTabId : tab.id
|
||||
reconcileWebSessionCloseIntents(
|
||||
environmentId,
|
||||
worktreeId,
|
||||
new Set(rawSnapshot.tabs.map((tab) => snapshotHostTabId(tab)))
|
||||
)
|
||||
const snapshot: RuntimeMobileSessionTabsResult = rawSnapshot.tabs.some((tab) =>
|
||||
isWebSessionCloseIntentPending(worktreeId, snapshotHostTabId(tab), now)
|
||||
isWebSessionCloseIntentPending(environmentId, worktreeId, snapshotHostTabId(tab), now)
|
||||
)
|
||||
? {
|
||||
...rawSnapshot,
|
||||
tabs: rawSnapshot.tabs.filter(
|
||||
(tab) => !isWebSessionCloseIntentPending(worktreeId, snapshotHostTabId(tab), now)
|
||||
(tab) =>
|
||||
!isWebSessionCloseIntentPending(environmentId, worktreeId, snapshotHostTabId(tab), now)
|
||||
)
|
||||
}
|
||||
: rawSnapshot
|
||||
|
|
|
|||
|
|
@ -273,6 +273,25 @@ export type RuntimeMobileSessionTabMoveResult = {
|
|||
moved: true
|
||||
}
|
||||
|
||||
export type RuntimeMobileSessionTabCloseResult = {
|
||||
closed: true
|
||||
refused?: true
|
||||
refusalReason?:
|
||||
| 'missing-intent'
|
||||
| 'stale-publication'
|
||||
| 'stale-terminal'
|
||||
| 'live-host-pty'
|
||||
| 'unknown-liveness'
|
||||
| 'retirement-owner'
|
||||
// Why: only a republished snapshot can restore a live mirror; dead-leaf refusals intentionally omit this marker.
|
||||
snapshotRepublished?: true
|
||||
}
|
||||
|
||||
// Why: lets the host tell a user's close from a client-lifecycle echo
|
||||
// ('pty-exit'/'cleanup') and adjudicate against its own PTY liveness.
|
||||
// Absent on the wire for legacy desktop clients, which new hosts conservatively refuse.
|
||||
export type RuntimeSessionTabCloseReason = 'user' | 'pty-exit' | 'cleanup'
|
||||
|
||||
export type RuntimeMobileSessionTabsSnapshot = {
|
||||
worktree: string
|
||||
publicationEpoch: string
|
||||
|
|
|
|||
|
|
@ -0,0 +1,450 @@
|
|||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { expect, test, type TestInfo } from '@playwright/test'
|
||||
import { DaemonClient } from '../../src/main/daemon/client'
|
||||
import { PROTOCOL_VERSION } from '../../src/main/daemon/types'
|
||||
import {
|
||||
cleanupDaemonGenerationFixtures,
|
||||
createDaemonGenerationRuntime,
|
||||
launchDaemonGeneration,
|
||||
spawnGenerationCanary,
|
||||
type DaemonGeneration,
|
||||
type DaemonGenerationRuntime,
|
||||
type GenerationCanary
|
||||
} from './helpers/daemon-generation-safety-fixtures'
|
||||
import {
|
||||
processIdentityLiveness,
|
||||
recordProcessIdentity,
|
||||
recordProcessTree,
|
||||
terminateRecordedTree,
|
||||
waitForCondition,
|
||||
type RecordedProcessIdentity
|
||||
} from './helpers/daemon-generation-processes'
|
||||
|
||||
const GENERATION_PROTOCOLS = [21, 22, 23] as const
|
||||
const ALL_GENERATION_PROTOCOLS = [
|
||||
...new Set([...GENERATION_PROTOCOLS, PROTOCOL_VERSION - 1, PROTOCOL_VERSION])
|
||||
]
|
||||
const configuredReconnectBursts = Number.parseInt(
|
||||
process.env.ORCA_DAEMON_GENERATION_RECONNECT_BURSTS ?? '3',
|
||||
10
|
||||
)
|
||||
const RECONNECT_BURSTS =
|
||||
Number.isInteger(configuredReconnectBursts) && configuredReconnectBursts > 0
|
||||
? configuredReconnectBursts
|
||||
: 3
|
||||
|
||||
type LivenessReport = {
|
||||
daemons: Record<string, boolean>
|
||||
roots: Record<string, boolean>
|
||||
descendants: Record<string, boolean>
|
||||
}
|
||||
|
||||
type CloseBurstReport = {
|
||||
closeAttempts: Record<string, number>
|
||||
}
|
||||
|
||||
function generationLabel(protocolVersion: number): string {
|
||||
return `generation-v${protocolVersion}`
|
||||
}
|
||||
|
||||
function canaryLabel(canary: GenerationCanary): string {
|
||||
return `${canary.generation.label}-${canary.role}`
|
||||
}
|
||||
|
||||
function killEvents(generation: DaemonGeneration, sessionId: string): Record<string, unknown>[] {
|
||||
return generation
|
||||
.logEvents()
|
||||
.filter((event) => event.event === 'session-killed' && event.sessionId === sessionId)
|
||||
}
|
||||
|
||||
function helloCount(generation: DaemonGeneration): number {
|
||||
return generation.logEvents().filter((event) => event.event === 'client-hello-accepted').length
|
||||
}
|
||||
|
||||
async function collectLiveness(
|
||||
generations: readonly DaemonGeneration[],
|
||||
canaries: readonly GenerationCanary[]
|
||||
): Promise<LivenessReport> {
|
||||
const identities: RecordedProcessIdentity[] = [
|
||||
...generations.map((generation) => generation.identity),
|
||||
...canaries.flatMap((canary) => [canary.rootIdentity, canary.descendantIdentity])
|
||||
]
|
||||
const live = await processIdentityLiveness(identities)
|
||||
return {
|
||||
daemons: Object.fromEntries(
|
||||
generations.map((generation) => [
|
||||
generation.label,
|
||||
live.get(generation.identity.pid) === true
|
||||
])
|
||||
),
|
||||
roots: Object.fromEntries(
|
||||
canaries.map((canary) => [canaryLabel(canary), live.get(canary.rootIdentity.pid) === true])
|
||||
),
|
||||
descendants: Object.fromEntries(
|
||||
canaries.map((canary) => [
|
||||
canaryLabel(canary),
|
||||
live.get(canary.descendantIdentity.pid) === true
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function launchReconnectClient(options: {
|
||||
runtime: DaemonGenerationRuntime
|
||||
generations: readonly DaemonGeneration[]
|
||||
canaries: readonly GenerationCanary[]
|
||||
}): { child: ChildProcess; ready: Promise<CloseBurstReport>; finish(): void; output(): string } {
|
||||
const { runtime, generations, canaries } = options
|
||||
const configPath = path.join(runtime.rootDir, 'reconnect-client-config.json')
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify({
|
||||
generations: generations.map((generation) => ({
|
||||
protocolVersion: generation.protocolVersion,
|
||||
socketPath: generation.socketPath,
|
||||
tokenPath: generation.tokenPath
|
||||
})),
|
||||
currentProtocolVersion: PROTOCOL_VERSION,
|
||||
daemonDir: runtime.daemonDir,
|
||||
historyDir: path.join(runtime.userDataDir, 'terminal-history'),
|
||||
sessions: canaries.map((canary) => ({
|
||||
protocolVersion: canary.generation.protocolVersion,
|
||||
sessionId: canary.sessionId,
|
||||
rootPid: canary.rootIdentity.pid,
|
||||
label: canaryLabel(canary),
|
||||
role: canary.role
|
||||
})),
|
||||
reconnectBursts: RECONNECT_BURSTS,
|
||||
cwd: runtime.rootDir
|
||||
})}\n`
|
||||
)
|
||||
let output = ''
|
||||
const child = fork(runtime.reconnectClientEntryPath, ['--config', configPath], {
|
||||
cwd: runtime.userDataDir,
|
||||
execPath: runtime.electronPath,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
NODE_PATH: path.join(process.cwd(), 'node_modules'),
|
||||
ORCA_USER_DATA_PATH: runtime.userDataDir
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc']
|
||||
})
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
output = `${output}${chunk.toString('utf8')}`.slice(-32_768)
|
||||
})
|
||||
const ready = new Promise<CloseBurstReport>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`Reconnect client timed out: ${output}`)),
|
||||
60_000
|
||||
)
|
||||
const settle = (callback: () => void): void => {
|
||||
clearTimeout(timer)
|
||||
child.off('message', onMessage)
|
||||
child.off('exit', onExit)
|
||||
callback()
|
||||
}
|
||||
const onExit = (code: number | null): void =>
|
||||
settle(() => reject(new Error(`Reconnect client exited with ${code}: ${output}`)))
|
||||
const onMessage = (message: unknown): void => {
|
||||
const payload = message as {
|
||||
type?: unknown
|
||||
message?: unknown
|
||||
closeAttempts?: Record<string, number>
|
||||
}
|
||||
if (payload.type === 'error') {
|
||||
settle(() => reject(new Error(String(payload.message))))
|
||||
} else if (payload.type === 'close-bursts-complete') {
|
||||
settle(() => resolve({ closeAttempts: payload.closeAttempts ?? {} }))
|
||||
}
|
||||
}
|
||||
child.on('message', onMessage)
|
||||
child.once('exit', onExit)
|
||||
})
|
||||
return {
|
||||
child,
|
||||
ready,
|
||||
finish: () => {
|
||||
if (!child.connected) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
child.send?.({ type: 'finish' }, () => {})
|
||||
} catch {
|
||||
// The fixture can finish between the connected check and the IPC write.
|
||||
}
|
||||
},
|
||||
output: () => output
|
||||
}
|
||||
}
|
||||
|
||||
async function finishReconnectClient(
|
||||
client: ReturnType<typeof launchReconnectClient>
|
||||
): Promise<void> {
|
||||
if (!client.child.pid || client.child.exitCode !== null) {
|
||||
return
|
||||
}
|
||||
const identity = await recordProcessIdentity(client.child.pid)
|
||||
client.finish()
|
||||
try {
|
||||
await waitForCondition('reconnect client exit', () => client.child.exitCode !== null, 2_000)
|
||||
} catch {
|
||||
await terminateRecordedTree(await recordProcessTree(identity))
|
||||
}
|
||||
}
|
||||
|
||||
function writeEventReconstruction(options: {
|
||||
testInfo: TestInfo
|
||||
generations: readonly DaemonGeneration[]
|
||||
beforeClose: LivenessReport
|
||||
afterClose: LivenessReport
|
||||
helloBaselines: ReadonlyMap<number, number>
|
||||
targetCanaries: readonly GenerationCanary[]
|
||||
closeBurst: CloseBurstReport
|
||||
clientPid: number | undefined
|
||||
}): void {
|
||||
const {
|
||||
testInfo,
|
||||
generations,
|
||||
beforeClose,
|
||||
afterClose,
|
||||
helloBaselines,
|
||||
targetCanaries,
|
||||
closeBurst,
|
||||
clientPid
|
||||
} = options
|
||||
writeFileSync(
|
||||
testInfo.outputPath('daemon-generation-reconnect-events.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
capturedAt: new Date().toISOString(),
|
||||
reconnectClientPid: clientPid,
|
||||
beforeClose,
|
||||
afterClose,
|
||||
closeBurst,
|
||||
generations: generations.map((generation) => ({
|
||||
label: generation.label,
|
||||
protocolVersion: generation.protocolVersion,
|
||||
daemonPid: generation.identity.pid,
|
||||
daemonStartedAtMs: generation.identity.startedAtMs,
|
||||
acceptedHellosDuringReconnect:
|
||||
helloCount(generation) - (helloBaselines.get(generation.protocolVersion) ?? 0),
|
||||
targetKills: targetCanaries
|
||||
.filter((canary) => canary.generation === generation)
|
||||
.map((canary) => ({
|
||||
sessionId: canary.sessionId,
|
||||
events: killEvents(generation, canary.sessionId)
|
||||
}))
|
||||
}))
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
async function cleanupGenerationTestFixtures(options: {
|
||||
runtime: DaemonGenerationRuntime
|
||||
generations: readonly DaemonGeneration[]
|
||||
canaries: readonly GenerationCanary[]
|
||||
retainDiagnostics: boolean
|
||||
}): Promise<void> {
|
||||
const { runtime, generations, canaries, retainDiagnostics } = options
|
||||
if (retainDiagnostics) {
|
||||
runtime.retainDiagnostics(generations)
|
||||
}
|
||||
try {
|
||||
await cleanupDaemonGenerationFixtures({ generations, canaries })
|
||||
} catch (error) {
|
||||
runtime.retainDiagnostics(generations)
|
||||
throw error
|
||||
}
|
||||
runtime.remove()
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('native Windows reconnect cannot turn stale mirror exits into cross-generation kills', async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires the fixture argument before testInfo.
|
||||
{}, testInfo) => {
|
||||
test.skip(process.platform !== 'win32', 'Native Windows named pipes and ConPTY are required')
|
||||
test.setTimeout(180_000)
|
||||
const fixtureRuntime = await createDaemonGenerationRuntime(testInfo)
|
||||
const generations: DaemonGeneration[] = []
|
||||
const canaries: GenerationCanary[] = []
|
||||
let client: ReturnType<typeof launchReconnectClient> | null = null
|
||||
let assertionsComplete = false
|
||||
|
||||
try {
|
||||
for (const protocolVersion of ALL_GENERATION_PROTOCOLS) {
|
||||
const generation = await launchDaemonGeneration({
|
||||
runtime: fixtureRuntime,
|
||||
label: generationLabel(protocolVersion),
|
||||
protocolVersion
|
||||
})
|
||||
generations.push(generation)
|
||||
for (const role of ['live', 'stale-mirror'] as const) {
|
||||
canaries.push(await spawnGenerationCanary({ runtime: fixtureRuntime, generation, role }))
|
||||
}
|
||||
}
|
||||
expect(new Set(generations.map((generation) => generation.socketPath)).size).toBe(
|
||||
ALL_GENERATION_PROTOCOLS.length
|
||||
)
|
||||
expect(
|
||||
generations.every(
|
||||
(generation) =>
|
||||
generation.socketPath.startsWith('\\\\') &&
|
||||
generation.socketPath.includes(
|
||||
`\\pipe\\orca-terminal-host-v${generation.protocolVersion}-`
|
||||
)
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
const helloBaselines = new Map(
|
||||
generations.map((generation) => [generation.protocolVersion, helloCount(generation)])
|
||||
)
|
||||
for (const canary of canaries) {
|
||||
await canary.adapter.disconnectOnly()
|
||||
canary.adapter.dispose()
|
||||
}
|
||||
const beforeClose = await collectLiveness(generations, canaries)
|
||||
expect(Object.values(beforeClose.daemons).every(Boolean)).toBe(true)
|
||||
expect(Object.values(beforeClose.roots).every(Boolean)).toBe(true)
|
||||
expect(Object.values(beforeClose.descendants).every(Boolean)).toBe(true)
|
||||
|
||||
client = launchReconnectClient({ runtime: fixtureRuntime, generations, canaries })
|
||||
const closeBurst = await client.ready
|
||||
expect(client.child.exitCode).toBeNull()
|
||||
for (const generation of generations) {
|
||||
expect(
|
||||
helloCount(generation) - helloBaselines.get(generation.protocolVersion)!
|
||||
).toBeGreaterThanOrEqual(RECONNECT_BURSTS * 2)
|
||||
}
|
||||
|
||||
const targetCanaries = canaries.filter((canary) => canary.role === 'stale-mirror')
|
||||
expect(Object.values(closeBurst.closeAttempts)).toHaveLength(targetCanaries.length)
|
||||
expect(Object.values(closeBurst.closeAttempts).every((attempts) => attempts === 3)).toBe(true)
|
||||
const afterFirstClient = await collectLiveness(generations, canaries)
|
||||
expect(client.child.exitCode).toBeNull()
|
||||
expect(Object.values(afterFirstClient.roots).every(Boolean)).toBe(true)
|
||||
expect(Object.values(afterFirstClient.descendants).every(Boolean)).toBe(true)
|
||||
|
||||
const firstClient = client
|
||||
await finishReconnectClient(firstClient)
|
||||
client = launchReconnectClient({ runtime: fixtureRuntime, generations, canaries })
|
||||
const relaunchCloseBurst = await client.ready
|
||||
expect(client.child.exitCode).toBeNull()
|
||||
const combinedCloseBurst: CloseBurstReport = {
|
||||
closeAttempts: Object.fromEntries(
|
||||
Object.entries(closeBurst.closeAttempts).map(([tabId, attempts]) => [
|
||||
tabId,
|
||||
attempts + (relaunchCloseBurst.closeAttempts[tabId] ?? 0)
|
||||
])
|
||||
)
|
||||
}
|
||||
expect(
|
||||
Object.values(combinedCloseBurst.closeAttempts).every((attempts) => attempts === 6)
|
||||
).toBe(true)
|
||||
const afterClose = await collectLiveness(generations, canaries)
|
||||
writeEventReconstruction({
|
||||
testInfo,
|
||||
generations,
|
||||
beforeClose,
|
||||
afterClose,
|
||||
helloBaselines,
|
||||
targetCanaries,
|
||||
closeBurst: combinedCloseBurst,
|
||||
clientPid: client.child.pid
|
||||
})
|
||||
expect(client.child.exitCode).toBeNull()
|
||||
expect(Object.values(afterClose.daemons).every(Boolean)).toBe(true)
|
||||
expect(Object.values(afterClose.roots).every(Boolean)).toBe(true)
|
||||
expect(Object.values(afterClose.descendants).every(Boolean)).toBe(true)
|
||||
// Why: each stale mirror crossed desktop and two remote-profile close paths
|
||||
// before and after an app-process relaunch; process health cannot hide a kill.
|
||||
expect(
|
||||
targetCanaries.every((canary) => killEvents(canary.generation, canary.sessionId).length === 0)
|
||||
).toBe(true)
|
||||
expect(targetCanaries.every((canary) => afterClose.roots[canaryLabel(canary)])).toBe(true)
|
||||
assertionsComplete = true
|
||||
} finally {
|
||||
if (client) {
|
||||
await finishReconnectClient(client)
|
||||
}
|
||||
await cleanupGenerationTestFixtures({
|
||||
runtime: fixtureRuntime,
|
||||
generations,
|
||||
canaries,
|
||||
retainDiagnostics: !assertionsComplete
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('shutdown disposal failure drops authority within a bounded window', async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires the fixture argument before testInfo.
|
||||
{}, testInfo) => {
|
||||
test.skip(process.platform !== 'win32', 'Native Windows named pipes and ConPTY are required')
|
||||
test.setTimeout(60_000)
|
||||
const fixtureRuntime = await createDaemonGenerationRuntime(testInfo)
|
||||
const generations: DaemonGeneration[] = []
|
||||
const canaries: GenerationCanary[] = []
|
||||
let assertionsComplete = false
|
||||
|
||||
try {
|
||||
const generation = await launchDaemonGeneration({
|
||||
runtime: fixtureRuntime,
|
||||
label: 'generation-v23-refused-dispose',
|
||||
protocolVersion: 23,
|
||||
refuseDispose: true
|
||||
})
|
||||
generations.push(generation)
|
||||
const canary = await spawnGenerationCanary({
|
||||
runtime: fixtureRuntime,
|
||||
generation,
|
||||
role: 'live'
|
||||
})
|
||||
canaries.push(canary)
|
||||
const shutdownClient = new DaemonClient({
|
||||
socketPath: generation.socketPath,
|
||||
tokenPath: generation.tokenPath,
|
||||
protocolVersion: generation.protocolVersion
|
||||
})
|
||||
await shutdownClient.ensureConnected()
|
||||
|
||||
const startedAt = Date.now()
|
||||
await expect(shutdownClient.request('shutdown', { killSessions: true })).resolves.toEqual({})
|
||||
shutdownClient.disconnect()
|
||||
expect(Date.now() - startedAt).toBeLessThan(15_000)
|
||||
await waitForCondition('shutdown-dispose-failed log', () =>
|
||||
generation.logEvents().some((event) => event.event === 'shutdown-dispose-failed')
|
||||
)
|
||||
const lateClient = new DaemonClient({
|
||||
socketPath: generation.socketPath,
|
||||
tokenPath: generation.tokenPath,
|
||||
protocolVersion: generation.protocolVersion
|
||||
})
|
||||
await expect(lateClient.ensureConnected()).rejects.toThrow()
|
||||
lateClient.disconnect()
|
||||
const fencedLiveness = await collectLiveness(generations, canaries)
|
||||
// Why: disposal failure may strand the fixture process, but fencing must
|
||||
// remove authority without pretending that the retained PTY disappeared.
|
||||
expect(fencedLiveness.daemons[generation.label]).toBe(true)
|
||||
expect(fencedLiveness.roots[canaryLabel(canary)]).toBe(true)
|
||||
expect(fencedLiveness.descendants[canaryLabel(canary)]).toBe(true)
|
||||
expect(generation.logEvents()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ event: 'shutdown', reason: 'rpc', killSessions: true }),
|
||||
expect.objectContaining({ event: 'shutdown-dispose-failed' })
|
||||
])
|
||||
)
|
||||
assertionsComplete = true
|
||||
} finally {
|
||||
await cleanupGenerationTestFixtures({
|
||||
runtime: fixtureRuntime,
|
||||
generations,
|
||||
canaries,
|
||||
retainDiagnostics: !assertionsComplete
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
const { spawn } = require('node:child_process')
|
||||
const readline = require('node:readline')
|
||||
|
||||
const [label, nonce] = process.argv.slice(2)
|
||||
if (!label || !nonce) {
|
||||
throw new Error('Usage: daemon-generation-canary <label> <nonce>')
|
||||
}
|
||||
|
||||
const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60_000)'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
console.log(`ORCA_GENERATION_CANARY_READY ${label} ${nonce} ${descendant.pid}`)
|
||||
|
||||
const input = readline.createInterface({ input: process.stdin })
|
||||
input.on('line', (line) => {
|
||||
const prefix = `PING ${label} `
|
||||
if (line.startsWith(prefix)) {
|
||||
console.log(`ORCA_GENERATION_CANARY_ACK ${label} ${line.slice(prefix.length)}`)
|
||||
}
|
||||
})
|
||||
|
||||
function shutdown() {
|
||||
input.close()
|
||||
if (descendant.pid) {
|
||||
try {
|
||||
process.kill(descendant.pid, 'SIGTERM')
|
||||
} catch {
|
||||
// The fixture-only process tree may already have been reaped.
|
||||
}
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGTERM', shutdown)
|
||||
process.on('SIGINT', shutdown)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { DaemonPtyAdapter } from '../../../src/main/daemon/daemon-pty-adapter'
|
||||
import { createLegacyDaemonAdapters } from '../../../src/main/daemon/daemon-init'
|
||||
import { DaemonPtyRouter } from '../../../src/main/daemon/daemon-pty-router'
|
||||
|
||||
export type DesktopDiscoveryGeneration = {
|
||||
protocolVersion: number
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
}
|
||||
|
||||
export async function createDesktopDiscoveredDaemonRouter(options: {
|
||||
generations: readonly DesktopDiscoveryGeneration[]
|
||||
currentProtocolVersion: number
|
||||
daemonDir: string
|
||||
historyDir: string
|
||||
}): Promise<{ router: DaemonPtyRouter; adapters: readonly DaemonPtyAdapter[] }> {
|
||||
const currentGeneration = options.generations.find(
|
||||
(generation) => generation.protocolVersion === options.currentProtocolVersion
|
||||
)
|
||||
if (!currentGeneration) {
|
||||
throw new Error(`Missing current protocol v${options.currentProtocolVersion} fixture`)
|
||||
}
|
||||
const current = new DaemonPtyAdapter({
|
||||
socketPath: currentGeneration.socketPath,
|
||||
tokenPath: currentGeneration.tokenPath,
|
||||
protocolVersion: currentGeneration.protocolVersion,
|
||||
historyPath: options.historyDir
|
||||
})
|
||||
// Why: exercise the desktop startup scanner itself, including versioned
|
||||
// named-pipe probing and legacy-adapter construction, not a fixture copy.
|
||||
const legacy = await createLegacyDaemonAdapters(options.daemonDir, options.historyDir)
|
||||
const discoveredProtocols = legacy.map((adapter) => adapter.protocolVersion)
|
||||
const expectedProtocols = options.generations
|
||||
.filter((generation) => generation.protocolVersion !== options.currentProtocolVersion)
|
||||
.map((generation) => generation.protocolVersion)
|
||||
if (JSON.stringify(discoveredProtocols) !== JSON.stringify(expectedProtocols)) {
|
||||
throw new Error(
|
||||
`Desktop legacy discovery mismatch: expected ${expectedProtocols.join(',')}, received ${discoveredProtocols.join(',')}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
router: new DaemonPtyRouter({ current, legacy }),
|
||||
adapters: [current, ...legacy]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import process from 'node:process'
|
||||
import { startDaemon, type DaemonHandle } from '../../../src/main/daemon/daemon-main'
|
||||
import { createPtySubprocess } from '../../../src/main/daemon/pty-subprocess'
|
||||
import { createDaemonFileLog } from '../../../src/main/daemon/daemon-file-log'
|
||||
|
||||
type FixtureArgs = {
|
||||
protocolVersion: number
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
logPath: string
|
||||
refuseDispose: boolean
|
||||
}
|
||||
|
||||
function parseFixtureArgs(argv: string[]): FixtureArgs {
|
||||
const values = new Map<string, string>()
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (!key || !value) {
|
||||
throw new Error('Daemon generation fixture arguments must be key/value pairs')
|
||||
}
|
||||
values.set(key, value)
|
||||
}
|
||||
|
||||
const protocolVersion = Number(values.get('--protocol'))
|
||||
const socketPath = values.get('--socket')
|
||||
const tokenPath = values.get('--token')
|
||||
const logPath = values.get('--log')
|
||||
const refuseDispose = values.get('--refuse-dispose') === 'true'
|
||||
if (
|
||||
!Number.isInteger(protocolVersion) ||
|
||||
protocolVersion < 1 ||
|
||||
!socketPath ||
|
||||
!tokenPath ||
|
||||
!logPath
|
||||
) {
|
||||
throw new Error(
|
||||
'Usage: daemon-generation-entry --protocol <n> --socket <path> --token <path> --log <path>'
|
||||
)
|
||||
}
|
||||
return { protocolVersion, socketPath, tokenPath, logPath, refuseDispose }
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { protocolVersion, socketPath, tokenPath, logPath, refuseDispose } = parseFixtureArgs(
|
||||
process.argv.slice(2)
|
||||
)
|
||||
let daemon: DaemonHandle | null = await startDaemon({
|
||||
protocolVersion,
|
||||
socketPath,
|
||||
tokenPath,
|
||||
log: createDaemonFileLog(logPath),
|
||||
spawnSubprocess: (options) => {
|
||||
const subprocess = createPtySubprocess(options)
|
||||
if (refuseDispose) {
|
||||
// Why: models an access-denied/unreapable Windows PTY while keeping the
|
||||
// real child and ConPTY handle inside this disposable fixture tree.
|
||||
subprocess.kill = () => {}
|
||||
subprocess.forceKill = () => {}
|
||||
}
|
||||
return subprocess
|
||||
}
|
||||
})
|
||||
let shuttingDown = false
|
||||
const shutdown = async (): Promise<void> => {
|
||||
if (shuttingDown) {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
try {
|
||||
await daemon?.shutdown()
|
||||
daemon = null
|
||||
} finally {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => void shutdown())
|
||||
process.on('SIGINT', () => void shutdown())
|
||||
process.send?.({
|
||||
type: 'ready',
|
||||
protocolVersion,
|
||||
startedAtMs: Date.now() - process.uptime() * 1000
|
||||
})
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const DAEMON_GENERATION_WORKTREE_ID = 'fixture-worktree'
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { DaemonPtyAdapter } from '../../../src/main/daemon/daemon-pty-adapter'
|
||||
import type { DaemonPtyRouter } from '../../../src/main/daemon/daemon-pty-router'
|
||||
import { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime'
|
||||
import { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher'
|
||||
import { SESSION_TAB_METHODS } from '../../../src/main/runtime/rpc/methods/session-tabs'
|
||||
import type { RuntimeMobileSessionTabsSnapshot } from '../../../src/shared/runtime-types'
|
||||
import { createDesktopDiscoveredDaemonRouter } from './daemon-generation-desktop-discovery'
|
||||
import { dispatchFixtureCloseBursts } from './daemon-generation-runtime-close'
|
||||
import { DAEMON_GENERATION_WORKTREE_ID } from './daemon-generation-fixture-contract'
|
||||
|
||||
type ClientGeneration = {
|
||||
protocolVersion: number
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
}
|
||||
|
||||
type ClientSession = {
|
||||
protocolVersion: number
|
||||
sessionId: string
|
||||
rootPid: number
|
||||
label: string
|
||||
role: 'live' | 'stale-mirror'
|
||||
}
|
||||
|
||||
type ClientConfig = {
|
||||
generations: ClientGeneration[]
|
||||
currentProtocolVersion: number
|
||||
daemonDir: string
|
||||
historyDir: string
|
||||
sessions: ClientSession[]
|
||||
reconnectBursts: number
|
||||
cwd: string
|
||||
}
|
||||
|
||||
const WORKTREE_ID = DAEMON_GENERATION_WORKTREE_ID
|
||||
const PARENT_TAB_ID = 'remote-reconnect-tab'
|
||||
const MAX_OUTPUT_CHARS = 32_768
|
||||
|
||||
function readConfig(): ClientConfig {
|
||||
const configIndex = process.argv.indexOf('--config')
|
||||
const configPath = configIndex >= 0 ? process.argv[configIndex + 1] : undefined
|
||||
if (!configPath) {
|
||||
throw new Error('Reconnect client requires --config <path>')
|
||||
}
|
||||
return JSON.parse(readFileSync(configPath, 'utf8')) as ClientConfig
|
||||
}
|
||||
|
||||
async function waitFor(description: string, predicate: () => boolean): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (Date.now() <= deadline) {
|
||||
if (predicate()) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${description}`)
|
||||
}
|
||||
|
||||
function createDirectAdapters(
|
||||
generations: readonly ClientGeneration[]
|
||||
): Map<number, DaemonPtyAdapter> {
|
||||
return new Map(
|
||||
generations.map((generation) => [
|
||||
generation.protocolVersion,
|
||||
new DaemonPtyAdapter({
|
||||
socketPath: generation.socketPath,
|
||||
tokenPath: generation.tokenPath,
|
||||
protocolVersion: generation.protocolVersion
|
||||
})
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
async function connectThroughDesktopDiscovery(
|
||||
config: ClientConfig,
|
||||
burst: number
|
||||
): Promise<DaemonPtyRouter> {
|
||||
const { router, adapters } = await createDesktopDiscoveredDaemonRouter(config)
|
||||
const outputBySessionId = new Map<string, string>()
|
||||
for (const adapter of adapters) {
|
||||
const protocolVersion = adapter.protocolVersion
|
||||
adapter.onData((event) => {
|
||||
const session = config.sessions.find(
|
||||
(candidate) =>
|
||||
candidate.protocolVersion === protocolVersion && candidate.sessionId === event.id
|
||||
)
|
||||
if (session) {
|
||||
outputBySessionId.set(
|
||||
event.id,
|
||||
`${outputBySessionId.get(event.id) ?? ''}${event.data}`.slice(-MAX_OUTPUT_CHARS)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await router.getCurrentAdapter().listProcesses()
|
||||
await router.discoverLegacySessions()
|
||||
for (const session of config.sessions) {
|
||||
const attached = await router.spawn({
|
||||
sessionId: session.sessionId,
|
||||
isNewSession: false,
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cwd: config.cwd
|
||||
})
|
||||
if (!attached.isReattach || attached.pid !== session.rootPid) {
|
||||
throw new Error(`Reconnect changed ${session.label} process incarnation`)
|
||||
}
|
||||
const nonce = `reconnect-${burst}-${randomUUID().slice(0, 8)}`
|
||||
router.write(session.sessionId, `PING ${session.label} ${nonce}\r`)
|
||||
await waitFor(`${session.label} reconnect reply`, () =>
|
||||
(outputBySessionId.get(session.sessionId) ?? '').includes(
|
||||
`ORCA_GENERATION_CANARY_ACK ${session.label} ${nonce}`
|
||||
)
|
||||
)
|
||||
}
|
||||
return router
|
||||
}
|
||||
|
||||
async function connectParallelRuntimeClients(config: ClientConfig): Promise<void> {
|
||||
const clients = [...createDirectAdapters(config.generations).values()]
|
||||
try {
|
||||
const lists = await Promise.all(clients.map((client) => client.listProcesses()))
|
||||
if (!lists.every((sessions) => sessions.length === 2)) {
|
||||
throw new Error('Parallel runtime client did not see both sessions in every generation')
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(clients.map((client) => client.disconnectOnly()))
|
||||
clients.forEach((client) => client.dispose())
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntimeClosePath(
|
||||
config: ClientConfig,
|
||||
router: DaemonPtyRouter
|
||||
): Promise<{
|
||||
dispatcher: RpcDispatcher
|
||||
targets: Map<string, { publicationEpoch: string; terminal: string }>
|
||||
pendingShutdowns: Promise<void>[]
|
||||
}> {
|
||||
const pendingShutdowns: Promise<void>[] = []
|
||||
const surfaces = config.sessions.map((session, index) => {
|
||||
// Why: app relaunch must target the same persisted mirror incarnation, not
|
||||
// accidentally mint a different tab identity that weakens the repetition proof.
|
||||
const leafId = `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`
|
||||
return { session, leafId, mobileTabId: `${PARENT_TAB_ID}::${leafId}` }
|
||||
})
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setPtyController({
|
||||
write: (ptyId, data) => {
|
||||
router.write(ptyId, data)
|
||||
return true
|
||||
},
|
||||
kill: (ptyId) => {
|
||||
const shutdown = router.shutdown(ptyId, { immediate: false })
|
||||
pendingShutdowns.push(shutdown)
|
||||
return true
|
||||
},
|
||||
listProcesses: (options) => router.listProcesses(options),
|
||||
hasPty: (ptyId) => router.hasPty(ptyId),
|
||||
getForegroundProcess: (ptyId) => router.getForegroundProcess(ptyId)
|
||||
})
|
||||
runtime.setNotifier({
|
||||
closeTerminal: (tabId: string) => {
|
||||
throw new Error(`Reconnect fixture unexpectedly fell back to renderer tab close: ${tabId}`)
|
||||
}
|
||||
} as never)
|
||||
runtime.attachWindow(1)
|
||||
const snapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'stale-remote-mirror',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: surfaces[0]?.mobileTabId ?? null,
|
||||
activeTabType: surfaces.length > 0 ? 'terminal' : null,
|
||||
tabs: surfaces.map((surface, index) => ({
|
||||
type: 'terminal',
|
||||
id: surface.mobileTabId,
|
||||
parentTabId: PARENT_TAB_ID,
|
||||
leafId: surface.leafId,
|
||||
ptyId: surface.session.sessionId,
|
||||
title: surface.session.label,
|
||||
isActive: index === 0
|
||||
}))
|
||||
}
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: PARENT_TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Reconnect fixture',
|
||||
activeLeafId: surfaces[0]?.leafId ?? null,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: surfaces.map((surface, index) => ({
|
||||
tabId: PARENT_TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: surface.leafId,
|
||||
paneRuntimeId: index + 1,
|
||||
ptyId: surface.session.sessionId,
|
||||
paneTitle: surface.session.label
|
||||
})),
|
||||
mobileSessionTabs: [snapshot]
|
||||
})
|
||||
return runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`).then((accepted) => {
|
||||
const acceptedTerminalIds = accepted.tabs.flatMap((tab) =>
|
||||
tab.type === 'terminal' ? [tab.id] : []
|
||||
)
|
||||
if (!surfaces.every((surface) => acceptedTerminalIds.includes(surface.mobileTabId))) {
|
||||
throw new Error(`Runtime did not retain reconnect surfaces: ${JSON.stringify(accepted.tabs)}`)
|
||||
}
|
||||
const internals = runtime as unknown as {
|
||||
tabs: Map<string, unknown>
|
||||
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
|
||||
}
|
||||
const stored = internals.mobileSessionTabsByWorktree.get(WORKTREE_ID)
|
||||
const storedLeafCount =
|
||||
stored?.tabs.filter((tab) => tab.type === 'terminal' && tab.parentTabId === PARENT_TAB_ID)
|
||||
.length ?? 0
|
||||
if (!internals.tabs.has(PARENT_TAB_ID) || storedLeafCount !== surfaces.length) {
|
||||
throw new Error(
|
||||
`Runtime close precondition drifted: parent=${internals.tabs.has(PARENT_TAB_ID)} leaves=${storedLeafCount}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
dispatcher: new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }),
|
||||
targets: new Map(
|
||||
surfaces
|
||||
.filter((surface) => surface.session.role === 'stale-mirror')
|
||||
.map((surface) => {
|
||||
const acceptedTab = accepted.tabs.find((tab) => tab.id === surface.mobileTabId)
|
||||
if (!acceptedTab || acceptedTab.type !== 'terminal' || acceptedTab.status !== 'ready') {
|
||||
throw new Error(`Missing lifecycle claim for ${surface.mobileTabId}`)
|
||||
}
|
||||
return [
|
||||
surface.mobileTabId,
|
||||
{
|
||||
publicationEpoch: accepted.publicationEpoch,
|
||||
terminal: acceptedTab.terminal
|
||||
}
|
||||
] as const
|
||||
})
|
||||
),
|
||||
pendingShutdowns
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForFinish(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
process.on('message', (message) => {
|
||||
if ((message as { type?: unknown })?.type === 'finish') {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = readConfig()
|
||||
let router: DaemonPtyRouter | null = null
|
||||
try {
|
||||
for (let burst = 1; burst <= config.reconnectBursts; burst += 1) {
|
||||
if (router) {
|
||||
await router.disconnectOnly()
|
||||
router.dispose()
|
||||
}
|
||||
router = await connectThroughDesktopDiscovery(config, burst)
|
||||
}
|
||||
await connectParallelRuntimeClients(config)
|
||||
const { dispatcher, targets, pendingShutdowns } = await createRuntimeClosePath(config, router)
|
||||
await dispatchFixtureCloseBursts({ dispatcher, worktreeId: WORKTREE_ID, targets })
|
||||
const shutdownResults = await Promise.allSettled(pendingShutdowns)
|
||||
const finish = waitForFinish()
|
||||
process.send?.({
|
||||
type: 'close-bursts-complete',
|
||||
closeAttempts: Object.fromEntries([...targets.keys()].map((tabId) => [tabId, 3])),
|
||||
shutdownResults: shutdownResults.map((result) => result.status)
|
||||
})
|
||||
await finish
|
||||
} finally {
|
||||
await router?.disconnectOnly().catch(() => {})
|
||||
router?.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
process.send?.({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.stack : String(error)
|
||||
})
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import type { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher'
|
||||
|
||||
async function dispatchSyntheticExitClose(options: {
|
||||
dispatcher: RpcDispatcher
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
publicationEpoch: string
|
||||
terminal: string
|
||||
requestId: string
|
||||
connection?: { connectionId: string; pairedDeviceId: string }
|
||||
}): Promise<void> {
|
||||
const { dispatcher, worktreeId, tabId, publicationEpoch, terminal, requestId, connection } =
|
||||
options
|
||||
const request = {
|
||||
id: requestId,
|
||||
authToken: 'fixture-only',
|
||||
method: 'session.tabs.closeLifecycle',
|
||||
params: {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId,
|
||||
reason: 'pty-exit',
|
||||
publicationEpoch,
|
||||
terminal
|
||||
}
|
||||
}
|
||||
const response = connection
|
||||
? await new Promise<Awaited<ReturnType<RpcDispatcher['dispatch']>>>((resolve, reject) => {
|
||||
void dispatcher
|
||||
.dispatchStreaming(
|
||||
request,
|
||||
(serialized) =>
|
||||
resolve(JSON.parse(serialized) as Awaited<ReturnType<RpcDispatcher['dispatch']>>),
|
||||
{
|
||||
clientKind: 'runtime',
|
||||
connectionId: connection.connectionId,
|
||||
pairedDeviceId: connection.pairedDeviceId
|
||||
}
|
||||
)
|
||||
.catch(reject)
|
||||
})
|
||||
: await dispatcher.dispatch(request)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Synthetic close ${requestId} failed: ${response.error.code}: ${response.error.message} ${JSON.stringify(response.error.data ?? null)}`
|
||||
)
|
||||
}
|
||||
const result = response.result as {
|
||||
refused?: unknown
|
||||
refusalReason?: unknown
|
||||
snapshotRepublished?: unknown
|
||||
}
|
||||
if (
|
||||
result.refused !== true ||
|
||||
result.refusalReason !== 'live-host-pty' ||
|
||||
result.snapshotRepublished !== true
|
||||
) {
|
||||
throw new Error(
|
||||
`Synthetic close ${requestId} bypassed live-host adjudication: ${JSON.stringify(result)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function dispatchFixtureCloseBursts(options: {
|
||||
dispatcher: RpcDispatcher
|
||||
worktreeId: string
|
||||
targets: ReadonlyMap<string, { publicationEpoch: string; terminal: string }>
|
||||
}): Promise<void> {
|
||||
const { dispatcher, worktreeId, targets } = options
|
||||
const targetEntries = [...targets.entries()]
|
||||
await Promise.all(
|
||||
targetEntries.map(([tabId, claim], index) =>
|
||||
dispatchSyntheticExitClose({
|
||||
dispatcher,
|
||||
worktreeId,
|
||||
tabId,
|
||||
...claim,
|
||||
requestId: `desktop-${index}`
|
||||
})
|
||||
)
|
||||
)
|
||||
for (const profile of ['a', 'b']) {
|
||||
await Promise.all(
|
||||
targetEntries.map(([tabId, claim], index) =>
|
||||
dispatchSyntheticExitClose({
|
||||
dispatcher,
|
||||
worktreeId,
|
||||
tabId,
|
||||
...claim,
|
||||
requestId: `profile-${profile}-${index}`,
|
||||
connection: {
|
||||
connectionId: `remote-profile-${profile}-generation-1`,
|
||||
pairedDeviceId: `fixture-profile-${profile}`
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const PROCESS_QUERY_TIMEOUT_MS = 5_000
|
||||
const PROCESS_QUERY_MAX_BYTES = 8 * 1024 * 1024
|
||||
|
||||
const processQueryOptions = {
|
||||
encoding: 'utf8' as const,
|
||||
timeout: PROCESS_QUERY_TIMEOUT_MS,
|
||||
maxBuffer: PROCESS_QUERY_MAX_BYTES,
|
||||
windowsHide: true
|
||||
}
|
||||
|
||||
export type RecordedProcessIdentity = {
|
||||
pid: number
|
||||
startedAtMs: number
|
||||
}
|
||||
|
||||
type ProcessRow = RecordedProcessIdentity & {
|
||||
parentPid: number
|
||||
}
|
||||
|
||||
export async function waitForCondition(
|
||||
description: string,
|
||||
predicate: () => boolean | Promise<boolean>,
|
||||
timeoutMs = 10_000
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() <= deadline) {
|
||||
if (await predicate()) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${description}`)
|
||||
}
|
||||
|
||||
async function readWindowsProcessRows(): Promise<ProcessRow[]> {
|
||||
const command = [
|
||||
'[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false);',
|
||||
'$rows = Get-CimInstance Win32_Process | ForEach-Object {',
|
||||
' [PSCustomObject]@{',
|
||||
' pid = [int]$_.ProcessId;',
|
||||
' parentPid = [int]$_.ParentProcessId;',
|
||||
" startedAt = if ($null -eq $_.CreationDate) { $null } else { $_.CreationDate.ToUniversalTime().ToString('O', [System.Globalization.CultureInfo]::InvariantCulture) }",
|
||||
' }',
|
||||
'};',
|
||||
'$rows | ConvertTo-Json -Compress'
|
||||
].join(' ')
|
||||
const { stdout } = await execFileAsync(
|
||||
'powershell.exe',
|
||||
['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command],
|
||||
processQueryOptions
|
||||
)
|
||||
const parsed = JSON.parse(stdout || '[]') as
|
||||
| { pid?: unknown; parentPid?: unknown; startedAt?: unknown }
|
||||
| { pid?: unknown; parentPid?: unknown; startedAt?: unknown }[]
|
||||
const rows = Array.isArray(parsed) ? parsed : [parsed]
|
||||
return rows.flatMap((row) => {
|
||||
const pid = Number(row.pid)
|
||||
const parentPid = Number(row.parentPid)
|
||||
const startedAtMs = typeof row.startedAt === 'string' ? Date.parse(row.startedAt) : Number.NaN
|
||||
return Number.isInteger(pid) && Number.isInteger(parentPid) && Number.isFinite(startedAtMs)
|
||||
? [{ pid, parentPid, startedAtMs }]
|
||||
: []
|
||||
})
|
||||
}
|
||||
|
||||
async function readPosixProcessRows(): Promise<ProcessRow[]> {
|
||||
const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,lstart='], {
|
||||
...processQueryOptions,
|
||||
// Why: start identity must not depend on a CI host's locale or timezone.
|
||||
env: { ...process.env, LANG: 'C', LC_ALL: 'C', TZ: 'UTC0' }
|
||||
})
|
||||
return stdout.split('\n').flatMap((line) => {
|
||||
const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line)
|
||||
if (!match) {
|
||||
return []
|
||||
}
|
||||
const startedAtMs = Date.parse(`${match[3]} UTC`)
|
||||
return Number.isFinite(startedAtMs)
|
||||
? [{ pid: Number(match[1]), parentPid: Number(match[2]), startedAtMs }]
|
||||
: []
|
||||
})
|
||||
}
|
||||
|
||||
async function readProcessRows(): Promise<ProcessRow[]> {
|
||||
return process.platform === 'win32' ? readWindowsProcessRows() : readPosixProcessRows()
|
||||
}
|
||||
|
||||
export async function recordProcessIdentity(pid: number): Promise<RecordedProcessIdentity> {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
throw new Error(`Cannot record invalid fixture pid ${pid}`)
|
||||
}
|
||||
const row = (await readProcessRows()).find((candidate) => candidate.pid === pid)
|
||||
if (!row) {
|
||||
throw new Error(`Could not record process-start identity for fixture pid ${pid}`)
|
||||
}
|
||||
return { pid, startedAtMs: row.startedAtMs }
|
||||
}
|
||||
|
||||
export async function processIdentityIsAlive(identity: RecordedProcessIdentity): Promise<boolean> {
|
||||
const current = (await readProcessRows()).find((row) => row.pid === identity.pid)
|
||||
return current !== undefined && current.startedAtMs === identity.startedAtMs
|
||||
}
|
||||
|
||||
export async function processIdentityLiveness(
|
||||
identities: readonly RecordedProcessIdentity[]
|
||||
): Promise<Map<number, boolean>> {
|
||||
const rowsByPid = new Map((await readProcessRows()).map((row) => [row.pid, row]))
|
||||
return new Map(
|
||||
identities.map((identity) => {
|
||||
const current = rowsByPid.get(identity.pid)
|
||||
return [identity.pid, current !== undefined && current.startedAtMs === identity.startedAtMs]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function recordProcessTree(
|
||||
root: RecordedProcessIdentity
|
||||
): Promise<RecordedProcessIdentity[]> {
|
||||
const rows = await readProcessRows()
|
||||
const currentRoot = rows.find((row) => row.pid === root.pid)
|
||||
if (!currentRoot || currentRoot.startedAtMs !== root.startedAtMs) {
|
||||
throw new Error(`Fixture root pid ${root.pid} changed incarnation before tree capture`)
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<number, ProcessRow[]>()
|
||||
for (const row of rows) {
|
||||
const children = childrenByParent.get(row.parentPid) ?? []
|
||||
children.push(row)
|
||||
childrenByParent.set(row.parentPid, children)
|
||||
}
|
||||
const recorded: RecordedProcessIdentity[] = [root]
|
||||
const pending = [...(childrenByParent.get(root.pid) ?? [])]
|
||||
while (pending.length > 0) {
|
||||
const row = pending.pop()
|
||||
if (!row) {
|
||||
continue
|
||||
}
|
||||
recorded.push({ pid: row.pid, startedAtMs: row.startedAtMs })
|
||||
pending.push(...(childrenByParent.get(row.pid) ?? []))
|
||||
}
|
||||
return recorded
|
||||
}
|
||||
|
||||
async function terminateRecordedProcess(identity: RecordedProcessIdentity): Promise<void> {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
await execFileAsync('taskkill', ['/pid', String(identity.pid), '/f'], processQueryOptions)
|
||||
} else {
|
||||
process.kill(identity.pid, 'SIGKILL')
|
||||
}
|
||||
} catch {
|
||||
// The exact process incarnation may exit between validation and signalling.
|
||||
}
|
||||
}
|
||||
|
||||
export async function terminateRecordedTree(identities: RecordedProcessIdentity[]): Promise<void> {
|
||||
const unique = [...new Map(identities.map((identity) => [identity.pid, identity])).values()]
|
||||
for (const identity of unique.toReversed()) {
|
||||
// Why: PID reuse between tree capture and cleanup must never authorize a
|
||||
// signal to a process incarnation the fixture did not create.
|
||||
if (!(await processIdentityIsAlive(identity))) {
|
||||
continue
|
||||
}
|
||||
await terminateRecordedProcess(identity)
|
||||
}
|
||||
await waitForCondition(
|
||||
'recorded fixture process tree to be absent',
|
||||
async () => {
|
||||
const liveness = await processIdentityLiveness(unique)
|
||||
return unique.every((identity) => !liveness.get(identity.pid))
|
||||
},
|
||||
5_000
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { build } from 'esbuild'
|
||||
import type { TestInfo } from '@playwright/test'
|
||||
|
||||
const TEMP_PREFIX = 'orca-9749-dg-'
|
||||
|
||||
type DiagnosticGeneration = {
|
||||
label: string
|
||||
protocolVersion: number
|
||||
identity: { pid: number }
|
||||
socketPath: string
|
||||
logEvents(): Record<string, unknown>[]
|
||||
startupLog(): string
|
||||
}
|
||||
|
||||
export type DaemonGenerationRuntime = {
|
||||
rootDir: string
|
||||
userDataDir: string
|
||||
daemonDir: string
|
||||
entryPath: string
|
||||
reconnectClientEntryPath: string
|
||||
canaryPath: string
|
||||
electronPath: string
|
||||
retainDiagnostics(generations: readonly DiagnosticGeneration[]): void
|
||||
remove(): void
|
||||
}
|
||||
|
||||
function normalizeForContainment(candidate: string): string {
|
||||
const normalized = path.resolve(candidate)
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
function isEqualToOrInside(candidate: string, parent: string): boolean {
|
||||
const normalizedCandidate = normalizeForContainment(candidate)
|
||||
const normalizedParent = normalizeForContainment(parent)
|
||||
return (
|
||||
normalizedCandidate === normalizedParent ||
|
||||
normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`)
|
||||
)
|
||||
}
|
||||
|
||||
function knownOrcaUserDataDirs(): string[] {
|
||||
if (process.platform === 'darwin') {
|
||||
const appSupport = path.join(homedir(), 'Library', 'Application Support')
|
||||
return [path.join(appSupport, 'orca'), path.join(appSupport, 'orca-dev')]
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
const roaming = process.env.APPDATA ?? path.join(homedir(), 'AppData', 'Roaming')
|
||||
return [path.join(roaming, 'orca'), path.join(roaming, 'orca-dev')]
|
||||
}
|
||||
const config = process.env.XDG_CONFIG_HOME ?? path.join(homedir(), '.config')
|
||||
return [path.join(config, 'orca'), path.join(config, 'orca-dev')]
|
||||
}
|
||||
|
||||
function assertDisposableRoot(rootDir: string): void {
|
||||
const tempRoot = process.platform === 'darwin' ? path.join(path.sep, 'tmp') : tmpdir()
|
||||
if (!path.basename(rootDir).startsWith(TEMP_PREFIX)) {
|
||||
throw new Error('Refusing daemon-generation cleanup without its fixture prefix')
|
||||
}
|
||||
if (!isEqualToOrInside(rootDir, tempRoot)) {
|
||||
throw new Error('Daemon-generation fixture escaped the OS temporary directory')
|
||||
}
|
||||
for (const userDataDir of knownOrcaUserDataDirs()) {
|
||||
if (isEqualToOrInside(rootDir, userDataDir)) {
|
||||
throw new Error('Refusing daemon-generation fixture inside real Orca user data')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveElectronExecutable(repoRoot: string): string {
|
||||
const relativePath = readFileSync(
|
||||
path.join(repoRoot, 'node_modules', 'electron', 'path.txt'),
|
||||
'utf8'
|
||||
).trim()
|
||||
const executable = path.join(repoRoot, 'node_modules', 'electron', 'dist', relativePath)
|
||||
if (!existsSync(executable)) {
|
||||
throw new Error(`Local Electron executable is missing: ${executable}`)
|
||||
}
|
||||
return executable
|
||||
}
|
||||
|
||||
async function buildFixtureEntry(entryPoint: string, outfile: string): Promise<void> {
|
||||
await build({
|
||||
entryPoints: [entryPoint],
|
||||
outfile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node20',
|
||||
packages: 'external',
|
||||
external: ['node-pty'],
|
||||
logLevel: 'silent'
|
||||
})
|
||||
}
|
||||
|
||||
export async function createDaemonGenerationRuntime(
|
||||
testInfo: TestInfo
|
||||
): Promise<DaemonGenerationRuntime> {
|
||||
const tempRoot = process.platform === 'darwin' ? path.join(path.sep, 'tmp') : tmpdir()
|
||||
const rootDir = mkdtempSync(path.join(tempRoot, TEMP_PREFIX))
|
||||
assertDisposableRoot(rootDir)
|
||||
const userDataDir = path.join(rootDir, 'user-data')
|
||||
const daemonDir = path.join(userDataDir, 'daemon')
|
||||
mkdirSync(daemonDir, { recursive: true })
|
||||
const entryPath = path.join(rootDir, 'daemon-generation-entry.cjs')
|
||||
const reconnectClientEntryPath = path.join(rootDir, 'daemon-generation-reconnect-client.cjs')
|
||||
const repoRoot = process.cwd()
|
||||
await buildFixtureEntry(
|
||||
path.join(repoRoot, 'tests/e2e/fixtures/daemon-generation-entry.ts'),
|
||||
entryPath
|
||||
)
|
||||
await buildFixtureEntry(
|
||||
path.join(repoRoot, 'tests/e2e/fixtures/daemon-generation-reconnect-client.ts'),
|
||||
reconnectClientEntryPath
|
||||
)
|
||||
return {
|
||||
rootDir,
|
||||
userDataDir,
|
||||
daemonDir,
|
||||
entryPath,
|
||||
reconnectClientEntryPath,
|
||||
canaryPath: path.join(repoRoot, 'tests/e2e/fixtures/daemon-generation-canary.cjs'),
|
||||
electronPath: resolveElectronExecutable(repoRoot),
|
||||
retainDiagnostics: (generations) => {
|
||||
mkdirSync(testInfo.outputDir, { recursive: true })
|
||||
writeFileSync(
|
||||
testInfo.outputPath('daemon-generation-safety-diagnostics.json'),
|
||||
`${JSON.stringify(
|
||||
generations.map((generation) => ({
|
||||
label: generation.label,
|
||||
protocolVersion: generation.protocolVersion,
|
||||
pid: generation.identity.pid,
|
||||
socketPath: generation.socketPath,
|
||||
logEvents: generation.logEvents(),
|
||||
startupLog: generation.startupLog()
|
||||
})),
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
},
|
||||
remove: () => {
|
||||
assertDisposableRoot(rootDir)
|
||||
// Why: Windows can release ConPTY/log handles just after exact-PID
|
||||
// liveness reaches zero; keep fixture cleanup bounded but non-flaky.
|
||||
rmSync(rootDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { DaemonPtyAdapter } from '../../../src/main/daemon/daemon-pty-adapter'
|
||||
import { getDaemonSocketPath, getDaemonTokenPath } from '../../../src/main/daemon/daemon-spawner'
|
||||
import {
|
||||
recordProcessIdentity,
|
||||
recordProcessTree,
|
||||
terminateRecordedTree,
|
||||
waitForCondition,
|
||||
type RecordedProcessIdentity
|
||||
} from './daemon-generation-processes'
|
||||
import {
|
||||
createDaemonGenerationRuntime,
|
||||
type DaemonGenerationRuntime
|
||||
} from './daemon-generation-runtime-fixture'
|
||||
import { DAEMON_GENERATION_WORKTREE_ID } from '../fixtures/daemon-generation-fixture-contract'
|
||||
|
||||
const MAX_CAPTURED_CHARS = 32_768
|
||||
export { createDaemonGenerationRuntime, type DaemonGenerationRuntime }
|
||||
|
||||
export type DaemonGeneration = {
|
||||
label: string
|
||||
protocolVersion: number
|
||||
child: ChildProcess
|
||||
identity: RecordedProcessIdentity
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
logPath: string
|
||||
startupLog(): string
|
||||
logEvents(): Record<string, unknown>[]
|
||||
}
|
||||
|
||||
export type GenerationCanary = {
|
||||
generation: DaemonGeneration
|
||||
role: 'live' | 'stale-mirror'
|
||||
sessionId: string
|
||||
rootIdentity: RecordedProcessIdentity
|
||||
descendantIdentity: RecordedProcessIdentity
|
||||
treeIdentities: RecordedProcessIdentity[]
|
||||
adapter: DaemonPtyAdapter
|
||||
output(): string
|
||||
}
|
||||
|
||||
function readLogEvents(logPath: string): Record<string, unknown>[] {
|
||||
if (!existsSync(logPath)) {
|
||||
return []
|
||||
}
|
||||
return readFileSync(logPath, 'utf8')
|
||||
.split('\n')
|
||||
.flatMap((line) => {
|
||||
if (!line.trim()) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
return [JSON.parse(line) as Record<string, unknown>]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function launchDaemonGeneration(options: {
|
||||
runtime: DaemonGenerationRuntime
|
||||
label: string
|
||||
protocolVersion: number
|
||||
refuseDispose?: boolean
|
||||
}): Promise<DaemonGeneration> {
|
||||
const { runtime, label, protocolVersion, refuseDispose = false } = options
|
||||
const socketPath = getDaemonSocketPath(runtime.daemonDir, protocolVersion)
|
||||
const tokenPath = getDaemonTokenPath(runtime.daemonDir, protocolVersion)
|
||||
const logPath = path.join(runtime.rootDir, `${label}.daemon.log`)
|
||||
let startupLog = ''
|
||||
const child = fork(
|
||||
runtime.entryPath,
|
||||
[
|
||||
'--protocol',
|
||||
String(protocolVersion),
|
||||
'--socket',
|
||||
socketPath,
|
||||
'--token',
|
||||
tokenPath,
|
||||
'--log',
|
||||
logPath,
|
||||
'--refuse-dispose',
|
||||
String(refuseDispose)
|
||||
],
|
||||
{
|
||||
cwd: runtime.userDataDir,
|
||||
execPath: runtime.electronPath,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
NODE_PATH: path.join(process.cwd(), 'node_modules'),
|
||||
ORCA_USER_DATA_PATH: runtime.userDataDir
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc']
|
||||
}
|
||||
)
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
startupLog = `${startupLog}${chunk.toString('utf8')}`.slice(-MAX_CAPTURED_CHARS)
|
||||
})
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`${label} daemon startup timed out: ${startupLog}`)),
|
||||
15_000
|
||||
)
|
||||
const settle = (callback: () => void): void => {
|
||||
clearTimeout(timer)
|
||||
child.off('error', onError)
|
||||
child.off('exit', onExit)
|
||||
child.off('message', onMessage)
|
||||
callback()
|
||||
}
|
||||
const onError = (error: Error): void => settle(() => reject(error))
|
||||
const onExit = (code: number | null): void =>
|
||||
settle(() => reject(new Error(`${label} daemon exited with ${code}: ${startupLog}`)))
|
||||
const onMessage = (message: unknown): void => {
|
||||
if ((message as { type?: unknown })?.type === 'ready') {
|
||||
settle(resolve)
|
||||
}
|
||||
}
|
||||
child.once('error', onError)
|
||||
child.once('exit', onExit)
|
||||
child.on('message', onMessage)
|
||||
})
|
||||
if (!child.pid) {
|
||||
throw new Error(`${label} daemon did not expose a PID`)
|
||||
}
|
||||
const identity = await recordProcessIdentity(child.pid)
|
||||
child.disconnect()
|
||||
return {
|
||||
label,
|
||||
protocolVersion,
|
||||
child,
|
||||
identity,
|
||||
socketPath,
|
||||
tokenPath,
|
||||
logPath,
|
||||
startupLog: () => startupLog,
|
||||
logEvents: () => readLogEvents(logPath)
|
||||
}
|
||||
} catch (error) {
|
||||
if (child.pid) {
|
||||
try {
|
||||
await terminateRecordedTree(await recordProcessTree(await recordProcessIdentity(child.pid)))
|
||||
} catch {
|
||||
// Startup may have exited before its process identity could be recorded.
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function canaryCommand(runtime: DaemonGenerationRuntime, label: string, nonce: string): string {
|
||||
const escapedPath = runtime.canaryPath.replaceAll('"', '\\"')
|
||||
return `node "${escapedPath}" ${label} ${nonce}`
|
||||
}
|
||||
|
||||
export async function spawnGenerationCanary(options: {
|
||||
runtime: DaemonGenerationRuntime
|
||||
generation: DaemonGeneration
|
||||
role: GenerationCanary['role']
|
||||
}): Promise<GenerationCanary> {
|
||||
const { runtime, generation, role } = options
|
||||
const label = `${generation.label}-${role}`
|
||||
const nonce = randomUUID()
|
||||
// Why: production daemon inventory infers ownership from the durable prefix;
|
||||
// keep the fixture on that path so live-host adjudication cannot degrade to unknown.
|
||||
const sessionId = `${DAEMON_GENERATION_WORKTREE_ID}@@orca-9749-${label}-${randomUUID().slice(0, 8)}`
|
||||
const adapter = new DaemonPtyAdapter({
|
||||
socketPath: generation.socketPath,
|
||||
tokenPath: generation.tokenPath,
|
||||
protocolVersion: generation.protocolVersion
|
||||
})
|
||||
let output = ''
|
||||
adapter.onData((event) => {
|
||||
if (event.id === sessionId) {
|
||||
output = `${output}${event.data}`.slice(-MAX_CAPTURED_CHARS)
|
||||
}
|
||||
})
|
||||
const result = await adapter.spawn({
|
||||
sessionId,
|
||||
isNewSession: true,
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cwd: runtime.rootDir,
|
||||
...(process.platform === 'win32' ? { shellOverride: 'powershell.exe' } : {}),
|
||||
command: canaryCommand(runtime, label, nonce)
|
||||
})
|
||||
if (!result.pid) {
|
||||
throw new Error(`${label} canary did not expose its root PID`)
|
||||
}
|
||||
await waitForCondition(`${label} canary readiness`, () =>
|
||||
output.includes(`ORCA_GENERATION_CANARY_READY ${label} ${nonce}`)
|
||||
)
|
||||
const match = new RegExp(`ORCA_GENERATION_CANARY_READY ${label} ${nonce} (\\d+)`).exec(output)
|
||||
const descendantPid = Number(match?.[1])
|
||||
if (!Number.isInteger(descendantPid) || descendantPid <= 0) {
|
||||
throw new Error(`${label} canary did not report its descendant PID`)
|
||||
}
|
||||
const rootIdentity = await recordProcessIdentity(result.pid)
|
||||
const descendantIdentity = await recordProcessIdentity(descendantPid)
|
||||
const treeIdentities = await recordProcessTree(rootIdentity)
|
||||
if (!treeIdentities.some((identity) => identity.pid === descendantIdentity.pid)) {
|
||||
throw new Error(`${label} descendant is outside its disposable PTY tree`)
|
||||
}
|
||||
return {
|
||||
generation,
|
||||
role,
|
||||
sessionId,
|
||||
rootIdentity,
|
||||
descendantIdentity,
|
||||
treeIdentities,
|
||||
adapter,
|
||||
output: () => output
|
||||
}
|
||||
}
|
||||
|
||||
export async function pingGenerationCanary(canary: GenerationCanary, nonce: string): Promise<void> {
|
||||
const label = `${canary.generation.label}-${canary.role}`
|
||||
const expected = `ORCA_GENERATION_CANARY_ACK ${label} ${nonce}`
|
||||
canary.adapter.write(canary.sessionId, `PING ${label} ${nonce}\r`)
|
||||
await waitForCondition(`${label} canary reply`, () => canary.output().includes(expected))
|
||||
}
|
||||
|
||||
export async function cleanupDaemonGenerationFixtures(options: {
|
||||
generations: readonly DaemonGeneration[]
|
||||
canaries: readonly GenerationCanary[]
|
||||
}): Promise<void> {
|
||||
const identities = [
|
||||
...options.generations.map((generation) => generation.identity),
|
||||
...options.canaries.flatMap((canary) => canary.treeIdentities)
|
||||
]
|
||||
for (const canary of options.canaries) {
|
||||
canary.adapter.dispose()
|
||||
}
|
||||
await terminateRecordedTree(identities)
|
||||
}
|
||||
Loading…
Reference in New Issue