mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(remote): park paired terminals with host snapshots
This commit is contained in:
+105
-34
@@ -3250,13 +3250,15 @@
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.remote-hidden-retention-budget",
|
||||
"title": "Paired remote terminals paint retained output and bound hidden renderer state",
|
||||
"title": "Paired terminals park client renderers while host PTYs preserve bounded history",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "paired-headed-runtime",
|
||||
"layer": "paired-headed-and-headless-runtime",
|
||||
"surfaces": [
|
||||
"paired remote terminal first paint",
|
||||
"paired remote terminal ordinary parking",
|
||||
"paired remote terminal bounded scrollback restore",
|
||||
"stalled paired terminal stream recovery",
|
||||
"hidden remote worktree retention",
|
||||
"remote terminal reveal and input",
|
||||
@@ -3266,37 +3268,47 @@
|
||||
"providers": ["paired-runtime", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["paired-runtime"],
|
||||
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. They prove a truncated retained history still paints its latest-screen snapshot; an ACK-starved stream can stop client delivery while its host PTY cursor advances, then recover without replacing the PTY; and six real paired host-backed worktrees with bounded scrollback shrink to the configured manager/cell budget before reveal restores the original PTY and live input. The same stream-stall oracle passes against an isolated headless `orca serve` host. A separate headed memory test measures staged xterm buffer release for remote-shaped terminals. Unit tests cover stream timeouts and diagnostic reasons, SSH and paired classification, plus desktop and browser manual-disconnect fencing. Linux, Windows, mixed-version paired hosts, live SSH, headless retention, and production-scale paired hosts remain gaps.",
|
||||
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8652",
|
||||
"https://github.com/stablyai/orca/pull/10625"
|
||||
],
|
||||
"invariant": "Historical retained-tail overflow must not invalidate a complete serialized or latest-screen first-paint snapshot. A paired terminal stream whose delivery credits or command responses stop progressing must replace only that stream, preserve its tab and PTY identity, and repaint authoritative host output. With terminal parking enabled, hidden unparkable SSH and paired-runtime worktrees must retain at most the configured count and none beyond the TTL, including paired tabs with a host-backed remote PTY and stale activation residue. Force-parking must preserve the captured screen and later PTY input/output. Manual disconnect must retain pairing while preventing passive calls and subscriptions from recreating transport until explicit Connect.",
|
||||
"oracle": "Launch a headed desktop host and a separate paired client. Produce more than the retained terminal-read limit, require terminal.read to report truncated, reload the client, and require the final marker to paint before new output. Separately withhold ACKs until one stream exhausts its 512 KiB window, require the host to finish bounded output and advance its cursor after accepted input while the client lacks the marker, then require command-response recovery to repaint it with the same tab and PTY. Run the byte-identical oracle with recovery disabled and require it to stay stalled. Create six live host PTYs in distinct paired worktrees, stage bounded high-output scrollback in every mounted xterm, disable the budget and require all six managers to survive, then enable a limit of two and require exactly two managers plus at most 45% of staged buffer cells to remain without material heap growth or a renderer timer stall. Reveal an evicted tab, require the same PTY identity and fresh live echo. Disconnect transport, exercise passive status, call, and subscribe paths, and require no client reconstruction until explicit Connect.",
|
||||
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits or command responses stop progressing must replace only that stream and repaint authoritative host output. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
|
||||
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY stream recovery, and queued manual-disconnect fencing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_REMOTE_TERMINAL_STALL_RECOVERY=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-parked-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/renderer/src/web/web-preload-api.test.ts",
|
||||
"src/main/ipc/runtime-environments.test.ts",
|
||||
"src/main/runtime/rpc/terminal-multiplex.test.ts",
|
||||
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts",
|
||||
"src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts",
|
||||
"src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
|
||||
"src/renderer/src/runtime/runtime-client-events.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
|
||||
"tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts",
|
||||
"tests/e2e/paired-remote-terminal-stall-recovery.spec.ts",
|
||||
"tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts",
|
||||
"tests/e2e/paired-remote-terminal-retention-memory.spec.ts",
|
||||
"tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts",
|
||||
"tests/e2e/terminal-parked-memory.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
@@ -3316,13 +3328,19 @@
|
||||
"file": "tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts",
|
||||
"assertions": [
|
||||
"paints a paired remote terminal when only its retained text tail overflowed",
|
||||
"bounds hidden paired-runtime pane managers and restores PTY output"
|
||||
"legacy paired hosts retain the lossy hidden-manager budget fallback"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-remote-terminal-retention-memory.spec.ts",
|
||||
"assertions": [
|
||||
"bounds retained buffers across real paired worktrees and preserves PTYs"
|
||||
"ordinary-parks paired terminals and restores authoritative host scrollback"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts",
|
||||
"assertions": [
|
||||
"ordinary-parks paired terminals against an isolated headless Orca host"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -3339,17 +3357,61 @@
|
||||
"force-parks the least-recently-hidden candidates beyond the retention limit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime.test.ts",
|
||||
"assertions": [
|
||||
"forwards facts over the shared client-event stream without a desktop renderer",
|
||||
"prefers provider history over a partial headless mirror for requested snapshots",
|
||||
"falls back to the available mirror when authoritative provider history is unavailable"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/terminal-multiplex.test.ts",
|
||||
"assertions": [
|
||||
"binary first paint remains valid when only retained history was truncated"
|
||||
"binary first paint remains valid when only retained history was truncated",
|
||||
"supports 44 active streams while retaining a bounded per-connection limit",
|
||||
"reserves PTY wait capacity independently from active streams"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts",
|
||||
"assertions": [
|
||||
"consumes host facts without a raw terminal stream for paired PTYs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"assertions": [
|
||||
"restores configured paired scrollback after an ordinary park reveal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts",
|
||||
"assertions": [
|
||||
"selects only reachable hosts advertising the paired parking contract",
|
||||
"accepts paired ptys only for the exact snapshot-capable owner",
|
||||
"rejects paired, fail-open, foreign, and null ptys without capability evidence"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"assertions": [
|
||||
"starts a fact watcher for snapshot-capable paired PTYs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts",
|
||||
"assertions": [
|
||||
"classifies a capacity rejection followed by end as recoverable transport pressure"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/web/web-preload-api.test.ts",
|
||||
"assertions": [
|
||||
"keeps pairing while manual disconnect fences passive reconnects",
|
||||
"fences a web runtime response that completes after manual disconnect"
|
||||
"fences a web runtime response that completes after manual disconnect",
|
||||
"returns a disconnect envelope when a queued active runtime call disconnects",
|
||||
"returns a disconnect envelope when a queued selected environment call disconnects"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -3358,10 +3420,10 @@
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5.98,
|
||||
"summary": "Six focused files passed 202 tests covering transport truncation semantics, paired retention classification, deterministic overrides, and desktop/browser disconnect fencing."
|
||||
"durationSeconds": 21.33,
|
||||
"summary": "Sixteen focused runtime, multiplex, parking, restore, side-effect, capacity, lifecycle, and queued-disconnect files passed 1,921 tests with one existing skip."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
@@ -3370,16 +3432,7 @@
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 18.5,
|
||||
"summary": "Both headed paired-server scenarios passed: truncated retained history painted after reload, and the retention control/candidate run evicted exactly the older hidden manager before successful reveal and live PTY input."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_REMOTE_TERMINAL_STALL_RECOVERY=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "failed",
|
||||
"durationSeconds": 36.6,
|
||||
"summary": "With stream recovery disabled, the host cursor advanced after accepted input while the paired client remained frozen around flood row 5,396 and never painted the later marker."
|
||||
"summary": "The headed paired-server scenario painted a truncated retained history after reload; the capability-disabled legacy fallback scenario is skipped unless explicitly selected."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
@@ -3396,8 +3449,8 @@
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 14.8,
|
||||
"summary": "Six real paired worktrees and host PTYs retained bounded high-output client buffers with the budget disabled, shrank to exactly two managers and under 45% of staged cells when enabled, stayed within heap and timer-lag budgets, then restored live output on the original evicted PTY."
|
||||
"durationSeconds": 14.5,
|
||||
"summary": "Six real paired worktrees and host PTYs ordinary-parked five client xterms with the lossy budget disabled, released at least 55% of staged cells within heap and timer-lag budgets, then restored requested row 4,000, parked-time output, and live I/O on the original PTY."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
@@ -3407,6 +3460,24 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 14.4,
|
||||
"summary": "An isolated `orca serve` host and paired web renderer exhausted one terminal stream, recovered it, and preserved the original PTY and tab without exposing readiness or pairing material."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 14.2,
|
||||
"summary": "The isolated headless `orca serve` host passed the byte-identical six-PTY ordinary-parking, memory/lag, deep-history, same-PTY, parked-output, and post-reveal I/O oracle."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 18.8,
|
||||
"summary": "A host without terminal.paired-parking.v1 preserved the existing lossy limit-one fallback, restored retained output, and continued PTY input/output."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -3419,27 +3490,27 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Before the snapshot fix, the headed paired reload stayed blank because retained-tail truncation invalidated first paint. Before the paired-retention follow-up, the live control/candidate oracle kept remote managers mounted because stale pendingActivationSpawn residue excluded host-backed worktrees. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The six-worktree retention oracle keeps all managers and staged cells when the budget is disabled, then releases four managers and their buffers when enabled."
|
||||
"evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The production policy retains at most 12 hidden unparkable worktrees and force-parks any hidden beyond 45 minutes. Stream recovery adds no polling: timers exist only while an entered command awaits a frame or renderer delivery credit remains unsettled, and restart is scoped to one stream. The headed paired memory oracle requires exact manager reduction, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift; all paired oracles use bounded output and deterministic barriers rather than an overnight sleep."
|
||||
"evidence": "Capable paired hosts use ordinary parking: the host PTY and bounded 5,000-row provider model remain, while five hidden client xterms and their raw per-PTY multiplex streams are destroyed. Parked side effects share the singleton runtime client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy. Stream recovery adds no polling and is scoped to one stream."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive CI passes or 14 days of soak history for both headed commands.",
|
||||
"Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.",
|
||||
"Run the paired topology on Windows and Linux and add one live SSH retention run.",
|
||||
"Run the stream-stall oracle with a v1.4.160-rc.3 host and v1.4.160-rc.4 client.",
|
||||
"Add a production-scale paired run with dozens of worktrees and explicit event-loop and renderer-memory budgets.",
|
||||
"Keep the budget-off control, exact manager-count assertion, reveal marker, and post-reveal PTY input in one headed run."
|
||||
"Keep the budget-off control, exact manager count, row-4,000 reveal marker, parked-time output, and post-reveal PTY input in both topology runs."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Remote reveal restores only the serialized screen or captured tail because no full remote scrollback RPC exists.",
|
||||
"The scaled paired headed retention scenario uses six worktrees and a cap of two; it proves real host-backed eviction and buffer release, not a 100-worktree soak or the production cap of 12.",
|
||||
"Paired reveal restores at most the requested 5,000 rows; older history is intentionally unavailable.",
|
||||
"The scaled paired scenarios use six worktrees and ordinary-park five; they prove real host-backed eviction and buffer release, not a 100-worktree soak.",
|
||||
"Display-off reveal latency is a separate atlas and viewport-reflow class and is not covered by this gate.",
|
||||
"Historical field trace archives are still required to order replay-wedge events against renderer heartbeat loss and deduplicate archived records.",
|
||||
"The live windows-issues incident proves a healthy host PTY and dead existing client stream, but lacks per-stream ACK counters; ACK starvation is reproduced, not yet proven as that field incident's unique boundary."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if paired first paint is blank, passive work reconnects after manual disconnect, an eligible hidden remote manager survives the cap or TTL, reveal loses the captured screen or live PTY, retained buffer cells do not fall, or either headed scenario flakes."
|
||||
"demotionRule": "Keep experimental or demote to protection none if paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy host bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.no-hot-list-sessions",
|
||||
|
||||
@@ -8068,6 +8068,38 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull()
|
||||
})
|
||||
|
||||
it('forwards facts over the shared client-event stream without a desktop renderer', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const events: RuntimeClientEvent[] = []
|
||||
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
|
||||
const unsubscribe = runtime.onClientEvent((event) => events.push(event))
|
||||
|
||||
runtime.onPtyData('pty-remote', '\x1b]0;Codex working\x07\x07', 100)
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: 'terminalSideEffects',
|
||||
batch: {
|
||||
ptyId: 'pty-remote',
|
||||
seq: 19,
|
||||
facts: [
|
||||
{
|
||||
kind: 'title',
|
||||
normalizedTitle: 'Codex working',
|
||||
rawTitle: 'Codex working'
|
||||
},
|
||||
{ kind: 'agent-working' },
|
||||
{ kind: 'bell' }
|
||||
]
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
unsubscribe()
|
||||
runtime.onPtyData('pty-remote', '\x07', 101)
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits one batched event per chunk with facts in byte order and attribution', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
@@ -8456,6 +8488,62 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers provider history over a partial headless mirror for requested snapshots', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
const serializeProviderBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'authoritative screen\r\n',
|
||||
scrollbackAnsi: 'deep provider history\r\n',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: 900,
|
||||
source: 'headless'
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.onPtyData('pty-1', 'partial current screen\r\n', 100)
|
||||
|
||||
await expect(
|
||||
runtime.serializeAuthoritativeTerminalBuffer('pty-1', { scrollbackRows: 5000 })
|
||||
).resolves.toMatchObject({
|
||||
data: 'authoritative screen\r\n',
|
||||
scrollbackAnsi: 'deep provider history\r\n',
|
||||
seq: 900
|
||||
})
|
||||
expect(serializeProviderBuffer).toHaveBeenCalledWith('pty-1', {
|
||||
scrollbackRows: 5000
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the available mirror when authoritative provider history is unavailable', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
const serializeProviderBuffer = vi.fn().mockResolvedValue(null)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer,
|
||||
hasRendererSerializer: () => false
|
||||
})
|
||||
syncSinglePty(runtime)
|
||||
runtime.onPtyData('pty-1', 'partial current screen\r\n', 100)
|
||||
|
||||
await expect(
|
||||
runtime.serializeAuthoritativeTerminalBuffer('pty-1', { scrollbackRows: 5000 })
|
||||
).resolves.toMatchObject({
|
||||
data: expect.stringContaining('partial current screen'),
|
||||
source: 'headless'
|
||||
})
|
||||
expect(serializeProviderBuffer).toHaveBeenCalledWith('pty-1', {
|
||||
scrollbackRows: 5000
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to provider history when a mounted renderer has not hydrated yet', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -438,6 +438,7 @@ import {
|
||||
REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY,
|
||||
RUNTIME_CAPABILITIES,
|
||||
RUNTIME_PROTOCOL_VERSION,
|
||||
TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY,
|
||||
type RuntimeCapability
|
||||
} from '../../shared/protocol-version'
|
||||
import {
|
||||
@@ -1444,6 +1445,20 @@ type ProviderBufferAcquisition = {
|
||||
promise: Promise<PtyProviderBufferSnapshot | null>
|
||||
}
|
||||
|
||||
type RuntimeTerminalBufferSnapshot = {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
cwd?: string | null
|
||||
lastTitle?: string
|
||||
source?: 'headless' | 'renderer'
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
alternateScreen?: boolean
|
||||
scrollbackAnsi?: string
|
||||
pendingEscapeTailAnsi?: string
|
||||
}
|
||||
|
||||
type HeadlessSeedMetadata = {
|
||||
cwd?: string | null
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
@@ -3027,6 +3042,7 @@ export class OrcaRuntimeService {
|
||||
private readonly onPtyStopped: ((ptyId: string) => void) | null
|
||||
private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null
|
||||
private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null
|
||||
private terminalSideEffectLocalConsumerAvailable = false
|
||||
private terminalSideEffectConsumerAvailable = false
|
||||
private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null
|
||||
private readonly getAgentProviderSessionSnapshotFn: (() => AgentStatusIpcPayload[]) | null
|
||||
@@ -4502,7 +4518,9 @@ export class OrcaRuntimeService {
|
||||
(capability !== 'browser.screencast.v1' || canBrowse) &&
|
||||
// Why: the nested-runtime E2E needs a real legacy transport without maintaining an old binary fixture.
|
||||
(process.env.ORCA_E2E_DISABLE_RUNTIME_SHARED_CONTROL !== '1' ||
|
||||
capability !== REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY)
|
||||
capability !== REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) &&
|
||||
(process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' ||
|
||||
capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY)
|
||||
)
|
||||
if (hasOffscreen) {
|
||||
capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY)
|
||||
@@ -4568,8 +4586,10 @@ export class OrcaRuntimeService {
|
||||
|
||||
onClientEvent(listener: (event: RuntimeClientEvent) => void): () => void {
|
||||
this.clientEventListeners.add(listener)
|
||||
this.refreshTerminalSideEffectConsumerAvailability()
|
||||
return () => {
|
||||
this.clientEventListeners.delete(listener)
|
||||
this.refreshTerminalSideEffectConsumerAvailability()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9064,7 +9084,7 @@ export class OrcaRuntimeService {
|
||||
/** Record one derived side-effect fact: batched per chunk while applying
|
||||
* bytes, emitted immediately for between-chunk facts (stale-title timer). */
|
||||
private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void {
|
||||
if (!this.onTerminalSideEffects || !this.terminalSideEffectConsumerAvailable) {
|
||||
if (!this.terminalSideEffectConsumerAvailable) {
|
||||
return
|
||||
}
|
||||
const entry = this.ptyTitleTrackersByPtyId.get(ptyId)
|
||||
@@ -9080,11 +9100,7 @@ export class OrcaRuntimeService {
|
||||
facts: TerminalSideEffectFact[],
|
||||
options: { replay?: boolean } = {}
|
||||
): void {
|
||||
if (
|
||||
!this.onTerminalSideEffects ||
|
||||
!this.terminalSideEffectConsumerAvailable ||
|
||||
facts.length === 0
|
||||
) {
|
||||
if (!this.terminalSideEffectConsumerAvailable || facts.length === 0) {
|
||||
return
|
||||
}
|
||||
const batch: TerminalSideEffectBatch = {
|
||||
@@ -9094,10 +9110,15 @@ export class OrcaRuntimeService {
|
||||
...(options.replay ? { replay: true } : {}),
|
||||
...this.resolveTerminalSideEffectAttribution(ptyId)
|
||||
}
|
||||
try {
|
||||
this.onTerminalSideEffects(batch)
|
||||
} catch (err) {
|
||||
console.error('[runtime] terminal side-effect listener threw', { ptyId, err })
|
||||
if (this.terminalSideEffectLocalConsumerAvailable) {
|
||||
try {
|
||||
this.onTerminalSideEffects?.(batch)
|
||||
} catch (err) {
|
||||
console.error('[runtime] terminal side-effect listener threw', { ptyId, err })
|
||||
}
|
||||
}
|
||||
if (this.clientEventListeners.size > 0) {
|
||||
this.emitClientEvent({ type: 'terminalSideEffects', batch })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9425,7 +9446,13 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private setTerminalSideEffectConsumerAvailable(available: boolean): void {
|
||||
const nextAvailable = available && this.onTerminalSideEffects !== null
|
||||
this.terminalSideEffectLocalConsumerAvailable = available && this.onTerminalSideEffects !== null
|
||||
this.refreshTerminalSideEffectConsumerAvailability()
|
||||
}
|
||||
|
||||
private refreshTerminalSideEffectConsumerAvailability(): void {
|
||||
const nextAvailable =
|
||||
this.terminalSideEffectLocalConsumerAvailable || this.clientEventListeners.size > 0
|
||||
if (nextAvailable === this.terminalSideEffectConsumerAvailable) {
|
||||
return
|
||||
}
|
||||
@@ -9863,19 +9890,18 @@ export class OrcaRuntimeService {
|
||||
serializeTerminalBuffer(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
): Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
cwd?: string | null
|
||||
lastTitle?: string
|
||||
source?: 'headless' | 'renderer'
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
alternateScreen?: boolean
|
||||
scrollbackAnsi?: string
|
||||
pendingEscapeTailAnsi?: string
|
||||
} | null> {
|
||||
): Promise<RuntimeTerminalBufferSnapshot | null> {
|
||||
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
|
||||
}
|
||||
|
||||
async serializeAuthoritativeTerminalBuffer(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
): Promise<RuntimeTerminalBufferSnapshot | null> {
|
||||
const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts)
|
||||
if (providerSnapshot) {
|
||||
return providerSnapshot
|
||||
}
|
||||
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,8 +56,10 @@ import {
|
||||
TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES,
|
||||
TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES,
|
||||
TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES,
|
||||
TERMINAL_MULTIPLEX_MAX_STREAMS_PER_CONNECTION,
|
||||
TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION,
|
||||
TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION,
|
||||
TERMINAL_MULTIPLEX_PENDING_MAX_BYTES,
|
||||
TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR,
|
||||
TERMINAL_OUTPUT_BATCH_MAX_BYTES
|
||||
} from '../../../../shared/terminal-multiplex-flow-control'
|
||||
import { drainTerminalMultiplexRoundRobin } from '../terminal-multiplex-round-robin'
|
||||
@@ -532,11 +534,17 @@ async function serializeBudgetedRequestedSnapshot(
|
||||
): Promise<SerializedSnapshot> {
|
||||
const requestedRows = scrollbackRows ?? 0
|
||||
for (const rows of requestedSnapshotScrollbackCandidates(scrollbackRows)) {
|
||||
const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows })
|
||||
const serialized = await runtime.serializeAuthoritativeTerminalBuffer(ptyId, {
|
||||
scrollbackRows: rows
|
||||
})
|
||||
if (!serialized) {
|
||||
return null
|
||||
}
|
||||
const data = (serialized.scrollbackAnsi ?? '') + serialized.data
|
||||
const scrollbackAnsi =
|
||||
'scrollbackAnsi' in serialized && typeof serialized.scrollbackAnsi === 'string'
|
||||
? serialized.scrollbackAnsi
|
||||
: ''
|
||||
const data = scrollbackAnsi + serialized.data
|
||||
const overByteBudget = terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET)
|
||||
if (!overByteBudget || rows === 0) {
|
||||
return {
|
||||
@@ -2068,14 +2076,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
const request = parsed.data
|
||||
detachStream(request.streamId, false)
|
||||
cancelPendingPtyWaits(request.streamId)
|
||||
if (
|
||||
streams.size + pendingPtyWaitControllers.size >=
|
||||
TERMINAL_MULTIPLEX_MAX_STREAMS_PER_CONNECTION
|
||||
) {
|
||||
sendStreamError(request.streamId, 'terminal_stream_limit_exceeded')
|
||||
emit({ type: 'end', streamId: request.streamId })
|
||||
return
|
||||
}
|
||||
|
||||
const isMobile = request.client?.type === 'mobile'
|
||||
let leaf: { ptyId: string | null } | null
|
||||
@@ -2088,6 +2088,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
return
|
||||
}
|
||||
if (!leaf?.ptyId && request.client) {
|
||||
if (
|
||||
pendingPtyWaitControllers.size >=
|
||||
TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION
|
||||
) {
|
||||
sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR)
|
||||
emit({ type: 'end', streamId: request.streamId })
|
||||
return
|
||||
}
|
||||
// Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree.
|
||||
runtime.requestRendererTerminalTabMount(request.terminal)
|
||||
const waitController = new AbortController()
|
||||
@@ -2138,6 +2146,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
// Why: a competing subscribe may own this streamId after the PTY await; detach it so an orphaned view subscriber can't silence the model responder (terminal-query-authority.md).
|
||||
detachStream(request.streamId, false)
|
||||
if (streams.size >= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION) {
|
||||
sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR)
|
||||
emit({ type: 'end', streamId: request.streamId })
|
||||
return
|
||||
}
|
||||
|
||||
const ptyId = leaf.ptyId
|
||||
const stream: TerminalMultiplexStream = {
|
||||
|
||||
@@ -14,8 +14,16 @@ import {
|
||||
encodeTerminalStreamJson,
|
||||
encodeTerminalStreamText
|
||||
} from '../../../shared/terminal-stream-protocol'
|
||||
import {
|
||||
TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION,
|
||||
TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION
|
||||
} from '../../../shared/terminal-multiplex-flow-control'
|
||||
|
||||
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
||||
const serializeAuthoritativeTerminalBuffer =
|
||||
overrides.serializeAuthoritativeTerminalBuffer ??
|
||||
((ptyId: string, opts?: { scrollbackRows?: number }) =>
|
||||
overrides.serializeTerminalBuffer?.(ptyId, opts))
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
// Why: every multiplex stream registers as a remote view subscriber for
|
||||
@@ -33,6 +41,7 @@ function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeSe
|
||||
isPtyResizeDrivenRemotely: vi.fn().mockReturnValue(false),
|
||||
getRemoteDesktopFitHold: vi.fn().mockReturnValue({ mode: 'desktop-fit', cols: 120, rows: 40 }),
|
||||
isRemoteDesktopViewerOwner: vi.fn().mockReturnValue(false),
|
||||
serializeAuthoritativeTerminalBuffer,
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
@@ -218,6 +227,11 @@ describe('terminal multiplex RPC', () => {
|
||||
cols: 120,
|
||||
rows: 40
|
||||
}),
|
||||
serializeAuthoritativeTerminalBuffer: vi.fn().mockResolvedValue({
|
||||
data: 'authoritative snapshot',
|
||||
cols: 120,
|
||||
rows: 40
|
||||
}),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
@@ -508,7 +522,10 @@ describe('terminal multiplex RPC', () => {
|
||||
).toMatchObject({
|
||||
requestId: 7
|
||||
})
|
||||
expect(runtime.serializeTerminalBuffer).toHaveBeenLastCalledWith('pty-1', {
|
||||
expect(runtime.serializeTerminalBuffer).toHaveBeenCalledWith('pty-1', {
|
||||
scrollbackRows: 0
|
||||
})
|
||||
expect(runtime.serializeAuthoritativeTerminalBuffer).toHaveBeenLastCalledWith('pty-1', {
|
||||
scrollbackRows: 5000
|
||||
})
|
||||
expect(
|
||||
@@ -516,7 +533,7 @@ describe('terminal multiplex RPC', () => {
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
|
||||
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
||||
.join('')
|
||||
).toBe('snapshot')
|
||||
).toBe('authoritative snapshot')
|
||||
|
||||
// A viewport-less stream is passive: it must neither register nor later
|
||||
// release the active stream's width floor when the connection closes.
|
||||
@@ -3106,14 +3123,18 @@ describe('terminal multiplex RPC', () => {
|
||||
await harness.dispatchPromise
|
||||
})
|
||||
|
||||
it('caps multiplex stream slots so aggregate pending output stays bounded', async () => {
|
||||
it('supports 44 active streams while retaining a bounded per-connection limit', async () => {
|
||||
const harness = startDesktopMultiplexSubscribe()
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.messages.some((message) => JSON.parse(message).result?.type === 'ready')).toBe(
|
||||
true
|
||||
)
|
||||
)
|
||||
for (let streamId = 1; streamId <= 33; streamId += 1) {
|
||||
for (
|
||||
let streamId = 1;
|
||||
streamId <= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION + 1;
|
||||
streamId += 1
|
||||
) {
|
||||
harness.handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
@@ -3133,13 +3154,118 @@ describe('terminal multiplex RPC', () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const results = harness.messages.map((message) => JSON.parse(message).result)
|
||||
expect(results.filter((result) => result?.type === 'subscribed')).toHaveLength(32)
|
||||
const subscribedStreamIds = results
|
||||
.filter((result) => result?.type === 'subscribed')
|
||||
.map((result) => result.streamId)
|
||||
expect(subscribedStreamIds).toHaveLength(TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION)
|
||||
expect(subscribedStreamIds).toContain(44)
|
||||
expect(results).toContainEqual({
|
||||
type: 'error',
|
||||
streamId: 33,
|
||||
streamId: TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION + 1,
|
||||
message: 'terminal_stream_limit_exceeded'
|
||||
})
|
||||
expect(results).toContainEqual({ type: 'end', streamId: 33 })
|
||||
expect(results).toContainEqual({
|
||||
type: 'end',
|
||||
streamId: TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION + 1
|
||||
})
|
||||
})
|
||||
|
||||
harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.()
|
||||
await harness.dispatchPromise
|
||||
})
|
||||
|
||||
it('reserves PTY wait capacity independently from active streams', async () => {
|
||||
const waitSignals: AbortSignal[] = []
|
||||
const resolveWaits: ((ptyId: string) => void)[] = []
|
||||
const runtime = stubRuntime({
|
||||
resolveLiveLeafForHandle: vi.fn((terminal: string) =>
|
||||
terminal.startsWith('pending-') ? { ptyId: null } : { ptyId: `pty-${terminal}` }
|
||||
),
|
||||
waitForLeafPtyId: vi.fn(
|
||||
(_handle: string, _timeoutMs?: number, signal?: AbortSignal) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
if (signal) {
|
||||
waitSignals.push(signal)
|
||||
}
|
||||
resolveWaits.push(resolve)
|
||||
signal?.addEventListener('abort', () => reject(new Error('request_aborted')), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
const harness = startDesktopMultiplexSubscribe(runtime)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.messages.some((message) => JSON.parse(message).result?.type === 'ready')).toBe(
|
||||
true
|
||||
)
|
||||
)
|
||||
const sendSubscribe = (streamId: number, terminal: string): void => {
|
||||
harness.handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: streamId,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId,
|
||||
terminal,
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
capabilities: { ackOutput: 1 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
}
|
||||
|
||||
for (let streamId = 1; streamId <= 44; streamId += 1) {
|
||||
sendSubscribe(streamId, `active-${streamId}`)
|
||||
}
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
harness.messages.filter((message) => JSON.parse(message).result?.type === 'subscribed')
|
||||
).toHaveLength(44)
|
||||
)
|
||||
|
||||
for (
|
||||
let offset = 1;
|
||||
offset <= TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION + 1;
|
||||
offset += 1
|
||||
) {
|
||||
sendSubscribe(44 + offset, `pending-${offset}`)
|
||||
}
|
||||
await vi.waitFor(() =>
|
||||
expect(waitSignals).toHaveLength(TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION)
|
||||
)
|
||||
const rejectedStreamId = 45 + TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION
|
||||
await vi.waitFor(() => {
|
||||
const results = harness.messages.map((message) => JSON.parse(message).result)
|
||||
expect(results).toContainEqual({
|
||||
type: 'error',
|
||||
streamId: rejectedStreamId,
|
||||
message: 'terminal_stream_limit_exceeded'
|
||||
})
|
||||
expect(results).toContainEqual({ type: 'end', streamId: rejectedStreamId })
|
||||
})
|
||||
|
||||
for (const [index, resolve] of resolveWaits.entries()) {
|
||||
resolve(`pty-pending-${index + 1}`)
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
const results = harness.messages.map((message) => JSON.parse(message).result)
|
||||
expect(results.filter((result) => result?.type === 'subscribed')).toHaveLength(
|
||||
TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION
|
||||
)
|
||||
expect(
|
||||
results.filter(
|
||||
(result) =>
|
||||
result?.type === 'error' && result.message === 'terminal_stream_limit_exceeded'
|
||||
)
|
||||
).toHaveLength(
|
||||
45 +
|
||||
TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION -
|
||||
TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION
|
||||
)
|
||||
})
|
||||
|
||||
harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.()
|
||||
|
||||
@@ -88,6 +88,7 @@ import { getTerminalWorktreeColdParkRecheckDelayMs } from './terminal-pane/termi
|
||||
import {
|
||||
TERMINAL_WORKTREE_COLD_PARK_DELAY_MS,
|
||||
canParkTerminalWorktreeRenderers,
|
||||
selectPairedRuntimeParkingEnvironmentIds,
|
||||
selectColdParkedTerminalWorktrees,
|
||||
type TerminalWorktreeColdParkCandidate
|
||||
} from './terminal-pane/terminal-hidden-view-parking'
|
||||
@@ -300,6 +301,11 @@ function Terminal(): React.JSX.Element | null {
|
||||
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
|
||||
const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false)
|
||||
const terminalSshParkingEnabled = useAppStore((s) => s.settings?.terminalSshViewParking !== false)
|
||||
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
|
||||
const pairedRuntimeParkingEnvironmentIds = useMemo(
|
||||
() => selectPairedRuntimeParkingEnvironmentIds(runtimeStatusByEnvironmentId),
|
||||
[runtimeStatusByEnvironmentId]
|
||||
)
|
||||
const terminalRetentionBudgetEnabled = useAppStore(
|
||||
(s) => s.settings?.terminalHiddenWorktreeRetentionBudget !== false
|
||||
)
|
||||
@@ -953,7 +959,10 @@ function Terminal(): React.JSX.Element | null {
|
||||
})
|
||||
}
|
||||
|
||||
const restorePolicy = { sshParkingEnabled: terminalSshParkingEnabled }
|
||||
const restorePolicy = {
|
||||
sshParkingEnabled: terminalSshParkingEnabled,
|
||||
pairedRuntimeParkingEnvironmentIds
|
||||
}
|
||||
const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({
|
||||
worktrees: retentionCandidates,
|
||||
pendingStartupByTabId,
|
||||
@@ -1134,6 +1143,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
activityTerminalPortals,
|
||||
backgroundMountRevision,
|
||||
pendingStartupByTabId,
|
||||
pairedRuntimeParkingEnvironmentIds,
|
||||
renderedActiveWorktreeId,
|
||||
tabsByWorktree,
|
||||
terminalParkingEnabled,
|
||||
|
||||
@@ -554,6 +554,32 @@ describe('startParkedTerminalByteWatcher', () => {
|
||||
second.dispose()
|
||||
})
|
||||
|
||||
it('consumes host facts without a raw terminal stream for paired PTYs', async () => {
|
||||
const remotePtyId = 'remote:env-1@@terminal-1'
|
||||
const { dispose } = await startWatcher({ ptyId: remotePtyId })
|
||||
const handler = await import('./terminal-side-effect-facts-handler')
|
||||
|
||||
expect(onData).toBeNull()
|
||||
handler._dispatchTerminalSideEffectBatchForTest({
|
||||
ptyId: remotePtyId,
|
||||
seq: 10,
|
||||
facts: [
|
||||
{
|
||||
kind: 'title',
|
||||
normalizedTitle: '⠋ Remote build',
|
||||
rawTitle: '⠋ Remote build'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(
|
||||
TAB_ID,
|
||||
PANE_ID,
|
||||
'⠋ Remote build'
|
||||
)
|
||||
dispose()
|
||||
})
|
||||
|
||||
// ─── Main side-effect authority (terminal-side-effect-authority.md) ────
|
||||
//
|
||||
// With the kill switch on, the watcher must not register byte parsers —
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from './terminal-side-effect-facts-handler'
|
||||
import { dispatchTerminalNotification } from './use-notification-dispatch'
|
||||
import { acquireHiddenRendererPtyDeliveryClaim } from './pty-renderer-delivery-claims'
|
||||
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
|
||||
|
||||
// Why: keep the live path's BEL-vs-completion race window so notification behavior is identical whether a tab is parked or mounted.
|
||||
const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS
|
||||
@@ -70,6 +71,7 @@ export function startParkedTerminalByteWatcher(
|
||||
options: ParkedTerminalByteWatcherOptions
|
||||
): () => void {
|
||||
const { ptyId, tabId, worktreeId, paneId, sendInput } = options
|
||||
const remoteRuntimePty = isRemoteRuntimePtyId(ptyId)
|
||||
const drivesTabTitle = options.drivesTabTitle ?? true
|
||||
const paneKey = makePaneKey(tabId, options.leafId)
|
||||
|
||||
@@ -204,14 +206,18 @@ export function startParkedTerminalByteWatcher(
|
||||
})
|
||||
|
||||
// Why: with the authority switch on, the fact consumer is the single policy consumer — registering byte parsers too would double-fire bells.
|
||||
const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: useAppStore.getState().settings,
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
const mainSideEffectAuthority =
|
||||
!remoteRuntimePty &&
|
||||
isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: useAppStore.getState().settings,
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
const factSideEffectAuthority = mainSideEffectAuthority || remoteRuntimePty
|
||||
// Why: decided once at watcher start — it picks which 2031 responder (byte sidecar vs fact reply) exists, so it must never flip per chunk.
|
||||
const hiddenDeliveryGateActive =
|
||||
mainSideEffectAuthority &&
|
||||
isRendererHiddenPtyDeliveryGateEnabled(useAppStore.getState().settings)
|
||||
const factOwnsMode2031 = hiddenDeliveryGateActive || remoteRuntimePty
|
||||
|
||||
const sendMode2031Reply = (): void => {
|
||||
const settings = useAppStore.getState().settings
|
||||
@@ -220,32 +226,32 @@ export function startParkedTerminalByteWatcher(
|
||||
|
||||
// Why (byte-parser mode only): reuse the transport's output processor to keep exact live-path parsing semantics.
|
||||
// initialAgentTitle: an agent already working at park time still produces a working→idle transition.
|
||||
const processor = mainSideEffectAuthority
|
||||
const processor = factSideEffectAuthority
|
||||
? null
|
||||
: createPtyOutputProcessor({
|
||||
...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}),
|
||||
...sideEffectCallbacks
|
||||
})
|
||||
// Why (byte-parser mode only): under main authority, byte-scanning PR links too would observe every link twice (facts already carry them).
|
||||
const observeTerminalGitHubPRLink = mainSideEffectAuthority
|
||||
const observeTerminalGitHubPRLink = factSideEffectAuthority
|
||||
? null
|
||||
: createTerminalGitHubPRLinkDetector()
|
||||
// Why (byte-parser mode only): mode parity — main's tracker emits these as facts; the byte
|
||||
// path scans the same shared parsers the mounted kill-switch-off pane uses.
|
||||
const commandFinishedScanner = mainSideEffectAuthority
|
||||
const commandFinishedScanner = factSideEffectAuthority
|
||||
? null
|
||||
: createOsc133CommandFinishedScanner(commandStatusPolicy.onCommandFinished)
|
||||
// Why the seed: this detector is recreated per park cycle with no startup command
|
||||
// to fast-arm it, and a Command Code TUI parked mid-turn is long past its banner —
|
||||
// unseeded it would never scrape the turn's return to the idle composer.
|
||||
const commandCodeOutputStatusDetector = mainSideEffectAuthority
|
||||
const commandCodeOutputStatusDetector = factSideEffectAuthority
|
||||
? null
|
||||
: createCommandCodeOutputStatusDetector({
|
||||
inFlightTurn: readInFlightCommandCodeTurn(paneKey),
|
||||
onWorking: commandStatusPolicy.onCommandCodeWorking,
|
||||
onDone: commandStatusPolicy.onCommandCodeDone
|
||||
})
|
||||
const unregisterFactConsumer = mainSideEffectAuthority
|
||||
const unregisterFactConsumer = factSideEffectAuthority
|
||||
? registerTerminalSideEffectFactConsumer({
|
||||
ptyId,
|
||||
// Why: ordinary park already has a pane-owned title; the flag below requests a snapshot only when no pane did.
|
||||
@@ -257,7 +263,7 @@ export function startParkedTerminalByteWatcher(
|
||||
onPrLink: (link) =>
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link),
|
||||
// Why (gate mode only): the 2031 subscribe arrives as a fact, but the reply stays here — query authority stays with the view/watcher (invariant 6).
|
||||
...(hiddenDeliveryGateActive ? { onMode2031Subscribe: sendMode2031Reply } : {})
|
||||
...(factOwnsMode2031 ? { onMode2031Subscribe: sendMode2031Reply } : {})
|
||||
},
|
||||
// Why: activation-deferred tabs can start a watcher before any pane restored the title; ordinary parked tabs avoid this IPC.
|
||||
restoreTitleOnRegister: options.restoreTitleOnRegister === true
|
||||
@@ -265,7 +271,7 @@ export function startParkedTerminalByteWatcher(
|
||||
: null
|
||||
|
||||
// Why: no xterm answers DECSET 2031 while parked; with the gate ON, the responder's sidecar would force-feed bytes to the gated PTY, so skip it.
|
||||
const stopMode2031Responder = hiddenDeliveryGateActive
|
||||
const stopMode2031Responder = factOwnsMode2031
|
||||
? null
|
||||
: startParkedTerminalMode2031Responder({ ptyId, sendInput })
|
||||
|
||||
@@ -274,21 +280,23 @@ export function startParkedTerminalByteWatcher(
|
||||
? acquireHiddenRendererPtyDeliveryClaim(ptyId)
|
||||
: null
|
||||
|
||||
// Why (byte-parser mode only): under main authority, registering byte parsers here would double-fire policy already carried by facts.
|
||||
const processLiveData = (data: string): void => {
|
||||
if (!processor) {
|
||||
return
|
||||
}
|
||||
processor.processData(data, {})
|
||||
commandFinishedScanner?.scan(data)
|
||||
commandCodeOutputStatusDetector?.observe(data)
|
||||
if (observeTerminalGitHubPRLink) {
|
||||
for (const link of observeTerminalGitHubPRLink(data)) {
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: paired hosts forward derived facts over the one environment event
|
||||
// stream; parked PTYs never consume terminal multiplex slots or raw bytes.
|
||||
const unsubscribeByteParsers =
|
||||
processor === null
|
||||
? null
|
||||
: subscribeToPtyData(ptyId, (data) => {
|
||||
// Why: empty pane callbacks — no xterm to deliver bytes to, the watcher wants only the parser side effects.
|
||||
processor.processData(data, {})
|
||||
commandFinishedScanner?.scan(data)
|
||||
commandCodeOutputStatusDetector?.observe(data)
|
||||
if (observeTerminalGitHubPRLink) {
|
||||
for (const link of observeTerminalGitHubPRLink(data)) {
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
|
||||
}
|
||||
}
|
||||
})
|
||||
processor === null ? null : subscribeToPtyData(ptyId, processLiveData)
|
||||
|
||||
const dispose = (): void => {
|
||||
if (disposed) {
|
||||
|
||||
@@ -165,6 +165,13 @@ type StoreState = {
|
||||
}[]
|
||||
>
|
||||
runtimeEnvironments?: { id: string }[]
|
||||
runtimeStatusByEnvironmentId: Map<
|
||||
string,
|
||||
{
|
||||
checkedAt: number
|
||||
status: { capabilities?: string[] } | null
|
||||
}
|
||||
>
|
||||
runtimeEnvironmentCatalogHydrated?: boolean
|
||||
repos: {
|
||||
id: string
|
||||
@@ -894,6 +901,7 @@ describe('connectPanePty', () => {
|
||||
worktreesByRepo: {
|
||||
repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', displayName: 'feat/notis' }]
|
||||
},
|
||||
runtimeStatusByEnvironmentId: new Map(),
|
||||
repos: [{ id: 'repo1', connectionId: null, displayName: 'orca' }],
|
||||
projects: [],
|
||||
sshConnectionStates: new Map(),
|
||||
@@ -20043,6 +20051,54 @@ describe('connectPanePty', () => {
|
||||
expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH')
|
||||
})
|
||||
|
||||
it('restores configured paired scrollback after an ordinary park reveal', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const remotePtyId = 'remote:env-1@@terminal-1'
|
||||
const transport = createMockTransport(remotePtyId)
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
transport.getPtyId.mockReturnValue(remotePtyId)
|
||||
callbacks.onReplayData?.('current screen from initial subscribe\r\n')
|
||||
return { id: remotePtyId, isReattach: true, replay: '' }
|
||||
})
|
||||
transport.serializeBuffer = vi.fn().mockResolvedValue({
|
||||
data: 'DEEP_PAIRED_SCROLLBACK\r\ncurrent screen\r\n',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
seq: 4_096,
|
||||
source: 'headless'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
await parkTabForReveal('tab-1', remotePtyId)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
[
|
||||
'env-1',
|
||||
{
|
||||
checkedAt: Date.now(),
|
||||
status: { capabilities: ['terminal.paired-parking.v1'] }
|
||||
}
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
const pane = createPane(1)
|
||||
const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
for (let step = 0; step < 30; step += 1) {
|
||||
parseCallbacks.shift()?.()
|
||||
await flushAsyncTicks(2)
|
||||
}
|
||||
|
||||
expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 })
|
||||
expect(transport.attach).not.toHaveBeenCalled()
|
||||
expect(writes.join('')).toContain('DEEP_PAIRED_SCROLLBACK')
|
||||
expect(writes.join('')).toContain('current screen from initial subscribe')
|
||||
expect(transport.getPtyId).toHaveReturnedWith(remotePtyId)
|
||||
})
|
||||
|
||||
it('falls back to relay replay when the SSH model snapshot stalls', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { SSH_REATTACH_MODEL_SNAPSHOT_TIMEOUT_MS } = await import('./ssh-reattach-model-restore')
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { isEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id'
|
||||
import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic'
|
||||
@@ -768,6 +769,17 @@ function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean {
|
||||
return typeof ptyId === 'string' && ptyId.startsWith(REMOTE_PTY_ID_PREFIX)
|
||||
}
|
||||
|
||||
function canRestorePairedParkedTerminal(ptyId: string): boolean {
|
||||
const environmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
|
||||
return (
|
||||
environmentId !== null &&
|
||||
useAppStore
|
||||
.getState()
|
||||
.runtimeStatusByEnvironmentId.get(environmentId)
|
||||
?.status?.capabilities?.includes(TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) === true
|
||||
)
|
||||
}
|
||||
|
||||
function consumeInactiveForegroundImmediateBudget(dataLength: number): boolean {
|
||||
const now = performance.now()
|
||||
if (now - inactiveForegroundImmediateBudgetWindowStart > FOREGROUND_BUDGET_WINDOW_MS) {
|
||||
@@ -2587,7 +2599,7 @@ export function connectPanePty(
|
||||
breadcrumbIdentity: {
|
||||
tabId: deps.tabId,
|
||||
worktreeId: deps.worktreeId,
|
||||
ptyId: transport.getPtyId()
|
||||
ptyId
|
||||
},
|
||||
shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously
|
||||
})
|
||||
@@ -7690,20 +7702,30 @@ export function connectPanePty(
|
||||
// the probe; a later in-place reconnect on this same mount must not buy a
|
||||
// second timeout before the relay paint.
|
||||
const revealFollowsTerminalPark =
|
||||
mountFollowsTerminalPark && connectResult?.isReattach === true
|
||||
mountFollowsTerminalPark &&
|
||||
(connectResult?.isReattach === true || isRemoteRuntimePtyId(ptyId))
|
||||
mountFollowsTerminalPark = false
|
||||
// Why: a relay restart empties the replay buffer, but main's model may
|
||||
// still hold the session — a park-reveal probes it even with no replay
|
||||
// so the reveal is never blank when main has content. Prefetched (before
|
||||
// the payload task) so the coordinator route covers the paint.
|
||||
let prefetchedSshModelSnapshot: PtyBufferSnapshot | null = null
|
||||
if (revealFollowsTerminalPark && !hasStructuralReplay) {
|
||||
prefetchedSshModelSnapshot = await fetchSshMainModelReattachSnapshot()
|
||||
// Why: ordinary parking destroys xterm. Rebuild from the authoritative
|
||||
// host snapshot before releasing queued live bytes; null falls back to
|
||||
// the subscribe screen without keeping the old xterm mounted.
|
||||
let prefetchedParkModelSnapshot: PtyBufferSnapshot | null = null
|
||||
if (revealFollowsTerminalPark && (!hasStructuralReplay || isRemoteRuntimePtyId(ptyId))) {
|
||||
if (isRemoteRuntimePtyId(ptyId)) {
|
||||
try {
|
||||
prefetchedParkModelSnapshot = await serializeHiddenOutputSnapshot(ptyId, {
|
||||
scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback)
|
||||
})
|
||||
} catch {
|
||||
prefetchedParkModelSnapshot = null
|
||||
}
|
||||
} else {
|
||||
prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot()
|
||||
}
|
||||
if (!isCurrentReattachPayload()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
let reattachPayloadApplied = !hasStructuralReplay && prefetchedSshModelSnapshot === null
|
||||
let reattachPayloadApplied = !hasStructuralReplay && prefetchedParkModelSnapshot === null
|
||||
const applyReattachPayload = async (): Promise<void> => {
|
||||
if (!isCurrentReattachPayload()) {
|
||||
return
|
||||
@@ -7744,13 +7766,14 @@ export function connectPanePty(
|
||||
window.api.pty.ackColdRestore(ptyId)
|
||||
}
|
||||
}
|
||||
} else if (connectResult?.replay || prefetchedSshModelSnapshot) {
|
||||
} else if (connectResult?.replay || prefetchedParkModelSnapshot) {
|
||||
// Why scoped to a park-reveal: the 100KiB relay tail loses scrollback the
|
||||
// model still holds, but an in-place reattach (network reconnect, wake,
|
||||
// reload) already has that replay in hand, so probing would only delay its
|
||||
// paint by the timeout. Memoized, so this is never a second probe.
|
||||
const modelSnapshot = revealFollowsTerminalPark
|
||||
? (prefetchedSshModelSnapshot ?? (await fetchSshMainModelReattachSnapshot()))
|
||||
? (prefetchedParkModelSnapshot ??
|
||||
(isRemoteRuntimePtyId(ptyId) ? null : await fetchSshMainModelReattachSnapshot()))
|
||||
: null
|
||||
if (!isCurrentReattachPayload()) {
|
||||
return
|
||||
@@ -7876,7 +7899,7 @@ export function connectPanePty(
|
||||
schedulePendingStartupCommandDelivery()
|
||||
}
|
||||
}
|
||||
if (hasStructuralReplay || prefetchedSshModelSnapshot) {
|
||||
if (hasStructuralReplay || prefetchedParkModelSnapshot) {
|
||||
await waitForTerminalReplayWritesParsed(pane.terminal)
|
||||
if (!isCurrentReattachPayload()) {
|
||||
return
|
||||
@@ -7930,7 +7953,7 @@ export function connectPanePty(
|
||||
window.api.pty.signal(reattachPtyId, 'SIGWINCH')
|
||||
}
|
||||
}
|
||||
if (hasStructuralReplay || prefetchedSshModelSnapshot) {
|
||||
if (hasStructuralReplay || prefetchedParkModelSnapshot) {
|
||||
await structuralReplayCoordinator.run(applyReattachPayload, {
|
||||
shouldRestore: isCurrentReattachPayload,
|
||||
afterRestore: fitAfterReattachRestore
|
||||
@@ -8345,9 +8368,17 @@ export function connectPanePty(
|
||||
const legacyAttachOnlyPtyId = isLegacyWorkerAutomaticResumeBlocked()
|
||||
? candidateReattachSessionId
|
||||
: null
|
||||
const pairedParkedReattachSessionId =
|
||||
mountFollowsTerminalPark &&
|
||||
candidateReattachSessionId &&
|
||||
isRemoteRuntimePtyId(candidateReattachSessionId) &&
|
||||
canRestorePairedParkedTerminal(candidateReattachSessionId)
|
||||
? candidateReattachSessionId
|
||||
: null
|
||||
const deferredReattachSessionId = legacyAttachOnlyPtyId
|
||||
? null
|
||||
: (runtimeHostPtyWakeHint ??
|
||||
pairedParkedReattachSessionId ??
|
||||
(candidateReattachSessionId &&
|
||||
!isRemoteRuntimePtyId(candidateReattachSessionId) &&
|
||||
!candidateHasEagerBuffer &&
|
||||
|
||||
@@ -736,7 +736,11 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
callbacks: {}
|
||||
})
|
||||
|
||||
expect(result).toEqual({ id: 'remote:hub-env@@hub-terminal-1', replay: '' })
|
||||
expect(result).toEqual({
|
||||
id: 'remote:hub-env@@hub-terminal-1',
|
||||
replay: '',
|
||||
isReattach: true
|
||||
})
|
||||
expect(transport.getPtyId()).toBe('remote:hub-env@@hub-terminal-1')
|
||||
expect(transport.getExecutionHostId?.()).toBe('ssh:hub-private')
|
||||
expect(transport.getRemotePlatform?.()).toBe('win32')
|
||||
@@ -2485,7 +2489,11 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
|
||||
const result = await transport.connect({ url: '', callbacks: {} })
|
||||
|
||||
expect(result).toEqual({ id: 'remote:env-1@@terminal-1', replay: '' })
|
||||
expect(result).toEqual({
|
||||
id: 'remote:env-1@@terminal-1',
|
||||
replay: '',
|
||||
isReattach: true
|
||||
})
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'session.tabs.activate',
|
||||
@@ -2632,7 +2640,11 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
|
||||
const result = await transport.connect({ url: '', callbacks: {} })
|
||||
|
||||
expect(result).toEqual({ id: 'remote:env-1@@terminal-2', replay: '' })
|
||||
expect(result).toEqual({
|
||||
id: 'remote:env-1@@terminal-2',
|
||||
replay: '',
|
||||
isReattach: true
|
||||
})
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'session.tabs.activate',
|
||||
@@ -2737,7 +2749,11 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
|
||||
const result = await transport.connect({ url: '', callbacks: {} })
|
||||
|
||||
expect(result).toEqual({ id: 'remote:env-1@@terminal-2', replay: '' })
|
||||
expect(result).toEqual({
|
||||
id: 'remote:env-1@@terminal-2',
|
||||
replay: '',
|
||||
isReattach: true
|
||||
})
|
||||
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-2' })
|
||||
})
|
||||
|
||||
@@ -3112,6 +3128,44 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('backs off before retrying a capacity-rejected terminal stream', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1'
|
||||
})
|
||||
|
||||
await transport.connect({ url: '', callbacks: {} })
|
||||
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
|
||||
const { streamId } = latestSubscribePayload()
|
||||
subscriptionCallbacks?.onResponse({
|
||||
ok: true,
|
||||
result: {
|
||||
type: 'error',
|
||||
streamId,
|
||||
message: 'terminal_stream_limit_exceeded'
|
||||
}
|
||||
})
|
||||
subscriptionCallbacks?.onResponse({
|
||||
ok: true,
|
||||
result: { type: 'end', streamId }
|
||||
})
|
||||
|
||||
expect(transport.getRecoveryState?.().phase).toBe('backoff')
|
||||
expect(runtimeSubscribe).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(runtimeSubscribe).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2))
|
||||
transport.destroy?.()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps retrying when the first post-partition terminal reattach fails', async () => {
|
||||
let subscribeAttempt = 0
|
||||
const recoveryPhases: string[] = []
|
||||
@@ -4051,6 +4105,9 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
expect(onConnect).toHaveBeenCalled()
|
||||
|
||||
const snapshotPromise = transport.serializeBuffer?.({ scrollbackRows: 5000 })
|
||||
await vi.waitFor(() =>
|
||||
expect(latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)).toBeDefined()
|
||||
)
|
||||
const snapshotRequestFrame = latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)
|
||||
const snapshotRequestPayload = snapshotRequestFrame
|
||||
? decodeTerminalStreamJson<{ requestId?: number; scrollbackRows?: number }>(
|
||||
@@ -4103,16 +4160,21 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
const { streamId } = latestSubscribePayload()
|
||||
|
||||
const snapshotPromise = transport.serializeBuffer?.({ scrollbackRows: 5000 })
|
||||
expect(latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)).toBeUndefined()
|
||||
|
||||
emitSnapshot(streamId, 'initial replay')
|
||||
expect(onReplayData).toHaveBeenCalledWith('initial replay')
|
||||
expect(onConnect).toHaveBeenCalled()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)).toBeDefined()
|
||||
)
|
||||
const snapshotRequestFrame = latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)
|
||||
const snapshotRequestPayload = snapshotRequestFrame
|
||||
? decodeTerminalStreamJson<{ requestId?: number }>(snapshotRequestFrame.payload)
|
||||
: null
|
||||
expect(snapshotRequestPayload?.requestId).toBe(1)
|
||||
|
||||
emitSnapshot(streamId, 'initial replay')
|
||||
expect(onReplayData).toHaveBeenCalledWith('initial replay')
|
||||
expect(onConnect).toHaveBeenCalled()
|
||||
|
||||
emitSnapshotFrame(
|
||||
streamId,
|
||||
TerminalStreamOpcode.SnapshotStart,
|
||||
|
||||
@@ -146,6 +146,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
let destroyed = false
|
||||
let terminalEnded = false
|
||||
let connecting = false
|
||||
const attachmentReadyWaiters = new Set<(ready: boolean) => void>()
|
||||
// Why: transport methods overlap during remounts; only the latest pane lifecycle may install a returned PTY.
|
||||
let lifecycleEpoch = 0
|
||||
let handle: string | null = null
|
||||
@@ -165,6 +166,35 @@ export function createRemoteRuntimePtyTransport(
|
||||
let stopWaitingForPublishedHandle: (() => void) | null = null
|
||||
let attachGeneration = 0
|
||||
let subscriptionGeneration = 0
|
||||
|
||||
function setAttachmentReady(ready: boolean): void {
|
||||
attachmentReady = ready
|
||||
for (const resolve of attachmentReadyWaiters) {
|
||||
resolve(ready)
|
||||
}
|
||||
attachmentReadyWaiters.clear()
|
||||
}
|
||||
|
||||
function waitForAttachmentReady(): Promise<boolean> {
|
||||
if (attachmentReady) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
if (destroyed || terminalEnded || !connected || !handle) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
attachmentReadyWaiters.delete(settle)
|
||||
resolve(false)
|
||||
}, HOST_SESSION_ATTACH_TIMEOUT_MS)
|
||||
const settle = (ready: boolean): void => {
|
||||
clearTimeout(timer)
|
||||
resolve(ready)
|
||||
}
|
||||
attachmentReadyWaiters.add(settle)
|
||||
})
|
||||
}
|
||||
|
||||
const recovery = new RemoteRuntimePtyRecoveryState(() => {
|
||||
if (recovery.currentPhase === 'disconnected') {
|
||||
clearPublishedHandleWait()
|
||||
@@ -562,7 +592,8 @@ export function createRemoteRuntimePtyTransport(
|
||||
|
||||
return {
|
||||
id: remotePtyId,
|
||||
replay: ''
|
||||
replay: '',
|
||||
isReattach: true
|
||||
} satisfies PtyConnectResult
|
||||
}
|
||||
|
||||
@@ -846,7 +877,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return { id: remotePtyId, replay: '' }
|
||||
return { id: remotePtyId, replay: '', isReattach: true }
|
||||
}
|
||||
|
||||
function recoverExpiredHostPane(): void {
|
||||
@@ -1033,7 +1064,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
multiplexedStream?.close()
|
||||
multiplexedStream = null
|
||||
multiplexedStreamHandle = null
|
||||
attachmentReady = false
|
||||
setAttachmentReady(false)
|
||||
}
|
||||
|
||||
function clearPublishedHandleWait(): void {
|
||||
@@ -1077,7 +1108,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
handle = nextHandle
|
||||
remotePtyId = toRemoteRuntimePtyId(nextHandle, currentRuntimeEnvironmentId)
|
||||
registerShutdownHandlers(remotePtyId)
|
||||
attachmentReady = false
|
||||
setAttachmentReady(false)
|
||||
// Why: host handle rotation preserves the pane generation; only the store identity changes, not spawn/exit semantics.
|
||||
if (replacedPtyId) {
|
||||
replaceFitOverridePtyId(replacedPtyId, remotePtyId)
|
||||
@@ -1306,6 +1337,22 @@ export function createRemoteRuntimePtyTransport(
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleCapacityPressureRetry(): void {
|
||||
if (destroyed || !connected || !handle) {
|
||||
return
|
||||
}
|
||||
const recoveryWasActive = recovery.isActive
|
||||
const recoveryEpoch = recovery.begin()
|
||||
if (!recoveryWasActive) {
|
||||
inputBatcher.clear()
|
||||
viewportBatcher.clear()
|
||||
clearPendingViewportClaim()
|
||||
}
|
||||
recovery.schedule(recoveryEpoch, (nextEpoch) => {
|
||||
scheduleResubscribeAfterTransportClose(false, nextEpoch)
|
||||
})
|
||||
}
|
||||
|
||||
async function subscribeToHandle(expectedRecoveryEpoch?: number): Promise<void> {
|
||||
if (!handle) {
|
||||
return
|
||||
@@ -1313,7 +1360,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
const subscribedHandle = handle
|
||||
const subscribedPtyId = remotePtyId
|
||||
const generation = ++subscriptionGeneration
|
||||
attachmentReady = false
|
||||
setAttachmentReady(false)
|
||||
let transportClosed = false
|
||||
let subscriptionAttached = false
|
||||
// Why: viewport handed to subscribe; a resize during the round-trip falls back to the refresh-only one-shot RPC, replayed through the stream below once current.
|
||||
@@ -1358,7 +1405,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
return
|
||||
}
|
||||
subscriptionAttached = true
|
||||
attachmentReady = true
|
||||
setAttachmentReady(true)
|
||||
connecting = false
|
||||
recoveryRequiresReplacement = false
|
||||
recovery.markHealthy()
|
||||
@@ -1386,7 +1433,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
remotePtyId = null
|
||||
multiplexedStream = null
|
||||
multiplexedStreamHandle = null
|
||||
attachmentReady = false
|
||||
setAttachmentReady(false)
|
||||
terminalEnded = true
|
||||
clearPendingViewportClaim()
|
||||
emitRecoveryState()
|
||||
@@ -1411,7 +1458,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
setDriverForPty(subscribedPtyId, driver)
|
||||
}
|
||||
},
|
||||
onTransportClose: ({ recoverable }) => {
|
||||
onTransportClose: ({ recoverable, retryWithBackoff }) => {
|
||||
transportClosed = true
|
||||
if (generation !== subscriptionGeneration) {
|
||||
return
|
||||
@@ -1424,9 +1471,13 @@ export function createRemoteRuntimePtyTransport(
|
||||
}
|
||||
multiplexedStream = null
|
||||
multiplexedStreamHandle = null
|
||||
attachmentReady = false
|
||||
setAttachmentReady(false)
|
||||
if (recoverable) {
|
||||
scheduleResubscribeAfterTransportClose()
|
||||
if (retryWithBackoff) {
|
||||
scheduleCapacityPressureRetry()
|
||||
} else {
|
||||
scheduleResubscribeAfterTransportClose()
|
||||
}
|
||||
} else {
|
||||
connecting = false
|
||||
recovery.cancel()
|
||||
@@ -1450,7 +1501,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
closeMultiplexedStream()
|
||||
multiplexedStream = nextStream
|
||||
multiplexedStreamHandle = subscribedHandle
|
||||
attachmentReady = subscriptionAttached
|
||||
setAttachmentReady(subscriptionAttached)
|
||||
if (subscriptionAttached) {
|
||||
recoveryRequiresReplacement = false
|
||||
recovery.markHealthy()
|
||||
@@ -1982,11 +2033,16 @@ export function createRemoteRuntimePtyTransport(
|
||||
if (!connected || !handle) {
|
||||
return null
|
||||
}
|
||||
return getCurrentMultiplexedStream(handle)?.serializeBuffer(opts) ?? null
|
||||
const targetHandle = handle
|
||||
if (!(await waitForAttachmentReady()) || handle !== targetHandle) {
|
||||
return null
|
||||
}
|
||||
return getCurrentMultiplexedStream(targetHandle)?.serializeBuffer(opts) ?? null
|
||||
},
|
||||
|
||||
destroy() {
|
||||
destroyed = true
|
||||
setAttachmentReady(false)
|
||||
this.disconnect()
|
||||
recovery.dispose()
|
||||
inputBatcher.clear()
|
||||
|
||||
@@ -11,10 +11,26 @@ import {
|
||||
canParkTerminalWorktreeRenderers,
|
||||
isParkRestorableTerminalPty,
|
||||
isSnapshotBackedTerminalPty,
|
||||
selectPairedRuntimeParkingEnvironmentIds,
|
||||
selectColdParkedTerminalTabs,
|
||||
selectColdParkedTerminalWorktrees
|
||||
} from './terminal-hidden-view-parking'
|
||||
|
||||
describe('selectPairedRuntimeParkingEnvironmentIds', () => {
|
||||
it('selects only reachable hosts advertising the paired parking contract', () => {
|
||||
const statuses = new Map([
|
||||
[
|
||||
'capable',
|
||||
{ status: { capabilities: ['terminal.paired-parking.v1'] }, checkedAt: Date.now() }
|
||||
],
|
||||
['legacy', { status: { capabilities: ['terminal.multiplex.v1'] }, checkedAt: Date.now() }],
|
||||
['offline', { status: null, checkedAt: Date.now() }]
|
||||
])
|
||||
|
||||
expect(selectPairedRuntimeParkingEnvironmentIds(statuses)).toEqual(new Set(['capable']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSnapshotBackedTerminalPty', () => {
|
||||
it('allows local daemon sessions owned by the worktree', () => {
|
||||
expect(isSnapshotBackedTerminalPty('repo::/worktree@@session-1', 'repo::/worktree')).toBe(true)
|
||||
@@ -76,7 +92,22 @@ describe('isParkRestorableTerminalPty', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects remote-runtime, fail-open, foreign, and null ptys under every policy', () => {
|
||||
it('accepts paired ptys only for the exact snapshot-capable owner', () => {
|
||||
const pairedPolicy = {
|
||||
...sshPolicy,
|
||||
pairedRuntimeParkingEnvironmentIds: new Set(['env-1'])
|
||||
}
|
||||
|
||||
expect(isParkRestorableTerminalPty('remote:env-1@@terminal-1', worktreeId, pairedPolicy)).toBe(
|
||||
true
|
||||
)
|
||||
expect(isParkRestorableTerminalPty('remote:env-2@@terminal-1', worktreeId, pairedPolicy)).toBe(
|
||||
false
|
||||
)
|
||||
expect(isParkRestorableTerminalPty('remote:terminal-1', worktreeId, pairedPolicy)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects paired, fail-open, foreign, and null ptys without capability evidence', () => {
|
||||
for (const ptyId of ['remote:env-1@@terminal-1', 'pty-local-detached', 'other@@s-1', null]) {
|
||||
expect(isParkRestorableTerminalPty(ptyId, worktreeId, sshPolicy)).toBe(false)
|
||||
}
|
||||
@@ -214,6 +245,22 @@ describe('canParkTerminalWorktreeRenderers', () => {
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores mirrored activation residue after a capable paired PTY exists', () => {
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
terminalTabs: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
ptyId: 'remote:env-1@@terminal-1',
|
||||
pendingActivationSpawn: true
|
||||
}
|
||||
],
|
||||
restorePolicy: { pairedRuntimeParkingEnvironmentIds: new Set(['env-1']) }
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canParkTerminalTabRenderer', () => {
|
||||
@@ -237,6 +284,20 @@ describe('canParkTerminalTabRenderer', () => {
|
||||
expect(canParkTerminalTabRenderer({ ...base, parkingEnabled: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores mirrored activation residue after a capable paired PTY exists', () => {
|
||||
expect(
|
||||
canParkTerminalTabRenderer({
|
||||
...base,
|
||||
terminalTab: {
|
||||
...base.terminalTab,
|
||||
ptyId: 'remote:env-1@@terminal-1',
|
||||
pendingActivationSpawn: true
|
||||
},
|
||||
restorePolicy: { pairedRuntimeParkingEnvironmentIds: new Set(['env-1']) }
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('honors a per-call cold-park delay override', () => {
|
||||
expect(
|
||||
canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 99 })
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
|
||||
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
|
||||
import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format'
|
||||
import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
|
||||
@@ -56,6 +58,13 @@ function getPendingActivationSpawnCount(value: boolean | number | undefined): nu
|
||||
return typeof value === 'number' && value > 0 ? value : 0
|
||||
}
|
||||
|
||||
function hasPendingActivationSpawn(tab: ColdParkableTerminalTab): boolean {
|
||||
return (
|
||||
getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0 &&
|
||||
(!tab.ptyId || !isRemoteRuntimePtyId(tab.ptyId))
|
||||
)
|
||||
}
|
||||
|
||||
// Why: snapshot-backed = local daemon session owned by this worktree (foreign
|
||||
// ids reattach through a path parking cannot replay). SSH is restorable too,
|
||||
// via isParkRestorableTerminalPty + main's headless model; only remote-runtime
|
||||
@@ -77,12 +86,24 @@ export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: st
|
||||
export type TerminalParkRestorePolicy = {
|
||||
/** settings.terminalSshViewParking !== false — the C1 SSH-parking kill switch. */
|
||||
sshParkingEnabled?: boolean
|
||||
/** Exact paired environments whose host advertises bounded snapshot restore. */
|
||||
pairedRuntimeParkingEnvironmentIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
// Why: SSH bytes transit local main, so main's headless model (served over
|
||||
// pty:getMainBufferSnapshot) can re-hydrate a parked SSH reveal, with the
|
||||
// relay's replay buffer as fallback — fact-mode watchers cover side effects
|
||||
// either way. Remote-runtime ptys never transit main; they stay un-parkable.
|
||||
export function selectPairedRuntimeParkingEnvironmentIds(
|
||||
statuses: ReadonlyMap<string, { status: { capabilities?: readonly string[] } | null | undefined }>
|
||||
): Set<string> {
|
||||
const capable = new Set<string>()
|
||||
for (const [environmentId, entry] of statuses) {
|
||||
if (entry.status?.capabilities?.includes(TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY)) {
|
||||
capable.add(environmentId)
|
||||
}
|
||||
}
|
||||
return capable
|
||||
}
|
||||
|
||||
// Why: SSH uses local main's model; paired PTYs are eligible only when their
|
||||
// exact host advertises authoritative bounded restore.
|
||||
export function isParkRestorableTerminalPty(
|
||||
ptyId: string | null,
|
||||
worktreeId: string,
|
||||
@@ -91,6 +112,13 @@ export function isParkRestorableTerminalPty(
|
||||
if (isSnapshotBackedTerminalPty(ptyId, worktreeId)) {
|
||||
return true
|
||||
}
|
||||
if (ptyId && isRemoteRuntimePtyId(ptyId)) {
|
||||
const environmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
|
||||
return (
|
||||
environmentId !== null &&
|
||||
policy?.pairedRuntimeParkingEnvironmentIds?.has(environmentId) === true
|
||||
)
|
||||
}
|
||||
return policy?.sshParkingEnabled === true && ptyId !== null && parseAppSshPtyId(ptyId) !== null
|
||||
}
|
||||
|
||||
@@ -130,7 +158,7 @@ export function canParkTerminalWorktreeRenderers(args: {
|
||||
if (args.pendingStartupByTabId[tab.id] !== undefined) {
|
||||
return false
|
||||
}
|
||||
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
|
||||
if (hasPendingActivationSpawn(tab)) {
|
||||
return false
|
||||
}
|
||||
return isParkRestorableTerminalPty(tab.ptyId, args.worktreeId, args.restorePolicy)
|
||||
@@ -164,7 +192,7 @@ export function canParkTerminalTabRenderer(args: {
|
||||
if (args.pendingStartupByTabId[tab.id] !== undefined) {
|
||||
return false
|
||||
}
|
||||
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
|
||||
if (hasPendingActivationSpawn(tab)) {
|
||||
return false
|
||||
}
|
||||
return isParkRestorableTerminalPty(tab.ptyId, args.worktreeId, args.restorePolicy)
|
||||
|
||||
@@ -80,6 +80,10 @@ type MockStoreState = {
|
||||
>
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
|
||||
settings: { terminalSshViewParking?: boolean } | null
|
||||
runtimeStatusByEnvironmentId: Map<
|
||||
string,
|
||||
{ status: { capabilities?: string[] } | null; checkedAt: number }
|
||||
>
|
||||
clearTabLaunchAgent: ReturnType<typeof vi.fn>
|
||||
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
|
||||
setTabLayout: ReturnType<typeof vi.fn>
|
||||
@@ -141,6 +145,7 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
terminalLayoutsByTabId: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
settings: null,
|
||||
runtimeStatusByEnvironmentId: new Map(),
|
||||
clearTabLaunchAgent: vi.fn(),
|
||||
clearRuntimePaneTitle: vi.fn(),
|
||||
setTabLayout: vi.fn(),
|
||||
@@ -222,6 +227,23 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
})
|
||||
|
||||
it('starts a fact watcher for snapshot-capable paired PTYs', () => {
|
||||
mockStoreState.runtimeStatusByEnvironmentId.set('env-1', {
|
||||
status: { capabilities: ['terminal.paired-parking.v1'] },
|
||||
checkedAt: Date.now()
|
||||
})
|
||||
capturePanes([
|
||||
{ ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }
|
||||
])
|
||||
syncParked({ tabs: [{ id: TAB_ID, ptyId: 'remote:env-1@@terminal-1' }] })
|
||||
|
||||
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
|
||||
expect(startedWatchers[0].options).toMatchObject({
|
||||
ptyId: 'remote:env-1@@terminal-1'
|
||||
})
|
||||
expect(startedWatchers[0].options).not.toHaveProperty('onPtyExit')
|
||||
})
|
||||
|
||||
it('starts watchers for SSH PTYs (C1 SSH parking, default on)', () => {
|
||||
capturePanes([{ ptyId: 'ssh:conn-1@@pty-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked({ tabs: [{ id: TAB_ID, ptyId: 'ssh:conn-1@@pty-1' }] })
|
||||
@@ -263,6 +285,7 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
syncParked({ tabs: [], parkedTabIds: [TAB_ID] })
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 1)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -6,21 +6,23 @@
|
||||
* disposes them on reveal, tab close, PTY exit, or worktree teardown.
|
||||
*/
|
||||
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import {
|
||||
collectLeafIdsInOrder,
|
||||
resolveRootlessTerminalLayoutLeafId
|
||||
} from './terminal-layout-leaf-ids'
|
||||
import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
|
||||
import { subscribeToPtyExit } from './pty-dispatcher'
|
||||
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
|
||||
import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher'
|
||||
import {
|
||||
isParkRestorableTerminalPty,
|
||||
selectPairedRuntimeParkingEnvironmentIds,
|
||||
type TerminalParkRestorePolicy
|
||||
} from './terminal-hidden-view-parking'
|
||||
import {
|
||||
reconcileParkedWatcherPtyIds,
|
||||
resolveParkedTerminalPaneCandidates,
|
||||
type ParkableTerminalTabModel
|
||||
} from './terminal-parked-watcher-reconciliation'
|
||||
import {
|
||||
resolveTabTitleAfterPaneClose,
|
||||
shouldClearLaunchAgentForClosedPane
|
||||
@@ -28,8 +30,7 @@ import {
|
||||
import {
|
||||
capturedPanesByTabId,
|
||||
disposeParkedTabWatchers,
|
||||
parkedWatchersByTabId,
|
||||
type ParkedTerminalPaneCapture
|
||||
parkedWatchersByTabId
|
||||
} from './terminal-parked-watcher-registry'
|
||||
|
||||
// Why: re-export so callers keep one import surface; the registry split only breaks the store-slice import cycle.
|
||||
@@ -43,67 +44,31 @@ export {
|
||||
pruneParkedTerminalWatchers
|
||||
} from './terminal-parked-watcher-registry'
|
||||
export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry'
|
||||
|
||||
export type ParkableTerminalTabModel = Pick<TerminalTab, 'id' | 'ptyId'>
|
||||
export {
|
||||
fallbackParkedPaneCandidates,
|
||||
resolveParkedTerminalPaneCandidates
|
||||
} from './terminal-parked-watcher-reconciliation'
|
||||
export type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation'
|
||||
export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean
|
||||
|
||||
const allowSnapshotBackedPty = (): boolean => true
|
||||
|
||||
type ParkedPaneFallbackState = {
|
||||
terminalLayoutsByTabId: ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId']
|
||||
runtimePaneTitlesByTabId: ReturnType<typeof useAppStore.getState>['runtimePaneTitlesByTabId']
|
||||
}
|
||||
|
||||
// Why: pane ids are unknown in this layout fallback; reuse the sole runtime-title slot when unambiguous to overwrite a stale title, else negative slots that can't collide with real PaneManager ids.
|
||||
export function fallbackParkedPaneCandidates(
|
||||
tab: ParkableTerminalTabModel,
|
||||
state: ParkedPaneFallbackState
|
||||
): ParkedTerminalPaneCapture[] {
|
||||
const layout = state.terminalLayoutsByTabId[tab.id]
|
||||
// Why: a tab that never mounted a pane persists a rootless layout, so there is
|
||||
// no root to walk. replayTerminalLayout resolves the single leaf for that same
|
||||
// shape — match it here, or such a tab is permanently uncoverable and can
|
||||
// never park.
|
||||
const rootLeafIds = collectLeafIdsInOrder(layout?.root)
|
||||
const rootlessLeafId = layout ? resolveRootlessTerminalLayoutLeafId(layout) : null
|
||||
const leafIds =
|
||||
rootLeafIds.length > 0 ? rootLeafIds : rootlessLeafId !== null ? [rootlessLeafId] : []
|
||||
if (leafIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
const ptyIdsByLeafId = layout?.ptyIdsByLeafId ?? {}
|
||||
const titleSlots = Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {})
|
||||
const reusableSlot =
|
||||
leafIds.length === 1 && titleSlots.length === 1 ? Number(titleSlots[0]) : null
|
||||
return leafIds.map((leafId, index) => ({
|
||||
ptyId: ptyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null),
|
||||
paneId: reusableSlot ?? -(index + 1),
|
||||
leafId,
|
||||
drivesTabTitle: layout?.activeLeafId ? leafId === layout.activeLeafId : index === 0
|
||||
}))
|
||||
}
|
||||
|
||||
// Why: start path and eligibility check must resolve identical candidates, or a tab passes the check then starts uncoverable.
|
||||
export function resolveParkedTerminalPaneCandidates(
|
||||
tab: ParkableTerminalTabModel,
|
||||
state: ParkedPaneFallbackState
|
||||
): ParkedTerminalPaneCapture[] {
|
||||
const captured = capturedPanesByTabId.get(tab.id)
|
||||
// Why: a capture missing the tab's current PTY is stale (PTY re-minted since unmount); fall back to the layout.
|
||||
const capturedIsCurrent =
|
||||
captured !== undefined &&
|
||||
captured.panes.length > 0 &&
|
||||
(tab.ptyId === null || captured.panes.some((pane) => pane.ptyId === tab.ptyId))
|
||||
return capturedIsCurrent ? captured.panes : fallbackParkedPaneCandidates(tab, state)
|
||||
}
|
||||
|
||||
// Why: fact-mode watchers work for any pty whose bytes transit local main —
|
||||
// SSH included — so watcher coverage follows the park-restore policy, not the
|
||||
// stricter daemon-snapshot predicate.
|
||||
function parkRestorePolicyFromState(state: {
|
||||
settings: { terminalSshViewParking?: boolean } | null
|
||||
runtimeStatusByEnvironmentId: ReadonlyMap<
|
||||
string,
|
||||
{ status: { capabilities?: readonly string[] } | null | undefined }
|
||||
>
|
||||
}): TerminalParkRestorePolicy {
|
||||
return { sshParkingEnabled: state.settings?.terminalSshViewParking !== false }
|
||||
return {
|
||||
sshParkingEnabled: state.settings?.terminalSshViewParking !== false,
|
||||
pairedRuntimeParkingEnvironmentIds: selectPairedRuntimeParkingEnvironmentIds(
|
||||
state.runtimeStatusByEnvironmentId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,22 +118,7 @@ function startParkedTabWatchers(
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId]
|
||||
const disposeWatcher = startParkedTerminalByteWatcher({
|
||||
ptyId,
|
||||
tabId: tab.id,
|
||||
worktreeId,
|
||||
leafId: pane.leafId,
|
||||
paneId: pane.paneId,
|
||||
drivesTabTitle: pane.drivesTabTitle,
|
||||
// Why: seed the agent tracker with the last title so an agent working at park time still notifies on finish.
|
||||
...(initialTitle !== undefined ? { initialTitle } : {}),
|
||||
...(restoreTitleOnRegister ? { restoreTitleOnRegister: true } : {}),
|
||||
// Why: no pane transport while parked, so write straight to the PTY (same channel as background launches).
|
||||
sendInput: (data) => window.api.pty.write(ptyId, data)
|
||||
})
|
||||
// Why: a PTY exiting while parked has no pane for cleanup, so its watcher must not outlive it.
|
||||
const unsubscribeExit = subscribeToPtyExit(ptyId, (_code, { hadPrimary }) => {
|
||||
const handlePtyExit = (_code: number, { hadPrimary }: { hadPrimary: boolean }): void => {
|
||||
// Why: while parked this sidecar is the only exit observer, so teardown must run here or dead leaves resurrect on reveal.
|
||||
useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId)
|
||||
if (disposersByPtyId.size > 1) {
|
||||
@@ -207,7 +157,26 @@ function startParkedTabWatchers(
|
||||
// Why: cancellation keeps the buffered final frame/exit for the reveal-mounted pane.
|
||||
onCancel: () => {}
|
||||
})
|
||||
}
|
||||
const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId]
|
||||
const disposeWatcher = startParkedTerminalByteWatcher({
|
||||
ptyId,
|
||||
tabId: tab.id,
|
||||
worktreeId,
|
||||
leafId: pane.leafId,
|
||||
paneId: pane.paneId,
|
||||
drivesTabTitle: pane.drivesTabTitle,
|
||||
...(initialTitle !== undefined ? { initialTitle } : {}),
|
||||
...(restoreTitleOnRegister ? { restoreTitleOnRegister: true } : {}),
|
||||
sendInput: (data) => {
|
||||
sendRuntimePtyInput(useAppStore.getState().settings, ptyId, data)
|
||||
}
|
||||
})
|
||||
// Why: the paired host retires exited surfaces authoritatively; local/SSH
|
||||
// exits still arrive through the renderer's singleton main channel.
|
||||
const unsubscribeExit = isRemoteRuntimePtyId(ptyId)
|
||||
? () => {}
|
||||
: subscribeToPtyExit(ptyId, handlePtyExit)
|
||||
paneIdByPtyId.set(ptyId, pane.paneId)
|
||||
disposersByPtyId.set(ptyId, () => {
|
||||
unsubscribeExit()
|
||||
@@ -297,9 +266,26 @@ function disposeClosedParkedTabWatchers(
|
||||
for (const ptyId of entry.paneIdByPtyId.keys()) {
|
||||
discardPreHandlerPtyState(ptyId)
|
||||
}
|
||||
for (const paneId of entry.paneIdByPtyId.values()) {
|
||||
useAppStore.getState().clearRuntimePaneTitle(tabId, paneId)
|
||||
}
|
||||
disposeParkedTabWatchers(tabId)
|
||||
}
|
||||
|
||||
function watchablePtyIds(worktreeId: string, tab: ParkableTerminalTabModel): Set<string> {
|
||||
const state = useAppStore.getState()
|
||||
const restorePolicy = parkRestorePolicyFromState(state)
|
||||
return new Set(
|
||||
resolveParkedTerminalPaneCandidates(tab, state).flatMap((pane) =>
|
||||
pane.ptyId &&
|
||||
isTerminalLeafId(pane.leafId) &&
|
||||
isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy)
|
||||
? [pane.ptyId]
|
||||
: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles watchers for one worktree against its rendered parked set.
|
||||
* Run from an effect keyed on committed render state so disposal shares the
|
||||
@@ -336,7 +322,19 @@ export function syncParkedTerminalTabWatchers(args: {
|
||||
continue
|
||||
}
|
||||
const entry = parkedWatchersByTabId.get(tab.id)
|
||||
if (entry && entry.tabPtyId !== tab.ptyId) {
|
||||
const expectedPtyIds = watchablePtyIds(args.worktreeId, tab)
|
||||
const reconciliation = entry
|
||||
? reconcileParkedWatcherPtyIds({
|
||||
currentTabPtyId: tab.ptyId,
|
||||
entryTabPtyId: entry.tabPtyId,
|
||||
paneIdByPtyId: entry.paneIdByPtyId,
|
||||
expectedPtyIds
|
||||
})
|
||||
: null
|
||||
if (entry && reconciliation?.restart) {
|
||||
for (const paneId of reconciliation.retiredPaneIds) {
|
||||
useAppStore.getState().clearRuntimePaneTitle(tab.id, paneId)
|
||||
}
|
||||
disposeParkedTabWatchers(tab.id)
|
||||
}
|
||||
if (!parkedWatchersByTabId.has(tab.id)) {
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
captureParkedTerminalPaneCandidates,
|
||||
retireParkedTerminalTab
|
||||
} from './terminal-parked-watcher-registry'
|
||||
import {
|
||||
reconcileParkedWatcherPtyIds,
|
||||
resolveParkedTerminalPaneCandidates
|
||||
} from './terminal-parked-watcher-reconciliation'
|
||||
|
||||
const TAB_ID = 'tab-1'
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const FIRST_PTY_ID = 'remote:env-1@@terminal-1'
|
||||
const OLD_SECOND_PTY_ID = 'remote:env-1@@terminal-2'
|
||||
const NEW_SECOND_PTY_ID = 'remote:env-1@@terminal-3'
|
||||
|
||||
afterEach(() => {
|
||||
retireParkedTerminalTab(TAB_ID)
|
||||
})
|
||||
|
||||
describe('paired parked-watcher reconciliation', () => {
|
||||
it('prefers an authoritative inactive split-leaf remint over the unmount capture', () => {
|
||||
captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, [
|
||||
{ ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true },
|
||||
{
|
||||
ptyId: OLD_SECOND_PTY_ID,
|
||||
paneId: 2,
|
||||
leafId: SECOND_LEAF_ID,
|
||||
drivesTabTitle: false
|
||||
}
|
||||
])
|
||||
|
||||
const panes = resolveParkedTerminalPaneCandidates(
|
||||
{ id: TAB_ID, ptyId: FIRST_PTY_ID },
|
||||
{
|
||||
runtimePaneTitlesByTabId: {},
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB_ID]: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: FIRST_LEAF_ID },
|
||||
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
|
||||
},
|
||||
activeLeafId: FIRST_LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
[FIRST_LEAF_ID]: FIRST_PTY_ID,
|
||||
[SECOND_LEAF_ID]: NEW_SECOND_PTY_ID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(panes).toEqual([
|
||||
{ ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true },
|
||||
{
|
||||
ptyId: NEW_SECOND_PTY_ID,
|
||||
paneId: 2,
|
||||
leafId: SECOND_LEAF_ID,
|
||||
drivesTabTitle: false
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('restarts the watcher set and identifies the retired title slot', () => {
|
||||
expect(
|
||||
reconcileParkedWatcherPtyIds({
|
||||
currentTabPtyId: FIRST_PTY_ID,
|
||||
entryTabPtyId: FIRST_PTY_ID,
|
||||
paneIdByPtyId: new Map([
|
||||
[FIRST_PTY_ID, 1],
|
||||
[OLD_SECOND_PTY_ID, 2]
|
||||
]),
|
||||
expectedPtyIds: new Set([FIRST_PTY_ID, NEW_SECOND_PTY_ID])
|
||||
})
|
||||
).toEqual({ restart: true, retiredPaneIds: [2] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import type { useAppStore } from '@/store'
|
||||
import {
|
||||
collectLeafIdsInOrder,
|
||||
resolveRootlessTerminalLayoutLeafId
|
||||
} from './terminal-layout-leaf-ids'
|
||||
import {
|
||||
capturedPanesByTabId,
|
||||
type ParkedTerminalPaneCapture
|
||||
} from './terminal-parked-watcher-registry'
|
||||
|
||||
export type ParkableTerminalTabModel = Pick<TerminalTab, 'id' | 'ptyId'>
|
||||
|
||||
type ParkedPaneFallbackState = {
|
||||
terminalLayoutsByTabId: ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId']
|
||||
runtimePaneTitlesByTabId: ReturnType<typeof useAppStore.getState>['runtimePaneTitlesByTabId']
|
||||
}
|
||||
|
||||
export function fallbackParkedPaneCandidates(
|
||||
tab: ParkableTerminalTabModel,
|
||||
state: ParkedPaneFallbackState
|
||||
): ParkedTerminalPaneCapture[] {
|
||||
const layout = state.terminalLayoutsByTabId[tab.id]
|
||||
const rootLeafIds = collectLeafIdsInOrder(layout?.root)
|
||||
const rootlessLeafId = layout ? resolveRootlessTerminalLayoutLeafId(layout) : null
|
||||
const leafIds =
|
||||
rootLeafIds.length > 0 ? rootLeafIds : rootlessLeafId !== null ? [rootlessLeafId] : []
|
||||
if (leafIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
const ptyIdsByLeafId = layout?.ptyIdsByLeafId ?? {}
|
||||
const titleSlots = Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {})
|
||||
const reusableSlot =
|
||||
leafIds.length === 1 && titleSlots.length === 1 ? Number(titleSlots[0]) : null
|
||||
return leafIds.map((leafId, index) => ({
|
||||
ptyId: ptyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null),
|
||||
paneId: reusableSlot ?? -(index + 1),
|
||||
leafId,
|
||||
drivesTabTitle: layout?.activeLeafId ? leafId === layout.activeLeafId : index === 0
|
||||
}))
|
||||
}
|
||||
|
||||
export function resolveParkedTerminalPaneCandidates(
|
||||
tab: ParkableTerminalTabModel,
|
||||
state: ParkedPaneFallbackState
|
||||
): ParkedTerminalPaneCapture[] {
|
||||
const captured = capturedPanesByTabId.get(tab.id)
|
||||
const fallback = fallbackParkedPaneCandidates(tab, state)
|
||||
const capturedIsCurrent =
|
||||
captured !== undefined &&
|
||||
captured.panes.length > 0 &&
|
||||
(tab.ptyId === null || captured.panes.some((pane) => pane.ptyId === tab.ptyId)) &&
|
||||
(fallback.length === 0 ||
|
||||
(captured.panes.length === fallback.length &&
|
||||
fallback.every((pane) =>
|
||||
captured.panes.some(
|
||||
(candidate) => candidate.leafId === pane.leafId && candidate.ptyId === pane.ptyId
|
||||
)
|
||||
)))
|
||||
if (capturedIsCurrent) {
|
||||
return captured.panes
|
||||
}
|
||||
return fallback.map((pane) => {
|
||||
const prior = captured?.panes.find((candidate) => candidate.leafId === pane.leafId)
|
||||
return prior ? { ...pane, paneId: prior.paneId, drivesTabTitle: prior.drivesTabTitle } : pane
|
||||
})
|
||||
}
|
||||
|
||||
export function reconcileParkedWatcherPtyIds(args: {
|
||||
currentTabPtyId: string | null
|
||||
entryTabPtyId: string | null
|
||||
paneIdByPtyId: ReadonlyMap<string, number>
|
||||
expectedPtyIds: ReadonlySet<string>
|
||||
}): { restart: boolean; retiredPaneIds: number[] } {
|
||||
const samePtyIds =
|
||||
args.paneIdByPtyId.size === args.expectedPtyIds.size &&
|
||||
Array.from(args.paneIdByPtyId.keys()).every((ptyId) => args.expectedPtyIds.has(ptyId))
|
||||
return {
|
||||
restart: args.entryTabPtyId !== args.currentTabPtyId || !samePtyIds,
|
||||
retiredPaneIds: Array.from(args.paneIdByPtyId)
|
||||
.filter(([ptyId]) => !args.expectedPtyIds.has(ptyId))
|
||||
.map(([, paneId]) => paneId)
|
||||
}
|
||||
}
|
||||
@@ -547,8 +547,10 @@ describe('registerTerminalSideEffectFactConsumer', () => {
|
||||
})
|
||||
dispose()
|
||||
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(15_001)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
const { callbacks, events } = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
|
||||
|
||||
@@ -177,29 +177,46 @@ const HANDOFF_FACT_BUFFER_TTL_MS = 15_000
|
||||
const MAX_HANDOFF_FACT_BATCHES = 64
|
||||
const MAX_HANDOFF_FACT_PTYS = 32
|
||||
|
||||
type HandoffFactBuffer = { batches: TerminalSideEffectBatch[]; expiresAtMs: number }
|
||||
type HandoffFactBuffer = {
|
||||
batches: TerminalSideEffectBatch[]
|
||||
expiresAtMs: number
|
||||
expiryTimer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
const handoffFactBuffersByPtyId = new Map<string, HandoffFactBuffer>()
|
||||
|
||||
function deleteHandoffFactBuffer(ptyId: string): void {
|
||||
const buffer = handoffFactBuffersByPtyId.get(ptyId)
|
||||
if (buffer) {
|
||||
clearTimeout(buffer.expiryTimer)
|
||||
handoffFactBuffersByPtyId.delete(ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
function openHandoffFactBuffer(ptyId: string): void {
|
||||
const nowMs = Date.now()
|
||||
for (const [bufferedPtyId, buffer] of handoffFactBuffersByPtyId) {
|
||||
if (buffer.expiresAtMs <= nowMs) {
|
||||
handoffFactBuffersByPtyId.delete(bufferedPtyId)
|
||||
deleteHandoffFactBuffer(bufferedPtyId)
|
||||
}
|
||||
}
|
||||
if (
|
||||
!handoffFactBuffersByPtyId.has(ptyId) &&
|
||||
handoffFactBuffersByPtyId.size >= MAX_HANDOFF_FACT_PTYS
|
||||
) {
|
||||
deleteHandoffFactBuffer(ptyId)
|
||||
if (handoffFactBuffersByPtyId.size >= MAX_HANDOFF_FACT_PTYS) {
|
||||
const oldestPtyId = handoffFactBuffersByPtyId.keys().next().value
|
||||
if (typeof oldestPtyId === 'string') {
|
||||
handoffFactBuffersByPtyId.delete(oldestPtyId)
|
||||
deleteHandoffFactBuffer(oldestPtyId)
|
||||
}
|
||||
}
|
||||
handoffFactBuffersByPtyId.set(ptyId, {
|
||||
const buffer: HandoffFactBuffer = {
|
||||
batches: [],
|
||||
expiresAtMs: nowMs + HANDOFF_FACT_BUFFER_TTL_MS
|
||||
})
|
||||
expiresAtMs: nowMs + HANDOFF_FACT_BUFFER_TTL_MS,
|
||||
expiryTimer: setTimeout(() => {
|
||||
if (handoffFactBuffersByPtyId.get(ptyId) === buffer) {
|
||||
handoffFactBuffersByPtyId.delete(ptyId)
|
||||
}
|
||||
}, HANDOFF_FACT_BUFFER_TTL_MS)
|
||||
}
|
||||
buffer.expiryTimer.unref?.()
|
||||
handoffFactBuffersByPtyId.set(ptyId, buffer)
|
||||
}
|
||||
|
||||
function bufferHandoffFactBatch(batch: TerminalSideEffectBatch): void {
|
||||
@@ -208,7 +225,7 @@ function bufferHandoffFactBatch(batch: TerminalSideEffectBatch): void {
|
||||
return
|
||||
}
|
||||
if (buffer.expiresAtMs <= Date.now()) {
|
||||
handoffFactBuffersByPtyId.delete(batch.ptyId)
|
||||
deleteHandoffFactBuffer(batch.ptyId)
|
||||
return
|
||||
}
|
||||
// Why: replay batches are snapshots the next consumer requests for itself.
|
||||
@@ -226,7 +243,7 @@ function drainHandoffFactBuffer(ptyId: string, entry: ConsumerEntry): void {
|
||||
if (!buffer) {
|
||||
return
|
||||
}
|
||||
handoffFactBuffersByPtyId.delete(ptyId)
|
||||
deleteHandoffFactBuffer(ptyId)
|
||||
if (buffer.expiresAtMs <= Date.now()) {
|
||||
return
|
||||
}
|
||||
@@ -235,7 +252,7 @@ function drainHandoffFactBuffer(ptyId: string, entry: ConsumerEntry): void {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSideEffectBatch(batch: TerminalSideEffectBatch): void {
|
||||
export function dispatchTerminalSideEffectBatch(batch: TerminalSideEffectBatch): void {
|
||||
const entry = consumersByPtyId.get(batch.ptyId)
|
||||
if (!entry) {
|
||||
bufferHandoffFactBatch(batch)
|
||||
@@ -254,7 +271,7 @@ function ensureSideEffectChannelSubscription(): void {
|
||||
if (typeof onSideEffect !== 'function') {
|
||||
return
|
||||
}
|
||||
channelUnsubscribe = onSideEffect(handleSideEffectBatch)
|
||||
channelUnsubscribe = onSideEffect(dispatchTerminalSideEffectBatch)
|
||||
}
|
||||
|
||||
export type TerminalSideEffectFactConsumerOptions = {
|
||||
@@ -310,13 +327,15 @@ export function registerTerminalSideEffectFactConsumer(
|
||||
|
||||
/** Test seam: deliver a batch as if it arrived on the channel. */
|
||||
export function _dispatchTerminalSideEffectBatchForTest(batch: TerminalSideEffectBatch): void {
|
||||
handleSideEffectBatch(batch)
|
||||
dispatchTerminalSideEffectBatch(batch)
|
||||
}
|
||||
|
||||
/** Test seam: reset module state between tests. */
|
||||
export function _resetTerminalSideEffectFactConsumersForTest(): void {
|
||||
consumersByPtyId.clear()
|
||||
handoffFactBuffersByPtyId.clear()
|
||||
for (const ptyId of Array.from(handoffFactBuffersByPtyId.keys())) {
|
||||
deleteHandoffFactBuffer(ptyId)
|
||||
}
|
||||
channelUnsubscribe?.()
|
||||
channelUnsubscribe = null
|
||||
persistedAuthorityFlagCache = undefined
|
||||
|
||||
Vendored
+2
-11
@@ -4,6 +4,7 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { OnboardingFeatureSetupDeps } from '@/components/onboarding/onboarding-feature-setup'
|
||||
import type { languages } from 'monaco-editor'
|
||||
import type { MonacoE2EProbe } from './components/editor/monaco-e2e-probe'
|
||||
import type { TerminalWorktreeParkingDebugVerdict } from './components/terminal-pane/terminal-parking-e2e-overrides'
|
||||
|
||||
declare module 'monaco-editor/esm/vs/basic-languages/python/python.js' {
|
||||
export const conf: languages.LanguageConfiguration
|
||||
@@ -72,17 +73,7 @@ declare global {
|
||||
parkDelayMs: number
|
||||
parkedTabIds: () => string[]
|
||||
retentionLimit: number | null
|
||||
worktreeVerdicts: () => {
|
||||
worktreeId: string
|
||||
forceParked: boolean
|
||||
hasActivityTerminalPortal: boolean
|
||||
hasPendingSpawnWork: boolean
|
||||
hiddenSinceMs: number | null
|
||||
isVisible: boolean
|
||||
ordinaryParkingCovers: boolean
|
||||
parkCooldownUntilMs: number | null
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
}[]
|
||||
worktreeVerdicts: () => TerminalWorktreeParkingDebugVerdict[]
|
||||
}
|
||||
__monacoEditorE2E?: MonacoE2EProbe
|
||||
__e2ePtyAppliedSizeReadDelayMs?: number
|
||||
|
||||
@@ -88,6 +88,8 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
|
||||
import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close'
|
||||
import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue'
|
||||
import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events'
|
||||
import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
||||
import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/terminal-side-effect-facts-handler'
|
||||
import { subscribeToUnpairedDeviceAuthNotification } from './unpaired-device-auth-notification'
|
||||
import {
|
||||
applyRuntimeEnvironmentSshStateChanged,
|
||||
@@ -927,6 +929,13 @@ export function useIpcEvents(): void {
|
||||
applyHostWorktreeTerminalSleepState(environmentId, event)
|
||||
return
|
||||
}
|
||||
if (event.type === 'terminalSideEffects') {
|
||||
dispatchTerminalSideEffectBatch({
|
||||
...event.batch,
|
||||
ptyId: toRemoteRuntimePtyId(event.batch.ptyId, environmentId)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.type === 'reposChanged') {
|
||||
runtimeProjectRefreshScheduler.request(environmentId)
|
||||
return
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "El historial de sesiones de Agents no está disponible para este host de ejecución.",
|
||||
"runtimeEnvironmentManuallyDisconnected": "Runtime environment is manually disconnected."
|
||||
"runtimeEnvironmentManuallyDisconnected": "El entorno de ejecución se ha desconectado manualmente."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "この実行ホストでは Agent セッション履歴を使用できません。",
|
||||
"runtimeEnvironmentManuallyDisconnected": "Runtime environment is manually disconnected."
|
||||
"runtimeEnvironmentManuallyDisconnected": "ランタイム環境は手動で切断されています。"
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "이 실행 호스트에서는 Agent 세션 기록을 사용할 수 없습니다.",
|
||||
"runtimeEnvironmentManuallyDisconnected": "Runtime environment is manually disconnected."
|
||||
"runtimeEnvironmentManuallyDisconnected": "런타임 환경이 수동으로 연결 해제되었습니다."
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
},
|
||||
"webPreloadApi": {
|
||||
"aiVaultUnavailableForHost": "此执行主机不支持 Agent 会话历史记录。",
|
||||
"runtimeEnvironmentManuallyDisconnected": "Runtime environment is manually disconnected."
|
||||
"runtimeEnvironmentManuallyDisconnected": "运行时环境已手动断开连接。"
|
||||
},
|
||||
"web": {
|
||||
"preload": {
|
||||
|
||||
@@ -17,7 +17,8 @@ import { unwrapRuntimeRpcResult } from './runtime-rpc-client'
|
||||
import { getRuntimeEnvironmentRevision } from './runtime-environment-revision'
|
||||
import {
|
||||
TERMINAL_MULTIPLEX_ACK_BATCH_BYTES,
|
||||
TERMINAL_MULTIPLEX_ACK_FLUSH_MS
|
||||
TERMINAL_MULTIPLEX_ACK_FLUSH_MS,
|
||||
TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR
|
||||
} from '../../../shared/terminal-multiplex-flow-control'
|
||||
import {
|
||||
createRemoteTerminalStreamWatchdog,
|
||||
@@ -62,7 +63,7 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = {
|
||||
onDriverChanged?: (
|
||||
driver: { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string }
|
||||
) => void
|
||||
onTransportClose?: (event: { recoverable: boolean }) => void
|
||||
onTransportClose?: (event: { recoverable: boolean; retryWithBackoff?: boolean }) => void
|
||||
}
|
||||
|
||||
export type RemoteRuntimeMultiplexedTerminal = {
|
||||
@@ -106,6 +107,7 @@ type RemoteRuntimeMultiplexedTerminalState = {
|
||||
resyncPendingSend: boolean
|
||||
resyncTimer: ReturnType<typeof setTimeout> | null
|
||||
resyncAttempts: number
|
||||
capacityRejected: boolean
|
||||
watchdog: RemoteTerminalStreamWatchdog
|
||||
}
|
||||
|
||||
@@ -282,6 +284,7 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
resyncPendingSend: false,
|
||||
resyncTimer: null,
|
||||
resyncAttempts: 0,
|
||||
capacityRejected: false,
|
||||
watchdog: createRemoteTerminalStreamWatchdog((stall) => {
|
||||
if (!e2eDisableRemoteTerminalStallRecovery) {
|
||||
recordRendererCrashBreadcrumb('remote_terminal_stream_stall_recovery', {
|
||||
@@ -476,14 +479,25 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
clearResyncTimer(stream)
|
||||
rejectPendingSnapshotRequest(stream, 'Remote terminal stream ended.')
|
||||
this.streams.delete(event.streamId)
|
||||
stream.callbacks.onEnd?.()
|
||||
if (stream.capacityRejected) {
|
||||
if (stream.callbacks.onTransportClose) {
|
||||
stream.callbacks.onTransportClose({ recoverable: true, retryWithBackoff: true })
|
||||
} else {
|
||||
stream.callbacks.onError?.(TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR)
|
||||
}
|
||||
} else {
|
||||
stream.callbacks.onEnd?.()
|
||||
}
|
||||
this.closeIfIdle()
|
||||
} else if (event.type === 'error') {
|
||||
clearSnapshot(stream)
|
||||
rejectPendingSnapshotRequest(
|
||||
stream,
|
||||
const message =
|
||||
typeof event.message === 'string' ? event.message : 'Remote terminal stream failed.'
|
||||
)
|
||||
if (message === TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) {
|
||||
stream.capacityRejected = true
|
||||
return
|
||||
}
|
||||
clearSnapshot(stream)
|
||||
rejectPendingSnapshotRequest(stream, message)
|
||||
// Why: the paired binary Error frame can be dropped under backpressure;
|
||||
// this reliable event must also dispatch or release the resync gate, and
|
||||
// must never disarm the watchdog while leaving the gate shut.
|
||||
@@ -493,9 +507,7 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
clearResyncTimer(stream)
|
||||
stream.resyncInFlight = false
|
||||
}
|
||||
stream.callbacks.onError?.(
|
||||
typeof event.message === 'string' ? event.message : 'Remote terminal stream failed.'
|
||||
)
|
||||
stream.callbacks.onError?.(message)
|
||||
} else if (event.type === 'fit-override-changed') {
|
||||
if (
|
||||
(event.mode !== 'mobile-fit' &&
|
||||
@@ -729,11 +741,16 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Error) {
|
||||
const message = decodeTerminalStreamText(frame.payload)
|
||||
if (message === TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) {
|
||||
stream.capacityRejected = true
|
||||
return
|
||||
}
|
||||
clearSnapshot(stream)
|
||||
const pendingSnapshotRequest = stream.pendingSnapshotRequest
|
||||
if (pendingSnapshotRequest) {
|
||||
clearPendingSnapshotRequest(stream)
|
||||
pendingSnapshotRequest.reject(new Error(decodeTerminalStreamText(frame.payload)))
|
||||
pendingSnapshotRequest.reject(new Error(message))
|
||||
this.sendDeferredResyncSnapshot(stream)
|
||||
return
|
||||
}
|
||||
@@ -741,7 +758,7 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
clearResyncTimer(stream)
|
||||
stream.resyncInFlight = false
|
||||
stream.resyncPendingSend = false
|
||||
stream.callbacks.onError?.(decodeTerminalStreamText(frame.payload))
|
||||
stream.callbacks.onError?.(message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,47 @@ describe('remote terminal stalled stream recovery', () => {
|
||||
stream.close()
|
||||
})
|
||||
|
||||
it('classifies a capacity rejection followed by end as recoverable transport pressure', async () => {
|
||||
const { getRemoteRuntimeTerminalMultiplexer } =
|
||||
await import('./remote-runtime-terminal-multiplexer')
|
||||
const onEnd = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const onTransportClose = vi.fn()
|
||||
const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({
|
||||
terminal: 'term-over-capacity',
|
||||
client: { id: 'mac-viewer', type: 'desktop' },
|
||||
callbacks: {
|
||||
onData: vi.fn(),
|
||||
onSnapshot: vi.fn(),
|
||||
onEnd,
|
||||
onError,
|
||||
onTransportClose
|
||||
}
|
||||
})
|
||||
|
||||
callbacks?.onResponse({
|
||||
ok: true,
|
||||
result: {
|
||||
type: 'error',
|
||||
streamId: stream.streamId,
|
||||
message: 'terminal_stream_limit_exceeded'
|
||||
}
|
||||
})
|
||||
callbacks?.onResponse({
|
||||
ok: true,
|
||||
result: { type: 'end', streamId: stream.streamId }
|
||||
})
|
||||
|
||||
expect(onTransportClose).toHaveBeenCalledOnce()
|
||||
expect(onTransportClose).toHaveBeenCalledWith({
|
||||
recoverable: true,
|
||||
retryWithBackoff: true
|
||||
})
|
||||
expect(onEnd).not.toHaveBeenCalled()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
function emitOutput(streamId: number, text: string): void {
|
||||
callbacks?.onBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
|
||||
@@ -47,13 +47,24 @@ describe('subscribeRuntimeClientEvents', () => {
|
||||
ok: true,
|
||||
result: { type: 'worktreesChanged', repoId: 'repo-1' }
|
||||
})
|
||||
capturedOnResponse({
|
||||
ok: true,
|
||||
result: {
|
||||
type: 'terminalSideEffects',
|
||||
batch: { ptyId: 'pty-1', seq: 7, facts: [{ kind: 'bell' }] }
|
||||
}
|
||||
})
|
||||
capturedOnResponse({
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'missing' }
|
||||
})
|
||||
|
||||
expect(onEvent).toHaveBeenCalledTimes(1)
|
||||
expect(onEvent).toHaveBeenCalledTimes(2)
|
||||
expect(onEvent).toHaveBeenCalledWith({ type: 'worktreesChanged', repoId: 'repo-1' })
|
||||
expect(onEvent).toHaveBeenCalledWith({
|
||||
type: 'terminalSideEffects',
|
||||
batch: { ptyId: 'pty-1', seq: 7, facts: [{ kind: 'bell' }] }
|
||||
})
|
||||
expect(onError).toHaveBeenCalledWith({ code: 'method_not_found', message: 'missing' })
|
||||
|
||||
subscription.unsubscribe()
|
||||
|
||||
@@ -86,6 +86,7 @@ function isRuntimeClientEvent(
|
||||
return (
|
||||
message.type === 'reposChanged' ||
|
||||
message.type === 'worktreesChanged' ||
|
||||
message.type === 'terminalSideEffects' ||
|
||||
message.type === 'sshStateChanged' ||
|
||||
message.type === 'linearLinkedIssueUpdated' ||
|
||||
message.type === 'activateWorktree' ||
|
||||
|
||||
@@ -11,6 +11,14 @@ export type RemoteRuntimePtyIdParts = {
|
||||
handle: string
|
||||
}
|
||||
|
||||
export type RuntimeTerminalDataSubscriptionOptions = {
|
||||
startAtLiveTail?: boolean
|
||||
onSnapshot?: (data: string, meta?: { pendingEscapeTailAnsi?: string }) => void
|
||||
onEnd?: () => void
|
||||
onError?: (message: string) => void
|
||||
onTransportClose?: (event: { recoverable: boolean; retryWithBackoff?: boolean }) => void
|
||||
}
|
||||
|
||||
export function toRemoteRuntimePtyId(handle: string, environmentId?: string | null): string {
|
||||
const owner = environmentId?.trim()
|
||||
if (!owner) {
|
||||
@@ -58,7 +66,7 @@ export async function subscribeToRuntimeTerminalData(
|
||||
ptyId: string,
|
||||
clientId: string,
|
||||
watcher: (data: string) => void,
|
||||
options?: { startAtLiveTail?: boolean }
|
||||
options?: RuntimeTerminalDataSubscriptionOptions
|
||||
): Promise<() => void> {
|
||||
const terminal = getRemoteRuntimeTerminalHandle(ptyId)
|
||||
const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
|
||||
@@ -88,9 +96,12 @@ export async function subscribeToRuntimeTerminalData(
|
||||
client: { id: clientId, type: 'desktop' },
|
||||
callbacks: {
|
||||
onData: (data) => watcher(data),
|
||||
onSnapshot: (data) => {
|
||||
onSnapshot: (data, meta) => {
|
||||
options?.onSnapshot?.(data, meta)
|
||||
if (!options?.startAtLiveTail) {
|
||||
watcher(data)
|
||||
if (!options?.onSnapshot) {
|
||||
watcher(data)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSubscribed: () => {
|
||||
@@ -98,10 +109,18 @@ export async function subscribeToRuntimeTerminalData(
|
||||
resolveLiveTail = null
|
||||
rejectLiveTail = null
|
||||
},
|
||||
onEnd: () => rejectPendingLiveTail('Remote terminal ended before live output was ready.'),
|
||||
onError: (message) => rejectPendingLiveTail(message),
|
||||
onTransportClose: () =>
|
||||
onEnd: () => {
|
||||
rejectPendingLiveTail('Remote terminal ended before live output was ready.')
|
||||
options?.onEnd?.()
|
||||
},
|
||||
onError: (message) => {
|
||||
rejectPendingLiveTail(message)
|
||||
options?.onError?.(message)
|
||||
},
|
||||
onTransportClose: (event) => {
|
||||
rejectPendingLiveTail('Remote terminal closed before live output was ready.')
|
||||
options?.onTransportClose?.(event)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -465,6 +465,72 @@ describe('web runtime environment identity', () => {
|
||||
error: { code: 'runtime_manually_disconnected' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['active runtime', 'selected environment'] as const)(
|
||||
'returns a disconnect envelope when a queued %s call disconnects',
|
||||
async (route) => {
|
||||
const pending: ((response: RuntimeRpcResponse<unknown>) => void)[] = []
|
||||
const call = vi.fn(
|
||||
(method: string) =>
|
||||
new Promise<RuntimeRpcResponse<unknown>>((resolve) => {
|
||||
pending.push((response) => resolve({ ...response, id: method }))
|
||||
})
|
||||
)
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call = call
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
const invoke = (): Promise<RuntimeRpcResponse<unknown>> =>
|
||||
route === 'active runtime'
|
||||
? globals.window.api.runtime.call({ method: 'repos.list' })
|
||||
: globals.window.api.runtimeEnvironments.call({
|
||||
selector: 'web-server-a',
|
||||
method: 'repos.list'
|
||||
})
|
||||
|
||||
const activeCalls = Array.from({ length: 8 }, invoke)
|
||||
await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(8))
|
||||
const queuedCall = invoke()
|
||||
expect(call).toHaveBeenCalledTimes(8)
|
||||
|
||||
await globals.window.api.runtimeEnvironments.disconnect({ selector: 'web-server-a' })
|
||||
pending[0]?.({
|
||||
id: 'repos.list',
|
||||
ok: true,
|
||||
result: {},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
await expect(queuedCall).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'runtime_manually_disconnected' }
|
||||
})
|
||||
expect(call).toHaveBeenCalledTimes(8)
|
||||
|
||||
for (const resolve of pending.slice(1)) {
|
||||
resolve({
|
||||
id: 'repos.list',
|
||||
ok: true,
|
||||
result: {},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
await expect(Promise.all(activeCalls)).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
ok: false,
|
||||
error: expect.objectContaining({ code: 'runtime_manually_disconnected' })
|
||||
})
|
||||
])
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('web browser-local port capability', () => {
|
||||
|
||||
@@ -3169,9 +3169,12 @@ async function callRuntimeEnvelope<TResult = unknown>(
|
||||
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
|
||||
return manuallyDisconnectedResponse(environment)
|
||||
}
|
||||
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () =>
|
||||
getClientForEnvironment(environment).call(method, params, { timeoutMs })
|
||||
)
|
||||
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => {
|
||||
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
|
||||
return Promise.resolve(manuallyDisconnectedResponse(environment))
|
||||
}
|
||||
return getClientForEnvironment(environment).call(method, params, { timeoutMs })
|
||||
})
|
||||
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
|
||||
return manuallyDisconnectedResponse(environment)
|
||||
}
|
||||
@@ -3189,9 +3192,12 @@ async function callEnvironmentEnvelope<TResult = unknown>(
|
||||
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
|
||||
return manuallyDisconnectedResponse(environment)
|
||||
}
|
||||
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () =>
|
||||
getClientForEnvironment(environment).call(method, params, { timeoutMs })
|
||||
)
|
||||
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => {
|
||||
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
|
||||
return Promise.resolve(manuallyDisconnectedResponse(environment))
|
||||
}
|
||||
return getClientForEnvironment(environment).call(method, params, { timeoutMs })
|
||||
})
|
||||
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
|
||||
return manuallyDisconnectedResponse(environment)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate
|
||||
// floor-taking input. Mobile must not forward replies unless advertised.
|
||||
export const TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY =
|
||||
'terminal.query-reply-input.v1' as const
|
||||
// Why: paired clients may unmount xterm only when the host can return a
|
||||
// bounded, sequenced scrollback snapshot for lossless reveal.
|
||||
export const TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY = 'terminal.paired-parking.v1' as const
|
||||
// Why: older hosts lack the targeted settings RPCs and strip agentPrompt from
|
||||
// terminal creation, so mobile must hide Quick Commands unless both are present.
|
||||
export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-commands.v1' as const
|
||||
@@ -93,6 +96,7 @@ export const RUNTIME_CAPABILITIES = [
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY,
|
||||
AI_VAULT_RUNTIME_CAPABILITY,
|
||||
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY,
|
||||
TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY,
|
||||
TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
|
||||
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -5,10 +5,12 @@ import type {
|
||||
WorktreeStartupLaunch
|
||||
} from './types'
|
||||
import type { SshConnectionState } from './ssh-types'
|
||||
import type { TerminalSideEffectBatch } from './terminal-side-effect-facts'
|
||||
|
||||
export type RuntimeClientEvent =
|
||||
| { type: 'reposChanged' }
|
||||
| { type: 'worktreesChanged'; repoId: string }
|
||||
| { type: 'terminalSideEffects'; batch: TerminalSideEffectBatch }
|
||||
// Why: SSH connections live on the runtime host; paired clients have no IPC
|
||||
// channel for ssh:state-changed, so without this event their reconnect
|
||||
// overlays never learn the host connected (STA-1468).
|
||||
|
||||
@@ -7,4 +7,7 @@ export const TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES = 8 * 1024 * 1024
|
||||
export const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024
|
||||
export const TERMINAL_MULTIPLEX_ACK_BATCH_BYTES = 192 * 1024
|
||||
export const TERMINAL_MULTIPLEX_ACK_FLUSH_MS = 4
|
||||
export const TERMINAL_MULTIPLEX_MAX_STREAMS_PER_CONNECTION = 32
|
||||
// 64 covers observed 43-manager sessions with headroom while bounding per-stream replay buffers.
|
||||
export const TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION = 64
|
||||
export const TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION = 32
|
||||
export const TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR = 'terminal_stream_limit_exceeded'
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
|
||||
import { launchPairedWebClient, type PairedWebClient } from './helpers/paired-electron-client'
|
||||
import { runPairedTerminalParkingOracle } from './helpers/paired-terminal-parking-oracle'
|
||||
|
||||
test('ordinary-parks paired terminals against an isolated headless Orca host @headful', async ({
|
||||
testRepoPath
|
||||
}) => {
|
||||
test.setTimeout(240_000)
|
||||
const host = await launchHeadlessPairedRuntimeHost()
|
||||
let client: PairedWebClient | null = null
|
||||
try {
|
||||
await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
|
||||
client = await launchPairedWebClient(host.app, host.offer, {
|
||||
terminalParkingDelayMs: 100
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
client?.page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const worktree = state?.allWorktrees()[0]
|
||||
return worktree ? { id: worktree.id, repoId: worktree.repoId } : null
|
||||
}) ?? null,
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.not.toBeNull()
|
||||
const seed = await client.page.evaluate(() => {
|
||||
const worktree = window.__store?.getState().allWorktrees()[0]
|
||||
if (!worktree) {
|
||||
throw new Error('Headless paired client did not receive the host worktree')
|
||||
}
|
||||
return { fallbackWorktreeId: worktree.id, repoId: worktree.repoId }
|
||||
})
|
||||
await runPairedTerminalParkingOracle(client.page, seed)
|
||||
} finally {
|
||||
await client?.dispose()
|
||||
await host.dispose()
|
||||
}
|
||||
})
|
||||
@@ -69,7 +69,12 @@ test('recovers an ACK-starved stream from an isolated headless Orca host @headfu
|
||||
}) => {
|
||||
test.setTimeout(180_000)
|
||||
const host = await launchHeadlessPairedRuntimeHost()
|
||||
const client = await launchPairedWebClient(host.app, host.offer, { waitForWorkspace: false })
|
||||
const client = await launchPairedWebClient(host.app, host.offer, {
|
||||
waitForWorkspace: false
|
||||
}).catch(async (error) => {
|
||||
await host.dispose()
|
||||
throw error
|
||||
})
|
||||
let terminal: string | null = null
|
||||
try {
|
||||
await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
|
||||
|
||||
@@ -29,7 +29,8 @@ export type HeadlessPairedRuntimeHost = {
|
||||
}
|
||||
|
||||
async function readPairingOffer(app: ElectronApplication): Promise<RuntimeDesktopPairingOffer> {
|
||||
const stdout = app.process().stdout
|
||||
const child = app.process()
|
||||
const stdout = child.stdout
|
||||
if (!stdout) {
|
||||
throw new Error('Headless runtime stdout is unavailable')
|
||||
}
|
||||
@@ -42,6 +43,15 @@ async function readPairingOffer(app: ElectronApplication): Promise<RuntimeDeskto
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timeout)
|
||||
stdout.off('data', onData)
|
||||
child.off('close', onClose)
|
||||
}
|
||||
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
|
||||
cleanup()
|
||||
reject(
|
||||
new Error(
|
||||
`Headless runtime exited before pairing readiness (code=${code ?? 'none'}, signal=${signal ?? 'none'})`
|
||||
)
|
||||
)
|
||||
}
|
||||
const onData = (chunk: Buffer): void => {
|
||||
buffered += chunk.toString()
|
||||
@@ -69,42 +79,47 @@ async function readPairingOffer(app: ElectronApplication): Promise<RuntimeDeskto
|
||||
}
|
||||
}
|
||||
stdout.on('data', onData)
|
||||
child.on('close', onClose)
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
onClose(child.exitCode, child.signalCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function launchHeadlessPairedRuntimeHost(): Promise<HeadlessPairedRuntimeHost> {
|
||||
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-headless-paired-'))
|
||||
writeFileSync(
|
||||
path.join(userDataDir, 'orca-data.json'),
|
||||
`${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n`
|
||||
)
|
||||
const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env
|
||||
void _unused
|
||||
const isolation = createElectronHomeIsolation({
|
||||
inheritedEnv: cleanEnv,
|
||||
launchEnv: {
|
||||
NODE_ENV: 'development',
|
||||
ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1',
|
||||
ORCA_E2E_HEADLESS: '1'
|
||||
},
|
||||
extraEnv: {},
|
||||
userDataDir,
|
||||
codexRealHomeEnabled: false
|
||||
})
|
||||
const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js')
|
||||
const app = await electron.launch({
|
||||
args: [
|
||||
...getOrcaElectronLaunchArgs(mainPath, false),
|
||||
'--serve',
|
||||
'--serve-json',
|
||||
'--serve-port',
|
||||
'0',
|
||||
'--serve-pairing-address',
|
||||
'127.0.0.1'
|
||||
],
|
||||
env: isolation.env
|
||||
})
|
||||
let app: ElectronApplication | undefined
|
||||
try {
|
||||
writeFileSync(
|
||||
path.join(userDataDir, 'orca-data.json'),
|
||||
`${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n`
|
||||
)
|
||||
const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env
|
||||
void _unused
|
||||
const isolation = createElectronHomeIsolation({
|
||||
inheritedEnv: cleanEnv,
|
||||
launchEnv: {
|
||||
NODE_ENV: 'development',
|
||||
ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1',
|
||||
ORCA_E2E_HEADLESS: '1'
|
||||
},
|
||||
extraEnv: {},
|
||||
userDataDir,
|
||||
codexRealHomeEnabled: false
|
||||
})
|
||||
const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js')
|
||||
app = await electron.launch({
|
||||
args: [
|
||||
...getOrcaElectronLaunchArgs(mainPath, false),
|
||||
'--serve',
|
||||
'--serve-json',
|
||||
'--serve-port',
|
||||
'0',
|
||||
'--serve-pairing-address',
|
||||
'127.0.0.1'
|
||||
],
|
||||
env: isolation.env
|
||||
})
|
||||
assertElectronResolvedIsolatedHome(
|
||||
await app.evaluate(({ app: electronApp }) => electronApp.getPath('home')),
|
||||
isolation
|
||||
@@ -121,9 +136,14 @@ export async function launchHeadlessPairedRuntimeHost(): Promise<HeadlessPairedR
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await closeElectronAppForE2E(app)
|
||||
await cleanupE2EDaemons(userDataDir)
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
try {
|
||||
if (app) {
|
||||
await closeElectronAppForE2E(app)
|
||||
}
|
||||
await cleanupE2EDaemons(userDataDir)
|
||||
} finally {
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,32 +100,41 @@ export async function launchPairedWebClient(
|
||||
throw new Error('HUB runtime did not provide a paired web client URL')
|
||||
}
|
||||
const clientUrl = createPairedWebClientUrl(offer.webClientUrl, options)
|
||||
const pagePromise = hubApp.waitForEvent('window')
|
||||
await hubApp.evaluate(
|
||||
async ({ BrowserWindow }, { partition, url }) => {
|
||||
const clientWindow = new BrowserWindow({
|
||||
height: 1200,
|
||||
show: false,
|
||||
width: 1440,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
partition,
|
||||
sandbox: true
|
||||
}
|
||||
})
|
||||
await clientWindow.loadURL(url)
|
||||
},
|
||||
{
|
||||
partition: `e2e-nested-runtime-web-${randomUUID()}`,
|
||||
url: clientUrl
|
||||
let page: Page | undefined
|
||||
const pagePromise = hubApp.waitForEvent('window').then((candidate) => (page = candidate))
|
||||
try {
|
||||
await hubApp.evaluate(
|
||||
async ({ BrowserWindow }, { partition, url }) => {
|
||||
const clientWindow = new BrowserWindow({
|
||||
height: 1200,
|
||||
show: false,
|
||||
width: 1440,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
partition,
|
||||
sandbox: true
|
||||
}
|
||||
})
|
||||
await clientWindow.loadURL(url).catch((error) => {
|
||||
clientWindow.destroy()
|
||||
throw error
|
||||
})
|
||||
},
|
||||
{
|
||||
partition: `e2e-nested-runtime-web-${randomUUID()}`,
|
||||
url: clientUrl
|
||||
}
|
||||
)
|
||||
page = await pagePromise
|
||||
if (options.waitForWorkspace !== false) {
|
||||
await page.locator('[data-worktree-sidebar]').waitFor({ state: 'visible', timeout: 30_000 })
|
||||
}
|
||||
)
|
||||
const page = await pagePromise
|
||||
if (options.waitForWorkspace !== false) {
|
||||
await page.locator('[data-worktree-sidebar]').waitFor({ state: 'visible', timeout: 30_000 })
|
||||
return { page, dispose: () => page?.close() ?? Promise.resolve() }
|
||||
} catch (error) {
|
||||
await page?.close().catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
return { page, dispose: () => page.close() }
|
||||
}
|
||||
|
||||
export async function launchPairedElectronClient(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const FILL_ROWS = 6_000
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`
|
||||
}
|
||||
|
||||
function fixtureCommand(fixturePath: string, marker: string): string {
|
||||
const command = [process.execPath, fixturePath, marker]
|
||||
return process.platform === 'win32'
|
||||
? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')
|
||||
: command.map(shellQuote).join(' ')
|
||||
}
|
||||
|
||||
export function createPairedTerminalParkingFixture(): {
|
||||
command: (marker: string) => string
|
||||
dispose: () => void
|
||||
} {
|
||||
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-retention-memory-'))
|
||||
const fixturePath = path.join(scratch, 'paired-retention-memory.mjs')
|
||||
writeFileSync(
|
||||
fixturePath,
|
||||
[
|
||||
'const marker = process.argv[2]',
|
||||
'process.stdout.write(`READY:${marker}\\r\\n`)',
|
||||
'process.stdin.setRawMode?.(true)',
|
||||
"process.stdin.setEncoding('utf8')",
|
||||
"process.stdin.on('data', (data) => {",
|
||||
' for (const command of data.split(/\\r\\n|\\r|\\n/).filter(Boolean)) {',
|
||||
" if (command === 'FILL') {",
|
||||
` for (let row = 0; row < ${FILL_ROWS}; row += 1) process.stdout.write(\`fill-${'${marker}'}-${'${row}'}-${'x'.repeat(80)}\\r\\n\`)`,
|
||||
' process.stdout.write(`FILLED:${marker}\\r\\n`)',
|
||||
' continue',
|
||||
' }',
|
||||
' process.stdout.write(`LIVE:${command}\\r\\n`)',
|
||||
' }',
|
||||
'})',
|
||||
'process.stdin.resume()'
|
||||
].join('\n')
|
||||
)
|
||||
return {
|
||||
command: (marker) => fixtureCommand(fixturePath, marker),
|
||||
dispose: () => rmSync(scratch, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import type { RuntimeTerminalRead } from '../../../src/shared/runtime-types'
|
||||
import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
|
||||
import { toWebTerminalSurfaceTabId } from '../../../src/shared/terminal-surface-id'
|
||||
import {
|
||||
readPairedRetentionSample,
|
||||
startRendererLagProbe
|
||||
} from '../paired-runtime-retention-metrics'
|
||||
import { expect } from './orca-app'
|
||||
import { createPairedTerminalParkingFixture } from './paired-terminal-parking-fixture'
|
||||
import { getTerminalContent, waitForActivePanePtyId } from './terminal'
|
||||
|
||||
const TARGET_WORKTREE_COUNT = 6
|
||||
const MIN_STAGED_BUFFER_CELLS = 1_000_000
|
||||
const MAX_RETAINED_CELL_FRACTION = 0.45
|
||||
const MAX_EVICTION_LAG_MS = 500
|
||||
const MAX_HEAP_GROWTH_BYTES = 16 * 1024 * 1024
|
||||
|
||||
type PairedTerminalParkingSeed = {
|
||||
fallbackWorktreeId: string
|
||||
repoId: string
|
||||
}
|
||||
|
||||
type RemoteTab = {
|
||||
marker: string
|
||||
originalPtyId: string
|
||||
tabId: string
|
||||
terminal: string
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
async function callRuntime<TResult>(page: Page, method: string, params: unknown): Promise<TResult> {
|
||||
return page.evaluate(
|
||||
async ({ method, params }) => {
|
||||
const response = await window.api.runtime.call({ method, params })
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.error.code}: ${response.error.message}`)
|
||||
}
|
||||
return response.result
|
||||
},
|
||||
{ method, params }
|
||||
) as Promise<TResult>
|
||||
}
|
||||
|
||||
export async function runPairedTerminalParkingOracle(
|
||||
page: Page,
|
||||
seed: PairedTerminalParkingSeed
|
||||
): Promise<void> {
|
||||
const fixture = createPairedTerminalParkingFixture()
|
||||
const createdWorktreeIds: string[] = []
|
||||
const remoteTabs: RemoteTab[] = []
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((capability) => {
|
||||
const statuses = window.__store?.getState().runtimeStatusByEnvironmentId.values() ?? []
|
||||
return Array.from(statuses).some((entry) =>
|
||||
entry.status?.capabilities?.includes(capability)
|
||||
)
|
||||
}, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
await page.evaluate(async () => {
|
||||
await window.__store?.getState().updateSettings({
|
||||
terminalHiddenViewParking: false,
|
||||
terminalHiddenWorktreeRetentionBudget: false
|
||||
})
|
||||
})
|
||||
const createdTerminals: Omit<RemoteTab, 'originalPtyId'>[] = []
|
||||
while (createdTerminals.length < TARGET_WORKTREE_COUNT) {
|
||||
const index = createdTerminals.length
|
||||
const marker = `PAIR_RETENTION_${index}`
|
||||
const suffix = `${Date.now()}-${index}`
|
||||
const created = await callRuntime<{
|
||||
startupTerminal?: { handle?: string; tabId?: string }
|
||||
worktree: { id: string }
|
||||
}>(page, 'worktree.create', {
|
||||
repo: seed.repoId,
|
||||
name: `paired-retention-${suffix}`,
|
||||
setupDecision: 'skip',
|
||||
activate: false,
|
||||
noParent: true,
|
||||
startupCommand: fixture.command(marker)
|
||||
})
|
||||
if (!created.startupTerminal?.handle || !created.startupTerminal.tabId) {
|
||||
throw new Error(`Paired retention startup terminal ${index} was not created`)
|
||||
}
|
||||
createdWorktreeIds.push(created.worktree.id)
|
||||
createdTerminals.push({
|
||||
marker,
|
||||
tabId: toWebTerminalSurfaceTabId(created.startupTerminal.tabId),
|
||||
terminal: created.startupTerminal.handle,
|
||||
worktreeId: created.worktree.id
|
||||
})
|
||||
}
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
(ids) =>
|
||||
ids.every((id) =>
|
||||
window.__store
|
||||
?.getState()
|
||||
.allWorktrees()
|
||||
.some((worktree) => worktree.id === id)
|
||||
),
|
||||
createdWorktreeIds
|
||||
),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
for (const created of createdTerminals) {
|
||||
await page.evaluate(
|
||||
(id) => window.__store?.getState().setActiveWorktree(id),
|
||||
created.worktreeId
|
||||
)
|
||||
const tab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${created.tabId}"]`)
|
||||
await expect(tab).toBeVisible({ timeout: 30_000 })
|
||||
await tab.click()
|
||||
const originalPtyId = await waitForActivePanePtyId(page, 30_000)
|
||||
await callRuntime(page, 'terminal.send', {
|
||||
terminal: created.terminal,
|
||||
text: 'FILL',
|
||||
enter: true,
|
||||
client: { id: 'paired-retention-memory-e2e', type: 'desktop' }
|
||||
})
|
||||
await expect
|
||||
.poll(() => getTerminalContent(page), { timeout: 30_000 })
|
||||
.toContain(`FILLED:${created.marker}`)
|
||||
remoteTabs.push({ ...created, originalPtyId })
|
||||
}
|
||||
|
||||
await page.evaluate(() => window.__store?.getState().setActiveView('tasks'))
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
(ids) => ids.filter((id) => window.__paneManagers?.has(id)).length,
|
||||
remoteTabs.map((tab) => tab.tabId)
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(TARGET_WORKTREE_COUNT)
|
||||
const baseline = await readPairedRetentionSample(
|
||||
page,
|
||||
remoteTabs.map((tab) => tab.tabId)
|
||||
)
|
||||
expect(baseline.bufferCells).toBeGreaterThan(MIN_STAGED_BUFFER_CELLS)
|
||||
|
||||
const lagProbe = await startRendererLagProbe(page)
|
||||
await page.evaluate(async () => {
|
||||
await window.__store?.getState().updateSettings({ terminalHiddenViewParking: true })
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
({ tabIds, worktreeIds }) => {
|
||||
const verdicts = window.__terminalParkingDebug?.worktreeVerdicts() ?? []
|
||||
return {
|
||||
forceParked: worktreeIds.map(
|
||||
(id) => verdicts.find((verdict) => verdict.worktreeId === id)?.forceParked
|
||||
),
|
||||
mounted: tabIds.filter((id) => window.__paneManagers?.has(id)).length,
|
||||
ordinaryParkingCovers: worktreeIds.map(
|
||||
(id) =>
|
||||
verdicts.find((verdict) => verdict.worktreeId === id)?.ordinaryParkingCovers
|
||||
),
|
||||
parked: window.__terminalParkingDebug?.parkedTabIds().length,
|
||||
retentionBudgetEnabled:
|
||||
window.__store?.getState().settings?.terminalHiddenWorktreeRetentionBudget
|
||||
}
|
||||
},
|
||||
{
|
||||
tabIds: remoteTabs.map((tab) => tab.tabId),
|
||||
worktreeIds: remoteTabs.map((tab) => tab.worktreeId)
|
||||
}
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual({
|
||||
forceParked: Array(TARGET_WORKTREE_COUNT).fill(false),
|
||||
mounted: 1,
|
||||
ordinaryParkingCovers: Array(TARGET_WORKTREE_COUNT).fill(true),
|
||||
parked: TARGET_WORKTREE_COUNT - 1,
|
||||
retentionBudgetEnabled: false
|
||||
})
|
||||
const maxLagMs = await lagProbe.evaluate((probe) => probe.stop())
|
||||
await lagProbe.dispose()
|
||||
const after = await readPairedRetentionSample(
|
||||
page,
|
||||
remoteTabs.map((tab) => tab.tabId)
|
||||
)
|
||||
expect(after.bufferCells).toBeLessThanOrEqual(baseline.bufferCells * MAX_RETAINED_CELL_FRACTION)
|
||||
expect(after.mountedTargetManagers).toBe(1)
|
||||
expect(maxLagMs).toBeLessThan(MAX_EVICTION_LAG_MS)
|
||||
if (baseline.heapBytes !== null && after.heapBytes !== null) {
|
||||
expect(after.heapBytes).toBeLessThanOrEqual(baseline.heapBytes + MAX_HEAP_GROWTH_BYTES)
|
||||
}
|
||||
|
||||
const evicted = await page.evaluate(
|
||||
(tabs) => tabs.find((tab) => !window.__paneManagers?.has(tab.tabId)) ?? null,
|
||||
remoteTabs
|
||||
)
|
||||
if (!evicted) {
|
||||
throw new Error('Ordinary parking did not unmount a paired terminal')
|
||||
}
|
||||
const parkedMarker = `WHILE_PARKED_${Date.now()}`
|
||||
await callRuntime(page, 'terminal.send', {
|
||||
terminal: evicted.terminal,
|
||||
text: parkedMarker,
|
||||
enter: true,
|
||||
client: { id: 'paired-retention-memory-e2e', type: 'desktop' }
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const result = await callRuntime<{ terminal: RuntimeTerminalRead }>(
|
||||
page,
|
||||
'terminal.read',
|
||||
{ terminal: evicted.terminal, limit: 1_000 }
|
||||
)
|
||||
return result.terminal.tail.join('\n')
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toContain(`LIVE:${parkedMarker}`)
|
||||
|
||||
await page.evaluate((worktreeId) => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setActiveView('terminal')
|
||||
state?.setActiveWorktree(worktreeId)
|
||||
}, evicted.worktreeId)
|
||||
const restored = page.locator(`[data-testid="sortable-tab"][data-tab-id="${evicted.tabId}"]`)
|
||||
await expect(restored).toBeVisible({ timeout: 30_000 })
|
||||
await restored.click()
|
||||
expect(await waitForActivePanePtyId(page, 30_000)).toBe(evicted.originalPtyId)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(page, 1_000_000), { timeout: 30_000 })
|
||||
.toContain(`fill-${evicted.marker}-4000-`)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(page, 1_000_000), { timeout: 30_000 })
|
||||
.toContain(`LIVE:${parkedMarker}`)
|
||||
const liveMarker = `AFTER_RETENTION_${Date.now()}`
|
||||
await callRuntime(page, 'terminal.send', {
|
||||
terminal: evicted.terminal,
|
||||
text: liveMarker,
|
||||
enter: true,
|
||||
client: { id: 'paired-retention-memory-e2e', type: 'desktop' }
|
||||
})
|
||||
await expect
|
||||
.poll(() => getTerminalContent(page), { timeout: 30_000 })
|
||||
.toContain(`LIVE:${liveMarker}`)
|
||||
} finally {
|
||||
for (const tab of remoteTabs) {
|
||||
await callRuntime(page, 'terminal.closeTab', { terminal: tab.terminal }).catch(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
await page
|
||||
.evaluate((id) => window.__store?.getState().setActiveWorktree(id), seed.fallbackWorktreeId)
|
||||
.catch(() => undefined)
|
||||
for (const worktreeId of createdWorktreeIds.toReversed()) {
|
||||
await callRuntime(page, 'worktree.rm', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
force: true,
|
||||
runHooks: false
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
fixture.dispose()
|
||||
}
|
||||
}
|
||||
@@ -1,87 +1,11 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { test } from './helpers/orca-app'
|
||||
import {
|
||||
createRuntimeDesktopPairingOffer,
|
||||
launchPairedWebClient
|
||||
} from './helpers/paired-electron-client'
|
||||
import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal'
|
||||
import {
|
||||
readPairedRetentionSample,
|
||||
startRendererLagProbe
|
||||
} from './paired-runtime-retention-metrics'
|
||||
import { runPairedTerminalParkingOracle } from './helpers/paired-terminal-parking-oracle'
|
||||
|
||||
const RETENTION_LIMIT = 2
|
||||
const TARGET_WORKTREE_COUNT = 6
|
||||
const PARK_DELAY_MS = 100
|
||||
const FILL_ROWS = 6_000
|
||||
const MIN_STAGED_BUFFER_CELLS = 1_000_000
|
||||
const MAX_RETAINED_CELL_FRACTION = 0.45
|
||||
const MAX_EVICTION_LAG_MS = 500
|
||||
const MAX_HEAP_GROWTH_BYTES = 16 * 1024 * 1024
|
||||
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-retention-memory-'))
|
||||
const fixturePath = path.join(scratch, 'paired-retention-memory.mjs')
|
||||
|
||||
writeFileSync(
|
||||
fixturePath,
|
||||
[
|
||||
'const marker = process.argv[2]',
|
||||
'process.stdout.write(`READY:${marker}\\r\\n`)',
|
||||
"process.stdin.setEncoding('utf8')",
|
||||
"process.stdin.on('data', (data) => {",
|
||||
' for (const command of data.split(/\\r\\n|\\r|\\n/).filter(Boolean)) {',
|
||||
" if (command === 'FILL') {",
|
||||
` for (let row = 0; row < ${FILL_ROWS}; row += 1) process.stdout.write(\`fill-${'${marker}'}-${'${row}'}-${'x'.repeat(80)}\\r\\n\`)`,
|
||||
' process.stdout.write(`FILLED:${marker}\\r\\n`)',
|
||||
' continue',
|
||||
' }',
|
||||
' process.stdout.write(`LIVE:${command}\\r\\n`)',
|
||||
' }',
|
||||
'})',
|
||||
'process.stdin.resume()'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
test.afterAll(() => {
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`
|
||||
}
|
||||
|
||||
function fixtureCommand(marker: string): string {
|
||||
const command = [process.execPath, fixturePath, marker]
|
||||
return process.platform === 'win32'
|
||||
? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')
|
||||
: command.map(shellQuote).join(' ')
|
||||
}
|
||||
|
||||
async function callRuntime<TResult>(page: Page, method: string, params: unknown): Promise<TResult> {
|
||||
return page.evaluate(
|
||||
async ({ method, params }) => {
|
||||
const response = await window.api.runtime.call({ method, params })
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.error.code}: ${response.error.message}`)
|
||||
}
|
||||
return response.result
|
||||
},
|
||||
{ method, params }
|
||||
) as Promise<TResult>
|
||||
}
|
||||
|
||||
type RemoteTab = {
|
||||
marker: string
|
||||
originalPtyId: string
|
||||
tabId: string
|
||||
terminal: string
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
test('bounds retained buffers across real paired worktrees and preserves PTYs @headful', async ({
|
||||
test('ordinary-parks paired terminals and restores authoritative host scrollback @headful', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}) => {
|
||||
@@ -97,177 +21,14 @@ test('bounds retained buffers across real paired worktrees and preserves PTYs @h
|
||||
})
|
||||
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
|
||||
const client = await launchPairedWebClient(electronApp, offer, {
|
||||
terminalParkingDelayMs: PARK_DELAY_MS,
|
||||
terminalRetentionLimit: RETENTION_LIMIT
|
||||
terminalParkingDelayMs: 100
|
||||
})
|
||||
const createdWorktreeIds: string[] = []
|
||||
const remoteTabs: RemoteTab[] = []
|
||||
try {
|
||||
await client.page.evaluate(async () => {
|
||||
await window.__store
|
||||
?.getState()
|
||||
.updateSettings({ terminalHiddenWorktreeRetentionBudget: false })
|
||||
await runPairedTerminalParkingOracle(client.page, {
|
||||
fallbackWorktreeId: seed.worktreeIds[0]!,
|
||||
repoId: seed.repoId
|
||||
})
|
||||
const worktreeIds = seed.worktreeIds.slice(0, TARGET_WORKTREE_COUNT)
|
||||
while (worktreeIds.length < TARGET_WORKTREE_COUNT) {
|
||||
const suffix = `${Date.now()}-${worktreeIds.length}`
|
||||
const created = await callRuntime<{ worktree: { id: string } }>(
|
||||
client.page,
|
||||
'worktree.create',
|
||||
{
|
||||
repo: seed.repoId,
|
||||
name: `paired-retention-${suffix}`,
|
||||
setupDecision: 'skip',
|
||||
activate: false,
|
||||
noParent: true
|
||||
}
|
||||
)
|
||||
worktreeIds.push(created.worktree.id)
|
||||
createdWorktreeIds.push(created.worktree.id)
|
||||
}
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
client.page.evaluate(
|
||||
(ids) =>
|
||||
ids.every((id) =>
|
||||
window.__store
|
||||
?.getState()
|
||||
.allWorktrees()
|
||||
.some((w) => w.id === id)
|
||||
),
|
||||
worktreeIds
|
||||
),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
for (const [index, worktreeId] of worktreeIds.entries()) {
|
||||
const marker = `PAIR_RETENTION_${index}`
|
||||
const created = await callRuntime<{
|
||||
tab: { parentTabId: string; terminal: string | null }
|
||||
}>(client.page, 'session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
command: fixtureCommand(marker),
|
||||
activate: false,
|
||||
select: false,
|
||||
navigation: 'caller'
|
||||
})
|
||||
if (!created.tab.terminal) {
|
||||
throw new Error(`Paired retention terminal ${index} was not published`)
|
||||
}
|
||||
const terminal = created.tab.terminal
|
||||
const tabId = toWebTerminalSurfaceTabId(created.tab.parentTabId)
|
||||
await client.page.evaluate(
|
||||
(id) => window.__store?.getState().setActiveWorktree(id),
|
||||
worktreeId
|
||||
)
|
||||
const tab = client.page.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`)
|
||||
await expect(tab).toBeVisible({ timeout: 30_000 })
|
||||
await tab.click()
|
||||
const originalPtyId = await waitForActivePanePtyId(client.page, 30_000)
|
||||
await callRuntime(client.page, 'terminal.send', {
|
||||
terminal,
|
||||
text: 'FILL',
|
||||
enter: true,
|
||||
client: { id: 'paired-retention-memory-e2e', type: 'desktop' }
|
||||
})
|
||||
await expect
|
||||
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
|
||||
.toContain(`FILLED:${marker}`)
|
||||
remoteTabs.push({ marker, originalPtyId, tabId, terminal, worktreeId })
|
||||
}
|
||||
|
||||
await client.page.evaluate(() => window.__store?.getState().setActiveView('tasks'))
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
client.page.evaluate(
|
||||
(ids) => ids.filter((id) => window.__paneManagers?.has(id)).length,
|
||||
remoteTabs.map((tab) => tab.tabId)
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(TARGET_WORKTREE_COUNT)
|
||||
const baseline = await readPairedRetentionSample(
|
||||
client.page,
|
||||
remoteTabs.map((tab) => tab.tabId)
|
||||
)
|
||||
expect(baseline.bufferCells).toBeGreaterThan(MIN_STAGED_BUFFER_CELLS)
|
||||
|
||||
const lagProbe = await startRendererLagProbe(client.page)
|
||||
await client.page.evaluate(async () => {
|
||||
await window.__store
|
||||
?.getState()
|
||||
.updateSettings({ terminalHiddenWorktreeRetentionBudget: true })
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
client.page.evaluate(
|
||||
(ids) => ids.filter((id) => window.__paneManagers?.has(id)).length,
|
||||
remoteTabs.map((tab) => tab.tabId)
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(RETENTION_LIMIT)
|
||||
const maxLagMs = await lagProbe.evaluate((probe) => probe.stop())
|
||||
await lagProbe.dispose()
|
||||
const after = await readPairedRetentionSample(
|
||||
client.page,
|
||||
remoteTabs.map((tab) => tab.tabId)
|
||||
)
|
||||
expect(after.bufferCells).toBeLessThanOrEqual(baseline.bufferCells * MAX_RETAINED_CELL_FRACTION)
|
||||
expect(after.mountedTargetManagers).toBe(RETENTION_LIMIT)
|
||||
expect(maxLagMs).toBeLessThan(MAX_EVICTION_LAG_MS)
|
||||
if (baseline.heapBytes !== null && after.heapBytes !== null) {
|
||||
expect(after.heapBytes).toBeLessThanOrEqual(baseline.heapBytes + MAX_HEAP_GROWTH_BYTES)
|
||||
}
|
||||
|
||||
const evicted = await client.page.evaluate(
|
||||
(tabs) => tabs.find((tab) => !window.__paneManagers?.has(tab.tabId)) ?? null,
|
||||
remoteTabs
|
||||
)
|
||||
if (!evicted) {
|
||||
throw new Error('Retention budget did not evict a paired terminal')
|
||||
}
|
||||
await client.page.evaluate((worktreeId) => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setActiveView('terminal')
|
||||
state?.setActiveWorktree(worktreeId)
|
||||
}, evicted.worktreeId)
|
||||
const restored = client.page.locator(
|
||||
`[data-testid="sortable-tab"][data-tab-id="${evicted.tabId}"]`
|
||||
)
|
||||
await expect(restored).toBeVisible({ timeout: 30_000 })
|
||||
await restored.click()
|
||||
expect(await waitForActivePanePtyId(client.page, 30_000)).toBe(evicted.originalPtyId)
|
||||
const liveMarker = `AFTER_RETENTION_${Date.now()}`
|
||||
await callRuntime(client.page, 'terminal.send', {
|
||||
terminal: evicted.terminal,
|
||||
text: liveMarker,
|
||||
enter: true,
|
||||
client: { id: 'paired-retention-memory-e2e', type: 'desktop' }
|
||||
})
|
||||
await expect
|
||||
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
|
||||
.toContain(`LIVE:${liveMarker}`)
|
||||
} finally {
|
||||
for (const tab of remoteTabs) {
|
||||
await callRuntime(client.page, 'terminal.closeTab', { terminal: tab.terminal }).catch(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
await client.page
|
||||
.evaluate((id) => window.__store?.getState().setActiveWorktree(id), seed.worktreeIds[0])
|
||||
.catch(() => undefined)
|
||||
for (const worktreeId of createdWorktreeIds.toReversed()) {
|
||||
await callRuntime(client.page, 'worktree.rm', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
force: true,
|
||||
runHooks: false
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
await client.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import type { RuntimeTerminalRead } from '../../src/shared/runtime-types'
|
||||
import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../src/shared/protocol-version'
|
||||
import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import {
|
||||
@@ -220,10 +221,14 @@ test('paints a paired remote terminal when only its retained text tail overflowe
|
||||
}
|
||||
})
|
||||
|
||||
test('bounds hidden paired-runtime pane managers and restores PTY output @headful', async ({
|
||||
test('legacy paired hosts retain the lossy hidden-manager budget fallback @headful', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}) => {
|
||||
test.skip(
|
||||
process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1',
|
||||
'The legacy fallback requires a host without terminal.paired-parking.v1.'
|
||||
)
|
||||
test.setTimeout(120_000)
|
||||
const worktreeIds = await orcaPage.evaluate(() =>
|
||||
window.__store
|
||||
@@ -247,6 +252,15 @@ test('bounds hidden paired-runtime pane managers and restores PTY output @headfu
|
||||
terminalParkingDelayMs: RETENTION_PARK_DELAY_MS,
|
||||
terminalRetentionLimit: 1
|
||||
})
|
||||
expect(
|
||||
await client.page.evaluate(
|
||||
(capability) =>
|
||||
Array.from(window.__store?.getState().runtimeStatusByEnvironmentId.values() ?? []).some(
|
||||
(entry) => entry.status?.capabilities?.includes(capability)
|
||||
),
|
||||
TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY
|
||||
)
|
||||
).toBe(false)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user