fix(remote): recover and safely park paired terminals (#11416)

This commit is contained in:
OrcaWin 2026-07-29 20:04:55 -07:00 committed by GitHub
parent 8ad9448905
commit bf894ef150
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 4916 additions and 541 deletions

View File

@ -3067,6 +3067,277 @@
],
"demotionRule": "Keep experimental or demote to protection none if output differs across replay slices, header-only restores consume a replay slot, retained payload bytes exceed the cap, or the focused gate flakes."
},
{
"id": "terminal-performance.remote-hidden-retention-budget",
"title": "Paired terminals park client renderers while host PTYs preserve bounded history",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-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",
"manual server disconnect"
],
"platforms": ["macos", "linux", "windows"],
"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. 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, 128 active streams plus retry after a 129th-stream capacity rejection, 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": "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 stop progressing must replace only that stream; command silence must first probe authoritative state and replace only if that probe times out. 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. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. 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 recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
"commands": [
"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-parked-watcher-partial-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 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-parked-watcher-partial-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": [
{
"file": "tests/e2e/paired-remote-terminal-stall-recovery.spec.ts",
"assertions": [
"restarts one ACK-starved paired terminal stream without replacing its PTY"
]
},
{
"file": "tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts",
"assertions": ["recovers an ACK-starved stream from an isolated headless Orca host"]
},
{
"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",
"legacy paired hosts retain the lossy hidden-manager budget fallback"
]
},
{
"file": "tests/e2e/paired-remote-terminal-retention-memory.spec.ts",
"assertions": [
"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"]
},
{
"file": "tests/e2e/terminal-parked-memory.spec.ts",
"assertions": [
"releases un-parkable hidden worktree buffers only once the retention budget engages"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts",
"assertions": [
"treats a host-backed paired PTY as settled despite activation residue",
"preserves real startup work and non-paired activation guards",
"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",
"bounds a hung authoritative provider acquisition and reuses its fallback"
]
},
{
"file": "src/main/runtime/rpc/terminal-multiplex.test.ts",
"assertions": [
"binary first paint remains valid when only retained history was truncated",
"admits 128 active streams, rejects the 129th, and reuses released capacity",
"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/remote-runtime-pty-transport.test.ts",
"assertions": [
"keeps a mounted HUB mirror alive when the old stream ends before the replacement snapshot"
]
},
{
"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/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts",
"assertions": [
"retains a continuing watcher and title while reconciling a reminted split leaf"
]
},
{
"file": "src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts",
"assertions": [
"restarts only the stream whose renderer delivery credit never settles",
"probes then restarts a stream when an entered command receives no frames",
"keeps a silent responsive stream after its authoritative snapshot probe",
"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",
"returns a disconnect envelope when a queued active runtime call disconnects",
"returns a disconnect envelope when a queued selected environment call disconnects"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-29",
"runner": "local",
"platform": "macos",
"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-parked-watcher-partial-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": 19.54,
"summary": "Seventeen focused files passed 1,938 tests with one existing skip, including 128 active streams, retryable rejection at 129, released-slot snapshot recovery, bounded liveness probes, and balanced subscriber cleanup."
},
{
"date": "2026-07-29",
"runner": "local",
"platform": "macos",
"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": 11.4,
"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",
"runner": "local",
"platform": "macos",
"command": "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",
"result": "passed",
"durationSeconds": 18,
"summary": "The byte-identical headed oracle exhausted one paired stream, proved host/client divergence, then repainted the marker while preserving the original PTY and tab."
},
{
"date": "2026-07-29",
"runner": "local",
"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.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",
"runner": "local",
"platform": "macos",
"command": "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",
"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.6,
"summary": "A host without terminal.paired-parking.v1 preserved the existing lossy limit-one fallback, restored retained output, and continued PTY input/output."
}
],
"runtimeBudget": {
"p95Seconds": 360,
"scope": "focused units plus isolated headed paired-runtime and renderer-memory scenarios"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The paired headed oracle is deterministic locally but has no CI or soak history yet."
},
"redGreenEvidence": {
"status": "complete",
"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,9635,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. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases."
},
"performanceBudget": {
"required": true,
"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 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, row-4,000 reveal marker, parked-time output, and post-reveal PTY input in both topology runs."
],
"knownGaps": [
"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, 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",
"title": "Hot terminal interactions do not call global PTY session listing",
@ -3181,46 +3452,67 @@
"id": "terminal-observability.lifecycle-breadcrumbs",
"title": "Terminal lifecycle anomalies enter crash diagnostics as compact breadcrumbs",
"maturity": "experimental",
"protection": "none",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "renderer-observability",
"surfaces": [
"terminal lifecycle",
"reattach",
"restore",
"replay-wedge identity",
"provider ownership",
"diagnostics bundle"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": [],
"coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Registered gap on main. The crash-breadcrumb recording and its test exist only on the pending reliability stack. It registers here with its owning split PR.",
"coverageNotes": "Renderer unit coverage proves replay-guard lost-completion and certified-wedge events carry correlatable tab, worktree, durable leaf, pane, and redacted PTY identity without exposing path-bearing values. Full provider lifecycle attribution and diagnostics-bundle artifact proof remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6800",
"https://github.com/stablyai/orca/issues/6773"
],
"invariant": "Terminal lifecycle anomalies around reattach, restore, provider ownership, stale liveness, and fallback routing must leave compact, deduped, privacy-safe breadcrumbs in crash diagnostics so future reports can be attributed from evidence.",
"oracle": "The current executable slice calls warnTerminalLifecycleAnomaly with terminal identity, provider, PTY id, binding epoch, and reason, then asserts the existing console warning is preserved and a compact terminal_lifecycle_anomaly crash breadcrumb is recorded once per lifecycle identity. Full pane transition traces and diagnostics-bundle artifact proof remain follow-ups.",
"commands": [],
"testFiles": [],
"assertionRefs": [],
"evidenceRuns": [],
"invariant": "Terminal lifecycle anomalies around reattach, restore, replay wedges, provider ownership, stale liveness, and fallback routing must leave compact, deduped, privacy-safe breadcrumbs in crash diagnostics so future reports can be attributed from evidence. Replay anomalies must distinguish pane managers and PTYs without recording path-bearing worktree or session identities.",
"oracle": "Drop a replay write completion while allowing its FIFO probe to parse, then require the lost-completion breadcrumb to include pane ID, stable hashes for tab/worktree/leaf identity, and a path-redacted PTY ID. Existing wedge tests require both lost-completion and certified-dead paths to record their distinct event names. Full pane transition traces and diagnostics-bundle artifact proof remain follow-ups.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/replay-guard.test.ts"
],
"testFiles": ["src/renderer/src/components/terminal-pane/replay-guard.test.ts"],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/replay-guard.test.ts",
"assertions": [
"records correlatable replay identity without exposing worktree or PTY paths",
"releases after the probe itself never parses (wedged pipeline) and reports it"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-29",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/replay-guard.test.ts",
"result": "passed",
"durationSeconds": 0.201,
"summary": "The focused file passed 29 tests; replay anomaly breadcrumbs preserved event classification while adding hashed tab/worktree/leaf correlation and a path-redacted PTY identity."
}
],
"runtimeBudget": {
"p95Seconds": 10,
"scope": "renderer observability unit test"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Focused unit slice passed locally once; no CI soak history yet."
"evidence": "The replay-guard unit slice passed locally once; no CI soak history yet."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Tests would fail if lifecycle anomalies stopped recording crash breadcrumbs or stopped deduping repeated identities. Needs diagnostics-bundle artifact proof and full transition-trace evidence before promotion."
"evidence": "Tests fail if replay anomalies stop recording their event name, omit pane/terminal correlation, or expose the fixture's path-bearing worktree and PTY prefixes. Needs diagnostics-bundle artifact proof and full transition-trace evidence before promotion."
},
"performanceBudget": {
"required": true,
"evidence": "Breadcrumb recording is deduped and capped by the existing lifecycle anomaly guard; full trace buffers must include size and event-count caps before promotion."
"evidence": "Replay identity hashing is synchronous and only runs when replay writes are queued; it adds no polling or provider calls. Breadcrumb storage remains bounded by the existing crash reporter. Full trace buffers must include size and event-count caps before promotion."
},
"promotionCriteria": [
"Add full compact pane lifecycle trace buffer with event-count caps.",
@ -3228,10 +3520,10 @@
"Add forbidden-transition tests for stale close, unknown owner fallback, and stuck zero-size panes."
],
"knownGaps": [
"No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.",
"Current command records anomaly breadcrumbs only, not a full pane lifecycle state-machine trace.",
"Current command does not prove crash/diagnostics bundle export includes the breadcrumb.",
"Current command does not assert forbidden transitions across live Electron/provider flows."
"The replay identity schema has not yet been exercised in a live Electron/provider failure.",
"Current coverage records anomaly breadcrumbs only, not a full pane lifecycle state-machine trace.",
"Current coverage does not prove crash/diagnostics bundle export includes the breadcrumb.",
"Current coverage does not assert forbidden transitions across live Electron/provider flows."
],
"demotionRule": "Cannot promote if diagnostics are console-only, unbounded, or missing from support artifacts."
},
@ -4091,6 +4383,7 @@
{
"file": "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts",
"assertions": [
"startup waits for hydration before mounting terminal panes while degraded mode remains interactive",
"only an explicit local execution host can defer cold activation",
"SSH, remote-runtime, and unresolved owners remain eager"
]

View File

@ -0,0 +1,173 @@
import { ipcMain } from 'electron'
import {
addEnvironmentFromPairingCode,
listEnvironments,
removeEnvironment,
resolveEnvironment
} from '../../shared/runtime-environment-store'
import {
redactRuntimeEnvironment,
type PublicKnownRuntimeEnvironment
} from '../../shared/runtime-environments'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type { Store } from '../persistence'
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
import {
callRuntimeEnvironment,
clearSharedControlSupport,
getRuntimeEnvironmentStatus
} from './runtime-environment-transport-routing'
const manuallyDisconnectedEnvironmentIds = new Set<string>()
function manuallyDisconnectedResponse(
environment: ReturnType<typeof resolveEnvironment>
): RuntimeRpcResponse<never> {
return {
id: 'runtime.manualDisconnect',
ok: false,
error: {
code: 'runtime_manually_disconnected',
message: 'Runtime environment is manually disconnected.'
},
_meta: { runtimeId: environment.runtimeId }
}
}
export function isRuntimeEnvironmentManuallyDisconnected(environmentId: string): boolean {
return manuallyDisconnectedEnvironmentIds.has(environmentId)
}
type ConnectivityHandlerOptions = {
store: Store
getUserDataPath: () => string
invalidateTransport: (environmentId: string) => void
}
export function registerRuntimeEnvironmentConnectivityHandlers({
store,
getUserDataPath,
invalidateTransport
}: ConnectivityHandlerOptions): void {
ipcMain.handle('runtimeEnvironments:list', () =>
listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment)
)
ipcMain.handle(
'runtimeEnvironments:addFromPairingCode',
(
_event,
args: { name: string; pairingCode: string }
): { environment: PublicKnownRuntimeEnvironment } => {
const environment = addEnvironmentFromPairingCode(getUserDataPath(), args)
manuallyDisconnectedEnvironmentIds.delete(environment.id)
return { environment: redactRuntimeEnvironment(environment) }
}
)
ipcMain.handle('runtimeEnvironments:resolve', (_event, args: { selector: string }) =>
redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector))
)
ipcMain.handle(
'runtimeEnvironments:remove',
(_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
if (store.getSettings().activeRuntimeEnvironmentId === environment.id) {
throw new Error('Choose another Active Server in Advanced before removing this server.')
}
const removed = removeEnvironment(getUserDataPath(), args.selector)
manuallyDisconnectedEnvironmentIds.delete(removed.id)
invalidateTransport(removed.id)
closeLegacySelectorTransport(args.selector, removed.id)
return { removed: redactRuntimeEnvironment(removed) }
}
)
ipcMain.handle(
'runtimeEnvironments:disconnect',
(_event, args: { selector: string }): { disconnected: PublicKnownRuntimeEnvironment } => {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
manuallyDisconnectedEnvironmentIds.add(environment.id)
invalidateTransport(environment.id)
closeLegacySelectorTransport(args.selector, environment.id)
return { disconnected: redactRuntimeEnvironment(environment) }
}
)
ipcMain.handle(
'runtimeEnvironments:connect',
async (
_event,
args: { selector: string; timeoutMs?: number }
): Promise<RuntimeRpcResponse<RuntimeStatus>> => {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
manuallyDisconnectedEnvironmentIds.delete(environment.id)
return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs)
}
)
}
export function registerRuntimeEnvironmentPassiveHandlers(getUserDataPath: () => string): void {
registerPassiveStatusHandler(getUserDataPath)
registerPassiveCallHandler(getUserDataPath)
}
function closeLegacySelectorTransport(selector: string, environmentId: string): void {
if (selector === environmentId) {
return
}
closeRemoteRuntimeRequestConnection(selector)
clearSharedControlSupport(selector)
}
function registerPassiveStatusHandler(getUserDataPath: () => string): void {
ipcMain.handle(
'runtimeEnvironments:getStatus',
async (
_event,
args: { selector: string; timeoutMs?: number }
): Promise<RuntimeRpcResponse<RuntimeStatus>> => {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
return manuallyDisconnectedResponse(environment)
}
const response = await getRuntimeEnvironmentStatus(
getUserDataPath(),
environment.id,
args.timeoutMs
)
return isRuntimeEnvironmentManuallyDisconnected(environment.id)
? manuallyDisconnectedResponse(environment)
: response
}
)
}
function registerPassiveCallHandler(getUserDataPath: () => string): void {
ipcMain.handle(
'runtimeEnvironments:call',
async (
_event,
args: {
selector: string
method: string
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
}
): Promise<RuntimeRpcResponse<unknown>> => {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
return manuallyDisconnectedResponse(environment)
}
const response = await callRuntimeEnvironment(
getUserDataPath(),
environment.id,
args.method,
args.params,
args.timeoutMs,
args.expectedEnvironmentPairingRevision
)
return isRuntimeEnvironmentManuallyDisconnected(environment.id)
? manuallyDisconnectedResponse(environment)
: response
}
)
}

View File

@ -135,6 +135,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
'runtimeEnvironments:resolve',
'runtimeEnvironments:remove',
'runtimeEnvironments:disconnect',
'runtimeEnvironments:connect',
'runtimeEnvironments:retryConnectionsNow',
'runtimeEnvironments:getStatus',
'runtimeEnvironments:call',
@ -155,6 +156,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
'runtimeEnvironments:resolve',
'runtimeEnvironments:remove',
'runtimeEnvironments:disconnect',
'runtimeEnvironments:connect',
'runtimeEnvironments:getStatus',
'runtimeEnvironments:call',
'runtimeEnvironments:subscribe',
@ -230,6 +232,12 @@ describe('registerRuntimeEnvironmentHandlers', () => {
it('disconnects a saved runtime without removing it', async () => {
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
result: { runtimeId: 'runtime-remote' },
_meta: { runtimeId: 'runtime-remote' }
})
const add = handler<
{ name: string; pairingCode: string },
@ -250,6 +258,39 @@ describe('registerRuntimeEnvironmentHandlers', () => {
const list = handler<undefined, { id: string; name: string }[]>('runtimeEnvironments:list')
expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }])
const getStatus = handler<{ selector: string }, { ok: boolean; error?: { code: string } }>(
'runtimeEnvironments:getStatus'
)
await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({
ok: false,
error: { code: 'runtime_manually_disconnected' }
})
const call = handler<
{ selector: string; method: string },
{ ok: boolean; error?: { code: string } }
>('runtimeEnvironments:call')
await expect(call(null, { selector: 'desk', method: 'repo.list' })).resolves.toMatchObject({
ok: false,
error: { code: 'runtime_manually_disconnected' }
})
const subscribe = handler<{ selector: string; method: string }, { subscriptionId: string }>(
'runtimeEnvironments:subscribe'
)
await expect(
subscribe(null, { selector: 'desk', method: 'terminal.multiplex' })
).rejects.toThrow('runtime_manually_disconnected')
expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled()
expect(subscribeRemoteRuntimeRequestMock).not.toHaveBeenCalled()
const connect = handler<{ selector: string }, { ok: boolean; result?: { runtimeId: string } }>(
'runtimeEnvironments:connect'
)
await expect(connect(null, { selector: 'desk' })).resolves.toMatchObject({
ok: true,
result: { runtimeId: 'runtime-remote' }
})
expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledOnce()
})
it('marks environments owned by ephemeral VM runtimes in the public list', async () => {
@ -1155,9 +1196,12 @@ describe('registerRuntimeEnvironmentHandlers', () => {
{ disconnected: { id: string; name: string } }
>('runtimeEnvironments:disconnect')
await disconnect(null, { selector: 'desk' })
const connect = handler<{ selector: string }, { ok: boolean }>('runtimeEnvironments:connect')
await connect(null, { selector: 'desk' })
await call(null, { selector: 'desk', method: 'repo.list' })
expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([
'status.get',
'status.get',
'status.get'
])

View File

@ -1,19 +1,13 @@
import { app, ipcMain } from 'electron'
import { randomUUID } from 'node:crypto'
import {
addEnvironmentFromPairingCode,
listEnvironments,
removeEnvironment,
resolveEnvironment
} from '../../shared/runtime-environment-store'
import {
redactRuntimeEnvironment,
type PublicKnownRuntimeEnvironment
} from '../../shared/runtime-environments'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import { resolveEnvironment } from '../../shared/runtime-environment-store'
import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client'
import type { Store } from '../persistence'
import {
isRuntimeEnvironmentManuallyDisconnected,
registerRuntimeEnvironmentConnectivityHandlers,
registerRuntimeEnvironmentPassiveHandlers
} from './runtime-environment-connectivity-handlers'
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
import { registerRuntimeEnvironmentRecoveryHandler } from './runtime-environment-recovery-handler'
import {
@ -21,9 +15,7 @@ import {
getRuntimeEnvironmentTransportGeneration
} from './runtime-environment-transport-generation'
import {
callRuntimeEnvironment,
clearSharedControlSupport,
getRuntimeEnvironmentStatus,
resetSharedControlSupport,
subscribeRuntimeEnvironment
} from './runtime-environment-transport-routing'
@ -34,6 +26,7 @@ const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
'runtimeEnvironments:resolve',
'runtimeEnvironments:remove',
'runtimeEnvironments:disconnect',
'runtimeEnvironments:connect',
'runtimeEnvironments:getStatus',
'runtimeEnvironments:call',
'runtimeEnvironments:subscribe',
@ -66,11 +59,6 @@ export function invalidateRuntimeEnvironmentTransport(environmentId: string): vo
closeSubscriptionsForEnvironment(environmentId)
}
function listPublicRuntimeEnvironments(): PublicKnownRuntimeEnvironment[] {
// Why: a corrupt VM store must not break persisted environment listing.
return listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment)
}
export function registerRuntimeEnvironmentHandlers(store: Store): void {
// Why: keep direct re-registration safe even though register-core-handlers
// normally guards this path; otherwise the binary send listener can stack.
@ -80,81 +68,13 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
}
ipcMain.removeAllListeners('runtimeEnvironments:subscriptionBinary')
ipcMain.handle('runtimeEnvironments:list', listPublicRuntimeEnvironments)
ipcMain.handle(
'runtimeEnvironments:addFromPairingCode',
(
_event,
args: { name: string; pairingCode: string }
): { environment: PublicKnownRuntimeEnvironment } => ({
environment: redactRuntimeEnvironment(addEnvironmentFromPairingCode(getUserDataPath(), args))
})
)
ipcMain.handle('runtimeEnvironments:resolve', (_event, args: { selector: string }) =>
redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector))
)
ipcMain.handle(
'runtimeEnvironments:remove',
(_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
if (store.getSettings().activeRuntimeEnvironmentId === environment.id) {
throw new Error('Choose another Active Server in Advanced before removing this server.')
}
const removed = removeEnvironment(getUserDataPath(), args.selector)
invalidateRuntimeEnvironmentTransport(removed.id)
if (args.selector !== removed.id) {
closeRemoteRuntimeRequestConnection(args.selector)
clearSharedControlSupport(args.selector)
}
return { removed: redactRuntimeEnvironment(removed) }
}
)
ipcMain.handle(
'runtimeEnvironments:disconnect',
(_event, args: { selector: string }): { disconnected: PublicKnownRuntimeEnvironment } => {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
// Why: disconnect is intentionally non-destructive; it drops live
// transport state while keeping the paired server available for later.
invalidateRuntimeEnvironmentTransport(environment.id)
if (args.selector !== environment.id) {
closeRemoteRuntimeRequestConnection(args.selector)
clearSharedControlSupport(args.selector)
}
return { disconnected: redactRuntimeEnvironment(environment) }
}
)
registerRuntimeEnvironmentConnectivityHandlers({
store,
getUserDataPath,
invalidateTransport: invalidateRuntimeEnvironmentTransport
})
registerRuntimeEnvironmentRecoveryHandler()
ipcMain.handle(
'runtimeEnvironments:getStatus',
async (
_event,
args: { selector: string; timeoutMs?: number }
): Promise<RuntimeRpcResponse<RuntimeStatus>> => {
return getRuntimeEnvironmentStatus(getUserDataPath(), args.selector, args.timeoutMs)
}
)
ipcMain.handle(
'runtimeEnvironments:call',
async (
_event,
args: {
selector: string
method: string
params?: unknown
timeoutMs?: number
expectedEnvironmentPairingRevision?: number
}
): Promise<RuntimeRpcResponse<unknown>> => {
return callRuntimeEnvironment(
getUserDataPath(),
args.selector,
args.method,
args.params,
args.timeoutMs,
args.expectedEnvironmentPairingRevision
)
}
)
registerRuntimeEnvironmentPassiveHandlers(getUserDataPath)
ipcMain.handle(
'runtimeEnvironments:subscribe',
async (
@ -176,6 +96,9 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
throw new Error('Runtime environment subscription id already exists')
}
const environment = resolveEnvironment(getUserDataPath(), args.selector)
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
throw new Error('runtime_manually_disconnected')
}
const pairingRevision = environment.pairingRevision ?? environment.createdAt
if (
args.expectedEnvironmentPairingRevision !== undefined &&

View File

@ -58,6 +58,7 @@ import { getBaseRefDefault, getBranchConflictKind } from '../git/repo'
import { OrchestrationDb } from './orchestration/db'
import type { MessagePriority, MessageRow, MessageType } from './orchestration/types'
import {
AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS,
appendNormalizedToTailBuffer,
appendRecentPtyPathCandidates,
buildPreview,
@ -8231,6 +8232,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)
@ -8619,6 +8652,129 @@ 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('bounds a hung authoritative provider acquisition and reuses its fallback', async () => {
vi.useFakeTimers()
try {
let releaseProvider: (value: null) => void = () => {}
const hungProvider = new Promise<null>((resolve) => {
releaseProvider = resolve
})
const serializeProviderBuffer = vi
.fn()
.mockReturnValueOnce(hungProvider)
.mockResolvedValueOnce({
data: 'provider recovered\r\n',
cols: 100,
rows: 30,
seq: 200,
source: 'headless'
})
const { runtime } = createSideEffectRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'available mirror\r\n', 100)
const firstSnapshot = runtime.serializeAuthoritativeTerminalBuffer('pty-1', {
scrollbackRows: 5000
})
const concurrentSnapshot = runtime.serializeAuthoritativeTerminalBuffer('pty-1', {
scrollbackRows: 5000
})
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS)
await expect(firstSnapshot).resolves.toMatchObject({
data: expect.stringContaining('available mirror'),
source: 'headless'
})
await expect(concurrentSnapshot).resolves.toMatchObject({
data: expect.stringContaining('available mirror'),
source: 'headless'
})
await expect(
runtime.serializeAuthoritativeTerminalBuffer('pty-1', { scrollbackRows: 5000 })
).resolves.toMatchObject({
data: expect.stringContaining('available mirror'),
source: 'headless'
})
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
releaseProvider(null)
await vi.advanceTimersByTimeAsync(0)
await expect(
runtime.serializeAuthoritativeTerminalBuffer('pty-1', { scrollbackRows: 5000 })
).resolves.toMatchObject({
data: 'provider recovered\r\n',
source: 'headless'
})
expect(serializeProviderBuffer).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('falls back to provider history when a mounted renderer has not hydrated yet', async () => {
const { runtime } = createSideEffectRuntime()
const serializeBuffer = vi.fn().mockResolvedValue({

View File

@ -448,6 +448,7 @@ import {
REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY,
RUNTIME_CAPABILITIES,
RUNTIME_PROTOCOL_VERSION,
TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../shared/protocol-version'
import {
@ -1472,6 +1473,21 @@ type ProviderBufferAcquisition = {
generation: number
scrollbackRows: number
promise: Promise<PtyProviderBufferSnapshot | null>
timedOut: boolean
}
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 = {
@ -3056,6 +3072,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
@ -4531,7 +4548,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)
@ -4597,8 +4616,10 @@ export class OrcaRuntimeService {
onClientEvent(listener: (event: RuntimeClientEvent) => void): () => void {
this.clientEventListeners.add(listener)
this.refreshTerminalSideEffectConsumerAvailability()
return () => {
this.clientEventListeners.delete(listener)
this.refreshTerminalSideEffectConsumerAvailability()
}
}
@ -9157,7 +9178,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)
@ -9173,11 +9194,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 = {
@ -9187,10 +9204,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 })
}
}
@ -9518,7 +9540,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
}
@ -10013,19 +10041,21 @@ 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, {
timeoutMs: AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS,
retireOnTimeout: true
})
if (providerSnapshot) {
return providerSnapshot
}
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
}
@ -10589,7 +10619,7 @@ export class OrcaRuntimeService {
private async serializeProviderTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number } = {},
wait: { timeoutMs?: number } = {}
wait: { timeoutMs?: number; retireOnTimeout?: boolean } = {}
): Promise<PtyProviderBufferSnapshot | null> {
const generation = this.getPtyLifecycleGeneration(ptyId)
const scrollbackRows = Math.max(0, Math.floor(opts.scrollbackRows ?? 0))
@ -10600,7 +10630,7 @@ export class OrcaRuntimeService {
acquisition.scrollbackRows < scrollbackRows
) {
const promise = this.captureProviderTerminalBuffer(ptyId, opts, generation)
acquisition = { generation, scrollbackRows, promise }
acquisition = { generation, scrollbackRows, promise, timedOut: false }
this.providerBufferAcquisitionsByPtyId.set(ptyId, acquisition)
void promise.finally(() => {
if (this.providerBufferAcquisitionsByPtyId.get(ptyId) === acquisition) {
@ -10608,9 +10638,26 @@ export class OrcaRuntimeService {
}
})
}
return typeof wait.timeoutMs === 'number'
? withTimeout(acquisition.promise, wait.timeoutMs, null)
: acquisition.promise
if (acquisition.timedOut) {
return null
}
if (typeof wait.timeoutMs !== 'number') {
return acquisition.promise
}
const result = await withTimeout<
{ settled: true; value: PtyProviderBufferSnapshot | null } | { settled: false }
>(
acquisition.promise.then((value) => ({ settled: true as const, value })),
wait.timeoutMs,
{ settled: false as const }
)
if (!result.settled) {
if (wait.retireOnTimeout) {
acquisition.timedOut = true
}
return null
}
return result.value
}
private async captureProviderTerminalBuffer(
@ -32593,6 +32640,7 @@ const MAX_TAIL_PENDING_ANSI_CHARS = 4096
const DEFAULT_TERMINAL_READ_LIMIT = 120
const MAX_TERMINAL_READ_LIMIT = 2000
const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024
export const AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 8_000
const VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 750
const VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS = 1_000
const MAX_PREVIEW_LINES = 6

View File

@ -58,8 +58,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'
@ -582,11 +584,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 {
@ -2299,14 +2307,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
@ -2319,6 +2319,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()
@ -2369,6 +2377,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 remoteDesktopSubscriptionKey = `multiplex:${connectionId}:${request.streamId}`
@ -2532,9 +2545,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
capabilities: { ackOutputSourceRanges: 1 as const }
}
: {}),
truncated:
initialOutputOverflowed ||
(serialized ? read.truncated : isTerminalReadPayloadIncomplete(read))
// Why: retained-tail truncation loses history, not the authoritative latest-screen fallback.
truncated: initialOutputOverflowed
})
stream.sourceRangeReplacement =
stream.ackOutputSourceRanges &&
@ -2559,9 +2571,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
displayMode,
seq: snapshotFrameSeq,
cwd: serialized?.cwd,
truncated:
initialOutputOverflowed ||
(serialized ? read.truncated : isTerminalReadPayloadIncomplete(read)),
truncated: initialOutputOverflowed,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
source: serialized?.source,
oscLinks: serialized?.oscLinks,
@ -3321,8 +3331,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
streamId,
lines: read.tail,
truncated:
initialOutputOverflowed ||
(serialized ? read.truncated : isTerminalReadPayloadIncomplete(read)),
initialOutputOverflowed || (!sendBinary && isTerminalReadPayloadIncomplete(read)),
cols: serialized?.cols ?? size?.cols,
rows: serialized?.rows ?? size?.rows,
displayMode,
@ -3335,9 +3344,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
displayMode,
seq: snapshotFrameSeq,
cwd: serialized?.cwd,
truncated:
initialOutputOverflowed ||
(serialized ? read.truncated : isTerminalReadPayloadIncomplete(read)),
truncated: initialOutputOverflowed,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
oscLinks: serialized?.oscLinks,
data: serialized?.data ?? ''

View File

@ -42,20 +42,22 @@ async function setupMultiplexStream(): Promise<{
let snapshot: { data: string; seq?: number } = { data: 'INITIAL', seq: 0 }
let deferSerialize = false
let releaseDeferredSerialize: (() => void) | null = null
const serializeSnapshot = vi.fn(async () => {
if (deferSerialize) {
deferSerialize = false
await new Promise<void>((resolve) => {
releaseDeferredSerialize = resolve
})
}
return { data: snapshot.data, cols: 80, rows: 24, seq: snapshot.seq }
})
const runtime = {
getRuntimeId: () => 'test-runtime',
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn(async () => {
if (deferSerialize) {
deferSerialize = false
await new Promise<void>((resolve) => {
releaseDeferredSerialize = resolve
})
}
return { data: snapshot.data, cols: 80, rows: 24, seq: snapshot.seq }
}),
serializeTerminalBuffer: serializeSnapshot,
serializeAuthoritativeTerminalBuffer: serializeSnapshot,
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),

View File

@ -14,10 +14,18 @@ import {
encodeTerminalStreamJson,
encodeTerminalStreamText
} from '../../../shared/terminal-stream-protocol'
import { TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES } from '../../../shared/terminal-multiplex-flow-control'
import {
TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES,
TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION,
TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION
} from '../../../shared/terminal-multiplex-flow-control'
import { SshPtyOutputIntake, type SshPtyOutputDataEvent } from '../../ipc/ssh-pty-output-intake'
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
@ -35,6 +43,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,
getPtyOutputSequence: vi.fn().mockReturnValue(0),
...overrides
} as OrcaRuntimeService
@ -935,6 +944,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 }),
@ -1225,7 +1239,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(
@ -1233,7 +1250,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.
@ -2894,7 +2911,7 @@ describe('terminal multiplex RPC', () => {
await dispatchPromise
})
it('marks multiplex fallback snapshots truncated when the uncursored read is limited', async () => {
it('keeps a limited retained-tail fallback usable for multiplex first paint', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const handlers = new Map<
@ -2977,7 +2994,7 @@ describe('terminal multiplex RPC', () => {
expect(subscribed).toMatchObject({
type: 'subscribed',
streamId: 11,
truncated: true
truncated: false
})
const decodedFrames = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame))
@ -2985,7 +3002,7 @@ describe('terminal multiplex RPC', () => {
(frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart && frame.streamId === 11
)
expect(snapshotStart && decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({
truncated: true
truncated: false
})
const snapshotData = decodedFrames
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
@ -2997,6 +3014,50 @@ describe('terminal multiplex RPC', () => {
await dispatchPromise
})
it('does not mark a serialized multiplex snapshot truncated from an overflowed read', async () => {
const harness = startDesktopMultiplexSubscribe({
readTerminal: vi.fn().mockResolvedValue({
tail: ['old retained line'],
truncated: true,
limited: true
}),
serializeTerminalBuffer: vi.fn().mockResolvedValue({
data: 'authoritative current screen\r\n',
cols: 120,
rows: 40
})
})
await vi.waitFor(() =>
expect(harness.messages.some((message) => JSON.parse(message).result?.type === 'ready')).toBe(
true
)
)
sendDesktopMultiplexSubscribe(harness.handlers)
await vi.waitFor(() =>
expect(
harness.messages.some((message) => JSON.parse(message).result?.type === 'subscribed')
).toBe(true)
)
const snapshotStart = harness.binaryFrames
.map((bytes) => decodeTerminalStreamFrame(bytes))
.find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart && frame.streamId === 7)
expect(snapshotStart && decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({
truncated: false
})
expect(
harness.binaryFrames
.map((bytes) => decodeTerminalStreamFrame(bytes))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.join('')
).toBe('authoritative current screen\r\n')
harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.()
await harness.dispatchPromise
})
it('falls back to smaller requested snapshots when serialized data exceeds the send budget', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
@ -4055,14 +4116,37 @@ describe('terminal multiplex RPC', () => {
await harness.dispatchPromise
})
it('caps multiplex stream slots so aggregate pending output stays bounded', async () => {
const harness = startDesktopMultiplexSubscribe()
it('admits 128 active streams, rejects the 129th, and reuses released capacity', async () => {
let dataSubscriberCount = 0
let viewSubscriberCount = 0
const harness = startDesktopMultiplexSubscribe({
subscribeToTerminalData: vi.fn(() => {
dataSubscriberCount += 1
let released = false
return () => {
if (!released) {
released = true
dataSubscriberCount -= 1
}
}
}),
registerRemoteTerminalViewSubscriber: vi.fn(() => {
viewSubscriberCount += 1
let released = false
return () => {
if (!released) {
released = true
viewSubscriberCount -= 1
}
}
})
})
await vi.waitFor(() =>
expect(harness.messages.some((message) => JSON.parse(message).result?.type === 'ready')).toBe(
true
)
)
for (let streamId = 1; streamId <= 33; streamId += 1) {
const sendSubscribe = (streamId: number): void => {
harness.handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
@ -4079,16 +4163,167 @@ describe('terminal multiplex RPC', () => {
)!
)
}
expect(TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION).toBe(128)
for (
let streamId = 1;
streamId <= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION + 1;
streamId += 1
) {
sendSubscribe(streamId)
}
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
})
})
expect(dataSubscriberCount).toBe(TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION)
expect(viewSubscriberCount).toBe(TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION)
harness.handlers.get(1)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Unsubscribe,
streamId: 1,
seq: TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION + 2,
payload: new Uint8Array()
})
)!
)
await vi.waitFor(() => {
expect(dataSubscriberCount).toBe(TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION - 1)
expect(viewSubscriberCount).toBe(TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION - 1)
expect(harness.handlers.has(1)).toBe(false)
})
const retriedStreamId = TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION + 1
sendSubscribe(retriedStreamId)
await vi.waitFor(() => {
const subscribedStreamIds = harness.messages
.map((message) => JSON.parse(message).result)
.filter((result) => result?.type === 'subscribed')
.map((result) => result.streamId)
expect(subscribedStreamIds).toContain(retriedStreamId)
expect(
harness.binaryFrames.some((bytes) => {
const frame = decodeTerminalStreamFrame(bytes)
return (
frame?.streamId === retriedStreamId && frame.opcode === TerminalStreamOpcode.SnapshotEnd
)
})
).toBe(true)
})
expect(dataSubscriberCount).toBe(TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION)
expect(viewSubscriberCount).toBe(TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION)
harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.()
await harness.dispatchPromise
expect(dataSubscriberCount).toBe(0)
expect(viewSubscriberCount).toBe(0)
})
it('reserves PTY wait capacity independently from active streams', async () => {
const activeStreamCount = 44
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 <= activeStreamCount; streamId += 1) {
sendSubscribe(streamId, `active-${streamId}`)
}
await vi.waitFor(() =>
expect(
harness.messages.filter((message) => JSON.parse(message).result?.type === 'subscribed')
).toHaveLength(activeStreamCount)
)
for (
let offset = 1;
offset <= TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION + 1;
offset += 1
) {
sendSubscribe(activeStreamCount + offset, `pending-${offset}`)
}
await vi.waitFor(() =>
expect(waitSignals).toHaveLength(TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION)
)
const rejectedStreamId =
activeStreamCount + TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION + 1
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(
activeStreamCount + TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION
)
expect(
results.filter(
(result) =>
result?.type === 'error' && result.message === 'terminal_stream_limit_exceeded'
)
).toHaveLength(1)
})
harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.()

View File

@ -1,4 +1,4 @@
import { TERMINAL_MULTIPLEX_MAX_STREAMS_PER_CONNECTION } from '../../../shared/terminal-multiplex-flow-control'
import { TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION } from '../../../shared/terminal-multiplex-flow-control'
import {
TerminalSourceRangeLedger,
type TerminalSourceRangeBudget
@ -11,7 +11,7 @@ export class TerminalSourceRangeRegistry {
private retainedBytes = 0
open(streamGeneration: string): TerminalSourceRangeLedger | null {
if (this.ledgers.size >= TERMINAL_MULTIPLEX_MAX_STREAMS_PER_CONNECTION) {
if (this.ledgers.size >= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION) {
return null
}
let ledger: TerminalSourceRangeLedger

View File

@ -316,7 +316,7 @@ describe('terminal subscribe buffering', () => {
}
})
it('marks binary subscribed previews truncated when the uncursored read is limited', async () => {
it('keeps a limited retained-tail fallback usable for binary first paint', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
@ -374,20 +374,20 @@ describe('terminal subscribe buffering', () => {
expect(subscribed).toMatchObject({
type: 'subscribed',
lines: ['line 120'],
truncated: true
truncated: false
})
const snapshotStart = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)
expect(snapshotStart && decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({
truncated: true
truncated: false
})
runtime.cleanupSubscription('terminal-1:desktop-1')
await dispatchPromise
})
it('does not mark binary snapshot frames truncated from a limited read when serialized data is available', async () => {
it('does not mark binary snapshot frames truncated from an overflowed read when serialized data is available', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
@ -395,7 +395,7 @@ describe('terminal subscribe buffering', () => {
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({
tail: ['line 120'],
truncated: false,
truncated: true,
limited: true
}),
serializeTerminalBuffer: vi.fn().mockResolvedValue({

View File

@ -3319,6 +3319,10 @@ export type PreloadApi = {
disconnect: (args: {
selector: string
}) => Promise<{ disconnected: PublicKnownRuntimeEnvironment }>
connect: (args: {
selector: string
timeoutMs?: number
}) => Promise<RuntimeRpcResponse<RuntimeStatus>>
getStatus: (args: {
selector: string
timeoutMs?: number

View File

@ -4281,6 +4281,11 @@ const api = {
selector: string
}): Promise<{ disconnected: PublicKnownRuntimeEnvironment }> =>
ipcRenderer.invoke('runtimeEnvironments:disconnect', args),
connect: (args: {
selector: string
timeoutMs?: number
}): Promise<RuntimeRpcResponse<RuntimeStatus>> =>
ipcRenderer.invoke('runtimeEnvironments:connect', args),
getStatus: (args: {
selector: string
timeoutMs?: number

View File

@ -69,6 +69,7 @@ import { scheduleBackgroundTerminalWorktreeMeasure } from './terminal/background
import {
applyBackgroundMountTabRestriction,
canDeferColdActivationTabsForHost,
canMountTerminalWorkspaceForStartup,
planColdActivationTabDeferral,
pruneClosedBackgroundMountTabs,
revealActivationDeferredTabs,
@ -88,18 +89,23 @@ 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'
import {
TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS,
hasPendingRetentionSpawnWork,
selectForceParkEvictableTabIds,
selectRetentionForceParkedTerminalWorktrees,
type TerminalWorktreeRetentionCandidate
} from './terminal-pane/terminal-hidden-worktree-retention'
import { captureForceParkedWorktreeBuffers } from './terminal-pane/force-park-buffer-capture'
import { warnTerminalLifecycleAnomaly } from './terminal-pane/terminal-lifecycle-diagnostics'
import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides'
import {
getTerminalParkingPolicyOverrides,
recordTerminalWorktreeParkingDebugVerdicts
} from './terminal-pane/terminal-parking-e2e-overrides'
import { selectEvictionExemptTerminalTabIds } from './terminal-pane/terminal-eviction-exempt-tabs'
import {
canWatcherCoverParkedTerminalTab,
@ -296,6 +302,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
)
@ -317,6 +328,7 @@ function Terminal(): React.JSX.Element | null {
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const hydrationSucceeded = useAppStore((s) => s.hydrationSucceeded)
const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted)
const openFiles = useAppStore((s) => s.openFiles)
const activeFileId = useAppStore((s) => s.activeFileId)
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
@ -949,7 +961,10 @@ function Terminal(): React.JSX.Element | null {
})
}
const restorePolicy = { sshParkingEnabled: terminalSshParkingEnabled }
const restorePolicy = {
sshParkingEnabled: terminalSshParkingEnabled,
pairedRuntimeParkingEnvironmentIds
}
const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({
worktrees: retentionCandidates,
pendingStartupByTabId,
@ -1006,14 +1021,11 @@ function Terminal(): React.JSX.Element | null {
isVisible: candidate.isVisible,
shouldMeasureHiddenWorktree: candidate.shouldMeasureHiddenWorktree,
hasActivityTerminalPortal: candidate.hasActivityTerminalPortal,
parkCooldownUntilMs: candidate.parkCooldownUntilMs,
parkCooldownUntilMs: candidate.parkCooldownUntilMs ?? null,
ordinaryParkingCovers:
parkEligible && worktreeTabsAreWatcherCovered(candidate.worktreeId, tabs),
hasPendingSpawnWork: tabs.some(
(tab) =>
pendingStartupByTabId[tab.id] !== undefined ||
tab.pendingActivationSpawn === true ||
(typeof tab.pendingActivationSpawn === 'number' && tab.pendingActivationSpawn > 0)
hasPendingSpawnWork: tabs.some((tab) =>
hasPendingRetentionSpawnWork(tab, pendingStartupByTabId)
)
}
}
@ -1025,6 +1037,13 @@ function Terminal(): React.JSX.Element | null {
nowMs,
...overrides
})
recordTerminalWorktreeParkingDebugVerdicts(
retentionBudgetCandidates.map((candidate) => ({
...candidate,
parkCooldownUntilMs: candidate.parkCooldownUntilMs ?? null,
forceParked: forceParkedWorktreeIds.has(candidate.worktreeId)
}))
)
const capturedForceParked = forceParkedCaptureDoneRef.current
for (const id of Array.from(capturedForceParked)) {
if (!forceParkedWorktreeIds.has(id)) {
@ -1126,6 +1145,7 @@ function Terminal(): React.JSX.Element | null {
activityTerminalPortals,
backgroundMountRevision,
pendingStartupByTabId,
pairedRuntimeParkingEnvironmentIds,
renderedActiveWorktreeId,
tabsByWorktree,
terminalParkingEnabled,
@ -1134,8 +1154,15 @@ function Terminal(): React.JSX.Element | null {
terminalSshParkingEnabled,
workspaceSurfaces
])
// Why: gate on workspaceSessionReady so TerminalPane doesn't mount and spawn a duplicate PTY before reconnectPersistedTerminals() finishes.
if (renderedActiveWorktreeId && workspaceSessionReady) {
// Why: a slow post-reconnect step exposes workspaceSessionReady before hydration can populate snapshot capabilities.
if (
renderedActiveWorktreeId &&
canMountTerminalWorkspaceForStartup({
workspaceSessionReady,
hydrationSucceeded,
startupWorktreeRefreshCompleted
})
) {
// Why: mounting every saved tab at once (scrollback replay + WebGL + sync-IPC snapshot per pane) freezes the renderer, so hidden tabs defer and mount on first reveal.
const worktreeTabs = tabsByWorktree[renderedActiveWorktreeId] ?? []
const coldActivationDeferralEnabled =

View File

@ -600,7 +600,7 @@ export function RuntimeEnvironmentsPane({
setConnectingId(environment.id)
setSwitchError(null)
try {
const response = await window.api.runtimeEnvironments.getStatus({
const response = await window.api.runtimeEnvironments.connect({
selector: environment.id,
timeoutMs: 15_000
})

View File

@ -20,6 +20,7 @@ import { isUserManagedRuntimeEnvironment } from '../../../../shared/runtime-envi
import { RuntimeHostStatusRow, type RuntimeHostConnectionState } from './RuntimeHostStatusRow'
import { SshTargetStatusRow } from './SshTargetStatusRow'
import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types'
import { connectRuntimeEnvironmentAndRecordStatus } from './runtime-environment-explicit-connect'
function isConnecting(status: SshConnectionStatus): boolean {
return ['connecting', 'deploying-relay', 'reconnecting'].includes(status)
@ -177,7 +178,6 @@ export function SshStatusSegment({
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus)
const hydrateRuntimeEnvironmentStatuses = useAppStore((s) => s.hydrateRuntimeEnvironmentStatuses)
const refreshRuntimeEnvironmentStatus = useAppStore((s) => s.refreshRuntimeEnvironmentStatus)
const remoteWorkspaceSyncStatusByTargetId = useAppStore(
(s) => s.remoteWorkspaceSyncStatusByTargetId
)
@ -232,7 +232,7 @@ export function SshStatusSegment({
const store = useAppStore.getState()
const reachable = await connectRuntimeHostForNavigation({
environmentId,
refreshStatus: refreshRuntimeEnvironmentStatus,
refreshStatus: connectRuntimeEnvironmentAndRecordStatus,
fetchRepos: store.fetchRuntimeEnvironmentRepos,
fetchWorktrees: store.fetchWorktrees,
fetchLineage: store.fetchWorktreeLineage
@ -248,7 +248,7 @@ export function SshStatusSegment({
}
recordFeatureInteraction('ssh')
},
[recordFeatureInteraction, refreshRuntimeEnvironmentStatus]
[recordFeatureInteraction]
)
const disconnectRuntimeHost = useCallback(
async (environmentId: string): Promise<void> => {

View File

@ -0,0 +1,22 @@
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '../../store'
export async function connectRuntimeEnvironmentAndRecordStatus(
environmentId: string,
timeoutMs: number
): Promise<boolean> {
const setStatus = useAppStore.getState().setRuntimeEnvironmentStatus
try {
const response = await window.api.runtimeEnvironments.connect({
selector: environmentId,
timeoutMs
})
const status = unwrapRuntimeRpcResult<RuntimeStatus>(response)
setStatus(environmentId, { status, checkedAt: Date.now() })
return true
} catch {
setStatus(environmentId, { status: null, checkedAt: Date.now() })
return false
}
}

View File

@ -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 —

View File

@ -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) {

View File

@ -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(),
@ -20047,6 +20055,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')

View File

@ -14,6 +14,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'
@ -769,6 +770,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) {
@ -2051,6 +2063,11 @@ export function connectPanePty(
// surviving shell then receives pointer moves as typed SGR reports; the
// replay guard keeps xterm's auto-replies from leaking to the shell.
replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_REATTACH_RESET, {
breadcrumbIdentity: {
tabId: deps.tabId,
worktreeId: deps.worktreeId,
ptyId: transport.getPtyId()
},
shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously
})
if (reason === 'visible-pty') {
@ -2580,6 +2597,11 @@ export function connectPanePty(
// eats every click and keystroke against a dead transport — disarm the
// modes now and arm the reveal-time wake.
replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_MODE_RESET, {
breadcrumbIdentity: {
tabId: deps.tabId,
worktreeId: deps.worktreeId,
ptyId
},
shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously
})
hibernatedWakeTarget = { ptyId, record: sleepingRecordEntry.record }
@ -5339,6 +5361,11 @@ export function connectPanePty(
// scheduler's deferred drain cannot land older bytes on top of the replay.
flushTerminalOutput(pane.terminal)
replayIntoTerminal(pane, deps.replayingPanesRef, data, {
breadcrumbIdentity: {
tabId: deps.tabId,
worktreeId: deps.worktreeId,
ptyId: transport.getPtyId()
},
shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously,
shouldReleaseRenderPause: () => deps.isVisibleRef.current
})
@ -5349,6 +5376,11 @@ export function connectPanePty(
// merely after the write was queued.
flushTerminalOutput(pane.terminal)
return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data, {
breadcrumbIdentity: {
tabId: deps.tabId,
worktreeId: deps.worktreeId,
ptyId: transport.getPtyId()
},
shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously,
shouldReleaseRenderPause: () => deps.isVisibleRef.current
})
@ -7677,20 +7709,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
@ -7731,13 +7773,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
@ -7863,7 +7906,7 @@ export function connectPanePty(
schedulePendingStartupCommandDelivery()
}
}
if (hasStructuralReplay || prefetchedSshModelSnapshot) {
if (hasStructuralReplay || prefetchedParkModelSnapshot) {
await waitForTerminalReplayWritesParsed(pane.terminal)
if (!isCurrentReattachPayload()) {
return
@ -7917,7 +7960,7 @@ export function connectPanePty(
window.api.pty.signal(reattachPtyId, 'SIGWINCH')
}
}
if (hasStructuralReplay || prefetchedSshModelSnapshot) {
if (hasStructuralReplay || prefetchedParkModelSnapshot) {
await structuralReplayCoordinator.run(applyReattachPayload, {
shouldRestore: isCurrentReattachPayload,
afterRestore: fitAfterReattachRestore
@ -8332,9 +8375,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 &&

View File

@ -737,7 +737,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')
@ -1504,6 +1508,8 @@ describe('createRemoteRuntimePtyTransport', () => {
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const oldStreamId = latestSubscribePayload().streamId
emitSnapshot(oldStreamId, 'before restart')
expect(transport.isConnected()).toBe(true)
runtimeCall.mockImplementation(async (args: { method: string }) =>
args.method === 'session.tabs.list' ? new Promise(() => {}) : { ok: true, result: {} }
@ -1512,10 +1518,17 @@ describe('createRemoteRuntimePtyTransport', () => {
ok: true,
result: { type: 'end', streamId: oldStreamId, code: 0 }
})
const replacementSnapshot = transport.serializeBuffer?.({ scrollbackRows: 5000 })
let snapshotSettled = false
void replacementSnapshot?.then(() => {
snapshotSettled = true
})
await Promise.resolve()
expect(onExit).not.toHaveBeenCalled()
expect(onPtyExit).not.toHaveBeenCalled()
expect(transport.getPtyId()).toBe('remote:hub-env@@terminal-1')
expect(snapshotSettled).toBe(false)
expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1)
handleEvents.queueAcceptedWebSessionTerminalSnapshot(
@ -1553,6 +1566,39 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(onPtySpawn).not.toHaveBeenCalled()
expect(onPtyExit).not.toHaveBeenCalled()
expect(onExit).not.toHaveBeenCalled()
emitSnapshot(latestSubscribePayload().streamId, 'replacement initial state')
await vi.waitFor(() =>
expect(latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)).toBeDefined()
)
const requestFrame = latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)
const request = requestFrame
? decodeTerminalStreamJson<{ requestId?: number }>(requestFrame.payload)
: null
emitSnapshotFrame(
latestSubscribePayload().streamId,
TerminalStreamOpcode.SnapshotStart,
encodeTerminalStreamJson({
kind: 'scrollback',
requestId: request?.requestId,
cols: 100,
rows: 30
})
)
emitSnapshotFrame(
latestSubscribePayload().streamId,
TerminalStreamOpcode.SnapshotChunk,
encodeTerminalStreamText('replacement authoritative state')
)
emitSnapshotFrame(
latestSubscribePayload().streamId,
TerminalStreamOpcode.SnapshotEnd,
new Uint8Array()
)
await expect(replacementSnapshot).resolves.toMatchObject({
data: 'replacement authoritative state',
cols: 100,
rows: 30
})
})
it('coalesces concurrent stale errors for the handle that was replaced', async () => {
@ -2486,7 +2532,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',
@ -2633,7 +2683,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',
@ -2738,7 +2792,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' })
})
@ -3113,6 +3171,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[] = []
@ -4052,6 +4148,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 }>(
@ -4104,16 +4203,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,

View File

@ -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,46 @@ export function createRemoteRuntimePtyTransport(
let stopWaitingForPublishedHandle: (() => void) | null = null
let attachGeneration = 0
let subscriptionGeneration = 0
function setAttachmentReady(ready: boolean): void {
attachmentReady = ready
if (!ready) {
return
}
for (const resolve of attachmentReadyWaiters) {
resolve(true)
}
attachmentReadyWaiters.clear()
}
function setAttachmentUnavailable(): void {
attachmentReady = false
for (const resolve of attachmentReadyWaiters) {
resolve(false)
}
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 +603,8 @@ export function createRemoteRuntimePtyTransport(
return {
id: remotePtyId,
replay: ''
replay: '',
isReattach: true
} satisfies PtyConnectResult
}
@ -846,7 +888,7 @@ export function createRemoteRuntimePtyTransport(
) {
return undefined
}
return { id: remotePtyId, replay: '' }
return { id: remotePtyId, replay: '', isReattach: true }
}
function recoverExpiredHostPane(): void {
@ -1033,7 +1075,7 @@ export function createRemoteRuntimePtyTransport(
multiplexedStream?.close()
multiplexedStream = null
multiplexedStreamHandle = null
attachmentReady = false
setAttachmentReady(false)
}
function clearPublishedHandleWait(): void {
@ -1064,6 +1106,7 @@ export function createRemoteRuntimePtyTransport(
handle = null
remotePtyId = null
closeMultiplexedStream()
setAttachmentUnavailable()
emitRecoveryState()
if (stalePtyId) {
onPtyExit?.(stalePtyId)
@ -1077,7 +1120,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 +1349,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 +1372,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 +1417,7 @@ export function createRemoteRuntimePtyTransport(
return
}
subscriptionAttached = true
attachmentReady = true
setAttachmentReady(true)
connecting = false
recoveryRequiresReplacement = false
recovery.markHealthy()
@ -1372,6 +1431,7 @@ export function createRemoteRuntimePtyTransport(
}
outputProcessor.clearAccumulatedState()
if (tabId && isWebTerminalSurfaceTabId(tabId)) {
setAttachmentReady(false)
multiplexedStream = null
multiplexedStreamHandle = null
clearPendingViewportClaim()
@ -1386,7 +1446,7 @@ export function createRemoteRuntimePtyTransport(
remotePtyId = null
multiplexedStream = null
multiplexedStreamHandle = null
attachmentReady = false
setAttachmentUnavailable()
terminalEnded = true
clearPendingViewportClaim()
emitRecoveryState()
@ -1411,7 +1471,7 @@ export function createRemoteRuntimePtyTransport(
setDriverForPty(subscribedPtyId, driver)
}
},
onTransportClose: ({ recoverable }) => {
onTransportClose: ({ recoverable, retryWithBackoff }) => {
transportClosed = true
if (generation !== subscriptionGeneration) {
return
@ -1424,12 +1484,17 @@ export function createRemoteRuntimePtyTransport(
}
multiplexedStream = null
multiplexedStreamHandle = null
attachmentReady = false
setAttachmentReady(false)
if (recoverable) {
scheduleResubscribeAfterTransportClose()
if (retryWithBackoff) {
scheduleCapacityPressureRetry()
} else {
scheduleResubscribeAfterTransportClose()
}
} else {
connecting = false
recovery.cancel()
setAttachmentUnavailable()
emitRecoveryState()
}
}
@ -1450,7 +1515,7 @@ export function createRemoteRuntimePtyTransport(
closeMultiplexedStream()
multiplexedStream = nextStream
multiplexedStreamHandle = subscribedHandle
attachmentReady = subscriptionAttached
setAttachmentReady(subscriptionAttached)
if (subscriptionAttached) {
recoveryRequiresReplacement = false
recovery.markHealthy()
@ -1809,6 +1874,7 @@ export function createRemoteRuntimePtyTransport(
const id = remotePtyId
unregisterShutdownHandlers(id)
closeMultiplexedStream()
setAttachmentUnavailable()
handle = null
remotePtyId = null
emitRecoveryState()
@ -1834,6 +1900,7 @@ export function createRemoteRuntimePtyTransport(
connecting = false
clearPendingViewportClaim()
closeMultiplexedStream()
setAttachmentUnavailable()
emitRecoveryState()
storedCallbacks = {}
},
@ -1982,11 +2049,15 @@ export function createRemoteRuntimePtyTransport(
if (!connected || !handle) {
return null
}
if (!(await waitForAttachmentReady()) || !handle) {
return null
}
return getCurrentMultiplexedStream(handle)?.serializeBuffer(opts) ?? null
},
destroy() {
destroyed = true
setAttachmentUnavailable()
this.disconnect()
recovery.dispose()
inputBatcher.clear()

View File

@ -495,6 +495,43 @@ describe('replay-guard stall handling (probe-certified release)', () => {
}
})
it('records correlatable replay identity without exposing worktree or PTY paths', () => {
vi.useFakeTimers()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const ref = makeRef()
const { pane, terminal } = makeFakePane(1)
pane.leafId = 'leaf-private-identity' as ManagedPane['leafId']
replayIntoTerminal(pane, ref, 'restored bytes', {
breadcrumbIdentity: {
tabId: 'tab-private-identity',
worktreeId: 'repo::/Users/alice/private-worktree',
ptyId: '/Users/alice/private-worktree@@ab12cd34'
},
stallCheckMs: 1_000
})
terminal.pendingCallbacks.shift()
vi.advanceTimersByTime(1_000)
terminal.flush()
const breadcrumbData = mocks.recordRendererCrashBreadcrumb.mock.calls[0]?.[1]
expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith(
'terminal_replay_guard_lost_completion',
{
paneId: 1,
leafIdHash: expect.stringMatching(/^[0-9a-f]{8}$/),
tabIdHash: expect.stringMatching(/^[0-9a-f]{8}$/),
worktreeIdHash: expect.stringMatching(/^[0-9a-f]{8}$/),
ptyId: '…@@ab12cd34'
}
)
expect(JSON.stringify(breadcrumbData)).not.toContain('/Users/alice')
} finally {
errorSpy.mockRestore()
}
})
it('releases after the probe itself never parses (wedged pipeline) and reports it', () => {
vi.useFakeTimers()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

View File

@ -9,6 +9,7 @@ import {
notifyUndeliverableWrite,
recordTerminalParseProgress
} from '@/lib/pane-manager/terminal-write-pipeline-health'
import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics'
// Why this guard exists: xterm auto-replies to query sequences (DA1/DECRQM/OSC 10-11/CPR) via onData → shell stdin, so replaying recorded PTY bytes leaks stray replies onto the new shell's prompt.
// No wasUserInput flag distinguishes replay replies from real keystrokes, so a per-pane in-flight counter gates onData; bounded by xterm's parse completion (not a timer), only auto-replies from replayed bytes are dropped.
@ -20,11 +21,53 @@ export type ReplayingPanesRef = React.RefObject<Map<number, number>>
const REPLAY_GUARD_STALL_CHECK_MS = 10_000
type ReplayTerminalOptions = {
breadcrumbIdentity?: {
tabId?: string
worktreeId?: string
ptyId?: string | null
}
shouldRefreshViewportSynchronously?: () => boolean
shouldReleaseRenderPause?: () => boolean
stallCheckMs?: number
}
type ReplayGuardBreadcrumbData = {
paneId: number
tabIdHash?: string
worktreeIdHash?: string
leafIdHash?: string
ptyId?: string
}
function hashReplayIdentity(value: string): string {
let hash = 0x811c9dc5
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(16).padStart(8, '0')
}
function replayGuardBreadcrumbData(
pane: ManagedPane,
identity: ReplayTerminalOptions['breadcrumbIdentity']
): ReplayGuardBreadcrumbData {
const data: ReplayGuardBreadcrumbData = { paneId: pane.id }
if (pane.leafId) {
data.leafIdHash = hashReplayIdentity(pane.leafId)
}
if (identity?.tabId) {
data.tabIdHash = hashReplayIdentity(identity.tabId)
}
if (identity?.worktreeId) {
data.worktreeIdHash = hashReplayIdentity(identity.worktreeId)
}
if (identity?.ptyId) {
data.ptyId = redactPtyIdForDiagnostics(identity.ptyId)
}
return data
}
export function isPaneReplaying(ref: ReplayingPanesRef, paneId: number): boolean {
return (ref.current.get(paneId) ?? 0) > 0
}
@ -45,6 +88,7 @@ function engageReplayGuard(
paneId: number,
terminal: ReplayGuardWriteTarget,
stallCheckMs: number,
breadcrumbData: ReplayGuardBreadcrumbData,
onRelease?: () => void
): ReplayGuardWriteCallbacks {
map.set(paneId, (map.get(paneId) ?? 0) + 1)
@ -69,12 +113,12 @@ function engageReplayGuard(
console.error(
`[terminal] replay guard released for pane ${paneId} — the probe write parsed but the replay completion never arrived (lost write callback)`
)
recordRendererCrashBreadcrumb('terminal_replay_guard_lost_completion', { paneId })
recordRendererCrashBreadcrumb('terminal_replay_guard_lost_completion', breadcrumbData)
} else if (reason === 'wedged') {
console.error(
`[terminal] replay guard released for pane ${paneId} — xterm rejected the replay write or its probe never parsed (undeliverable write pipeline; pane likely needs recovery)`
)
recordRendererCrashBreadcrumb('terminal_replay_guard_wedged_release', { paneId })
recordRendererCrashBreadcrumb('terminal_replay_guard_wedged_release', breadcrumbData)
// Why: a rejected replay or silent probe makes the pipeline undeliverable; recover instead of a fossil that eats input.
notifyUndeliverableWrite(terminal, 'replay-wedged')
}
@ -144,7 +188,8 @@ export function replayIntoTerminal(
replayingPanesRef.current,
pane.id,
pane.terminal,
options.stallCheckMs ?? REPLAY_GUARD_STALL_CHECK_MS
options.stallCheckMs ?? REPLAY_GUARD_STALL_CHECK_MS,
replayGuardBreadcrumbData(pane, options.breadcrumbIdentity)
)
// Why: hidden/snapshot replay skips the foreground path; WebGL/canvas still need a post-parse repaint to drop stale cells.
writeForegroundTerminalChunk(pane.terminal, data, {
@ -178,6 +223,7 @@ export function replayIntoTerminalAsync(
pane.id,
pane.terminal,
options.stallCheckMs ?? REPLAY_GUARD_STALL_CHECK_MS,
replayGuardBreadcrumbData(pane, options.breadcrumbIdentity),
resolve
)
writeForegroundTerminalChunk(pane.terminal, data, {

View File

@ -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 })

View File

@ -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)

View File

@ -2,12 +2,46 @@ import { describe, expect, it } from 'vitest'
import { TERMINAL_WORKTREE_PARK_DELAY_MS } from './terminal-hidden-view-parking'
import {
TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS,
hasPendingRetentionSpawnWork,
isEvictionExemptTerminalPty,
selectForceParkEvictableTabIds,
selectRetentionForceParkedTerminalWorktrees,
type TerminalWorktreeRetentionCandidate
} from './terminal-hidden-worktree-retention'
describe('hasPendingRetentionSpawnWork', () => {
const remoteTab = {
id: 'tab-remote',
ptyId: 'remote:env-1@@terminal-1',
pendingActivationSpawn: true as const
}
it('treats a host-backed paired PTY as settled despite activation residue', () => {
expect(hasPendingRetentionSpawnWork(remoteTab, {})).toBe(false)
expect(hasPendingRetentionSpawnWork({ ...remoteTab, pendingActivationSpawn: 2 }, {})).toBe(
false
)
})
it('preserves real startup work and non-paired activation guards', () => {
expect(hasPendingRetentionSpawnWork(remoteTab, { [remoteTab.id]: ['echo', 'pending'] })).toBe(
true
)
expect(
hasPendingRetentionSpawnWork(
{ id: 'tab-local', ptyId: 'pty-local', pendingActivationSpawn: true },
{}
)
).toBe(true)
expect(
hasPendingRetentionSpawnWork(
{ id: 'tab-unbound', ptyId: null, pendingActivationSpawn: true },
{}
)
).toBe(true)
})
})
describe('isEvictionExemptTerminalPty', () => {
const worktreeId = 'repo::/worktree'

View File

@ -7,6 +7,7 @@ import {
type ColdParkRetainCandidate,
type TerminalColdParkPolicyOverrides
} from './terminal-hidden-view-parking'
import type { TerminalTab } from '../../../../shared/types'
// Why these sizes: a retained hidden pane costs a measured ~2.5MB of V8 heap
// at the 5k-row default scrollback and ~19MB at 50k (plus per-pane queues),
@ -28,6 +29,18 @@ import {
export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 12
export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 45 * 60_000
export function hasPendingRetentionSpawnWork(
tab: Pick<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>,
pendingStartupByTabId: Readonly<Record<string, unknown>>
): boolean {
if (pendingStartupByTabId[tab.id] !== undefined) {
return true
}
// Why: paired mirrors never spawn locally; their host-backed PTY id proves
// activation's sort-suppression residue cannot represent unfinished work.
return Boolean(tab.pendingActivationSpawn && (!tab.ptyId || !isRemoteRuntimePtyId(tab.ptyId)))
}
// Why: an eviction-exempt pty is a live local one a remount could not reattach
// (daemon-fail-open separator-less ids, ptys minted under another worktree) — a
// fresh spawn would orphan the live shell. Its TAB keeps its mounted pane when

View File

@ -0,0 +1,131 @@
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import { useAppStore } from '@/store'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher'
import { subscribeToPtyExit } from './pty-dispatcher'
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
import {
isParkRestorableTerminalPty,
type TerminalParkRestorePolicy
} from './terminal-hidden-view-parking'
import type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation'
import {
resolveTabTitleAfterPaneClose,
shouldClearLaunchAgentForClosedPane
} from './terminal-pane-close-identity'
import {
capturedPanesByTabId,
parkedWatchersByTabId,
type ParkedTabWatcherEntry,
type ParkedTerminalPaneCapture
} from './terminal-parked-watcher-registry'
export function startParkedPtyWatcher(args: {
worktreeId: string
tab: ParkableTerminalTabModel
pane: ParkedTerminalPaneCapture
entry: ParkedTabWatcherEntry
restoreTitleOnRegister: boolean
restorePolicy: TerminalParkRestorePolicy
}): void {
const { worktreeId, tab, pane, entry, restoreTitleOnRegister, restorePolicy } = args
const state = useAppStore.getState()
const ptyId = pane.ptyId
// Why: the tab model can change after the park decision, and legacy leaf ids make pane keys throw.
if (
!ptyId ||
entry.disposersByPtyId.has(ptyId) ||
!isTerminalLeafId(pane.leafId) ||
!isParkRestorableTerminalPty(ptyId, worktreeId, restorePolicy)
) {
return
}
const handlePtyExit = (_code: number, { hadPrimary }: { hadPrimary: boolean }): void => {
useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId)
if (entry.disposersByPtyId.size > 1) {
discardPreHandlerPtyState(ptyId)
collapseParkedExitedLeaf(tab.id, ptyId)
entry.disposersByPtyId.get(ptyId)?.()
entry.disposersByPtyId.delete(ptyId)
return
}
if (hadPrimary) {
entry.disposersByPtyId.get(ptyId)?.()
entry.disposersByPtyId.delete(ptyId)
return
}
// Why: the empty entry prevents a pending pinned-close confirmation from restarting the dead PTY.
entry.disposersByPtyId.get(ptyId)?.()
entry.disposersByPtyId.delete(ptyId)
closeTerminalTab(tab.id, {
captureRecentlyClosed: false,
hostCloseReason: 'pty-exit',
lifecyclePtyId: ptyId,
onClosed: () => {
discardPreHandlerPtyState(ptyId)
if (parkedWatchersByTabId.get(tab.id) === entry) {
parkedWatchersByTabId.delete(tab.id)
}
},
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)
}
})
const unsubscribeExit = isRemoteRuntimePtyId(ptyId)
? () => {}
: subscribeToPtyExit(ptyId, handlePtyExit)
entry.paneIdByPtyId.set(ptyId, pane.paneId)
entry.disposersByPtyId.set(ptyId, () => {
unsubscribeExit()
disposeWatcher()
})
}
export function collapseParkedExitedLeaf(tabId: string, ptyId: string): void {
const state = useAppStore.getState()
const layout = state.terminalLayoutsByTabId[tabId]
const leafId =
capturedPanesByTabId.get(tabId)?.panes.find((pane) => pane.ptyId === ptyId)?.leafId ??
Object.entries(layout?.ptyIdsByLeafId ?? {}).find(([, boundPtyId]) => boundPtyId === ptyId)?.[0]
if (!leafId) {
return
}
const detached = detachTerminalLayoutLeaf(layout, leafId)
if (!detached) {
return
}
const terminalTab = Object.values(state.tabsByWorktree)
.flat()
.find((candidate) => candidate.id === tabId)
if (shouldClearLaunchAgentForClosedPane(terminalTab, ptyId)) {
state.clearTabLaunchAgent(tabId)
}
state.setTabLayout(tabId, detached.sourceLayout)
const activeLeafId = detached.sourceLayout.activeLeafId
const activePtyId = activeLeafId
? detached.sourceLayout.ptyIdsByLeafId?.[activeLeafId]
: undefined
const activePaneId = activePtyId
? (parkedWatchersByTabId.get(tabId)?.paneIdByPtyId.get(activePtyId) ?? null)
: null
state.updateTabTitle(
tabId,
resolveTabTitleAfterPaneClose(state.runtimePaneTitlesByTabId[tabId] ?? {}, activePaneId)
)
}

View File

@ -80,8 +80,13 @@ 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>
setRuntimePaneTitle: ReturnType<typeof vi.fn>
setTabLayout: ReturnType<typeof vi.fn>
updateTabTitle: ReturnType<typeof vi.fn>
}
@ -141,8 +146,10 @@ describe('terminal-parked-tab-watchers', () => {
terminalLayoutsByTabId: {},
runtimePaneTitlesByTabId: {},
settings: null,
runtimeStatusByEnvironmentId: new Map(),
clearTabLaunchAgent: vi.fn(),
clearRuntimePaneTitle: vi.fn(),
setRuntimePaneTitle: vi.fn(),
setTabLayout: vi.fn(),
updateTabTitle: vi.fn()
}
@ -222,6 +229,24 @@ 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(subscribeToPtyExit).not.toHaveBeenCalled()
expect(exitSubscriptions).toEqual([])
})
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 +288,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([])
})

View File

@ -6,29 +6,24 @@
* 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 { 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 {
resolveTabTitleAfterPaneClose,
shouldClearLaunchAgentForClosedPane
} from './terminal-pane-close-identity'
reconcileParkedWatcherPtyIds,
resolveParkedTerminalPaneCandidates,
type ParkableTerminalTabModel
} from './terminal-parked-watcher-reconciliation'
import { collapseParkedExitedLeaf, startParkedPtyWatcher } from './terminal-parked-pty-watcher'
import {
capturedPanesByTabId,
disposeParkedTabWatchers,
parkedWatchersByTabId,
type ParkedTabWatcherEntry,
type ParkedTerminalPaneCapture
} from './terminal-parked-watcher-registry'
@ -43,67 +38,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
)
}
}
/**
@ -137,90 +96,99 @@ function startParkedTabWatchers(
tab: ParkableTerminalTabModel,
restoreTitleOnRegister: boolean
): void {
const state = useAppStore.getState()
const panes = resolveParkedTerminalPaneCandidates(tab, state)
const restorePolicy = parkRestorePolicyFromState(state)
const disposersByPtyId = new Map<string, () => void>()
const paneIdByPtyId = new Map<string, number>()
for (const pane of panes) {
const ptyId = pane.ptyId
// Why: re-guard — the tab model can change after the park decision, and legacy non-UUID leaf ids make makePaneKey throw.
if (
!ptyId ||
disposersByPtyId.has(ptyId) ||
!isTerminalLeafId(pane.leafId) ||
!isParkRestorableTerminalPty(ptyId, worktreeId, restorePolicy)
) {
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 }) => {
// 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) {
// Why: a parked PaneManager is gone, so its retained primary cannot remove a dead split leaf from persisted layout.
discardPreHandlerPtyState(ptyId)
collapseParkedExitedLeaf(tab.id, ptyId)
disposersByPtyId.get(ptyId)?.()
disposersByPtyId.delete(ptyId)
return
}
if (hadPrimary) {
// Why: the sole pane's primary owner closes its tab; retire the sidecar to avoid duplicate confirmation.
disposersByPtyId.get(ptyId)?.()
disposersByPtyId.delete(ptyId)
return
}
// Why: keep the empty entry so a pending pinned-close confirm can't let parking restart a watcher on the dead PTY.
disposersByPtyId.get(ptyId)?.()
disposersByPtyId.delete(ptyId)
closeTerminalTab(tab.id, {
// Why: autonomous PTY exit still needs pinned-tab confirmation but must not enter reopen history.
captureRecentlyClosed: false,
// Why: same lifecycle echo as the mounted pty-exit handlers — tag the
// wire so the host can refuse it while its PTY is live, without
// `reason: 'pty-exit'` skipping the pinned confirmation above.
hostCloseReason: 'pty-exit',
lifecyclePtyId: ptyId,
onClosed: () => {
discardPreHandlerPtyState(ptyId)
const entry = parkedWatchersByTabId.get(tab.id)
if (entry?.disposersByPtyId === disposersByPtyId) {
parkedWatchersByTabId.delete(tab.id)
}
},
// Why: cancellation keeps the buffered final frame/exit for the reveal-mounted pane.
onCancel: () => {}
})
})
paneIdByPtyId.set(ptyId, pane.paneId)
disposersByPtyId.set(ptyId, () => {
unsubscribeExit()
disposeWatcher()
})
}
// Why: track even with zero watchers so window.__terminalParkingDebug reflects every parked tab.
parkedWatchersByTabId.set(tab.id, {
const entry: ParkedTabWatcherEntry = {
worktreeId,
tabPtyId: tab.ptyId,
paneIdByPtyId,
disposersByPtyId
paneIdByPtyId: new Map(),
disposersByPtyId: new Map()
}
parkedWatchersByTabId.set(tab.id, entry)
const restorePolicy = parkRestorePolicyFromState(useAppStore.getState())
for (const pane of resolveParkedTerminalPaneCandidates(tab, useAppStore.getState())) {
startParkedPtyWatcher({
worktreeId,
tab,
pane,
entry,
restoreTitleOnRegister,
restorePolicy
})
}
}
function reconcileParkedTabWatchers(
worktreeId: string,
tab: ParkableTerminalTabModel,
entry: ParkedTabWatcherEntry,
restoreTitleOnRegister: boolean
): void {
const state = useAppStore.getState()
const expectedPanes = watchablePanes(worktreeId, tab)
const expectedPtyIds = new Set(expectedPanes.keys())
const reconciliation = reconcileParkedWatcherPtyIds({
currentTabPtyId: tab.ptyId,
entryTabPtyId: entry.tabPtyId,
paneIdByPtyId: entry.paneIdByPtyId,
expectedPtyIds
})
if (reconciliation.restartAll) {
const retainedTitles = reconciliation.retainedPtyIds.flatMap((ptyId) => {
const paneId = entry.paneIdByPtyId.get(ptyId)
const title =
paneId === undefined ? undefined : state.runtimePaneTitlesByTabId[tab.id]?.[paneId]
return paneId !== undefined && title !== undefined ? [{ paneId, title }] : []
})
for (const paneId of reconciliation.retiredPaneIds) {
state.clearRuntimePaneTitle(tab.id, paneId)
}
disposeParkedTabWatchers(tab.id)
for (const { paneId, title } of retainedTitles) {
useAppStore.getState().setRuntimePaneTitle(tab.id, paneId, title)
}
startParkedTabWatchers(worktreeId, tab, restoreTitleOnRegister)
return
}
for (const [ptyId, paneId] of Array.from(entry.paneIdByPtyId)) {
if (expectedPtyIds.has(ptyId)) {
continue
}
entry.paneIdByPtyId.delete(ptyId)
const dispose = entry.disposersByPtyId.get(ptyId)
entry.disposersByPtyId.delete(ptyId)
dispose?.()
state.clearRuntimePaneTitle(tab.id, paneId)
}
const restorePolicy = parkRestorePolicyFromState(useAppStore.getState())
for (const ptyId of reconciliation.addedPtyIds) {
const pane = expectedPanes.get(ptyId)
if (pane) {
startParkedPtyWatcher({
worktreeId,
tab,
pane,
entry,
restoreTitleOnRegister,
restorePolicy
})
}
}
}
function watchablePanes(
worktreeId: string,
tab: ParkableTerminalTabModel
): Map<string, ParkedTerminalPaneCapture> {
const state = useAppStore.getState()
const restorePolicy = parkRestorePolicyFromState(state)
return new Map(
resolveParkedTerminalPaneCandidates(tab, state).flatMap((pane) =>
pane.ptyId &&
isTerminalLeafId(pane.leafId) &&
isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy)
? [[pane.ptyId, pane] as const]
: []
)
)
}
/**
@ -256,39 +224,6 @@ export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string):
return defer
}
// Why: collapse the leaf from the stored layout so reveal can't reattach and resurrect the exited shell.
function collapseParkedExitedLeaf(tabId: string, ptyId: string): void {
const state = useAppStore.getState()
const layout = state.terminalLayoutsByTabId[tabId]
const leafId =
capturedPanesByTabId.get(tabId)?.panes.find((pane) => pane.ptyId === ptyId)?.leafId ??
Object.entries(layout?.ptyIdsByLeafId ?? {}).find(([, boundPtyId]) => boundPtyId === ptyId)?.[0]
if (!leafId) {
return
}
const detached = detachTerminalLayoutLeaf(layout, leafId)
if (detached) {
const terminalTab = Object.values(state.tabsByWorktree)
.flat()
.find((candidate) => candidate.id === tabId)
if (shouldClearLaunchAgentForClosedPane(terminalTab, ptyId)) {
state.clearTabLaunchAgent(tabId)
}
state.setTabLayout(tabId, detached.sourceLayout)
const activeLeafId = detached.sourceLayout.activeLeafId
const activePtyId = activeLeafId
? detached.sourceLayout.ptyIdsByLeafId?.[activeLeafId]
: undefined
const activePaneId = activePtyId
? (parkedWatchersByTabId.get(tabId)?.paneIdByPtyId.get(activePtyId) ?? null)
: null
state.updateTabTitle(
tabId,
resolveTabTitleAfterPaneClose(state.runtimePaneTitlesByTabId[tabId] ?? {}, activePaneId)
)
}
}
function disposeClosedParkedTabWatchers(
tabId: string,
entry: { paneIdByPtyId: ReadonlyMap<string, number> }
@ -297,6 +232,9 @@ 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)
}
@ -336,15 +274,11 @@ export function syncParkedTerminalTabWatchers(args: {
continue
}
const entry = parkedWatchersByTabId.get(tab.id)
if (entry && entry.tabPtyId !== tab.ptyId) {
disposeParkedTabWatchers(tab.id)
}
if (!parkedWatchersByTabId.has(tab.id)) {
startParkedTabWatchers(
args.worktreeId,
tab,
args.restoreTitleOnStartTabIds?.has(tab.id) === true
)
const restoreTitleOnRegister = args.restoreTitleOnStartTabIds?.has(tab.id) === true
if (entry) {
reconcileParkedTabWatchers(args.worktreeId, tab, entry, restoreTitleOnRegister)
} else {
startParkedTabWatchers(args.worktreeId, tab, restoreTitleOnRegister)
}
}
}

View File

@ -0,0 +1,111 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type {
ParkedTabWatcherEntry,
ParkedTerminalPaneCapture
} from './terminal-parked-watcher-registry'
const WORKTREE_ID = 'repo::/worktree'
const TAB_ID = 'tab-1'
const FIRST_PTY_ID = `${WORKTREE_ID}@@session-1`
const OLD_SECOND_PTY_ID = `${WORKTREE_ID}@@session-2`
const NEW_SECOND_PTY_ID = `${WORKTREE_ID}@@session-3`
const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
const startedWatchers: {
pane: ParkedTerminalPaneCapture
dispose: ReturnType<typeof vi.fn>
}[] = []
vi.mock('./terminal-parked-pty-watcher', () => ({
collapseParkedExitedLeaf: vi.fn(),
startParkedPtyWatcher: (args: {
pane: ParkedTerminalPaneCapture
entry: ParkedTabWatcherEntry
}) => {
if (!args.pane.ptyId) {
return
}
const dispose = vi.fn()
startedWatchers.push({ pane: args.pane, dispose })
args.entry.paneIdByPtyId.set(args.pane.ptyId, args.pane.paneId)
args.entry.disposersByPtyId.set(args.pane.ptyId, dispose)
}
}))
vi.mock('./pty-pre-handler-buffer', () => ({
discardPreHandlerPtyState: vi.fn()
}))
const state = {
tabsByWorktree: {},
terminalLayoutsByTabId: {} as Record<string, object>,
runtimePaneTitlesByTabId: {} as Record<string, Record<number, string>>,
settings: null,
runtimeStatusByEnvironmentId: new Map(),
clearRuntimePaneTitle: vi.fn(),
setRuntimePaneTitle: vi.fn()
}
vi.mock('@/store', () => ({
useAppStore: { getState: () => state }
}))
import {
captureParkedTerminalPaneCandidates,
pruneParkedTerminalWatchers,
syncParkedTerminalTabWatchers
} from './terminal-parked-tab-watchers'
function sync(): void {
syncParkedTerminalTabWatchers({
worktreeId: WORKTREE_ID,
tabs: [{ id: TAB_ID, ptyId: FIRST_PTY_ID }],
parkedTabIds: new Set([TAB_ID])
})
}
beforeEach(() => {
state.terminalLayoutsByTabId = {}
state.runtimePaneTitlesByTabId = { [TAB_ID]: { 1: '⠋ Continuing agent', 2: 'Retired shell' } }
startedWatchers.length = 0
vi.clearAllMocks()
})
afterEach(() => {
pruneParkedTerminalWatchers(new Set())
})
it('retains a continuing watcher and title while reconciling a reminted split leaf', () => {
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 }
])
sync()
state.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
}
}
sync()
expect(startedWatchers[0].dispose).not.toHaveBeenCalled()
expect(startedWatchers[1].dispose).toHaveBeenCalledOnce()
expect(startedWatchers[2].pane).toMatchObject({
ptyId: NEW_SECOND_PTY_ID,
paneId: 2,
drivesTabTitle: false
})
expect(state.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2)
expect(state.clearRuntimePaneTitle).not.toHaveBeenCalledWith(TAB_ID, 1)
})

View File

@ -0,0 +1,87 @@
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('surgically reconciles a reminted split leaf without restarting its sibling', () => {
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({
restartAll: false,
addedPtyIds: [NEW_SECOND_PTY_ID],
retainedPtyIds: [FIRST_PTY_ID],
retiredPaneIds: [2]
})
})
})

View File

@ -0,0 +1,91 @@
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>
}): {
restartAll: boolean
addedPtyIds: string[]
retainedPtyIds: string[]
retiredPaneIds: number[]
} {
const retainedPtyIds = Array.from(args.paneIdByPtyId.keys()).filter((ptyId) =>
args.expectedPtyIds.has(ptyId)
)
return {
restartAll: args.entryTabPtyId !== args.currentTabPtyId,
addedPtyIds: Array.from(args.expectedPtyIds).filter((ptyId) => !args.paneIdByPtyId.has(ptyId)),
retainedPtyIds,
retiredPaneIds: Array.from(args.paneIdByPtyId)
.filter(([ptyId]) => !args.expectedPtyIds.has(ptyId))
.map(([, paneId]) => paneId)
}
}

View File

@ -5,6 +5,20 @@ import {
} from './terminal-hidden-view-parking'
import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers'
export type TerminalWorktreeParkingDebugVerdict = {
worktreeId: string
forceParked: boolean
hasActivityTerminalPortal: boolean
hasPendingSpawnWork: boolean
hiddenSinceMs: number | null
isVisible: boolean
ordinaryParkingCovers: boolean
parkCooldownUntilMs: number | null
shouldMeasureHiddenWorktree: boolean
}
let worktreeVerdicts: TerminalWorktreeParkingDebugVerdict[] = []
// Why: ORCA_E2E_TERMINAL_PARKING_DELAY_MS must shrink BOTH the cold-park
// hysteresis and the hot-retain window — recently hidden tabs otherwise sit
// in the hot-retain working set for 5 minutes and never park within a test
@ -36,7 +50,17 @@ export function registerTerminalParkingDebugHandle(): void {
window.__terminalParkingDebug = {
parkDelayMs:
getTerminalParkingPolicyOverrides().coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
parkedTabIds: () => getParkedTerminalWatcherTabIds()
parkedTabIds: () => getParkedTerminalWatcherTabIds(),
retentionLimit: getTerminalParkingPolicyOverrides().retentionLimit ?? null,
worktreeVerdicts: () => worktreeVerdicts
}
}
export function recordTerminalWorktreeParkingDebugVerdicts(
verdicts: TerminalWorktreeParkingDebugVerdict[]
): void {
if (e2eConfig.exposeStore) {
worktreeVerdicts = verdicts
}
}

View File

@ -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 })

View File

@ -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

View File

@ -11,6 +11,7 @@ import {
addBackgroundMountedTerminalWorktree,
applyBackgroundMountTabRestriction,
canDeferColdActivationTabsForHost,
canMountTerminalWorkspaceForStartup,
collectDeferredMountTabIds,
hasRequestedBackgroundTerminalWorktreeMount,
planColdActivationTabDeferral,
@ -22,6 +23,32 @@ import {
shouldMountBackgroundWorktreeTab
} from './background-terminal-worktree-mount'
describe('terminal workspace startup mount gate', () => {
it('waits for hydration unless startup entered degraded mode', () => {
expect(
canMountTerminalWorkspaceForStartup({
workspaceSessionReady: true,
hydrationSucceeded: false,
startupWorktreeRefreshCompleted: false
})
).toBe(false)
expect(
canMountTerminalWorkspaceForStartup({
workspaceSessionReady: true,
hydrationSucceeded: true,
startupWorktreeRefreshCompleted: false
})
).toBe(true)
expect(
canMountTerminalWorkspaceForStartup({
workspaceSessionReady: true,
hydrationSucceeded: false,
startupWorktreeRefreshCompleted: true
})
).toBe(true)
})
})
describe('background terminal mount request registry', () => {
it('replays a request made before the Terminal listener mounts', () => {
takeAllPendingBackgroundTerminalWorktreeMounts()

View File

@ -141,6 +141,16 @@ export function shouldMountBackgroundWorktreeTab(
// until first reveal, parked byte watchers own their side effects meanwhile.
export const COLD_ACTIVATION_TAB_DEFER_THRESHOLD = 4
export function canMountTerminalWorkspaceForStartup(args: {
workspaceSessionReady: boolean
hydrationSucceeded: boolean
startupWorktreeRefreshCompleted: boolean
}): boolean {
return (
args.workspaceSessionReady && (args.hydrationSucceeded || args.startupWorktreeRefreshCompleted)
)
}
export function canDeferColdActivationTabsForHost(args: {
executionHostId: string | null
}): boolean {

View File

@ -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
@ -71,6 +72,8 @@ declare global {
__terminalParkingDebug?: {
parkDelayMs: number
parkedTabIds: () => string[]
retentionLimit: number | null
worktreeVerdicts: () => TerminalWorktreeParkingDebugVerdict[]
}
__monacoEditorE2E?: MonacoE2EProbe
__e2ePtyAppliedSizeReadDelayMs?: number

View File

@ -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

View File

@ -171,7 +171,8 @@
"mobileScopeRejected": "This QR code grants limited (mobile) access. To use the full web app, open the browser access link from Settings → Runtime Environments → Share this Orca server → New Link."
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "Agent Session History is not available for this execution host."
"aiVaultUnavailableForHost": "Agent Session History is not available for this execution host.",
"runtimeEnvironmentManuallyDisconnected": "Runtime environment is manually disconnected."
},
"web": {
"preload": {

View File

@ -148,7 +148,8 @@
"mobileScopeRejected": "Este código QR solo da acceso limitado para móvil. Para usar la app web completa, abre el enlace para navegador desde Ajustes → Servidores remotos de Orca → Comparte este servidor Orca → Nuevo enlace."
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "El historial de sesiones de Agents no está disponible para este host de ejecución."
"aiVaultUnavailableForHost": "El historial de sesiones de Agents no está disponible para este host de ejecución.",
"runtimeEnvironmentManuallyDisconnected": "El entorno de ejecución se ha desconectado manualmente."
},
"web": {
"preload": {

View File

@ -148,7 +148,8 @@
"mobileScopeRejected": "この QR コードは制限付き(モバイル)アクセスを許可します。完全な Web アプリを使うには、設定 → リモート Orca サーバー → この Orca サーバーを共有する → 新規リンク からブラウザー用アクセスリンクを開いてください。"
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "この実行ホストでは Agent セッション履歴を使用できません。"
"aiVaultUnavailableForHost": "この実行ホストでは Agent セッション履歴を使用できません。",
"runtimeEnvironmentManuallyDisconnected": "ランタイム環境は手動で切断されています。"
},
"web": {
"preload": {

View File

@ -148,7 +148,8 @@
"mobileScopeRejected": "이 QR 코드는 제한된(모바일) 액세스 권한만 제공합니다. 전체 웹 앱을 사용하려면 설정 → 원격 Orca 서버 → 이 Orca 서버 공유 → 새 링크에서 브라우저 액세스 링크를 여세요."
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "이 실행 호스트에서는 Agent 세션 기록을 사용할 수 없습니다."
"aiVaultUnavailableForHost": "이 실행 호스트에서는 Agent 세션 기록을 사용할 수 없습니다.",
"runtimeEnvironmentManuallyDisconnected": "런타임 환경이 수동으로 연결 해제되었습니다."
},
"web": {
"preload": {

View File

@ -148,7 +148,8 @@
"mobileScopeRejected": "此二维码仅授予受限(移动端)访问权限。若要使用完整 Web 应用,请从设置 → 远程 Orca 服务器 → 分享此 Orca 服务器 → 新链接打开浏览器访问链接。"
},
"webPreloadApi": {
"aiVaultUnavailableForHost": "此执行主机不支持 Agent 会话历史记录。"
"aiVaultUnavailableForHost": "此执行主机不支持 Agent 会话历史记录。",
"runtimeEnvironmentManuallyDisconnected": "运行时环境已手动断开连接。"
},
"web": {
"preload": {

View File

@ -1,11 +1,24 @@
import { createE2EConfig } from '../../../shared/e2e-config'
const rendererE2EExposeStore = String(import.meta.env.VITE_EXPOSE_STORE) === 'true'
// Why: the paired web API installs after static modules initialize, so its
// build-gated fallback must read the test URL before caching this config.
const rendererE2EQuery =
rendererE2EExposeStore && typeof window !== 'undefined'
? new URLSearchParams(window.location.search)
: null
export const e2eDisableRemoteTerminalStallRecovery =
rendererE2EExposeStore &&
rendererE2EQuery?.get('orcaE2EDisableRemoteTerminalStallRecovery') === '1'
const rendererFallbackE2EConfig = createE2EConfig({
exposeStore: rendererE2EExposeStore,
terminalParkingDelayMs: Number(rendererE2EQuery?.get('orcaE2ETerminalParkingDelayMs')) || null,
terminalRetentionLimit: Number(rendererE2EQuery?.get('orcaE2ETerminalRetentionLimit')) || null
})
// Why: preload owns the Electron startup contract, so renderer code should
// consume the bridged E2E config from window.api instead of reading env vars.
export const e2eConfig =
typeof window !== 'undefined' && window.api?.e2e
? window.api.e2e.getConfig()
: createE2EConfig({
// Why: paired browser E2E has no preload bridge, so its build flag is the only safe test-hook signal.
exposeStore: String(import.meta.env.VITE_EXPOSE_STORE) === 'true'
})
: rendererFallbackE2EConfig

View File

@ -10,14 +10,20 @@ import {
encodeTerminalStreamJson,
encodeTerminalStreamText
} from '../../../shared/terminal-stream-protocol'
import { e2eConfig } from '@/lib/e2e-config'
import { e2eConfig, e2eDisableRemoteTerminalStallRecovery } from '@/lib/e2e-config'
import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
import { deliverTerminalDataWithDeferredCredit } from '@/lib/pane-manager/terminal-delivery-credit'
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,
type RemoteTerminalStreamWatchdog
} from './remote-terminal-stream-watchdog'
type RuntimeEnvironmentSubscriptionHandle = {
unsubscribe: () => void
@ -62,7 +68,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 = {
@ -109,6 +115,8 @@ type RemoteRuntimeMultiplexedTerminalState = {
resyncPendingSend: boolean
resyncTimer: ReturnType<typeof setTimeout> | null
resyncAttempts: number
capacityRejected: boolean
watchdog: RemoteTerminalStreamWatchdog
}
type RemoteRuntimeSnapshotInfo = {
@ -142,7 +150,7 @@ type RemoteRuntimeSnapshotRequest = {
const CONTROL_STREAM_ID = 0
const MAX_REMOTE_TERMINAL_SNAPSHOT_BYTES = 2 * 1024 * 1024
const REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS = 10_000
export const REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS = 10_000
const REMOTE_TERMINAL_RESYNC_TIMEOUT_MS = 10_000
// Why: a truncated recovery means the server is too flooded to serialize;
// retrying once per incoming chunk would stampede it, so back off instead.
@ -163,6 +171,7 @@ type E2eRemoteTerminalMultiplexAckGateSnapshot = {
type E2eRemoteTerminalMultiplexAckGateApi = {
hold: (terminals: string[]) => void
release: () => void
sendInput: (terminal: string, text: string) => number
snapshot: () => E2eRemoteTerminalMultiplexAckGateSnapshot
}
@ -216,6 +225,13 @@ function exposeE2eRemoteTerminalMultiplexAckGate(): void {
}
},
release: releaseE2eRemoteTerminalAcks,
sendInput: (terminal, value) => {
let sent = 0
for (const multiplexer of multiplexers.values()) {
sent += multiplexer.sendInputForE2e(terminal, value)
}
return sent
},
snapshot: getE2eRemoteAckSnapshot
}
}
@ -278,14 +294,37 @@ class RemoteRuntimeTerminalMultiplexer {
resyncInFlight: false,
resyncPendingSend: false,
resyncTimer: null,
resyncAttempts: 0
resyncAttempts: 0,
capacityRejected: false,
watchdog: createRemoteTerminalStreamWatchdog((stall) => {
if (e2eDisableRemoteTerminalStallRecovery) {
state.watchdog.completeCommandResponseProbe()
return
}
recordRendererCrashBreadcrumb('remote_terminal_stream_stall_recovery', {
environmentId: this.environmentId,
expectedSeq: state.expectedSeq ?? null,
inactiveForMs: stall.inactiveForMs,
outstandingDeliveryBytes: stall.outstandingDeliveryBytes,
pendingAckBytes: state.pendingAckBytes,
reason: stall.reason,
resyncAttempts: state.resyncAttempts,
snapshotPending: state.pendingSnapshotRequest !== null,
streamId: state.streamId,
terminal: state.terminal
})
if (stall.reason === 'command-response-timeout') {
this.probeCommandResponse(state)
} else {
this.recoverStalledStream(state)
}
})
}
this.streams.set(streamId, state)
const stream: RemoteRuntimeMultiplexedTerminal = {
streamId,
sendInput: (text) =>
this.sendFrame(streamId, TerminalStreamOpcode.Input, encodeTerminalStreamText(text)),
sendInput: (text) => this.sendInput(state, text),
resize: (cols, rows) =>
this.sendFrame(
streamId,
@ -312,6 +351,7 @@ class RemoteRuntimeTerminalMultiplexer {
close: () => {
if (this.streams.get(streamId) === state) {
discardOutputAcknowledgements(state)
state.watchdog.dispose()
this.sendFrame(streamId, TerminalStreamOpcode.Unsubscribe)
clearResyncTimer(state)
rejectPendingSnapshotRequest(state, 'Remote terminal stream closed.')
@ -451,6 +491,7 @@ class RemoteRuntimeTerminalMultiplexer {
if (!stream) {
return
}
stream.watchdog.recordInbound()
if (event.type === 'subscribed') {
const capabilities =
typeof event.capabilities === 'object' && event.capabilities !== null
@ -466,18 +507,30 @@ class RemoteRuntimeTerminalMultiplexer {
}
} else if (event.type === 'end') {
discardOutputAcknowledgements(stream)
stream.watchdog.dispose()
clearSnapshot(stream)
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.
@ -487,9 +540,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' &&
@ -535,6 +586,7 @@ class RemoteRuntimeTerminalMultiplexer {
}
return
}
stream.watchdog.recordInbound()
if (
frame.opcode === TerminalStreamOpcode.Output ||
frame.opcode === TerminalStreamOpcode.OutputSpan
@ -604,7 +656,9 @@ class RemoteRuntimeTerminalMultiplexer {
return
}
try {
const settleWatchdog = stream.watchdog.beginOutputDelivery(frame.payload.byteLength)
deliverTerminalDataWithDeferredCredit(() => {
settleWatchdog()
if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) {
stream.heldAckBytes += frame.payload.byteLength
} else {
@ -720,11 +774,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
}
@ -732,7 +791,7 @@ class RemoteRuntimeTerminalMultiplexer {
clearResyncTimer(stream)
stream.resyncInFlight = false
stream.resyncPendingSend = false
stream.callbacks.onError?.(decodeTerminalStreamText(frame.payload))
stream.callbacks.onError?.(message)
}
}
@ -873,7 +932,7 @@ class RemoteRuntimeTerminalMultiplexer {
if (stream.pendingSnapshotRequest?.timer === timer) {
clearPendingSnapshotRequest(stream)
reject(new Error('Remote terminal snapshot timed out.'))
this.sendDeferredResyncSnapshot(stream)
this.recoverStalledStream(stream)
}
}, REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS)
if (typeof timer.unref === 'function') {
@ -923,6 +982,59 @@ class RemoteRuntimeTerminalMultiplexer {
)
}
private sendInput(stream: RemoteRuntimeMultiplexedTerminalState, text: string): boolean {
const sent = this.sendFrame(
stream.streamId,
TerminalStreamOpcode.Input,
encodeTerminalStreamText(text)
)
if (sent) {
stream.watchdog.recordCommandInput(text)
}
return sent
}
private probeCommandResponse(stream: RemoteRuntimeMultiplexedTerminalState): void {
void this.requestSnapshot(stream).then(
() => {
if (this.streams.get(stream.streamId) !== stream) {
return
}
stream.watchdog.completeCommandResponseProbe()
recordRendererCrashBreadcrumb('remote_terminal_stream_stall_probe_succeeded', {
environmentId: this.environmentId,
streamId: stream.streamId,
terminal: stream.terminal
})
},
() => {
// Snapshot timeout owns recovery; an explicit host error already proves liveness.
if (this.streams.get(stream.streamId) === stream) {
stream.watchdog.completeCommandResponseProbe()
}
}
)
}
private recoverStalledStream(stream: RemoteRuntimeMultiplexedTerminalState): void {
if (this.streams.get(stream.streamId) !== stream) {
return
}
stream.watchdog.dispose()
discardOutputAcknowledgements(stream)
clearSnapshot(stream)
clearResyncTimer(stream)
rejectPendingSnapshotRequest(stream, 'Remote terminal stream stopped responding.')
this.streams.delete(stream.streamId)
this.sendFrame(stream.streamId, TerminalStreamOpcode.Unsubscribe)
if (stream.callbacks.onTransportClose) {
stream.callbacks.onTransportClose({ recoverable: true })
} else {
stream.callbacks.onError?.('Remote terminal stream stopped responding.')
}
this.closeIfIdle()
}
private queueOutputAcknowledgement(
stream: RemoteRuntimeMultiplexedTerminalState,
bytes: number
@ -969,6 +1081,16 @@ class RemoteRuntimeTerminalMultiplexer {
return released
}
sendInputForE2e(terminal: string, text: string): number {
let sent = 0
for (const stream of this.streams.values()) {
if (stream.terminal === terminal && this.sendInput(stream, text)) {
sent += 1
}
}
return sent
}
private sendFrame(
streamId: number,
opcode: TerminalStreamOpcode,
@ -1025,6 +1147,7 @@ class RemoteRuntimeTerminalMultiplexer {
this.releaseIfCurrent(this.environmentId, this)
for (const stream of streams) {
discardOutputAcknowledgements(stream)
stream.watchdog.dispose()
clearSnapshot(stream)
clearResyncTimer(stream)
rejectPendingSnapshotRequest(stream, message ?? 'Remote runtime connection closed.')

View File

@ -0,0 +1,277 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
decodeTerminalStreamJson,
encodeTerminalStreamFrame,
encodeTerminalStreamJson,
encodeTerminalStreamText
} from '../../../shared/terminal-stream-protocol'
import {
REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS,
REMOTE_TERMINAL_DELIVERY_STALL_TIMEOUT_MS
} from './remote-terminal-stream-watchdog'
describe('remote terminal stalled stream recovery', () => {
const sendBinary = vi.fn()
const unsubscribe = vi.fn()
const recordBreadcrumb = vi.fn()
let callbacks: {
onResponse: (response: unknown) => void
onBinary: (bytes: Uint8Array<ArrayBufferLike>) => void
} | null = null
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
sendBinary.mockReset()
unsubscribe.mockReset()
recordBreadcrumb.mockReset()
callbacks = null
vi.stubGlobal('window', {
api: {
crashReports: {
recordBreadcrumb
},
runtimeEnvironments: {
subscribe: vi.fn(async (_args, nextCallbacks) => {
callbacks = nextCallbacks
queueMicrotask(() => {
callbacks?.onResponse({ ok: true, result: { type: 'ready' } })
})
return { unsubscribe, sendBinary }
})
}
}
})
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('restarts only the stream whose renderer delivery credit never settles', async () => {
const { getRemoteRuntimeTerminalMultiplexer } =
await import('./remote-runtime-terminal-multiplexer')
const { takeCurrentTerminalDeliveryCredit } =
await import('../lib/pane-manager/terminal-delivery-credit')
const stalledCredits: (() => void)[] = []
const onTransportClose = vi.fn()
const multiplexer = getRemoteRuntimeTerminalMultiplexer('windows-test')
const stalled = await multiplexer.subscribeTerminal({
terminal: 'term-stalled',
client: { id: 'mac-viewer', type: 'desktop' },
callbacks: {
onData: () => {
const credit = takeCurrentTerminalDeliveryCredit()
if (credit) {
stalledCredits.push(credit)
}
},
onSnapshot: vi.fn(),
onTransportClose
}
})
const healthy = await multiplexer.subscribeTerminal({
terminal: 'term-healthy',
client: { id: 'mac-viewer', type: 'desktop' },
callbacks: { onData: vi.fn(), onSnapshot: vi.fn() }
})
sendBinary.mockClear()
emitOutput(stalled.streamId, 'host output that xterm never parses')
expect(stalledCredits).toHaveLength(1)
await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_DELIVERY_STALL_TIMEOUT_MS)
expect(onTransportClose).toHaveBeenCalledWith({ recoverable: true })
expect(sentUnsubscribeStreamIds()).toEqual([stalled.streamId])
expect(unsubscribe).not.toHaveBeenCalled()
expect(recordBreadcrumb).toHaveBeenCalledWith({
name: 'remote_terminal_stream_stall_recovery',
data: expect.objectContaining({
inactiveForMs: REMOTE_TERMINAL_DELIVERY_STALL_TIMEOUT_MS,
outstandingDeliveryBytes: 'host output that xterm never parses'.length,
reason: 'delivery-credit-timeout',
streamId: stalled.streamId,
terminal: 'term-stalled'
})
})
healthy.close()
})
it('probes then restarts a stream when an entered command receives no frames', async () => {
const { getRemoteRuntimeTerminalMultiplexer, REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS } =
await import('./remote-runtime-terminal-multiplexer')
const onTransportClose = vi.fn()
const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({
terminal: 'term-silent',
client: { id: 'mac-viewer', type: 'desktop' },
callbacks: { onData: vi.fn(), onSnapshot: vi.fn(), onTransportClose }
})
sendBinary.mockClear()
expect(stream.sendInput('ls\r')).toBe(true)
await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS)
expect(onTransportClose).not.toHaveBeenCalled()
expect(sentFrames(TerminalStreamOpcode.SnapshotRequest)).toHaveLength(1)
await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS)
expect(onTransportClose).toHaveBeenCalledWith({ recoverable: true })
expect(sentUnsubscribeStreamIds()).toEqual([stream.streamId])
expect(recordBreadcrumb).toHaveBeenCalledWith({
name: 'remote_terminal_stream_stall_recovery',
data: expect.objectContaining({
inactiveForMs: REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS,
outstandingDeliveryBytes: 0,
reason: 'command-response-timeout',
streamId: stream.streamId,
terminal: 'term-silent'
})
})
})
it('keeps a silent responsive stream after its authoritative snapshot probe', async () => {
const { getRemoteRuntimeTerminalMultiplexer } =
await import('./remote-runtime-terminal-multiplexer')
const onTransportClose = vi.fn()
const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({
terminal: 'term-password',
client: { id: 'mac-viewer', type: 'desktop' },
callbacks: { onData: vi.fn(), onSnapshot: vi.fn(), onTransportClose }
})
sendBinary.mockClear()
expect(stream.sendInput('secret\r')).toBe(true)
await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS)
const firstRequest = sentFrames(TerminalStreamOpcode.SnapshotRequest)[0]
const firstRequestPayload = firstRequest
? decodeTerminalStreamJson<{ requestId: number }>(firstRequest.payload)
: null
expect(firstRequestPayload?.requestId).toBeTypeOf('number')
emitRequestedSnapshot(stream.streamId, firstRequestPayload?.requestId ?? 0)
await vi.advanceTimersByTimeAsync(0)
expect(onTransportClose).not.toHaveBeenCalled()
expect(sentUnsubscribeStreamIds()).toEqual([])
expect(stream.sendInput('silent-command\r')).toBe(true)
await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS)
expect(sentFrames(TerminalStreamOpcode.SnapshotRequest)).toHaveLength(2)
stream.close()
})
it('keeps a command stream when any host frame proves it is responsive', async () => {
const { getRemoteRuntimeTerminalMultiplexer } =
await import('./remote-runtime-terminal-multiplexer')
const onTransportClose = vi.fn()
const onData = vi.fn()
const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({
terminal: 'term-responsive',
client: { id: 'mac-viewer', type: 'desktop' },
callbacks: { onData, onSnapshot: vi.fn(), onTransportClose }
})
sendBinary.mockClear()
expect(stream.sendInput('ls\r')).toBe(true)
emitOutput(stream.streamId, 'responsive output')
await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS)
expect(onData).toHaveBeenCalledWith('responsive output', {
seq: 'responsive output'.length,
rawLength: 'responsive output'.length
})
expect(onTransportClose).not.toHaveBeenCalled()
expect(sentUnsubscribeStreamIds()).toEqual([])
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({
opcode: TerminalStreamOpcode.Output,
streamId,
seq: text.length,
payload: encodeTerminalStreamText(text)
})
)
}
function emitRequestedSnapshot(streamId: number, requestId: number): void {
callbacks?.onBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.SnapshotStart,
streamId,
seq: 1,
payload: encodeTerminalStreamJson({
kind: 'scrollback',
requestId,
cols: 80,
rows: 24
})
})
)
callbacks?.onBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.SnapshotEnd,
streamId,
seq: 2,
payload: new Uint8Array()
})
)
}
function sentFrames(opcode: TerminalStreamOpcode) {
return sendBinary.mock.calls.flatMap(([bytes]) => {
const frame = decodeTerminalStreamFrame(bytes)
return frame?.opcode === opcode ? [frame] : []
})
}
function sentUnsubscribeStreamIds(): number[] {
return sentFrames(TerminalStreamOpcode.Unsubscribe).map((frame) => frame.streamId)
}
})

View File

@ -0,0 +1,108 @@
export const REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS = 10_000
export const REMOTE_TERMINAL_DELIVERY_STALL_TIMEOUT_MS = 30_000
export type RemoteTerminalStreamStall = {
inactiveForMs: number
outstandingDeliveryBytes: number
reason: 'command-response-timeout' | 'delivery-credit-timeout'
}
export type RemoteTerminalStreamWatchdog = {
beginOutputDelivery: (bytes: number) => () => void
completeCommandResponseProbe: () => void
recordCommandInput: (text: string) => void
recordInbound: () => void
dispose: () => void
}
export function createRemoteTerminalStreamWatchdog(
onStall: (stall: RemoteTerminalStreamStall) => void
): RemoteTerminalStreamWatchdog {
let responseTimer: ReturnType<typeof setTimeout> | null = null
let deliveryTimer: ReturnType<typeof setTimeout> | null = null
let outstandingDeliveryBytes = 0
let lastInboundAtMs = Date.now()
let commandResponseProbePending = false
let disposed = false
const clearResponseTimer = (): void => {
if (responseTimer) {
clearTimeout(responseTimer)
responseTimer = null
}
}
const clearDeliveryTimer = (): void => {
if (deliveryTimer) {
clearTimeout(deliveryTimer)
deliveryTimer = null
}
}
const trip = (reason: RemoteTerminalStreamStall['reason']): void => {
if (disposed) {
return
}
clearResponseTimer()
if (reason === 'command-response-timeout') {
commandResponseProbePending = true
} else {
disposed = true
clearDeliveryTimer()
}
onStall({
inactiveForMs: Math.max(0, Date.now() - lastInboundAtMs),
outstandingDeliveryBytes,
reason
})
}
const armDeliveryTimer = (): void => {
clearDeliveryTimer()
if (outstandingDeliveryBytes <= 0 || disposed) {
return
}
deliveryTimer = setTimeout(
() => trip('delivery-credit-timeout'),
REMOTE_TERMINAL_DELIVERY_STALL_TIMEOUT_MS
)
}
return {
beginOutputDelivery(bytes) {
outstandingDeliveryBytes += bytes
if (!deliveryTimer) {
armDeliveryTimer()
}
let settled = false
return () => {
if (settled || disposed) {
return
}
settled = true
outstandingDeliveryBytes = Math.max(0, outstandingDeliveryBytes - bytes)
armDeliveryTimer()
}
},
completeCommandResponseProbe() {
commandResponseProbePending = false
},
recordCommandInput(text) {
if (disposed || commandResponseProbePending || responseTimer || !/[\r\n]/u.test(text)) {
return
}
responseTimer = setTimeout(
() => trip('command-response-timeout'),
REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS
)
},
recordInbound() {
lastInboundAtMs = Date.now()
clearResponseTimer()
},
dispose() {
disposed = true
commandResponseProbePending = false
clearResponseTimer()
clearDeliveryTimer()
outstandingDeliveryBytes = 0
}
}
}

View File

@ -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()

View File

@ -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' ||

View File

@ -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)
}
}
})

View File

@ -235,6 +235,7 @@ describe('web runtime environment identity', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.doUnmock('./web-runtime-client')
})
it('does not resolve an old server selector through a differently keyed server', async () => {
@ -359,6 +360,177 @@ describe('web runtime environment identity', () => {
globals.window.api.runtimeEnvironments.resolve({ selector: 'web-server-old' })
).rejects.toThrow('Unknown Orca runtime environment: web-server-old')
})
it('keeps pairing while manual disconnect fences passive reconnects', async () => {
const calls: string[] = []
const close = vi.fn()
let clientCount = 0
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
constructor() {
clientCount += 1
}
call(method: string): Promise<RuntimeRpcResponse<unknown>> {
calls.push(method)
return Promise.resolve({
id: method,
ok: true,
result: { runtimeId: 'runtime-1' },
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {
close()
}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await expect(
globals.window.api.runtimeEnvironments.getStatus({ selector: 'web-server-a' })
).resolves.toMatchObject({ ok: true })
await globals.window.api.runtimeEnvironments.disconnect({ selector: 'web-server-a' })
await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([
{ id: 'web-server-a' }
])
await expect(
globals.window.api.runtimeEnvironments.getStatus({ selector: 'web-server-a' })
).resolves.toMatchObject({
ok: false,
error: { code: 'runtime_manually_disconnected' }
})
await expect(
globals.window.api.runtimeEnvironments.call({
selector: 'web-server-a',
method: 'repos.list'
})
).resolves.toMatchObject({
ok: false,
error: { code: 'runtime_manually_disconnected' }
})
await expect(
globals.window.api.runtimeEnvironments.subscribe(
{ selector: 'web-server-a', method: 'terminal.subscribe' },
{ onResponse: vi.fn() }
)
).rejects.toThrow('runtime_manually_disconnected')
expect(clientCount).toBe(1)
expect(calls).toEqual(['status.get'])
expect(close).toHaveBeenCalledOnce()
await expect(
globals.window.api.runtimeEnvironments.connect({ selector: 'web-server-a' })
).resolves.toMatchObject({ ok: true })
expect(clientCount).toBe(2)
expect(calls).toEqual(['status.get', 'status.get'])
})
it('fences a web runtime response that completes after manual disconnect', async () => {
let resolveCall!: (response: RuntimeRpcResponse<unknown>) => void
const pendingCall = new Promise<RuntimeRpcResponse<unknown>>((resolve) => {
resolveCall = resolve
})
const call = vi.fn(() => pendingCall)
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 status = globals.window.api.runtimeEnvironments.getStatus({
selector: 'web-server-a'
})
await vi.waitFor(() => expect(call).toHaveBeenCalledOnce())
await globals.window.api.runtimeEnvironments.disconnect({ selector: 'web-server-a' })
resolveCall({
id: 'status.get',
ok: true,
result: { runtimeId: 'runtime-1' },
_meta: { runtimeId: 'runtime-1' }
})
await expect(status).resolves.toMatchObject({
ok: false,
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(
Array.from({ length: 8 }, () =>
expect.objectContaining({
ok: false,
error: expect.objectContaining({ code: 'runtime_manually_disconnected' })
})
)
)
}
)
})
describe('web browser-local port capability', () => {

View File

@ -148,6 +148,14 @@ const SESSION_STORAGE_KEY = 'orca.web.workspaceSession.v1'
const ONBOARDING_STORAGE_KEY = 'orca.web.onboarding.v1'
const GITHUB_CACHE_STORAGE_KEY = 'orca.web.githubCache.v1'
const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1'
// Why: paired web clients lack Electron env/preload state; the E2E build gate keeps URL overrides out of releases.
const webE2EExposeStore = String(import.meta.env.VITE_EXPOSE_STORE) === 'true'
const webE2EQuery = webE2EExposeStore ? new URLSearchParams(window.location.search) : null
const webE2EConfig = createE2EConfig({
exposeStore: webE2EExposeStore,
terminalParkingDelayMs: Number(webE2EQuery?.get('orcaE2ETerminalParkingDelayMs')) || null,
terminalRetentionLimit: Number(webE2EQuery?.get('orcaE2ETerminalRetentionLimit')) || null
})
// Why: paired clients need parity for large dev sessions; the runtime default stays capped for lower-level RPC callers.
const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000
const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS
@ -160,6 +168,7 @@ const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000
let activeEnvironment: StoredWebRuntimeEnvironment | null = readStoredWebRuntimeEnvironment()
let activeClient: WebRuntimeClient | null = null
let activeClientEnvironmentId: string | null = null
const manuallyDisconnectedEnvironmentIds = new Set<string>()
let cachedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null
let cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null
const runtimeCallQueuePool = new RuntimeRpcCallQueuePool()
@ -648,7 +657,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
orgMemberRemove: async () => ({ status: 'unconfigured' })
},
e2e: {
getConfig: () => createE2EConfig({})
getConfig: () => webE2EConfig
},
settings: {
get: async () => getRuntimeBackedStoredSettings(),
@ -1348,6 +1357,7 @@ function createRuntimeEnvironmentsApi(): NonNullable<Partial<PreloadApi>['runtim
const previousEnvironment = activeEnvironment
closeActiveRuntimeClients()
activeEnvironment = createStoredWebRuntimeEnvironment({ name, offer, previousEnvironment })
manuallyDisconnectedEnvironmentIds.clear()
saveStoredWebRuntimeEnvironment(activeEnvironment)
return { environment: redactStoredWebRuntimeEnvironment(activeEnvironment) }
},
@ -1356,17 +1366,29 @@ function createRuntimeEnvironmentsApi(): NonNullable<Partial<PreloadApi>['runtim
remove: async ({ selector }) => {
const environment = resolveEnvironment(selector)
if (activeEnvironment?.id === environment.id) {
disconnectActiveRuntimeEnvironment()
removeActiveRuntimeEnvironment()
}
manuallyDisconnectedEnvironmentIds.delete(environment.id)
return { removed: redactStoredWebRuntimeEnvironment(environment) }
},
disconnect: async ({ selector }) => {
const environment = resolveEnvironment(selector)
if (activeEnvironment?.id === environment.id) {
manuallyDisconnectedEnvironmentIds.add(environment.id)
disconnectActiveRuntimeEnvironment()
}
return { disconnected: redactStoredWebRuntimeEnvironment(environment) }
},
connect: ({ selector, timeoutMs }) => {
const environment = resolveEnvironment(selector)
manuallyDisconnectedEnvironmentIds.delete(environment.id)
return callEnvironmentEnvelope<RuntimeStatus>(
environment.id,
'status.get',
undefined,
timeoutMs
)
},
getStatus: ({ selector, timeoutMs }) =>
callEnvironmentEnvelope<RuntimeStatus>(selector, 'status.get', undefined, timeoutMs),
call: ({ selector, method, params, timeoutMs }) =>
@ -1374,7 +1396,12 @@ function createRuntimeEnvironmentsApi(): NonNullable<Partial<PreloadApi>['runtim
subscribe: async ({ selector, method, params, timeoutMs }, callbacks) => {
const environment = resolveEnvironment(selector)
const client = getClientForEnvironment(environment)
return client.subscribe(method, params, callbacks, { timeoutMs })
const subscription = await client.subscribe(method, params, callbacks, { timeoutMs })
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
subscription.unsubscribe()
throw new Error('runtime_manually_disconnected')
}
return subscription
}
}
}
@ -3139,9 +3166,18 @@ async function callRuntimeEnvelope<TResult = unknown>(
timeoutMs?: number
): Promise<RuntimeRpcResponse<TResult>> {
const environment = requireActiveEnvironment()
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () =>
getClientForEnvironment(environment).call(method, params, { timeoutMs })
)
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
return manuallyDisconnectedResponse(environment)
}
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)
}
updateEnvironmentFromResponse(environment, response)
return response as RuntimeRpcResponse<TResult>
}
@ -3153,9 +3189,18 @@ async function callEnvironmentEnvelope<TResult = unknown>(
timeoutMs?: number
): Promise<RuntimeRpcResponse<TResult>> {
const environment = resolveEnvironment(selector)
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () =>
getClientForEnvironment(environment).call(method, params, { timeoutMs })
)
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
return manuallyDisconnectedResponse(environment)
}
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)
}
updateEnvironmentFromResponse(environment, response)
return response as RuntimeRpcResponse<TResult>
}
@ -3332,6 +3377,9 @@ async function getRemoteRuntimeStatus(): Promise<RuntimeStatus> {
}
function getClientForEnvironment(environment: StoredWebRuntimeEnvironment): WebRuntimeClient {
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
throw new Error('runtime_manually_disconnected')
}
if (!activeClient || activeClientEnvironmentId !== environment.id) {
activeClient?.close()
activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment))
@ -3349,10 +3397,31 @@ function closeActiveRuntimeClients(): void {
function disconnectActiveRuntimeEnvironment(): void {
closeActiveRuntimeClients()
}
function removeActiveRuntimeEnvironment(): void {
disconnectActiveRuntimeEnvironment()
clearStoredWebRuntimeEnvironment()
activeEnvironment = null
}
function manuallyDisconnectedResponse(
environment: StoredWebRuntimeEnvironment
): RuntimeRpcResponse<never> {
return {
id: 'runtime.manualDisconnect',
ok: false,
error: {
code: 'runtime_manually_disconnected',
message: translate(
'auto.web.webPreloadApi.runtimeEnvironmentManuallyDisconnected',
'Runtime environment is manually disconnected.'
)
},
_meta: { runtimeId: environment.runtimeId }
}
}
function resolveEnvironment(selector: string): StoredWebRuntimeEnvironment {
const environment = requireActiveEnvironment()
if (selector === environment.id || selector === environment.name || selector === 'active') {

View File

@ -54,6 +54,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
@ -96,6 +99,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,

View File

@ -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).

View File

@ -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
// 128 covers large paired clients while fixed ACK windows and per-stream queues bound pressure.
export const TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION = 128
export const TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION = 32
export const TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR = 'terminal_stream_limit_exceeded'

View File

@ -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()
}
})

View File

@ -0,0 +1,226 @@
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 type { RuntimeTerminalRead } from '../../src/shared/runtime-types'
import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id'
import { expect, test } from './helpers/orca-app'
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
import { launchPairedWebClient } from './helpers/paired-electron-client'
import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal'
const MIN_EXHAUSTED_ACK_BYTES = 400 * 1024
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-headless-stalled-stream-'))
const fixturePath = path.join(scratch, 'headless-stalled-stream.mjs')
writeFileSync(
fixturePath,
[
"process.stdout.write('HEADLESS_STALL_READY\\r\\n')",
"process.stdin.setEncoding('utf8')",
"let pending = ''",
"process.stdin.on('data', (data) => {",
' pending += data',
' const commands = pending.split(/\\r\\n|\\r|\\n/)',
' pending = commands.pop() ?? ""',
' for (const input of commands) {',
" if (input === 'GO') {",
" for (let row = 0; row < 16_000; row += 1) process.stdout.write(`headless-${row}-${'x'.repeat(80)}\\r\\n`)",
" process.stdout.write('HEADLESS_FLOOD_COMPLETE\\r\\n')",
' continue',
' }',
' process.stdout.write(`LIVE:${input}\\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(): string {
const command = [process.execPath, fixturePath]
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>
}
test('recovers an ACK-starved stream from an isolated headless Orca host @headful', async ({
testRepoPath
}) => {
test.setTimeout(180_000)
const host = await launchHeadlessPairedRuntimeHost()
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' })
try {
await client.page.locator('[data-worktree-sidebar]').waitFor({
state: 'visible',
timeout: 30_000
})
} catch {
const boot = await client.page.evaluate(() => ({
bodyChildren: document.body?.children.length ?? 0,
bodyTextLength: document.body?.innerText.length ?? 0,
hasApi: Boolean(window.api),
hasRoot: Boolean(document.querySelector('#root')),
hasStore: Boolean(window.__store),
readyState: document.readyState,
title: document.title
}))
throw new Error(`Headless paired web client did not boot: ${JSON.stringify(boot)}`)
}
await expect
.poll(
() =>
client.page.evaluate(() => {
const worktrees = window.__store?.getState().allWorktrees() ?? []
return worktrees[0]?.id ?? null
}),
{ timeout: 30_000 }
)
.not.toBeNull()
const worktreeId = await client.page.evaluate(
() => window.__store?.getState().allWorktrees()[0]?.id ?? null
)
if (!worktreeId) {
throw new Error('Headless paired client did not receive the host worktree')
}
const created = await callRuntime<{
tab: { parentTabId: string; terminal: string | null }
}>(client.page, 'session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
command: fixtureCommand(),
activate: false,
select: false,
navigation: 'caller'
})
terminal = created.tab.terminal
if (!terminal) {
throw new Error('Headless paired host did not publish the fixture terminal')
}
const webTabId = 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="${webTabId}"]`)
await expect(tab).toBeVisible({ timeout: 30_000 })
await tab.click()
const originalPtyId = await waitForActivePanePtyId(client.page, 30_000)
await expect
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
.toContain('HEADLESS_STALL_READY')
await client.page.evaluate((target) => {
const gate = (
window as typeof window & {
__remoteTerminalMultiplexAckGate?: { hold: (terminals: string[]) => void }
}
).__remoteTerminalMultiplexAckGate
if (!gate) {
throw new Error('Remote terminal multiplex ACK gate is unavailable')
}
gate.hold([target])
}, terminal)
const textarea = client.page.locator('.xterm-helper-textarea:visible').first()
await textarea.focus()
await client.page.keyboard.type('GO')
await client.page.keyboard.press('Enter')
await expect
.poll(
() =>
client.page.evaluate(() => {
const gate = (
window as typeof window & {
__remoteTerminalMultiplexAckGate?: {
snapshot: () => { heldAckChars: number }
}
}
).__remoteTerminalMultiplexAckGate
return gate?.snapshot().heldAckChars ?? 0
}),
{ timeout: 30_000 }
)
.toBeGreaterThan(MIN_EXHAUSTED_ACK_BYTES)
await expect
.poll(
async () => {
const result = await callRuntime<{ terminal: RuntimeTerminalRead }>(
client.page,
'terminal.read',
{ terminal }
)
return result.terminal.tail.join('\n')
},
{ timeout: 30_000 }
)
.toContain('HEADLESS_FLOOD_COMPLETE')
const marker = `HEADLESS_RECOVERED_${Date.now()}`
await callRuntime(client.page, 'terminal.send', {
terminal,
text: marker,
enter: true,
client: { id: 'headless-stalled-stream-e2e', type: 'desktop' }
})
expect(await getTerminalContent(client.page)).not.toContain(marker)
expect(
await client.page.evaluate(
({ target }) => {
const gate = (
window as typeof window & {
__remoteTerminalMultiplexAckGate?: {
sendInput: (terminal: string, text: string) => number
}
}
).__remoteTerminalMultiplexAckGate
return gate?.sendInput(target, '\r') ?? 0
},
{ target: terminal }
)
).toBe(1)
await expect
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
.toContain(`LIVE:${marker}`)
expect(await waitForActivePanePtyId(client.page, 30_000)).toBe(originalPtyId)
await expect(tab).toHaveAttribute('data-active', 'true')
} finally {
await client.page
.evaluate(() => {
;(
window as typeof window & {
__remoteTerminalMultiplexAckGate?: { release: () => void }
}
).__remoteTerminalMultiplexAckGate?.release()
})
.catch(() => undefined)
if (terminal) {
await callRuntime(client.page, 'terminal.closeTab', { terminal }).catch(() => undefined)
}
await client.dispose()
await host.dispose()
}
})

View File

@ -0,0 +1,149 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { _electron as electron, type ElectronApplication } from '@stablyai/playwright-test'
import { RuntimeClient } from '../../../src/cli/runtime/client'
import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile'
import { getOrcaElectronLaunchArgs } from './electron-launch-args'
import { cleanupE2EDaemons, closeElectronAppForE2E } from './electron-process-shutdown'
import {
assertElectronResolvedIsolatedHome,
createElectronHomeIsolation
} from './electron-home-isolation'
import type { RuntimeDesktopPairingOffer } from './paired-electron-client'
type ServeReady = {
type?: unknown
pairing?: {
available?: unknown
url?: unknown
webClientUrl?: unknown
}
}
export type HeadlessPairedRuntimeHost = {
app: ElectronApplication
client: RuntimeClient
dispose: () => Promise<void>
offer: RuntimeDesktopPairingOffer
}
async function readPairingOffer(app: ElectronApplication): Promise<RuntimeDesktopPairingOffer> {
const child = app.process()
const stdout = child.stdout
if (!stdout) {
throw new Error('Headless runtime stdout is unavailable')
}
return new Promise((resolve, reject) => {
let buffered = ''
const timeout = setTimeout(() => {
cleanup()
reject(new Error('Headless runtime did not publish pairing readiness'))
}, 60_000)
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()
const lines = buffered.split(/\r?\n/)
buffered = lines.pop() ?? ''
for (const line of lines) {
let readiness: ServeReady
try {
readiness = JSON.parse(line) as ServeReady
} catch {
continue
}
const pairing = readiness.pairing
if (
readiness.type !== 'orca_server_ready' ||
pairing?.available !== true ||
typeof pairing.url !== 'string' ||
typeof pairing.webClientUrl !== 'string'
) {
continue
}
cleanup()
resolve({ pairingUrl: pairing.url, webClientUrl: pairing.webClientUrl })
return
}
}
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-'))
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
)
const offer = await readPairingOffer(app)
return {
app,
client: new RuntimeClient(userDataDir, 5_000),
offer,
dispose: async () => {
await closeElectronAppForE2E(app)
await cleanupE2EDaemons(userDataDir)
rmSync(userDataDir, { recursive: true, force: true })
}
}
} catch (error) {
try {
if (app) {
await closeElectronAppForE2E(app)
}
await cleanupE2EDaemons(userDataDir)
} finally {
rmSync(userDataDir, { recursive: true, force: true })
}
throw error
}
}

View File

@ -21,6 +21,7 @@ import {
replaceRuntimePairingInPlace,
type SameIdPairingReplacement
} from './nested-runtime-same-id-pairing'
import { createPairedWebClientUrl, type PairedWebClientOptions } from './paired-web-client-url'
export type { SameIdPairingReplacement } from './nested-runtime-same-id-pairing'
@ -92,35 +93,49 @@ export async function createRuntimeDesktopPairingOffer(
export async function launchPairedWebClient(
hubApp: ElectronApplication,
offer: RuntimeDesktopPairingOffer
offer: RuntimeDesktopPairingOffer,
options: PairedWebClientOptions = {}
): Promise<PairedWebClient> {
if (!offer.webClientUrl) {
throw new Error('HUB runtime did not provide a paired web client URL')
}
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: offer.webClientUrl
const clientUrl = createPairedWebClientUrl(offer.webClientUrl, options)
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
await page.locator('[data-worktree-sidebar]').waitFor({ state: 'visible', timeout: 30_000 })
return { page, dispose: () => page.close() }
return { page, dispose: () => page?.close() ?? Promise.resolve() }
} catch (error) {
void pagePromise.catch(() => undefined)
await page?.close().catch(() => undefined)
throw error
}
}
export async function launchPairedElectronClient(

View File

@ -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 })
}
}

View File

@ -0,0 +1,284 @@
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)
let maxLagMs = Number.POSITIVE_INFINITY
let lagProbeStopped = false
try {
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
})
maxLagMs = await lagProbe.evaluate((probe) => probe.stop())
lagProbeStopped = true
} finally {
if (!lagProbeStopped) {
await lagProbe.evaluate((probe) => probe.stop()).catch(() => undefined)
}
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()
}
}

View File

@ -0,0 +1,23 @@
export type PairedWebClientOptions = {
disableRemoteTerminalStallRecovery?: boolean
terminalParkingDelayMs?: number
terminalRetentionLimit?: number
waitForWorkspace?: boolean
}
export function createPairedWebClientUrl(
offerUrl: string,
options: PairedWebClientOptions
): string {
const clientUrl = new URL(offerUrl)
if (options.disableRemoteTerminalStallRecovery) {
clientUrl.searchParams.set('orcaE2EDisableRemoteTerminalStallRecovery', '1')
}
if (options.terminalParkingDelayMs !== undefined) {
clientUrl.searchParams.set('orcaE2ETerminalParkingDelayMs', `${options.terminalParkingDelayMs}`)
}
if (options.terminalRetentionLimit !== undefined) {
clientUrl.searchParams.set('orcaE2ETerminalRetentionLimit', `${options.terminalRetentionLimit}`)
}
return clientUrl.href
}

View File

@ -0,0 +1,34 @@
import { test } from './helpers/orca-app'
import {
createRuntimeDesktopPairingOffer,
launchPairedWebClient
} from './helpers/paired-electron-client'
import { runPairedTerminalParkingOracle } from './helpers/paired-terminal-parking-oracle'
test('ordinary-parks paired terminals and restores authoritative host scrollback @headful', async ({
electronApp,
orcaPage
}) => {
test.setTimeout(240_000)
const seed = await orcaPage.evaluate(() => {
const state = window.__store?.getState()
const worktrees = state?.allWorktrees() ?? []
const active = worktrees.find((worktree) => worktree.id === state?.activeWorktreeId)
if (!active) {
throw new Error('Paired retention host has no active seeded worktree')
}
return { repoId: active.repoId, worktreeIds: worktrees.map((worktree) => worktree.id) }
})
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
const client = await launchPairedWebClient(electronApp, offer, {
terminalParkingDelayMs: 100
})
try {
await runPairedTerminalParkingOracle(client.page, {
fallbackWorktreeId: seed.worktreeIds[0]!,
repoId: seed.repoId
})
} finally {
await client.dispose()
}
})

View File

@ -0,0 +1,251 @@
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 type { RuntimeTerminalRead } from '../../src/shared/runtime-types'
import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id'
import { expect, test } from './helpers/orca-app'
import {
createRuntimeDesktopPairingOffer,
launchPairedWebClient
} from './helpers/paired-electron-client'
import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal'
const MIN_EXHAUSTED_ACK_BYTES = 400 * 1024
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-stalled-stream-'))
const fixturePath = path.join(scratch, 'stalled-stream-terminal.mjs')
writeFileSync(
fixturePath,
[
"process.stdout.write('PAIRED_STALL_READY\\r\\n')",
"process.stdin.setEncoding('utf8')",
"let pending = ''",
"process.stdin.on('data', (data) => {",
' pending += data',
' const commands = pending.split(/\\r\\n|\\r|\\n/)',
' pending = commands.pop() ?? ""',
' for (const input of commands) {',
" if (input === 'GO') {",
" for (let row = 0; row < 16_000; row += 1) process.stdout.write(`flood-${row}-${'x'.repeat(80)}\\r\\n`)",
" process.stdout.write('HOST_FLOOD_COMPLETE\\r\\n')",
' continue',
' }',
' process.stdout.write(`LIVE:${input}\\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(): string {
const command = [process.execPath, fixturePath]
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>
}
test('restarts one ACK-starved paired terminal stream without replacing its PTY @headful', async ({
electronApp,
orcaPage
}) => {
test.setTimeout(120_000)
const liveMarker = `PAIRED_STALL_RECOVERED_${Date.now()}`
const worktree = await orcaPage.evaluate(() => {
const state = window.__store?.getState()
const id = state?.activeWorktreeId
const active = state?.allWorktrees().find((candidate) => candidate.id === id)
if (!active) {
throw new Error('Headed host did not select its seeded worktree')
}
return { id: active.id }
})
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
const client = await launchPairedWebClient(electronApp, offer, {
disableRemoteTerminalStallRecovery:
process.env.ORCA_E2E_DISABLE_REMOTE_TERMINAL_STALL_RECOVERY === '1'
})
let terminal: string | null = null
try {
await expect
.poll(
() =>
client.page.evaluate(
(worktreeId) =>
window.__store
?.getState()
.allWorktrees()
.some((candidate) => candidate.id === worktreeId),
worktree.id
),
{ timeout: 30_000 }
)
.toBe(true)
const created = await callRuntime<{
tab: { parentTabId: string; terminal: string | null }
}>(client.page, 'session.tabs.createTerminal', {
worktree: `id:${worktree.id}`,
command: fixtureCommand(),
activate: false,
select: false,
navigation: 'caller'
})
terminal = created.tab.terminal
if (!terminal) {
throw new Error('Paired runtime did not publish the stalled-stream fixture')
}
const webTabId = toWebTerminalSurfaceTabId(created.tab.parentTabId)
await expect
.poll(
() =>
client.page.evaluate(
({ tabId, worktreeId }) =>
(window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some(
(tab) => tab.id === tabId
),
{ tabId: webTabId, worktreeId: worktree.id }
),
{ timeout: 30_000 }
)
.toBe(true)
await client.page.evaluate(
(worktreeId) => window.__store?.getState().setActiveWorktree(worktreeId),
worktree.id
)
const tab = client.page.locator(`[data-testid="sortable-tab"][data-tab-id="${webTabId}"]`)
await expect(tab).toBeVisible({ timeout: 30_000 })
await tab.click()
const originalPtyId = await waitForActivePanePtyId(client.page, 30_000)
await expect
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
.toContain('PAIRED_STALL_READY')
await client.page.evaluate((target) => {
const gate = (
window as typeof window & {
__remoteTerminalMultiplexAckGate?: { hold: (terminals: string[]) => void }
}
).__remoteTerminalMultiplexAckGate
if (!gate) {
throw new Error('Remote terminal multiplex ACK gate is unavailable')
}
gate.hold([target])
}, terminal)
const textarea = client.page.locator('.xterm-helper-textarea:visible').first()
await textarea.focus()
await client.page.keyboard.type('GO')
await client.page.keyboard.press('Enter')
await expect
.poll(
() =>
client.page.evaluate(() => {
const gate = (
window as typeof window & {
__remoteTerminalMultiplexAckGate?: {
snapshot: () => { heldAckChars: number }
}
}
).__remoteTerminalMultiplexAckGate
return gate?.snapshot().heldAckChars ?? 0
}),
{ timeout: 30_000 }
)
.toBeGreaterThan(MIN_EXHAUSTED_ACK_BYTES)
await expect
.poll(
async () => {
const result = await callRuntime<{ terminal: RuntimeTerminalRead }>(
client.page,
'terminal.read',
{ terminal }
)
return result.terminal.tail.join('\n').includes('HOST_FLOOD_COMPLETE')
},
{ timeout: 30_000 }
)
.toBe(true)
const beforeInput = await callRuntime<{ terminal: RuntimeTerminalRead }>(
client.page,
'terminal.read',
{ terminal }
)
const sent = await callRuntime<{ send: { accepted: boolean } }>(client.page, 'terminal.send', {
terminal,
text: liveMarker,
enter: true,
client: { id: 'paired-stalled-stream-e2e', type: 'desktop' }
})
expect(sent.send.accepted).toBe(true)
await expect
.poll(
async () => {
const result = await callRuntime<{ terminal: RuntimeTerminalRead }>(
client.page,
'terminal.read',
{ terminal }
)
return Number(result.terminal.latestCursor)
},
{ timeout: 30_000 }
)
.toBeGreaterThan(Number(beforeInput.terminal.latestCursor))
expect(await getTerminalContent(client.page)).not.toContain(liveMarker)
expect(
await client.page.evaluate(
({ target, text }) => {
const gate = (
window as typeof window & {
__remoteTerminalMultiplexAckGate?: {
sendInput: (terminal: string, text: string) => number
}
}
).__remoteTerminalMultiplexAckGate
return gate?.sendInput(target, text) ?? 0
},
{ target: terminal, text: '\r' }
)
).toBe(1)
await expect
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
.toContain(`LIVE:${liveMarker}`)
expect(await waitForActivePanePtyId(client.page, 30_000)).toBe(originalPtyId)
await expect(tab).toHaveAttribute('data-active', 'true')
} finally {
await client.page
.evaluate(() => {
;(
window as typeof window & {
__remoteTerminalMultiplexAckGate?: { release: () => void }
}
).__remoteTerminalMultiplexAckGate?.release()
})
.catch(() => undefined)
if (terminal) {
await callRuntime(client.page, 'terminal.closeTab', { terminal }).catch(() => undefined)
}
await client.dispose()
}
})

View File

@ -0,0 +1,483 @@
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 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 {
createRuntimeDesktopPairingOffer,
launchPairedWebClient
} from './helpers/paired-electron-client'
import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal'
const RETENTION_PARK_DELAY_MS = 100
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-truncated-tail-'))
const fixturePath = path.join(scratch, 'truncated-tail-terminal.mjs')
writeFileSync(
fixturePath,
[
'const marker = process.argv[2]',
'let flooded = false',
"process.stdout.write('REMOTE_TRUNCATED_TAIL_READY\\r\\n')",
"process.stdin.setEncoding('utf8')",
"process.stdin.on('data', (data) => {",
" if (!flooded && data.includes('GO')) {",
' flooded = true',
" for (let row = 0; row < 4_000; row += 1) process.stdout.write(`overflow-${row}-${'x'.repeat(80)}\\r\\n`)",
' process.stdout.write(`${marker}\\r\\n`)',
' return',
' }',
' process.stdout.write(`LIVE:${data.trim()}\\r\\n`)',
'})',
'process.stdin.resume()'
].join('\n')
)
test.afterAll(() => {
rmSync(scratch, { recursive: true, force: true })
})
test.use({
orcaAppExtraEnv: {
ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(RETENTION_PARK_DELAY_MS),
ORCA_E2E_TERMINAL_RETENTION_LIMIT: '1'
}
})
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>
}
test('paints a paired remote terminal when only its retained text tail overflowed @headful', async ({
electronApp,
orcaPage
}) => {
test.setTimeout(120_000)
const firstPaintMarker = `REMOTE_TRUNCATED_TAIL_FIRST_PAINT_${Date.now()}`
const liveMarker = `REMOTE_TRUNCATED_TAIL_LIVE_${Date.now()}`
const worktree = await orcaPage.evaluate(() => {
const state = window.__store?.getState()
const activeWorktreeId = state?.activeWorktreeId
if (!activeWorktreeId) {
throw new Error('Headed host did not select its seeded worktree')
}
const activeWorktree = state
.allWorktrees()
.find((candidate) => candidate.id === activeWorktreeId)
if (!activeWorktree) {
throw new Error('Headed host active worktree was absent from inventory')
}
return { id: activeWorktree.id, path: activeWorktree.path }
})
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
const client = await launchPairedWebClient(electronApp, offer)
let terminal: string | null = null
try {
await expect
.poll(
() =>
client.page.evaluate(
(worktreeId) =>
window.__store
?.getState()
.allWorktrees()
.some((candidate) => candidate.id === worktreeId),
worktree.id
),
{ timeout: 30_000 }
)
.toBe(true)
const created = await callRuntime<{
tab: {
parentTabId: string
leafId: string
terminal: string | null
}
}>(client.page, 'session.tabs.createTerminal', {
worktree: `id:${worktree.id}`,
command: fixtureCommand(firstPaintMarker),
activate: false,
select: false,
navigation: 'caller'
})
terminal = created.tab.terminal
if (!terminal) {
throw new Error('Paired runtime did not publish the overflow fixture terminal')
}
const webTabId = toWebTerminalSurfaceTabId(created.tab.parentTabId)
await expect
.poll(
() =>
client.page.evaluate(
({ tabId, worktreeId }) =>
(window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some(
(tab) => tab.id === tabId
),
{ tabId: webTabId, worktreeId: worktree.id }
),
{ timeout: 30_000 }
)
.toBe(true)
await client.page.evaluate(
(worktreeId) => window.__store?.getState().setActiveWorktree(worktreeId),
worktree.id
)
const remoteTab = client.page.locator(`[data-testid="sortable-tab"][data-tab-id="${webTabId}"]`)
await expect(remoteTab).toBeVisible({ timeout: 30_000 })
await remoteTab.click()
await expect(remoteTab).toHaveAttribute('data-active', 'true')
await waitForActivePanePtyId(client.page, 30_000)
await expect
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
.toContain('REMOTE_TRUNCATED_TAIL_READY')
await callRuntime(client.page, 'terminal.send', {
terminal,
text: 'WARMUP',
enter: true,
client: { id: 'paired-truncated-tail-e2e', type: 'desktop' }
})
await expect
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
.toContain('LIVE:WARMUP')
await callRuntime(client.page, 'terminal.send', {
terminal,
text: 'GO',
enter: true,
client: { id: 'paired-truncated-tail-e2e', type: 'desktop' }
})
await expect
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
.toContain(firstPaintMarker)
await expect
.poll(
async () => {
const result = await callRuntime<{ terminal: RuntimeTerminalRead }>(
client.page,
'terminal.read',
{ terminal }
)
return {
marker: result.terminal.tail.join('\n').includes(firstPaintMarker),
truncated: result.terminal.truncated
}
},
{ timeout: 30_000 }
)
.toEqual({ marker: true, truncated: true })
await client.page.reload()
await client.page.locator('[data-worktree-sidebar]').waitFor({
state: 'visible',
timeout: 30_000
})
await client.page.evaluate(
(worktreeId) => window.__store?.getState().setActiveWorktree(worktreeId),
worktree.id
)
const restoredRemoteTab = client.page.locator(
`[data-testid="sortable-tab"][data-tab-id="${webTabId}"]`
)
await expect(restoredRemoteTab).toBeVisible({ timeout: 30_000 })
await restoredRemoteTab.click()
await expect(restoredRemoteTab).toHaveAttribute('data-active', 'true')
await expect
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
.toContain(firstPaintMarker)
await callRuntime(client.page, 'terminal.send', {
terminal,
text: liveMarker,
enter: true,
client: { id: 'paired-truncated-tail-e2e', type: 'desktop' }
})
await expect
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
.toContain(`LIVE:${liveMarker}`)
} finally {
if (terminal) {
await callRuntime(client.page, 'terminal.closeTab', { terminal }).catch(() => undefined)
}
await client.dispose()
}
})
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
?.getState()
.allWorktrees()
.slice(0, 2)
.map((worktree) => worktree.id)
)
if (!worktreeIds || worktreeIds.length < 2) {
throw new Error('Paired retention fixture requires two seeded worktrees')
}
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
const client = await launchPairedWebClient(electronApp, offer, {
terminalParkingDelayMs: RETENTION_PARK_DELAY_MS,
terminalRetentionLimit: 1
})
const createdTerminals: string[] = []
try {
expect(await client.page.evaluate(() => window.api.e2e.getConfig())).toMatchObject({
exposeStore: true,
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(
() =>
client.page.evaluate((ids) => {
const known = new Set(
window.__store
?.getState()
.allWorktrees()
.map((worktree) => worktree.id)
)
return ids.every((id) => known.has(id))
}, worktreeIds),
{ timeout: 30_000 }
)
.toBe(true)
await client.page.evaluate(async () => {
await window.__store
?.getState()
.updateSettings({ terminalHiddenWorktreeRetentionBudget: false })
})
const remoteTabs: { tabId: string; terminal: string; worktreeId: string; marker: string }[] = []
for (const [index, worktreeId] of worktreeIds.entries()) {
const marker = `PAIRED_RETENTION_${index}_${Date.now()}`
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`)
}
createdTerminals.push(created.tab.terminal)
const tabId = toWebTerminalSurfaceTabId(created.tab.parentTabId)
await expect
.poll(
() =>
client.page.evaluate(
({ tabId, worktreeId }) =>
(window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some(
(tab) => tab.id === tabId
),
{ tabId, worktreeId }
),
{ timeout: 30_000 }
)
.toBe(true)
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()
await waitForActivePanePtyId(client.page, 30_000)
await callRuntime(client.page, 'terminal.send', {
terminal: created.tab.terminal,
text: marker,
enter: true,
client: { id: 'paired-retention-e2e', type: 'desktop' }
})
await expect
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
.toContain(`LIVE:${marker}`)
remoteTabs.push({ tabId, terminal: created.tab.terminal, worktreeId, marker })
}
await expect
.poll(
() =>
client.page.evaluate(() => ({
parkDelayMs: window.__terminalParkingDebug?.parkDelayMs,
retentionLimit: window.__terminalParkingDebug?.retentionLimit
})),
{ timeout: 10_000 }
)
.toEqual({ parkDelayMs: RETENTION_PARK_DELAY_MS, retentionLimit: 1 })
await client.page.evaluate(() => window.__store?.getState().setActiveView('tasks'))
const controlStartedAt = Date.now()
await expect
.poll(
() =>
client.page.evaluate(
({ tabIds, controlStartedAt, delayMs }) => ({
heldLongEnough: Date.now() - controlStartedAt >= delayMs * 4,
mounted: tabIds.filter((tabId) => window.__paneManagers?.has(tabId)).length
}),
{
tabIds: remoteTabs.map((tab) => tab.tabId),
controlStartedAt,
delayMs: RETENTION_PARK_DELAY_MS
}
),
{ timeout: 10_000 }
)
.toEqual({ heldLongEnough: true, mounted: 2 })
await client.page.evaluate(async () => {
await window.__store
?.getState()
.updateSettings({ terminalHiddenWorktreeRetentionBudget: true })
})
await expect
.poll(
() =>
client.page.evaluate(
({ delayMs, tabIds: [olderTabId, newerTabId], worktreeIds }) => {
const state = window.__store?.getState()
const terminalTabs = Object.values(state?.tabsByWorktree ?? {}).flat()
const verdicts = window.__terminalParkingDebug?.worktreeVerdicts() ?? []
return {
activeView: state?.activeView,
budgetEnabled: state?.settings?.terminalHiddenWorktreeRetentionBudget,
newerMounted: window.__paneManagers?.has(newerTabId),
olderMounted: window.__paneManagers?.has(olderTabId),
remotePtys: [olderTabId, newerTabId].map((tabId) =>
terminalTabs.find((tab) => tab.id === tabId)?.ptyId?.startsWith('remote:')
),
verdicts: worktreeIds.map((worktreeId) => {
const verdict = verdicts.find((candidate) => candidate.worktreeId === worktreeId)
return verdict
? {
forceParked: verdict.forceParked,
hasActivityTerminalPortal: verdict.hasActivityTerminalPortal,
hasPendingSpawnWork: verdict.hasPendingSpawnWork,
hidden: verdict.hiddenSinceMs !== null,
hiddenPastDelay:
verdict.hiddenSinceMs !== null &&
Date.now() - verdict.hiddenSinceMs >= delayMs,
isVisible: verdict.isVisible,
ordinaryParkingCovers: verdict.ordinaryParkingCovers,
parkCooldown:
verdict.parkCooldownUntilMs !== null &&
Date.now() < verdict.parkCooldownUntilMs,
shouldMeasureHiddenWorktree: verdict.shouldMeasureHiddenWorktree
}
: null
})
}
},
{
delayMs: RETENTION_PARK_DELAY_MS,
tabIds: [remoteTabs[0]!.tabId, remoteTabs[1]!.tabId],
worktreeIds
}
),
{ timeout: 10_000 }
)
.toEqual({
activeView: 'tasks',
budgetEnabled: true,
newerMounted: true,
olderMounted: false,
remotePtys: [true, true],
verdicts: [
{
forceParked: true,
hasActivityTerminalPortal: false,
hasPendingSpawnWork: false,
hidden: true,
hiddenPastDelay: true,
isVisible: false,
ordinaryParkingCovers: false,
parkCooldown: false,
shouldMeasureHiddenWorktree: false
},
{
forceParked: false,
hasActivityTerminalPortal: false,
hasPendingSpawnWork: false,
hidden: true,
hiddenPastDelay: true,
isVisible: false,
ordinaryParkingCovers: false,
parkCooldown: false,
shouldMeasureHiddenWorktree: false
}
]
})
const older = remoteTabs[0]!
await client.page.evaluate((worktreeId) => {
const state = window.__store?.getState()
state?.setActiveView('terminal')
state?.setActiveWorktree(worktreeId)
}, older.worktreeId)
const olderTab = client.page.locator(
`[data-testid="sortable-tab"][data-tab-id="${older.tabId}"]`
)
await expect(olderTab).toBeVisible({ timeout: 30_000 })
await olderTab.click()
await waitForActivePanePtyId(client.page, 30_000)
await expect
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
.toContain(`LIVE:${older.marker}`)
await callRuntime(client.page, 'terminal.send', {
terminal: older.terminal,
text: 'AFTER_RESTORE',
enter: true,
client: { id: 'paired-retention-e2e', type: 'desktop' }
})
await expect
.poll(() => getTerminalContent(client.page), { timeout: 30_000 })
.toContain('LIVE:AFTER_RESTORE')
await expect(olderTab).toHaveAttribute('data-active', 'true')
} finally {
for (const terminal of createdTerminals) {
await callRuntime(client.page, 'terminal.closeTab', { terminal }).catch(() => undefined)
}
await client.dispose()
}
})

View File

@ -0,0 +1,67 @@
import type { JSHandle, Page } from '@stablyai/playwright-test'
export type PairedRetentionSample = {
bufferCells: number
heapBytes: number | null
mountedTargetManagers: number
targetPanes: number
}
export async function readPairedRetentionSample(
page: Page,
tabIds: string[]
): Promise<PairedRetentionSample> {
try {
const session = await page.context().newCDPSession(page)
await session.send('HeapProfiler.collectGarbage')
await session.detach()
} catch {
// GC only improves measurement fidelity.
}
return page.evaluate((targets) => {
let bufferCells = 0
let mountedTargetManagers = 0
let targetPanes = 0
for (const tabId of targets) {
const manager = window.__paneManagers?.get(tabId)
if (!manager) {
continue
}
mountedTargetManagers += 1
for (const pane of manager.getPanes?.() ?? []) {
const buffer = pane.terminal?.buffer?.active
if (!buffer) {
continue
}
targetPanes += 1
bufferCells += buffer.length * pane.terminal.cols
}
}
const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory
return {
bufferCells,
heapBytes: memory?.usedJSHeapSize ?? null,
mountedTargetManagers,
targetPanes
}
}, tabIds)
}
export async function startRendererLagProbe(page: Page): Promise<JSHandle<{ stop: () => number }>> {
return page.evaluateHandle(() => {
const sampleMs = 16
let lastAt = performance.now()
let maxDriftMs = 0
const timer = window.setInterval(() => {
const now = performance.now()
maxDriftMs = Math.max(maxDriftMs, now - lastAt - sampleMs)
lastAt = now
}, sampleMs)
return {
stop: () => {
window.clearInterval(timer)
return maxDriftMs
}
}
})
}