diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index 372dce928ff..27092ed4230 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -866,6 +866,20 @@ jobs:
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
+ - name: Gate runtime file-watcher process isolation
+ if: runner.os == 'Linux'
+ run: |
+ # Why: #8212 is a native-process crash contract. Prove both the Node
+ # host and the exact Electron runtime survive SIGSEGV before packaging.
+ node config/scripts/runtime-file-watcher-fault-harness.mjs
+ ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
+
+ - name: Gate SSH relay watcher process isolation
+ run: |
+ # Why: the remote native watcher shares a daemon with live PTYs.
+ # Kill only its child and require both PTY and watch recovery before packaging.
+ node config/scripts/relay-watcher-fault-harness.mjs
+
- name: Publish release artifacts (Linux)
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
uses: nick-fields/retry@v4
diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml
index 3638d40f00c..bd5c5dc9c00 100644
--- a/.github/workflows/release-mac-build.yml
+++ b/.github/workflows/release-mac-build.yml
@@ -120,6 +120,19 @@ jobs:
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
+ - name: Gate runtime file-watcher process isolation
+ run: |
+ # Why: #8212 is a native-process crash contract. Prove both the Node
+ # host and the exact Electron runtime survive SIGSEGV before packaging.
+ node config/scripts/runtime-file-watcher-fault-harness.mjs
+ ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
+
+ - name: Gate SSH relay watcher process isolation
+ run: |
+ # Why: the remote native watcher shares a daemon with live PTYs.
+ # Kill only its child and require both PTY and watch recovery before packaging.
+ node config/scripts/relay-watcher-fault-harness.mjs
+
- name: Publish release artifacts (macOS)
uses: nick-fields/retry@v4
with:
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index 4c2a32c5784..bdb96248ce8 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -1,14 +1,8 @@
{
"schemaVersion": 1,
- "updatedAt": "2026-07-11",
+ "updatedAt": "2026-07-12",
"policy": {
- "maturityLevels": [
- "experimental",
- "soak",
- "blocking",
- "accepted-gap",
- "deprecated"
- ],
+ "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
"minimumSoakRuns": 100,
"minimumSoakDays": 14,
@@ -16,6 +10,255 @@
}
},
"gates": [
+ {
+ "id": "git-worktree.refresh-event-semantics",
+ "title": "Index-only Git metadata cannot trigger structural worktree refresh fanout",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "terminal-runtime",
+ "layer": "main-preload-renderer-contract",
+ "surfaces": [
+ "terminal input availability",
+ "worktree discovery",
+ "Source Control status refresh"
+ ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "ssh"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local", "ssh"],
+ "coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, and Source Control active-visible repo filtering. Linux/Windows are covered at the shared poller layer by forcing the non-darwin path; live platform runs remain gaps.",
+ "motivatingLinks": ["https://github.com/stablyai/orca/pull/7086"],
+ "invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control; external head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout.",
+ "oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts"
+ ],
+ "testFiles": [
+ "src/main/ipc/worktree-base-directory-event-filter.test.ts",
+ "src/main/ipc/worktree-base-directory-watcher.test.ts",
+ "src/main/ipc/worktree-base-directory-poller.test.ts",
+ "src/main/ipc/worktree-head-identity-reader.test.ts",
+ "src/renderer/src/hooks/worktree-head-identity-apply.test.ts",
+ "src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts",
+ "src/renderer/src/hooks/useIpcEvents.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/main/ipc/worktree-base-directory-event-filter.test.ts",
+ "assertions": [
+ "primary HEAD and packed-refs classify as structural while primary index classifies as status-only",
+ "linked HEAD/gitdir/locked classify as structural while linked index classifies as status-only",
+ "HEAD reflog appends classify as status-only for linked and primary checkouts while per-ref reflogs stay ignored",
+ "config.worktree classifies as structural at both linked and primary levels",
+ "ignored common-dir churn, spaces, Windows separators, and outside-root paths do not match structurally"
+ ]
+ },
+ {
+ "file": "src/main/ipc/worktree-base-directory-watcher.test.ts",
+ "assertions": [
+ "linked index bursts produce zero notifyWorktreesChanged calls and one debounced status-only notification",
+ "linked HEAD and locked metadata still produce a structural worktree notification",
+ "status-only head moves emit head identities without structural fanout and only when heads actually changed",
+ "structural notifications re-baseline head identities silently and SSH watches never read identities",
+ "SSH-shaped index renames are status-only while overflow remains conservatively structural"
+ ]
+ },
+ {
+ "file": "src/main/ipc/worktree-base-directory-poller.test.ts",
+ "assertions": [
+ "non-darwin git-common polling emits entry create/delete and allowlisted HEAD/index leaf events",
+ "linked HEAD rewrites are detected even after restoring the entry-directory mtime",
+ "linked and primary HEAD reflog appends emit despite bumping no watched leaf or entry dir",
+ "in-place index rewrites surface through the periodic backstop re-stat",
+ "primary checkout HEAD changes and macOS narrow watch/fallback behavior still emit"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts",
+ "assertions": [
+ "Source Control nudges only for the active visible repo on structural and status-only signals",
+ "preload subscriptions and terminal command-finished listeners are cleaned up"
+ ]
+ },
+ {
+ "file": "src/main/ipc/worktree-head-identity-reader.test.ts",
+ "assertions": [
+ "loose-ref, packed-refs, detached, unborn, and relative-gitdir layouts resolve or skip without spawning Git",
+ "traversal-shaped or backslash/colon symrefs are rejected before any path join and only hex object ids are ever emitted"
+ ]
+ },
+ {
+ "file": "src/renderer/src/hooks/worktree-head-identity-apply.test.ts",
+ "assertions": [
+ "head identities patch matching rows by path (including Windows separator/casing drift) and skip unknown rows"
+ ]
+ },
+ {
+ "file": "src/renderer/src/hooks/useIpcEvents.test.ts",
+ "assertions": [
+ "renderer preload API fixtures include the status-metadata and head-identity subscription contracts"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-07-12",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts",
+ "result": "passed",
+ "durationSeconds": 3.12,
+ "summary": "7 files and 130 tests passed locally, adding head-identity emit-on-change without structural fanout, reflog status triggers, config.worktree structural classification, the in-place index backstop, and the spawn-free head reader with symref traversal rejection and hex-object-id output validation."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 10,
+ "scope": "focused main/preload/renderer unit and polling tests"
+ },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "New experimental deterministic gate with one local macOS run; needs CI soak before promotion."
+ },
+ "redGreenEvidence": {
+ "status": "partial",
+ "evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. Saved CI red/green artifacts are still needed before blocking promotion."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping, compares HEAD/gitdir/locked signatures every tick, and gates linked index inspection behind the entry-directory signature. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows; a locked create/delete positive control still caused structural refreshes."
+ },
+ "promotionCriteria": [
+ "Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.",
+ "Attach live Electron main-thread diagnostic evidence for repeated linked-worktree index rewrites while typing.",
+ "Add Linux/Windows live watcher evidence if shared poller-layer coverage diverges from platform behavior."
+ ],
+ "knownGaps": [
+ "The live Electron diagnostic and screenshot evidence must remain attached to the motivating PR for durable review.",
+ "Linux and Windows are forced through the shared non-darwin poller in unit tests but are not live-tested here.",
+ "Git loose ref watching remains outside this incident fix by design.",
+ "SSH watches classify head-move triggers but skip the metadata-read identity diff; remote background-worktree heads still wait on a structural event or activation."
+ ],
+ "demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if index-only churn can emit worktrees:changed, or if structural add/remove/HEAD/lock changes fail to converge."
+ },
+ {
+ "id": "editor.live-log-append-stability",
+ "title": "Long live session logs retain their Monaco viewport while appending",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "editor-runtime",
+ "layer": "renderer-electron-contract",
+ "surfaces": [
+ "Agent Session History View Log",
+ "Monaco external-content reconciliation",
+ "renderer crash containment"
+ ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local"],
+ "coverageNotes": "Focused tests and real-Monaco 9/50 MiB benchmarks are platform-independent. Local macOS Electron evidence opens a synthetic 9 MiB transcript through Agent Session History at fixed 900x720 viewport, 13px font, 1x zoom, and asserts the full 9 MiB model length loaded as a font-metric-independent containment check (word-wrap pixel geometry varies ~10% across runners, so a generous content-height floor is only a collapsed/truncated-render smoke check), then verifies three five-second-cadence watcher appends with Find open and closed. Live Windows/Linux evidence remains uncollected.",
+ "motivatingLinks": ["https://github.com/stablyai/orca/pull/8432"],
+ "invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; arbitrary rewrites continue to replace the model content.",
+ "oracle": "Focused tests assert one post-mount content owner, actual outer lifecycle remount ordering across retained path models, exact end-of-model suffix edits with one model read, no-op equality, and full replacement for non-appends. With Node forced GC, real-Monaco benchmarks alternate 30 suffix and 30 replacement samples after five warmups on fresh equivalent models at 9 and 50 MiB, forcing GC and event-loop settlement between every arm. The Electron scenario alternates an e2e-only legacy setValue red control and the fixed watcher append from restored equivalent model/geometry at the measured legacy-failure cadence, asserting that the control disrupts anchor state while the fixed path preserves visible ranges, selection, complete Find state, scroll offset, renderer survival, and forced-GC heap/native-memory budgets.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.test.ts src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx",
+ "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.bench.ts --pool=threads",
+ "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1"
+ ],
+ "testFiles": [
+ "src/renderer/src/components/editor/monaco-content-sync.test.ts",
+ "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx",
+ "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx",
+ "src/renderer/src/components/editor/monaco-content-sync.bench.ts",
+ "tests/e2e/agent-session-log-tail-stability.spec.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/renderer/src/components/editor/monaco-content-sync.test.ts",
+ "assertions": [
+ "append-only drift reads the current model once and inserts only at the previous model end",
+ "identical content emits no edit and non-append drift retains full replacement plus undo stops",
+ "a stale retained target model reconciles on mount without explicit undo stops while prior-path content and undo history remain isolated"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx",
+ "assertions": ["the Monaco wrapper receives defaultValue and no controlled value prop"]
+ },
+ {
+ "file": "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx",
+ "assertions": [
+ "a same-pane path switch unmounts the prior outer Monaco before real mount reconciliation refreshes the stale target; the prior retained model content and undo sentinel remain untouched"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/editor/monaco-content-sync.bench.ts",
+ "assertions": [
+ "with forced GC and deterministic settlement between every arm, fresh real-Monaco 9 MiB and 50 MiB models alternate 30 append and 30 replacement samples after five warmups; append p95 stays below 50/100ms and at least 2x faster"
+ ]
+ },
+ {
+ "file": "tests/e2e/agent-session-log-tail-stability.spec.ts",
+ "assertions": [
+ "production Agent Session History opens a synthetic 9 MiB View Log and confirms the full model length loaded as font-metric-independent containment, with a generous content-height floor as a collapsed/truncated-render smoke check",
+ "an executable e2e-only legacy setValue control disrupts selection/Find/anchor state at each fixed-geometry five-second sample, then restores the equivalent model state before the fixed arm",
+ "three alternating watcher suffix appends preserve visible ranges, selection, scroll offset, Find open/query/active-match state, and exact suffix content",
+ "the renderer remains responsive with no render-process-gone event and forced-GC JS-heap/working-set/private-memory peak and retained budgets hold against paired legacy controls"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-07-12",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.test.ts src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx",
+ "result": "passed",
+ "durationSeconds": 5,
+ "summary": "Focused editor ownership, edit-shape, mount reconciliation, lifecycle-key, and actual same-pane retained-model remount tests passed."
+ },
+ {
+ "date": "2026-07-12",
+ "runner": "local",
+ "platform": "macos",
+ "command": "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.bench.ts --pool=threads",
+ "result": "passed",
+ "durationSeconds": 75,
+ "summary": "Forced-GC, settled, alternating fresh-model Monaco p95: 9 MiB append 4.02-5.51ms versus replacement 81.42-83.76ms; 50 MiB append 22.72-26.60ms versus replacement 445.11-449.21ms."
+ },
+ {
+ "date": "2026-07-12",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1",
+ "result": "passed",
+ "durationSeconds": 78,
+ "summary": "The production View Log journey alternated retained e2e-only legacy-red controls with fixed appends from restored equivalent state; every control detected instability while the fixed path retained viewport, selection, complete Find state, renderer liveness, and normalized forced-GC/native memory budgets."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 600,
+ "scope": "local focused renderer tests plus one Electron production-journey scenario"
+ },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "New deterministic gate with local macOS passes; CI soak history is not yet available."
+ },
+ "redGreenEvidence": {
+ "status": "complete",
+ "evidence": "The retained Electron gate itself executes the former read-only wrapper setValue behavior behind MODE=e2e at fixed 900x720, 13px, 1x zoom, the full 9 MiB model length loaded (with a generous content-height floor, since word-wrap pixel geometry is runner-dependent), and five-second cadence. The control retains a flat incoming value like the former controlled IPC prop. Each legacy arm must disrupt selection, Find active-match state, visible range, or scroll anchor, then restore identical model length/tail/geometry and anchor state before the fixed watcher arm; every fixed arm must preserve them. Production builds never install the control. The original exact-file dev repro also recorded renderer exit code 5."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Every update retrieves the model value once and performs at most one equality-or-prefix comparison; a matching append submits only the suffix. The registered Node command requires --expose-gc and --pool=threads so worker GC is available; alternating fresh real-Monaco arms force GC and deterministic event-loop settlement after every operation. Observed p95 ranges: 9 MiB append 4.02-5.51ms versus replacement 81.42-83.76ms; 50 MiB append 22.72-26.60ms versus replacement 445.11-449.21ms. Electron alternates each legacy replacement control and fixed watcher suffix from equivalent restored model state/geometry, samples forced-GC JS heap plus app.getAppMetrics renderer working set and OS private memory before/after/settled, asserts retained memory within max(20MiB,10%), and requires suffix peak deltas for jsHeapMb, workingSetMb, and privateMb not exceed the paired legacy controls."
+ },
+ "promotionCriteria": [
+ "Collect stable soak history on macOS, Linux, and Windows.",
+ "Accumulate 100 consecutive deterministic gate passes or 14 days without unexplained flakes."
+ ],
+ "knownGaps": ["No live Windows or Linux View Log evidence yet."],
+ "demotionRule": "Quarantine the Electron scenario if it flakes without a product or harness bug; demote if viewport/Find drift, renderer loss, p95 regression, or memory retention exceeds the registered budgets."
+ },
{
"id": "terminal-session.snapshot-freshness",
"title": "Stale liveness snapshots cannot close newer PTY bindings",
@@ -23,23 +266,10 @@
"protection": "partial",
"owner": "terminal-runtime",
"layer": "renderer-unit",
- "surfaces": [
- "terminal lifecycle",
- "dead-session reconciliation",
- "tab creation"
- ],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "surfaces": ["terminal lifecycle", "dead-session reconciliation", "tab creation"],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon"],
+ "coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence over the reconcile guards that exist on main@1282f5c2d. Broader targeted-hasPty resume paths, no-hot listing counts, and live Electron survival arrive with the pending reliability stack.",
"motivatingLinks": [
@@ -119,31 +349,12 @@
"Manage Sessions",
"Resource Manager"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "wsl",
- "remote-runtime",
- "mobile-relay"
- ],
- "coveredPlatforms": [
- "macos"
- ],
- "coveredProviders": [
- "local",
- "daemon",
- "ssh"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime", "mobile-relay"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local", "daemon", "ssh"],
"coverageNotes": "Local macOS deterministic evidence covers the renderer snapshot/coordinator, exact local and SSH-shaped PTY request settlement, active-last and pinned terminal-tab routing, component-unmount continuation, and the existing current/legacy daemon management contract. Windows Electron process absence, live SSH/WSL behavior, and remote-runtime/mobile host completion remain explicit gaps.",
- "motivatingLinks": [
- "https://github.com/stablyai/orca/issues/8001"
- ],
+ "motivatingLinks": ["https://github.com/stablyai/orca/issues/8001"],
"invariant": "Every terminal surface confirmed in the invoking renderer is force-closed exactly once after daemon management settles, later-created surfaces and non-terminal tabs survive, and exact shutdown requests are limited to deduplicated current non-runtime PTY bindings of the confirmed surfaces.",
"oracle": "Snapshot terminal entity IDs before the first await; mutate ownership, active selection, bindings, and tab presence while daemon management is pending; then assert only the immutable targets disappear from both terminal stores, active targets close last with valid editor/browser/deactivated post-state, every captured exact PTY promise settles before callbacks, and no provider inventory sweep or late-tab kill occurs.",
"commands": [
@@ -240,33 +451,11 @@
"protection": "partial",
"owner": "terminal-runtime",
"layer": "shared-main-renderer-contract",
- "surfaces": [
- "terminal lifecycle",
- "tab creation",
- "PTY spawn",
- "startup cwd persistence"
- ],
- "platforms": [
- "macos",
- "linux",
- "windows",
- "mobile"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "wsl",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
- "coveredProviders": [
- "local",
- "ssh",
- "remote-runtime"
- ],
+ "surfaces": ["terminal lifecycle", "tab creation", "PTY spawn", "startup cwd persistence"],
+ "platforms": ["macos", "linux", "windows", "mobile"],
+ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local", "ssh", "remote-runtime"],
"coverageNotes": "Local macOS evidence covers the shared missing-dir fallback policy, main pty:spawn recovery and metadata, no-flag and reattach strictness, renderer IPC flag routing, SSH-tagged and remote-runtime omission, and the visibility-gated terminal notice. Daemon shares the same pre-provider main cwd decision but lacks a live daemon-provider run; WSL UNC paths are exempt from the probe by design and lack a live run; Linux/Windows and mobile/API strictness are gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/7239",
@@ -403,21 +592,9 @@
"runtime reload and session replacement",
"loopback restart and stall recovery"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "wsl",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Local macOS execution of the generated Pi/OMP extension plus the shared hook normalizer. WSL fallback behavior is covered with mocked native-fetch failure and Windows curl handoff. Daemon PTYs use the same generated extension without a distinct delivery path. SSH/relay ingest uses the shared normalizer, but no live remote Pi process is exercised.",
"motivatingLinks": [
@@ -507,21 +684,9 @@
"provider session dedupe",
"sidebar and mobile identity"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "wsl",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence over the ownership/dedupe suite on main@1282f5c2d. Queued/pending resume-claim indexing, same-session and wrong-session hook proofs, and Electron repeat-activation coverage arrive with the pending stack (#7008).",
"motivatingLinks": [
@@ -535,9 +700,7 @@
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts"
],
- "testFiles": [
- "src/renderer/src/lib/resume-sleeping-agent-session.test.ts"
- ],
+ "testFiles": ["src/renderer/src/lib/resume-sleeping-agent-session.test.ts"],
"assertionRefs": [
{
"file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
@@ -595,26 +758,10 @@
"protection": "partial",
"owner": "terminal-rendering",
"layer": "renderer-provider-contract",
- "surfaces": [
- "PTY sizing",
- "split layout",
- "restore",
- "hidden-to-visible transitions"
- ],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "surfaces": ["PTY sizing", "split layout", "restore", "hidden-to-visible transitions"],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Shell-visible size, SSH/remote geometry, Windows ConPTY readback, and resume-time reassertion coverage arrive with the pending stack and #7006.",
"motivatingLinks": [
@@ -716,27 +863,10 @@
"protection": "partial",
"owner": "terminal-rendering",
"layer": "renderer-unit",
- "surfaces": [
- "terminal search",
- "links",
- "WebGL",
- "decorations",
- "keyboard navigation"
- ],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "surfaces": ["terminal search", "links", "WebGL", "decorations", "keyboard navigation"],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence over the WebGL/link/search containment suites on main@1282f5c2d, adopting the #6949 atlas-recovery rename and #7133's reveal hardening tests. Core addon-load throw containment and live typed-input survival arrive with #7004 and a live follow-up.",
"motivatingLinks": [
@@ -860,34 +990,18 @@
"xterm scrollbar DOM",
"scrollback"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "wsl",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Renderer-unit coverage proves the shared xterm DOM intent path. Live Electron evidence is PR validation evidence for local macOS only until the flow has stable automation; live SSH, WSL, Linux, and Windows paths remain unproved.",
- "motivatingLinks": [
- "STA-1341"
- ],
+ "motivatingLinks": ["STA-1341"],
"invariant": "A user-driven xterm scrollbar thumb or track scroll updates the live terminal scroll intent before tab, visibility, or layout resume enforces intent, so resume preserves the latest dragged viewport instead of an older pinned line.",
"oracle": "Pointerdown on .xterm-scrollbar or .xterm-slider followed by xterm viewport movement records the new pinned viewport, and enforcing current intent restores that dragged line instead of stale top intent.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts"
],
- "testFiles": [
- "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts"
- ],
+ "testFiles": ["src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts"],
"assertionRefs": [
{
"file": "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts",
@@ -944,23 +1058,9 @@
"protection": "none",
"owner": "startup-persistence",
"layer": "upgrade-fixture",
- "surfaces": [
- "startup",
- "upgrade",
- "session restore",
- "daemon restore"
- ],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "wsl"
- ],
+ "surfaces": ["startup", "upgrade", "session restore", "daemon restore"],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "wsl"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap only; no executable coverage is wired yet.",
@@ -996,9 +1096,7 @@
"Run second restart after current code writes upgraded state.",
"Record startup timing and failure artifact."
],
- "knownGaps": [
- "No fixture corpus or command yet."
- ],
+ "knownGaps": ["No fixture corpus or command yet."],
"demotionRule": "Cannot promote without old production fixture provenance."
},
{
@@ -1017,13 +1115,8 @@
"resize",
"exit cleanup"
],
- "platforms": [
- "linux",
- "macos"
- ],
- "providers": [
- "local"
- ],
+ "platforms": ["linux", "macos"],
+ "providers": ["local"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap on main. The live Electron Playwright slice exists only on the pending reliability stack. It registers here with its owning split PR.",
@@ -1082,20 +1175,10 @@
"CJK repaint",
"cursor and resize"
],
- "platforms": [
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "wsl"
- ],
- "coveredPlatforms": [
- "windows"
- ],
- "coveredProviders": [
- "daemon"
- ],
+ "platforms": ["windows"],
+ "providers": ["local", "daemon", "wsl"],
+ "coveredPlatforms": ["windows"],
+ "coveredProviders": ["daemon"],
"coverageNotes": "Issue #8048 now has deterministic wrapper and cold-restore re-anchor tests plus a Windows PR-CI harness that drives the built daemon through 25 real ConPTY workspace-close races while an unrelated witness PTY stays alive. Keyboard reset, CJK repaint, WSL, and full visible Electron coverage remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6541",
@@ -1193,18 +1276,8 @@
"resize",
"render"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "wsl",
- "remote-runtime"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap on main. The targeted-hasPty product hardening and no-hot count assertions exist only on the pending reliability stack. It registers here with its owning split PR.",
@@ -1260,18 +1333,8 @@
"provider ownership",
"diagnostics bundle"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "wsl",
- "remote-runtime"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": [],
"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.",
@@ -1331,20 +1394,9 @@
"xterm scheduler",
"hidden output"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse, idle-path serve-sim detection, and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.",
"motivatingLinks": [
@@ -1492,15 +1544,8 @@
"provider ownership",
"startup restore"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "daemon",
- "local"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["daemon", "local"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap on main. The fail-closed degraded-daemon hardening and its contracts exist only on the pending reliability stack. It registers here with its owning split PR.",
@@ -1557,16 +1602,8 @@
"reattach",
"unknown liveness"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "ssh",
- "remote-runtime",
- "wsl"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["ssh", "remote-runtime", "wsl"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap on main. The SSH unknown-liveness hardening and deferred-reattach contracts exist only on the pending reliability stack. It registers here with its owning split PR.",
@@ -1628,12 +1665,8 @@
"provider liveness",
"restore"
],
- "platforms": [
- "windows"
- ],
- "providers": [
- "wsl"
- ],
+ "platforms": ["windows"],
+ "providers": ["wsl"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap only; no executable coverage is wired yet.",
@@ -1691,17 +1724,8 @@
"metadata-only replay",
"WebGL recovery"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "remote-runtime"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap on main. The replay FIFO/burst coalescing product change and its tests exist only on the pending reliability stack; main still uses a single pendingReplayData slot. It registers here with its owning split PR.",
@@ -1760,22 +1784,11 @@
"keyboard bypass",
"JIS yen"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
+ "coveredPlatforms": ["macos", "linux"],
"coveredProviders": [],
- "coverageNotes": "Local macOS evidence on main@1282f5c2d, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy, and a local Electron/CDP live-PTY Sogou-style repro. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
+ "coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, and Electron/CDP live-PTY Sogou-style repros. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6699",
"https://github.com/stablyai/orca/pull/6682",
@@ -1783,9 +1796,10 @@
"https://github.com/stablyai/orca/pull/6999"
],
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
- "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary post-IME typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit candidate selectors submit only the committed Chinese text. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
+ "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
],
"testFiles": [
@@ -1794,6 +1808,7 @@
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
+ "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"tests/e2e/chinese-ime-chat-input-repro.spec.ts"
@@ -1814,9 +1829,7 @@
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
- "assertions": [
- "paste/runtime forwarding avoids duplicate terminal payloads"
- ]
+ "assertions": ["paste/runtime forwarding avoids duplicate terminal payloads"]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
@@ -1834,6 +1847,14 @@
"fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release"
]
},
+ {
+ "file": "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
+ "assertions": [
+ "an orphaned plain-letter keyup arms exactly the next bare digit guard",
+ "ordinary, overlapping, shifted, and long-held letter keydowns keep following digits available",
+ "physical letter tracking survives cross-pane focus handoff and clears on renderer-window blur"
+ ]
+ },
{
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
"assertions": [
@@ -1852,11 +1873,39 @@
"assertions": [
"Sogou-style Space candidate selection submits only the committed Chinese character",
"Sogou-style digit candidate selection submits only the committed Chinese phrase",
- "Post-composition Sogou-style digit selection stays out of the PTY after compositionend"
+ "Post-composition Sogou-style digit selection stays out of the PTY after compositionend",
+ "Legacy orphaned-letter-keyup digit selection submits no selector byte to the PTY; real legacy candidate commit preservation remains a platform gap"
]
}
],
"evidenceRuns": [
+ {
+ "date": "2026-07-11",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.619,
+ "summary": "1 state test file and 10 tests passed on macOS, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests."
+ },
+ {
+ "date": "2026-07-11",
+ "runner": "local",
+ "platform": "linux",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.747,
+ "summary": "1 state test file and 10 tests passed in Debian 12 arm64 Docker under Node 24, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests."
+ },
+ {
+ "date": "2026-07-11",
+ "runner": "local",
+ "platform": "linux",
+ "command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
+ "result": "passed",
+ "durationSeconds": 30.3,
+ "summary": "Debian 12 arm64 Docker with Node 24 and Xvfb passed both live Electron/PTY IME scenarios; the orphaned-letter-keyup candidate digit submitted no selector byte, and the real-Codex opt-in scenario was skipped."
+ },
{
"date": "2026-07-07",
"runner": "local",
@@ -1890,7 +1939,7 @@
},
"performanceBudget": {
"required": true,
- "evidence": "Terminal IME hot-path audit clean on 2026-07-07: candidate guard adds O(1) boolean checks plus a bounded Space/digit release map; composition listeners are disposed with pane lifecycle; no polling, IPC fan-out, subprocess work, or SSH/remote transport cost added."
+ "evidence": "Terminal IME hot-path audit clean on 2026-07-11: candidate guards add O(1) boolean checks plus bounded per-pane Space/digit state and one renderer-scoped currently pressed physical-letter set; ref-counted renderer and pane blur listeners are disposed with pane lifecycle; no per-pane keyboard-event fan-out, polling, IPC, subprocess work, or SSH/remote transport cost added."
},
"promotionCriteria": [
"Cover deterministic byte/cell oracles first.",
@@ -1917,14 +1966,8 @@
"TUI exit",
"standard key input"
],
- "platforms": [
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "wsl"
- ],
+ "platforms": ["windows"],
+ "providers": ["local", "daemon", "wsl"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap only; no executable coverage is wired yet.",
@@ -1981,32 +2024,21 @@
"WSL",
"remote runtime"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "wsl",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
"coveredProviders": [],
- "coverageNotes": "Deterministic main/renderer tests run on macOS and exercise simulated Windows plus POSIX/Linux process-confirmation behavior. They cover exact local-ConPTY membership, detached-child rejection, unchanged SSH/WSL/remote fallback, process-confirmed Droid routing and launch-triggered confirmation, typed-alias and forged-OSC isolation, shells without OSC 133, split/detach ownership with preserved shell classification, stale PTY exit/rebind rejection, command-generation revocation, daemon v21 warm-reattach identity, unavailable inspection, and lazy subprocess/RPC callback counts. The Electron byte test runs only on Windows; live Windows and Linux PTY evidence remains uncollected. Test failures report exact expected bytes; no product telemetry or raw terminal logging is added.",
+ "coverageNotes": "Deterministic main/renderer tests run on macOS and exercise simulated Windows plus POSIX/Linux process-confirmation behavior. They cover exact local-ConPTY membership, detached-child rejection, SSH and paired-runtime host-platform routing including legacy runtime PTY IDs, active-PTY ownership after worktree host changes, Windows-to-WSL routing, unknown-metadata fallback, production handler composition, KKP authorization on every host, process-confirmed Droid routing and launch-triggered confirmation, typed-alias and forged-OSC isolation, shells without OSC 133, split/detach ownership with preserved shell classification, stale PTY exit/rebind rejection, command-generation revocation, daemon v21 warm-reattach identity, unavailable inspection, and lazy subprocess/RPC callback counts. A live linux-arm64 Docker target additionally proved real relay deployment, SSH PTY host detection, and exact inactive/active KKP bytes. The Electron byte test runs only on Windows; live Windows evidence remains uncollected. Test failures report exact expected bytes; no product telemetry or raw terminal logging is added.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/7620",
"https://github.com/stablyai/orca/pull/7668"
],
- "invariant": "Shift+Enter sends CSI-u only when the active pane is a genuine local Windows ConPTY with allowlisted Droid ownership or fresh routing-trusted Droid process evidence. Every new command and PTY replacement revokes stale routing until current evidence settles. All other Windows panes preserve Esc+CR; non-Windows panes remain CSI-u; SSH/WSL/remote behavior is unchanged; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no agent or ConPTY lookup.",
- "oracle": "Main/renderer tests assert exact bytes for local and non-local Windows plus non-Windows policy cells. Fresh Windows process evidence authorizes bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.",
+ "invariant": "Shift+Enter key recognition follows the client OS, while emitted bytes follow the active application and PTY host: active Kitty keyboard protocol authorizes CSI-u on every host; otherwise Windows hosts use Esc+CR except when allowlisted Droid ownership or fresh routing-trusted Droid process evidence authorizes CSI-u, and non-Windows hosts use Esc+CR. The active SSH connection, remote-runtime PTY owner, or live local session outranks later worktree ownership changes, and missing host-platform metadata falls back to the client. Every new command and PTY replacement revokes stale agent routing until current evidence settles; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no host, agent, protocol, or ConPTY lookup.",
+ "oracle": "Renderer tests assert exact bytes across client/PTY-host combinations, including Kitty keyboard protocol active and inactive on Windows and non-Windows hosts, and prove SSH identity, the environment encoded in an active remote-runtime PTY id, and live local-session identity outrank current worktree ownership, while unavailable platform metadata falls back to the client. A live linux-arm64 SSH PTY independently captured Esc+CR with KKP inactive and CSI-u with KKP active. Fresh Windows process evidence authorizes Droid bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-cascades.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts",
- "pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts"
+ "pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts"
],
"testFiles": [
"src/main/daemon/daemon-foreground-confirmation-protocol.test.ts",
@@ -2021,9 +2053,12 @@
"src/main/providers/local-pty-provider.test.ts",
"src/main/providers/windows-conpty-process-membership.test.ts",
"src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts",
+ "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
+ "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
+ "src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts",
"src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts",
"src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts",
"src/renderer/src/store/slices/store-cascades.test.ts",
@@ -2093,12 +2128,33 @@
"one fresh command-finish scan confirms real shell exit, while unavailable inspection performs exactly three bounded reads and never claims shell"
]
},
+ {
+ "file": "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts",
+ "assertions": [
+ "the production keyboard-policy boundary requires active-host routing and composes macOS Shift+Enter with Windows PTY bytes"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
+ "assertions": [
+ "a legacy remote PTY id retains the transport's captured runtime environment identity"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts",
+ "assertions": [
+ "SSH terminals use remote-platform metadata and execution-host identity when the active transport has not captured a connection id",
+ "encoded and legacy active remote-runtime PTY identities plus a live local PTY session outrank later worktree host changes",
+ "a live WSL session normalizes to a Linux terminal host",
+ "missing SSH/runtime platform metadata and local terminals fall back to the client platform"
+ ]
+ },
{
"file": "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts",
"assertions": [
"Windows defaults and non-Droid agents preserve Esc+CR while explicit Droid capability selects CSI-u",
- "SSH, WSL, and remote Windows-client panes preserve Esc+CR without consulting the Droid capability",
- "non-Windows Shift+Enter remains CSI-u and unrelated keys invoke neither agent nor ConPTY callbacks"
+ "Windows and non-Windows PTYs reached cross-OS use CSI-u only with active Kitty keyboard protocol unless trusted Windows Droid evidence independently authorizes it",
+ "unrelated keys invoke neither host, agent, nor protocol callbacks"
]
},
{
@@ -2199,20 +2255,20 @@
},
"performanceBudget": {
"required": true,
- "evidence": "Callback-count tests prove unrelated ordinary keys invoke neither ConPTY nor agent resolution. Windows Shift+Enter performs two store reads: one local-ConPTY classification with an O(tabs-in-worktree) tab lookup, then two pane-keyed O(1) ownership lookups. Typed text performs no routing write. Ordinary fast OSC 133;C-to-D shell commands perform no fresh confirmation RPC or process scan. An accepted submit/interrupt while trusted Droid is current, a typed allowlisted agent command, title exit, focus, visibility transition, or an idle burst after trusted Droid Shift+Enter can schedule the bounded confirmation ladder; each event cancels/deduplicates prior work and no timer recurs after settling. Windows confirmation performs one fresh process-table scan and forks exact ConPTY membership only after a recognized agent candidate appears; no recognized candidate makes zero helper calls. Same-turn fresh requests share one scan and later arrivals queue at most one successor. Recovery remains three bounded one-shot reads (350 ms, 1.2 s, 6 s). No polling loop, provider fanout, persistent listener, hidden-pane wake loop, or per-printable-key work is added."
+ "evidence": "Callback-count tests prove unrelated ordinary keys invoke neither host, agent, nor Kitty-protocol resolution. Host and agent callbacks are created once per keyboard effect, so printable input adds no per-key allocations or store reads. Shift+Enter performs bounded synchronous active-transport and indexed store lookups, plus at most one pane-keyed protocol-mode lookup when trusted Windows Droid evidence does not already authorize CSI-u, with no IPC, RPC, subprocess, polling, listener, or timer. Typed text performs no routing write. Ordinary fast OSC 133;C-to-D shell commands perform no fresh confirmation RPC or process scan. An accepted submit/interrupt while trusted Droid is current, a typed allowlisted agent command, title exit, focus, visibility transition, or an idle burst after trusted Droid Shift+Enter can schedule the bounded confirmation ladder; each event cancels/deduplicates prior work and no timer recurs after settling. Windows confirmation performs one fresh process-table scan and forks exact ConPTY membership only after a recognized agent candidate appears; no recognized candidate makes zero helper calls. Same-turn fresh requests share one scan and later arrivals queue at most one successor. Recovery remains three bounded one-shot reads (350 ms, 1.2 s, 6 s). No polling loop, provider fanout, persistent listener, hidden-pane wake loop, or per-printable-key work is added."
},
"promotionCriteria": [
"Run the exact PTY-byte Electron oracle on Windows without silent platform skips.",
- "Collect stable runtime and flake history for local and daemon Windows ConPTY while retaining deterministic unchanged-provider assertions for SSH, WSL, and remote runtime.",
+ "Collect stable runtime and flake history for local and daemon Windows ConPTY while retaining deterministic host-routing assertions for SSH, WSL, and remote runtime.",
"Attach saved intentional-break evidence for Droid capability removal and split-sibling identity leakage."
],
"knownGaps": [
- "No Windows runner or live Linux PTY evidence is attached; the Electron byte test skips on macOS and Linux, while Linux behavior is covered only by deterministic POSIX unit paths.",
- "Droid inside SSH, WSL, or remote-runtime intentionally keeps the pre-existing Windows-client Esc+CR behavior because local foreground confirmation is unavailable; this PR narrows the exception to issue #7620's local ConPTY surface.",
+ "No Windows runner or live Windows PTY evidence is attached; the Electron byte test skips on macOS and Linux, while a live Linux SSH PTY covers only the remote Linux host path.",
+ "Droid agent-specific CSI-u authorization is available only from trusted pane evidence; without it, Windows and non-Windows PTY hosts depend on the application's Kitty keyboard protocol state.",
"The Electron test injects allowlisted pane identity rather than launching the real Droid and Antigravity binaries.",
"If both Windows process-enumeration backends remain unavailable through the bounded ladder, routing fails closed to the legacy Esc+CR path; no live Droid artifact covers that degraded host configuration.",
"Protocol v21 restarts older daemons because the minimal persisted launchAgent is a new contract; sessions preserved by pre-v21 daemons cannot supply warm-reattach launch ownership and fail closed until current process confirmation or restart.",
- "No live daemon, SSH, WSL, remote-runtime, paired-web, or restore/replay byte artifact has been collected.",
+ "No live daemon, WSL, remote-runtime, paired-web, or restore/replay byte artifact has been collected; SSH is covered only by a macOS-client-to-Linux-host relay run.",
"A running PTY keeps its spawn-time ORCA_PANE_KEY after pane-to-tab detach. This change transfers launch/process routing evidence and retires the stale hook snapshot, but does not redesign future hook reattribution across tab IDs."
],
"demotionRule": "Keep non-blocking or demote to protection none if the Windows byte test silently skips, flakes without an actionable artifact, routes bytes using tab/title/terminal-output heuristics, or adds any ordinary-key lookup, IPC, polling, scan, or lifecycle leak."
@@ -2231,29 +2287,12 @@
"PTY identity",
"foreground process confirmation"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "wsl",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
- "coveredProviders": [
- "local",
- "daemon"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local", "daemon"],
"coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal on a shell conflict.",
- "motivatingLinks": [
- "https://github.com/stablyai/orca/issues/8303"
- ],
+ "motivatingLinks": ["https://github.com/stablyai/orca/issues/8303"],
"invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.",
"oracle": "Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.",
"commands": [
@@ -2305,9 +2344,7 @@
},
{
"file": "src/main/providers/local-pty-provider.test.ts",
- "assertions": [
- "fresh confirmation is discarded when its owning local PTY exits"
- ]
+ "assertions": ["fresh confirmation is discarded when its owning local PTY exits"]
},
{
"file": "src/main/providers/windows-conpty-process-membership.test.ts",
@@ -2330,9 +2367,7 @@
},
{
"file": "src/renderer/src/lib/active-agent-note-send.test.ts",
- "assertions": [
- "selected active-agent note sends retain guarded paste and submit routing"
- ]
+ "assertions": ["selected active-agent note sends retain guarded paste and submit routing"]
},
{
"file": "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx",
@@ -2396,27 +2431,12 @@
"PTY writes",
"bracketed paste"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
- "coveredProviders": [
- "local"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local"],
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract and a live dev-runtime CLI repro. SSH, daemon, remote-runtime, Linux, and Windows remain provider/platform gaps; the product path stays provider-owned and does not add local filesystem or process assumptions.",
- "motivatingLinks": [
- "https://github.com/stablyai/orca/issues/7226"
- ],
+ "motivatingLinks": ["https://github.com/stablyai/orca/issues/7226"],
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and send Enter only after the paste frame completes.",
"oracle": "Runtime tests assert the exact PTY write sequence and failure cleanup; orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness dispatches a 32KB task to a fake Codex-like TUI and requires marker present, bracketed paste present, zero unframed line breaks, and submit observed.",
"commands": [
@@ -2535,14 +2555,8 @@
"cursor repaint",
"rewrite output"
],
- "platforms": [
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "wsl"
- ],
+ "platforms": ["windows"],
+ "providers": ["local", "daemon", "wsl"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap only; no executable coverage is wired yet.",
@@ -2578,10 +2592,7 @@
"Keep stress cases non-blocking until Windows runtime history is stable.",
"Fail promotion on silent Windows environment skips."
],
- "knownGaps": [
- "No manifest command yet.",
- "No Windows CJK/emoji repaint command is wired."
- ],
+ "knownGaps": ["No manifest command yet.", "No Windows CJK/emoji repaint command is wired."],
"demotionRule": "Cannot promote if the oracle is screenshot-only or environment-skipped."
},
{
@@ -2599,14 +2610,8 @@
"local provider",
"daemon provider"
],
- "platforms": [
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "wsl"
- ],
+ "platforms": ["windows"],
+ "providers": ["local", "daemon", "wsl"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap only; no executable coverage is wired yet.",
@@ -2661,17 +2666,8 @@
"renderer CPU",
"resize churn"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "remote-runtime"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap only; no executable coverage is wired yet.",
@@ -2719,21 +2715,9 @@
"protection": "none",
"owner": "terminal-performance",
"layer": "daemon-provider-contract",
- "surfaces": [
- "daemon stream",
- "socket write",
- "drain",
- "hidden output",
- "input starvation"
- ],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "daemon"
- ],
+ "surfaces": ["daemon stream", "socket write", "drain", "hidden output", "input starvation"],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["daemon"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap on main. The daemon batcher write(false)/drain contracts exist only on the pending reliability stack. It registers here with its owning split PR.",
@@ -2791,23 +2775,12 @@
"workspace switch",
"agent status"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "remote-runtime"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap on main. The boot-hydration counters and their tests exist only on the pending reliability stack. It registers here with its owning split PR.",
- "motivatingLinks": [
- "https://github.com/stablyai/orca/pull/7002"
- ],
+ "motivatingLinks": ["https://github.com/stablyai/orca/pull/7002"],
"invariant": "Terminal typing, focus, resize, tab/workspace switch, and agent/session restore must not trigger unbounded store projection, git status, provider listing, or per-pane polling work.",
"oracle": "The current executable slice instruments boot-time local PTY registry hydration with repo counts, local-vs-remote repo skips, worktree enumeration counts, adapter/session listing counts, registration/skipped-session counts, duration, and failure phase. The broader oracle still needs instrumentation that counts store selector recomputes, git status requests, provider listings, and session scans during scripted hot interactions with many worktrees and terminal panes.",
"commands": [],
@@ -2856,20 +2829,9 @@
"metadata-only replay",
"clear semantics"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence over the merged #7133/#7173 restore and hidden-output ordering tests on main@1282f5c2d. The dirty-state exactness contract, normal-buffer clear semantics, and metadata-only replay remain pending-stack work.",
"motivatingLinks": [
@@ -2884,9 +2846,7 @@
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts"
],
- "testFiles": [
- "src/renderer/src/components/terminal-pane/pty-connection.test.ts"
- ],
+ "testFiles": ["src/renderer/src/components/terminal-pane/pty-connection.test.ts"],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
@@ -2954,23 +2914,12 @@
"renderer stream",
"terminal colors"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "remote-runtime"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap only; no executable coverage is wired yet.",
- "motivatingLinks": [
- "https://github.com/stablyai/orca/pull/6949"
- ],
+ "motivatingLinks": ["https://github.com/stablyai/orca/pull/6949"],
"invariant": "Startup OSC 10/11 color queries are answered out of band at startup only, never leak into shell/provider output streams, and ordinary runtime OSC color queries remain renderer-handled.",
"oracle": "A provider-contract fixture records startup query replies, shell-visible bytes, renderer-visible bytes, and later runtime OSC behavior to prove no query leakage or color deadlock.",
"commands": [],
@@ -2998,10 +2947,7 @@
"Cover startup-only and runtime OSC paths separately.",
"Keep screenshot evidence diagnostic only."
],
- "knownGaps": [
- "No manifest command yet.",
- "No startup color-query contract is wired."
- ],
+ "knownGaps": ["No manifest command yet.", "No startup color-query contract is wired."],
"demotionRule": "Cannot promote if success is based only on absence of visible artifacts."
},
{
@@ -3018,21 +2964,9 @@
"mobile subscription replay",
"multi-mobile input floor"
],
- "platforms": [
- "macos",
- "linux",
- "windows",
- "mobile"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "platforms": ["macos", "linux", "windows", "mobile"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Deterministic local tests execute the exact injected mobile replay/generation gate, the React Native query classifier, stale-subscription sender, server-side single-responder election, query-reply RPC semantics, and live-output capture during async mobile fit. The provider write path is shared, but no live iOS/Android, SSH, WSL, or multi-device run is registered yet.",
"motivatingLinks": [
@@ -3163,21 +3097,9 @@
"JSON subscribe fallback",
"snapshot buffering"
],
- "platforms": [
- "macos",
- "linux",
- "windows",
- "mobile"
- ],
- "providers": [
- "remote-runtime",
- "ssh",
- "local",
- "daemon"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "platforms": ["macos", "linux", "windows", "mobile"],
+ "providers": ["remote-runtime", "ssh", "local", "daemon"],
+ "coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.",
"motivatingLinks": [
@@ -3273,20 +3195,9 @@
"unicode width",
"parser configuration"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "remote-runtime"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence over #7148's width-parity oracle on main@1282f5c2d. A broader byte corpus, a shared parser-construction assertion over terminal-unicode-provider.ts/pane-terminal-options.ts, and recorded agent-session corpora remain gaps.",
"motivatingLinks": [
@@ -3298,9 +3209,7 @@
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/headless-emulator-unicode-width.test.ts"
],
- "testFiles": [
- "src/main/daemon/headless-emulator-unicode-width.test.ts"
- ],
+ "testFiles": ["src/main/daemon/headless-emulator-unicode-width.test.ts"],
"assertionRefs": [
{
"file": "src/main/daemon/headless-emulator-unicode-width.test.ts",
@@ -3357,23 +3266,9 @@
"protection": "none",
"owner": "terminal-rendering",
"layer": "renderer-observability",
- "surfaces": [
- "hidden-output restore",
- "snapshot replay",
- "anomaly breadcrumbs",
- "telemetry"
- ],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon",
- "ssh",
- "remote-runtime"
- ],
+ "surfaces": ["hidden-output restore", "snapshot replay", "anomaly breadcrumbs", "telemetry"],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap only; the anomaly-breadcrumb machinery exists in this branch but no convergence probe is implemented.",
@@ -3430,15 +3325,8 @@
"window wake",
"render model"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon"],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap only; the live repro harness with a refresh-repair oracle exists but is not productized into the release-blocking terminal-rendering-golden suite.",
@@ -3492,18 +3380,9 @@
"renderer reload / crash recovery",
"surviving daemon and local PTYs"
],
- "platforms": [
- "macos",
- "linux",
- "windows"
- ],
- "providers": [
- "local",
- "daemon"
- ],
- "coveredPlatforms": [
- "macos"
- ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon"],
+ "coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Local macOS unit evidence over the shared main-process delivery pipeline in registerPtyHandlers. The oracle drives a local mock PTY, but the in-flight/pending accounting is provider-agnostic and daemon PTYs ride the identical pipeline (the manual dev repro that proved the freeze was daemon-backed). SSH is a separate relay/credit path and never enters these counters; mobile/relay ride unaffected paths; WSL terminals ride this same local/daemon pipeline and are covered by the same accounting (no dedicated provider-contract test yet). Motivated by a production incident on v1.4.131 (three frozen/broken terminal panes in one desktop session) diagnosed to leaked in-flight/pending accounting across a renderer reload (rendererGraphEpoch 3). Live validation of the fix on a rebuilt dev instance surfaced a second leak mode (the boot window): after the reset ran, main resumed flushing a flooding PTY into the still-booting page before its pty:data listener re-registered, so those sends were dropped yet counted and re-pinned rendererInFlightChars at 524288. The gate now also covers holding sends until the renderer's pty:rendererDispatcherReady handshake. A watchdog self-heals a lost handshake: on each reset a one-shot ~10s timer arms and, if the handshake never arrives, force-opens the gate (rendererDispatcherReadyForcedCount increments) so a dropped handshake degrades to pre-handshake behavior instead of a permanent hold; the real handshake or a re-registration cancels it. Two new diagnostics — rendererPtyDispatcherReady and rendererDispatcherReadyForcedCount — expose the otherwise-invisible boot-window hold, which early-returns before ackGatedFlushSkipCount increments.",
"motivatingLinks": [
@@ -3583,6 +3462,272 @@
],
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
},
+ {
+ "id": "runtime-files.watcher-process-isolation",
+ "title": "Runtime and SSH relay watcher faults stay process-isolated without disrupting host services",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "runtime-platform",
+ "layer": "process-rpc-contract",
+ "surfaces": [
+ "headless orca serve",
+ "paired file explorer",
+ "runtime editor external-change detection",
+ "desktop filesystem watcher",
+ "SSH relay filesystem watcher and live PTYs"
+ ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "remote-runtime", "ssh"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local", "remote-runtime", "ssh"],
+ "coverageNotes": "Deterministic tests cover a four-child healthy runtime pool plus at most four bounded quarantine children, one quarantine attempt per watch lifetime, queue-aware cancellation, bounded initial and crash-resubscribe crawls, physical desktop-install abort, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. A built-entry macOS harness drives the real supervisor, kills its child with SIGSEGV, then requires automatic resubscription and a later event. SSH now bundles the same child boundary beside relay.js, requires both artifacts for install completeness, and preserves registration-owned same-root cancellation. A built-relay harness kills that child and requires the original relay PID and existing PTY to survive before overflow and precise events resume.",
+ "motivatingLinks": [
+ "https://github.com/stablyai/orca/issues/5308",
+ "https://github.com/stablyai/orca/issues/7547",
+ "https://github.com/stablyai/orca/issues/8212"
+ ],
+ "invariant": "Local runtime and SSH relay roots use at most four shared watcher children while healthy and at most four additional quarantine children after shard failure. Cancelling queued setup removes only that request; cancelling an active crawl restarts one shard and restores its healthy records. Setup deadlines start only when the child owns the crawl; the runtime RPC transport owns the normal 15-second initial budget, while a 60-second recovery deadline quarantines a root that would otherwise pin its shard. A crash-fused shared shard moves affected roots into the bounded quarantine pool, and a second fused quarantine ends those watch lifetimes instead of spawning replacements indefinitely. Every interrupted setup emits overflow after recovery, event delivery stays bounded, and the SSH relay, PTYs, Git, and other filesystem operations never load the watcher native addon or share its fault boundary.",
+ "oracle": "Fill the four-child healthy pool with multiple roots, inject IPC disconnect-before-exit, queued and active setup cancellation, initial and crash-resubscribe timeouts, and shard-level terminal failure, then assert healthy roots are restored, affected roots spread across no more than four quarantine supervisors, a second quarantine fuse creates no replacement, stale generations stop delivering, and existing same-root clients receive overflow plus later events without error/end. Abort a pending desktop install and require worktree close and app shutdown to settle before the native crawl resolves. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts",
+ "pnpm run build:electron-vite",
+ "node config/scripts/runtime-file-watcher-fault-harness.mjs",
+ "ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs",
+ "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs",
+ "pnpm run build:relay",
+ "node config/scripts/relay-watcher-fault-harness.mjs"
+ ],
+ "testFiles": [
+ "src/main/ipc/parcel-watcher-process.test.ts",
+ "src/main/ipc/runtime-watcher-process-pool.test.ts",
+ "src/main/ipc/parcel-watcher-process-entry.test.ts",
+ "src/main/ipc/parcel-watcher-event-delivery.test.ts",
+ "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts",
+ "src/main/ipc/filesystem-watcher-wsl.test.ts",
+ "src/main/runtime/file-watcher-host.test.ts",
+ "src/main/runtime/rpc/methods/files.test.ts",
+ "src/main/providers/ssh-filesystem-provider.test.ts",
+ "src/renderer/src/runtime/runtime-file-client.test.ts",
+ "config/scripts/runtime-file-watcher-fault-harness.mjs",
+ "src/relay/relay-filesystem-watch-registry.test.ts",
+ "src/relay/fs-handler.test.ts",
+ "src/relay/subprocess.test.ts",
+ "src/main/ssh/ssh-relay-versioned-install.test.ts",
+ "config/scripts/package-electron-runtime-contract.test.mjs",
+ "config/scripts/relay-watcher-fault-harness.mjs"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/main/ipc/parcel-watcher-process.test.ts",
+ "assertions": [
+ "initial-subscribe crashes complete with an interruption refresh",
+ "IPC disconnect recovery restores every existing root before accepting new setup",
+ "queued abort removes one request without restarting healthy roots, while active abort or crawl-scoped timeout restores the shard",
+ "a crash-resubscribe crawl deadline ends only the stuck root and tears down its shard process",
+ "missing built entries fail closed and unavailable canary storage remains optional",
+ "fault-harness PID reporting cannot clobber an existing filesystem path"
+ ]
+ },
+ {
+ "file": "src/main/ipc/runtime-watcher-process-pool.test.ts",
+ "assertions": [
+ "healthy roots spread across at most four shared supervisors before sharing",
+ "a supervisor-scoped failure spreads affected roots across at most four quarantine supervisors",
+ "a second fused quarantine creates no further replacement for that watch lifetime",
+ "a root-specific failure preserves healthy shard assignments",
+ "a live root whose crash-resubscribe times out moves to quarantine",
+ "an older same-root teardown cannot erase a newer watch lease's assignment"
+ ]
+ },
+ {
+ "file": "src/main/ipc/parcel-watcher-process-entry.test.ts",
+ "assertions": [
+ "queued cancellation removes the request before Parcel starts its crawl",
+ "active cancellation requests one host restart and emits no stale subscribed acknowledgement",
+ "runtime batches receive directory metadata before child IPC",
+ "an FSEvents dropped-events error is reported as recoverable and later events continue",
+ "batches above the configured event limit become one overflow before stat or IPC fanout",
+ "while process.send is backpressured, later bursts retain at most one bounded pending batch and collapse excess paths to overflow",
+ "unsubscribe discards pending delivery before it can perform stale stat work"
+ ]
+ },
+ {
+ "file": "src/main/ipc/parcel-watcher-event-delivery.test.ts",
+ "assertions": [
+ "eight concurrent root batches never exceed eight active directory metadata stats within one child"
+ ]
+ },
+ {
+ "file": "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts",
+ "assertions": [
+ "worktree deletion physically aborts pending native setup and settles before its crawl resolves",
+ "app shutdown physically aborts pending native setup and releases a late native handle"
+ ]
+ },
+ {
+ "file": "src/main/runtime/file-watcher-host.test.ts",
+ "assertions": [
+ "same-root clients share one physical watch and one client abort cannot cancel another owner's setup",
+ "shard failure preserves existing subscribers through isolated recovery and rejects stale-generation events",
+ "only failed isolated recovery ends subscribers, after a conservative overflow"
+ ]
+ },
+ {
+ "file": "src/main/runtime/rpc/methods/files.test.ts",
+ "assertions": [
+ "pre-ready transport abort reaches RuntimeFileCommands as the owning AbortSignal",
+ "terminal watcher loss flushes a final overflow change before error/end and unregisters cleanup"
+ ]
+ },
+ {
+ "file": "src/main/providers/ssh-filesystem-provider.test.ts",
+ "assertions": [
+ "an SSH caller abort rejects promptly without cancelling another same-root subscriber's shared setup",
+ "the physical relay request is cancelled when the last pending subscriber leaves",
+ "provider disposal cancels pending setup and sends one remote unwatch"
+ ]
+ },
+ {
+ "file": "src/renderer/src/runtime/runtime-file-client.test.ts",
+ "assertions": [
+ "terminal error evicts the shared renderer watch before an error callback can synchronously retry",
+ "the old stream's later end cannot evict or orphan the replacement stream",
+ "the end response releases retained preload and main-process subscription bookkeeping",
+ "a later same-root listener opens a fresh server subscription instead of reusing a dead stream"
+ ]
+ },
+ {
+ "file": "src/relay/relay-filesystem-watch-registry.test.ts",
+ "assertions": [
+ "replacement emits overflow before later ordered events",
+ "terminal shard failure preserves same-root clients through recovery",
+ "pending setup aborts only after the last same-root client leaves",
+ "a missing child fails closed even if the relay inherits a VITEST environment"
+ ]
+ },
+ {
+ "file": "src/relay/fs-handler.test.ts",
+ "assertions": [
+ "shared watches retain the twenty-root budget and release after every client leaves"
+ ]
+ },
+ {
+ "file": "src/relay/subprocess.test.ts",
+ "assertions": [
+ "the real relay bundle serves PTY, filesystem, and Git requests with its adjacent watcher child"
+ ]
+ },
+ {
+ "file": "src/main/ssh/ssh-relay-versioned-install.test.ts",
+ "assertions": [
+ "remote completeness requires relay.js, relay-watcher.js, and the install sentinel"
+ ]
+ },
+ {
+ "file": "config/scripts/package-electron-runtime-contract.test.mjs",
+ "assertions": ["every release platform packages and gates the hashed watcher child"]
+ },
+ {
+ "file": "config/scripts/runtime-file-watcher-fault-harness.mjs",
+ "assertions": [
+ "the built watcher entry delivers an event before fault injection",
+ "the harness parent survives watcher-child SIGSEGV",
+ "the real supervisor automatically replaces the child, resubscribes, and delivers a post-crash event"
+ ]
+ },
+ {
+ "file": "config/scripts/relay-watcher-fault-harness.mjs",
+ "assertions": [
+ "the built relay and existing PTY survive watcher-child fault injection",
+ "replacement emits overflow and a precise post-crash event"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-07-12",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs",
+ "result": "passed",
+ "durationSeconds": 4.5,
+ "summary": "Five relay registry, handler, bundled subprocess, install, and package contract files passed with 114 tests."
+ },
+ {
+ "date": "2026-07-12",
+ "runner": "local",
+ "platform": "macos",
+ "command": "node config/scripts/relay-watcher-fault-harness.mjs",
+ "result": "passed",
+ "durationSeconds": 0.61,
+ "summary": "The relay PID and existing PTY survived watcher-child SIGSEGV; overflow and precise events resumed from a replacement child."
+ },
+ {
+ "date": "2026-07-12",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts",
+ "result": "passed",
+ "durationSeconds": 1.7,
+ "summary": "10 focused supervisor, pool, child-entry, delivery, desktop/WSL cleanup, runtime-host, RPC, SSH ownership, and renderer-client test files passed with 223 tests."
+ },
+ {
+ "date": "2026-07-12",
+ "runner": "local",
+ "platform": "macos",
+ "command": "node config/scripts/runtime-file-watcher-fault-harness.mjs",
+ "result": "passed",
+ "durationSeconds": 5,
+ "summary": "All 26 built-entry SIGSEGV injections preserved the parent, replaced and resubscribed the child, and delivered a post-crash event; watcher-canary temp-directory count remained 6 before and after the final five-run leak check."
+ },
+ {
+ "date": "2026-07-11",
+ "runner": "local",
+ "platform": "macos",
+ "command": "node config/scripts/runtime-file-watcher-fault-harness.mjs",
+ "result": "passed",
+ "durationSeconds": 0.3,
+ "summary": "The source-bundled real supervisor delivered through the built child before and after SIGSEGV; the parent survived and automatic resubscription used a replacement PID."
+ },
+ {
+ "date": "2026-07-11",
+ "runner": "local",
+ "platform": "macos",
+ "command": "node config/scripts/runtime-file-watcher-fault-harness.mjs",
+ "result": "passed",
+ "durationSeconds": 2.2,
+ "summary": "Ten consecutive invocations survived, automatically replaced and resubscribed the child, and delivered a post-crash event. The watcher-canary temp-directory count remained 8 before and after the loop."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 60,
+ "scope": "focused process/RPC contract tests plus built child fault harness"
+ },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "New deterministic unit coverage and ten consecutive local built-entry harness passes; still below the CI and soak history required for promotion."
+ },
+ "redGreenEvidence": {
+ "status": "partial",
+ "evidence": "Red tests reproduced the reviewed failures: IPC disconnect-before-exit orphaned existing roots, queue-admission deadlines cascaded shard replacement, a fused shard created one child per root, destructive desktop cleanup waited indefinitely for native setup, and synchronous renderer retry joined a terminal stream. Earlier red coverage also reproduced missing-entry fail-open, terminal recovery loss, stale same-root lease deletion, and unbounded per-child stat lanes. The bounded quarantine and cancellation rewrite passes those contracts, and the real-supervisor built-entry harness survives SIGSEGV with automatic replacement. CI/soak history is still absent."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Healthy operation is capped at four shared child processes instead of one child per root; based on the prior 51.9 MiB one-child measurement this bounds the unremeasured eight-root healthy ceiling near 207.6 MiB rather than the rejected 388.7 MiB per-root design. Degraded operation adds at most four quarantine children, distributes affected roots by least load, disposes empty slots, and permits no replacement after a quarantine shard itself fuses. Each child caps directory stats at eight across its assigned roots (at most 32 healthy or 64 total logical stat operations), each subscription retains at most one active plus one bounded pending batch, and no polling or serve-process stat fanout is added."
+ },
+ "promotionCriteria": [
+ "Run the built-entry SIGSEGV harness in soak for at least 100 consecutive passes or 14 days on macOS.",
+ "Add an eight-root/six-writer event-loop-delay soak artifact with an explicit latency budget.",
+ "Add a packaged orca serve pairing scenario that proves a connected client refreshes after child replacement."
+ ],
+ "knownGaps": [
+ "The fault harness bundles the real source supervisor and drives the built child, but it does not launch a packaged orca serve pairing session or exercise app.asar path resolution.",
+ "The four-child healthy and four-child quarantine memory ceilings are inferred from the earlier one-child measurement; direct degraded-state RSS plus sustained six-writer event-loop-delay and CPU soak evidence is still absent.",
+ "Linux native watcher fault containment is source-equivalent but not live fault-injected in this evidence run.",
+ "Windows runtime watches bypass this path; SSH registration ownership is deterministic-contract tested but not live-relay fault-injected."
+ ],
+ "demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds four runtime watcher children, if total healthy plus quarantine operation exceeds eight, if quarantine children outlive their roots or repeat after fusing, if event delivery becomes unbounded, or if metadata/stat work returns to the serve process."
+ },
{
"id": "terminal-session.authoritative-exit-status",
"title": "Install ownership requires an authoritative successful terminal exit",
diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs
index c9cabcea838..cb7e01c9700 100644
--- a/config/scripts/build-relay.mjs
+++ b/config/scripts/build-relay.mjs
@@ -1,9 +1,9 @@
#!/usr/bin/env node
/**
- * Bundle the relay daemon into a single relay.js file per platform.
+ * Bundle the relay daemon and its crash-isolated watcher child per platform.
*
- * The relay runs on remote hosts via `node relay.js`, so it must be a
- * self-contained CommonJS bundle with no external dependencies beyond
+ * The relay runs on remote hosts via `node relay.js`, so both outputs use
+ * self-contained CommonJS bundles with no external dependencies beyond
* Node.js built-ins. Native addons (node-pty, @parcel/watcher) are
* marked external and expected to be installed on the remote or
* gracefully degraded.
@@ -17,6 +17,7 @@ const __dirname = import.meta.dirname
// Why: the script lives under config/scripts, so go two levels up to reach the repo root.
const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
+const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const PLATFORMS = [
'linux-x64',
@@ -42,7 +43,22 @@ for (const platform of PLATFORMS) {
outfile: join(outDir, 'relay.js'),
// Native addons cannot be bundled — they must exist on the remote host.
// The relay gracefully degrades when they are absent.
- external: ['node-pty', '@parcel/watcher'],
+ external: ['node-pty', '@parcel/watcher', 'electron'],
+ sourcemap: false,
+ minify: true,
+ define: {
+ 'process.env.NODE_ENV': '"production"'
+ }
+ })
+
+ await build({
+ entryPoints: [WATCHER_ENTRY],
+ bundle: true,
+ platform: 'node',
+ target: 'node18',
+ format: 'cjs',
+ outfile: join(outDir, 'relay-watcher.js'),
+ external: ['@parcel/watcher'],
sourcemap: false,
minify: true,
define: {
@@ -51,9 +67,15 @@ for (const platform of PLATFORMS) {
})
// Why: include a content hash so the deploy check detects code changes
- // even when RELAY_VERSION hasn't been bumped (common during development).
+ // even when RELAY_VERSION hasn't been bumped. Hash both process artifacts
+ // so a watcher-only change always deploys beside the matching relay host.
const relayContent = readFileSync(join(outDir, 'relay.js'))
- const hash = createHash('sha256').update(relayContent).digest('hex').slice(0, 12)
+ const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
+ const hash = createHash('sha256')
+ .update(relayContent)
+ .update(watcherContent)
+ .digest('hex')
+ .slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs
index 82b408a336f..16747b1ad3d 100644
--- a/config/scripts/orchestration-skill-guidance.test.mjs
+++ b/config/scripts/orchestration-skill-guidance.test.mjs
@@ -198,6 +198,13 @@ describe('orchestration skill guidance', () => {
expect(messaging).toContain('`@grok`')
})
+ it('documents @cursor in the Messaging group address list', () => {
+ const skill = readSkill()
+ const messaging = getSection(skill, 'Messaging')
+
+ expect(messaging).toContain('`@cursor`')
+ })
+
it('keeps agent-first launch, handle recovery, and inbox injection distinct', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs
index 5e70cf68f42..e4a118a311f 100644
--- a/config/scripts/package-electron-runtime-contract.test.mjs
+++ b/config/scripts/package-electron-runtime-contract.test.mjs
@@ -91,6 +91,77 @@ describe('Electron runtime package contract', () => {
)
})
+ it('blocks Linux and macOS release packaging on watcher process fault recovery', () => {
+ const releaseWorkflow = parse(
+ readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
+ )
+ const macWorkflow = parse(
+ readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8')
+ )
+ const assertFaultGate = (steps, publishStepName, expectedCondition) => {
+ const names = steps.map((step) => step.name)
+ const gate = steps.find((step) => step.name === 'Gate runtime file-watcher process isolation')
+
+ expect(gate.if).toBe(expectedCondition)
+ expect(gate['continue-on-error']).toBeUndefined()
+ expect(gate.run).toContain('node config/scripts/runtime-file-watcher-fault-harness.mjs')
+ expect(gate.run).toContain('ELECTRON_RUN_AS_NODE=1 pnpm exec electron')
+ expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name))
+ expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName))
+ }
+
+ assertFaultGate(
+ releaseWorkflow.jobs.build.steps,
+ 'Publish release artifacts (Linux)',
+ "runner.os == 'Linux'"
+ )
+ assertFaultGate(
+ macWorkflow.jobs['build-mac'].steps,
+ 'Publish release artifacts (macOS)',
+ undefined
+ )
+ })
+
+ it('packages and release-gates the SSH relay watcher child', () => {
+ const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8')
+ const builderConfig = readFileSync(
+ join(projectDir, 'config/electron-builder.config.cjs'),
+ 'utf8'
+ )
+ const remoteCommands = readFileSync(
+ join(projectDir, 'src/main/ssh/ssh-remote-commands.ts'),
+ 'utf8'
+ )
+ const releaseWorkflow = parse(
+ readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
+ )
+ const macWorkflow = parse(
+ readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8')
+ )
+
+ expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'")
+ expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')")
+ expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))")
+ expect(builderConfig).toContain("from: 'out/relay'")
+ expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')")
+
+ const assertRelayGate = (steps, publishStepName) => {
+ const names = steps.map((step) => step.name)
+ const gate = steps.find((step) => step.name === 'Gate SSH relay watcher process isolation')
+ expect(gate['continue-on-error']).toBeUndefined()
+ expect(gate.run).toContain('node config/scripts/relay-watcher-fault-harness.mjs')
+ expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name))
+ expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName))
+ }
+
+ assertRelayGate(releaseWorkflow.jobs.build.steps, 'Publish release artifacts (Linux)')
+ assertRelayGate(macWorkflow.jobs['build-mac'].steps, 'Publish release artifacts (macOS)')
+ const releaseNames = releaseWorkflow.jobs.build.steps.map((step) => step.name)
+ expect(releaseNames.indexOf('Gate SSH relay watcher process isolation')).toBeLessThan(
+ releaseNames.indexOf('Build Windows release artifacts')
+ )
+ })
+
it('pins the Windows release builder to the VS 2022 runner image', () => {
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs
new file mode 100644
index 00000000000..c86ba764979
--- /dev/null
+++ b/config/scripts/relay-watcher-fault-harness.mjs
@@ -0,0 +1,286 @@
+import { build } from 'esbuild'
+import { spawn } from 'node:child_process'
+import { createRequire } from 'node:module'
+import { existsSync } from 'node:fs'
+import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+
+const WAIT_TIMEOUT_MS = 20_000
+const require = createRequire(import.meta.url)
+
+function withTimeout(promise, label, stderr) {
+ return new Promise((resolvePromise, rejectPromise) => {
+ const timer = setTimeout(() => {
+ rejectPromise(new Error(`Timed out waiting for ${label}\n${stderr()}`))
+ }, WAIT_TIMEOUT_MS)
+ promise.then(
+ (value) => {
+ clearTimeout(timer)
+ resolvePromise(value)
+ },
+ (error) => {
+ clearTimeout(timer)
+ rejectPromise(error)
+ }
+ )
+ })
+}
+
+function pollUntil(readValue, label, stderr) {
+ const deadline = Date.now() + WAIT_TIMEOUT_MS
+ return new Promise((resolveValue, rejectValue) => {
+ const poll = async () => {
+ try {
+ const value = await readValue()
+ if (value !== undefined) {
+ resolveValue(value)
+ return
+ }
+ } catch {}
+ if (Date.now() >= deadline) {
+ rejectValue(new Error(`Timed out waiting for ${label}\n${stderr()}`))
+ return
+ }
+ setTimeout(poll, 10)
+ }
+ void poll()
+ })
+}
+
+function waitForExit(proc) {
+ if (proc.exitCode !== null || proc.signalCode !== null) {
+ return Promise.resolve()
+ }
+ return new Promise((resolveExit) => proc.once('exit', resolveExit))
+}
+
+async function loadProtocol(bundleDir) {
+ const outfile = join(bundleDir, 'relay-protocol.cjs')
+ await build({
+ entryPoints: [resolve('src/relay/protocol.ts')],
+ bundle: true,
+ platform: 'node',
+ format: 'cjs',
+ outfile,
+ logLevel: 'silent'
+ })
+ return require(outfile)
+}
+
+function createRelayClient(entryPath, args, env, protocol) {
+ const proc = spawn(process.execPath, [entryPath, ...args], {
+ cwd: dirname(entryPath),
+ env,
+ stdio: ['pipe', 'pipe', 'pipe']
+ })
+ const messages = []
+ let stderr = ''
+ let nextSequence = 1
+ let stdoutBuffer = Buffer.alloc(0)
+ let ready = false
+ let resolveReady
+ const sentinelReceived = new Promise((resolvePromise) => {
+ resolveReady = resolvePromise
+ })
+ const decoder = new protocol.FrameDecoder((frame) => {
+ if (frame.type === protocol.MessageType.Regular) {
+ messages.push(protocol.parseJsonRpcMessage(frame.payload))
+ }
+ })
+ proc.stderr.on('data', (chunk) => {
+ stderr = `${stderr}${String(chunk)}`.slice(-8_000)
+ })
+ proc.stdout.on('data', (chunk) => {
+ if (ready) {
+ decoder.feed(chunk)
+ return
+ }
+ stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
+ const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
+ const index = stdoutBuffer.indexOf(sentinel)
+ if (index < 0) {
+ return
+ }
+ ready = true
+ resolveReady()
+ const remainder = stdoutBuffer.subarray(index + sentinel.length)
+ if (remainder.length > 0) {
+ decoder.feed(remainder)
+ }
+ })
+
+ const waitForMessage = (startIndex, predicate, label) =>
+ pollUntil(
+ () => messages.slice(startIndex).find(predicate),
+ label,
+ () => stderr
+ )
+
+ const request = async (method, params = {}) => {
+ const id = nextSequence++
+ const startIndex = messages.length
+ proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0))
+ const response = await waitForMessage(
+ startIndex,
+ (message) => message.id === id,
+ `response to ${method}`
+ )
+ if (response.error) {
+ throw new Error(`${method} failed: ${response.error.message}`)
+ }
+ return response.result
+ }
+
+ const notify = (method, params = {}) => {
+ const sequence = nextSequence++
+ proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, sequence, 0))
+ }
+
+ return {
+ proc,
+ request,
+ notify,
+ sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr),
+ messageCount: () => messages.length,
+ waitForNotification: (startIndex, method, predicate = () => true) =>
+ waitForMessage(
+ startIndex,
+ (message) => message.method === method && predicate(message.params ?? {}),
+ `${method} notification`
+ ),
+ stderr: () => stderr
+ }
+}
+
+async function waitForWatcherPid(pidFile, previousPid, stderr) {
+ return pollUntil(
+ async () => {
+ const pid = Number((await readFile(pidFile, 'utf8')).trim())
+ if (!Number.isInteger(pid) || pid <= 0 || pid === previousPid) {
+ return undefined
+ }
+ // Why: replacement children reuse this exclusive path after fault injection.
+ await rm(pidFile, { force: true })
+ return pid
+ },
+ previousPid ? 'replacement watcher child pid' : 'initial watcher child pid',
+ stderr
+ )
+}
+
+function includesWatchPath(params, targetPath) {
+ return Array.isArray(params.events)
+ ? params.events.some((event) => event.absolutePath === targetPath)
+ : false
+}
+
+async function main() {
+ const platform = `${process.platform}-${process.arch}`
+ const relayEntry = resolve('out', 'relay', platform, 'relay.js')
+ const watcherEntry = resolve('out', 'relay', platform, 'relay-watcher.js')
+ if (!existsSync(relayEntry) || !existsSync(watcherEntry)) {
+ throw new Error(`Missing built relay artifacts for ${platform}; run pnpm run build:relay first`)
+ }
+
+ let tempRoot
+ let relay
+ try {
+ tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-'))
+ const watchRoot = await realpath(tempRoot)
+ const pidFile = join(tempRoot, 'watcher.pid')
+ const protocol = await loadProtocol(tempRoot)
+ const socketPath =
+ process.platform === 'win32'
+ ? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}`
+ : join(tempRoot, 'relay.sock')
+ relay = createRelayClient(
+ relayEntry,
+ ['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')],
+ { ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
+ protocol
+ )
+ await relay.sentinelReceived
+
+ const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot })
+ const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}`
+ let startIndex = relay.messageCount()
+ relay.notify('pty.data', { id: spawned.id, data: `echo ${beforePtyMarker}\r` })
+ await relay.waitForNotification(
+ startIndex,
+ 'pty.data',
+ (params) => params.id === spawned.id && String(params.data).includes(beforePtyMarker)
+ )
+
+ await relay.request('fs.watch', { rootPath: watchRoot })
+ const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr)
+ const beforePath = join(watchRoot, 'before.txt')
+ startIndex = relay.messageCount()
+ await writeFile(beforePath, 'before')
+ await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
+ includesWatchPath(params, beforePath)
+ )
+
+ const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV'
+ startIndex = relay.messageCount()
+ process.kill(firstWatcherPid, faultSignal)
+ const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr)
+ await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
+ Array.isArray(params.events)
+ ? params.events.some(
+ (event) => event.kind === 'overflow' && event.absolutePath === watchRoot
+ )
+ : false
+ )
+
+ const status = await relay.request('relay.status')
+ if (status.pid !== relay.proc.pid) {
+ throw new Error('relay.status did not come from the original surviving relay process')
+ }
+ const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}`
+ startIndex = relay.messageCount()
+ relay.notify('pty.data', { id: spawned.id, data: `echo ${afterPtyMarker}\r` })
+ await relay.waitForNotification(
+ startIndex,
+ 'pty.data',
+ (params) => params.id === spawned.id && String(params.data).includes(afterPtyMarker)
+ )
+
+ const afterPath = join(watchRoot, 'after.txt')
+ startIndex = relay.messageCount()
+ await writeFile(afterPath, 'after')
+ await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
+ includesWatchPath(params, afterPath)
+ )
+
+ relay.notify('fs.unwatch', { rootPath: watchRoot })
+ await relay.request('pty.shutdown', { id: spawned.id })
+ console.log(
+ JSON.stringify({
+ relayPid: relay.proc.pid,
+ killedWatcherPid: firstWatcherPid,
+ replacementWatcherPid,
+ faultSignal,
+ relaySurvived: true,
+ existingPtySurvived: true,
+ overflowRefreshDelivered: true,
+ postCrashEventDelivered: true
+ })
+ )
+ } finally {
+ if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) {
+ relay.proc.kill('SIGTERM')
+ try {
+ await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr)
+ } catch {
+ relay.proc.kill('SIGKILL')
+ await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr)
+ }
+ }
+ if (tempRoot) {
+ await rm(tempRoot, { recursive: true, force: true })
+ }
+ }
+}
+
+await main()
diff --git a/config/scripts/runtime-file-watcher-fault-harness.mjs b/config/scripts/runtime-file-watcher-fault-harness.mjs
new file mode 100644
index 00000000000..88fec4245fb
--- /dev/null
+++ b/config/scripts/runtime-file-watcher-fault-harness.mjs
@@ -0,0 +1,180 @@
+import { createRequire } from 'node:module'
+import { existsSync } from 'node:fs'
+import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { build } from 'esbuild'
+
+const ENTRY_PATH = resolve('out/main/parcel-watcher-process-entry.js')
+const SUPERVISOR_SOURCE = resolve('src/main/ipc/parcel-watcher-process-supervisor.ts')
+const WAIT_TIMEOUT_MS = 15_000
+const require = createRequire(import.meta.url)
+
+function withTimeout(promise, label) {
+ return new Promise((resolvePromise, rejectPromise) => {
+ const timer = setTimeout(
+ () => rejectPromise(new Error(`Timed out waiting for ${label}`)),
+ WAIT_TIMEOUT_MS
+ )
+ promise.then(
+ (value) => {
+ clearTimeout(timer)
+ resolvePromise(value)
+ },
+ (error) => {
+ clearTimeout(timer)
+ rejectPromise(error)
+ }
+ )
+ })
+}
+
+function nextMatchingEvent(register, predicate, label) {
+ return withTimeout(
+ new Promise((resolveEvent) => {
+ register((events) => {
+ if (events.some(predicate)) {
+ resolveEvent(events)
+ }
+ })
+ }),
+ label
+ )
+}
+
+async function loadSupervisor(bundleDir) {
+ const outfile = join(bundleDir, 'watcher-supervisor.cjs')
+ await build({
+ entryPoints: [SUPERVISOR_SOURCE],
+ bundle: true,
+ platform: 'node',
+ format: 'cjs',
+ outfile,
+ external: ['@parcel/watcher', 'electron'],
+ logLevel: 'silent'
+ })
+ return require(outfile).WatcherProcessSupervisor
+}
+
+async function main() {
+ if (process.platform === 'win32') {
+ console.log('[runtime-file-watcher-fault] SKIP: SIGSEGV oracle is macOS/Linux only')
+ return
+ }
+ if (!existsSync(ENTRY_PATH)) {
+ throw new Error(`Missing ${ENTRY_PATH}; run pnpm run build:electron-vite first`)
+ }
+
+ // Why: mkdtemp/realpath/bundle/construction can fail before the body runs.
+ // Keep cleanup in finally from the first successful mkdtemp onward, and clean
+ // the original temp path if realpath never succeeds.
+ let createdRootPath
+ let bundleDir
+ let rootPath
+ let supervisor
+ let subscription
+ let watcherCanaryDir
+ let eventListener = () => undefined
+ let rejectWatcherError
+ const watcherError = new Promise((_, reject) => {
+ rejectWatcherError = reject
+ })
+ // Attach early so a callback rejection before the race cannot become unhandled.
+ watcherError.catch(() => undefined)
+
+ try {
+ createdRootPath = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-fault-'))
+ bundleDir = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-harness-'))
+ // Parcel reports canonical event paths on macOS, where tmpdir() may use the
+ // /var symlink spelling. Keep the oracle in the same path domain.
+ rootPath = await realpath(createdRootPath)
+ const WatcherProcessSupervisor = await loadSupervisor(bundleDir)
+ supervisor = new WatcherProcessSupervisor()
+
+ let resolveInterruption
+ const interrupted = withTimeout(
+ new Promise((resolveWait) => {
+ resolveInterruption = resolveWait
+ }),
+ 'automatic watcher resubscription'
+ )
+ subscription = await supervisor.subscribe(
+ rootPath,
+ (error, events) => {
+ if (error) {
+ // Why: throws from the async watcher callback escape main()'s try/finally
+ // and skip teardown. Surface failures through a harness promise instead.
+ rejectWatcherError(error)
+ return
+ }
+ eventListener(events)
+ },
+ { ignore: ['.git', 'node_modules'] },
+ {
+ delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 200 },
+ onInterruption: () => resolveInterruption()
+ }
+ )
+ watcherCanaryDir = supervisor.canaryDir
+
+ const beforeEvent = nextMatchingEvent(
+ (listener) => {
+ eventListener = listener
+ },
+ (event) => event.path === join(rootPath, 'before.txt'),
+ 'pre-crash watch event'
+ )
+ await writeFile(join(rootPath, 'before.txt'), 'before')
+ await Promise.race([beforeEvent, watcherError])
+
+ const firstChildPid = supervisor.child?.pid
+ if (!firstChildPid) {
+ throw new Error('Watcher supervisor did not expose a live child')
+ }
+ process.kill(firstChildPid, 'SIGSEGV')
+ await Promise.race([interrupted, watcherError])
+
+ const replacementChildPid = supervisor.child?.pid
+ if (!replacementChildPid || replacementChildPid === firstChildPid) {
+ throw new Error('Watcher supervisor did not replace the faulted child')
+ }
+ const afterEvent = nextMatchingEvent(
+ (listener) => {
+ eventListener = listener
+ },
+ (event) => event.path === join(rootPath, 'after.txt'),
+ 'post-crash watch event'
+ )
+ await writeFile(join(rootPath, 'after.txt'), 'after')
+ await Promise.race([afterEvent, watcherError])
+
+ console.log(
+ JSON.stringify({
+ hostPid: process.pid,
+ killedWatcherPid: firstChildPid,
+ replacementWatcherPid: replacementChildPid,
+ hostSurvived: true,
+ automaticResubscribe: true,
+ postCrashEventDelivered: true
+ })
+ )
+ } finally {
+ try {
+ await subscription?.unsubscribe()
+ } finally {
+ supervisor?.dispose()
+ await Promise.all([
+ createdRootPath ? rm(createdRootPath, { recursive: true, force: true }) : Promise.resolve(),
+ rootPath && rootPath !== createdRootPath
+ ? rm(rootPath, { recursive: true, force: true })
+ : Promise.resolve(),
+ bundleDir ? rm(bundleDir, { recursive: true, force: true }) : Promise.resolve()
+ ])
+ }
+ }
+ if (watcherCanaryDir && existsSync(watcherCanaryDir)) {
+ throw new Error(`Watcher supervisor leaked its canary directory: ${watcherCanaryDir}`)
+ }
+}
+
+await main()
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index dbe2fbc02b3..7b8c9cb8318 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -182,7 +182,6 @@ export default defineConfig({
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
- 'file-watcher-worker': resolve('src/main/runtime/file-watcher-worker.ts'),
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
diff --git a/mobile/src/components/MobileAgentIcon.tsx b/mobile/src/components/MobileAgentIcon.tsx
index ade35feda45..6c47fce7a5b 100644
--- a/mobile/src/components/MobileAgentIcon.tsx
+++ b/mobile/src/components/MobileAgentIcon.tsx
@@ -1,8 +1,11 @@
import { Image, StyleSheet, Text, View } from 'react-native'
+import type { ImageSourcePropType } from 'react-native'
import { Terminal } from 'lucide-react-native'
+import type { TuiAgent } from '../../../src/shared/types'
import Svg, { Defs, G, LinearGradient, Path, Stop } from 'react-native-svg'
import { colors } from '../theme/mobile-theme'
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
+import { MOBILE_AGENT_ICON_ASSETS } from './mobile-agent-icon-assets'
import { ClaudeIcon, OpenAIIcon } from './AgentIcons'
// Why: agent branding should match the desktop/new-worktree picker everywhere
@@ -49,6 +52,10 @@ function AiderIcon({ size = 16 }: { size?: number }) {
)
}
+function BundledIcon({ source, size = 16 }: { source: ImageSourcePropType; size?: number }) {
+ return
+}
+
function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) {
return (
}
+ // Why: prefer the favicon bundled into the app over Google's favicon service,
+ // which is unreachable in some regions and offline (#8451).
+ const bundledIcon = MOBILE_AGENT_ICON_ASSETS[agentId as TuiAgent]
+ if (bundledIcon) {
+ return
+ }
const agent = MOBILE_AGENT_CATALOG.find((entry) => entry.id === agentId)
if (agent?.faviconDomain) {
return
diff --git a/mobile/src/components/MobilePRSidebar.tsx b/mobile/src/components/MobilePRSidebar.tsx
index 42829df0310..f98854682c9 100644
--- a/mobile/src/components/MobilePRSidebar.tsx
+++ b/mobile/src/components/MobilePRSidebar.tsx
@@ -14,6 +14,7 @@ import {
type MobilePrTitleAction
} from '../session/use-mobile-pr-title-action'
import { useMobilePrAiTriage, type MobilePrAiTriage } from '../session/use-mobile-pr-ai-triage'
+import { usePRBotAuthorOverrides } from '../session/use-pr-bot-author-overrides'
import { buildFixChecksPrompt, buildResolveConflictsPrompt } from '../session/pr-ai-triage-prompt'
import { prSidebarRenderBranch } from './mobile-pr-sidebar-presentation'
import { mobilePrSidebarStyles as styles } from './pr-sidebar/mobile-pr-sidebar-styles'
@@ -89,6 +90,13 @@ export function MobilePRSidebar({
refetch
})
const triage = useMobilePrAiTriage({ client, connState, worktreeId })
+ // Keyed on the PR payload identity so overrides re-fetch with each PR refetch
+ // instead of staying a stale one-shot snapshot for the whole session.
+ const botAuthorOverrides = usePRBotAuthorOverrides(
+ client,
+ connState,
+ state.kind === 'ready' ? state.data.details : null
+ )
return (
@@ -134,7 +143,8 @@ function PrSidebarContent({
commentActions,
titleAction,
triage,
- showOpenOnWeb
+ showOpenOnWeb,
+ botAuthorOverrides
}: {
branch: ReturnType
state: PrSidebarState
@@ -150,6 +160,7 @@ function PrSidebarContent({
titleAction: MobilePrTitleAction
triage: MobilePrAiTriage
showOpenOnWeb: boolean
+ botAuthorOverrides: ReadonlySet
}) {
if (branch === 'loading') {
return (
@@ -215,6 +226,7 @@ function PrSidebarContent({
titleAction={titleAction}
triage={triage}
refetch={refetch}
+ botAuthorOverrides={botAuthorOverrides}
showOpenOnWeb={showOpenOnWeb}
/>
)
@@ -231,7 +243,8 @@ function PrSidebarSections({
titleAction,
triage,
refetch,
- showOpenOnWeb
+ showOpenOnWeb,
+ botAuthorOverrides
}: {
data: Extract['data']
client: RpcClient | null
@@ -242,6 +255,7 @@ function PrSidebarSections({
triage: MobilePrAiTriage
refetch: () => void
showOpenOnWeb: boolean
+ botAuthorOverrides: ReadonlySet
}) {
const pr = data.pr
// Bind the triage launchers to this PR's data; the prompt builders are pure so
@@ -314,6 +328,7 @@ function PrSidebarSections({
prState={data.pr.state}
prRepo={data.pr.prRepo ?? null}
actions={commentActions}
+ botAuthorOverrides={botAuthorOverrides}
/>
>
)
diff --git a/mobile/src/components/mobile-agent-icon-assets.test.ts b/mobile/src/components/mobile-agent-icon-assets.test.ts
new file mode 100644
index 00000000000..edbe71760c7
--- /dev/null
+++ b/mobile/src/components/mobile-agent-icon-assets.test.ts
@@ -0,0 +1,45 @@
+import { existsSync, readFileSync } from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import type { TuiAgent } from '../../../src/shared/types'
+import { MOBILE_TUI_AGENT_FAVICON_DOMAINS } from '../tasks/mobile-tui-agents'
+
+// Agents that render a hand-authored glyph in MobileAgentIcon and therefore
+// never reach the favicon/bundled-asset path.
+const GLYPH_AGENT_IDS = new Set([
+ 'claude',
+ 'claude-agent-teams',
+ 'codex',
+ 'pi',
+ 'omp',
+ 'aider'
+])
+
+const dirname = import.meta.dirname
+const assetsDir = path.resolve(dirname, '../../../src/shared/agent-icons')
+const assetsModuleSource = readFileSync(path.join(dirname, 'mobile-agent-icon-assets.ts'), 'utf8')
+
+// Why: these agents previously loaded from Google's favicon service, which is
+// unreachable in some regions/offline (#8451). Every one that lacks a glyph
+// must ship a bundled PNG so the icon renders without a network request.
+const agentsNeedingBundledIcon = Object.keys(MOBILE_TUI_AGENT_FAVICON_DOMAINS).filter(
+ (id): id is TuiAgent => !GLYPH_AGENT_IDS.has(id as TuiAgent)
+)
+
+describe('mobile bundled agent icons', () => {
+ it('ships a bundled PNG on disk for every favicon-path agent', () => {
+ for (const id of agentsNeedingBundledIcon) {
+ expect(existsSync(path.join(assetsDir, `${id}.png`)), `missing bundled icon for ${id}`).toBe(
+ true
+ )
+ }
+ })
+
+ it('wires every favicon-path agent into the bundled asset map', () => {
+ for (const id of agentsNeedingBundledIcon) {
+ expect(assetsModuleSource, `${id} not mapped in mobile-agent-icon-assets.ts`).toContain(
+ `agent-icons/${id}.png`
+ )
+ }
+ })
+})
diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts
new file mode 100644
index 00000000000..52615e89df8
--- /dev/null
+++ b/mobile/src/components/mobile-agent-icon-assets.ts
@@ -0,0 +1,41 @@
+import type { ImageSourcePropType } from 'react-native'
+import type { TuiAgent } from '../../../src/shared/types'
+
+// Why: mobile previously rendered these agent icons from Google's favicon
+// service, which is unreachable in some regions (e.g. mainland China) and
+// offline, leaving broken images in the agent picker (#8451). Bundle each
+// favicon so Metro packages it into the app and the icon renders without any
+// network request. The PNGs are shared with desktop in src/shared/agent-icons
+// (Metro watches src/shared via metro.config.js sharedRoot). Metro requires
+// static literal require() paths, so keep this as an explicit map rather than
+// building the path from the agent id.
+export const MOBILE_AGENT_ICON_ASSETS: Partial> = {
+ openclaude: require('../../../src/shared/agent-icons/openclaude.png'),
+ grok: require('../../../src/shared/agent-icons/grok.png'),
+ copilot: require('../../../src/shared/agent-icons/copilot.png'),
+ opencode: require('../../../src/shared/agent-icons/opencode.png'),
+ 'mimo-code': require('../../../src/shared/agent-icons/mimo-code.png'),
+ ante: require('../../../src/shared/agent-icons/ante.png'),
+ gemini: require('../../../src/shared/agent-icons/gemini.png'),
+ antigravity: require('../../../src/shared/agent-icons/antigravity.png'),
+ goose: require('../../../src/shared/agent-icons/goose.png'),
+ amp: require('../../../src/shared/agent-icons/amp.png'),
+ kilo: require('../../../src/shared/agent-icons/kilo.png'),
+ kiro: require('../../../src/shared/agent-icons/kiro.png'),
+ crush: require('../../../src/shared/agent-icons/crush.png'),
+ aug: require('../../../src/shared/agent-icons/aug.png'),
+ autohand: require('../../../src/shared/agent-icons/autohand.png'),
+ cline: require('../../../src/shared/agent-icons/cline.png'),
+ codebuff: require('../../../src/shared/agent-icons/codebuff.png'),
+ 'command-code': require('../../../src/shared/agent-icons/command-code.png'),
+ continue: require('../../../src/shared/agent-icons/continue.png'),
+ cursor: require('../../../src/shared/agent-icons/cursor.png'),
+ droid: require('../../../src/shared/agent-icons/droid.png'),
+ kimi: require('../../../src/shared/agent-icons/kimi.png'),
+ 'mistral-vibe': require('../../../src/shared/agent-icons/mistral-vibe.png'),
+ 'qwen-code': require('../../../src/shared/agent-icons/qwen-code.png'),
+ rovo: require('../../../src/shared/agent-icons/rovo.png'),
+ hermes: require('../../../src/shared/agent-icons/hermes.png'),
+ devin: require('../../../src/shared/agent-icons/devin.png'),
+ openclaw: require('../../../src/shared/agent-icons/openclaw.png')
+}
diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts b/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts
new file mode 100644
index 00000000000..d174f455c67
--- /dev/null
+++ b/mobile/src/components/pr-sidebar/PRCommentsSection.test.ts
@@ -0,0 +1,131 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { GitHubWorkItemDetails, PRComment } from '../../../../src/shared/types'
+import { PRCommentsSection } from './PRCommentsSection'
+
+vi.mock('react-native', () => ({
+ ActivityIndicator: 'ActivityIndicator',
+ Pressable: 'Pressable',
+ Text: 'Text',
+ View: 'View'
+}))
+
+vi.mock('lucide-react-native', () => ({
+ ChevronDown: 'ChevronDown',
+ ChevronRight: 'ChevronRight'
+}))
+
+vi.mock('../../session/pr-comment-actions', () => ({
+ canAddRootComment: () => false
+}))
+
+vi.mock('../../session/mobile-pr-sidebar-state', () => ({
+ isPrSidebarDetailsPlaceholder: () => false
+}))
+
+vi.mock('./PRSection', () => ({ PRSection: 'PRSection' }))
+vi.mock('./CommentMarkdown', () => ({ CommentMarkdown: 'CommentMarkdown' }))
+vi.mock('./PRCommentCard', () => ({ PRCommentCard: 'PRCommentCard' }))
+vi.mock('./PRCommentComposer', () => ({ PRCommentComposer: 'PRCommentComposer' }))
+vi.mock('./pr-comments-styles', () => ({ prCommentsStyles: {} }))
+vi.mock('./mobile-pr-sidebar-styles', () => ({ mobilePrSidebarStyles: {} }))
+vi.mock('../../theme/mobile-theme', () => ({ colors: { textSecondary: '#999' } }))
+
+function suppressReactTestRendererDeprecationWarning(): () => void {
+ const originalConsoleError = console.error
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
+ const firstArg = args[0]
+ if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
+ return
+ }
+ originalConsoleError(...args)
+ })
+ return () => consoleErrorSpy.mockRestore()
+}
+
+function comment(id: number): PRComment {
+ return {
+ id,
+ author: 'octocat',
+ authorAvatarUrl: '',
+ body: '',
+ createdAt: '',
+ url: ''
+ }
+}
+
+function detailsWithComments(count: number): GitHubWorkItemDetails {
+ return {
+ item: { id: 'pr:1', type: 'pr' },
+ body: '',
+ comments: Array.from({ length: count }, (_, index) => comment(index + 1))
+ } as GitHubWorkItemDetails
+}
+
+async function renderComments(details: GitHubWorkItemDetails): Promise {
+ let renderer: ReactTestRenderer | null = null
+ const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
+ try {
+ await act(async () => {
+ renderer = create(createElement(PRCommentsSection, { details, prState: 'open' }))
+ })
+ } finally {
+ restoreConsoleError()
+ }
+ if (!renderer) {
+ throw new Error('PRCommentsSection did not render')
+ }
+ return renderer
+}
+
+function audienceTabs(renderer: ReactTestRenderer) {
+ return renderer.root
+ .findAllByType('Pressable')
+ .filter((node) => node.props.accessibilityState !== undefined)
+}
+
+function showMoreButton(renderer: ReactTestRenderer) {
+ const button = renderer.root
+ .findAllByType('Pressable')
+ .find((node) => node.props.accessibilityState === undefined)
+ if (!button) {
+ throw new Error('Show more button not found')
+ }
+ return button
+}
+
+async function press(node: { props: { onPress: () => void } }): Promise {
+ await act(async () => {
+ node.props.onPress()
+ })
+}
+
+describe('PRCommentsSection', () => {
+ let renderer: ReactTestRenderer | null = null
+
+ afterEach(() => {
+ renderer?.unmount()
+ renderer = null
+ vi.restoreAllMocks()
+ })
+
+ it('resets pagination only when the user chooses a different audience filter', async () => {
+ renderer = await renderComments(detailsWithComments(25))
+ expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(12)
+
+ await press(showMoreButton(renderer))
+ expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(24)
+
+ // The second tab is Humans; moving from All to Humans resets the page limit.
+ await press(audienceTabs(renderer)[1])
+ expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(12)
+
+ await press(showMoreButton(renderer))
+ expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(24)
+
+ // Retapping the active tab used to leave the page size alone; retain that behavior.
+ await press(audienceTabs(renderer)[1])
+ expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(24)
+ })
+})
diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx
index b6c0a1009d6..bb473307619 100644
--- a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx
+++ b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useMemo, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/types'
@@ -39,6 +39,9 @@ type Props = {
// Interactive comment actions (reply/resolve/add/edit/delete). Absent (e.g.
// non-PR) leaves the timeline read-only.
actions?: MobilePrCommentActions
+ // Author logins manually marked as bots on desktop; keeps the Humans/Bots
+ // tabs classifying the same comments as the desktop panel.
+ botAuthorOverrides?: ReadonlySet
}
// Render comments in bounded pages — the whole sidebar is one ScrollView (can't
@@ -49,7 +52,13 @@ const COMMENT_PAGE = 12
// PR body + full comment timeline, mirroring the desktop PR page: a Description
// card, then a Comments section with an audience filter (PRs only), threaded
// review comments, reactions, and collapsible resolved threads.
-export function PRCommentsSection({ details, prState, prRepo, actions }: Props) {
+export function PRCommentsSection({
+ details,
+ prState,
+ prRepo,
+ actions,
+ botAuthorOverrides
+}: Props) {
// details is null while phase 2 (the heavy comments/body payload) is still loading.
// A synthetic placeholder means phase 2 failed — do not paint that as empty success.
const loadingDetails = details === null
@@ -83,15 +92,27 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
const canComment = isPr && actions !== undefined && canAddRootComment(prState)
const [filter, setFilter] = useState('all')
- const counts = useMemo(() => getPRCommentAudienceCounts(comments), [comments])
- const visible = useMemo(() => filterPRCommentsByAudience(comments, filter), [comments, filter])
+ const counts = useMemo(
+ () => getPRCommentAudienceCounts(comments, botAuthorOverrides),
+ [botAuthorOverrides, comments]
+ )
+ const visible = useMemo(
+ () => filterPRCommentsByAudience(comments, filter, botAuthorOverrides),
+ [botAuthorOverrides, comments, filter]
+ )
const groups = useMemo(() => groupPRComments(visible), [visible])
- // Bounded render window; reset to the first page whenever the filtered set changes.
+ // Bounded render window; reset to the first page when the user selects another filter.
const [limit, setLimit] = useState(COMMENT_PAGE)
- useEffect(() => {
+ const selectFilter = (nextFilter: PRCommentAudienceFilter): void => {
+ if (nextFilter === filter) {
+ return
+ }
+ // Why: paging belongs to the filter-tab event, so reset it in the same batch
+ // instead of briefly rendering the new filter with the previous page size.
setLimit(COMMENT_PAGE)
- }, [filter])
+ setFilter(nextFilter)
+ }
const shownGroups = groups.slice(0, limit)
const remaining = groups.length - shownGroups.length
@@ -139,7 +160,7 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
setFilter(tab.value)}
+ onPress={() => selectFilter(tab.value)}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
diff --git a/mobile/src/components/pr-sidebar/pr-comment-audience.ts b/mobile/src/components/pr-sidebar/pr-comment-audience.ts
index 309c548184d..71032a74f81 100644
--- a/mobile/src/components/pr-sidebar/pr-comment-audience.ts
+++ b/mobile/src/components/pr-sidebar/pr-comment-audience.ts
@@ -1,4 +1,10 @@
import type { PRComment } from '../../../../src/shared/types'
+import {
+ createBotAuthorOverrideSet,
+ normalizePRCommentAuthorLogin
+} from '../../../../src/shared/pr-bot-author-overrides'
+
+export { createBotAuthorOverrideSet }
// Audience filtering for the PR comment timeline, ported from the desktop helper
// (src/renderer/src/lib/pr-comment-audience.ts) minus its i18n wrapper so it stays
@@ -43,12 +49,18 @@ const KNOWN_AUTOMATION_LOGIN_SUBSTRINGS = [
'-reviewer'
]
-export function isBotPRComment(comment: PRComment): boolean {
+export function isBotPRComment(
+ comment: PRComment,
+ botAuthorOverrides?: ReadonlySet
+): boolean {
+ const author = comment.author.trim()
+ const normalized = normalizePRCommentAuthorLogin(author)
+ if (botAuthorOverrides?.has(normalized)) {
+ return true
+ }
if (comment.isBot === true) {
return true
}
- const author = comment.author.trim()
- const normalized = author.toLowerCase()
if (normalized.endsWith(BOT_LOGIN_SUFFIX)) {
return true
}
@@ -59,9 +71,10 @@ export function isBotPRComment(comment: PRComment): boolean {
}
export function getPRCommentAudienceCounts(
- comments: PRComment[]
+ comments: PRComment[],
+ botAuthorOverrides?: ReadonlySet
): Record {
- const bot = comments.filter(isBotPRComment).length
+ const bot = comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides)).length
return {
all: comments.length,
human: comments.length - bot,
@@ -71,13 +84,14 @@ export function getPRCommentAudienceCounts(
export function filterPRCommentsByAudience(
comments: PRComment[],
- filter: PRCommentAudienceFilter
+ filter: PRCommentAudienceFilter,
+ botAuthorOverrides?: ReadonlySet
): PRComment[] {
if (filter === 'bot') {
- return comments.filter(isBotPRComment)
+ return comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides))
}
if (filter === 'human') {
- return comments.filter((comment) => !isBotPRComment(comment))
+ return comments.filter((comment) => !isBotPRComment(comment, botAuthorOverrides))
}
return comments
}
diff --git a/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts b/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts
index 2fd5eb437fb..e01fb4166dc 100644
--- a/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts
+++ b/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { PRComment } from '../../../../src/shared/types'
import {
+ createBotAuthorOverrideSet,
filterPRCommentsByAudience,
getPRCommentAudienceCounts,
isBotPRComment
@@ -38,6 +39,15 @@ describe('pr comment audience', () => {
expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.id)).toEqual([1, 3])
expect(filterPRCommentsByAudience(comments, 'all')).toHaveLength(3)
})
+
+ it('classifies desktop-marked bot authors via overrides (desktop parity)', () => {
+ const overrides = createBotAuthorOverrideSet([' GretelFlux '])
+ const comments = [comment({ id: 1, author: 'alice' }), comment({ id: 2, author: 'gretelflux' })]
+ expect(isBotPRComment(comments[1])).toBe(false)
+ expect(isBotPRComment(comments[1], overrides)).toBe(true)
+ expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 2, human: 1, bot: 1 })
+ expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((c) => c.id)).toEqual([2])
+ })
})
describe('pr comment groups', () => {
diff --git a/mobile/src/files/MobileFileMarkdownPreview.test.ts b/mobile/src/files/MobileFileMarkdownPreview.test.ts
new file mode 100644
index 00000000000..2afdb0b2ce9
--- /dev/null
+++ b/mobile/src/files/MobileFileMarkdownPreview.test.ts
@@ -0,0 +1,122 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview'
+
+vi.mock('react-native', () => ({
+ Pressable: 'Pressable',
+ ScrollView: 'ScrollView',
+ View: 'View'
+}))
+
+vi.mock('lucide-react-native', () => ({
+ Code: 'Code',
+ Pencil: 'Pencil'
+}))
+
+vi.mock('../components/MobileMarkdown', () => ({
+ MobileMarkdown: 'MobileMarkdown'
+}))
+
+vi.mock('./MobileFilePreviewSourceText', () => ({
+ MobileFilePreviewSourceText: 'MobileFilePreviewSourceText',
+ MobileFilePreviewTruncatedNote: 'MobileFilePreviewTruncatedNote'
+}))
+
+vi.mock('../theme/mobile-theme', () => ({
+ colors: { textPrimary: '#fff', textSecondary: '#999' }
+}))
+
+vi.mock('./mobile-file-preview-styles', () => ({
+ filePreviewStyles: {}
+}))
+
+type PreviewProps = Parameters[0]
+
+function suppressReactTestRendererDeprecationWarning(): () => void {
+ const originalConsoleError = console.error
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
+ const firstArg = args[0]
+ if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
+ return
+ }
+ originalConsoleError(...args)
+ })
+ return () => consoleErrorSpy.mockRestore()
+}
+
+async function renderPreview(props: PreviewProps): Promise {
+ let renderer: ReactTestRenderer | null = null
+ const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
+ try {
+ await act(async () => {
+ renderer = create(createElement(MobileFileMarkdownPreview, props))
+ })
+ } finally {
+ restoreConsoleError()
+ }
+ if (!renderer) {
+ throw new Error('MobileFileMarkdownPreview did not render')
+ }
+ return renderer
+}
+
+async function updatePreview(renderer: ReactTestRenderer, props: PreviewProps): Promise {
+ await act(async () => {
+ renderer.update(createElement(MobileFileMarkdownPreview, props))
+ })
+}
+
+function modeToggle(renderer: ReactTestRenderer, label: string) {
+ const toggle = renderer.root
+ .findAllByType('Pressable')
+ .find((node) => node.props.accessibilityLabel === label)
+ if (!toggle) {
+ throw new Error(`Missing ${label} toggle`)
+ }
+ return toggle
+}
+
+async function selectMode(renderer: ReactTestRenderer, label: string): Promise {
+ await act(async () => {
+ modeToggle(renderer, label).props.onPress()
+ })
+}
+
+function isSelected(renderer: ReactTestRenderer, label: string): boolean {
+ return modeToggle(renderer, label).props.accessibilityState.selected === true
+}
+
+describe('MobileFileMarkdownPreview', () => {
+ let renderer: ReactTestRenderer | null = null
+
+ afterEach(() => {
+ renderer?.unmount()
+ renderer = null
+ vi.restoreAllMocks()
+ })
+
+ it('resets the selected mode for a new file or line target without remounting the preview', async () => {
+ const baseProps: PreviewProps = {
+ relativePath: 'notes/first.md',
+ content: '# First',
+ truncated: false,
+ byteLength: 7
+ }
+ renderer = await renderPreview(baseProps)
+
+ expect(isSelected(renderer, 'View rendered Markdown preview')).toBe(true)
+ await selectMode(renderer, 'View Markdown source')
+ expect(isSelected(renderer, 'View Markdown source')).toBe(true)
+
+ // Content updates alone preserve the user's explicitly selected mode.
+ await updatePreview(renderer, { ...baseProps, content: '# First updated' })
+ expect(isSelected(renderer, 'View Markdown source')).toBe(true)
+
+ await updatePreview(renderer, { ...baseProps, relativePath: 'notes/second.md' })
+ expect(isSelected(renderer, 'View rendered Markdown preview')).toBe(true)
+
+ await updatePreview(renderer, { ...baseProps, relativePath: 'notes/second.md', initialLine: 8 })
+ expect(isSelected(renderer, 'View Markdown source')).toBe(true)
+ })
+})
diff --git a/mobile/src/files/MobileFileMarkdownPreview.tsx b/mobile/src/files/MobileFileMarkdownPreview.tsx
index 0923cf1f9a0..9d53af3fe6b 100644
--- a/mobile/src/files/MobileFileMarkdownPreview.tsx
+++ b/mobile/src/files/MobileFileMarkdownPreview.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+import { useState } from 'react'
import { Pressable, ScrollView, View } from 'react-native'
import { Code, Pencil } from 'lucide-react-native'
import { MobileMarkdown } from '../components/MobileMarkdown'
@@ -25,9 +25,15 @@ export function MobileFileMarkdownPreview({
initialLine
}: Props) {
const [mode, setMode] = useState<'preview' | 'source'>(() => (initialLine ? 'source' : 'preview'))
- useEffect(() => {
+ const [previousRelativePath, setPreviousRelativePath] = useState(relativePath)
+ const [previousInitialLine, setPreviousInitialLine] = useState(initialLine)
+ // Why: opening a different file or line target must switch modes before paint,
+ // never briefly retain the prior file's manually selected mode.
+ if (relativePath !== previousRelativePath || initialLine !== previousInitialLine) {
+ setPreviousRelativePath(relativePath)
+ setPreviousInitialLine(initialLine)
setMode(initialLine ? 'source' : 'preview')
- }, [initialLine, relativePath])
+ }
const previewSelected = mode === 'preview'
const sourceSelected = mode === 'source'
diff --git a/mobile/src/session/use-pr-bot-author-overrides.ts b/mobile/src/session/use-pr-bot-author-overrides.ts
new file mode 100644
index 00000000000..4d9166c39af
--- /dev/null
+++ b/mobile/src/session/use-pr-bot-author-overrides.ts
@@ -0,0 +1,61 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import type { ConnectionState } from '../transport/types'
+import type { RpcSuccess } from '../transport/types'
+import type { RpcClient } from '../transport/rpc-client'
+import { createBotAuthorOverrideSet } from '../../../src/shared/pr-bot-author-overrides'
+
+// Fetches the desktop's manual bot-author overrides (GlobalSettings.prBotAuthorOverrides,
+// marked from the desktop Comments panel) so the mobile Humans/Bots comment filter
+// classifies the same authors as bots. There is no settings-change stream over the
+// mobile RPC, so callers pass a refreshKey (e.g. the PR payload identity) to re-fetch
+// alongside PR data instead of holding a one-shot snapshot for the whole session.
+export function usePRBotAuthorOverrides(
+ client: RpcClient | null,
+ connState: ConnectionState,
+ refreshKey?: unknown
+): ReadonlySet {
+ const [logins, setLogins] = useState([])
+ const sourceClientRef = useRef(null)
+
+ useEffect(() => {
+ if (!client || connState !== 'connected') {
+ sourceClientRef.current = null
+ setLogins([])
+ return
+ }
+ if (sourceClientRef.current !== client) {
+ // Why: overrides belong to the connected runtime; never render a prior
+ // host's classification while the replacement host is still loading.
+ sourceClientRef.current = client
+ setLogins([])
+ }
+ if (refreshKey == null) {
+ return
+ }
+ let stale = false
+ void client
+ .sendRequest('settings.get')
+ .then((response) => {
+ if (stale || !response.ok) {
+ return
+ }
+ const result = (response as RpcSuccess).result as {
+ settings?: { prBotAuthorOverrides?: unknown }
+ } | null
+ const overrides = result?.settings?.prBotAuthorOverrides
+ setLogins(
+ Array.isArray(overrides)
+ ? overrides.filter((login): login is string => typeof login === 'string')
+ : []
+ )
+ })
+ .catch(() => {
+ // Best-effort: without the setting the heuristics still classify most bots.
+ })
+ return () => {
+ stale = true
+ }
+ }, [client, connState, refreshKey])
+
+ return useMemo(() => createBotAuthorOverrideSet(logins), [logins])
+}
diff --git a/package.json b/package.json
index a2c0d40dda3..0460d05830c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.138-rc.2",
+ "version": "1.4.138-rc.5",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json
index d282de48f39..e6dc7b4792c 100644
--- a/resources/skills/current-manifest.json
+++ b/resources/skills/current-manifest.json
@@ -1,11 +1,11 @@
{
"schemaVersion": 1,
- "appVersion": "1.4.138-rc.2",
+ "appVersion": "1.4.138-rc.5",
"skills": [
{
"name": "computer-use",
"sourcePath": "skills/computer-use",
- "appVersion": "1.4.138-rc.2",
+ "appVersion": "1.4.138-rc.5",
"releaseRevision": 4,
"packageDigest": "35b6d91d1d3d38f3828c60e5b856a91324bdd09273db33664f508392c5c48dfc",
"gitTreeSha": "b4e2d7e7af8f8f29c6d0df6e92f23c1b2c439e3b",
@@ -24,7 +24,7 @@
{
"name": "linear-tickets",
"sourcePath": "skills/linear-tickets",
- "appVersion": "1.4.138-rc.2",
+ "appVersion": "1.4.138-rc.5",
"releaseRevision": 4,
"packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b",
"gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176",
@@ -43,7 +43,7 @@
{
"name": "orca-cli",
"sourcePath": "skills/orca-cli",
- "appVersion": "1.4.138-rc.2",
+ "appVersion": "1.4.138-rc.5",
"releaseRevision": 31,
"packageDigest": "c52a999a35f8e96ec72c97a537ce5906ef098879495382d85f50c70a888ba901",
"gitTreeSha": "77be03ea57ddbc4c4067bca22ab58c3af5ae3d2c",
@@ -62,7 +62,7 @@
{
"name": "orca-emulator",
"sourcePath": "skills/orca-emulator",
- "appVersion": "1.4.138-rc.2",
+ "appVersion": "1.4.138-rc.5",
"releaseRevision": 3,
"packageDigest": "1807d287d2c08b1c535ba4e14ca2f29afe39274c4bde5737995a1792e44d5412",
"gitTreeSha": "dbb8e0daba768527885715ee5efebeaa1ee38f0f",
@@ -81,7 +81,7 @@
{
"name": "orca-emulator-android",
"sourcePath": "skills/orca-emulator-android",
- "appVersion": "1.4.138-rc.2",
+ "appVersion": "1.4.138-rc.5",
"releaseRevision": 1,
"packageDigest": "cf86dd085b981febd8e4e11b7796c90d69a60bd9c1dfaab5a16b99f77841aaa4",
"gitTreeSha": "6299122f69f0218ec01214e2a14c479b1811cdf6",
@@ -100,7 +100,7 @@
{
"name": "orca-linear",
"sourcePath": "skills/orca-linear",
- "appVersion": "1.4.138-rc.2",
+ "appVersion": "1.4.138-rc.5",
"releaseRevision": 2,
"packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f",
"gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8",
@@ -119,7 +119,7 @@
{
"name": "orca-per-workspace-env",
"sourcePath": "skills/orca-per-workspace-env",
- "appVersion": "1.4.138-rc.2",
+ "appVersion": "1.4.138-rc.5",
"releaseRevision": 2,
"packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
"gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
@@ -138,19 +138,19 @@
{
"name": "orchestration",
"sourcePath": "skills/orchestration",
- "appVersion": "1.4.138-rc.2",
- "releaseRevision": 25,
- "packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b",
- "gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2",
+ "appVersion": "1.4.138-rc.5",
+ "releaseRevision": 24,
+ "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9",
+ "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982",
"files": [
{
"path": "SKILL.md",
- "size": 22850,
+ "size": 22861,
"executable": false,
"classification": "text",
- "exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
- "textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
- "identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9"
+ "exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5",
+ "textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5",
+ "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5"
}
]
}
diff --git a/resources/skills/snapshot-registry.json b/resources/skills/snapshot-registry.json
index d39f4f1681f..c05eeed801c 100644
--- a/resources/skills/snapshot-registry.json
+++ b/resources/skills/snapshot-registry.json
@@ -883,22 +883,6 @@
"identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5"
}
]
- },
- {
- "releaseRevision": 25,
- "packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b",
- "gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2",
- "files": [
- {
- "path": "SKILL.md",
- "size": 22850,
- "executable": false,
- "classification": "text",
- "exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
- "textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
- "identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9"
- }
- ]
}
],
"mobile-fit-debug": [
diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md
index b7596f76cc5..583fc2ad661 100644
--- a/skills/orchestration/SKILL.md
+++ b/skills/orchestration/SKILL.md
@@ -96,7 +96,7 @@ Rules:
- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.
- Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly.
- `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion.
-- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, and `@worktree:`.
+- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.
- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`.
- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.
- `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals.
diff --git a/src/cli/handlers/orchestration-lifecycle-rejection.test.ts b/src/cli/handlers/orchestration-lifecycle-rejection.test.ts
new file mode 100644
index 00000000000..269dfd7a47a
--- /dev/null
+++ b/src/cli/handlers/orchestration-lifecycle-rejection.test.ts
@@ -0,0 +1,45 @@
+import { afterEach, expect, it, vi } from 'vitest'
+
+const callMock = vi.fn()
+const originalExitCode = process.exitCode
+vi.mock('../format', () => ({ printResult: vi.fn() }))
+vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
+
+import { printResult } from '../format'
+import { ORCHESTRATION_HANDLERS } from './orchestration'
+
+afterEach(() => {
+ process.exitCode = originalExitCode
+})
+
+it('prints a lifecycle rejection and exits unsuccessfully', async () => {
+ const response = {
+ result: {
+ message: { id: 'msg_rejected' },
+ lifecycle: {
+ action: 'rejected' as const,
+ code: 'sender_not_assignee',
+ reason: 'dispatch ctx_1 expected the assigned pane'
+ }
+ }
+ }
+ callMock.mockResolvedValueOnce(response)
+
+ await ORCHESTRATION_HANDLERS['orchestration send']({
+ flags: new Map([
+ ['from', 'term_foreign'],
+ ['to', 'term_coord'],
+ ['subject', 'done'],
+ ['type', 'worker_done']
+ ]),
+ client: { call: callMock },
+ cwd: '/tmp/repo',
+ json: false
+ } as never)
+
+ expect(process.exitCode).toBe(1)
+ const formatter = vi.mocked(printResult).mock.calls[0]?.[2]
+ expect(formatter?.(response.result)).toBe(
+ 'Rejected msg_rejected: dispatch ctx_1 expected the assigned pane'
+ )
+})
diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts
index 735a72a53cf..2a8f6d44dfb 100644
--- a/src/cli/handlers/orchestration.test.ts
+++ b/src/cli/handlers/orchestration.test.ts
@@ -317,6 +317,26 @@ describe('orchestration send structured payload flags', () => {
})
expect(callMock).not.toHaveBeenCalled()
})
+
+ it.each(['worker_done', 'heartbeat'] as const)(
+ 'does not resolve an identity-less %s sender from the active terminal',
+ async (type) => {
+ getTerminalHandleMock.mockResolvedValue('term_active_coordinator')
+
+ await expect(
+ invokeSend(
+ new Map([
+ ['to', 'term_coord'],
+ ['subject', 'update'],
+ ['type', type]
+ ])
+ )
+ ).rejects.toMatchObject({ code: 'no_active_sender_terminal' })
+
+ expect(getTerminalHandleMock).not.toHaveBeenCalled()
+ expect(callMock).not.toHaveBeenCalled()
+ }
+ )
})
describe('orchestration dispatch coordinator handle', () => {
diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts
index d215fcb2e2e..fc285c56440 100644
--- a/src/cli/handlers/orchestration.ts
+++ b/src/cli/handlers/orchestration.ts
@@ -80,6 +80,16 @@ type MessageSummary = {
read?: number
}
+type LifecycleSendRejection = {
+ action: 'rejected'
+ code: string
+ reason: string
+}
+
+type OrchestrationSendResult =
+ | { message: { id: string }; lifecycle?: LifecycleSendRejection }
+ | { messages: { id: string }[]; recipients: number }
+
function getOptionalStructuredMessagePayload(
flags: Map
): string | undefined {
@@ -347,6 +357,16 @@ export const ORCHESTRATION_HANDLERS: Record = {
const type = getOptionalStringFlag(flags, 'type')
rejectLifecycleGroupRecipient(type, to)
+ if (
+ (type === 'worker_done' || type === 'heartbeat') &&
+ !getOptionalStringFlag(flags, 'from') &&
+ !process.env.ORCA_TERMINAL_HANDLE
+ ) {
+ // Why: focus is not lifecycle authority; injected dispatches carry an
+ // explicit worker handle so an identity-less subprocess must fail closed.
+ throwNoActiveSenderTerminal()
+ }
+
// Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness
// probe (terminal.show throws runtime_unavailable in the exact mid-restart
// window worker_done must survive) and no pane remint (pre-payload-
@@ -354,9 +374,7 @@ export const ORCHESTRATION_HANDLERS: Record = {
// and coordinator replies route to the sender row while the worker's own
// `check` reads its env-handle inbox).
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
- const result = await client.call<
- { message: { id: string } } | { messages: { id: string }[]; recipients: number }
- >('orchestration.send', {
+ const result = await client.call('orchestration.send', {
from,
to,
subject: getRequiredStringFlag(flags, 'subject'),
@@ -370,8 +388,16 @@ export const ORCHESTRATION_HANDLERS: Record = {
senderPaneKey: process.env.ORCA_PANE_KEY || undefined,
devMode: isDevCliInvocation()
})
+ if ('message' in result.result && result.result.lifecycle?.action === 'rejected') {
+ // Why: a persisted diagnostic is not a successful lifecycle signal;
+ // the non-zero status prevents workers from treating it as completion.
+ process.exitCode = 1
+ }
printResult(result, json, (r) => {
if ('message' in r) {
+ if (r.lifecycle?.action === 'rejected') {
+ return `Rejected ${r.message.id}: ${r.lifecycle.reason}`
+ }
return `Sent ${r.message.id}`
}
return `Sent ${r.messages.length} messages to ${r.recipients} recipients`
diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts
index 05138e2f993..5a4fd2b7f25 100644
--- a/src/cli/runtime/launch.test.ts
+++ b/src/cli/runtime/launch.test.ts
@@ -1,6 +1,7 @@
import { EventEmitter } from 'node:events'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn()
@@ -18,6 +19,57 @@ class FakeChildProcess extends EventEmitter {
unref = vi.fn()
}
+const RECIPE_JSON = JSON.stringify({
+ schemaVersion: 1,
+ pairingCode: encodePairingOffer({
+ v: PAIRING_OFFER_VERSION,
+ endpoint: 'wss://sandbox.example.com',
+ deviceToken: 'token',
+ publicKeyB64: 'public-key'
+ }),
+ projectRoot: '/workspace/repo'
+})
+const SERVE_INSTALL_STATUS = '[serve] orca CLI install: installed'
+const SSH_PRIVATE_KEY = 'TOP-SECRET-PRIVATE-KEY'
+const SSH_AUTHORIZATION = 'Bearer TOP-SECRET-AUTHORIZATION'
+const SSH_PASSPHRASE = 'TOP-SECRET-PASSPHRASE'
+const SSH_COOKIE = 'session=TOP-SECRET-COOKIE'
+const SSH_RECIPE_JSON = JSON.stringify({
+ schemaVersion: 1,
+ connection: {
+ type: 'ssh',
+ target: {
+ label: 'Sandbox',
+ host: 'sandbox.example.com',
+ port: 22,
+ username: 'root'
+ },
+ projectRoot: '/workspace/repo'
+ },
+ userData: {
+ credentials: {
+ privateKey: SSH_PRIVATE_KEY,
+ authorization: SSH_AUTHORIZATION,
+ passphrase: SSH_PASSPHRASE,
+ cookie: SSH_COOKIE
+ }
+ }
+})
+const INVALID_SSH_RECIPE_JSON = SSH_RECIPE_JSON.replace('/workspace/repo', 'relative/repo')
+const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
+
+function startRecipeJsonServer() {
+ const child = new FakeChildProcess()
+ spawnMock.mockReturnValue(child)
+ const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
+ const result = serveOrcaApp({
+ recipeJson: true,
+ projectRoot: '/workspace/repo'
+ })
+ return { child, result, stdoutSpy, stderrSpy }
+}
+
describe('serveOrcaApp', () => {
beforeEach(() => {
spawnMock.mockReset()
@@ -133,10 +185,7 @@ describe('serveOrcaApp', () => {
projectRoot: '/workspace/repo'
})
queueMicrotask(() => {
- child.stdout.emit(
- 'data',
- '{"schemaVersion":1,"pairingCode":"orca://pair?code=abc","projectRoot":"/workspace/repo"}\n'
- )
+ child.stdout.emit('data', `${RECIPE_JSON}\n`)
})
await expect(result).resolves.toBe(0)
@@ -157,12 +206,92 @@ describe('serveOrcaApp', () => {
stdio: ['ignore', 'pipe', 'inherit']
})
)
- expect(writeSpy).toHaveBeenCalledWith(
- '{"schemaVersion":1,"pairingCode":"orca://pair?code=abc","projectRoot":"/workspace/repo"}\n'
- )
+ expect(writeSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`)
expect(child.unref).toHaveBeenCalled()
})
+ it('waits past startup status lines for valid recipe JSON', async () => {
+ const { child, result, stdoutSpy, stderrSpy } = startRecipeJsonServer()
+ queueMicrotask(() => {
+ child.stdout.emit(
+ 'data',
+ `${SERVE_INSTALL_STATUS}\n${INVALID_SSH_RECIPE_JSON}\n${SSH_RECIPE_JSON}\n${RECIPE_JSON.slice(0, 40)}`
+ )
+ child.stdout.emit('data', `${RECIPE_JSON.slice(40)}\n`)
+ })
+
+ await expect(result).resolves.toBe(0)
+
+ expect(stderrSpy).toHaveBeenNthCalledWith(1, `${IGNORED_NON_RECIPE_STDOUT}\n`)
+ expect(stderrSpy).toHaveBeenNthCalledWith(2, `${IGNORED_NON_RECIPE_STDOUT}\n`)
+ expect(stderrSpy).toHaveBeenNthCalledWith(3, `${IGNORED_NON_RECIPE_STDOUT}\n`)
+ for (const secret of [SSH_PRIVATE_KEY, SSH_AUTHORIZATION, SSH_PASSPHRASE, SSH_COOKIE]) {
+ expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining(secret))
+ }
+ expect(stdoutSpy).toHaveBeenCalledTimes(1)
+ expect(stdoutSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`)
+ expect(child.unref).toHaveBeenCalledOnce()
+ })
+
+ it('preserves UTF-8 recipe JSON split across Buffer chunks', async () => {
+ const { child, result, stdoutSpy } = startRecipeJsonServer()
+ const unicodeRecipeJson = RECIPE_JSON.replace('/workspace/repo', '/workspace/café')
+ const recipeBuffer = Buffer.from(`${unicodeRecipeJson}\n`)
+ const splitIndex = recipeBuffer.indexOf(Buffer.from('é')) + 1
+ queueMicrotask(() => {
+ child.stdout.emit('data', recipeBuffer.subarray(0, splitIndex))
+ child.stdout.emit('data', recipeBuffer.subarray(splitIndex))
+ })
+
+ await expect(result).resolves.toBe(0)
+
+ expect(stdoutSpy).toHaveBeenCalledWith(`${unicodeRecipeJson}\n`)
+ expect(child.unref).toHaveBeenCalledOnce()
+ })
+
+ it('rejects when the server exits without valid recipe JSON', async () => {
+ const { child, result, stdoutSpy, stderrSpy } = startRecipeJsonServer()
+ const secrets = ['UPPER-SECRET', 'SLASH-SECRET', 'LEGACY-SECRET', 'PRIVATE-SECRET']
+ const untrustedLines = [
+ 'ORCA://pair?code=UPPER-SECRET',
+ 'orca://pair/?code=SLASH-SECRET',
+ 'orca://pair#LEGACY-SECRET',
+ '"embedded privateKey PRIVATE-SECRET"',
+ '{privateKey:"PRIVATE-SECRET"}'
+ ].join('\n')
+ queueMicrotask(() => {
+ child.stdout.emit('data', `${untrustedLines}\n`)
+ child.emit('exit', 0, null)
+ child.emit('close', 0, null)
+ })
+
+ await expect(result).rejects.toMatchObject({
+ code: 'runtime_serve_failed',
+ message: 'Orca serve exited before printing valid recipe JSON with code 0.'
+ })
+ expect(stdoutSpy).not.toHaveBeenCalled()
+ expect(stderrSpy).toHaveBeenCalledTimes(5)
+ expect(stderrSpy).toHaveBeenCalledWith(`${IGNORED_NON_RECIPE_STDOUT}\n`)
+ for (const secret of secrets) {
+ expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining(secret))
+ }
+ expect(child.unref).not.toHaveBeenCalled()
+ })
+
+ it('accepts valid recipe JSON at exit without a trailing newline', async () => {
+ const { child, result, stdoutSpy } = startRecipeJsonServer()
+ queueMicrotask(() => {
+ child.emit('exit', 0, null)
+ child.stdout.emit('data', RECIPE_JSON)
+ child.emit('close', 0, null)
+ })
+
+ await expect(result).resolves.toBe(0)
+
+ expect(stdoutSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`)
+ expect(child.unref).toHaveBeenCalledOnce()
+ })
+
it('uses a shell when a Windows npm command shim is the Electron executable', async () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts
index 29b5d561b16..cdb20f194ab 100644
--- a/src/cli/runtime/launch.ts
+++ b/src/cli/runtime/launch.ts
@@ -1,7 +1,14 @@
import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process'
import { dirname, resolve } from 'node:path'
+import { StringDecoder } from 'node:string_decoder'
+import {
+ getEphemeralVmRecipeResultConnection,
+ parseEphemeralVmRecipeResult
+} from '../../shared/ephemeral-vm-recipes'
import { RuntimeClientError } from './types'
+const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
+
export function launchOrcaApp(): void {
const overrideCommand = process.env.ORCA_OPEN_COMMAND
if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) {
@@ -155,7 +162,7 @@ function waitForRecipeJson(child: ReturnType): Promise): Promise {
- process.stdout.write(`${line}\n`)
- finish()
+ const writeIgnoredRecipeStdout = (): void => {
+ // Why: non-readiness child stdout is untrusted and cannot be safely
+ // redacted, including schema-valid results with arbitrary user data.
+ process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`)
}
- const onData = (chunk: Buffer | string): void => {
- output += chunk.toString()
- const newlineIndex = output.indexOf('\n')
- if (newlineIndex === -1) {
+ const processRecipeOutputLine = (line: string): void => {
+ const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line
+ if (!normalizedLine.trim()) {
return
}
- emitLine(output.slice(0, newlineIndex))
+ const parsed = parseEphemeralVmRecipeResult(normalizedLine)
+ if (!parsed.ok) {
+ writeIgnoredRecipeStdout()
+ return
+ }
+ if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') {
+ writeIgnoredRecipeStdout()
+ return
+ }
+ process.stdout.write(`${normalizedLine.trim()}\n`)
+ finish()
+ }
+ const stdoutDecoder = new StringDecoder('utf8')
+ const onData = (chunk: Buffer | string): void => {
+ output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk)
+ while (!settled) {
+ const newlineIndex = output.indexOf('\n')
+ if (newlineIndex === -1) {
+ return
+ }
+ const line = output.slice(0, newlineIndex)
+ output = output.slice(newlineIndex + 1)
+ processRecipeOutputLine(line)
+ }
}
const onError = (error: Error): void => {
finish(error)
}
- const onExit = (code: number | null, signal: NodeJS.Signals | null): void => {
+ const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (settled) {
return
}
- const trimmed = output.trim()
- if (trimmed) {
- emitLine(trimmed)
+ output += stdoutDecoder.end()
+ if (output.trim()) {
+ processRecipeOutputLine(output)
+ }
+ if (settled) {
return
}
finish(
new RuntimeClientError(
'runtime_serve_failed',
typeof code === 'number'
- ? `Orca serve exited before printing recipe JSON with code ${code}.`
- : `Orca serve exited before printing recipe JSON via ${signal}.`
+ ? `Orca serve exited before printing valid recipe JSON with code ${code}.`
+ : `Orca serve exited before printing valid recipe JSON via ${signal}.`
)
)
}
child.stdout?.on('data', onData)
child.once('error', onError)
- child.once('exit', onExit)
+ // Why: `exit` can precede the final piped stdout data. `close` waits until
+ // stdio closes so a last recipe chunk is not mistaken for missing output.
+ child.once('close', onClose)
})
}
diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts
index 3eb022dd7ed..bb63f261d00 100644
--- a/src/cli/specs/orchestration.ts
+++ b/src/cli/specs/orchestration.ts
@@ -26,7 +26,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
notes: [
'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:".',
'worker_done and heartbeat must target a concrete coordinator terminal handle; use status for broadcast updates.',
- 'A worker_done with the active task/dispatch IDs completes that task when sent from the dispatched pane (or when pane identity is unavailable); on older runtimes the sender must match the dispatch assignee handle, so avoid overriding --from.',
+ 'A worker_done with the active task/dispatch IDs completes that task only from the dispatched pane. When stable pane identity is unavailable, the sender handle must exactly match the dispatch assignee; injected preambles include the correct --from value.',
'Prefer --task-id/--dispatch-id/etc. over raw --payload JSON in worker commands; PowerShell strips JSON quotes easily.'
]
},
diff --git a/src/main/agent-hooks/branch-rename-failure-output.test.ts b/src/main/agent-hooks/branch-rename-failure-output.test.ts
new file mode 100644
index 00000000000..6df43fa457a
--- /dev/null
+++ b/src/main/agent-hooks/branch-rename-failure-output.test.ts
@@ -0,0 +1,51 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import {
+ __resetBranchRenameFailureOutputForTests,
+ readBranchRenameFailureOutputForDisplay,
+ rememberBranchRenameFailureOutput
+} from './branch-rename-failure-output'
+
+const output = (label: string) => ({
+ label,
+ exitCode: 1,
+ stdout: '',
+ stderr: `${label} failed`
+})
+
+beforeEach(() => {
+ __resetBranchRenameFailureOutputForTests()
+})
+
+describe('branch rename failure output store', () => {
+ it('returns the formatted capture for a remembered worktree', () => {
+ rememberBranchRenameFailureOutput('wt-1', output('Pi'))
+ expect(readBranchRenameFailureOutputForDisplay('wt-1')).toBe(
+ 'Pi exited with code 1.\n\n[stderr]\nPi failed'
+ )
+ })
+
+ it('returns null for unknown worktrees and after clearing', () => {
+ expect(readBranchRenameFailureOutputForDisplay('wt-unknown')).toBeNull()
+ rememberBranchRenameFailureOutput('wt-1', output('Pi'))
+ rememberBranchRenameFailureOutput('wt-1', null)
+ expect(readBranchRenameFailureOutputForDisplay('wt-1')).toBeNull()
+ })
+
+ it('evicts the least recently recorded worktree beyond the cap', () => {
+ for (let index = 0; index < 33; index += 1) {
+ rememberBranchRenameFailureOutput(`wt-${index}`, output(`Agent${index}`))
+ }
+ expect(readBranchRenameFailureOutputForDisplay('wt-0')).toBeNull()
+ expect(readBranchRenameFailureOutputForDisplay('wt-32')).not.toBeNull()
+ })
+
+ it('re-recording refreshes recency so the entry survives eviction', () => {
+ for (let index = 0; index < 32; index += 1) {
+ rememberBranchRenameFailureOutput(`wt-${index}`, output(`Agent${index}`))
+ }
+ rememberBranchRenameFailureOutput('wt-0', output('Agent0-again'))
+ rememberBranchRenameFailureOutput('wt-new', output('AgentNew'))
+ expect(readBranchRenameFailureOutputForDisplay('wt-0')).not.toBeNull()
+ expect(readBranchRenameFailureOutputForDisplay('wt-1')).toBeNull()
+ })
+})
diff --git a/src/main/agent-hooks/branch-rename-failure-output.ts b/src/main/agent-hooks/branch-rename-failure-output.ts
new file mode 100644
index 00000000000..8b8660484b7
--- /dev/null
+++ b/src/main/agent-hooks/branch-rename-failure-output.ts
@@ -0,0 +1,40 @@
+import {
+ formatAgentGenerationFailureOutputForDisplay,
+ type AgentGenerationFailureOutput
+} from '../text-generation/agent-failure-output'
+
+// Why: the full CLI output is a diagnostic for the local user only. Keeping it
+// in memory (never in worktree metadata) means nothing environment-identifying
+// is persisted or synced to paired clients; a restart just loses the on-demand
+// view while the sanitized excerpt badge survives.
+const MAX_ENTRIES = 32
+const entriesByWorktreeId = new Map()
+
+export function rememberBranchRenameFailureOutput(
+ worktreeId: string,
+ output: AgentGenerationFailureOutput | null | undefined
+): void {
+ // Delete-then-set keeps insertion order as recency so eviction drops the
+ // stalest worktree first.
+ entriesByWorktreeId.delete(worktreeId)
+ if (!output) {
+ return
+ }
+ entriesByWorktreeId.set(worktreeId, output)
+ while (entriesByWorktreeId.size > MAX_ENTRIES) {
+ const oldest = entriesByWorktreeId.keys().next().value
+ if (oldest === undefined) {
+ break
+ }
+ entriesByWorktreeId.delete(oldest)
+ }
+}
+
+export function readBranchRenameFailureOutputForDisplay(worktreeId: string): string | null {
+ const entry = entriesByWorktreeId.get(worktreeId)
+ return entry ? formatAgentGenerationFailureOutputForDisplay(entry) : null
+}
+
+export function __resetBranchRenameFailureOutputForTests(): void {
+ entriesByWorktreeId.clear()
+}
diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts
index e84cd3d0cf4..6247ee9f45c 100644
--- a/src/main/agent-hooks/first-work-branch-rename.test.ts
+++ b/src/main/agent-hooks/first-work-branch-rename.test.ts
@@ -300,11 +300,16 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
expect(setRenameError).toHaveBeenLastCalledWith(WORKTREE_ID, null)
})
- it('records a user-facing error when branch-name generation fails', async () => {
- generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'agent not ready' })
+ it('records a user-facing error and the full CLI output when generation fails', async () => {
+ const failureOutput = { label: 'Pi', exitCode: 1, stdout: '', stderr: 'No API key found.' }
+ generateBranchNameMock.mockResolvedValueOnce({
+ success: false,
+ error: 'agent not ready',
+ failureOutput
+ })
const { deps, setRenameError } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
- expect(setRenameError).toHaveBeenCalledWith(WORKTREE_ID, 'agent not ready')
+ expect(setRenameError).toHaveBeenCalledWith(WORKTREE_ID, 'agent not ready', failureOutput)
})
it('records a user-facing error when no generation agent is configured', async () => {
@@ -341,7 +346,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'agent not ready' })
const { deps, setRenameError } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
- expect(setRenameError).toHaveBeenCalledWith(WORKTREE_ID, 'agent not ready')
+ expect(setRenameError).toHaveBeenCalledWith(WORKTREE_ID, 'agent not ready', null)
// Second event: the user has since pushed the branch, so it settles benignly.
// The stale "rename failed" badge must be cleared rather than stick forever.
diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts
index 2b734fd41ca..1619cf9a9ef 100644
--- a/src/main/agent-hooks/first-work-branch-rename.ts
+++ b/src/main/agent-hooks/first-work-branch-rename.ts
@@ -28,7 +28,9 @@ import {
resolveTextGenerationParams
} from '../text-generation/commit-message-text-generation'
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
+import type { AgentGenerationFailureOutput } from '../text-generation/agent-failure-output'
import { resolveGenerationTarget } from './first-work-generation-target'
+import { runFolderWorkspaceTitleAutoRename } from './first-work-workspace-title-rename'
export type FirstWorkBranchRenameEvent = {
paneKey: string
@@ -58,8 +60,14 @@ export type FirstWorkBranchRenameDeps = {
/** Align the on-disk folder with the new branch leaf (best-effort, local-only). */
renameWorktreeFolder?: (worktreeId: string, newLeaf: string) => Promise
/** Record (or clear with null) a user-facing auto-rename generation failure
- * so the sidebar can show a "rename failed" badge instead of silent retries. */
- setRenameError: (worktreeId: string, error: string | null) => void
+ * so the sidebar can show a "rename failed" badge instead of silent retries.
+ * `failureOutput` carries the bounded full CLI output for local on-demand
+ * display; omitted/null replaces any stale capture. */
+ setRenameError: (
+ worktreeId: string,
+ error: string | null,
+ failureOutput?: AgentGenerationFailureOutput | null
+ ) => void
/** Authoritative tab→worktree resolution from the session's tab map. */
resolveWorktreeIdForTab: (tabId: string) => string | undefined
/** Invalidate caches + notify the renderer so the new branch name surfaces. */
@@ -249,7 +257,7 @@ async function runAutoRename(
// event retry rather than permanently leaving the creature name.
// A user-canceled generation isn't a failure to surface, so skip the badge.
if (!generated.canceled) {
- deps.setRenameError(worktreeId, generated.error)
+ deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null)
}
return retry(`generation failed: ${generated.error}`)
}
@@ -344,56 +352,3 @@ async function runAutoRename(
)
return true
}
-
-async function runFolderWorkspaceTitleAutoRename(
- worktreeId: string,
- prompt: string,
- assistantMessage: string | undefined,
- deps: FirstWorkBranchRenameDeps,
- stop: (reason: string, clearError?: boolean) => true,
- retry: (reason: string) => false
-): Promise {
- if (deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true) {
- return stop('folder workspace is not pending title rename', true)
- }
- const folderPath = deps.getFolderWorkspacePath?.(worktreeId)
- if (!folderPath) {
- return stop('folder workspace path unavailable')
- }
-
- const settings = deps.getSettings()
- const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null)
- if (!resolvedParams.ok) {
- deps.setRenameError(worktreeId, resolvedParams.error)
- return stop(`no generation agent: ${resolvedParams.error}`)
- }
- const target = await resolveGenerationTarget(
- folderPath,
- resolvedParams.params.agentId,
- null,
- deps
- )
- if (!target) {
- deps.setRenameError(worktreeId, 'Could not prepare the workspace-name generation environment.')
- return retry('could not prepare generation environment')
- }
-
- const generated = await generateBranchNameFromContext(
- { firstPrompt: prompt, assistantMessage },
- resolvedParams.params,
- target
- )
- if (!generated.success) {
- if (!generated.canceled) {
- deps.setRenameError(worktreeId, generated.error)
- }
- return retry(`generation failed: ${generated.error}`)
- }
-
- const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug: generated.slug })
- deps.setDisplayName(worktreeId, newDisplayName)
- deps.setRenameError(worktreeId, null)
- deps.onRenamed(worktreeId)
- console.info(`[auto-branch-rename] renamed folder workspace title -> "${newDisplayName}"`)
- return true
-}
diff --git a/src/main/agent-hooks/first-work-workspace-title-rename.ts b/src/main/agent-hooks/first-work-workspace-title-rename.ts
new file mode 100644
index 00000000000..96bcdbde419
--- /dev/null
+++ b/src/main/agent-hooks/first-work-workspace-title-rename.ts
@@ -0,0 +1,65 @@
+import { deriveWorkspaceDisplayName } from '../../shared/display-name-from-work'
+import {
+ generateBranchNameFromContext,
+ resolveTextGenerationParams
+} from '../text-generation/commit-message-text-generation'
+import { resolveGenerationTarget } from './first-work-generation-target'
+import type { FirstWorkBranchRenameDeps } from './first-work-branch-rename'
+
+/**
+ * Non-git folder workspaces have no branch to rename; the first-work hook
+ * instead generates a workspace title. Shares the branch-name generation
+ * machinery and the caller's stop/retry verdict semantics.
+ */
+export async function runFolderWorkspaceTitleAutoRename(
+ worktreeId: string,
+ prompt: string,
+ assistantMessage: string | undefined,
+ deps: FirstWorkBranchRenameDeps,
+ stop: (reason: string, clearError?: boolean) => true,
+ retry: (reason: string) => false
+): Promise {
+ if (deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true) {
+ return stop('folder workspace is not pending title rename', true)
+ }
+ const folderPath = deps.getFolderWorkspacePath?.(worktreeId)
+ if (!folderPath) {
+ return stop('folder workspace path unavailable')
+ }
+
+ const settings = deps.getSettings()
+ const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null)
+ if (!resolvedParams.ok) {
+ deps.setRenameError(worktreeId, resolvedParams.error)
+ return stop(`no generation agent: ${resolvedParams.error}`)
+ }
+ const target = await resolveGenerationTarget(
+ folderPath,
+ resolvedParams.params.agentId,
+ null,
+ deps
+ )
+ if (!target) {
+ deps.setRenameError(worktreeId, 'Could not prepare the workspace-name generation environment.')
+ return retry('could not prepare generation environment')
+ }
+
+ const generated = await generateBranchNameFromContext(
+ { firstPrompt: prompt, assistantMessage },
+ resolvedParams.params,
+ target
+ )
+ if (!generated.success) {
+ if (!generated.canceled) {
+ deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null)
+ }
+ return retry(`generation failed: ${generated.error}`)
+ }
+
+ const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug: generated.slug })
+ deps.setDisplayName(worktreeId, newDisplayName)
+ deps.setRenameError(worktreeId, null)
+ deps.onRenamed(worktreeId)
+ console.info(`[auto-branch-rename] renamed folder workspace title -> "${newDisplayName}"`)
+ return true
+}
diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts
index 9e9676c2243..7423be836dd 100644
--- a/src/main/agent-hooks/server.test.ts
+++ b/src/main/agent-hooks/server.test.ts
@@ -3330,6 +3330,30 @@ describe('AgentHookServer prompt-sent telemetry', () => {
expect(trackMock).toHaveBeenCalledTimes(2)
})
+ it('includes Command Code prompt interaction keys in the IPC snapshot', () => {
+ const server = new AgentHookServer()
+
+ server.ingestRemote(
+ {
+ paneKey: PANE,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ hasExplicitPrompt: true,
+ promptInteractionKey: 'command-code-transcript-user-1',
+ payload: { state: 'working', prompt: 'rerun', agentType: 'command-code' }
+ },
+ 'conn-1'
+ )
+
+ expect(server.getStatusSnapshot()[0]).toMatchObject({
+ paneKey: PANE,
+ promptInteractionKey: 'command-code-transcript-user-1',
+ state: 'working',
+ prompt: 'rerun',
+ agentType: 'command-code'
+ })
+ })
+
it('dedupes Command Code direct prompt hooks followed by transcript-backed stop hooks', () => {
const server = new AgentHookServer()
@@ -3767,8 +3791,17 @@ describe('Claude hook normalization', () => {
expect(result?.payload.toolInput).toBeUndefined()
})
- it('PostToolUseFailure surfaces the error text as lastAssistantMessage', () => {
- const result = _internals.normalizeHookPayload(
+ it('PostToolUseFailure clears stale tool fields until the next PreToolUse', () => {
+ _internals.normalizeHookPayload(
+ 'claude',
+ buildBody({
+ hook_event_name: 'PreToolUse',
+ tool_name: 'Edit',
+ tool_input: { file_path: '/src/config.ts' }
+ }),
+ 'production'
+ )
+ const failed = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PostToolUseFailure',
@@ -3778,9 +3811,27 @@ describe('Claude hook normalization', () => {
}),
'production'
)
- expect(result?.payload.state).toBe('working')
- expect(result?.payload.toolName).toBe('Edit')
- expect(result?.payload.lastAssistantMessage).toBe('file is read-only')
+ expect(failed?.payload).toMatchObject({
+ state: 'working',
+ lastAssistantMessage: 'file is read-only'
+ })
+ expect(failed?.payload.toolName).toBeUndefined()
+ expect(failed?.payload.toolInput).toBeUndefined()
+
+ const retry = _internals.normalizeHookPayload(
+ 'claude',
+ buildBody({
+ hook_event_name: 'PreToolUse',
+ tool_name: 'Read',
+ tool_input: { file_path: '/src/config.ts' }
+ }),
+ 'production'
+ )
+ expect(retry?.payload).toMatchObject({
+ state: 'working',
+ toolName: 'Read',
+ toolInput: '/src/config.ts'
+ })
})
it('PreToolUse normalizes to working + tool fields', () => {
@@ -4500,6 +4551,36 @@ describe('Cursor hook normalization', () => {
expect(result?.payload.toolInput).toBe('/repo/src/app.ts')
})
+ it('postToolUseFailure surfaces the error and clears stale tool fields', () => {
+ _internals.normalizeHookPayload(
+ 'cursor',
+ buildBody({
+ hook_event_name: 'preToolUse',
+ tool_name: 'Read',
+ tool_input: { file_path: '/repo/src/app.ts' }
+ }),
+ 'production'
+ )
+ const failed = _internals.normalizeHookPayload(
+ 'cursor',
+ buildBody({
+ hook_event_name: 'postToolUseFailure',
+ tool_name: 'Read',
+ tool_input: { file_path: '/repo/src/app.ts' },
+ error_message: 'file not found'
+ }),
+ 'production'
+ )
+ // Why: keeping toolName set would let the compact sidebar show the tool
+ // instead of the failure text, hiding the error from the user.
+ expect(failed?.payload).toMatchObject({
+ state: 'working',
+ lastAssistantMessage: 'file not found'
+ })
+ expect(failed?.payload.toolName).toBeUndefined()
+ expect(failed?.payload.toolInput).toBeUndefined()
+ })
+
it('afterAgentResponse carries text into lastAssistantMessage', () => {
const result = _internals.normalizeHookPayload(
'cursor',
@@ -5138,6 +5219,36 @@ describe('Copilot hook normalization', () => {
expect(result?.payload.toolInput).toBe('pnpm test')
})
+ it('PostToolUseFailure surfaces the error and clears stale tool fields', () => {
+ _internals.normalizeHookPayload(
+ 'copilot',
+ buildBody({
+ hook_event_name: 'PreToolUse',
+ toolName: 'bash',
+ toolInput: { command: 'pnpm test' }
+ }),
+ 'production'
+ )
+ const failed = _internals.normalizeHookPayload(
+ 'copilot',
+ buildBody({
+ hook_event_name: 'PostToolUseFailure',
+ toolName: 'bash',
+ toolInput: { command: 'pnpm test' },
+ error_message: 'command not found'
+ }),
+ 'production'
+ )
+ // Why: keeping toolName set would let the compact sidebar show the tool
+ // instead of the failure text, hiding the error from the user.
+ expect(failed?.payload).toMatchObject({
+ state: 'working',
+ lastAssistantMessage: 'command not found'
+ })
+ expect(failed?.payload.toolName).toBeUndefined()
+ expect(failed?.payload.toolInput).toBeUndefined()
+ })
+
it('PreToolUse ask_user maps to blocked and surfaces the question', () => {
_internals.normalizeHookPayload(
'copilot',
diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts
index f859277303d..32954344084 100644
--- a/src/main/agent-hooks/server.ts
+++ b/src/main/agent-hooks/server.ts
@@ -59,6 +59,7 @@ import {
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
import type { LegacyPaneKeyAliasEntry } from '../../shared/types'
import { normalizeAgentProviderSession } from '../../shared/agent-session-resume'
+import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary'
export type { AgentHookSource }
@@ -251,6 +252,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
receivedAt: entry.receivedAt,
stateStartedAt: entry.stateStartedAt,
...(entry.providerSession ? { providerSession: entry.providerSession } : {}),
+ ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}),
...entry.payload
}
}
@@ -653,8 +655,22 @@ export class AgentHookServer {
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
+ const commandCodeNewTurn =
+ previous !== undefined &&
+ isCommandCodeNewTurnWhileWorking({
+ agentType: payload.payload.agentType,
+ previousState: previous.payload.state,
+ incomingState: payload.payload.state,
+ previousPrompt: previous.payload.prompt,
+ incomingPrompt: payload.payload.prompt,
+ hasExplicitPrompt: payload.hasExplicitPrompt,
+ previousPromptInteractionKey: previous.promptInteractionKey,
+ incomingPromptInteractionKey: payload.promptInteractionKey
+ })
const stateStartedAt =
- previous && previous.payload.state === payload.payload.state ? previous.stateStartedAt : now
+ previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn
+ ? previous.stateStartedAt
+ : now
return {
...payload,
receivedAt: now,
diff --git a/src/main/cli/keyed-promise-queue.test.ts b/src/main/cli/keyed-promise-queue.test.ts
new file mode 100644
index 00000000000..a4a9aab5687
--- /dev/null
+++ b/src/main/cli/keyed-promise-queue.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import { getKeyedSerializedQueueTail, runKeyedSerializedOperation } from './keyed-promise-queue'
+
+describe('runKeyedSerializedOperation', () => {
+ it('propagates rejections to the caller but never through the stored tail', async () => {
+ const queues = new Map>()
+ const failing = runKeyedSerializedOperation(queues, 'key', async () => {
+ throw new Error('write failed')
+ })
+ // Why: awaiting the tail is how reads barrier on writes; an unrelated
+ // failed write must not abort the reader (startup candidate discovery).
+ const tail = getKeyedSerializedQueueTail(queues, 'key')
+
+ await expect(failing).rejects.toThrow('write failed')
+ await expect(tail).resolves.toBeUndefined()
+ await expect(runKeyedSerializedOperation(queues, 'key', async () => 'recovered')).resolves.toBe(
+ 'recovered'
+ )
+ })
+
+ it('serializes operations per key and clears settled queues', async () => {
+ const queues = new Map>()
+ const events: string[] = []
+ let release!: () => void
+ const first = runKeyedSerializedOperation(queues, 'a', async () => {
+ events.push('first-start')
+ await new Promise((resolve) => {
+ release = resolve
+ })
+ events.push('first-end')
+ })
+ const second = runKeyedSerializedOperation(queues, 'a', async () => {
+ events.push('second')
+ })
+ const other = runKeyedSerializedOperation(queues, 'b', async () => {
+ events.push('other')
+ })
+
+ await other
+ expect(events).toEqual(['first-start', 'other'])
+ release()
+ await Promise.all([first, second])
+ expect(events).toEqual(['first-start', 'other', 'first-end', 'second'])
+ await expect(getKeyedSerializedQueueTail(queues, 'a')).resolves.toBeUndefined()
+ expect(queues.size).toBe(0)
+ })
+})
diff --git a/src/main/cli/keyed-promise-queue.ts b/src/main/cli/keyed-promise-queue.ts
new file mode 100644
index 00000000000..8fb7b8d56aa
--- /dev/null
+++ b/src/main/cli/keyed-promise-queue.ts
@@ -0,0 +1,33 @@
+/**
+ * Serializes async operations per key. The stored queue tail never rejects, so
+ * callers may await it (e.g. read-after-write barriers) without inheriting an
+ * unrelated operation's failure; rejections still propagate to the enqueuer.
+ */
+export function runKeyedSerializedOperation(
+ queues: Map>,
+ key: string,
+ operation: () => Promise
+): Promise {
+ const previous = queues.get(key) ?? Promise.resolve()
+ const current = previous.then(operation)
+ const queued = current.then(
+ () => undefined,
+ () => undefined
+ )
+ queues.set(key, queued)
+
+ const clear = (): void => {
+ if (queues.get(key) === queued) {
+ queues.delete(key)
+ }
+ }
+ queued.then(clear, clear)
+ return current
+}
+
+export function getKeyedSerializedQueueTail(
+ queues: Map>,
+ key: string
+): Promise {
+ return queues.get(key) ?? Promise.resolve()
+}
diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts
index b9228fcb6a2..6324e75174e 100644
--- a/src/main/cli/wsl-cli-installer.test.ts
+++ b/src/main/cli/wsl-cli-installer.test.ts
@@ -1,22 +1,28 @@
import type { CliInstallStatus } from '../../shared/cli-install-types'
+import { execFileSync } from 'node:child_process'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const execFileMock = vi.hoisted(() => vi.fn())
-vi.mock('node:child_process', () => ({
+vi.mock('node:child_process', async (importOriginal) => ({
+ ...(await importOriginal>()),
execFile: execFileMock
}))
import { WslCliInstaller, _internals } from './wsl-cli-installer'
+import { reconcileManagedWslCliRegistrations } from './wsl-cli-registration-reconciliation'
function makeHostStatus(
- launcherPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
+ launcherPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe'
) {
return {
platform: 'win32',
commandName: 'orca',
- commandPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd',
- pathDirectory: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin',
+ commandPath: launcherPath,
+ pathDirectory: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin',
pathConfigured: true,
launcherPath,
installMethod: 'wrapper',
@@ -28,13 +34,51 @@ function makeHostStatus(
} satisfies CliInstallStatus
}
-function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin = true) {
+// Frozen from v1.4.138-rc.2: the upgrade regression only reproduces when the
+// persisted managed script still names the pre-native Windows batch launcher.
+const PRE_RC4_MANAGED_WSL_LAUNCHER = `#!/usr/bin/env bash
+set -euo pipefail
+# Orca managed WSL CLI launcher
+# ORCA_WIN_LAUNCHER_B64=QzpcUHJvZ3JhbSBGaWxlc1xPcmNhXHJlc291cmNlc1xiaW5cb3JjYS5jbWQ=
+ORCA_WIN_LAUNCHER='C:\\Program Files\\Orca\\resources\\bin\\orca.cmd'
+ORCA_BRIDGE_PS1='/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
+if command -v powershell.exe >/dev/null 2>&1; then
+ ORCA_POWERSHELL=powershell.exe
+elif [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; then
+ ORCA_POWERSHELL=/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe
+else
+ echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2
+ exit 1
+fi
+ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1")
+exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@"
+`
+
+function createWslRunner(
+ initialFile: string | null = null,
+ pathIncludesLocalBin = true,
+ options: {
+ initialBridge?: string | null
+ initialLegacyFile?: string | null
+ failInstall?: boolean
+ interopReady?: boolean
+ } = {}
+) {
const commandPath = '/home/alice/.local/bin/orca-ide'
+ const legacyCommandPath = '/home/alice/.local/bin/orca'
const bridgePath = '/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
const files = new Map()
if (initialFile !== null) {
files.set(commandPath, initialFile)
- files.set(bridgePath, _internals.buildWslBridgeScript())
+ }
+ if (
+ options.initialBridge !== null &&
+ (initialFile !== null || options.initialBridge !== undefined)
+ ) {
+ files.set(bridgePath, options.initialBridge ?? _internals.buildWslBridgeScript())
+ }
+ if (options.initialLegacyFile) {
+ files.set(legacyCommandPath, options.initialLegacyFile)
}
const calls: string[] = []
const runner = vi.fn(async (_distro: string, command: string) => {
@@ -46,6 +90,15 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
return pathIncludesLocalBin ? 'yes' : 'no'
}
if (command.includes('cat > "$command_tmp"')) {
+ if (options.failInstall) {
+ throw new Error('simulated replacement failure')
+ }
+ if (
+ files.has(bridgePath) &&
+ !files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge')
+ ) {
+ throw new Error('__ORCA_CONFLICT__')
+ }
const launcher =
command.match(/cat > "\$command_tmp" <<'ORCA_WSL_CLI'\n([\s\S]*)\nORCA_WSL_CLI/)?.[1] ?? ''
const bridge =
@@ -54,20 +107,31 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
)?.[1] ?? ''
files.set(commandPath, launcher)
files.set(bridgePath, bridge)
+ if (files.get(legacyCommandPath)?.includes('# Orca managed WSL CLI launcher')) {
+ files.delete(legacyCommandPath)
+ }
return ''
}
if (command.includes('command -v powershell.exe')) {
- return 'yes'
+ return options.interopReady === false ? 'no' : 'yes'
}
if (command.includes('rm -f')) {
+ if (command.includes(`rm -f '${commandPath}'`)) {
+ if (
+ files.has(bridgePath) &&
+ !files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge')
+ ) {
+ throw new Error('__ORCA_CONFLICT__')
+ }
+ files.delete(commandPath)
+ files.delete(bridgePath)
+ }
if (
- files.has(bridgePath) &&
- !files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge')
+ command.includes(legacyCommandPath) &&
+ files.get(legacyCommandPath)?.includes('# Orca managed WSL CLI launcher')
) {
- throw new Error('__ORCA_CONFLICT__')
+ files.delete(legacyCommandPath)
}
- files.delete(commandPath)
- files.delete(bridgePath)
return ''
}
if (command.includes('cat ')) {
@@ -77,6 +141,9 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
if (command.includes(bridgePath)) {
return files.get(bridgePath) ?? '__ORCA_MISSING__'
}
+ if (command.includes(legacyCommandPath)) {
+ return files.get(legacyCommandPath) ?? '__ORCA_MISSING__'
+ }
}
throw new Error(`Unexpected WSL command: ${command}`)
})
@@ -84,7 +151,8 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
runner,
calls,
getBridge: () => files.get(bridgePath) ?? null,
- getFile: () => files.get(commandPath) ?? null
+ getFile: () => files.get(commandPath) ?? null,
+ getLegacyFile: () => files.get(legacyCommandPath) ?? null
}
}
@@ -116,11 +184,11 @@ describe('WslCliInstaller', () => {
expect(installed).toMatchObject({
state: 'installed',
pathConfigured: true,
- launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
+ launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe'
})
expect(wsl.getFile()).toBe(
_internals.buildWslLauncher(
- 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd',
+ 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe',
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
)
)
@@ -128,6 +196,7 @@ describe('WslCliInstaller', () => {
const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"'))
expect(installCommand).toContain("legacy_command_path='/home/alice/.local/bin/orca'")
expect(installCommand).toContain('rm -f "$legacy_command_path"')
+ expect(installCommand).toContain('[ ! -L "$legacy_command_path" ]')
})
it('derives the shared WSL bridge path for current and legacy command names', () => {
@@ -291,15 +360,387 @@ describe('WslCliInstaller', () => {
await expect(installer.getStatus()).resolves.toMatchObject({
state: 'stale',
currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd',
- launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
+ launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe'
})
await expect(installer.install()).resolves.toMatchObject({
state: 'installed',
- currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
+ currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe'
})
})
+ it('repairs the frozen pre-rc4 registration so orchestration send/reply reach native rc4', async () => {
+ const nativeLauncher = 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe'
+ const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER)
+ const installer = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
+ wslRunner: wsl.runner
+ })
+ const orchestrationCalls = [
+ ['orchestration', 'send', '--type', 'heartbeat'],
+ ['orchestration', 'send', '--type', 'worker_done'],
+ ['orchestration', 'reply', '--message', 'line one\nline two']
+ ]
+ const simulateRc4Launch = (args: string[]): number => {
+ const target = _internals.parseManagedLauncherTarget(wsl.getFile() ?? '')
+ return target?.toLowerCase().endsWith('orca.cmd') &&
+ args[0] === 'orchestration' &&
+ (args[1] === 'send' || args[1] === 'reply')
+ ? 2
+ : 0
+ }
+
+ expect(orchestrationCalls.map(simulateRc4Launch)).toEqual([2, 2, 2])
+
+ await expect(
+ reconcileManagedWslCliRegistrations({
+ platform: 'win32',
+ isPackaged: true,
+ userDataPath: '/user-data',
+ listDistros: async () => ['Ubuntu'],
+ registry: {
+ getCandidates: async () => ['Ubuntu'],
+ recordObservations: async () => undefined
+ },
+ createInstaller: () => installer
+ })
+ ).resolves.toEqual([
+ { distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true }
+ ])
+ await expect(installer.getStatus()).resolves.toMatchObject({
+ state: 'installed',
+ currentTarget: nativeLauncher
+ })
+ expect(orchestrationCalls.map(simulateRc4Launch)).toEqual([0, 0, 0])
+ })
+
+ it('leaves unmanaged WSL commands and conflicting bridges untouched during automatic repair', async () => {
+ const unmanaged = '#!/usr/bin/env bash\necho user-owned\n'
+ const wsl = createWslRunner(unmanaged)
+ const installer = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus() },
+ wslRunner: wsl.runner
+ })
+
+ await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
+ changed: false,
+ status: { state: 'conflict' }
+ })
+ expect(wsl.getFile()).toBe(unmanaged)
+ expect(wsl.calls.some((command) => command.includes('cat > "$command_tmp"'))).toBe(false)
+ })
+
+ it('repairs a managed launcher whose bridge is missing, but preserves a conflicting bridge', async () => {
+ const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe'
+ const missingBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, {
+ initialBridge: null
+ })
+ const missingBridgeInstaller = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
+ wslRunner: missingBridge.runner
+ })
+
+ await expect(missingBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({
+ changed: true,
+ status: { state: 'installed' }
+ })
+ expect(missingBridge.getBridge()).toBe(_internals.buildWslBridgeScript())
+
+ const staleBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, {
+ initialBridge: '# Orca managed WSL CLI PowerShell bridge\nWrite-Output "stale"\n'
+ })
+ const staleBridgeInstaller = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
+ wslRunner: staleBridge.runner
+ })
+ await expect(staleBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({
+ changed: true,
+ status: { state: 'installed' }
+ })
+ expect(staleBridge.getBridge()).toBe(_internals.buildWslBridgeScript())
+
+ const conflictingBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, {
+ initialBridge: 'Write-Output "user-owned bridge"\n'
+ })
+ const conflictingBridgeInstaller = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
+ wslRunner: conflictingBridge.runner
+ })
+
+ // Why: a stale launcher with a user-owned bridge must surface as a
+ // non-throwing conflict, not retry a doomed install on every startup.
+ await expect(conflictingBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({
+ changed: false,
+ managed: true,
+ status: { state: 'conflict' }
+ })
+ expect(conflictingBridge.getBridge()).toBe('Write-Output "user-owned bridge"\n')
+ expect(conflictingBridge.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
+ expect(
+ conflictingBridge.calls.some((command) => command.includes('cat > "$command_tmp"'))
+ ).toBe(false)
+ })
+
+ it('retains command ownership when only the bridge conflicts', async () => {
+ const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe'
+ const currentLauncher = _internals.buildWslLauncher(
+ nativeLauncher,
+ '/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
+ )
+ const wsl = createWslRunner(currentLauncher, true, {
+ initialBridge: 'Write-Output "user-owned bridge"\n'
+ })
+ const installer = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
+ wslRunner: wsl.runner
+ })
+
+ await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
+ changed: false,
+ managed: true,
+ status: { state: 'conflict' }
+ })
+ expect(wsl.getBridge()).toBe('Write-Output "user-owned bridge"\n')
+ expect(wsl.getFile()).toBe(currentLauncher)
+ })
+
+ it('moves a legacy-only managed registration to orca-ide without touching unmanaged names', async () => {
+ const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe'
+ const managedLegacy = createWslRunner(null, true, {
+ initialBridge: _internals.buildWslBridgeScript(),
+ initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER
+ })
+ const installer = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
+ wslRunner: managedLegacy.runner
+ })
+
+ await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
+ changed: true,
+ status: { state: 'installed', currentTarget: nativeLauncher }
+ })
+ expect(managedLegacy.getLegacyFile()).toBeNull()
+
+ const unmanagedLegacy = createWslRunner(null, true, {
+ initialLegacyFile: '#!/bin/sh\necho user-owned\n'
+ })
+ const unmanagedInstaller = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
+ wslRunner: unmanagedLegacy.runner
+ })
+ await expect(unmanagedInstaller.repairManagedRegistration()).resolves.toMatchObject({
+ changed: false,
+ status: { state: 'not_installed' }
+ })
+ expect(unmanagedLegacy.getLegacyFile()).toBe('#!/bin/sh\necho user-owned\n')
+ })
+
+ it('does not adopt a legacy-managed registration when the bridge is user-owned', async () => {
+ const wsl = createWslRunner(null, true, {
+ initialBridge: 'Write-Output "user-owned bridge"\n',
+ initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER
+ })
+ const installer = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus() },
+ wslRunner: wsl.runner
+ })
+
+ // Why: adoption would fail install()'s bridge guard on every startup;
+ // repair must report blocked-but-managed instead of a doomed install.
+ await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
+ changed: false,
+ managed: true,
+ status: { state: 'not_installed' }
+ })
+ expect(wsl.getLegacyFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
+ expect(wsl.calls.some((command) => command.includes('cat > "$command_tmp"'))).toBe(false)
+ })
+
+ it('removes the managed legacy launcher on removal so reconciliation cannot re-adopt it', async () => {
+ const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe'
+ const managedLegacy = createWslRunner(null, true, {
+ initialBridge: _internals.buildWslBridgeScript(),
+ initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER
+ })
+ const managedLegacyInstaller = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
+ wslRunner: managedLegacy.runner
+ })
+ await expect(managedLegacyInstaller.remove()).resolves.toMatchObject({
+ state: 'not_installed'
+ })
+ expect(managedLegacy.getLegacyFile()).toBeNull()
+
+ const unmanagedLegacy = createWslRunner(null, true, {
+ initialLegacyFile: '#!/bin/sh\necho user-owned\n'
+ })
+ const unmanagedInstaller2 = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
+ wslRunner: unmanagedLegacy.runner
+ })
+ await expect(unmanagedInstaller2.remove()).resolves.toMatchObject({
+ state: 'not_installed'
+ })
+ expect(unmanagedLegacy.getLegacyFile()).toBe('#!/bin/sh\necho user-owned\n')
+
+ const installedWithLegacy = createWslRunner(
+ _internals.buildWslLauncher(
+ nativeLauncher,
+ '/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
+ ),
+ true,
+ { initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER }
+ )
+ const installedInstaller = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
+ wslRunner: installedWithLegacy.runner
+ })
+ await expect(installedInstaller.remove()).resolves.toMatchObject({ state: 'not_installed' })
+ expect(installedWithLegacy.getFile()).toBeNull()
+ expect(installedWithLegacy.getLegacyFile()).toBeNull()
+ })
+
+ it('keeps the pre-rc4 files on a transactional replacement failure', async () => {
+ const bridge = _internals.buildWslBridgeScript()
+ const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, {
+ initialBridge: bridge,
+ failInstall: true
+ })
+ const installer = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: {
+ getStatus: async () => makeHostStatus('C:\\Program Files\\Orca\\resources\\bin\\orca.exe')
+ },
+ wslRunner: wsl.runner
+ })
+
+ await expect(installer.repairManagedRegistration()).rejects.toThrow(
+ 'simulated replacement failure'
+ )
+ expect(wsl.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
+ expect(wsl.getBridge()).toBe(bridge)
+ const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"'))
+ expect(installCommand).toContain('rollback() {')
+ expect(installCommand).toContain('set +e')
+ expect(installCommand).toContain('bridge_backup="${bridge_tmp}.backup"')
+ expect(installCommand).toContain('cp -p')
+ expect(installCommand).toContain('elif [ "$bridge_touched" -eq 1 ]')
+ expect(installCommand).toContain('committed=1')
+ expect(installCommand).toContain('flock -x -w 30 9')
+ // Why: the command replace must stay one atomic rename; a mv-based backup
+ // would leave a window where a concurrent shell finds no orca-ide at all.
+ expect(installCommand).not.toContain('command_backup')
+ expect(installCommand).not.toContain(`mv -f '/home/alice/.local/bin/orca-ide'`)
+ })
+
+ it.skipIf(process.platform === 'win32')(
+ 'rolls both files back when the command replacement fails after the bridge move',
+ async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-wsl-cli-rollback-'))
+ const home = join(root, 'home with spaces')
+ const commandPath = join(home, '.local', 'bin', 'orca-ide')
+ const bridgePath = join(home, '.local', 'share', 'orca', 'orca-wsl-bridge.ps1')
+ const bridge = _internals.buildWslBridgeScript()
+ await mkdir(join(home, '.local', 'bin'), { recursive: true })
+ await mkdir(join(home, '.local', 'share', 'orca'), { recursive: true })
+ await writeFile(commandPath, PRE_RC4_MANAGED_WSL_LAUNCHER, 'utf8')
+ await writeFile(bridgePath, bridge, 'utf8')
+
+ const runner = async (_distro: string, command: string): Promise => {
+ if (command.includes('printf %s "$HOME"')) {
+ return home
+ }
+ if (command.includes('cat > "$command_tmp"')) {
+ const executableCommand = command
+ .split('\n')
+ .map((line) => (line.startsWith('mv -f "$command_tmp" ') ? 'exit 71' : line))
+ .join('\n')
+ return execFileSync('bash', ['-c', executableCommand], { encoding: 'utf8' })
+ }
+ if (command.includes('command -v powershell.exe')) {
+ return 'yes'
+ }
+ if (command.includes('case ":$PATH:"')) {
+ return 'yes'
+ }
+ return execFileSync('bash', ['-c', command], { encoding: 'utf8' })
+ }
+ const installer = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: {
+ getStatus: async () => makeHostStatus('C:\\Program Files\\Orca\\resources\\bin\\orca.exe')
+ },
+ wslRunner: runner
+ })
+
+ try {
+ await expect(installer.repairManagedRegistration()).rejects.toThrow()
+ await expect(readFile(commandPath, 'utf8')).resolves.toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
+ await expect(readFile(bridgePath, 'utf8')).resolves.toBe(bridge)
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+ }
+ )
+
+ it('skips automatic repair when WSL interop is unavailable', async () => {
+ const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, { interopReady: false })
+ const installer = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus() },
+ wslRunner: wsl.runner
+ })
+
+ await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
+ changed: false,
+ status: { state: 'unsupported' }
+ })
+ expect(wsl.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
+ })
+
+ it('is idempotent after repairing an old managed registration', async () => {
+ const nativeLauncher = 'D:\\Custom Orca\\resources\\bin\\orca.exe'
+ const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER)
+ const installer = new WslCliInstaller({
+ platform: 'win32',
+ distro: 'Ubuntu',
+ hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
+ wslRunner: wsl.runner
+ })
+
+ await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ changed: true })
+ await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ changed: false })
+ expect(wsl.calls.filter((command) => command.includes('cat > "$command_tmp"'))).toHaveLength(1)
+ expect(wsl.getFile()).toContain("ORCA_WIN_LAUNCHER='D:\\Custom Orca\\resources\\bin\\orca.exe'")
+ })
+
it('settles when wsl.exe never reports completion', async () => {
vi.useFakeTimers()
const killMock = vi.fn()
diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts
index 5d473ad5bfd..f6a1684e19c 100644
--- a/src/main/cli/wsl-cli-installer.ts
+++ b/src/main/cli/wsl-cli-installer.ts
@@ -5,6 +5,8 @@ import type { CliInstallStatus } from '../../shared/cli-install-types'
import { getDefaultWslDistro } from '../wsl'
import { CliInstaller } from './cli-installer'
import {
+ buildManagedLegacyRemoveCommand,
+ buildRegistrationLockPrelude,
buildSafeRemoveCommand,
buildSafeReplaceGuard,
buildWslBridgeScript,
@@ -38,6 +40,12 @@ type WslCliInstallerOptions = {
wslRunner?: (distro: string, command: string) => Promise
}
+export type ManagedWslCliRepairResult = {
+ changed: boolean
+ managed: boolean
+ status: CliInstallStatus
+}
+
export class WslCliInstaller {
private readonly platform: NodeJS.Platform
private readonly distro: string | null
@@ -119,21 +127,80 @@ export class WslCliInstaller {
})
}
+ // Why: a stale managed launcher is only repairable when its bridge is
+ // ours too; reporting conflict here keeps repair from a doomed install
+ // whose bridge guard would fail on every startup.
+ const bridgeConflict = managed && (await this.isBridgeConflict(ready.distro, ready.bridgePath))
return this.buildStatus({
distro: ready.distro,
commandPath: ready.commandPath,
launcherPath: ready.launcherPath,
- state: managed ? 'stale' : 'conflict',
+ state: managed && !bridgeConflict ? 'stale' : 'conflict',
currentTarget,
pathConfigured: ready.pathConfigured,
- detail: managed
- ? `${ready.commandPath} points to a different Orca launcher.`
- : `${ready.commandPath} exists but is not managed by Orca.`
+ detail: !managed
+ ? `${ready.commandPath} exists but is not managed by Orca.`
+ : bridgeConflict
+ ? `${ready.bridgePath} exists but is not managed by Orca.`
+ : `${ready.commandPath} points to a different Orca launcher.`
})
}
- async install(): Promise {
+ private async isBridgeConflict(distro: string, bridgePath: string): Promise {
+ const bridgeContent = await this.readCommandFile(distro, bridgePath)
+ if (bridgeContent === null) {
+ return false
+ }
+ return bridgeContent === 'not_file' || !bridgeContent.includes(BRIDGE_MANAGED_MARKER)
+ }
+
+ async repairManagedRegistration(): Promise {
const status = await this.getStatus()
+ if (!status.supported) {
+ return { changed: false, managed: false, status }
+ }
+ if (status.state === 'conflict') {
+ // Why: a user-owned bridge conflicts with repair, but the launcher is
+ // still Orca-managed and must remain registered for future reconciliation.
+ return { changed: false, managed: status.currentTarget !== null, status }
+ }
+
+ if (status.state === 'stale') {
+ return { changed: true, managed: true, status: await this.install(status) }
+ }
+
+ const legacyCommandPath = status.commandPath
+ ? `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
+ : null
+ if (!legacyCommandPath || !this.distro) {
+ return { changed: false, managed: status.state === 'installed', status }
+ }
+
+ const legacyContent = await this.readCommandFile(this.distro, legacyCommandPath)
+ const legacyManaged =
+ typeof legacyContent === 'string' && legacyContent.includes(MANAGED_MARKER)
+ if (!legacyManaged) {
+ return { changed: false, managed: status.state === 'installed', status }
+ }
+
+ if (
+ status.commandPath &&
+ (await this.isBridgeConflict(this.distro, getBridgePathFromCommandPath(status.commandPath)))
+ ) {
+ // Why: adopting the legacy command would fail install()'s bridge guard
+ // forever; stay registered so reconciliation retries after an update.
+ return { changed: false, managed: true, status }
+ }
+
+ // Why: a legacy-only managed command proves the user opted into WSL CLI
+ // registration; install the current name before removing that owned script.
+ return { changed: true, managed: true, status: await this.install(status) }
+ }
+
+ async install(precomputedStatus?: CliInstallStatus): Promise {
+ // Why: repair passes its fresh probe; re-probing here would double every
+ // WSL round trip on the startup reconciliation path.
+ const status = precomputedStatus ?? (await this.getStatus())
if (!status.supported || !status.commandPath || !status.launcherPath) {
throw new Error(status.detail ?? 'WSL CLI registration is unavailable.')
}
@@ -141,20 +208,36 @@ export class WslCliInstaller {
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`)
}
+ // Why: the launcher and PowerShell bridge are one registration; the
+ // command replacement stays a single atomic rename (never missing for a
+ // concurrent shell) while a bridge copy enables rollback of the pair.
await this.run(
this.distro as string,
[
'set -euo pipefail',
`mkdir -p ${quoteShell(status.pathDirectory as string)}`,
`mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`,
+ buildRegistrationLockPrelude(status.commandPath),
`command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`,
`bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
`legacy_command_path=${quoteShell(
`${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
)}`,
'bridge_tmp="${bridge_path}.tmp.$$"',
- 'cleanup() { rm -f "$command_tmp" "$bridge_tmp"; }',
- 'trap cleanup EXIT',
+ 'bridge_backup="${bridge_tmp}.backup"',
+ 'bridge_had_original=0',
+ 'bridge_touched=0',
+ 'committed=0',
+ 'rollback() {',
+ ' result=$?',
+ ' set +e',
+ ' if [ "$committed" -ne 1 ]; then',
+ ` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; fi`,
+ ' fi',
+ ' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"',
+ ' exit "$result"',
+ '}',
+ 'trap rollback EXIT',
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(
getBridgePathFromCommandPath(status.commandPath),
@@ -173,11 +256,15 @@ export class WslCliInstaller {
getBridgePathFromCommandPath(status.commandPath),
BRIDGE_MANAGED_MARKER
),
- // Why: the command was renamed to avoid GNOME Orca; remove only the
- // old Orca-managed WSL wrapper so unmanaged `orca` commands survive.
- `if [ -f "$legacy_command_path" ] && grep -Fq ${quoteShell(MANAGED_MARKER)} "$legacy_command_path"; then rm -f "$legacy_command_path"; fi`,
+ `if [ -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} ]; then cp -p ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} "$bridge_backup"; bridge_had_original=1; fi`,
`mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
+ 'bridge_touched=1',
`mv -f "$command_tmp" ${quoteShell(status.commandPath)}`,
+ 'committed=1',
+ 'rm -f "$bridge_backup"',
+ // Why: the command was renamed to avoid GNOME Orca; remove only the
+ // old Orca-managed WSL wrapper after the replacement has committed.
+ buildManagedLegacyRemoveCommand('"$legacy_command_path"'),
'trap - EXIT'
].join('\n')
)
@@ -189,14 +276,26 @@ export class WslCliInstaller {
if (!status.supported || !status.commandPath) {
return status
}
+ const legacyCommandPath = `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
if (status.state === 'not_installed') {
+ // Why: a managed legacy `orca` left behind would later be re-adopted by
+ // startup reconciliation as opt-in proof, silently undoing this removal.
+ await this.run(
+ this.distro as string,
+ ['set -euo pipefail', buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))].join(
+ '\n'
+ )
+ )
return status
}
if (status.state === 'conflict') {
throw new Error(`Refusing to remove non-Orca command at ${status.commandPath}.`)
}
- await this.run(this.distro as string, buildSafeRemoveCommand(status.commandPath))
+ await this.run(
+ this.distro as string,
+ buildSafeRemoveCommand(status.commandPath, legacyCommandPath)
+ )
return this.getStatus()
}
@@ -411,5 +510,6 @@ export const _internals = {
buildEncodedWslBashCommand,
buildWslBridgeScript,
buildWslLauncher,
- getBridgePathFromCommandPath
+ getBridgePathFromCommandPath,
+ parseManagedLauncherTarget
}
diff --git a/src/main/cli/wsl-cli-registration-operation.test.ts b/src/main/cli/wsl-cli-registration-operation.test.ts
new file mode 100644
index 00000000000..0240690ffb8
--- /dev/null
+++ b/src/main/cli/wsl-cli-registration-operation.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it, vi } from 'vitest'
+import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation'
+
+describe('runSerializedWslCliRegistrationOperation', () => {
+ it('serializes operations for distro names with different casing', async () => {
+ let releaseFirst!: () => void
+ const events: string[] = []
+ const first = runSerializedWslCliRegistrationOperation('Ubuntu', async () => {
+ events.push('first-start')
+ await new Promise((resolve) => {
+ releaseFirst = resolve
+ })
+ events.push('first-end')
+ })
+
+ await vi.waitFor(() => expect(events).toEqual(['first-start']))
+ const second = runSerializedWslCliRegistrationOperation(' ubuntu ', async () => {
+ events.push('second')
+ })
+ await Promise.resolve()
+ expect(events).toEqual(['first-start'])
+
+ releaseFirst()
+ await Promise.all([first, second])
+ expect(events).toEqual(['first-start', 'first-end', 'second'])
+ })
+
+ it('allows different distros to progress independently', async () => {
+ let releaseUbuntu!: () => void
+ const events: string[] = []
+ const ubuntu = runSerializedWslCliRegistrationOperation('Ubuntu', async () => {
+ events.push('ubuntu-start')
+ await new Promise((resolve) => {
+ releaseUbuntu = resolve
+ })
+ })
+ const debian = runSerializedWslCliRegistrationOperation('Debian', async () => {
+ events.push('debian')
+ })
+
+ await debian
+ expect(events).toEqual(['ubuntu-start', 'debian'])
+ releaseUbuntu()
+ await ubuntu
+ })
+
+ it('releases the queue after an operation fails', async () => {
+ await expect(
+ runSerializedWslCliRegistrationOperation('Ubuntu', async () => {
+ throw new Error('interop failed')
+ })
+ ).rejects.toThrow('interop failed')
+
+ await expect(
+ runSerializedWslCliRegistrationOperation('ubuntu', async () => 'recovered')
+ ).resolves.toBe('recovered')
+ })
+})
diff --git a/src/main/cli/wsl-cli-registration-operation.ts b/src/main/cli/wsl-cli-registration-operation.ts
new file mode 100644
index 00000000000..2ed8404774c
--- /dev/null
+++ b/src/main/cli/wsl-cli-registration-operation.ts
@@ -0,0 +1,23 @@
+import { runKeyedSerializedOperation } from './keyed-promise-queue'
+
+const operationQueues = new Map>()
+
+/**
+ * Canonical key for a WSL distro name. The operation queue and the
+ * registration registry must agree on this to serialize against each other.
+ */
+export function normalizeWslDistroKey(distro: string): string {
+ return distro.trim().toLowerCase()
+}
+
+/**
+ * Serializes registration reads and mutations for one WSL distro.
+ */
+export function runSerializedWslCliRegistrationOperation(
+ distro: string,
+ operation: () => Promise
+): Promise {
+ // Why: startup repair continues in the background and can otherwise undo a
+ // concurrent Settings install/remove or overwrite its ownership metadata.
+ return runKeyedSerializedOperation(operationQueues, normalizeWslDistroKey(distro), operation)
+}
diff --git a/src/main/cli/wsl-cli-registration-reconciliation.test.ts b/src/main/cli/wsl-cli-registration-reconciliation.test.ts
new file mode 100644
index 00000000000..48b75dd207d
--- /dev/null
+++ b/src/main/cli/wsl-cli-registration-reconciliation.test.ts
@@ -0,0 +1,229 @@
+import { describe, expect, it, vi } from 'vitest'
+import { reconcileManagedWslCliRegistrations } from './wsl-cli-registration-reconciliation'
+import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation'
+
+describe('reconcileManagedWslCliRegistrations', () => {
+ it('repairs known and newly discovered registrations, then records ownership', async () => {
+ const registry = {
+ getCandidates: vi.fn(async () => ['Ubuntu', 'Debian']),
+ recordObservations: vi.fn(async () => undefined)
+ }
+ const repairUbuntu = vi.fn(async () => ({
+ changed: true,
+ managed: true,
+ status: { state: 'installed' as const }
+ }))
+ const repairDebian = vi.fn(async () => ({
+ changed: false,
+ managed: false,
+ status: { state: 'not_installed' as const }
+ }))
+
+ const results = await reconcileManagedWslCliRegistrations({
+ platform: 'win32',
+ isPackaged: true,
+ userDataPath: '/user-data',
+ listDistros: async () => ['Ubuntu', 'Debian', 'Fedora'],
+ registry,
+ createInstaller: (distro) => {
+ if (distro === 'Ubuntu') {
+ return { repairManagedRegistration: repairUbuntu }
+ }
+ return { repairManagedRegistration: repairDebian }
+ }
+ })
+
+ expect(registry.getCandidates).toHaveBeenCalledWith(['Ubuntu', 'Debian', 'Fedora'], {
+ currentTarget: null,
+ appVersion: ''
+ })
+ expect(registry.recordObservations).toHaveBeenCalledTimes(2)
+ expect(registry.recordObservations).toHaveBeenCalledWith([
+ { distro: 'Ubuntu', inspected: true, managed: true }
+ ])
+ expect(registry.recordObservations).toHaveBeenCalledWith([
+ { distro: 'Debian', inspected: true, managed: false, reconciled: null }
+ ])
+ expect(results).toEqual([
+ { distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true },
+ { distro: 'Debian', outcome: 'unchanged', state: 'not_installed', managed: false }
+ ])
+ })
+
+ it('passes the host launcher target through and records reconciliations against it', async () => {
+ const registry = {
+ getCandidates: vi.fn(async () => ['Ubuntu']),
+ recordObservations: vi.fn(async () => undefined)
+ }
+
+ await reconcileManagedWslCliRegistrations({
+ platform: 'win32',
+ isPackaged: true,
+ userDataPath: '/user-data',
+ appVersion: '1.4.138',
+ listDistros: async () => ['Ubuntu'],
+ getHostLauncherTarget: async () => 'C:\\Orca\\resources\\bin\\orca.exe',
+ registry,
+ createInstaller: () => ({
+ repairManagedRegistration: async () => ({
+ changed: true,
+ managed: true,
+ status: { state: 'installed' as const }
+ })
+ })
+ })
+
+ expect(registry.getCandidates).toHaveBeenCalledWith(['Ubuntu'], {
+ currentTarget: 'C:\\Orca\\resources\\bin\\orca.exe',
+ appVersion: '1.4.138'
+ })
+ expect(registry.recordObservations).toHaveBeenCalledWith([
+ {
+ distro: 'Ubuntu',
+ inspected: true,
+ managed: true,
+ reconciled: { target: 'C:\\Orca\\resources\\bin\\orca.exe', appVersion: '1.4.138' }
+ }
+ ])
+ })
+
+ it('records unsupported distros without changing ownership and skips failed ones', async () => {
+ const registry = {
+ getCandidates: vi.fn(async () => ['Broken Distro', 'No Interop']),
+ recordObservations: vi.fn(async () => undefined)
+ }
+
+ const results = await reconcileManagedWslCliRegistrations({
+ platform: 'win32',
+ isPackaged: true,
+ userDataPath: '/user-data',
+ listDistros: async () => ['Broken Distro', 'No Interop'],
+ registry,
+ createInstaller: (distro) => ({
+ repairManagedRegistration: async () => {
+ if (distro === 'Broken Distro') {
+ throw new Error('WSL interop failed')
+ }
+ return {
+ changed: false,
+ managed: false,
+ status: { state: 'unsupported' as const }
+ }
+ }
+ })
+ })
+
+ expect(results).toEqual([
+ { distro: 'Broken Distro', outcome: 'failed', error: 'WSL interop failed' },
+ { distro: 'No Interop', outcome: 'unchanged', state: 'unsupported', managed: false }
+ ])
+ expect(registry.recordObservations).toHaveBeenCalledTimes(1)
+ // Why: unsupported gets a TTL-stamped inspection (managed: null) so an
+ // interop-off distro is not re-probed and VM-booted on every startup.
+ expect(registry.recordObservations).toHaveBeenCalledWith([
+ { distro: 'No Interop', inspected: true, managed: null, reconciled: null }
+ ])
+ })
+
+ it('keeps a successful repair result when observation recording fails', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ try {
+ const results = await reconcileManagedWslCliRegistrations({
+ platform: 'win32',
+ isPackaged: true,
+ userDataPath: '/user-data',
+ listDistros: async () => ['Ubuntu'],
+ registry: {
+ getCandidates: async () => ['Ubuntu'],
+ recordObservations: async () => {
+ throw new Error('ENOSPC')
+ }
+ },
+ createInstaller: () => ({
+ repairManagedRegistration: async () => ({
+ changed: true,
+ managed: true,
+ status: { state: 'installed' as const }
+ })
+ })
+ })
+
+ expect(results).toEqual([
+ { distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true }
+ ])
+ expect(warn).toHaveBeenCalledOnce()
+ } finally {
+ warn.mockRestore()
+ }
+ })
+
+ it.each([
+ { platform: 'darwin' as const, isPackaged: true },
+ { platform: 'linux' as const, isPackaged: true },
+ { platform: 'win32' as const, isPackaged: false }
+ ])('does not inspect local, SSH, or development hosts for $platform', async (host) => {
+ const listDistros = vi.fn(async () => ['Ubuntu'])
+
+ await expect(
+ reconcileManagedWslCliRegistrations({
+ ...host,
+ userDataPath: '/user-data',
+ listDistros
+ })
+ ).resolves.toEqual([])
+ expect(listDistros).not.toHaveBeenCalled()
+ })
+
+ it('lets a Settings removal win over a late startup repair for the same distro', async () => {
+ const events: string[] = []
+ let repairStarted!: () => void
+ let finishRepair!: () => void
+ const started = new Promise((resolve) => {
+ repairStarted = resolve
+ })
+ const registry = {
+ getCandidates: vi.fn(async () => ['Ubuntu']),
+ recordObservations: vi.fn(async () => {
+ events.push('repair-observed')
+ })
+ }
+ const reconciliation = reconcileManagedWslCliRegistrations({
+ platform: 'win32',
+ isPackaged: true,
+ userDataPath: '/user-data',
+ listDistros: async () => ['Ubuntu'],
+ registry,
+ createInstaller: () => ({
+ repairManagedRegistration: async () => {
+ events.push('repair-started')
+ repairStarted()
+ await new Promise((resolve) => {
+ finishRepair = resolve
+ })
+ events.push('repair-finished')
+ return {
+ changed: true,
+ managed: true,
+ status: { state: 'installed' as const }
+ }
+ }
+ })
+ })
+ await started
+
+ const removal = runSerializedWslCliRegistrationOperation('ubuntu', async () => {
+ events.push('settings-remove')
+ })
+ await Promise.resolve()
+ expect(events).toEqual(['repair-started'])
+
+ finishRepair()
+ await Promise.all([reconciliation, removal])
+ expect(events).toEqual([
+ 'repair-started',
+ 'repair-finished',
+ 'repair-observed',
+ 'settings-remove'
+ ])
+ })
+})
diff --git a/src/main/cli/wsl-cli-registration-reconciliation.ts b/src/main/cli/wsl-cli-registration-reconciliation.ts
new file mode 100644
index 00000000000..b4c4933bcd1
--- /dev/null
+++ b/src/main/cli/wsl-cli-registration-reconciliation.ts
@@ -0,0 +1,175 @@
+import type { CliInstallState, CliInstallStatus } from '../../shared/cli-install-types'
+import { listWslDistrosAsync } from '../wsl'
+import { CliInstaller } from './cli-installer'
+import {
+ getWslCliRegistrationCandidates,
+ recordWslCliRegistrationObservations,
+ type WslCliRegistrationObservation
+} from './wsl-cli-registration-registry'
+import { WslCliInstaller } from './wsl-cli-installer'
+import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation'
+
+// Why: candidate distros can each boot a stopped WSL VM; a small cap staggers
+// those boots instead of spiking RAM/CPU for every distro at once at startup.
+const MAX_CONCURRENT_DISTRO_REPAIRS = 2
+
+type ManagedWslCliInstaller = {
+ repairManagedRegistration: () => Promise<{
+ changed: boolean
+ managed: boolean
+ status: { state: CliInstallState }
+ }>
+}
+
+type WslCliRegistrationCandidateContext = {
+ currentTarget?: string | null
+ appVersion?: string | null
+}
+
+type WslCliRegistrationRegistry = {
+ getCandidates: (
+ availableDistros: string[],
+ context?: WslCliRegistrationCandidateContext
+ ) => Promise
+ recordObservations: (observations: WslCliRegistrationObservation[]) => Promise
+}
+
+type WslCliRegistrationReconciliationOptions = {
+ platform?: NodeJS.Platform
+ isPackaged: boolean
+ userDataPath: string
+ appVersion?: string
+ listDistros?: () => Promise
+ createInstaller?: (distro: string) => ManagedWslCliInstaller
+ getHostLauncherTarget?: () => Promise
+ registry?: WslCliRegistrationRegistry
+}
+
+export type WslCliRegistrationReconciliationResult =
+ | {
+ distro: string
+ outcome: 'repaired' | 'unchanged'
+ state: CliInstallState
+ managed: boolean
+ }
+ | {
+ distro: string
+ outcome: 'failed'
+ error: string
+ }
+
+export async function reconcileManagedWslCliRegistrations(
+ options: WslCliRegistrationReconciliationOptions
+): Promise {
+ const platform = options.platform ?? process.platform
+ if (platform !== 'win32' || !options.isPackaged) {
+ return []
+ }
+
+ const registry =
+ options.registry ??
+ ({
+ getCandidates: (availableDistros, context) =>
+ getWslCliRegistrationCandidates(options.userDataPath, availableDistros, context ?? {}),
+ recordObservations: (observations) =>
+ recordWslCliRegistrationObservations(options.userDataPath, observations)
+ } satisfies WslCliRegistrationRegistry)
+
+ let createInstaller = options.createInstaller
+ let getHostLauncherTarget = options.getHostLauncherTarget
+ if (!createInstaller) {
+ const hostInstaller = new CliInstaller()
+ let hostStatus: Promise | null = null
+ // Why: every distro must target this app install; share one Windows PATH /
+ // launcher probe instead of spawning a PowerShell probe per distro. A
+ // rejected probe is evicted so one transient failure cannot poison the run.
+ const getHostStatus = (): Promise =>
+ (hostStatus ??= hostInstaller.getStatus().catch((error) => {
+ hostStatus = null
+ throw error
+ }))
+ createInstaller = (distro: string) =>
+ new WslCliInstaller({
+ distro,
+ hostInstaller: { getStatus: getHostStatus }
+ })
+ getHostLauncherTarget ??= () => getHostStatus().then((status) => status.launcherPath)
+ }
+
+ const availableDistros = await (options.listDistros ?? listWslDistrosAsync)()
+ const currentTarget = getHostLauncherTarget
+ ? await getHostLauncherTarget().catch(() => null)
+ : null
+ const appVersion = options.appVersion ?? ''
+ const distros = await registry.getCandidates(availableDistros, { currentTarget, appVersion })
+ if (distros.length === 0) {
+ return []
+ }
+
+ const reconcileDistro = async (
+ distro: string
+ ): Promise => {
+ let repair: Awaited>
+ try {
+ repair = await createInstaller(distro).repairManagedRegistration()
+ } catch (error) {
+ return {
+ distro,
+ outcome: 'failed',
+ error: error instanceof Error ? error.message : String(error)
+ }
+ }
+ const result: WslCliRegistrationReconciliationResult = {
+ distro,
+ outcome: repair.changed ? 'repaired' : 'unchanged',
+ state: repair.status.state,
+ managed: repair.managed
+ }
+ const observation: WslCliRegistrationObservation =
+ repair.status.state === 'unsupported'
+ ? // Why: managed-ness is unknowable without interop; stamp the
+ // inspection (negative TTL) without changing registration ownership.
+ { distro, inspected: true, managed: null, reconciled: null }
+ : repair.managed
+ ? {
+ distro,
+ inspected: true,
+ managed: true,
+ ...(currentTarget ? { reconciled: { target: currentTarget, appVersion } } : {})
+ }
+ : { distro, inspected: true, managed: false, reconciled: null }
+ try {
+ // Why: ownership metadata must commit before a concurrent Settings
+ // operation can mutate this distro, or stale startup state can win.
+ await registry.recordObservations([observation])
+ } catch (error) {
+ // Why: the repair already succeeded on disk; an advisory bookkeeping
+ // failure must not reclassify it, mirroring the Settings IPC contract.
+ console.warn(
+ `[wsl-cli] Failed to record ${distro} registration observation:`,
+ error instanceof Error ? error.message : String(error)
+ )
+ }
+ return result
+ }
+
+ const results: WslCliRegistrationReconciliationResult[] = Array.from({
+ length: distros.length
+ })
+ let nextIndex = 0
+ await Promise.all(
+ Array.from({ length: Math.min(MAX_CONCURRENT_DISTRO_REPAIRS, distros.length) }, async () => {
+ for (;;) {
+ const index = nextIndex++
+ if (index >= distros.length) {
+ return
+ }
+ const distro = distros[index]
+ results[index] = await runSerializedWslCliRegistrationOperation(distro, () =>
+ reconcileDistro(distro)
+ )
+ }
+ })
+ )
+ return results
+}
diff --git a/src/main/cli/wsl-cli-registration-registry.test.ts b/src/main/cli/wsl-cli-registration-registry.test.ts
new file mode 100644
index 00000000000..95bf554a104
--- /dev/null
+++ b/src/main/cli/wsl-cli-registration-registry.test.ts
@@ -0,0 +1,233 @@
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+ getWslCliRegistrationCandidates,
+ recordWslCliRegistrationObservations,
+ recordWslCliRegistrationRemoved
+} from './wsl-cli-registration-registry'
+
+describe('WSL CLI registration registry', () => {
+ let userDataPath: string
+
+ beforeEach(async () => {
+ userDataPath = await mkdtemp(join(tmpdir(), 'orca-wsl-cli-registry-'))
+ })
+
+ afterEach(async () => {
+ await rm(userDataPath, { recursive: true, force: true })
+ })
+
+ it('discovers each distro once while continuing to reconcile managed registrations', async () => {
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'])
+ ).resolves.toEqual(['Ubuntu', 'Debian'])
+
+ await recordWslCliRegistrationObservations(userDataPath, [
+ { distro: 'Ubuntu', inspected: true, managed: false },
+ { distro: 'Debian', inspected: true, managed: true }
+ ])
+
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['ubuntu', 'Debian', 'Fedora'])
+ ).resolves.toEqual(['Debian', 'Fedora'])
+ const state = JSON.parse(
+ await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8')
+ ) as Record
+ expect(state).toMatchObject({
+ schemaVersion: 2,
+ registeredDistros: ['Debian'],
+ inspectionTimes: {
+ ubuntu: expect.any(Number),
+ debian: expect.any(Number)
+ }
+ })
+ })
+
+ it('skips a registered distro already reconciled by this build against this launcher', async () => {
+ const reconciled = { target: 'C:\\Orca\\resources\\bin\\orca.exe', appVersion: '1.4.138' }
+ await recordWslCliRegistrationObservations(userDataPath, [
+ { distro: 'Ubuntu', inspected: true, managed: true, reconciled }
+ ])
+
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
+ currentTarget: reconciled.target,
+ appVersion: reconciled.appVersion
+ })
+ ).resolves.toEqual([])
+ // A launcher move or app update re-probes the registered distro.
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
+ currentTarget: 'D:\\Elsewhere\\orca.exe',
+ appVersion: reconciled.appVersion
+ })
+ ).resolves.toEqual(['Ubuntu'])
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
+ currentTarget: reconciled.target,
+ appVersion: '1.4.139'
+ })
+ ).resolves.toEqual(['Ubuntu'])
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
+ currentTarget: reconciled.target,
+ appVersion: reconciled.appVersion,
+ now: Date.now() + 365 * 24 * 60 * 60 * 1_000
+ })
+ ).resolves.toEqual([])
+ })
+
+ it('records unsupported inspections without changing registration ownership', async () => {
+ await recordWslCliRegistrationObservations(userDataPath, [
+ { distro: 'Ubuntu', inspected: true, managed: true }
+ ])
+
+ await recordWslCliRegistrationObservations(
+ userDataPath,
+ [{ distro: 'Ubuntu', inspected: true, managed: null, reconciled: null }],
+ { now: 1_000 }
+ )
+ const state = JSON.parse(
+ await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8')
+ ) as { registeredDistros: string[] }
+ expect(state.registeredDistros).toEqual(['Ubuntu'])
+
+ await recordWslCliRegistrationObservations(
+ userDataPath,
+ [{ distro: 'Fedora', inspected: true, managed: null }],
+ { now: 1_000 }
+ )
+ // Unregistered unsupported distros gain the negative-inspection TTL.
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['Fedora'], {
+ now: 2_000,
+ negativeInspectionTtlMs: 10_000
+ })
+ ).resolves.toEqual([])
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['Fedora'], {
+ now: 12_000,
+ negativeInspectionTtlMs: 10_000
+ })
+ ).resolves.toEqual(['Fedora'])
+ })
+
+ it('serializes concurrent registry updates without losing a distro', async () => {
+ const updates = Promise.all([
+ recordWslCliRegistrationObservations(userDataPath, [
+ { distro: 'Ubuntu', inspected: true, managed: true }
+ ]),
+ recordWslCliRegistrationObservations(userDataPath, [
+ { distro: 'Debian', inspected: true, managed: true }
+ ])
+ ])
+
+ // Reads join the write queue, so startup cannot observe a half-updated registry.
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'])
+ ).resolves.toEqual(['Ubuntu', 'Debian'])
+ await updates
+ const state = JSON.parse(
+ await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8')
+ ) as { registeredDistros: string[] }
+ expect(state.registeredDistros).toEqual(['Ubuntu', 'Debian'])
+ })
+
+ it('rediscovers available distros when the registry is corrupt', async () => {
+ await mkdir(userDataPath, { recursive: true })
+ await writeFile(join(userDataPath, 'wsl-cli-registrations.json'), '{broken', 'utf8')
+
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'])
+ ).resolves.toEqual(['Ubuntu', 'Debian'])
+ })
+
+ it('stops reconciling a registration removed through Settings', async () => {
+ await recordWslCliRegistrationObservations(userDataPath, [
+ { distro: 'Ubuntu', inspected: true, managed: true }
+ ])
+ await recordWslCliRegistrationRemoved(userDataPath, 'ubuntu')
+
+ await expect(getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'])).resolves.toEqual([])
+ })
+
+ it('periodically re-inspects a negative entry so restored distros are discovered', async () => {
+ await recordWslCliRegistrationObservations(
+ userDataPath,
+ [{ distro: 'Ubuntu', inspected: true, managed: false }],
+ { now: 1_000 }
+ )
+
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
+ now: 1_001,
+ negativeInspectionTtlMs: 10_000
+ })
+ ).resolves.toEqual([])
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
+ now: 11_001,
+ negativeInspectionTtlMs: 10_000
+ })
+ ).resolves.toEqual(['Ubuntu'])
+ })
+
+ it('rediscovers negative entries after the system clock moves backward', async () => {
+ await recordWslCliRegistrationObservations(
+ userDataPath,
+ [{ distro: 'Ubuntu', inspected: true, managed: false }],
+ { now: 100_000 }
+ )
+
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
+ now: 1_000,
+ negativeInspectionTtlMs: 10_000
+ })
+ ).resolves.toEqual(['Ubuntu'])
+ })
+
+ it('safely rediscovers schema-v1 negative entries with no inspection time', async () => {
+ await writeFile(
+ join(userDataPath, 'wsl-cli-registrations.json'),
+ JSON.stringify({
+ schemaVersion: 1,
+ registeredDistros: ['Debian'],
+ inspectedDistros: ['Ubuntu', 'Debian']
+ }),
+ 'utf8'
+ )
+
+ await expect(
+ getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'], {
+ now: 1,
+ negativeInspectionTtlMs: 1_000_000
+ })
+ ).resolves.toEqual(['Ubuntu', 'Debian'])
+ })
+
+ it('caps inspection bookkeeping while always keeping registered distros', async () => {
+ const observations = Array.from({ length: 70 }, (_, index) => ({
+ distro: `Distro${index}`,
+ inspected: true,
+ managed: false as const
+ }))
+ for (const [index, observation] of observations.entries()) {
+ await recordWslCliRegistrationObservations(userDataPath, [observation], { now: index })
+ }
+ await recordWslCliRegistrationObservations(
+ userDataPath,
+ [{ distro: 'Managed Oldest', inspected: true, managed: true }],
+ { now: 0 }
+ )
+
+ const state = JSON.parse(
+ await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8')
+ ) as { registeredDistros: string[]; inspectionTimes: Record }
+ expect(state.registeredDistros).toEqual(['Managed Oldest'])
+ expect(Object.keys(state.inspectionTimes).length).toBeLessThanOrEqual(65)
+ expect(state.inspectionTimes['managed oldest']).toBe(0)
+ })
+})
diff --git a/src/main/cli/wsl-cli-registration-registry.ts b/src/main/cli/wsl-cli-registration-registry.ts
new file mode 100644
index 00000000000..d1a84a42f52
--- /dev/null
+++ b/src/main/cli/wsl-cli-registration-registry.ts
@@ -0,0 +1,279 @@
+import { mkdir, readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { writeFileAtomically } from '../codex-accounts/fs-utils'
+import { getKeyedSerializedQueueTail, runKeyedSerializedOperation } from './keyed-promise-queue'
+import { normalizeWslDistroKey } from './wsl-cli-registration-operation'
+
+const REGISTRY_FILE_NAME = 'wsl-cli-registrations.json'
+const REGISTRY_SCHEMA_VERSION = 2
+const DEFAULT_NEGATIVE_INSPECTION_TTL_MS = 7 * 24 * 60 * 60 * 1_000
+// Why: the registry is advisory; cap per-distro bookkeeping so hosts that
+// cycle many uniquely named distros cannot grow the file without bound.
+const MAX_INSPECTION_ENTRIES = 64
+
+type WslCliRegistrationReconciliation = {
+ target: string
+ appVersion: string
+}
+
+type WslCliRegistrationRegistryState = {
+ schemaVersion: 2
+ registeredDistros: string[]
+ inspectionTimes: Record
+ reconciliations: Record
+}
+
+type WslCliRegistrationRegistryTiming = {
+ now?: number
+ negativeInspectionTtlMs?: number
+ // Why: a registered distro already reconciled against this exact launcher
+ // by this exact app build has nothing to repair; skipping it avoids booting
+ // its VM on every startup while still re-probing after each app update.
+ currentTarget?: string | null
+ appVersion?: string | null
+}
+
+export type WslCliRegistrationObservation = {
+ distro: string
+ inspected: boolean
+ // Why: null records an inspection without changing registration ownership —
+ // used for 'unsupported' probes where managed-ness could not be determined.
+ managed: boolean | null
+ reconciled?: WslCliRegistrationReconciliation | null
+}
+
+const writeQueues = new Map>()
+
+function emptyState(): WslCliRegistrationRegistryState {
+ return {
+ schemaVersion: REGISTRY_SCHEMA_VERSION,
+ registeredDistros: [],
+ inspectionTimes: {},
+ reconciliations: {}
+ }
+}
+
+function uniqueDistros(value: unknown): string[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+ const seen = new Set()
+ const distros: string[] = []
+ for (const entry of value) {
+ if (typeof entry !== 'string' || !entry.trim()) {
+ continue
+ }
+ const distro = entry.trim()
+ const key = normalizeWslDistroKey(distro)
+ if (!seen.has(key)) {
+ seen.add(key)
+ distros.push(distro)
+ }
+ }
+ return distros
+}
+
+function parseReconciliations(value: unknown): Record {
+ if (!value || typeof value !== 'object') {
+ return {}
+ }
+ return Object.fromEntries(
+ Object.entries(value).filter(
+ (entry): entry is [string, WslCliRegistrationReconciliation] =>
+ !!entry[1] &&
+ typeof entry[1] === 'object' &&
+ typeof (entry[1] as WslCliRegistrationReconciliation).target === 'string' &&
+ typeof (entry[1] as WslCliRegistrationReconciliation).appVersion === 'string'
+ )
+ )
+}
+
+function parseState(content: string): WslCliRegistrationRegistryState {
+ try {
+ const parsed = JSON.parse(content) as Record
+ if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== REGISTRY_SCHEMA_VERSION) {
+ return emptyState()
+ }
+ const inspectionTimes =
+ parsed.inspectionTimes && typeof parsed.inspectionTimes === 'object'
+ ? Object.fromEntries(
+ Object.entries(parsed.inspectionTimes).filter(
+ (entry): entry is [string, number] =>
+ typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] >= 0
+ )
+ )
+ : {}
+ return {
+ schemaVersion: REGISTRY_SCHEMA_VERSION,
+ registeredDistros: uniqueDistros(parsed.registeredDistros),
+ inspectionTimes,
+ reconciliations: parseReconciliations(parsed.reconciliations)
+ }
+ } catch {
+ // Why: a corrupt advisory registry must trigger safe rediscovery rather
+ // than preventing managed registrations from receiving future updates.
+ return emptyState()
+ }
+}
+
+function isMissingError(error: unknown): boolean {
+ return (error as NodeJS.ErrnoException)?.code === 'ENOENT'
+}
+
+function getRegistryPath(userDataPath: string): string {
+ return join(userDataPath, REGISTRY_FILE_NAME)
+}
+
+async function readState(userDataPath: string): Promise {
+ try {
+ return parseState(await readFile(getRegistryPath(userDataPath), 'utf8'))
+ } catch (error) {
+ if (isMissingError(error)) {
+ return emptyState()
+ }
+ throw error
+ }
+}
+
+function upsertDistro(distros: string[], distro: string): string[] {
+ const key = normalizeWslDistroKey(distro)
+ const existingIndex = distros.findIndex((entry) => normalizeWslDistroKey(entry) === key)
+ if (existingIndex < 0) {
+ return [...distros, distro.trim()]
+ }
+ return distros.map((entry, index) => (index === existingIndex ? distro.trim() : entry))
+}
+
+function removeDistro(distros: string[], distro: string): string[] {
+ const key = normalizeWslDistroKey(distro)
+ return distros.filter((entry) => normalizeWslDistroKey(entry) !== key)
+}
+
+async function writeState(
+ userDataPath: string,
+ state: WslCliRegistrationRegistryState
+): Promise {
+ await mkdir(userDataPath, { recursive: true })
+ // Why: userData writes on Windows can hit Chromium's Protected-DACL EPERM;
+ // writeFileAtomically carries the ACL-repair retry a plain rename lacks.
+ writeFileAtomically(getRegistryPath(userDataPath), `${JSON.stringify(state, null, 2)}\n`)
+}
+
+function capInspectionEntries(
+ state: WslCliRegistrationRegistryState
+): WslCliRegistrationRegistryState {
+ const registered = new Set(state.registeredDistros.map(normalizeWslDistroKey))
+ const entries = Object.entries(state.inspectionTimes)
+ const inspectionTimes =
+ entries.length <= MAX_INSPECTION_ENTRIES
+ ? state.inspectionTimes
+ : Object.fromEntries(
+ entries
+ .sort((a, b) => b[1] - a[1])
+ .filter((entry, index) => index < MAX_INSPECTION_ENTRIES || registered.has(entry[0]))
+ )
+ const reconciliations = Object.fromEntries(
+ Object.entries(state.reconciliations).filter(([key]) => registered.has(key))
+ )
+ return { ...state, inspectionTimes, reconciliations }
+}
+
+function updateState(
+ userDataPath: string,
+ update: (state: WslCliRegistrationRegistryState) => WslCliRegistrationRegistryState
+): Promise {
+ return runKeyedSerializedOperation(writeQueues, getRegistryPath(userDataPath), async () => {
+ await writeState(userDataPath, capInspectionEntries(update(await readState(userDataPath))))
+ })
+}
+
+export async function getWslCliRegistrationCandidates(
+ userDataPath: string,
+ availableDistros: string[],
+ timing: WslCliRegistrationRegistryTiming = {}
+): Promise {
+ // Why: the stored queue tail never rejects, so a failed concurrent write
+ // cannot abort candidate discovery; reads still see fully applied updates.
+ await getKeyedSerializedQueueTail(writeQueues, getRegistryPath(userDataPath))
+ const state = await readState(userDataPath)
+ const registered = new Set(state.registeredDistros.map(normalizeWslDistroKey))
+ const now = timing.now ?? Date.now()
+ const negativeInspectionTtlMs =
+ timing.negativeInspectionTtlMs ?? DEFAULT_NEGATIVE_INSPECTION_TTL_MS
+ return uniqueDistros(availableDistros).filter((distro) => {
+ const key = normalizeWslDistroKey(distro)
+ if (registered.has(key)) {
+ const reconciliation = state.reconciliations[key]
+ return !(
+ reconciliation &&
+ timing.currentTarget &&
+ reconciliation.target === timing.currentTarget &&
+ reconciliation.appVersion === (timing.appVersion ?? '')
+ )
+ }
+ const inspectedAt = state.inspectionTimes[key]
+ return (
+ inspectedAt === undefined || inspectedAt > now || now - inspectedAt >= negativeInspectionTtlMs
+ )
+ })
+}
+
+export function recordWslCliRegistrationObservations(
+ userDataPath: string,
+ observations: WslCliRegistrationObservation[],
+ timing: Pick = {}
+): Promise {
+ const effective = observations.filter(
+ (observation) => observation.inspected && observation.distro.trim()
+ )
+ if (effective.length === 0) {
+ return Promise.resolve()
+ }
+ return updateState(userDataPath, (state) => {
+ let registeredDistros = state.registeredDistros
+ let inspectionTimes = state.inspectionTimes
+ let reconciliations = state.reconciliations
+ const now = timing.now ?? Date.now()
+ for (const observation of effective) {
+ const key = normalizeWslDistroKey(observation.distro)
+ inspectionTimes = { ...inspectionTimes, [key]: now }
+ if (observation.managed === true) {
+ registeredDistros = upsertDistro(registeredDistros, observation.distro)
+ } else if (observation.managed === false) {
+ registeredDistros = removeDistro(registeredDistros, observation.distro)
+ }
+ if (observation.reconciled !== undefined) {
+ if (observation.reconciled === null) {
+ const { [key]: _removed, ...rest } = reconciliations
+ reconciliations = rest
+ } else {
+ reconciliations = { ...reconciliations, [key]: observation.reconciled }
+ }
+ }
+ }
+ return {
+ schemaVersion: REGISTRY_SCHEMA_VERSION,
+ registeredDistros,
+ inspectionTimes,
+ reconciliations
+ }
+ })
+}
+
+export function recordWslCliRegistrationInstalled(
+ userDataPath: string,
+ distro: string
+): Promise {
+ return recordWslCliRegistrationObservations(userDataPath, [
+ { distro, inspected: true, managed: true }
+ ])
+}
+
+export function recordWslCliRegistrationRemoved(
+ userDataPath: string,
+ distro: string
+): Promise {
+ return recordWslCliRegistrationObservations(userDataPath, [
+ { distro, inspected: true, managed: false, reconciled: null }
+ ])
+}
diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts
index 70e1b16fb25..136a2a682c9 100644
--- a/src/main/cli/wsl-cli-scripts.ts
+++ b/src/main/cli/wsl-cli-scripts.ts
@@ -71,13 +71,35 @@ export function buildSafeReplaceGuard(path: string, managedMarker: string): stri
].join('\n')
}
-export function buildSafeRemoveCommand(commandPath: string): string {
+export function buildRegistrationLockPrelude(commandPath: string): string {
+ const lockDir = getPosixDirname(getBridgePathFromCommandPath(commandPath))
+ // Why: the per-distro queue only serializes one Orca process; flock covers
+ // a second install (e.g. stable + nightly) mutating the same distro files.
+ return [
+ `if command -v flock >/dev/null 2>&1 && mkdir -p ${quoteShell(lockDir)} 2>/dev/null; then`,
+ ` exec 9>${quoteShell(`${lockDir}/.orca-wsl-cli.lock`)}`,
+ ' flock -x -w 30 9',
+ 'fi'
+ ].join('\n')
+}
+
+export function buildManagedLegacyRemoveCommand(quotedLegacyCommandPath: string): string {
+ // Why: remove only the Orca-managed pre-rename wrapper; user-owned `orca`
+ // commands and symlinks must survive.
+ return `if [ ! -L ${quotedLegacyCommandPath} ] && [ -f ${quotedLegacyCommandPath} ] && grep -Fq ${quoteShell(MANAGED_MARKER)} ${quotedLegacyCommandPath}; then rm -f ${quotedLegacyCommandPath}; fi`
+}
+
+export function buildSafeRemoveCommand(commandPath: string, legacyCommandPath?: string): string {
const bridgePath = getBridgePathFromCommandPath(commandPath)
return [
'set -euo pipefail',
+ buildRegistrationLockPrelude(commandPath),
buildSafeReplaceGuard(commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER),
- `rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}`
+ `rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}`,
+ // Why: leaving a managed legacy `orca` behind lets startup reconciliation
+ // re-adopt it as opt-in proof and silently undo this removal.
+ ...(legacyCommandPath ? [buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))] : [])
].join('\n')
}
diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts
index afee3f9dc53..501229444dc 100644
--- a/src/main/codex-accounts/runtime-home-service.test.ts
+++ b/src/main/codex-accounts/runtime-home-service.test.ts
@@ -97,6 +97,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings
floatingTerminalTriggerLocation: 'floating-button',
diffDefaultView: 'inline',
combinedDiffFileTreeVisibleByDefault: false,
+ prBotAuthorOverrides: [],
notifications: {
enabled: true,
agentTaskComplete: true,
diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts
index 8c4779573fc..9b185e4c357 100644
--- a/src/main/codex-accounts/service.test.ts
+++ b/src/main/codex-accounts/service.test.ts
@@ -102,6 +102,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings
floatingTerminalTriggerLocation: 'floating-button',
diffDefaultView: 'inline',
combinedDiffFileTreeVisibleByDefault: false,
+ prBotAuthorOverrides: [],
notifications: {
enabled: true,
agentTaskComplete: true,
diff --git a/src/main/index.ts b/src/main/index.ts
index 27e9aee2aa4..b76964ae872 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -85,6 +85,7 @@ import {
import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect'
import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect'
import { startFirstWindowStartupServices } from './startup/first-window-startup-services'
+import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier'
import { getDevInstanceIdentity } from './startup/dev-instance-identity'
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
import {
@@ -133,6 +134,7 @@ import { StarNagService } from './star-nag/service'
import { agentHookServer } from './agent-hooks/server'
import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager'
import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename'
+import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output'
import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename'
import { moveWorktree } from './git/worktree'
import { getRepoIdFromWorktreeId } from '../shared/worktree-id'
@@ -186,6 +188,7 @@ import { applyElectronProxySettings } from './network/proxy-settings'
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
import { CliInstaller } from './cli/cli-installer'
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
+import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation'
import { selfHealRuntimeEnvironmentFocus } from './runtime-environment-focus-self-heal'
let mainWindow: BrowserWindow | null = null
@@ -224,6 +227,8 @@ let keybindings: KeybindingService | null = null
const expectedRendererReload = createWebContentsTimedFlag()
const recoveryReloadInFlight = createWebContentsTimedFlag()
let firstWindowStartupServicesReady: Promise = Promise.resolve()
+let managedWslCliReconciliationReady: Promise = Promise.resolve()
+let managedWslCliStartupBarrierReady: Promise = Promise.resolve()
// Why: GPU child crashes clustered right after launch indicate a broken driver;
// track them so Orca can move this build onto software rendering.
const gpuLaunchTimeMs = Date.now()
@@ -320,6 +325,7 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true
},
setDisplayName: (worktreeId, displayName) => {
+ rememberBranchRenameFailureOutput(worktreeId, null)
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
currentStore.updateFolderWorkspace(scope.folderWorkspaceId, {
@@ -351,7 +357,10 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
moveWorktree
})
: undefined,
- setRenameError: (worktreeId, error) => {
+ setRenameError: (worktreeId, error, failureOutput) => {
+ // Refresh the local-only full-output capture before the dedupe below:
+ // a repeat of the same error string still comes from a fresh run.
+ rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput)
// Skip the write + renderer push when nothing changes — benign skips
// clear the error on every settled worktree, most of which never had one.
const scope = parseWorkspaceKey(worktreeId)
@@ -605,7 +614,9 @@ if (hasSingleInstanceLock) {
}
ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
- await firstWindowStartupServicesReady
+ // Why: window rendering and local RPC startup stay independent, but restored
+ // WSL terminals get a bounded chance to receive launcher repairs first.
+ await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])
})
ipcMain.handle(
@@ -1017,6 +1028,7 @@ function openMainWindow(): BrowserWindow {
stateStartedAt,
launchToken,
providerSession,
+ promptInteractionKey,
isReplay
}) => {
if (mainWindow?.isDestroyed()) {
@@ -1036,6 +1048,7 @@ function openMainWindow(): BrowserWindow {
receivedAt,
stateStartedAt,
...(providerSession ? { providerSession } : {}),
+ ...(promptInteractionKey ? { promptInteractionKey } : {}),
...(orchestration ? { orchestration } : {})
})
recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state)
@@ -1595,6 +1608,34 @@ app.whenReady().then(async () => {
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
app.setName(devInstanceIdentity.name)
+ // Why: managed WSL launchers live outside the Windows app bundle, so keep
+ // their launcher and bridge contract synchronized across app updates.
+ managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({
+ isPackaged: app.isPackaged,
+ userDataPath: getCanonicalUserDataPath(),
+ appVersion: app.getVersion()
+ })
+ .then((results) => {
+ for (const result of results) {
+ if (result.outcome === 'failed') {
+ console.warn(
+ `[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}`
+ )
+ } else if (result.outcome === 'repaired') {
+ console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`)
+ }
+ }
+ })
+ .catch((error) => {
+ console.warn(
+ '[wsl-cli] Managed registration reconciliation discovery failed:',
+ error instanceof Error ? error.message : String(error)
+ )
+ })
+ managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier(
+ managedWslCliReconciliationReady
+ )
+
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
logStartupMilestone('store-loaded')
@@ -2049,6 +2090,9 @@ app.whenReady().then(async () => {
}
if (serveOptions) {
+ // Why: headless serve has no renderer startup barrier, so settle managed
+ // WSL command reconciliation before exposing its runtime transport.
+ await managedWslCliReconciliationReady
await startServeAgentHookServer()
registerHeadlessPtyRuntime(
runtime,
diff --git a/src/main/ipc/cli.test.ts b/src/main/ipc/cli.test.ts
new file mode 100644
index 00000000000..eeb98539751
--- /dev/null
+++ b/src/main/ipc/cli.test.ts
@@ -0,0 +1,148 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ handlers,
+ ipcHandleMock,
+ wslInstallerMock,
+ recordInstalledMock,
+ recordRemovedMock,
+ getDefaultWslDistroMock
+} = vi.hoisted(() => ({
+ handlers: new Map unknown>(),
+ ipcHandleMock: vi.fn(),
+ wslInstallerMock: vi.fn(),
+ recordInstalledMock: vi.fn(),
+ recordRemovedMock: vi.fn(),
+ getDefaultWslDistroMock: vi.fn()
+}))
+
+vi.mock('electron', () => ({ ipcMain: { handle: ipcHandleMock } }))
+vi.mock('../cli/cli-installer', () => ({ CliInstaller: vi.fn() }))
+vi.mock('../cli/wsl-cli-installer', () => ({ WslCliInstaller: wslInstallerMock }))
+vi.mock('../cli/wsl-cli-registration-registry', () => ({
+ recordWslCliRegistrationInstalled: recordInstalledMock,
+ recordWslCliRegistrationRemoved: recordRemovedMock
+}))
+vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/canonical-user-data' }))
+vi.mock('../startup/hydrate-shell-path', () => ({
+ hydrateShellPath: vi.fn(async () => ({ ok: false })),
+ mergePathSegments: vi.fn()
+}))
+vi.mock('../wsl', () => ({ getDefaultWslDistro: getDefaultWslDistroMock }))
+
+import { registerCliHandlers } from './cli'
+
+type WslHandler = (event: unknown, args?: { distro?: string | null }) => Promise<{ state: string }>
+
+function getWslHandler(channel: string): WslHandler {
+ const handler = handlers.get(channel)
+ if (!handler) {
+ throw new Error(`Missing IPC handler: ${channel}`)
+ }
+ return handler as WslHandler
+}
+
+describe('WSL CLI registration IPC', () => {
+ beforeEach(() => {
+ handlers.clear()
+ ipcHandleMock.mockReset()
+ ipcHandleMock.mockImplementation(
+ (channel: string, handler: (...args: unknown[]) => unknown) => {
+ handlers.set(channel, handler)
+ }
+ )
+ wslInstallerMock.mockReset()
+ recordInstalledMock.mockReset().mockResolvedValue(undefined)
+ recordRemovedMock.mockReset().mockResolvedValue(undefined)
+ getDefaultWslDistroMock.mockReset().mockReturnValue('Ubuntu')
+ registerCliHandlers()
+ })
+
+ it('records a successful explicit-distro installation', async () => {
+ const install = vi.fn(async () => ({ state: 'installed' }))
+ wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
+ return { install }
+ })
+
+ await expect(getWslHandler('cli:installWsl')({}, { distro: ' Debian ' })).resolves.toEqual({
+ state: 'installed'
+ })
+ expect(wslInstallerMock).toHaveBeenCalledWith({ distro: 'Debian' })
+ expect(recordInstalledMock).toHaveBeenCalledWith('/canonical-user-data', 'Debian')
+ })
+
+ it('records removal from the resolved default distro', async () => {
+ const remove = vi.fn(async () => ({ state: 'not_installed' }))
+ wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
+ return { remove }
+ })
+
+ await expect(getWslHandler('cli:removeWsl')({})).resolves.toEqual({ state: 'not_installed' })
+ expect(wslInstallerMock).toHaveBeenCalledWith({ distro: 'Ubuntu' })
+ expect(recordRemovedMock).toHaveBeenCalledWith('/canonical-user-data', 'Ubuntu')
+ })
+
+ it('does not claim ownership when installation is not confirmed', async () => {
+ const install = vi.fn(async () => ({ state: 'unsupported' }))
+ wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
+ return { install }
+ })
+
+ await expect(getWslHandler('cli:installWsl')({})).resolves.toEqual({ state: 'unsupported' })
+ expect(recordInstalledMock).not.toHaveBeenCalled()
+ })
+
+ it('keeps successful installation successful when advisory registry persistence fails', async () => {
+ const install = vi.fn(async () => ({ state: 'installed' }))
+ wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
+ return { install }
+ })
+ recordInstalledMock.mockRejectedValueOnce(new Error('ENOSPC'))
+
+ await expect(getWslHandler('cli:installWsl')({})).resolves.toEqual({ state: 'installed' })
+ })
+
+ it('keeps successful removal successful when advisory registry persistence fails', async () => {
+ const remove = vi.fn(async () => ({ state: 'not_installed' }))
+ wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
+ return { remove }
+ })
+ recordRemovedMock.mockRejectedValueOnce(new Error('EACCES'))
+
+ await expect(getWslHandler('cli:removeWsl')({})).resolves.toEqual({ state: 'not_installed' })
+ })
+
+ it('serializes a concurrent removal after installation and ownership persistence', async () => {
+ let finishInstall!: () => void
+ const install = vi.fn(
+ () =>
+ new Promise<{ state: 'installed' }>((resolve) => {
+ finishInstall = () => resolve({ state: 'installed' })
+ })
+ )
+ const remove = vi.fn(async () => ({ state: 'not_installed' as const }))
+ wslInstallerMock
+ .mockImplementationOnce(function MockInstallWslCliInstaller() {
+ return { install }
+ })
+ .mockImplementationOnce(function MockRemoveWslCliInstaller() {
+ return { remove }
+ })
+
+ const installation = getWslHandler('cli:installWsl')({}, { distro: 'Ubuntu' })
+ await vi.waitFor(() => expect(install).toHaveBeenCalledOnce())
+ const removal = getWslHandler('cli:removeWsl')({}, { distro: 'ubuntu' })
+ await Promise.resolve()
+ expect(remove).not.toHaveBeenCalled()
+
+ finishInstall()
+ await expect(installation).resolves.toEqual({ state: 'installed' })
+ await expect(removal).resolves.toEqual({ state: 'not_installed' })
+ expect(recordInstalledMock.mock.invocationCallOrder[0]).toBeLessThan(
+ remove.mock.invocationCallOrder[0]
+ )
+ expect(remove.mock.invocationCallOrder[0]).toBeLessThan(
+ recordRemovedMock.mock.invocationCallOrder[0]
+ )
+ })
+})
diff --git a/src/main/ipc/cli.ts b/src/main/ipc/cli.ts
index b0b4f4c6311..659c69d9c02 100644
--- a/src/main/ipc/cli.ts
+++ b/src/main/ipc/cli.ts
@@ -1,13 +1,49 @@
import { ipcMain } from 'electron'
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { CliInstaller } from '../cli/cli-installer'
+import {
+ recordWslCliRegistrationInstalled,
+ recordWslCliRegistrationRemoved
+} from '../cli/wsl-cli-registration-registry'
import { WslCliInstaller } from '../cli/wsl-cli-installer'
+import { runSerializedWslCliRegistrationOperation } from '../cli/wsl-cli-registration-operation'
+import { getCanonicalUserDataPath } from '../persistence'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
+import { getDefaultWslDistro } from '../wsl'
function normalizeWslCliDistro(args?: { distro?: string | null }): string | undefined {
return args?.distro?.trim() || undefined
}
+function resolveWslCliDistro(args?: { distro?: string | null }): string | null {
+ return normalizeWslCliDistro(args) ?? getDefaultWslDistro()
+}
+
+function runWslCliRegistrationOperation(
+ distro: string | null,
+ operation: () => Promise
+): Promise {
+ return distro ? runSerializedWslCliRegistrationOperation(distro, operation) : operation()
+}
+
+async function persistWslCliRegistration(
+ operation: () => Promise,
+ action: 'install' | 'remove'
+): Promise {
+ try {
+ await operation()
+ } catch (error) {
+ // Why: the WSL file operation already succeeded; advisory metadata must
+ // not turn that success into a false Settings failure. The atomic write
+ // left the prior registry intact, and repair is disk-authoritative, so a
+ // stale entry self-corrects on the next startup probe.
+ console.warn(
+ `[wsl-cli] Failed to persist ${action} registration metadata:`,
+ error instanceof Error ? error.message : String(error)
+ )
+ }
+}
+
async function hydrateLocalShellPathForCli(force = false): Promise {
if (process.platform === 'win32') {
return
@@ -39,21 +75,44 @@ export function registerCliHandlers(): void {
ipcMain.handle(
'cli:getWslInstallStatus',
async (_event, args?: { distro?: string | null }): Promise => {
- return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).getStatus()
+ // Why: status is a read-only probe; queuing it behind a long-running
+ // repair/install would hang the Settings spinner for its duration, and
+ // Settings re-polls, so a rare transient read self-corrects.
+ return new WslCliInstaller({ distro: resolveWslCliDistro(args) }).getStatus()
}
)
ipcMain.handle(
'cli:installWsl',
async (_event, args?: { distro?: string | null }): Promise => {
- return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).install()
+ const distro = resolveWslCliDistro(args)
+ return runWslCliRegistrationOperation(distro, async () => {
+ const status = await new WslCliInstaller({ distro }).install()
+ if (distro && status.state === 'installed') {
+ await persistWslCliRegistration(
+ () => recordWslCliRegistrationInstalled(getCanonicalUserDataPath(), distro),
+ 'install'
+ )
+ }
+ return status
+ })
}
)
ipcMain.handle(
'cli:removeWsl',
async (_event, args?: { distro?: string | null }): Promise => {
- return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).remove()
+ const distro = resolveWslCliDistro(args)
+ return runWslCliRegistrationOperation(distro, async () => {
+ const status = await new WslCliInstaller({ distro }).remove()
+ if (distro && status.state === 'not_installed') {
+ await persistWslCliRegistration(
+ () => recordWslCliRegistrationRemoved(getCanonicalUserDataPath(), distro),
+ 'remove'
+ )
+ }
+ return status
+ })
}
)
}
diff --git a/src/main/ipc/filesystem-import-ssh-directory.ts b/src/main/ipc/filesystem-import-ssh-directory.ts
new file mode 100644
index 00000000000..bb8abd027f4
--- /dev/null
+++ b/src/main/ipc/filesystem-import-ssh-directory.ts
@@ -0,0 +1,102 @@
+import { lstat, readdir, realpath } from 'node:fs/promises'
+import { isAbsolute, join, relative, sep } from 'node:path'
+import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
+import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform'
+
+export async function captureLocalUploadRoot(
+ sourcePath: string,
+ sourceStat: Awaited>
+): Promise {
+ const rootRealPath = await realpath(sourcePath)
+ const rootRealStat = await lstat(rootRealPath)
+ if (
+ statIdentityPartChanged(sourceStat.ino, rootRealStat.ino) ||
+ statIdentityPartChanged(sourceStat.dev, rootRealStat.dev) ||
+ !rootRealStat.isDirectory()
+ ) {
+ throw new Error(`Upload source changed while being inspected: ${sourcePath}`)
+ }
+ return rootRealPath
+}
+
+export async function preScanSshImportDirectory(
+ dirPath: string,
+ remotePathFlavor: RemotePathFlavor
+): Promise {
+ const entries = await readdir(dirPath, { withFileTypes: true })
+ for (const entry of entries) {
+ assertSafeRemotePathSegment(entry.name, remotePathFlavor)
+ if (entry.isSymbolicLink()) {
+ return true
+ }
+ if (entry.isDirectory()) {
+ const childPath = join(dirPath, entry.name)
+ if (await preScanSshImportDirectory(childPath, remotePathFlavor)) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+export async function uploadSshImportDirectory(
+ provider: IFilesystemProvider,
+ uploadSession: FileUploadSession,
+ localDir: string,
+ remoteDir: string,
+ rootRealPath: string,
+ remotePathFlavor: RemotePathFlavor
+): Promise {
+ await assertLocalUploadPathInsideRoot(rootRealPath, localDir)
+ const entries = await readdir(localDir, { withFileTypes: true })
+ for (const entry of entries) {
+ assertSafeRemotePathSegment(entry.name, remotePathFlavor)
+ const localPath = join(localDir, entry.name)
+ const remotePath = `${remoteDir}/${entry.name}`
+ await assertLocalUploadPathInsideRoot(rootRealPath, localPath)
+ const statResult = await lstat(localPath)
+
+ // Why: skip symlinks and special files even after the up-front pre-scan;
+ // this closes the TOCTOU gap if one is created during upload.
+ if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) {
+ continue
+ }
+
+ if (statResult.isDirectory()) {
+ await provider.createDirNoClobber(remotePath)
+ await uploadSshImportDirectory(
+ provider,
+ uploadSession,
+ localPath,
+ remotePath,
+ rootRealPath,
+ remotePathFlavor
+ )
+ continue
+ }
+ await uploadSession.uploadFile(localPath, remotePath, { exclusive: true })
+ }
+}
+
+function statIdentityPartChanged(
+ left: number | bigint | undefined,
+ right: number | bigint | undefined
+): boolean {
+ const leftKnown = left !== undefined && left !== 0 && left !== 0n
+ const rightKnown = right !== undefined && right !== 0 && right !== 0n
+ return leftKnown && rightKnown && left !== right
+}
+
+async function assertLocalUploadPathInsideRoot(
+ rootRealPath: string,
+ candidatePath: string
+): Promise {
+ const candidateRealPath = await realpath(candidatePath)
+ const relativeToRoot = relative(rootRealPath, candidateRealPath)
+ if (
+ relativeToRoot !== '' &&
+ (relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot))
+ ) {
+ throw new Error(`Upload source escapes selected directory: ${candidatePath}`)
+ }
+}
diff --git a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts
new file mode 100644
index 00000000000..ec01175ad35
--- /dev/null
+++ b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts
@@ -0,0 +1,274 @@
+import path from 'node:path'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
+
+const { getConnMgrMock, lstatMock, providerMock, readdirMock, realpathMock } = vi.hoisted(() => ({
+ getConnMgrMock: vi.fn(),
+ lstatMock: vi.fn(),
+ providerMock: vi.fn(),
+ readdirMock: vi.fn(),
+ realpathMock: vi.fn()
+}))
+
+vi.mock('node:fs/promises', () => ({
+ lstat: lstatMock,
+ readdir: readdirMock,
+ realpath: realpathMock
+}))
+vi.mock('./filesystem-auth', () => ({
+ authorizeExternalPath: vi.fn(),
+ isENOENT: (error: NodeJS.ErrnoException) => error.code === 'ENOENT'
+}))
+vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock }))
+vi.mock('../providers/ssh-filesystem-dispatch', () => ({
+ requireSshFilesystemProvider: providerMock
+}))
+
+import { importExternalPathsSsh } from './filesystem-import-ssh'
+
+function createProvider(uploadSession: FileUploadSession): IFilesystemProvider {
+ const missing = Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ return {
+ stat: vi.fn().mockRejectedValue(missing),
+ createDirNoClobber: vi.fn().mockResolvedValue(undefined),
+ deletePath: vi.fn().mockResolvedValue(undefined),
+ openFileUploadSession: vi.fn().mockResolvedValue(uploadSession)
+ } as unknown as IFilesystemProvider
+}
+
+describe('SSH import remote path safety', () => {
+ const connectionId = 'ssh-windows'
+ const destDir = 'C:/Users/me/project/.orca/drops'
+ let provider: IFilesystemProvider
+ let uploadSession: FileUploadSession
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ getConnMgrMock.mockReturnValue({
+ getConnection: () => ({ getState: () => ({ status: 'connected' }) })
+ })
+ uploadSession = {
+ uploadFile: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn()
+ }
+ provider = createProvider(uploadSession)
+ providerMock.mockReturnValue(provider)
+ readdirMock.mockResolvedValue([])
+ realpathMock.mockImplementation(async (value: string) => value)
+ })
+
+ it.runIf(process.platform !== 'win32')(
+ 'rejects a POSIX filename containing Windows traversal before remote stat',
+ async () => {
+ const sourcePath = '/tmp/..\\..\\.ssh\\orca_drop'
+
+ const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
+
+ expect(results[0]).toMatchObject({
+ status: 'failed',
+ reason: expect.stringContaining('Unsafe')
+ })
+ expect(lstatMock).not.toHaveBeenCalled()
+ expect(provider.stat).not.toHaveBeenCalled()
+ expect(provider.createDirNoClobber).not.toHaveBeenCalled()
+ expect(uploadSession.uploadFile).not.toHaveBeenCalled()
+ expect(provider.deletePath).not.toHaveBeenCalled()
+ }
+ )
+
+ it.each(['report.txt:orca', 'NUL', 'trailing.', 'question?.txt'])(
+ 'rejects Windows-special top-level name %j before remote stat',
+ async (name) => {
+ const { results } = await importExternalPathsSsh([`/tmp/${name}`], destDir, connectionId)
+
+ expect(results[0]).toMatchObject({
+ status: 'failed',
+ reason: expect.stringContaining('Unsafe')
+ })
+ expect(lstatMock).not.toHaveBeenCalled()
+ expect(provider.stat).not.toHaveBeenCalled()
+ expect(uploadSession.uploadFile).not.toHaveBeenCalled()
+ }
+ )
+
+ it('rejects a nested Windows traversal name before creating the import root', async () => {
+ const sourcePath = path.resolve('/tmp/assets')
+ lstatMock.mockResolvedValue({
+ isFile: () => false,
+ isDirectory: () => true,
+ isSymbolicLink: () => false
+ })
+ readdirMock.mockResolvedValue([
+ {
+ name: '..\\..\\.ssh\\orca_drop',
+ isFile: () => true,
+ isDirectory: () => false,
+ isSymbolicLink: () => false
+ }
+ ])
+
+ const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
+
+ expect(results[0]).toMatchObject({
+ status: 'failed',
+ reason: expect.stringContaining('Unsafe')
+ })
+ expect(provider.stat).not.toHaveBeenCalled()
+ expect(provider.createDirNoClobber).not.toHaveBeenCalled()
+ expect(uploadSession.uploadFile).not.toHaveBeenCalled()
+ expect(provider.deletePath).not.toHaveBeenCalled()
+ })
+
+ it('keeps exclusive creation for safe file names', async () => {
+ const sourcePath = path.resolve('/tmp/report.txt')
+ lstatMock.mockResolvedValue({
+ isFile: () => true,
+ isDirectory: () => false,
+ isSymbolicLink: () => false
+ })
+
+ const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
+
+ expect(results[0]).toMatchObject({ status: 'imported', destPath: `${destDir}/report.txt` })
+ expect(uploadSession.uploadFile).toHaveBeenCalledWith(sourcePath, `${destDir}/report.txt`, {
+ exclusive: true
+ })
+ })
+
+ it.runIf(process.platform !== 'win32')(
+ 'preserves backslashes in valid POSIX names for POSIX remotes',
+ async () => {
+ const sourcePath = path.resolve('/tmp/notes\\2026.txt')
+ const posixDestDir = '/home/me/project'
+ lstatMock.mockResolvedValue({
+ isFile: () => true,
+ isDirectory: () => false,
+ isSymbolicLink: () => false
+ })
+
+ const { results } = await importExternalPathsSsh([sourcePath], posixDestDir, connectionId)
+
+ expect(results[0]).toMatchObject({
+ status: 'imported',
+ destPath: `${posixDestDir}/notes\\2026.txt`
+ })
+ expect(uploadSession.uploadFile).toHaveBeenCalledWith(
+ sourcePath,
+ `${posixDestDir}/notes\\2026.txt`,
+ { exclusive: true }
+ )
+ }
+ )
+
+ it('streams an N-file directory through guarded per-file uploads', async () => {
+ const sourcePath = path.resolve('/tmp/assets')
+ const names = ['one.txt', 'two.txt', 'three.txt']
+ const entries = names.map((name) => ({
+ name,
+ isFile: () => true,
+ isDirectory: () => false,
+ isSymbolicLink: () => false
+ }))
+ lstatMock.mockImplementation(async (value: string) =>
+ value === sourcePath
+ ? { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
+ : { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
+ )
+ readdirMock.mockResolvedValue(entries)
+
+ const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
+
+ expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' })
+ expect(uploadSession.uploadFile).toHaveBeenCalledTimes(3)
+ for (const name of names) {
+ expect(uploadSession.uploadFile).toHaveBeenCalledWith(
+ path.join(sourcePath, name),
+ `${destDir}/assets/${name}`,
+ { exclusive: true }
+ )
+ }
+ })
+
+ it('skips special entries without failing the directory import', async () => {
+ const sourcePath = path.resolve('/tmp/assets')
+ const regularPath = path.join(sourcePath, 'report.txt')
+ const socketPath = path.join(sourcePath, 'server.sock')
+ const entries = [
+ {
+ name: 'report.txt',
+ isFile: () => true,
+ isDirectory: () => false,
+ isSymbolicLink: () => false
+ },
+ {
+ name: 'server.sock',
+ isFile: () => false,
+ isDirectory: () => false,
+ isSymbolicLink: () => false
+ }
+ ]
+ lstatMock.mockImplementation(async (value: string) => {
+ if (value === sourcePath) {
+ return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
+ }
+ if (value === regularPath) {
+ return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
+ }
+ expect(value).toBe(socketPath)
+ return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => false }
+ })
+ readdirMock.mockResolvedValue(entries)
+
+ const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
+
+ expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' })
+ expect(uploadSession.uploadFile).toHaveBeenCalledOnce()
+ expect(uploadSession.uploadFile).toHaveBeenCalledWith(
+ regularPath,
+ `${destDir}/assets/report.txt`,
+ { exclusive: true }
+ )
+ })
+
+ it('rejects a selected root replaced after its realpath is captured', async () => {
+ const sourcePath = path.resolve('/tmp/assets')
+ lstatMock.mockResolvedValue({
+ isFile: () => false,
+ isDirectory: () => true,
+ isSymbolicLink: () => false
+ })
+ realpathMock.mockResolvedValueOnce(sourcePath).mockResolvedValueOnce('/private/replacement')
+
+ const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
+
+ expect(results[0]).toMatchObject({
+ status: 'failed',
+ reason: expect.stringContaining('escapes selected directory')
+ })
+ expect(uploadSession.uploadFile).not.toHaveBeenCalled()
+ expect(provider.deletePath).toHaveBeenCalledWith(`${destDir}/assets`, true)
+ })
+
+ it('rejects a selected root replaced while its realpath is captured', async () => {
+ const sourcePath = path.resolve('/tmp/assets')
+ const directoryStat = (ino: number) => ({
+ dev: 1,
+ ino,
+ isFile: () => false,
+ isDirectory: () => true,
+ isSymbolicLink: () => false
+ })
+ lstatMock.mockResolvedValueOnce(directoryStat(1)).mockResolvedValueOnce(directoryStat(2))
+ realpathMock.mockResolvedValue('/private/replacement')
+
+ const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
+
+ expect(results[0]).toMatchObject({
+ status: 'failed',
+ reason: expect.stringContaining('changed while being inspected')
+ })
+ expect(provider.createDirNoClobber).not.toHaveBeenCalled()
+ expect(uploadSession.uploadFile).not.toHaveBeenCalled()
+ expect(provider.deletePath).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts
index 4b5c4cd1495..afaabd179ba 100644
--- a/src/main/ipc/filesystem-import-ssh.ts
+++ b/src/main/ipc/filesystem-import-ssh.ts
@@ -1,10 +1,17 @@
-import { lstat, readdir, realpath } from 'node:fs/promises'
-import { basename, join, posix, resolve } from 'node:path'
+import { lstat } from 'node:fs/promises'
+import { basename, posix, resolve } from 'node:path'
import { authorizeExternalPath, isENOENT } from './filesystem-auth'
import { getSshConnectionManager } from './ssh'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
import type { ImportItemResult } from './filesystem-mutations'
+import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform'
+import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
+import {
+ captureLocalUploadRoot,
+ preScanSshImportDirectory,
+ uploadSshImportDirectory
+} from './filesystem-import-ssh-directory'
// Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so
// system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows.
@@ -47,6 +54,10 @@ export async function importExternalPathsSsh(
throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.')
}
const uploadSession = await provider.openFileUploadSession()
+ // Why: filename legality follows the remote filesystem, not the client's OS.
+ const remotePathFlavor: RemotePathFlavor = isWindowsAbsolutePathLike(destDir)
+ ? 'windows'
+ : 'posix'
try {
for (const sourcePath of sourcePaths) {
const result = await importOneSourceSsh(
@@ -54,7 +65,8 @@ export async function importExternalPathsSsh(
uploadSession,
sourcePath,
destDir,
- reservedNames
+ reservedNames,
+ remotePathFlavor
)
results.push(result)
if (result.status === 'imported') {
@@ -76,12 +88,24 @@ async function importOneSourceSsh(
uploadSession: FileUploadSession,
sourcePath: string,
destDir: string,
- reservedNames: Set
+ reservedNames: Set,
+ remotePathFlavor: RemotePathFlavor
): Promise {
const resolvedSource = resolve(sourcePath)
authorizeExternalPath(resolvedSource)
+ const originalName = basename(resolvedSource)
+ try {
+ assertSafeRemotePathSegment(originalName, remotePathFlavor)
+ } catch (error) {
+ return {
+ sourcePath,
+ status: 'failed',
+ reason: error instanceof Error ? error.message : String(error)
+ }
+ }
+
let sourceStat: Awaited>
try {
sourceStat = await lstat(resolvedSource)
@@ -114,17 +138,13 @@ async function importOneSourceSsh(
const isDir = sourceStat.isDirectory()
- if (isDir) {
- const hasSymlink = await preScanForSymlinks(resolvedSource)
- if (hasSymlink) {
- return { sourcePath, status: 'skipped', reason: 'symlink' }
- }
- }
-
- const originalName = basename(resolvedSource)
-
let createdDestDir: string | null = null
try {
+ const rootRealPath = isDir ? await captureLocalUploadRoot(resolvedSource, sourceStat) : null
+ if (isDir && (await preScanSshImportDirectory(resolvedSource, remotePathFlavor))) {
+ return { sourcePath, status: 'skipped', reason: 'symlink' }
+ }
+
const finalName = await deconflictName(provider, destDir, originalName, reservedNames)
const destPath = `${destDir}/${finalName}`
const renamed = finalName !== originalName
@@ -132,12 +152,13 @@ async function importOneSourceSsh(
if (isDir) {
await provider.createDirNoClobber(destPath)
createdDestDir = destPath
- await uploadDirectoryViaProvider(
+ await uploadSshImportDirectory(
provider,
uploadSession,
resolvedSource,
destPath,
- await realpath(resolvedSource)
+ rootRealPath!,
+ remotePathFlavor
)
} else {
await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true })
@@ -217,36 +238,6 @@ async function ensureDropStagingDir(provider: IFilesystemProvider, destDir: stri
await provider.createDir(destDir)
}
-async function uploadDirectoryViaProvider(
- provider: IFilesystemProvider,
- uploadSession: FileUploadSession,
- localDir: string,
- remoteDir: string,
- rootRealPath: string
-): Promise {
- await assertLocalUploadPathInsideRoot(rootRealPath, localDir)
- const entries = await readdir(localDir, { withFileTypes: true })
- for (const entry of entries) {
- const localPath = join(localDir, entry.name)
- const remotePath = `${remoteDir}/${entry.name}`
- await assertLocalUploadPathInsideRoot(rootRealPath, localPath)
- const statResult = await lstat(localPath)
-
- // Why: skip symlinks and special files even after the up-front pre-scan;
- // this closes the TOCTOU gap if one is created during upload.
- if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) {
- continue
- }
-
- if (statResult.isDirectory()) {
- await provider.createDirNoClobber(remotePath)
- await uploadDirectoryViaProvider(provider, uploadSession, localPath, remotePath, rootRealPath)
- continue
- }
- await uploadSession.uploadFile(localPath, remotePath, { exclusive: true })
- }
-}
-
async function remotePathExists(
provider: IFilesystemProvider,
remotePath: string
@@ -274,37 +265,3 @@ function isRemoteMissingError(error: unknown): boolean {
)
)
}
-
-async function preScanForSymlinks(dirPath: string): Promise {
- const entries = await readdir(dirPath, { withFileTypes: true })
- for (const entry of entries) {
- if (entry.isSymbolicLink()) {
- return true
- }
- if (entry.isDirectory()) {
- const childPath = join(dirPath, entry.name)
- if (await preScanForSymlinks(childPath)) {
- return true
- }
- }
- }
- return false
-}
-
-async function assertLocalUploadPathInsideRoot(
- rootRealPath: string,
- candidatePath: string
-): Promise {
- const candidateRealPath = await realpath(candidatePath)
- const root = rootRealPath.replace(/[\\/]+$/g, '')
- const candidate = candidateRealPath.replace(/[\\/]+$/g, '')
- const rootComparable = process.platform === 'win32' ? root.toLowerCase() : root
- const candidateComparable = process.platform === 'win32' ? candidate.toLowerCase() : candidate
- if (candidateComparable === rootComparable) {
- return
- }
- const boundary = process.platform === 'win32' ? '\\' : '/'
- if (!candidateComparable.startsWith(`${rootComparable}${boundary}`)) {
- throw new Error(`Upload source escapes selected directory: ${candidatePath}`)
- }
-}
diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts
index d0891ec35a2..a566bf178c6 100644
--- a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts
+++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts
@@ -300,6 +300,48 @@ describe('local filesystem watcher unsubscribe cleanup', () => {
}
})
+ it('aborts an opening local watcher when the last listener unwatches', async () => {
+ vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
+ let resolveSubscribe: (subscription: { unsubscribe: () => void }) => void = () => {}
+ const unsubscribeMock = vi.fn()
+ vi.mocked(subscribeParcelWatcher).mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSubscribe = resolve as typeof resolveSubscribe
+ })
+ )
+ const sender = {
+ isDestroyed: () => false,
+ send: vi.fn(),
+ once: vi.fn(),
+ id: 1
+ }
+
+ const watchPromise = handlers['fs:watchWorktree'](
+ { sender },
+ { worktreePath: '/tmp/repo' }
+ ) as Promise
+ await vi.waitFor(() => {
+ expect(subscribeParcelWatcher).toHaveBeenCalled()
+ })
+
+ const unwatchPromise = handlers['fs:unwatchWorktree'](
+ { sender },
+ { worktreePath: '/tmp/repo' }
+ ) as Promise
+ // Why: unwatch must abort the in-flight install so watchWorktree settles
+ // without waiting for the native subscribe crawl to finish.
+ const watchSettledEarly = await Promise.race([
+ watchPromise.then(() => true),
+ new Promise((resolve) => setTimeout(() => resolve(false), 20))
+ ])
+ resolveSubscribe({ unsubscribe: unsubscribeMock })
+ await Promise.all([watchPromise, unwatchPromise])
+
+ expect(watchSettledEarly).toBe(true)
+ expect(unsubscribeMock).toHaveBeenCalledTimes(1)
+ })
+
it('cancels an opening local watcher for worktree deletion', async () => {
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
let resolveSubscribe: (subscription: { unsubscribe: () => void }) => void = () => {}
@@ -325,9 +367,50 @@ describe('local filesystem watcher unsubscribe cleanup', () => {
expect(subscribeParcelWatcher).toHaveBeenCalled()
})
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo')
+ const closedBeforeNativeSubscribe = await Promise.race([
+ closePromise.then(() => true),
+ new Promise((resolve) => setTimeout(() => resolve(false), 0))
+ ])
resolveSubscribe({ unsubscribe: unsubscribeMock })
await Promise.all([watchPromise, closePromise])
+ expect(closedBeforeNativeSubscribe).toBe(true)
+ expect(unsubscribeMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('cancels an opening local watcher during app shutdown', async () => {
+ vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
+ let resolveSubscribe: (subscription: { unsubscribe: () => void }) => void = () => {}
+ const unsubscribeMock = vi.fn()
+ vi.mocked(subscribeParcelWatcher).mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSubscribe = resolve as typeof resolveSubscribe
+ })
+ )
+ const sender = {
+ isDestroyed: () => false,
+ send: vi.fn(),
+ once: vi.fn(),
+ id: 1
+ }
+
+ const watchPromise = handlers['fs:watchWorktree'](
+ { sender },
+ { worktreePath: '/tmp/repo' }
+ ) as Promise
+ await vi.waitFor(() => {
+ expect(subscribeParcelWatcher).toHaveBeenCalled()
+ })
+ const shutdownPromise = closeAllWatchers()
+ const closedBeforeNativeSubscribe = await Promise.race([
+ shutdownPromise.then(() => true),
+ new Promise((resolve) => setTimeout(() => resolve(false), 0))
+ ])
+ resolveSubscribe({ unsubscribe: unsubscribeMock })
+ await Promise.all([watchPromise, shutdownPromise])
+
+ expect(closedBeforeNativeSubscribe).toBe(true)
expect(unsubscribeMock).toHaveBeenCalledTimes(1)
})
})
diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts
index 203ca2fbfa1..4258c0e675e 100644
--- a/src/main/ipc/filesystem-watcher-wsl.test.ts
+++ b/src/main/ipc/filesystem-watcher-wsl.test.ts
@@ -172,4 +172,28 @@ describe('createWslWatcher', () => {
await expect(promise).rejects.toThrow('write EPIPE')
})
+
+ it('kills the snapshot process when the install abort signal fires during startup', async () => {
+ const child = new FakeChildProcess()
+ spawnMock.mockReturnValueOnce(child)
+ const controller = new AbortController()
+ const promise = createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps(), controller.signal)
+
+ controller.abort()
+
+ await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
+ expect(child.kill).toHaveBeenCalledOnce()
+ })
+
+ it('rejects immediately when the install abort signal is already aborted', async () => {
+ const controller = new AbortController()
+ controller.abort()
+
+ await expect(
+ createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps(), controller.signal)
+ ).rejects.toMatchObject({
+ name: 'AbortError'
+ })
+ expect(spawnMock).not.toHaveBeenCalled()
+ })
})
diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts
index b0d2c8e9d04..54e58415894 100644
--- a/src/main/ipc/filesystem-watcher-wsl.ts
+++ b/src/main/ipc/filesystem-watcher-wsl.ts
@@ -150,8 +150,15 @@ function markOverflowWithoutUncStat(root: WatchedRoot): void {
export async function createWslWatcher(
rootKey: string,
worktreePath: string,
- deps: WslWatcherDeps
+ deps: WslWatcherDeps,
+ signal?: AbortSignal
): Promise {
+ // Why: cancelled local installs pass the same abort controller used for
+ // native Parcel subscribe; honor it before spawning a WSL snapshot process.
+ if (signal?.aborted) {
+ throw new DOMException('WSL watcher subscription aborted', 'AbortError')
+ }
+
const wsl = parseWslUncPath(worktreePath)
if (!wsl) {
throw new Error(`Not a WSL path: ${worktreePath}`)
@@ -257,6 +264,12 @@ export async function createWslWatcher(
throw error instanceof Error ? error : new Error(String(error))
}
+ const onAbort = (): void => {
+ settleInitial(new DOMException('WSL watcher subscription aborted', 'AbortError'))
+ child.kill()
+ }
+ signal?.addEventListener('abort', onAbort, { once: true })
+
const startupTimer = setTimeout(() => {
settleInitial(new Error(`Timed out starting WSL watcher for ${worktreePath}`))
child.kill()
@@ -321,7 +334,12 @@ export async function createWslWatcher(
child.stdin.end(buildSnapshotScript(deps.ignoreDirs))
- await initialSnapshotReady.finally(() => clearTimeout(startupTimer))
+ try {
+ await initialSnapshotReady
+ } finally {
+ clearTimeout(startupTimer)
+ signal?.removeEventListener('abort', onAbort)
+ }
root.subscription = {
unsubscribe: async () => {
diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts
index 224c348b871..983fc9ce985 100644
--- a/src/main/ipc/filesystem-watcher.ts
+++ b/src/main/ipc/filesystem-watcher.ts
@@ -72,6 +72,7 @@ const pendingLocalUnsubscribes = new Set>()
type LocalWatcherInstallToken = {
cancelled: boolean
listeners: Map
+ abortController: AbortController
}
type LocalWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled'
// Why: native watcher creation is async. Concurrent local watch requests for
@@ -96,6 +97,9 @@ function cleanupInFlightLocalInstallsForSender(senderId: number): void {
token.listeners.delete(senderId)
if (token.listeners.size === 0) {
token.cancelled = true
+ // Why: match closeLocalWatcherForWorktreePath / closeAllWatchers — abort
+ // so a pending native/forked subscription stops early, not at completion.
+ token.abortController.abort()
}
}
}
@@ -278,7 +282,11 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void {
// ── Watcher creation ─────────────────────────────────────────────────
-async function createWatcher(rootKey: string, rootPath: string): Promise {
+async function createWatcher(
+ rootKey: string,
+ rootPath: string,
+ signal?: AbortSignal
+): Promise {
const root: WatchedRoot = {
subscription: null!,
listeners: new Map(),
@@ -300,6 +308,11 @@ async function createWatcher(rootKey: string, rootPath: string): Promise {
+ root.batch.overflowed = true
+ scheduleBatchFlush(rootKey, root)
+ }
+
// Why: subscriptions run in a forked watcher process (issue #7547 —
// watcher.node teardown races fail-fast the hosting process). A watcher
// crash there is recovered by resubscribing; onInterruption marks the
@@ -336,11 +349,13 @@ async function createWatcher(rootKey: string, rootPath: string): Promise {
- // The watcher process crashed and this root was resubscribed; events
- // during the gap are lost, so hand the renderer an overflow refresh.
- root.batch.overflowed = true
- scheduleBatchFlush(rootKey, root)
+ {
+ delivery: { maxEventsPerBatch: MAX_BATCHED_WATCHER_EVENTS },
+ // A child restart or bounded-queue overflow loses path precision; both
+ // require the same conservative renderer refresh.
+ onInterruption: markWatcherInterrupted,
+ onOverflow: markWatcherInterrupted,
+ signal
}
)
@@ -466,7 +481,11 @@ async function subscribe(worktreePath: string, sender: WebContents): Promise undefined)
@@ -926,6 +959,7 @@ export async function closeAllWatchers(): Promise {
for (const token of inFlightLocalInstalls.values()) {
token.listeners.clear()
token.cancelled = true
+ token.abortController.abort()
}
for (const [rootKey, root] of watchedRoots) {
diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts
index ea4b0602282..4091a298446 100644
--- a/src/main/ipc/mobile.test.ts
+++ b/src/main/ipc/mobile.test.ts
@@ -6,7 +6,9 @@ const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({
}))
vi.mock('electron', () => ({
- ipcMain: { handle: handleMock }
+ app: { isPackaged: false },
+ ipcMain: { handle: handleMock },
+ shell: { openExternal: vi.fn() }
}))
vi.mock('qrcode', () => ({
@@ -226,4 +228,55 @@ describe('registerMobileHandlers', () => {
})
expect(revokeRuntimeAccess).toHaveBeenCalledWith('runtime-1')
})
+
+ it('inspects and repairs the current packaged Windows websocket port', async () => {
+ const runPowerShell = vi
+ .fn()
+ .mockResolvedValueOnce(
+ JSON.stringify({
+ ruleAllowed: false,
+ privateFirewallEnabled: true,
+ networkCategory: 'Private'
+ })
+ )
+ .mockResolvedValueOnce('{"launched":true,"exitCode":0}')
+ const rpcServer = { getWebSocketEndpoint: () => 'ws://0.0.0.0:6768' }
+ registerMobileHandlers(rpcServer as never, {
+ firewallEnvironment: {
+ platform: 'win32',
+ isPackaged: true,
+ executablePath: 'C:\\Program Files\\Orca\\Orca.exe',
+ runPowerShell
+ }
+ })
+
+ await expect(
+ handlers.get('mobile:getWindowsFirewallStatus')?.(null, { address: '192.168.0.108' })
+ ).resolves.toMatchObject({ supported: true, port: 6768, ruleAllowed: false })
+ await expect(
+ handlers.get('mobile:repairWindowsFirewall')?.({
+ sender: { isDestroyed: () => false, getType: () => 'window' }
+ })
+ ).resolves.toEqual({ ok: true })
+ })
+
+ it('rejects firewall mutation from a non-window renderer', async () => {
+ const runPowerShell = vi.fn()
+ const rpcServer = { getWebSocketEndpoint: () => 'ws://0.0.0.0:6768' }
+ registerMobileHandlers(rpcServer as never, {
+ firewallEnvironment: {
+ platform: 'win32',
+ isPackaged: true,
+ executablePath: 'C:\\Program Files\\Orca\\Orca.exe',
+ runPowerShell
+ }
+ })
+
+ expect(
+ handlers.get('mobile:repairWindowsFirewall')?.({
+ sender: { isDestroyed: () => false, getType: () => 'webview' }
+ })
+ ).toEqual({ ok: false, reason: 'unsupported' })
+ expect(runPowerShell).not.toHaveBeenCalled()
+ })
})
diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts
index 11da72988d4..548835801ce 100644
--- a/src/main/ipc/mobile.ts
+++ b/src/main/ipc/mobile.ts
@@ -1,10 +1,16 @@
-import { ipcMain } from 'electron'
+import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
import { networkInterfaces } from 'node:os'
import QRCode from 'qrcode'
import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants'
import { isTailnetIPv4Address } from '../../shared/tailnet-address'
import type { DeviceEntry } from '../runtime/device-registry'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
+import {
+ getWebSocketPort,
+ inspectWindowsMobileFirewall,
+ repairWindowsMobileFirewall,
+ type WindowsMobileFirewallEnvironment
+} from '../runtime/windows-mobile-firewall'
export type NetworkInterface = {
name: string
@@ -51,7 +57,21 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant {
// device management, and WebSocket readiness status. They depend on the
// OrcaRuntimeRpcServer because it owns the device registry and TLS state.
-export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
+export type MobileHandlerDependencies = {
+ firewallEnvironment?: WindowsMobileFirewallEnvironment
+ openWindowsNetworkSettings?: () => Promise
+}
+
+export function registerMobileHandlers(
+ rpcServer: OrcaRuntimeRpcServer,
+ dependencies: MobileHandlerDependencies = {}
+): void {
+ const firewallEnvironment = dependencies.firewallEnvironment ?? {
+ platform: process.platform,
+ isPackaged: app.isPackaged,
+ executablePath: process.execPath,
+ systemRoot: process.env.SystemRoot
+ }
ipcMain.handle('mobile:listNetworkInterfaces', (): { interfaces: NetworkInterface[] } => ({
interfaces: getNetworkInterfaces()
}))
@@ -189,4 +209,33 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
endpoint: rpcServer.getWebSocketEndpoint()
}
})
+
+ ipcMain.handle('mobile:getWindowsFirewallStatus', (_event, args?: { address?: string }) => {
+ const port = getWebSocketPort(rpcServer.getWebSocketEndpoint())
+ return inspectWindowsMobileFirewall(port, args?.address, firewallEnvironment)
+ })
+
+ ipcMain.handle('mobile:repairWindowsFirewall', (event: IpcMainInvokeEvent) => {
+ if (!isWindowRenderer(event)) {
+ return { ok: false as const, reason: 'unsupported' as const }
+ }
+ // Why: elevated inputs come from the running runtime, never the renderer.
+ const port = getWebSocketPort(rpcServer.getWebSocketEndpoint())
+ return repairWindowsMobileFirewall(port, firewallEnvironment)
+ })
+
+ ipcMain.handle('mobile:openWindowsNetworkSettings', async (event: IpcMainInvokeEvent) => {
+ if (!isWindowRenderer(event) || firewallEnvironment.platform !== 'win32') {
+ return false
+ }
+ const openSettings =
+ dependencies.openWindowsNetworkSettings ??
+ (() => shell.openExternal('ms-settings:network-status'))
+ await openSettings()
+ return true
+ })
+}
+
+function isWindowRenderer(event: IpcMainInvokeEvent): boolean {
+ return !event.sender.isDestroyed() && event.sender.getType() === 'window'
}
diff --git a/src/main/ipc/parcel-watcher-canary-directory.ts b/src/main/ipc/parcel-watcher-canary-directory.ts
new file mode 100644
index 00000000000..28e7ec6518c
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-canary-directory.ts
@@ -0,0 +1,26 @@
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+export function createWatcherCanaryDirectory(): string | null {
+ try {
+ return mkdtempSync(join(tmpdir(), 'orca-watcher-canary-'))
+ } catch (error) {
+ // Why: the canary is diagnostic only; read-only temp storage must not
+ // disable the crash-isolated watcher itself.
+ console.warn('[parcel-watcher-process] canary directory unavailable:', error)
+ return null
+ }
+}
+
+export function removeWatcherCanaryDirectory(canaryDir: string | null): null {
+ if (!canaryDir) {
+ return null
+ }
+ try {
+ rmSync(canaryDir, { recursive: true, force: true })
+ } catch {
+ // Canary cleanup is best-effort; watcher lifecycle must still complete.
+ }
+ return null
+}
diff --git a/src/main/ipc/parcel-watcher-child-launch.ts b/src/main/ipc/parcel-watcher-child-launch.ts
new file mode 100644
index 00000000000..6a72bb98145
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-child-launch.ts
@@ -0,0 +1,57 @@
+import { fork, type ChildProcess } from 'node:child_process'
+import { writeFileSync } from 'node:fs'
+import {
+ createWatcherCanaryDirectory,
+ removeWatcherCanaryDirectory
+} from './parcel-watcher-canary-directory'
+import type { WatcherToHostMessage } from './parcel-watcher-process-protocol'
+
+export type LaunchedWatcherChild = {
+ child: ChildProcess
+ canaryDir: string | null
+}
+
+export function launchWatcherChild(
+ entryPath: string,
+ currentCanaryDir: string | null,
+ onMessage: (child: ChildProcess, message: WatcherToHostMessage) => void,
+ onGone: (child: ChildProcess, code?: number | null, signal?: NodeJS.Signals | null) => void
+): LaunchedWatcherChild | null {
+ const canaryDir = currentCanaryDir ?? createWatcherCanaryDirectory()
+ let child: ChildProcess
+ try {
+ child = fork(entryPath, [], {
+ stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
+ env: {
+ ...process.env,
+ ELECTRON_RUN_AS_NODE: '1',
+ ...(canaryDir ? { ORCA_WATCHER_CANARY_DIR: canaryDir } : {})
+ },
+ ...(process.platform === 'win32' ? { windowsHide: true } : {})
+ })
+ } catch (error) {
+ removeWatcherCanaryDirectory(canaryDir)
+ console.error('[parcel-watcher-process] failed to fork watcher process:', error)
+ return null
+ }
+ const faultHarnessPidFile = process.env.ORCA_WATCHER_CHILD_PID_FILE
+ if (faultHarnessPidFile && child.pid) {
+ try {
+ // Why: exclusive creation lets the harness identify the child without a
+ // leaked test-only environment variable clobbering an existing file.
+ writeFileSync(faultHarnessPidFile, String(child.pid), { flag: 'wx' })
+ } catch {
+ // Fault-injection observability must never affect watcher availability.
+ }
+ }
+ child.stderr?.on('data', (chunk: Buffer) => {
+ console.error('[parcel-watcher-process]', String(chunk).trimEnd())
+ })
+ child.on('message', (message) => onMessage(child, message as WatcherToHostMessage))
+ // Why: Node can close IPC before emitting exit. Recover at disconnect so a
+ // concurrent subscribe cannot replace the child without restoring old roots.
+ child.on('disconnect', () => onGone(child))
+ child.on('error', () => onGone(child))
+ child.on('exit', (code, signal) => onGone(child, code, signal))
+ return { child, canaryDir }
+}
diff --git a/src/main/ipc/parcel-watcher-crash-fuse.ts b/src/main/ipc/parcel-watcher-crash-fuse.ts
new file mode 100644
index 00000000000..69717311ba5
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-crash-fuse.ts
@@ -0,0 +1,24 @@
+const CRASH_WINDOW_MS = 30_000
+const MAX_CRASHES_PER_WINDOW = 3
+
+export class WatcherProcessCrashFuse {
+ private crashTimes: number[] = []
+
+ recordCrash(now = Date.now()): void {
+ this.removeExpired(now)
+ this.crashTimes.push(now)
+ }
+
+ isOpen(now = Date.now()): boolean {
+ this.removeExpired(now)
+ return this.crashTimes.length >= MAX_CRASHES_PER_WINDOW
+ }
+
+ reset(): void {
+ this.crashTimes = []
+ }
+
+ private removeExpired(now: number): void {
+ this.crashTimes = this.crashTimes.filter((time) => now - time < CRASH_WINDOW_MS)
+ }
+}
diff --git a/src/main/ipc/parcel-watcher-event-delivery.test.ts b/src/main/ipc/parcel-watcher-event-delivery.test.ts
new file mode 100644
index 00000000000..66c6232a63f
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-event-delivery.test.ts
@@ -0,0 +1,44 @@
+import { setImmediate } from 'node:timers/promises'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { statMock } = vi.hoisted(() => ({ statMock: vi.fn() }))
+
+vi.mock('node:fs/promises', () => ({ stat: statMock }))
+
+import { prepareWatcherProcessEvents } from './parcel-watcher-event-delivery'
+
+describe('prepareWatcherProcessEvents', () => {
+ beforeEach(() => {
+ statMock.mockReset()
+ })
+
+ it('caps directory metadata stat work across concurrent roots in one child', async () => {
+ let activeStats = 0
+ let maxActiveStats = 0
+ statMock.mockImplementation(async () => {
+ activeStats++
+ maxActiveStats = Math.max(maxActiveStats, activeStats)
+ await setImmediate()
+ activeStats--
+ return { isDirectory: () => false }
+ })
+ const batches = Array.from({ length: 8 }, (_, rootIndex) =>
+ Array.from({ length: 16 }, (_, eventIndex) => ({
+ type: 'update' as const,
+ path: `/repo-${rootIndex}/file-${eventIndex}.ts`
+ }))
+ )
+
+ await Promise.all(
+ batches.map((events) =>
+ prepareWatcherProcessEvents(events, {
+ includeDirectoryMetadata: true,
+ maxEventsPerBatch: 200
+ })
+ )
+ )
+
+ expect(statMock).toHaveBeenCalledTimes(128)
+ expect(maxActiveStats).toBe(8)
+ })
+})
diff --git a/src/main/ipc/parcel-watcher-event-delivery.ts b/src/main/ipc/parcel-watcher-event-delivery.ts
new file mode 100644
index 00000000000..54e8448801c
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-event-delivery.ts
@@ -0,0 +1,160 @@
+import { stat } from 'node:fs/promises'
+import type { Event as ParcelWatcherEvent } from '@parcel/watcher'
+import { MAX_BATCHED_WATCHER_EVENTS } from './filesystem-watcher-event-batch'
+import type {
+ WatcherProcessDeliveryOptions,
+ WatcherProcessEvent
+} from './parcel-watcher-process-protocol'
+
+const DIRECTORY_STAT_CONCURRENCY = 8
+let activeDirectoryStats = 0
+const directoryStatWaiters: (() => void)[] = []
+
+export type WatcherProcessEventDeliveryQueue = {
+ enqueue(events: readonly ParcelWatcherEvent[]): void
+ close(): void
+}
+
+async function acquireDirectoryStatSlot(): Promise {
+ if (activeDirectoryStats < DIRECTORY_STAT_CONCURRENCY) {
+ activeDirectoryStats++
+ return
+ }
+ await new Promise((resolve) => directoryStatWaiters.push(resolve))
+}
+
+function releaseDirectoryStatSlot(): void {
+ const next = directoryStatWaiters.shift()
+ if (next) {
+ // Transfer the existing slot directly so a newly arriving task cannot
+ // overtake this waiter and temporarily exceed the global budget.
+ next()
+ return
+ }
+ activeDirectoryStats--
+}
+
+async function statWatcherEventPath(eventPath: string): Promise {
+ await acquireDirectoryStatSlot()
+ try {
+ return (await stat(eventPath)).isDirectory()
+ } finally {
+ releaseDirectoryStatSlot()
+ }
+}
+
+async function mapWithConcurrency(
+ items: readonly T[],
+ limit: number,
+ mapper: (item: T) => Promise
+): Promise {
+ const results = Array.from({ length: items.length })
+ let cursor = 0
+ const lane = async (): Promise => {
+ while (cursor < items.length) {
+ const index = cursor++
+ results[index] = await mapper(items[index])
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane))
+ return results
+}
+
+async function mapWatcherEvent(
+ event: ParcelWatcherEvent,
+ includeDirectoryMetadata: boolean
+): Promise {
+ if (!includeDirectoryMetadata || event.type === 'delete') {
+ return { type: event.type, path: event.path }
+ }
+ let isDirectory = false
+ try {
+ isDirectory = await statWatcherEventPath(event.path)
+ } catch {
+ // Why: a path can vanish between the native event and metadata lookup.
+ // Treat unknown metadata as a file-like event so parent invalidation still runs.
+ }
+ return { type: event.type, path: event.path, isDirectory }
+}
+
+export async function prepareWatcherProcessEvents(
+ events: readonly ParcelWatcherEvent[],
+ delivery: WatcherProcessDeliveryOptions | undefined
+): Promise {
+ if (delivery?.maxEventsPerBatch !== undefined && events.length > delivery.maxEventsPerBatch) {
+ return null
+ }
+ if (delivery?.includeDirectoryMetadata !== true) {
+ return events.map((event) => ({ type: event.type, path: event.path }))
+ }
+ return mapWithConcurrency(events, DIRECTORY_STAT_CONCURRENCY, (event) =>
+ mapWatcherEvent(event, true)
+ )
+}
+
+/** Keep at most one active and one bounded pending batch per subscription. */
+export function createWatcherProcessEventDeliveryQueue(
+ delivery: WatcherProcessDeliveryOptions | undefined,
+ deliver: (events: WatcherProcessEvent[] | null) => Promise,
+ onError: (error: unknown) => void
+): WatcherProcessEventDeliveryQueue {
+ const eventLimit = delivery?.maxEventsPerBatch ?? MAX_BATCHED_WATCHER_EVENTS
+ let active = true
+ let draining = false
+ let pendingOverflow = false
+ let pendingEvents: ParcelWatcherEvent[] = []
+
+ const drain = async (): Promise => {
+ if (!active || draining) {
+ return
+ }
+ draining = true
+ try {
+ while (active && (pendingOverflow || pendingEvents.length > 0)) {
+ const overflowed = pendingOverflow
+ const events = pendingEvents
+ pendingOverflow = false
+ pendingEvents = []
+ if (overflowed) {
+ await deliver(null)
+ continue
+ }
+ const prepared = await prepareWatcherProcessEvents(events, delivery)
+ if (active) {
+ await deliver(prepared)
+ }
+ }
+ } catch (error) {
+ if (active) {
+ onError(error)
+ }
+ } finally {
+ draining = false
+ if (active && (pendingOverflow || pendingEvents.length > 0)) {
+ void drain()
+ }
+ }
+ }
+
+ return {
+ enqueue(events): void {
+ if (!active || events.length === 0 || pendingOverflow) {
+ return
+ }
+ if (events.length > eventLimit || pendingEvents.length + events.length > eventLimit) {
+ pendingEvents = []
+ pendingOverflow = true
+ } else {
+ for (const event of events) {
+ pendingEvents.push(event)
+ }
+ }
+ void drain()
+ },
+ close(): void {
+ active = false
+ pendingEvents = []
+ pendingOverflow = false
+ }
+ }
+}
diff --git a/src/main/ipc/parcel-watcher-host-subscriptions.ts b/src/main/ipc/parcel-watcher-host-subscriptions.ts
new file mode 100644
index 00000000000..71a3d82868f
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-host-subscriptions.ts
@@ -0,0 +1,155 @@
+import type { ChildProcess } from 'node:child_process'
+import {
+ resetPendingSubscribeAttempt,
+ takePendingSubscribe
+} from './parcel-watcher-pending-subscribe'
+import { WatcherProcessFailure } from './parcel-watcher-process-failure'
+import type { HostToWatcherMessage, WatcherToHostMessage } from './parcel-watcher-process-protocol'
+import type {
+ WatcherProcessSubscription,
+ WatcherProcessSubscriptionRecord
+} from './parcel-watcher-process-subscription'
+
+export function handleWatcherHostMessage(
+ message: Exclude<
+ WatcherToHostMessage,
+ { op: 'subscribe-started' } | { op: 'cancel-requires-restart' }
+ >,
+ records: Map,
+ pendingUnsubscribes: Map void>,
+ reportTerminalError: (record: WatcherProcessSubscriptionRecord, error: Error) => void,
+ killWatcherChildIfIdle: () => void
+): void {
+ if (message.op === 'unsubscribed') {
+ const resolve = pendingUnsubscribes.get(message.id)
+ pendingUnsubscribes.delete(message.id)
+ resolve?.()
+ return
+ }
+ const record = records.get(message.id)
+ if (!record) {
+ return
+ }
+ if (message.op === 'subscribed') {
+ resetPendingSubscribeAttempt(record)
+ takePendingSubscribe(record)?.resolve()
+ if (record.interrupted) {
+ record.interrupted = false
+ record.hooks.onInterruption?.()
+ }
+ return
+ }
+ if (message.op === 'subscribe-failed') {
+ records.delete(message.id)
+ resetPendingSubscribeAttempt(record)
+ const pending = takePendingSubscribe(record)
+ const error = new WatcherProcessFailure(message.message, 'subscription', 'subscribe_failed')
+ if (pending) {
+ pending.reject(error)
+ } else {
+ reportTerminalError(record, error)
+ }
+ killWatcherChildIfIdle()
+ return
+ }
+ if (message.op === 'events') {
+ record.callback(null, message.events)
+ return
+ }
+ if (message.op === 'overflow') {
+ record.hooks.onOverflow?.()
+ return
+ }
+ record.callback(new Error(message.message), [])
+}
+
+export function reportWatcherTerminalError(
+ record: WatcherProcessSubscriptionRecord,
+ error: Error
+): void {
+ if (record.hooks.onTerminalError) {
+ record.hooks.onTerminalError(error)
+ return
+ }
+ record.callback(error, [])
+}
+
+export function failAllWatcherSubscriptions(
+ records: Map,
+ error: Error
+): void {
+ // Snapshot first: onTerminalError hooks can dispose the supervisor and clear `records`.
+ for (const record of Array.from(records.values())) {
+ resetPendingSubscribeAttempt(record)
+ const pending = takePendingSubscribe(record)
+ if (pending) {
+ pending.reject(error)
+ } else {
+ reportWatcherTerminalError(record, error)
+ }
+ }
+ records.clear()
+}
+
+export function resolvePendingWatcherUnsubscribes(
+ pendingUnsubscribes: Map void>
+): void {
+ for (const resolve of pendingUnsubscribes.values()) {
+ resolve()
+ }
+ pendingUnsubscribes.clear()
+}
+
+export function disposeWatcherSupervisorSubscriptions(
+ records: Map,
+ pendingUnsubscribes: Map void>,
+ cancelledSubscribesAwaitingChild: Set,
+ error: Error
+): void {
+ for (const record of records.values()) {
+ resetPendingSubscribeAttempt(record)
+ takePendingSubscribe(record)?.reject(error)
+ }
+ resolvePendingWatcherUnsubscribes(pendingUnsubscribes)
+ cancelledSubscribesAwaitingChild.clear()
+ records.clear()
+}
+
+export type CreateHostWatcherSubscriptionOptions = {
+ record: WatcherProcessSubscriptionRecord
+ records: Map
+ pendingUnsubscribes: Map void>
+ getChild: () => ChildProcess | null
+ killWatcherChildIfIdle: () => void
+ sendToChild: (child: ChildProcess, message: HostToWatcherMessage) => void
+}
+
+export function createHostWatcherSubscription({
+ record,
+ records,
+ pendingUnsubscribes,
+ getChild,
+ killWatcherChildIfIdle,
+ sendToChild
+}: CreateHostWatcherSubscriptionOptions): WatcherProcessSubscription {
+ return {
+ unsubscribe: (): Promise => {
+ if (!records.delete(record.id)) {
+ return Promise.resolve()
+ }
+ resetPendingSubscribeAttempt(record)
+ const child = getChild()
+ if (!child?.connected) {
+ return Promise.resolve()
+ }
+ if (records.size === 0) {
+ killWatcherChildIfIdle()
+ return Promise.resolve()
+ }
+ return new Promise((resolve) => {
+ pendingUnsubscribes.set(record.id, resolve)
+ sendToChild(child, { op: 'unsubscribe', id: record.id })
+ })
+ }
+ }
+}
diff --git a/src/main/ipc/parcel-watcher-in-process-fallback.ts b/src/main/ipc/parcel-watcher-in-process-fallback.ts
new file mode 100644
index 00000000000..bcd2812d35d
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-in-process-fallback.ts
@@ -0,0 +1,124 @@
+import type * as ParcelWatcher from '@parcel/watcher'
+import { createWatcherProcessEventDeliveryQueue } from './parcel-watcher-event-delivery'
+import { WatcherProcessFailure } from './parcel-watcher-process-failure'
+import type {
+ WatcherProcessCallback,
+ WatcherProcessHooks,
+ WatcherProcessSubscription
+} from './parcel-watcher-process-subscription'
+import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol'
+
+/**
+ * Use Parcel directly when Vitest mocks it. Production builds fail closed if
+ * the crash-isolated child entry is unavailable.
+ */
+export async function subscribeWithInProcessWatcher(
+ dir: string,
+ callback: WatcherProcessCallback,
+ opts: WatcherProcessSubscribeOptions,
+ hooks: WatcherProcessHooks
+): Promise {
+ if (hooks.signal?.aborted) {
+ throw new WatcherProcessFailure(
+ 'file watcher subscription aborted',
+ 'subscription',
+ 'subscribe_aborted'
+ )
+ }
+ let abortListener: (() => void) | undefined
+ let timer: ReturnType | undefined
+ const cancellation = new Promise((_resolve, reject) => {
+ if (hooks.signal) {
+ abortListener = () =>
+ reject(
+ new WatcherProcessFailure(
+ 'file watcher subscription aborted',
+ 'subscription',
+ 'subscribe_aborted'
+ )
+ )
+ hooks.signal.addEventListener('abort', abortListener, { once: true })
+ }
+ if (hooks.subscribeTimeoutMs !== undefined) {
+ timer = setTimeout(() => {
+ reject(
+ new WatcherProcessFailure(
+ `file watcher subscription timed out after ${hooks.subscribeTimeoutMs}ms`,
+ 'subscription',
+ 'subscribe_timeout'
+ )
+ )
+ }, hooks.subscribeTimeoutMs)
+ timer.unref?.()
+ }
+ })
+ const clearPendingControls = (): void => {
+ if (abortListener && hooks.signal) {
+ hooks.signal.removeEventListener('abort', abortListener)
+ }
+ if (timer) {
+ clearTimeout(timer)
+ }
+ }
+ let watcher: typeof ParcelWatcher
+ try {
+ // Why: setup ownership starts before module loading; an abort or timeout
+ // during the import must settle the caller just like one during the crawl.
+ watcher = await Promise.race([import('@parcel/watcher'), cancellation])
+ } catch (error) {
+ clearPendingControls()
+ throw error
+ }
+ let active = true
+ const eventDelivery = createWatcherProcessEventDeliveryQueue(
+ hooks.delivery,
+ async (events) => {
+ if (!active) {
+ return
+ }
+ if (events === null) {
+ hooks.onOverflow?.()
+ } else {
+ callback(null, events)
+ }
+ },
+ (error) => callback(error instanceof Error ? error : new Error(String(error)), [])
+ )
+ const subscriptionPromise = watcher.subscribe(
+ dir,
+ (err, events) => {
+ if (!active) {
+ return
+ }
+ if (err) {
+ callback(err, [])
+ return
+ }
+ eventDelivery.enqueue(events)
+ },
+ opts as ParcelWatcher.Options
+ )
+ let subscription: ParcelWatcher.AsyncSubscription
+ try {
+ subscription = await Promise.race([subscriptionPromise, cancellation])
+ } catch (error) {
+ active = false
+ eventDelivery.close()
+ clearPendingControls()
+ // Why: Parcel cannot cancel a crawl in-process. If it eventually finishes,
+ // release the late native handle instead of leaking it into the test host.
+ // Swallow unsubscribe failures so they never surface as unhandled rejections.
+ void subscriptionPromise
+ .then((lateSubscription) => lateSubscription.unsubscribe())
+ .catch(() => undefined)
+ throw error
+ }
+ clearPendingControls()
+ return {
+ unsubscribe: async (): Promise => {
+ active = false
+ eventDelivery.close()
+ await subscription.unsubscribe()
+ }
+ }
+}
diff --git a/src/main/ipc/parcel-watcher-pending-subscribe.ts b/src/main/ipc/parcel-watcher-pending-subscribe.ts
new file mode 100644
index 00000000000..67a020b9d5e
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-pending-subscribe.ts
@@ -0,0 +1,105 @@
+import { WatcherProcessFailure } from './parcel-watcher-process-failure'
+import type {
+ PendingWatcherProcessSubscribe,
+ WatcherProcessSubscriptionRecord
+} from './parcel-watcher-process-subscription'
+
+export function installPendingSubscribeControls(
+ record: WatcherProcessSubscriptionRecord,
+ cancel: (error: WatcherProcessFailure) => void
+): void {
+ const pending = record.pendingSubscribe
+ if (!pending) {
+ return
+ }
+ if (record.hooks.signal) {
+ pending.abortListener = () =>
+ cancel(
+ new WatcherProcessFailure(
+ 'file watcher subscription aborted',
+ 'subscription',
+ 'subscribe_aborted'
+ )
+ )
+ record.hooks.signal.addEventListener('abort', pending.abortListener, { once: true })
+ }
+}
+
+export function startPendingSubscribeTimeout(
+ record: WatcherProcessSubscriptionRecord,
+ cancel: (error: WatcherProcessFailure) => void
+): void {
+ const pending = record.pendingSubscribe
+ if (!pending) {
+ return
+ }
+ record.crawlStarted = true
+ if (pending.timer || record.hooks.subscribeTimeoutMs === undefined) {
+ return
+ }
+ pending.timer = setTimeout(() => {
+ cancel(
+ new WatcherProcessFailure(
+ `file watcher subscription timed out after ${record.hooks.subscribeTimeoutMs}ms`,
+ 'subscription',
+ 'subscribe_timeout'
+ )
+ )
+ }, record.hooks.subscribeTimeoutMs)
+ pending.timer.unref?.()
+}
+
+export function startInterruptedSubscribeTimeout(
+ record: WatcherProcessSubscriptionRecord,
+ cancel: (error: WatcherProcessFailure) => void
+): void {
+ if (
+ !record.interrupted ||
+ record.pendingSubscribe ||
+ record.resubscribeTimer ||
+ record.hooks.subscribeTimeoutMs === undefined
+ ) {
+ return
+ }
+ record.crawlStarted = true
+ record.resubscribeTimer = setTimeout(() => {
+ cancel(
+ new WatcherProcessFailure(
+ `file watcher resubscription timed out after ${record.hooks.subscribeTimeoutMs}ms`,
+ 'subscription',
+ 'subscribe_timeout'
+ )
+ )
+ }, record.hooks.subscribeTimeoutMs)
+ record.resubscribeTimer.unref?.()
+}
+
+export function resetPendingSubscribeAttempt(record: WatcherProcessSubscriptionRecord): void {
+ record.crawlStarted = false
+ if (record.resubscribeTimer) {
+ clearTimeout(record.resubscribeTimer)
+ record.resubscribeTimer = undefined
+ }
+ const pending = record.pendingSubscribe
+ if (pending?.timer) {
+ clearTimeout(pending.timer)
+ pending.timer = undefined
+ }
+}
+
+export function takePendingSubscribe(
+ record: WatcherProcessSubscriptionRecord
+): PendingWatcherProcessSubscribe | undefined {
+ const pending = record.pendingSubscribe
+ if (!pending) {
+ return undefined
+ }
+ record.pendingSubscribe = undefined
+ if (pending.abortListener && record.hooks.signal) {
+ record.hooks.signal.removeEventListener('abort', pending.abortListener)
+ }
+ if (pending.timer) {
+ clearTimeout(pending.timer)
+ }
+ return pending
+}
diff --git a/src/main/ipc/parcel-watcher-process-entry.test.ts b/src/main/ipc/parcel-watcher-process-entry.test.ts
index 13f808ab650..19740f17fff 100644
--- a/src/main/ipc/parcel-watcher-process-entry.test.ts
+++ b/src/main/ipc/parcel-watcher-process-entry.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const { subscribeMock, writeFileSyncMock } = vi.hoisted(() => ({
+const { statMock, subscribeMock, writeFileSyncMock } = vi.hoisted(() => ({
+ statMock: vi.fn(),
subscribeMock: vi.fn(),
writeFileSyncMock: vi.fn()
}))
@@ -10,21 +11,25 @@ vi.mock('node:fs', () => ({
rmSync: vi.fn(),
writeFileSync: writeFileSyncMock
}))
+vi.mock('node:fs/promises', () => ({ stat: statMock }))
vi.mock('@parcel/watcher', () => ({ subscribe: subscribeMock }))
describe('parcel watcher process canary', () => {
let originalMessageListeners: ReturnType
let originalExitListeners: ReturnType
let originalDisconnectListeners: ReturnType
+ let originalSend: typeof process.send
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
subscribeMock.mockReset()
+ statMock.mockReset()
writeFileSyncMock.mockReset()
originalMessageListeners = process.listeners('message')
originalExitListeners = process.listeners('exit')
originalDisconnectListeners = process.listeners('disconnect')
+ originalSend = process.send
})
afterEach(() => {
@@ -43,6 +48,7 @@ describe('parcel watcher process canary', () => {
process.off('disconnect', listener)
}
}
+ process.send = originalSend
vi.useRealTimers()
vi.restoreAllMocks()
})
@@ -201,6 +207,80 @@ describe('parcel watcher process canary', () => {
expect(exitSpy).toHaveBeenCalledWith(2)
})
+ it('removes a cancelled queued crawl before it reaches Parcel', async () => {
+ let finishActiveCrawl:
+ | ((subscription: { unsubscribe: () => Promise }) => void)
+ | undefined
+ subscribeMock.mockResolvedValueOnce({ unsubscribe: vi.fn() }).mockReturnValueOnce(
+ new Promise((resolve) => {
+ finishActiveCrawl = resolve
+ })
+ )
+ const sendMock = vi.fn()
+ process.send = sendMock
+
+ await import('./parcel-watcher-process-entry')
+ await vi.advanceTimersByTimeAsync(0)
+ process.emit('message', { op: 'subscribe', id: 1, dir: '/active', opts: {} })
+ await vi.advanceTimersByTimeAsync(0)
+ process.emit('message', { op: 'subscribe', id: 2, dir: '/queued', opts: {} })
+ process.emit('message', { op: 'cancel-subscribe', id: 2 })
+
+ // Why: non-restart cancel reuses async unsubscribe teardown; drain the
+ // exclusive lifecycle queue after the active crawl finishes.
+ finishActiveCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) })
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 2 })
+ expect(subscribeMock).toHaveBeenCalledTimes(2)
+ expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribe-started', id: 2 })
+ expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 2 })
+ })
+
+ it('unsubscribes a late cancel after the crawl already finished', async () => {
+ const unsubscribe = vi.fn().mockResolvedValue(undefined)
+ subscribeMock.mockResolvedValueOnce({ unsubscribe: vi.fn() }).mockResolvedValueOnce({
+ unsubscribe
+ })
+ const sendMock = vi.fn()
+ process.send = sendMock
+
+ await import('./parcel-watcher-process-entry')
+ await vi.advanceTimersByTimeAsync(0)
+ process.emit('message', { op: 'subscribe', id: 1, dir: '/finished', opts: {} })
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(sendMock).toHaveBeenCalledWith({ op: 'subscribed', id: 1 })
+ process.emit('message', { op: 'cancel-subscribe', id: 1 })
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(unsubscribe).toHaveBeenCalledTimes(1)
+ expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 })
+ expect(sendMock).not.toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 })
+ })
+
+ it('asks the host to restart when an active crawl is cancelled', async () => {
+ let finishCrawl: ((subscription: { unsubscribe: () => Promise }) => void) | undefined
+ subscribeMock.mockResolvedValueOnce({ unsubscribe: vi.fn() }).mockReturnValueOnce(
+ new Promise((resolve) => {
+ finishCrawl = resolve
+ })
+ )
+ const sendMock = vi.fn()
+ process.send = sendMock
+
+ await import('./parcel-watcher-process-entry')
+ await vi.advanceTimersByTimeAsync(0)
+ process.emit('message', { op: 'subscribe', id: 1, dir: '/active', opts: {} })
+ await vi.advanceTimersByTimeAsync(0)
+ process.emit('message', { op: 'cancel-subscribe', id: 1 })
+
+ expect(sendMock).toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 })
+ finishCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) })
+ await vi.advanceTimersByTimeAsync(0)
+ expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 1 })
+ })
+
it('still restarts after consecutive missed events once every subscription is live', async () => {
subscribeMock.mockResolvedValue({ unsubscribe: vi.fn() })
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
@@ -215,4 +295,198 @@ describe('parcel watcher process canary', () => {
expect(writeFileSyncMock).toHaveBeenCalledTimes(3)
expect(exitSpy).toHaveBeenCalledWith(2)
})
+
+ it('collapses and enriches runtime batches before they cross child IPC', async () => {
+ let callback:
+ | ((err: Error | null, events: { type: string; path: string }[]) => void)
+ | undefined
+ subscribeMock
+ .mockResolvedValueOnce({ unsubscribe: vi.fn() })
+ .mockImplementationOnce(async (_dir, nextCallback) => {
+ callback = nextCallback
+ return { unsubscribe: vi.fn() }
+ })
+ statMock.mockImplementation(async (eventPath: string) => ({
+ isDirectory: () => eventPath.endsWith('/dir')
+ }))
+ const sendMock = vi.fn()
+ process.send = sendMock
+
+ await import('./parcel-watcher-process-entry')
+ await vi.advanceTimersByTimeAsync(0)
+ process.emit('message', {
+ op: 'subscribe',
+ id: 1,
+ dir: '/repo',
+ opts: {},
+ delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 }
+ })
+ await vi.advanceTimersByTimeAsync(0)
+
+ callback?.(null, [
+ { type: 'update', path: '/repo/file.txt' },
+ { type: 'create', path: '/repo/dir' }
+ ])
+ await vi.advanceTimersByTimeAsync(0)
+ expect(sendMock).toHaveBeenCalledWith(
+ {
+ op: 'events',
+ id: 1,
+ events: [
+ { type: 'update', path: '/repo/file.txt', isDirectory: false },
+ { type: 'create', path: '/repo/dir', isDirectory: true }
+ ]
+ },
+ expect.any(Function)
+ )
+
+ sendMock.mockClear()
+ callback?.(null, [
+ { type: 'update', path: '/repo/a' },
+ { type: 'update', path: '/repo/b' },
+ { type: 'update', path: '/repo/c' }
+ ])
+ await vi.advanceTimersByTimeAsync(0)
+ expect(sendMock).toHaveBeenCalledWith({ op: 'overflow', id: 1 }, expect.any(Function))
+ expect(statMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('reports an FSEvents overflow and keeps delivering later events', async () => {
+ let callback:
+ | ((err: Error | null, events: { type: string; path: string }[]) => void)
+ | undefined
+ subscribeMock
+ .mockResolvedValueOnce({ unsubscribe: vi.fn() })
+ .mockImplementationOnce(async (_dir, nextCallback) => {
+ callback = nextCallback
+ return { unsubscribe: vi.fn() }
+ })
+ const sendMock = vi.fn()
+ process.send = sendMock
+
+ await import('./parcel-watcher-process-entry')
+ await vi.advanceTimersByTimeAsync(0)
+ process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} })
+ await vi.advanceTimersByTimeAsync(0)
+
+ callback?.(
+ new Error('Events were dropped by the FSEvents client. File system must be re-scanned.'),
+ []
+ )
+ callback?.(null, [{ type: 'update', path: '/repo/after-overflow.txt' }])
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(sendMock).toHaveBeenCalledWith({
+ op: 'watch-error',
+ id: 1,
+ message: 'Events were dropped by the FSEvents client. File system must be re-scanned.'
+ })
+ expect(sendMock).toHaveBeenCalledWith(
+ {
+ op: 'events',
+ id: 1,
+ events: [{ type: 'update', path: '/repo/after-overflow.txt' }]
+ },
+ expect.any(Function)
+ )
+ })
+
+ it('bounds pending event batches while child IPC reports backpressure', async () => {
+ let callback:
+ | ((err: Error | null, events: { type: string; path: string }[]) => void)
+ | undefined
+ let releaseFirstEvent: (() => void) | undefined
+ const sentEventPaths: string[] = []
+ subscribeMock
+ .mockResolvedValueOnce({ unsubscribe: vi.fn() })
+ .mockImplementationOnce(async (_dir, nextCallback) => {
+ callback = nextCallback
+ return { unsubscribe: vi.fn() }
+ })
+ process.send = vi.fn((message, onSent) => {
+ if (message.op !== 'events') {
+ return true
+ }
+ sentEventPaths.push(message.events[0]?.path ?? '')
+ if (!releaseFirstEvent) {
+ releaseFirstEvent = onSent
+ return false
+ }
+ return true
+ }) as typeof process.send
+
+ await import('./parcel-watcher-process-entry')
+ await vi.advanceTimersByTimeAsync(0)
+ process.emit('message', {
+ op: 'subscribe',
+ id: 1,
+ dir: '/repo',
+ opts: {},
+ delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 }
+ })
+ await vi.advanceTimersByTimeAsync(0)
+
+ callback?.(null, [{ type: 'update', path: '/repo/first.txt' }])
+ callback?.(null, [{ type: 'update', path: '/repo/second.txt' }])
+ callback?.(null, [{ type: 'update', path: '/repo/third.txt' }])
+ callback?.(null, [{ type: 'update', path: '/repo/fourth.txt' }])
+ await vi.advanceTimersByTimeAsync(0)
+ expect(sentEventPaths).toEqual(['/repo/first.txt'])
+
+ releaseFirstEvent?.()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(sentEventPaths).toEqual(['/repo/first.txt'])
+ expect(process.send).toHaveBeenCalledWith({ op: 'overflow', id: 1 }, expect.any(Function))
+ expect(statMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('drops queued event work when the subscription is removed', async () => {
+ let callback:
+ | ((err: Error | null, events: { type: string; path: string }[]) => void)
+ | undefined
+ let releaseFirstEvent: (() => void) | undefined
+ const unsubscribe = vi.fn().mockResolvedValue(undefined)
+ subscribeMock
+ .mockResolvedValueOnce({ unsubscribe: vi.fn() })
+ .mockImplementationOnce(async (_dir, nextCallback) => {
+ callback = nextCallback
+ return { unsubscribe }
+ })
+ process.send = vi.fn((message, onSent) => {
+ if (message.op === 'events' && !releaseFirstEvent) {
+ releaseFirstEvent = onSent
+ return false
+ }
+ return true
+ }) as typeof process.send
+
+ await import('./parcel-watcher-process-entry')
+ await vi.advanceTimersByTimeAsync(0)
+ process.emit('message', {
+ op: 'subscribe',
+ id: 1,
+ dir: '/repo',
+ opts: {},
+ delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 }
+ })
+ await vi.advanceTimersByTimeAsync(0)
+
+ callback?.(null, [{ type: 'update', path: '/repo/first.txt' }])
+ callback?.(null, [{ type: 'update', path: '/repo/queued.txt' }])
+ await vi.advanceTimersByTimeAsync(0)
+ process.emit('message', { op: 'unsubscribe', id: 1 })
+ await vi.advanceTimersByTimeAsync(0)
+ releaseFirstEvent?.()
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(unsubscribe).toHaveBeenCalledTimes(1)
+ expect(statMock).toHaveBeenCalledTimes(1)
+ expect(process.send).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ op: 'events',
+ events: [expect.objectContaining({ path: '/repo/queued.txt' })]
+ }),
+ expect.any(Function)
+ )
+ })
})
diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts
index 6a296eb2af9..1e043739a4a 100644
--- a/src/main/ipc/parcel-watcher-process-entry.ts
+++ b/src/main/ipc/parcel-watcher-process-entry.ts
@@ -1,4 +1,4 @@
-// Forked (ELECTRON_RUN_AS_NODE) child that hosts all local @parcel/watcher
+// Forked (ELECTRON_RUN_AS_NODE) child that hosts its assigned @parcel/watcher
// subscriptions. Why: watcher.node has native teardown races that fail-fast
// the hosting process (issue #7547, 0xc0000409 on Windows; same class as
// #5377/#6635). Running the native module here turns a watcher fault into a
@@ -8,28 +8,16 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as ParcelWatcher from '@parcel/watcher'
-
-export type WatcherProcessEvent = {
- type: 'create' | 'update' | 'delete'
- path: string
-}
-
-export type WatcherProcessSubscribeOptions = {
- ignore?: string[]
- ignoreGlobs?: string[]
- backend?: string
-}
-
-export type HostToWatcherMessage =
- | { op: 'subscribe'; id: number; dir: string; opts: WatcherProcessSubscribeOptions }
- | { op: 'unsubscribe'; id: number }
-
-export type WatcherToHostMessage =
- | { op: 'subscribed'; id: number }
- | { op: 'subscribe-failed'; id: number; message: string }
- | { op: 'events'; id: number; events: WatcherProcessEvent[] }
- | { op: 'watch-error'; id: number; message: string }
- | { op: 'unsubscribed'; id: number }
+import {
+ createWatcherProcessEventDeliveryQueue,
+ type WatcherProcessEventDeliveryQueue
+} from './parcel-watcher-event-delivery'
+import type {
+ HostToWatcherMessage,
+ WatcherProcessDeliveryOptions,
+ WatcherProcessSubscribeOptions,
+ WatcherToHostMessage
+} from './parcel-watcher-process-protocol'
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
@@ -49,8 +37,9 @@ const CANARY_MAX_MISSES = 2
async function startCanary(getStableActivityRevision: () => number | null): Promise {
let canaryDir: string
let lastEventAt = 0
+ const configuredCanaryDir = process.env.ORCA_WATCHER_CANARY_DIR
try {
- canaryDir = mkdtempSync(join(tmpdir(), 'orca-watcher-canary-'))
+ canaryDir = configuredCanaryDir ?? mkdtempSync(join(tmpdir(), 'orca-watcher-canary-'))
const watcher = await import('@parcel/watcher')
// Why: pin the Windows backend like the main subscriptions do, so the
// canary never probes for Watchman.
@@ -71,6 +60,9 @@ async function startCanary(getStableActivityRevision: () => number | null): Prom
return
}
process.on('exit', () => {
+ if (configuredCanaryDir) {
+ return
+ }
try {
rmSync(canaryDir, { recursive: true, force: true })
} catch {
@@ -125,12 +117,34 @@ function main(): void {
}
}
+ const sendEventWithBackpressure = (message: WatcherToHostMessage): Promise => {
+ return new Promise((resolve) => {
+ let settled = false
+ const finish = (): void => {
+ if (!settled) {
+ settled = true
+ resolve()
+ }
+ }
+ try {
+ const accepted = process.send?.(message, finish)
+ if (accepted !== false) {
+ finish()
+ }
+ } catch {
+ finish()
+ }
+ })
+ }
+
// Subscribe promises are kept (not just subscriptions) so an unsubscribe
// that races a still-crawling subscribe awaits it instead of leaking the
// native handle — on Windows a leaked handle keeps the worktree dir locked.
const subscriptions = new Map>()
const liveSubscriptionIds = new Set()
+ const eventDeliveries = new Map()
let pendingSubscriptionCrawls = 0
+ let activeSubscriptionCrawlId: number | null = null
let subscriptionActivityRevision = 0
let nativeLifecycleTail = Promise.resolve()
@@ -156,11 +170,29 @@ function main(): void {
const handleSubscribe = async (
id: number,
dir: string,
- opts: WatcherProcessSubscribeOptions
+ opts: WatcherProcessSubscribeOptions,
+ delivery: WatcherProcessDeliveryOptions | undefined
): Promise => {
+ const eventDelivery = createWatcherProcessEventDeliveryQueue(
+ delivery,
+ async (events) => {
+ await sendEventWithBackpressure(
+ events === null ? { op: 'overflow', id } : { op: 'events', id, events }
+ )
+ },
+ (deliveryError) => {
+ send({ op: 'watch-error', id, message: errorMessage(deliveryError) })
+ }
+ )
+ eventDeliveries.set(id, eventDelivery)
try {
const subscription = await runNativeWatcherLifecycleExclusive(async () => {
+ if (!subscriptions.has(id)) {
+ return null
+ }
+ activeSubscriptionCrawlId = id
beginSubscriptionCrawl()
+ send({ op: 'subscribe-started', id })
try {
const watcher = await import('@parcel/watcher')
return await watcher.subscribe(
@@ -170,31 +202,32 @@ function main(): void {
send({ op: 'watch-error', id, message: errorMessage(err) })
return
}
- if (events.length > 0) {
- send({
- op: 'events',
- id,
- events: events.map((event) => ({ type: event.type, path: event.path }))
- })
- }
+ eventDelivery.enqueue(events)
},
opts as ParcelWatcher.Options
)
} finally {
+ activeSubscriptionCrawlId = null
finishSubscriptionCrawl()
}
})
// An unsubscribe can remove the record while subscribe() is crawling.
// Only advertise it as live if it is still owned by this process.
- if (subscriptions.has(id)) {
+ const stillOwned = subscriptions.has(id)
+ if (stillOwned) {
liveSubscriptionIds.add(id)
+ send({ op: 'subscribed', id })
}
- send({ op: 'subscribed', id })
return subscription
} catch (err) {
+ const stillOwned = subscriptions.has(id)
+ eventDelivery.close()
+ eventDeliveries.delete(id)
subscriptions.delete(id)
liveSubscriptionIds.delete(id)
- send({ op: 'subscribe-failed', id, message: errorMessage(err) })
+ if (stillOwned) {
+ send({ op: 'subscribe-failed', id, message: errorMessage(err) })
+ }
return null
}
}
@@ -206,6 +239,8 @@ function main(): void {
const pending = subscriptions.get(id)
subscriptions.delete(id)
liveSubscriptionIds.delete(id)
+ eventDeliveries.get(id)?.close()
+ eventDeliveries.delete(id)
try {
// Why: Parcel already serializes crawl and teardown on one backend mutex.
// Mirror that ordering here so neither operation can mask a canary miss.
@@ -223,16 +258,40 @@ function main(): void {
send({ op: 'unsubscribed', id })
}
+ const handleCancelSubscribe = (id: number): void => {
+ // Why: an in-flight native crawl cannot be cancelled safely; ask the host to
+ // kill this child instead of risking the Parcel teardown deadlock.
+ if (activeSubscriptionCrawlId === id) {
+ subscriptions.delete(id)
+ liveSubscriptionIds.delete(id)
+ eventDeliveries.get(id)?.close()
+ eventDeliveries.delete(id)
+ send({ op: 'cancel-requires-restart', id })
+ return
+ }
+ // Why: cancel can race a crawl that already finished. Reuse unsubscribe
+ // teardown so a live native handle is released instead of leaked (Windows
+ // keeps the worktree locked while the handle stays open).
+ void handleUnsubscribe(id)
+ }
+
process.on('message', (message: HostToWatcherMessage) => {
if (!message || typeof message !== 'object') {
return
}
if (message.op === 'subscribe') {
- subscriptions.set(message.id, handleSubscribe(message.id, message.dir, message.opts))
+ subscriptions.set(
+ message.id,
+ handleSubscribe(message.id, message.dir, message.opts, message.delivery)
+ )
return
}
if (message.op === 'unsubscribe') {
void handleUnsubscribe(message.id)
+ return
+ }
+ if (message.op === 'cancel-subscribe') {
+ handleCancelSubscribe(message.id)
}
})
diff --git a/src/main/ipc/parcel-watcher-process-failure.ts b/src/main/ipc/parcel-watcher-process-failure.ts
new file mode 100644
index 00000000000..80eb79ddd33
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-process-failure.ts
@@ -0,0 +1,32 @@
+export type WatcherProcessFailureScope = 'subscription' | 'supervisor'
+
+export type WatcherProcessFailureCode =
+ | 'entry_missing'
+ | 'process_unavailable'
+ | 'subscribe_aborted'
+ | 'subscribe_failed'
+ | 'subscribe_timeout'
+ | 'supervisor_crash_fuse'
+ | 'supervisor_disposed'
+
+export class WatcherProcessFailure extends Error {
+ constructor(
+ message: string,
+ readonly scope: WatcherProcessFailureScope,
+ readonly code: WatcherProcessFailureCode
+ ) {
+ super(message)
+ this.name = 'WatcherProcessFailure'
+ }
+}
+
+export function watcherHostFailure(
+ message: string,
+ code: WatcherProcessFailureCode
+): WatcherProcessFailure {
+ return new WatcherProcessFailure(message, 'supervisor', code)
+}
+
+export function isWatcherProcessFailure(error: unknown): error is WatcherProcessFailure {
+ return error instanceof WatcherProcessFailure
+}
diff --git a/src/main/ipc/parcel-watcher-process-protocol.ts b/src/main/ipc/parcel-watcher-process-protocol.ts
new file mode 100644
index 00000000000..17dbcc36a88
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-process-protocol.ts
@@ -0,0 +1,37 @@
+export type WatcherProcessEvent = {
+ type: 'create' | 'update' | 'delete'
+ path: string
+ isDirectory?: boolean
+}
+
+export type WatcherProcessSubscribeOptions = {
+ ignore?: string[]
+ ignoreGlobs?: string[]
+ backend?: string
+}
+
+export type WatcherProcessDeliveryOptions = {
+ includeDirectoryMetadata?: boolean
+ maxEventsPerBatch?: number
+}
+
+export type HostToWatcherMessage =
+ | {
+ op: 'subscribe'
+ id: number
+ dir: string
+ opts: WatcherProcessSubscribeOptions
+ delivery?: WatcherProcessDeliveryOptions
+ }
+ | { op: 'unsubscribe'; id: number }
+ | { op: 'cancel-subscribe'; id: number }
+
+export type WatcherToHostMessage =
+ | { op: 'subscribe-started'; id: number }
+ | { op: 'subscribed'; id: number }
+ | { op: 'subscribe-failed'; id: number; message: string }
+ | { op: 'events'; id: number; events: WatcherProcessEvent[] }
+ | { op: 'overflow'; id: number }
+ | { op: 'watch-error'; id: number; message: string }
+ | { op: 'cancel-requires-restart'; id: number }
+ | { op: 'unsubscribed'; id: number }
diff --git a/src/main/ipc/parcel-watcher-process-subscription.ts b/src/main/ipc/parcel-watcher-process-subscription.ts
new file mode 100644
index 00000000000..54489b86397
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-process-subscription.ts
@@ -0,0 +1,35 @@
+import type {
+ WatcherProcessDeliveryOptions,
+ WatcherProcessEvent,
+ WatcherProcessSubscribeOptions
+} from './parcel-watcher-process-protocol'
+
+export type WatcherProcessCallback = (err: Error | null, events: WatcherProcessEvent[]) => void
+export type WatcherProcessSubscription = { unsubscribe(): Promise }
+export type WatcherProcessHooks = {
+ delivery?: WatcherProcessDeliveryOptions
+ onInterruption?: () => void
+ onOverflow?: () => void
+ onTerminalError?: (error: Error) => void
+ signal?: AbortSignal
+ subscribeTimeoutMs?: number
+}
+
+export type PendingWatcherProcessSubscribe = {
+ resolve: () => void
+ reject: (err: Error) => void
+ abortListener?: () => void
+ timer?: ReturnType
+}
+
+export type WatcherProcessSubscriptionRecord = {
+ id: number
+ dir: string
+ opts: WatcherProcessSubscribeOptions
+ callback: WatcherProcessCallback
+ hooks: WatcherProcessHooks
+ interrupted: boolean
+ crawlStarted: boolean
+ resubscribeTimer?: ReturnType
+ pendingSubscribe?: PendingWatcherProcessSubscribe
+}
diff --git a/src/main/ipc/parcel-watcher-process-supervisor-options.ts b/src/main/ipc/parcel-watcher-process-supervisor-options.ts
new file mode 100644
index 00000000000..47f4282a7e6
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-process-supervisor-options.ts
@@ -0,0 +1,4 @@
+export type WatcherProcessSupervisorOptions = {
+ entryPath?: string
+ useInProcessVitestFallback?: boolean
+}
diff --git a/src/main/ipc/parcel-watcher-process-supervisor.ts b/src/main/ipc/parcel-watcher-process-supervisor.ts
new file mode 100644
index 00000000000..9be9b232b08
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-process-supervisor.ts
@@ -0,0 +1,316 @@
+import type { ChildProcess } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path'
+import { removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory'
+import { launchWatcherChild } from './parcel-watcher-child-launch'
+import { WatcherProcessCrashFuse } from './parcel-watcher-crash-fuse'
+import {
+ disposeWatcherSupervisorSubscriptions,
+ failAllWatcherSubscriptions,
+ handleWatcherHostMessage,
+ reportWatcherTerminalError,
+ resolvePendingWatcherUnsubscribes
+} from './parcel-watcher-host-subscriptions'
+import {
+ resetPendingSubscribeAttempt,
+ startInterruptedSubscribeTimeout,
+ startPendingSubscribeTimeout,
+ takePendingSubscribe
+} from './parcel-watcher-pending-subscribe'
+import { watcherHostFailure } from './parcel-watcher-process-failure'
+import type { WatcherProcessFailure } from './parcel-watcher-process-failure'
+import type {
+ HostToWatcherMessage,
+ WatcherProcessSubscribeOptions,
+ WatcherToHostMessage
+} from './parcel-watcher-process-protocol'
+import type {
+ WatcherProcessCallback,
+ WatcherProcessHooks,
+ WatcherProcessSubscription,
+ WatcherProcessSubscriptionRecord
+} from './parcel-watcher-process-subscription'
+import type { WatcherProcessSupervisorOptions } from './parcel-watcher-process-supervisor-options'
+import { subscribeThroughWatcherSupervisor } from './parcel-watcher-supervisor-subscribe'
+
+export type {
+ WatcherProcessCallback,
+ WatcherProcessHooks,
+ WatcherProcessSubscription
+} from './parcel-watcher-process-subscription'
+export type { WatcherProcessSupervisorOptions } from './parcel-watcher-process-supervisor-options'
+
+export class WatcherProcessSupervisor {
+ private child: ChildProcess | null = null
+ private nextSubscriptionId = 1
+ private readonly crashFuse = new WatcherProcessCrashFuse()
+ private shutdownRequested = false
+ private canaryDir: string | null = null
+ private readonly records = new Map()
+ private readonly pendingUnsubscribes = new Map void>()
+ private readonly cancelledSubscribesAwaitingChild = new Set()
+
+ constructor(private readonly options: WatcherProcessSupervisorOptions = {}) {}
+
+ subscribe(
+ dir: string,
+ callback: WatcherProcessCallback,
+ opts: WatcherProcessSubscribeOptions,
+ hooks: WatcherProcessHooks = {}
+ ): Promise {
+ return subscribeThroughWatcherSupervisor({
+ dir,
+ callback,
+ opts,
+ hooks,
+ shutdownRequested: this.shutdownRequested,
+ entryPath: this.options.entryPath ?? getWatcherProcessEntryPath(),
+ useInProcessVitestFallback: this.options.useInProcessVitestFallback ?? true,
+ allocateId: () => this.nextSubscriptionId++,
+ records: this.records,
+ pendingUnsubscribes: this.pendingUnsubscribes,
+ ensureWatcherProcess: (entryPath) => this.ensureWatcherProcess(entryPath),
+ getChild: () => this.child,
+ killWatcherChildIfIdle: () => this.killWatcherChildIfIdle(),
+ sendSubscribe: (child, record) => this.sendSubscribe(child, record),
+ sendToChild: (child, message) => this.sendToChild(child, message),
+ cancelPendingSubscribe: (record, error) => this.cancelPendingSubscribe(record, error)
+ })
+ }
+
+ dispose(): void {
+ this.shutdownRequested = true
+ const proc = this.child
+ this.child = null
+ const error = watcherHostFailure('file watcher supervisor disposed', 'supervisor_disposed')
+ disposeWatcherSupervisorSubscriptions(
+ this.records,
+ this.pendingUnsubscribes,
+ this.cancelledSubscribesAwaitingChild,
+ error
+ )
+ proc?.kill()
+ this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
+ }
+
+ resetForTest(): void {
+ this.dispose()
+ this.shutdownRequested = false
+ this.crashFuse.reset()
+ }
+
+ private ensureWatcherProcess(
+ entryPath = this.options.entryPath ?? getWatcherProcessEntryPath()
+ ): ChildProcess | null {
+ if (this.shutdownRequested) {
+ return null
+ }
+ if (this.child?.connected) {
+ return this.child
+ }
+ if (this.crashFuse.isOpen()) {
+ return null
+ }
+ if (!existsSync(entryPath)) {
+ console.error(`[parcel-watcher-process] entry not found at ${entryPath}; refusing fail-open`)
+ return null
+ }
+ const launched = launchWatcherChild(
+ entryPath,
+ this.canaryDir,
+ (child, message) => {
+ if (this.child === child) {
+ this.handleChildMessage(message)
+ }
+ },
+ (child, code, signal) => this.handleChildGone(child, code, signal)
+ )
+ if (!launched) {
+ this.canaryDir = null
+ return null
+ }
+ this.canaryDir = launched.canaryDir
+ this.child = launched.child
+ return launched.child
+ }
+
+ private handleChildMessage(message: WatcherToHostMessage): void {
+ if (message.op === 'subscribe-started') {
+ const record = this.records.get(message.id)
+ if (record) {
+ startPendingSubscribeTimeout(record, (error) => this.cancelPendingSubscribe(record, error))
+ startInterruptedSubscribeTimeout(record, (error) =>
+ this.cancelInterruptedSubscribe(record, error)
+ )
+ }
+ return
+ }
+ if (message.op === 'cancel-requires-restart') {
+ if (this.cancelledSubscribesAwaitingChild.delete(message.id)) {
+ this.restartAfterCancelledSubscribe()
+ }
+ return
+ }
+ if (message.op === 'unsubscribed') {
+ const completedCancellation = this.cancelledSubscribesAwaitingChild.delete(message.id)
+ handleWatcherHostMessage(
+ message,
+ this.records,
+ this.pendingUnsubscribes,
+ reportWatcherTerminalError,
+ () => this.killWatcherChildIfIdle()
+ )
+ if (completedCancellation) {
+ this.killWatcherChildIfIdle()
+ }
+ return
+ }
+ handleWatcherHostMessage(
+ message,
+ this.records,
+ this.pendingUnsubscribes,
+ reportWatcherTerminalError,
+ () => this.killWatcherChildIfIdle()
+ )
+ }
+
+ private handleChildGone(
+ proc: ChildProcess,
+ code?: number | null,
+ signal?: NodeJS.Signals | null
+ ): void {
+ if (this.child !== proc) {
+ return
+ }
+ if (code !== undefined && (code !== 0 || signal)) {
+ console.error(
+ `[parcel-watcher-process] watcher process exited (code=${code}, signal=${signal})`
+ )
+ }
+ this.child = null
+ this.cancelledSubscribesAwaitingChild.clear()
+ resolvePendingWatcherUnsubscribes(this.pendingUnsubscribes)
+ if (this.shutdownRequested || this.records.size === 0) {
+ return
+ }
+ this.crashFuse.recordCrash()
+ for (const record of this.records.values()) {
+ record.interrupted = true
+ resetPendingSubscribeAttempt(record)
+ }
+ const replacement = this.ensureWatcherProcess()
+ if (!replacement) {
+ console.error(
+ '[parcel-watcher-process] watcher process crashed repeatedly; disabling file watching'
+ )
+ failAllWatcherSubscriptions(
+ this.records,
+ watcherHostFailure('file watcher process crashed repeatedly', 'supervisor_crash_fuse')
+ )
+ this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
+ return
+ }
+ console.error(
+ `[parcel-watcher-process] watcher process crashed; resubscribing ${this.records.size} root(s)`
+ )
+ for (const record of this.records.values()) {
+ this.sendSubscribe(replacement, record)
+ }
+ }
+
+ private killWatcherChildIfIdle(): void {
+ const proc = this.child
+ if (!proc || this.records.size > 0) {
+ return
+ }
+ this.child = null
+ resolvePendingWatcherUnsubscribes(this.pendingUnsubscribes)
+ proc.kill()
+ this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
+ }
+
+ private cancelPendingSubscribe(
+ record: WatcherProcessSubscriptionRecord,
+ error: WatcherProcessFailure
+ ): void {
+ if (!record.pendingSubscribe || !this.records.delete(record.id)) {
+ return
+ }
+ const crawlStarted = record.crawlStarted
+ const pending = takePendingSubscribe(record)
+ pending?.reject(error)
+ if (crawlStarted) {
+ this.restartAfterCancelledSubscribe()
+ return
+ }
+ const proc = this.child
+ if (!proc?.connected) {
+ this.killWatcherChildIfIdle()
+ return
+ }
+ this.cancelledSubscribesAwaitingChild.add(record.id)
+ this.sendToChild(proc, { op: 'cancel-subscribe', id: record.id })
+ }
+
+ private cancelInterruptedSubscribe(
+ record: WatcherProcessSubscriptionRecord,
+ error: WatcherProcessFailure
+ ): void {
+ if (!record.interrupted || record.pendingSubscribe || !this.records.delete(record.id)) {
+ return
+ }
+ resetPendingSubscribeAttempt(record)
+ // Why: one slow recovery root must not pin every healthy root in its shard.
+ // End that subscription so the runtime pool can retry it in quarantine.
+ reportWatcherTerminalError(record, error)
+ this.restartAfterCancelledSubscribe()
+ }
+
+ private restartAfterCancelledSubscribe(): void {
+ const proc = this.child
+ this.child = null
+ this.cancelledSubscribesAwaitingChild.clear()
+ resolvePendingWatcherUnsubscribes(this.pendingUnsubscribes)
+ proc?.kill()
+ if (this.records.size === 0) {
+ this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
+ return
+ }
+ const replacement = this.ensureWatcherProcess()
+ if (!replacement) {
+ failAllWatcherSubscriptions(
+ this.records,
+ watcherHostFailure(
+ 'file watcher process unavailable after subscription cancellation',
+ 'process_unavailable'
+ )
+ )
+ this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
+ return
+ }
+ for (const liveRecord of this.records.values()) {
+ liveRecord.interrupted = true
+ resetPendingSubscribeAttempt(liveRecord)
+ this.sendSubscribe(replacement, liveRecord)
+ }
+ }
+
+ private sendSubscribe(proc: ChildProcess, record: WatcherProcessSubscriptionRecord): void {
+ resetPendingSubscribeAttempt(record)
+ this.sendToChild(proc, {
+ op: 'subscribe',
+ id: record.id,
+ dir: record.dir,
+ opts: record.opts,
+ delivery: record.hooks.delivery
+ })
+ }
+
+ private sendToChild(proc: ChildProcess, message: HostToWatcherMessage): void {
+ try {
+ proc.send(message)
+ } catch {
+ // Channel already closed — the child's exit handler recovers.
+ }
+ }
+}
diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts
index 4df3b666fbb..1809ad484a7 100644
--- a/src/main/ipc/parcel-watcher-process.test.ts
+++ b/src/main/ipc/parcel-watcher-process.test.ts
@@ -4,19 +4,37 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const { forkMock, existsSyncMock, parcelSubscribeMock } = vi.hoisted(() => ({
+const {
+ forkMock,
+ existsSyncMock,
+ mkdtempSyncMock,
+ parcelSubscribeMock,
+ rmSyncMock,
+ writeFileSyncMock
+} = vi.hoisted(() => ({
forkMock: vi.fn(),
existsSyncMock: vi.fn(),
- parcelSubscribeMock: vi.fn()
+ mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-canary-supervisor-test'),
+ parcelSubscribeMock: vi.fn(),
+ rmSyncMock: vi.fn(),
+ writeFileSyncMock: vi.fn()
}))
vi.mock('node:child_process', () => ({ fork: forkMock }))
-vi.mock('node:fs', () => ({ existsSync: existsSyncMock }))
+vi.mock('node:fs', () => ({
+ existsSync: existsSyncMock,
+ mkdtempSync: mkdtempSyncMock,
+ rmSync: rmSyncMock,
+ writeFileSync: writeFileSyncMock
+}))
vi.mock('@parcel/watcher', () => ({ subscribe: parcelSubscribeMock }))
import {
+ createWatcherProcessSupervisor,
disposeWatcherProcess,
+ resetRuntimeWatcherProcessForTest,
resetWatcherProcessForTest,
+ subscribeViaRuntimeWatcherProcess,
subscribeViaWatcherProcess
} from './parcel-watcher-process'
@@ -24,6 +42,7 @@ type SentMessage = { op: string; id: number; dir?: string }
class FakeChild extends EventEmitter {
connected = true
+ pid = 1234
sent: SentMessage[] = []
stderr = new EventEmitter()
kill = vi.fn(() => {
@@ -55,6 +74,7 @@ function ackSubscribe(child: FakeChild, index = -1): number {
describe('subscribeViaWatcherProcess', () => {
beforeEach(() => {
resetWatcherProcessForTest()
+ resetRuntimeWatcherProcessForTest()
// Why: the client deliberately runs in-process under vitest; these tests
// exercise the forked-process mode, so hide the vitest marker.
vi.stubEnv('VITEST', '')
@@ -64,6 +84,7 @@ describe('subscribeViaWatcherProcess', () => {
afterEach(() => {
disposeWatcherProcess()
+ resetRuntimeWatcherProcessForTest()
vi.unstubAllEnvs()
vi.clearAllMocks()
})
@@ -81,6 +102,19 @@ describe('subscribeViaWatcherProcess', () => {
expect(callback).toHaveBeenCalledWith(null, events)
})
+ it('creates the fault-harness pid file without clobbering an existing path', async () => {
+ vi.stubEnv('ORCA_WATCHER_CHILD_PID_FILE', '/tmp/orca-watcher.pid')
+
+ const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {})
+ const child = currentChild()
+
+ expect(writeFileSyncMock).toHaveBeenCalledWith('/tmp/orca-watcher.pid', '1234', {
+ flag: 'wx'
+ })
+ ackSubscribe(child)
+ await promise
+ })
+
it('forwards watcher errors to the callback', async () => {
const callback = vi.fn()
const promise = subscribeViaWatcherProcess('/repo', callback, {})
@@ -92,6 +126,42 @@ describe('subscribeViaWatcherProcess', () => {
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' }), [])
})
+ it('reports a terminal resubscribe failure separately from recoverable watch errors', async () => {
+ const callback = vi.fn()
+ const onTerminalError = vi.fn()
+ const promise = subscribeViaWatcherProcess('/repo', callback, {}, { onTerminalError })
+ const child = currentChild()
+ const id = ackSubscribe(child)
+ await promise
+
+ child.emit('message', { op: 'subscribe-failed', id, message: 'root unavailable' })
+
+ expect(onTerminalError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'root unavailable' })
+ )
+ expect(callback).not.toHaveBeenCalled()
+ })
+
+ it('passes bounded event-delivery options to the watcher child', async () => {
+ const promise = subscribeViaWatcherProcess(
+ '/repo',
+ vi.fn(),
+ {},
+ {
+ delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 200 }
+ }
+ )
+ const child = currentChild()
+
+ expect(child.sent[0]).toMatchObject({
+ op: 'subscribe',
+ dir: '/repo',
+ delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 200 }
+ })
+ ackSubscribe(child)
+ await promise
+ })
+
it('rejects the subscribe when the watcher process reports failure', async () => {
const promise = subscribeViaWatcherProcess('/gone', vi.fn(), {})
const child = currentChild()
@@ -135,7 +205,7 @@ describe('subscribeViaWatcherProcess', () => {
it('respawns after a crash, resubscribes, and reports the interruption', async () => {
const callback = vi.fn()
const onInterruption = vi.fn()
- const promise = subscribeViaWatcherProcess('/repo', callback, {}, onInterruption)
+ const promise = subscribeViaWatcherProcess('/repo', callback, {}, { onInterruption })
const first = currentChild()
const id = ackSubscribe(first)
await promise
@@ -157,8 +227,71 @@ describe('subscribeViaWatcherProcess', () => {
expect(callback).toHaveBeenCalledWith(null, events)
})
+ it('recovers existing records when IPC disconnects before child exit', async () => {
+ const firstPromise = subscribeViaWatcherProcess('/existing', vi.fn(), {})
+ const first = currentChild()
+ ackSubscribe(first)
+ await firstPromise
+
+ first.connected = false
+ first.emit('disconnect')
+ const replacement = currentChild()
+ const secondPromise = subscribeViaWatcherProcess('/new', vi.fn(), {})
+
+ expect(replacement).not.toBe(first)
+ expect(replacement.sent.filter((message) => message.op === 'subscribe')).toEqual([
+ expect.objectContaining({ dir: '/existing' }),
+ expect.objectContaining({ dir: '/new' })
+ ])
+ ackSubscribe(replacement, 0)
+ ackSubscribe(replacement, 1)
+ await secondPromise
+ })
+
+ it('spreads runtime roots across healthy children and contains a shard crash', async () => {
+ const firstInterruption = vi.fn()
+ const secondInterruption = vi.fn()
+ const firstPromise = subscribeViaRuntimeWatcherProcess(
+ '/repo-a',
+ vi.fn(),
+ {},
+ {
+ onInterruption: firstInterruption
+ }
+ )
+ const firstChild = currentChild()
+ ackSubscribe(firstChild)
+ await firstPromise
+
+ const secondPromise = subscribeViaRuntimeWatcherProcess(
+ '/repo-b',
+ vi.fn(),
+ {},
+ {
+ onInterruption: secondInterruption
+ }
+ )
+ const secondChild = currentChild()
+ expect(secondChild).not.toBe(firstChild)
+ ackSubscribe(secondChild)
+ await secondPromise
+ expect(forkMock).toHaveBeenCalledTimes(2)
+
+ firstChild.connected = false
+ firstChild.emit('exit', null, 'SIGSEGV')
+
+ const replacement = currentChild()
+ expect(replacement).not.toBe(firstChild)
+ expect(replacement.sent.filter((message) => message.op === 'subscribe')).toHaveLength(1)
+ ackSubscribe(replacement)
+ expect(firstInterruption).toHaveBeenCalledTimes(1)
+ expect(secondInterruption).not.toHaveBeenCalled()
+ expect(secondChild.connected).toBe(true)
+ })
+
it('completes an in-flight subscribe across a crash-respawn', async () => {
- const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {})
+ const onInterruption = vi.fn()
+ const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}, { onInterruption })
const first = currentChild()
expect(first.sent[0].op).toBe('subscribe')
@@ -168,6 +301,190 @@ describe('subscribeViaWatcherProcess', () => {
const second = currentChild()
ackSubscribe(second)
await expect(promise).resolves.toMatchObject({ unsubscribe: expect.any(Function) })
+ expect(onInterruption).toHaveBeenCalledTimes(1)
+ })
+
+ it('cancels a queued crawl without restarting healthy roots', async () => {
+ const healthyInterruption = vi.fn()
+ const healthyPromise = subscribeViaWatcherProcess(
+ '/healthy',
+ vi.fn(),
+ {},
+ {
+ onInterruption: healthyInterruption
+ }
+ )
+ const child = currentChild()
+ ackSubscribe(child)
+ await healthyPromise
+
+ const controller = new AbortController()
+ const pending = subscribeViaWatcherProcess(
+ '/queued',
+ vi.fn(),
+ {},
+ {
+ signal: controller.signal
+ }
+ )
+ const queuedId = child.sent.at(-1)?.id
+ controller.abort()
+
+ await expect(pending).rejects.toThrow('aborted')
+ expect(child.kill).not.toHaveBeenCalled()
+ expect(child.sent.at(-1)).toEqual({ op: 'cancel-subscribe', id: queuedId })
+ expect(healthyInterruption).not.toHaveBeenCalled()
+ })
+
+ it('cancels an active crawl and restores healthy roots when its owner aborts', async () => {
+ const healthyInterruption = vi.fn()
+ const healthyPromise = subscribeViaWatcherProcess(
+ '/healthy',
+ vi.fn(),
+ {},
+ {
+ onInterruption: healthyInterruption
+ }
+ )
+ const first = currentChild()
+ ackSubscribe(first)
+ await healthyPromise
+
+ const controller = new AbortController()
+ const pending = subscribeViaWatcherProcess('/slow', vi.fn(), {}, { signal: controller.signal })
+ first.emit('message', { op: 'subscribe-started', id: first.sent.at(-1)?.id })
+ controller.abort()
+
+ await expect(pending).rejects.toThrow('aborted')
+ expect(first.kill).toHaveBeenCalledTimes(1)
+ const replacement = currentChild()
+ expect(replacement).not.toBe(first)
+ expect(replacement.sent.filter((message) => message.op === 'subscribe')).toEqual([
+ expect.objectContaining({ dir: '/healthy' })
+ ])
+ ackSubscribe(replacement)
+ expect(healthyInterruption).toHaveBeenCalledTimes(1)
+ })
+
+ it('bounds a pending crawl and restores healthy roots after setup timeout', async () => {
+ vi.useFakeTimers()
+ try {
+ const healthyInterruption = vi.fn()
+ const healthyPromise = subscribeViaWatcherProcess(
+ '/healthy',
+ vi.fn(),
+ {},
+ {
+ onInterruption: healthyInterruption
+ }
+ )
+ const first = currentChild()
+ ackSubscribe(first)
+ await healthyPromise
+
+ const pending = subscribeViaWatcherProcess('/slow', vi.fn(), {}, { subscribeTimeoutMs: 100 })
+ first.emit('message', { op: 'subscribe-started', id: first.sent.at(-1)?.id })
+ const timedOut = expect(pending).rejects.toThrow('timed out')
+ await vi.advanceTimersByTimeAsync(100)
+
+ await timedOut
+ const replacement = currentChild()
+ expect(replacement).not.toBe(first)
+ ackSubscribe(replacement)
+ expect(healthyInterruption).toHaveBeenCalledTimes(1)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('ends a root whose crash resubscription crawl exceeds its deadline', async () => {
+ vi.useFakeTimers()
+ try {
+ const supervisor = createWatcherProcessSupervisor()
+ const onTerminalError = vi.fn()
+ const initial = supervisor.subscribe(
+ '/slow-recovery',
+ vi.fn(),
+ {},
+ {
+ onTerminalError,
+ subscribeTimeoutMs: 100
+ }
+ )
+ const first = currentChild()
+ ackSubscribe(first)
+ await initial
+
+ first.connected = false
+ first.emit('exit', null, 'SIGSEGV')
+ const replacement = currentChild()
+ replacement.emit('message', {
+ op: 'subscribe-started',
+ id: replacement.sent[0].id
+ })
+
+ await vi.advanceTimersByTimeAsync(100)
+
+ expect(onTerminalError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ code: 'subscribe_timeout',
+ message: 'file watcher resubscription timed out after 100ms'
+ })
+ )
+ expect(replacement.kill).toHaveBeenCalledTimes(1)
+ expect(forkMock).toHaveBeenCalledTimes(2)
+ supervisor.dispose()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('starts the setup timeout only when the child begins that crawl', async () => {
+ vi.useFakeTimers()
+ try {
+ const pending = subscribeViaWatcherProcess(
+ '/queued',
+ vi.fn(),
+ {},
+ {
+ subscribeTimeoutMs: 100
+ }
+ )
+ const child = currentChild()
+ let settled = false
+ void pending.then(
+ () => {
+ settled = true
+ },
+ () => {
+ settled = true
+ }
+ )
+
+ await vi.advanceTimersByTimeAsync(100)
+ expect(settled).toBe(false)
+ expect(child.kill).not.toHaveBeenCalled()
+
+ child.emit('message', { op: 'subscribe-started', id: child.sent.at(-1)?.id })
+ const timedOut = expect(pending).rejects.toThrow('timed out')
+ await vi.advanceTimersByTimeAsync(100)
+ await timedOut
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('rejects an in-flight and later subscribe when the supervisor is disposed', async () => {
+ const supervisor = createWatcherProcessSupervisor()
+ const pending = supervisor.subscribe('/repo', vi.fn(), {})
+ const child = currentChild()
+
+ supervisor.dispose()
+
+ await expect(pending).rejects.toThrow('supervisor disposed')
+ await expect(supervisor.subscribe('/later', vi.fn(), {})).rejects.toThrow('supervisor disposed')
+ expect(child.kill).toHaveBeenCalledTimes(1)
+ expect(forkMock).toHaveBeenCalledTimes(1)
})
it('disables watching after repeated crashes instead of fork-bombing', async () => {
@@ -203,6 +520,10 @@ describe('subscribeViaWatcherProcess', () => {
await expect(subscription.unsubscribe()).resolves.toBeUndefined()
expect(first.kill).toHaveBeenCalledTimes(1)
+ expect(rmSyncMock).toHaveBeenCalledWith('/tmp/orca-watcher-canary-supervisor-test', {
+ recursive: true,
+ force: true
+ })
// Why: the deliberate idle kill must not count as a crash — no respawn
// and no crash-fuse advance when the killed child's exit event lands.
@@ -279,8 +600,101 @@ describe('subscribeViaWatcherProcess', () => {
ignore: ['**/.git']
})
expect(forkMock).not.toHaveBeenCalled()
- expect(parcelSubscribeMock).toHaveBeenCalledWith('/repo', callback, { ignore: ['**/.git'] })
+ expect(parcelSubscribeMock).toHaveBeenCalledWith('/repo', expect.any(Function), {
+ ignore: ['**/.git']
+ })
await subscription.unsubscribe()
expect(unsubscribe).toHaveBeenCalled()
})
+
+ it('rejects a pre-aborted in-process subscribe before loading Parcel', async () => {
+ vi.stubEnv('VITEST', 'true')
+ const controller = new AbortController()
+ controller.abort()
+
+ await expect(
+ subscribeViaWatcherProcess('/repo', vi.fn(), {}, { signal: controller.signal })
+ ).rejects.toThrow('aborted')
+ expect(parcelSubscribeMock).not.toHaveBeenCalled()
+ })
+
+ it('releases a late in-process subscription after pending setup is aborted', async () => {
+ vi.stubEnv('VITEST', 'true')
+ const unsubscribe = vi.fn().mockResolvedValue(undefined)
+ let resolveSubscription!: (subscription: { unsubscribe: typeof unsubscribe }) => void
+ parcelSubscribeMock.mockReturnValue(
+ new Promise((resolve) => {
+ resolveSubscription = resolve
+ })
+ )
+ const controller = new AbortController()
+ const pending = subscribeViaWatcherProcess('/repo', vi.fn(), {}, { signal: controller.signal })
+
+ await vi.waitFor(() => expect(parcelSubscribeMock).toHaveBeenCalledTimes(1))
+ controller.abort()
+ await expect(pending).rejects.toThrow('aborted')
+ resolveSubscription({ unsubscribe })
+
+ await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1))
+ })
+
+ it('fails closed when the isolated watcher entry is absent outside tests', async () => {
+ existsSyncMock.mockReturnValue(false)
+
+ await expect(subscribeViaWatcherProcess('/repo', vi.fn(), {})).rejects.toThrow(
+ 'watcher process entry is missing'
+ )
+ expect(forkMock).not.toHaveBeenCalled()
+ expect(parcelSubscribeMock).not.toHaveBeenCalled()
+ })
+
+ it('starts without a canary when the diagnostic temp directory is unavailable', async () => {
+ mkdtempSyncMock.mockImplementationOnce(() => {
+ throw new Error('read-only temp directory')
+ })
+
+ const pending = subscribeViaWatcherProcess('/repo', vi.fn(), {})
+ const child = currentChild()
+ const forkOptions = forkMock.mock.calls.at(-1)?.[2] as { env?: NodeJS.ProcessEnv } | undefined
+ expect(forkOptions?.env?.ORCA_WATCHER_CANARY_DIR).toBeUndefined()
+ ackSubscribe(child)
+ await expect(pending).resolves.toMatchObject({ unsubscribe: expect.any(Function) })
+ })
+
+ it('keeps independently-created supervisors in separate crash-fuse domains', async () => {
+ const firstSupervisor = createWatcherProcessSupervisor()
+ const secondSupervisor = createWatcherProcessSupervisor()
+ const firstCallback = vi.fn()
+ const secondCallback = vi.fn()
+
+ const firstPromise = firstSupervisor.subscribe('/faulting', firstCallback, {})
+ const firstChild = currentChild()
+ ackSubscribe(firstChild)
+ await firstPromise
+
+ const secondPromise = secondSupervisor.subscribe('/healthy', secondCallback, {})
+ const secondChild = currentChild()
+ ackSubscribe(secondChild)
+ await secondPromise
+
+ let faultingChild = firstChild
+ for (let crash = 0; crash < 2; crash++) {
+ faultingChild.connected = false
+ faultingChild.emit('exit', null, 'SIGSEGV')
+ faultingChild = currentChild()
+ ackSubscribe(faultingChild)
+ }
+ faultingChild.connected = false
+ faultingChild.emit('exit', null, 'SIGSEGV')
+
+ expect(firstCallback).toHaveBeenCalledWith(
+ expect.objectContaining({ message: expect.stringContaining('crashed repeatedly') }),
+ []
+ )
+ expect(secondCallback).not.toHaveBeenCalled()
+ expect(secondChild.connected).toBe(true)
+
+ firstSupervisor.dispose()
+ secondSupervisor.dispose()
+ })
})
diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts
index 72e2ccf58ce..2d6b45ee97f 100644
--- a/src/main/ipc/parcel-watcher-process.ts
+++ b/src/main/ipc/parcel-watcher-process.ts
@@ -1,329 +1,75 @@
-// Host side of the out-of-process @parcel/watcher (see
-// parcel-watcher-process-entry.ts). Why: native watcher teardown races
-// fail-fast the hosting process (issue #7547), so local subscriptions live in
-// a disposable forked child; when it crashes the host respawns it and
-// resubscribes every live root, and callers are told via onInterruption so
-// they can refresh state that changed during the gap.
-import { fork, type ChildProcess } from 'node:child_process'
-import { existsSync } from 'node:fs'
-import type * as ParcelWatcher from '@parcel/watcher'
-import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path'
-import type {
- HostToWatcherMessage,
+// Host API for crash-isolated @parcel/watcher children. Desktop uses one
+// supervisor; runtime roots use a bounded pool with independent crash fuses.
+import {
+ WatcherProcessSupervisor,
+ type WatcherProcessCallback,
+ type WatcherProcessHooks,
+ type WatcherProcessSubscription
+} from './parcel-watcher-process-supervisor'
+import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol'
+import { RuntimeWatcherProcessPool } from './runtime-watcher-process-pool'
+
+export type {
+ WatcherProcessCallback,
+ WatcherProcessHooks,
+ WatcherProcessSubscription
+} from './parcel-watcher-process-supervisor'
+export type {
+ WatcherProcessDeliveryOptions,
WatcherProcessEvent,
- WatcherProcessSubscribeOptions,
- WatcherToHostMessage
-} from './parcel-watcher-process-entry'
+ WatcherProcessSubscribeOptions
+} from './parcel-watcher-process-protocol'
-export type { WatcherProcessEvent, WatcherProcessSubscribeOptions }
+const sharedWatcherProcessSupervisor = new WatcherProcessSupervisor()
+// Why: healthy and quarantine pools each cap at four children, containing a
+// failed shard without scaling process count with its number of roots.
+const runtimeWatcherProcessPool = new RuntimeWatcherProcessPool()
-export type WatcherProcessCallback = (err: Error | null, events: WatcherProcessEvent[]) => void
-export type WatcherProcessSubscription = { unsubscribe(): Promise }
-
-// Why: a crash loop (e.g. a root that faults the native watcher on every
-// resubscribe) must degrade to "file watching disabled" rather than fork-bomb.
-const CRASH_WINDOW_MS = 30_000
-const MAX_CRASHES_PER_WINDOW = 3
-
-type SubscriptionRecord = {
- id: number
- dir: string
- opts: WatcherProcessSubscribeOptions
- callback: WatcherProcessCallback
- onInterruption?: () => void
- pendingSubscribe?: { resolve: () => void; reject: (err: Error) => void }
+export function createWatcherProcessSupervisor(): WatcherProcessSupervisor {
+ return new WatcherProcessSupervisor()
}
-let child: ChildProcess | null = null
-let nextSubscriptionId = 1
-let crashTimes: number[] = []
-let shutdownRequested = false
-let loggedInProcessFallback = false
-const records = new Map()
-const pendingUnsubscribes = new Map void>()
-
-function shouldRunInProcess(entryPath: string): boolean {
- // Why: vitest suites mock '@parcel/watcher' at the module level; a forked
- // child would bypass those mocks (and could execute a stale build output),
- // so tests keep the historical in-process path.
- if (process.env.VITEST) {
- return true
- }
- if (existsSync(entryPath)) {
- return false
- }
- if (!loggedInProcessFallback) {
- loggedInProcessFallback = true
- console.warn(
- `[parcel-watcher-process] entry not found at ${entryPath}; using in-process watcher (no crash isolation)`
- )
- }
- return true
-}
-
-async function subscribeInProcess(
- dir: string,
- callback: WatcherProcessCallback,
- opts: WatcherProcessSubscribeOptions
-): Promise {
- const watcher = await import('@parcel/watcher')
- const subscription = await watcher.subscribe(
- dir,
- callback as ParcelWatcher.SubscribeCallback,
- opts as ParcelWatcher.Options
- )
- return { unsubscribe: () => subscription.unsubscribe() }
-}
-
-function inCrashCooldown(): boolean {
- const now = Date.now()
- crashTimes = crashTimes.filter((time) => now - time < CRASH_WINDOW_MS)
- return crashTimes.length >= MAX_CRASHES_PER_WINDOW
-}
-
-function sendToChild(proc: ChildProcess, message: HostToWatcherMessage): void {
- try {
- proc.send(message)
- } catch {
- // Channel already closed — the child's exit handler recovers.
- }
-}
-
-function ensureWatcherProcess(): ChildProcess | null {
- if (shutdownRequested) {
- return null
- }
- if (child?.connected) {
- return child
- }
- if (inCrashCooldown()) {
- return null
- }
- let proc: ChildProcess
- try {
- proc = fork(getWatcherProcessEntryPath(), [], {
- stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
- env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
- ...(process.platform === 'win32' ? { windowsHide: true } : {})
- })
- } catch (err) {
- console.error('[parcel-watcher-process] failed to fork watcher process:', err)
- return null
- }
- proc.stderr?.on('data', (chunk: Buffer) => {
- console.error('[parcel-watcher-process]', String(chunk).trimEnd())
- })
- proc.on('message', (message) => {
- if (child === proc) {
- handleChildMessage(message as WatcherToHostMessage)
- }
- })
- proc.on('error', () => handleChildGone(proc))
- proc.on('exit', (code, signal) => {
- if (child === proc && (code !== 0 || signal)) {
- console.error(
- `[parcel-watcher-process] watcher process exited (code=${code}, signal=${signal})`
- )
- }
- handleChildGone(proc)
- })
- child = proc
- return proc
-}
-
-function handleChildMessage(message: WatcherToHostMessage): void {
- if (!message || typeof message !== 'object') {
- return
- }
- if (message.op === 'unsubscribed') {
- const resolve = pendingUnsubscribes.get(message.id)
- pendingUnsubscribes.delete(message.id)
- resolve?.()
- return
- }
- const record = records.get(message.id)
- if (!record) {
- return
- }
- if (message.op === 'subscribed') {
- const pending = record.pendingSubscribe
- record.pendingSubscribe = undefined
- if (pending) {
- pending.resolve()
- } else {
- // Reached after a crash-respawn resubscribe: events emitted while the
- // watcher process was down are lost, so let the caller refresh.
- record.onInterruption?.()
- }
- return
- }
- if (message.op === 'subscribe-failed') {
- records.delete(message.id)
- const pending = record.pendingSubscribe
- record.pendingSubscribe = undefined
- if (pending) {
- pending.reject(new Error(message.message))
- } else {
- record.callback(new Error(message.message), [])
- }
- // Why: a failed last subscribe empties the records map without any
- // unsubscribe ever being called — tear down the idle child here too.
- killWatcherChildIfIdle()
- return
- }
- if (message.op === 'events') {
- record.callback(null, message.events)
- return
- }
- if (message.op === 'watch-error') {
- record.callback(new Error(message.message), [])
- }
-}
-
-function failAllSubscriptions(err: Error): void {
- for (const record of records.values()) {
- const pending = record.pendingSubscribe
- record.pendingSubscribe = undefined
- if (pending) {
- pending.reject(err)
- } else {
- record.callback(err, [])
- }
- }
- records.clear()
-}
-
-function handleChildGone(proc: ChildProcess): void {
- if (child !== proc) {
- return
- }
- child = null
- // Why: process death released every native handle, so a pending unsubscribe
- // is complete by definition — resolve rather than hang worktree deletion.
- for (const resolve of pendingUnsubscribes.values()) {
- resolve()
- }
- pendingUnsubscribes.clear()
- if (shutdownRequested || records.size === 0) {
- return
- }
- crashTimes.push(Date.now())
- const replacement = ensureWatcherProcess()
- if (!replacement) {
- console.error(
- '[parcel-watcher-process] watcher process crashed repeatedly; disabling file watching'
- )
- failAllSubscriptions(new Error('file watcher process crashed repeatedly'))
- return
- }
- console.error(
- `[parcel-watcher-process] watcher process crashed; resubscribing ${records.size} root(s)`
- )
- for (const record of records.values()) {
- sendToChild(replacement, {
- op: 'subscribe',
- id: record.id,
- dir: record.dir,
- opts: record.opts
- })
- }
-}
-
-// Why: the last record gone leaves the child idle until app shutdown. Killing
-// it releases native handles without running watcher.node's crash-prone async
-// teardown (same rationale as disposeWatcherProcess) and reclaims the process;
-// the next subscribe forks a fresh child. `child = null` before kill so the
-// exit event neither counts as a crash nor respawns. Process death also
-// completes any still-pending unsubscribe acks.
-function killWatcherChildIfIdle(): void {
- const proc = child
- if (!proc || records.size > 0) {
- return
- }
- child = null
- for (const resolve of pendingUnsubscribes.values()) {
- resolve()
- }
- pendingUnsubscribes.clear()
- proc.kill()
-}
-
-function makeSubscription(record: SubscriptionRecord): WatcherProcessSubscription {
- return {
- unsubscribe: (): Promise => {
- if (!records.delete(record.id)) {
- return Promise.resolve()
- }
- const proc = child
- if (!proc?.connected) {
- return Promise.resolve()
- }
- if (records.size === 0) {
- killWatcherChildIfIdle()
- return Promise.resolve()
- }
- return new Promise((resolve) => {
- pendingUnsubscribes.set(record.id, resolve)
- sendToChild(proc, { op: 'unsubscribe', id: record.id })
- })
- }
- }
-}
-
-/** Subscribe to a directory tree via the isolated watcher process. Falls back
- * to an in-process @parcel/watcher subscription when the forked entry is not
- * available (tests, unbuilt trees). `onInterruption` fires after the watcher
- * process crashed and this subscription was transparently re-established —
- * events in the gap were lost, so callers should refresh. */
export function subscribeViaWatcherProcess(
dir: string,
callback: WatcherProcessCallback,
opts: WatcherProcessSubscribeOptions,
- onInterruption?: () => void
+ hooks: WatcherProcessHooks = {}
): Promise {
- if (shouldRunInProcess(getWatcherProcessEntryPath())) {
- return subscribeInProcess(dir, callback, opts)
- }
- const record: SubscriptionRecord = {
- id: nextSubscriptionId++,
- dir,
- opts,
- callback,
- onInterruption
- }
- return new Promise((resolve, reject) => {
- const proc = ensureWatcherProcess()
- if (!proc) {
- reject(new Error('file watcher process unavailable'))
- return
- }
- records.set(record.id, record)
- record.pendingSubscribe = {
- resolve: () => resolve(makeSubscription(record)),
- reject
- }
- sendToChild(proc, { op: 'subscribe', id: record.id, dir, opts })
- })
+ return sharedWatcherProcessSupervisor.subscribe(dir, callback, opts, hooks)
}
-/** Kill the watcher process at app shutdown. Process death releases native
- * handles without running watcher.node's crash-prone async teardown. */
+export function subscribeViaRuntimeWatcherProcess(
+ dir: string,
+ callback: WatcherProcessCallback,
+ opts: WatcherProcessSubscribeOptions,
+ hooks: WatcherProcessHooks = {}
+): Promise {
+ return runtimeWatcherProcessPool.subscribe(dir, callback, opts, hooks)
+}
+
+/** Kill shared desktop and runtime watcher children at app/runtime shutdown. */
export function disposeWatcherProcess(): void {
- shutdownRequested = true
- const proc = child
- child = null
- for (const resolve of pendingUnsubscribes.values()) {
- resolve()
+ // Why: Vitest reuses this module singleton after closeAllWatchers(), while
+ // production shutdown must remain final and reject any later subscription.
+ if (process.env.VITEST) {
+ sharedWatcherProcessSupervisor.resetForTest()
+ runtimeWatcherProcessPool.resetForTest()
+ } else {
+ sharedWatcherProcessSupervisor.dispose()
+ // Why: the runtime pool owns independent children that otherwise outlive a
+ // shutdown sequence that does not immediately exit the main process.
+ runtimeWatcherProcessPool.dispose()
}
- pendingUnsubscribes.clear()
- records.clear()
- proc?.kill()
}
export function resetWatcherProcessForTest(): void {
- disposeWatcherProcess()
- shutdownRequested = false
- crashTimes = []
- loggedInProcessFallback = false
- nextSubscriptionId = 1
+ sharedWatcherProcessSupervisor.resetForTest()
+}
+
+export function resetRuntimeWatcherProcessForTest(): void {
+ runtimeWatcherProcessPool.resetForTest()
+}
+
+export function forgetRuntimeWatcherProcessRoot(rootPath: string): void {
+ runtimeWatcherProcessPool.forgetRoot(rootPath)
}
diff --git a/src/main/ipc/parcel-watcher-supervisor-subscribe.ts b/src/main/ipc/parcel-watcher-supervisor-subscribe.ts
new file mode 100644
index 00000000000..abfa9ffe540
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-supervisor-subscribe.ts
@@ -0,0 +1,129 @@
+import type { ChildProcess } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { createHostWatcherSubscription } from './parcel-watcher-host-subscriptions'
+import { subscribeWithInProcessWatcher } from './parcel-watcher-in-process-fallback'
+import { installPendingSubscribeControls } from './parcel-watcher-pending-subscribe'
+import { WatcherProcessFailure } from './parcel-watcher-process-failure'
+import type {
+ HostToWatcherMessage,
+ WatcherProcessSubscribeOptions
+} from './parcel-watcher-process-protocol'
+import type {
+ WatcherProcessCallback,
+ WatcherProcessHooks,
+ WatcherProcessSubscription,
+ WatcherProcessSubscriptionRecord
+} from './parcel-watcher-process-subscription'
+
+type WatcherSupervisorSubscribeOptions = {
+ dir: string
+ callback: WatcherProcessCallback
+ opts: WatcherProcessSubscribeOptions
+ hooks: WatcherProcessHooks
+ shutdownRequested: boolean
+ entryPath: string
+ useInProcessVitestFallback: boolean
+ allocateId: () => number
+ records: Map
+ pendingUnsubscribes: Map void>
+ ensureWatcherProcess: (entryPath: string) => ChildProcess | null
+ getChild: () => ChildProcess | null
+ killWatcherChildIfIdle: () => void
+ sendSubscribe: (child: ChildProcess, record: WatcherProcessSubscriptionRecord) => void
+ sendToChild: (child: ChildProcess, message: HostToWatcherMessage) => void
+ cancelPendingSubscribe: (
+ record: WatcherProcessSubscriptionRecord,
+ error: WatcherProcessFailure
+ ) => void
+}
+
+export function subscribeThroughWatcherSupervisor({
+ dir,
+ callback,
+ opts,
+ hooks,
+ shutdownRequested,
+ entryPath,
+ useInProcessVitestFallback,
+ allocateId,
+ records,
+ pendingUnsubscribes,
+ ensureWatcherProcess,
+ getChild,
+ killWatcherChildIfIdle,
+ sendSubscribe,
+ sendToChild,
+ cancelPendingSubscribe
+}: WatcherSupervisorSubscribeOptions): Promise {
+ if (shutdownRequested) {
+ return Promise.reject(
+ new WatcherProcessFailure(
+ 'file watcher supervisor disposed',
+ 'supervisor',
+ 'supervisor_disposed'
+ )
+ )
+ }
+ if (hooks.signal?.aborted) {
+ return Promise.reject(
+ new WatcherProcessFailure(
+ 'file watcher subscription aborted',
+ 'subscription',
+ 'subscribe_aborted'
+ )
+ )
+ }
+ // Why: under Vitest we cannot fork a real watcher child, so exercise the
+ // subscription path in-process (against mocked @parcel/watcher) instead.
+ if (process.env.VITEST && useInProcessVitestFallback) {
+ return subscribeWithInProcessWatcher(dir, callback, opts, hooks)
+ }
+ if (!existsSync(entryPath)) {
+ return Promise.reject(
+ new WatcherProcessFailure(
+ `watcher process entry is missing: ${entryPath}`,
+ 'supervisor',
+ 'entry_missing'
+ )
+ )
+ }
+ const record: WatcherProcessSubscriptionRecord = {
+ id: allocateId(),
+ dir,
+ opts,
+ callback,
+ hooks,
+ interrupted: false,
+ crawlStarted: false
+ }
+ return new Promise((resolve, reject) => {
+ const child = ensureWatcherProcess(entryPath)
+ if (!child) {
+ reject(
+ new WatcherProcessFailure(
+ 'file watcher process unavailable',
+ 'supervisor',
+ 'process_unavailable'
+ )
+ )
+ return
+ }
+ records.set(record.id, record)
+ record.pendingSubscribe = {
+ resolve: () =>
+ resolve(
+ createHostWatcherSubscription({
+ record,
+ records,
+ pendingUnsubscribes,
+ getChild,
+ killWatcherChildIfIdle,
+ sendToChild
+ })
+ ),
+ reject
+ }
+ installPendingSubscribeControls(record, (error) => cancelPendingSubscribe(record, error))
+ sendSubscribe(child, record)
+ })
+}
diff --git a/src/main/ipc/runtime-watcher-process-pool.test.ts b/src/main/ipc/runtime-watcher-process-pool.test.ts
new file mode 100644
index 00000000000..e43112e4ef4
--- /dev/null
+++ b/src/main/ipc/runtime-watcher-process-pool.test.ts
@@ -0,0 +1,273 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { WatcherProcessFailure } from './parcel-watcher-process-failure'
+import type {
+ WatcherProcessCallback,
+ WatcherProcessHooks,
+ WatcherProcessSubscription
+} from './parcel-watcher-process-subscription'
+import { RuntimeWatcherProcessPool } from './runtime-watcher-process-pool'
+
+type InstalledSubscription = {
+ dir: string
+ hooks: WatcherProcessHooks
+ unsubscribe: ReturnType Promise>>
+}
+
+class FakeSupervisor {
+ readonly subscriptions: InstalledSubscription[] = []
+ readonly dispose = vi.fn()
+ subscribeError?: Error
+
+ async subscribe(
+ dir: string,
+ _callback: WatcherProcessCallback,
+ _opts: object,
+ hooks: WatcherProcessHooks
+ ): Promise {
+ if (this.subscribeError) {
+ throw this.subscribeError
+ }
+ const unsubscribe = vi.fn(async () => undefined)
+ this.subscriptions.push({ dir, hooks, unsubscribe })
+ return { unsubscribe }
+ }
+}
+
+describe('RuntimeWatcherProcessPool', () => {
+ let supervisors: FakeSupervisor[]
+ let pool: RuntimeWatcherProcessPool
+
+ beforeEach(() => {
+ supervisors = []
+ pool = new RuntimeWatcherProcessPool({
+ maxSharedSupervisors: 4,
+ createSupervisor: () => {
+ const supervisor = new FakeSupervisor()
+ supervisors.push(supervisor)
+ return supervisor
+ }
+ })
+ })
+
+ it('spreads roots across a bounded healthy pool before sharing children', async () => {
+ for (const dir of ['/a', '/b', '/c', '/d', '/e']) {
+ await pool.subscribe(dir, vi.fn(), {}, {})
+ }
+
+ expect(supervisors).toHaveLength(4)
+ expect(supervisors.map((supervisor) => supervisor.subscriptions.map(({ dir }) => dir))).toEqual(
+ [['/a', '/e'], ['/b'], ['/c'], ['/d']]
+ )
+ })
+
+ it('keeps a newer same-root lease assigned while an older lease tears down', async () => {
+ const first = await pool.subscribe('/same', vi.fn(), {}, {})
+ await pool.subscribe('/same', vi.fn(), {}, {})
+
+ await first.unsubscribe()
+ await pool.subscribe('/same', vi.fn(), {}, {})
+
+ expect(supervisors).toHaveLength(1)
+ expect(supervisors[0].subscriptions.map(({ dir }) => dir)).toEqual(['/same', '/same', '/same'])
+ })
+
+ it('isolates every root from a failed shard when callers recover', async () => {
+ pool = new RuntimeWatcherProcessPool({
+ maxSharedSupervisors: 1,
+ createSupervisor: () => {
+ const supervisor = new FakeSupervisor()
+ supervisors.push(supervisor)
+ return supervisor
+ }
+ })
+ const firstError = vi.fn()
+ const secondError = vi.fn()
+ await pool.subscribe('/a', vi.fn(), {}, { onTerminalError: firstError })
+ await pool.subscribe('/b', vi.fn(), {}, { onTerminalError: secondError })
+ const failedShard = supervisors[0]
+ const failure = new WatcherProcessFailure(
+ 'file watcher process crashed repeatedly',
+ 'supervisor',
+ 'supervisor_crash_fuse'
+ )
+
+ for (const installed of failedShard.subscriptions) {
+ installed.hooks.onTerminalError?.(failure)
+ }
+ await pool.subscribe('/a', vi.fn(), {}, {})
+ await pool.subscribe('/b', vi.fn(), {}, {})
+
+ expect(firstError).toHaveBeenCalledWith(failure)
+ expect(secondError).toHaveBeenCalledWith(failure)
+ expect(supervisors).toHaveLength(3)
+ expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/a'])
+ expect(supervisors[2].subscriptions.map(({ dir }) => dir)).toEqual(['/b'])
+ })
+
+ it('bounds quarantine supervisors when a fused shard contains many roots', async () => {
+ pool = new RuntimeWatcherProcessPool({
+ maxSharedSupervisors: 1,
+ createSupervisor: () => {
+ const supervisor = new FakeSupervisor()
+ supervisors.push(supervisor)
+ return supervisor
+ }
+ })
+ const roots = Array.from({ length: 12 }, (_, index) => `/root-${index}`)
+ for (const root of roots) {
+ await pool.subscribe(root, vi.fn(), {}, {})
+ }
+ const failedShard = supervisors[0]
+ const failure = new WatcherProcessFailure(
+ 'file watcher process crashed repeatedly',
+ 'supervisor',
+ 'supervisor_crash_fuse'
+ )
+
+ for (const installed of failedShard.subscriptions) {
+ installed.hooks.onTerminalError?.(failure)
+ }
+ for (const root of roots) {
+ await pool.subscribe(root, vi.fn(), {}, {})
+ }
+
+ expect(supervisors).toHaveLength(5)
+ expect(supervisors.slice(1).map(({ subscriptions }) => subscriptions.length)).toEqual([
+ 3, 3, 3, 3
+ ])
+ })
+
+ it('moves an explicitly timed-out root into quarantine for its retry', async () => {
+ const timeout = new WatcherProcessFailure(
+ 'file watcher subscription timed out',
+ 'subscription',
+ 'subscribe_timeout'
+ )
+ pool = new RuntimeWatcherProcessPool({
+ maxSharedSupervisors: 1,
+ createSupervisor: () => {
+ const supervisor = new FakeSupervisor()
+ if (supervisors.length === 0) {
+ supervisor.subscribeError = timeout
+ }
+ supervisors.push(supervisor)
+ return supervisor
+ }
+ })
+
+ await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout)
+ await expect(pool.subscribe('/slow', vi.fn(), {}, {})).resolves.toBeDefined()
+
+ expect(supervisors).toHaveLength(2)
+ expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow'])
+ })
+
+ it('moves a live root into quarantine when crash resubscription times out', async () => {
+ const timeout = new WatcherProcessFailure(
+ 'file watcher resubscription timed out',
+ 'subscription',
+ 'subscribe_timeout'
+ )
+ const onTerminalError = vi.fn()
+ await pool.subscribe('/slow-recovery', vi.fn(), {}, { onTerminalError })
+
+ supervisors[0].subscriptions[0].hooks.onTerminalError?.(timeout)
+ await pool.subscribe('/slow-recovery', vi.fn(), {}, {})
+
+ expect(onTerminalError).toHaveBeenCalledWith(timeout)
+ expect(supervisors).toHaveLength(2)
+ expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow-recovery'])
+ })
+
+ it('keeps healthy shard assignments after a root-specific failure', async () => {
+ pool = new RuntimeWatcherProcessPool({
+ maxSharedSupervisors: 1,
+ createSupervisor: () => {
+ const supervisor = new FakeSupervisor()
+ supervisors.push(supervisor)
+ return supervisor
+ }
+ })
+ await pool.subscribe('/healthy', vi.fn(), {}, {})
+ const onTerminalError = vi.fn()
+ await pool.subscribe('/gone', vi.fn(), {}, { onTerminalError })
+ const shared = supervisors[0]
+ const failed = shared.subscriptions.find(({ dir }) => dir === '/gone')
+ const failure = new WatcherProcessFailure(
+ 'root unavailable',
+ 'subscription',
+ 'subscribe_failed'
+ )
+
+ failed?.hooks.onTerminalError?.(failure)
+ await pool.subscribe('/gone', vi.fn(), {}, {})
+
+ expect(onTerminalError).toHaveBeenCalledWith(failure)
+ expect(supervisors).toHaveLength(1)
+ expect(shared.subscriptions.map(({ dir }) => dir)).toEqual(['/healthy', '/gone', '/gone'])
+ expect(shared.dispose).not.toHaveBeenCalled()
+ })
+
+ it('stops replacing a root after its bounded quarantine shard also fuses', async () => {
+ pool = new RuntimeWatcherProcessPool({
+ maxSharedSupervisors: 1,
+ createSupervisor: () => {
+ const supervisor = new FakeSupervisor()
+ supervisors.push(supervisor)
+ return supervisor
+ }
+ })
+ const failure = new WatcherProcessFailure(
+ 'file watcher process crashed repeatedly',
+ 'supervisor',
+ 'supervisor_crash_fuse'
+ )
+ await pool.subscribe('/unstable', vi.fn(), {}, {})
+ supervisors[0].subscriptions[0].hooks.onTerminalError?.(failure)
+ await pool.subscribe('/unstable', vi.fn(), {}, {})
+ supervisors[1].subscriptions[0].hooks.onTerminalError?.(failure)
+
+ await expect(pool.subscribe('/unstable', vi.fn(), {}, {})).rejects.toThrow(
+ 'failed again in quarantine'
+ )
+ expect(supervisors).toHaveLength(2)
+ })
+
+ it('clears fused quarantine history when forgetRoot runs without an assignment', async () => {
+ pool = new RuntimeWatcherProcessPool({
+ maxSharedSupervisors: 1,
+ createSupervisor: () => {
+ const supervisor = new FakeSupervisor()
+ supervisors.push(supervisor)
+ return supervisor
+ }
+ })
+ const failure = new WatcherProcessFailure(
+ 'file watcher process crashed repeatedly',
+ 'supervisor',
+ 'supervisor_crash_fuse'
+ )
+ await pool.subscribe('/unstable', vi.fn(), {}, {})
+ supervisors[0].subscriptions[0].hooks.onTerminalError?.(failure)
+ await pool.subscribe('/unstable', vi.fn(), {}, {})
+ supervisors[1].subscriptions[0].hooks.onTerminalError?.(failure)
+
+ await expect(pool.subscribe('/unstable', vi.fn(), {}, {})).rejects.toThrow(
+ 'failed again in quarantine'
+ )
+
+ pool.forgetRoot('/unstable')
+
+ await expect(pool.subscribe('/unstable', vi.fn(), {}, {})).resolves.toBeDefined()
+ expect(supervisors).toHaveLength(3)
+ })
+
+ it('disposes every supervisor and clears isolation on reset', async () => {
+ await pool.subscribe('/a', vi.fn(), {}, {})
+ await pool.subscribe('/b', vi.fn(), {}, {})
+
+ pool.resetForTest()
+
+ expect(supervisors.every((supervisor) => supervisor.dispose.mock.calls.length === 1)).toBe(true)
+ })
+})
diff --git a/src/main/ipc/runtime-watcher-process-pool.ts b/src/main/ipc/runtime-watcher-process-pool.ts
new file mode 100644
index 00000000000..b5a2bf45d44
--- /dev/null
+++ b/src/main/ipc/runtime-watcher-process-pool.ts
@@ -0,0 +1,251 @@
+import { isWatcherProcessFailure, WatcherProcessFailure } from './parcel-watcher-process-failure'
+import { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor'
+import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol'
+import type {
+ WatcherProcessCallback,
+ WatcherProcessHooks,
+ WatcherProcessSubscription
+} from './parcel-watcher-process-subscription'
+
+const DEFAULT_MAX_SHARED_SUPERVISORS = 4
+const DEFAULT_MAX_QUARANTINE_SUPERVISORS = 4
+
+type WatcherSupervisor = Pick
+
+type RuntimeWatcherPoolSlot = {
+ supervisor: WatcherSupervisor
+ roots: Set
+ isolated: boolean
+ retired: boolean
+ disposed: boolean
+}
+
+type RuntimeWatcherPoolAssignment = {
+ slot: RuntimeWatcherPoolSlot
+ leases: number
+}
+
+export type RuntimeWatcherProcessPoolOptions = {
+ maxSharedSupervisors?: number
+ maxQuarantineSupervisors?: number
+ createSupervisor?: () => WatcherSupervisor
+}
+
+/**
+ * Share a bounded set of watcher children while healthy, then move roots from
+ * a failed shard into a separate bounded quarantine pool.
+ */
+export class RuntimeWatcherProcessPool {
+ private readonly maxSharedSupervisors: number
+ private readonly maxQuarantineSupervisors: number
+ private readonly createSupervisor: () => WatcherSupervisor
+ private readonly activeSlots = new Set()
+ private readonly allSlots = new Set()
+ private readonly assignments = new Map()
+ private readonly isolatedRoots = new Set()
+ private readonly failedQuarantineRoots = new Set()
+
+ constructor(options: RuntimeWatcherProcessPoolOptions = {}) {
+ this.maxSharedSupervisors = Math.max(
+ 1,
+ options.maxSharedSupervisors ?? DEFAULT_MAX_SHARED_SUPERVISORS
+ )
+ this.maxQuarantineSupervisors = Math.max(
+ 1,
+ options.maxQuarantineSupervisors ?? DEFAULT_MAX_QUARANTINE_SUPERVISORS
+ )
+ this.createSupervisor = options.createSupervisor ?? (() => new WatcherProcessSupervisor())
+ }
+
+ async subscribe(
+ dir: string,
+ callback: WatcherProcessCallback,
+ opts: WatcherProcessSubscribeOptions,
+ hooks: WatcherProcessHooks = {}
+ ): Promise {
+ const assignment = this.assignmentForRoot(dir)
+ const { slot } = assignment
+ let assignmentReleased = false
+ const releaseAssignment = (): void => {
+ if (assignmentReleased) {
+ return
+ }
+ assignmentReleased = true
+ this.releaseRoot(assignment, dir)
+ }
+ const onTerminalError = (error: Error): void => {
+ if (isWatcherProcessFailure(error) && error.scope === 'supervisor') {
+ this.retireSlot(slot)
+ } else {
+ releaseAssignment()
+ if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') {
+ this.isolatedRoots.add(dir)
+ }
+ }
+ hooks.onTerminalError?.(error)
+ }
+ let subscription: WatcherProcessSubscription
+ try {
+ subscription = await slot.supervisor.subscribe(dir, callback, opts, {
+ ...hooks,
+ onTerminalError
+ })
+ } catch (error) {
+ if (isWatcherProcessFailure(error) && error.scope === 'supervisor') {
+ this.retireSlot(slot)
+ } else {
+ releaseAssignment()
+ if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') {
+ this.isolatedRoots.add(dir)
+ }
+ }
+ throw error
+ }
+
+ let unsubscribePromise: Promise | undefined
+ return {
+ unsubscribe: (): Promise => {
+ unsubscribePromise ??= subscription.unsubscribe().finally(() => {
+ releaseAssignment()
+ })
+ return unsubscribePromise
+ }
+ }
+ }
+
+ /** Kill every pooled watcher child (production shutdown or test reset). */
+ dispose(): void {
+ // disposeSlot deletes the current slot from allSlots; deleting the
+ // in-progress element during Set iteration is safe, so no snapshot needed.
+ for (const slot of this.allSlots) {
+ this.disposeSlot(slot)
+ }
+ this.activeSlots.clear()
+ this.allSlots.clear()
+ this.assignments.clear()
+ this.isolatedRoots.clear()
+ this.failedQuarantineRoots.clear()
+ }
+
+ resetForTest(): void {
+ this.dispose()
+ }
+
+ forgetRoot(dir: string): void {
+ // Physical subscriptions release their assignment through unsubscribe or
+ // terminal callbacks. This clears fault history after setup gives up.
+ if (!this.assignments.has(dir)) {
+ this.isolatedRoots.delete(dir)
+ this.failedQuarantineRoots.delete(dir)
+ }
+ }
+
+ private assignmentForRoot(dir: string): RuntimeWatcherPoolAssignment {
+ if (this.failedQuarantineRoots.has(dir)) {
+ throw new WatcherProcessFailure(
+ 'file watcher process failed again in quarantine',
+ 'supervisor',
+ 'supervisor_crash_fuse'
+ )
+ }
+ const assigned = this.assignments.get(dir)
+ if (assigned && !assigned.slot.retired) {
+ assigned.leases++
+ return assigned
+ }
+ const slot = this.isolatedRoots.has(dir) ? this.quarantineSlot() : this.sharedSlot()
+ const assignment = { slot, leases: 1 }
+ slot.roots.add(dir)
+ this.assignments.set(dir, assignment)
+ return assignment
+ }
+
+ private sharedSlot(): RuntimeWatcherPoolSlot {
+ const sharedSlots = Array.from(this.activeSlots).filter(
+ (slot) => !slot.isolated && !slot.retired
+ )
+ if (sharedSlots.length < this.maxSharedSupervisors) {
+ return this.createSlot(false)
+ }
+ return sharedSlots.reduce((leastLoaded, candidate) =>
+ candidate.roots.size < leastLoaded.roots.size ? candidate : leastLoaded
+ )
+ }
+
+ private quarantineSlot(): RuntimeWatcherPoolSlot {
+ const quarantineSlots = Array.from(this.activeSlots).filter(
+ (slot) => slot.isolated && !slot.retired
+ )
+ if (quarantineSlots.length < this.maxQuarantineSupervisors) {
+ return this.createSlot(true)
+ }
+ return quarantineSlots.reduce((leastLoaded, candidate) =>
+ candidate.roots.size < leastLoaded.roots.size ? candidate : leastLoaded
+ )
+ }
+
+ private createSlot(isolated: boolean): RuntimeWatcherPoolSlot {
+ const slot: RuntimeWatcherPoolSlot = {
+ supervisor: this.createSupervisor(),
+ roots: new Set(),
+ isolated,
+ retired: false,
+ disposed: false
+ }
+ this.activeSlots.add(slot)
+ this.allSlots.add(slot)
+ return slot
+ }
+
+ private retireSlot(slot: RuntimeWatcherPoolSlot): void {
+ if (slot.retired) {
+ return
+ }
+ slot.retired = true
+ this.activeSlots.delete(slot)
+ for (const root of slot.roots) {
+ if (this.assignments.get(root)?.slot === slot) {
+ this.assignments.delete(root)
+ }
+ if (slot.isolated) {
+ // Why: one bounded quarantine attempt is the recovery budget for a
+ // watch lifetime; repeated fused replacements would recreate churn.
+ this.isolatedRoots.delete(root)
+ this.failedQuarantineRoots.add(root)
+ } else {
+ this.isolatedRoots.add(root)
+ }
+ }
+ slot.roots.clear()
+ // Why: failAllSubscriptions is still iterating callbacks; defer disposal
+ // so every logical root receives the supervisor failure first.
+ queueMicrotask(() => this.disposeSlot(slot))
+ }
+
+ private releaseRoot(assignment: RuntimeWatcherPoolAssignment, dir: string): void {
+ if (this.assignments.get(dir) !== assignment) {
+ return
+ }
+ assignment.leases--
+ if (assignment.leases > 0) {
+ return
+ }
+ const { slot } = assignment
+ this.assignments.delete(dir)
+ slot.roots.delete(dir)
+ this.isolatedRoots.delete(dir)
+ if (slot.isolated && slot.roots.size === 0 && !slot.retired) {
+ this.activeSlots.delete(slot)
+ this.disposeSlot(slot)
+ }
+ }
+
+ private disposeSlot(slot: RuntimeWatcherPoolSlot): void {
+ if (slot.disposed) {
+ return
+ }
+ slot.disposed = true
+ slot.supervisor.dispose()
+ this.allSlots.delete(slot)
+ }
+}
diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts
index 7af303c72a3..08b0abbe98d 100644
--- a/src/main/ipc/settings.test.ts
+++ b/src/main/ipc/settings.test.ts
@@ -108,6 +108,27 @@ describe('registerSettingsHandlers', () => {
expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false })
})
+ it('applies bot-author deltas against the authoritative settings snapshot', () => {
+ store.getSettings
+ .mockReturnValueOnce({ prBotAuthorOverrides: ['alice'] })
+ .mockReturnValueOnce({ prBotAuthorOverrides: ['alice', 'bob'] })
+ registerSettingsHandlers(store as never)
+ const handler = handleMock.mock.calls.find(
+ (call) => call[0] === 'settings:update-pr-bot-author-override'
+ )?.[1] as (
+ event: typeof settingsInvokeEvent,
+ args: { author: string; isBot: boolean }
+ ) => unknown
+
+ const result = handler(settingsInvokeEvent, { author: ' Bob ', isBot: true })
+
+ expect(store.updateSettings).toHaveBeenCalledWith(
+ { prBotAuthorOverrides: ['alice', 'bob'] },
+ { notifyListeners: true, originWebContentsId: 1 }
+ )
+ expect(result).toEqual({ prBotAuthorOverrides: ['alice', 'bob'] })
+ })
+
it('registers settings:previewWarpThemeImport handler', () => {
registerSettingsHandlers(store as never)
const channels = handleMock.mock.calls.map((call) => call[0])
diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts
index b59bf6add2e..1ba9a015b66 100644
--- a/src/main/ipc/settings.ts
+++ b/src/main/ipc/settings.ts
@@ -21,6 +21,7 @@ import { normalizeDesktopTerminalScrollbackRows } from '../../shared/terminal-sc
import { normalizeTerminalLineHeight } from '../../shared/terminal-line-height-settings'
import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation'
import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher'
+import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides'
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
// to a Set once at module load lets the IPC handler's per-key membership
@@ -67,6 +68,19 @@ export function registerSettingsHandlers(
return store.getSettings()
})
+ ipcMain.handle(
+ 'settings:update-pr-bot-author-override',
+ (event, args: { author: string; isBot: boolean }) => {
+ const current = store.getSettings().prBotAuthorOverrides
+ const next = applyPRBotAuthorOverride(current, args.author, args.isBot)
+ store.updateSettings(
+ { prBotAuthorOverrides: next },
+ { notifyListeners: true, originWebContentsId: event.sender.id }
+ )
+ return store.getSettings()
+ }
+ )
+
// Why: terminal panes can bind PTYs before async settings hydration
// completes. The side-effect authority kill switch is consulted once at
// transport creation, so the renderer needs the persisted value
diff --git a/src/main/ipc/worktree-base-directory-change-collector.ts b/src/main/ipc/worktree-base-directory-change-collector.ts
new file mode 100644
index 00000000000..829aa8c3b82
--- /dev/null
+++ b/src/main/ipc/worktree-base-directory-change-collector.ts
@@ -0,0 +1,95 @@
+import type { FsChangeEvent } from '../../shared/types'
+import {
+ classifyWorktreeBaseChange,
+ type WorktreeBaseWatchTarget
+} from './worktree-base-directory-event-filter'
+
+type WorktreeBaseWatcherEvent = {
+ type: 'create' | 'update' | 'delete'
+ path: string
+}
+
+export type WorktreeBaseCollectedChanges = {
+ overflow: boolean
+ structureRepoIds: string[]
+ gitStatusRepoIds: string[]
+}
+
+function emptyChanges(): WorktreeBaseCollectedChanges {
+ return { overflow: false, structureRepoIds: [], gitStatusRepoIds: [] }
+}
+
+function addMatchingChange(
+ target: WorktreeBaseWatchTarget,
+ event: WorktreeBaseWatcherEvent,
+ structureRepoIds: Set,
+ gitStatusRepoIds: Set
+): void {
+ const change = classifyWorktreeBaseChange(target, event)
+ for (const repoId of change.structureRepoIds) {
+ structureRepoIds.add(repoId)
+ }
+ for (const repoId of change.gitStatusRepoIds) {
+ gitStatusRepoIds.add(repoId)
+ }
+}
+
+function toCollectedChanges(
+ structureRepoIds: Set,
+ gitStatusRepoIds: Set
+): WorktreeBaseCollectedChanges {
+ return {
+ overflow: false,
+ structureRepoIds: [...structureRepoIds],
+ gitStatusRepoIds: [...gitStatusRepoIds]
+ }
+}
+
+export function collectLocalWorktreeBaseChanges(
+ target: WorktreeBaseWatchTarget,
+ events: WorktreeBaseWatcherEvent[]
+): WorktreeBaseCollectedChanges {
+ const structureRepoIds = new Set()
+ const gitStatusRepoIds = new Set()
+ for (const event of events) {
+ addMatchingChange(target, event, structureRepoIds, gitStatusRepoIds)
+ }
+ return toCollectedChanges(structureRepoIds, gitStatusRepoIds)
+}
+
+export function collectRemoteWorktreeBaseChanges(
+ target: WorktreeBaseWatchTarget,
+ events: FsChangeEvent[]
+): WorktreeBaseCollectedChanges {
+ const structureRepoIds = new Set()
+ const gitStatusRepoIds = new Set()
+ for (const event of events) {
+ if (event.kind === 'overflow') {
+ return { ...emptyChanges(), overflow: true }
+ }
+ if (event.kind === 'rename') {
+ if (event.oldAbsolutePath) {
+ addMatchingChange(
+ target,
+ { type: 'delete', path: event.oldAbsolutePath },
+ structureRepoIds,
+ gitStatusRepoIds
+ )
+ }
+ addMatchingChange(
+ target,
+ { type: 'create', path: event.absolutePath },
+ structureRepoIds,
+ gitStatusRepoIds
+ )
+ continue
+ }
+ addMatchingChange(
+ target,
+ { type: event.kind, path: event.absolutePath },
+ structureRepoIds,
+ gitStatusRepoIds
+ )
+ }
+ return toCollectedChanges(structureRepoIds, gitStatusRepoIds)
+}
diff --git a/src/main/ipc/worktree-base-directory-event-filter.test.ts b/src/main/ipc/worktree-base-directory-event-filter.test.ts
index ad66359d965..2a87b9f0b25 100644
--- a/src/main/ipc/worktree-base-directory-event-filter.test.ts
+++ b/src/main/ipc/worktree-base-directory-event-filter.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
-import { join } from 'node:path'
+import { join, win32 } from 'node:path'
import {
+ classifyWorktreeBaseChange,
matchingWorktreeBaseRepoIds,
type WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
@@ -17,29 +18,121 @@ function makeGitCommonTarget(): WorktreeBaseWatchTarget {
}
describe('matchingWorktreeBaseRepoIds (git-common)', () => {
- it('matches linked-worktree metadata under worktrees/', () => {
+ it('classifies linked-worktree structural metadata under worktrees/', () => {
const target = makeGitCommonTarget()
expect(
- matchingWorktreeBaseRepoIds(target, {
+ classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'HEAD')
})
- ).toEqual(['repo-1'])
+ ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
+ expect(
+ classifyWorktreeBaseChange(target, {
+ type: 'create',
+ path: join(COMMON_DIR, 'worktrees', 'wt-b')
+ })
+ ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
expect(
matchingWorktreeBaseRepoIds(target, {
- type: 'create',
+ type: 'delete',
path: join(COMMON_DIR, 'worktrees', 'wt-b')
})
).toEqual(['repo-1'])
})
- it('matches primary-checkout branch/index metadata at the common-dir top level', () => {
+ it('classifies primary-checkout branch metadata as structural and index as status-only', () => {
const target = makeGitCommonTarget()
- for (const file of ['HEAD', 'packed-refs', 'index']) {
+ for (const file of ['HEAD', 'packed-refs']) {
expect(
- matchingWorktreeBaseRepoIds(target, { type: 'update', path: join(COMMON_DIR, file) })
- ).toEqual(['repo-1'])
+ classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, file) })
+ ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
}
+ expect(
+ classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'index') })
+ ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
+ })
+
+ it('classifies linked-worktree index as status-only', () => {
+ const target = makeGitCommonTarget()
+ expect(
+ classifyWorktreeBaseChange(target, {
+ type: 'update',
+ path: join(COMMON_DIR, 'worktrees', 'wt-a', 'index')
+ })
+ ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
+ expect(
+ classifyWorktreeBaseChange(target, {
+ type: 'update',
+ path: join(COMMON_DIR, 'worktrees', 'wt-a', 'index.lock')
+ })
+ ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] })
+ })
+
+ it('classifies HEAD reflog appends as status-only for linked and primary checkouts', () => {
+ const target = makeGitCommonTarget()
+ // commit --amend / reset --soft move HEAD without touching index or HEAD.
+ expect(
+ classifyWorktreeBaseChange(target, {
+ type: 'update',
+ path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'HEAD')
+ })
+ ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
+ expect(
+ classifyWorktreeBaseChange(target, {
+ type: 'update',
+ path: join(COMMON_DIR, 'logs', 'HEAD')
+ })
+ ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
+ // Per-ref reflogs churn on fetches and stay ignored.
+ expect(
+ classifyWorktreeBaseChange(target, {
+ type: 'update',
+ path: join(COMMON_DIR, 'logs', 'refs', 'heads', 'main')
+ })
+ ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] })
+ expect(
+ classifyWorktreeBaseChange(target, {
+ type: 'update',
+ path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'refs', 'heads', 'main')
+ })
+ ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] })
+ })
+
+ it('classifies worktree-scoped config as structural for sparse-flag freshness', () => {
+ const target = makeGitCommonTarget()
+ expect(
+ classifyWorktreeBaseChange(target, {
+ type: 'update',
+ path: join(COMMON_DIR, 'worktrees', 'wt-a', 'config.worktree')
+ })
+ ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
+ expect(
+ classifyWorktreeBaseChange(target, {
+ type: 'create',
+ path: join(COMMON_DIR, 'config.worktree')
+ })
+ ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
+ })
+
+ it('classifies Windows-shaped linked metadata paths', () => {
+ const commonDir = win32.join('C:\\', 'repos', 'project', '.git')
+ const target: WorktreeBaseWatchTarget = {
+ ...makeGitCommonTarget(),
+ key: `git-common:local:${commonDir}`,
+ path: commonDir
+ }
+ expect(
+ classifyWorktreeBaseChange(target, {
+ type: 'update',
+ path: win32.join(commonDir, 'worktrees', 'wt a', 'gitdir')
+ })
+ ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
+ expect(
+ classifyWorktreeBaseChange(target, {
+ type: 'update',
+ path: win32.join(commonDir, 'worktrees', 'wt a', 'index')
+ })
+ ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
})
it('ignores non-status common-dir churn', () => {
diff --git a/src/main/ipc/worktree-base-directory-event-filter.ts b/src/main/ipc/worktree-base-directory-event-filter.ts
index c12da5d216b..7f161e33823 100644
--- a/src/main/ipc/worktree-base-directory-event-filter.ts
+++ b/src/main/ipc/worktree-base-directory-event-filter.ts
@@ -8,6 +8,11 @@ type WorktreeBaseWatcherEvent = {
path: string
}
+export type WorktreeBaseChangeClass = {
+ structureRepoIds: string[]
+ gitStatusRepoIds: string[]
+}
+
export type WorktreeBaseWatchKind = 'base' | 'git-common'
export type WorktreeBaseRepoWatchConfig = {
@@ -85,29 +90,95 @@ function matchingBaseRepoIds(
// Why: branch switches and commits in the primary checkout rewrite these
// top-level common-dir files; matching them keeps root-checkout branch/status
// as fresh as linked worktrees. Deeper churn (objects, refs, logs) is ignored.
-const GIT_COMMON_PRIMARY_METADATA_FILES = new Set(['HEAD', 'packed-refs', 'index'])
+// `config.worktree` is structural because it is the only file whose write
+// flips `git worktree list`'s sparse flag, and no status/commit path touches
+// it — so it cannot re-open the index-churn fanout this classifier closes.
+const GIT_COMMON_PRIMARY_STRUCTURAL_FILES = new Set(['HEAD', 'packed-refs', 'config.worktree'])
+const GIT_COMMON_PRIMARY_STATUS_FILES = new Set(['index'])
+const GIT_COMMON_LINKED_STRUCTURAL_FILES = new Set(['HEAD', 'gitdir', 'locked', 'config.worktree'])
+const GIT_COMMON_LINKED_STATUS_FILES = new Set(['index'])
+
+// `logs/HEAD` is the status-only trigger for head moves that rewrite no
+// watched leaf (commit --amend, reset --soft): every ref update through a
+// checkout appends there, while `git status` churn never touches it.
+function isHeadLogParts(parts: string[], offset: number): boolean {
+ return parts.length === offset + 2 && parts[offset] === 'logs' && parts[offset + 1] === 'HEAD'
+}
+
+function allRepoIds(target: WorktreeBaseWatchTarget): string[] {
+ return [...target.repos.keys()]
+}
// Why: Git records linked worktrees under the common dir's `worktrees`
// metadata, which is lower churn than watching checkout contents.
-function matchingGitCommonRepoIds(target: WorktreeBaseWatchTarget, eventPath: string): string[] {
- const parts = pathRelativeToWorktreeWatchRoot(target.path, eventPath)
+function classifyGitCommonEvent(
+ target: WorktreeBaseWatchTarget,
+ event: WorktreeBaseWatcherEvent
+): WorktreeBaseChangeClass {
+ const parts = pathRelativeToWorktreeWatchRoot(target.path, event.path)
if (!parts) {
- return []
+ return { structureRepoIds: [], gitStatusRepoIds: [] }
}
- if (
- parts[0] !== 'worktrees' &&
- !(parts.length === 1 && GIT_COMMON_PRIMARY_METADATA_FILES.has(parts[0]))
- ) {
- return []
+ const repoIds = allRepoIds(target)
+ if (parts.length === 1) {
+ if (parts[0] === 'worktrees') {
+ return { structureRepoIds: repoIds, gitStatusRepoIds: [] }
+ }
+ if (GIT_COMMON_PRIMARY_STRUCTURAL_FILES.has(parts[0])) {
+ return { structureRepoIds: repoIds, gitStatusRepoIds: [] }
+ }
+ if (GIT_COMMON_PRIMARY_STATUS_FILES.has(parts[0])) {
+ return { structureRepoIds: [], gitStatusRepoIds: repoIds }
+ }
+ return { structureRepoIds: [], gitStatusRepoIds: [] }
}
- return [...target.repos.keys()]
+ if (parts[0] !== 'worktrees') {
+ if (isHeadLogParts(parts, 0)) {
+ return { structureRepoIds: [], gitStatusRepoIds: repoIds }
+ }
+ return { structureRepoIds: [], gitStatusRepoIds: [] }
+ }
+ if (parts.length === 2) {
+ return event.type === 'update'
+ ? { structureRepoIds: [], gitStatusRepoIds: [] }
+ : { structureRepoIds: repoIds, gitStatusRepoIds: [] }
+ }
+ if (parts.length === 3) {
+ if (GIT_COMMON_LINKED_STRUCTURAL_FILES.has(parts[2])) {
+ return { structureRepoIds: repoIds, gitStatusRepoIds: [] }
+ }
+ if (GIT_COMMON_LINKED_STATUS_FILES.has(parts[2])) {
+ return { structureRepoIds: [], gitStatusRepoIds: repoIds }
+ }
+ }
+ if (isHeadLogParts(parts, 2)) {
+ return { structureRepoIds: [], gitStatusRepoIds: repoIds }
+ }
+ return { structureRepoIds: [], gitStatusRepoIds: [] }
+}
+
+function classifyBaseEvent(
+ target: WorktreeBaseWatchTarget,
+ event: WorktreeBaseWatcherEvent
+): WorktreeBaseChangeClass {
+ return {
+ structureRepoIds: matchingBaseRepoIds(target, event.path, event.type),
+ gitStatusRepoIds: []
+ }
+}
+
+export function classifyWorktreeBaseChange(
+ target: WorktreeBaseWatchTarget,
+ event: WorktreeBaseWatcherEvent
+): WorktreeBaseChangeClass {
+ return target.kind === 'git-common'
+ ? classifyGitCommonEvent(target, event)
+ : classifyBaseEvent(target, event)
}
export function matchingWorktreeBaseRepoIds(
target: WorktreeBaseWatchTarget,
event: WorktreeBaseWatcherEvent
): string[] {
- return target.kind === 'git-common'
- ? matchingGitCommonRepoIds(target, event.path)
- : matchingBaseRepoIds(target, event.path, event.type)
+ return classifyWorktreeBaseChange(target, event).structureRepoIds
}
diff --git a/src/main/ipc/worktree-base-directory-poller.test.ts b/src/main/ipc/worktree-base-directory-poller.test.ts
index 292530f0814..d42ed105948 100644
--- a/src/main/ipc/worktree-base-directory-poller.test.ts
+++ b/src/main/ipc/worktree-base-directory-poller.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'
+import { mkdtemp, mkdir, realpath, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
@@ -171,7 +171,7 @@ describe('worktree base directory poller', () => {
expect(fullScans.length).toBeGreaterThan(0)
})
- it('reports git-common worktrees metadata adds, updates, and removals via polling', async () => {
+ it('reports git-common entry creates, allowlisted leaf updates, and removals via polling', async () => {
const commonDir = await makeRoot()
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('git-common', commonDir)
@@ -190,12 +190,16 @@ describe('worktree base directory poller', () => {
flat.some((event) => event.type === 'create' && event.path === entry)
)
- // HEAD/gitdir metadata writes land as new files in the entry dir, which
- // bumps the entry dir mtime the poller compares.
await new Promise((resolve) => setTimeout(resolve, 10))
await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main')
await waitForEvents(received, (flat) =>
- flat.some((event) => event.type === 'update' && event.path === entry)
+ flat.some((event) => event.type === 'create' && event.path === join(entry, 'HEAD'))
+ )
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ await writeFile(join(entry, 'index'), 'status')
+ await waitForEvents(received, (flat) =>
+ flat.some((event) => event.type === 'create' && event.path === join(entry, 'index'))
)
await rm(entry, { recursive: true })
@@ -204,6 +208,106 @@ describe('worktree base directory poller', () => {
)
})
+ it('detects linked HEAD rewrites even when the entry directory mtime is restored', async () => {
+ const commonDir = await makeRoot()
+ const entry = join(commonDir, 'worktrees', 'external-head')
+ await mkdir(entry, { recursive: true })
+ await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main')
+
+ const received: WorktreeBasePollEvent[][] = []
+ const target = makeTarget('git-common', commonDir)
+ const poller = await startWorktreeBaseDirectoryPoller(
+ target,
+ () => target.repos,
+ (events) => received.push(events),
+ { pollIntervalMs: POLL_MS, platform: 'linux' }
+ )
+ cleanups.push(() => poller.unsubscribe())
+
+ const before = await stat(entry)
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/next')
+ await utimes(entry, before.atime, before.mtime)
+
+ await waitForEvents(received, (flat) =>
+ flat.some((event) => event.type === 'update' && event.path === join(entry, 'HEAD'))
+ )
+ })
+
+ it('reports linked HEAD reflog appends that bump no watched leaf or entry dir', async () => {
+ const commonDir = await makeRoot()
+ const entry = join(commonDir, 'worktrees', 'external-amend')
+ await mkdir(join(entry, 'logs'), { recursive: true })
+ await writeFile(join(entry, 'logs', 'HEAD'), 'baseline\n')
+
+ const received: WorktreeBasePollEvent[][] = []
+ const target = makeTarget('git-common', commonDir)
+ const poller = await startWorktreeBaseDirectoryPoller(
+ target,
+ () => target.repos,
+ (events) => received.push(events),
+ { pollIntervalMs: POLL_MS, platform: 'linux' }
+ )
+ cleanups.push(() => poller.unsubscribe())
+
+ // commit --amend appends to the reflog inside logs/ — the entry dir mtime
+ // never moves, so the leaf must be stat'd directly.
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ await writeFile(join(entry, 'logs', 'HEAD'), 'baseline\namended\n')
+ await waitForEvents(received, (flat) =>
+ flat.some((event) => event.type === 'update' && event.path === join(entry, 'logs', 'HEAD'))
+ )
+ })
+
+ it('surfaces in-place index rewrites through the backstop re-stat', async () => {
+ const commonDir = await makeRoot()
+ const entry = join(commonDir, 'worktrees', 'external-inplace')
+ await mkdir(entry, { recursive: true })
+ await writeFile(join(entry, 'index'), 'index-v1')
+
+ const received: WorktreeBasePollEvent[][] = []
+ const target = makeTarget('git-common', commonDir)
+ const poller = await startWorktreeBaseDirectoryPoller(
+ target,
+ () => target.repos,
+ (events) => received.push(events),
+ { pollIntervalMs: POLL_MS, platform: 'linux' }
+ )
+ cleanups.push(() => poller.unsubscribe())
+
+ // An in-place rewrite leaves the entry-dir signature untouched, so only
+ // the periodic ungated re-stat can observe it.
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ await writeFile(join(entry, 'index'), 'index-v2-longer')
+ await waitForEvents(received, (flat) =>
+ flat.some((event) => event.type === 'update' && event.path === join(entry, 'index'))
+ )
+ })
+
+ it('reports primary-checkout HEAD reflog appends via polling', async () => {
+ const commonDir = await makeRoot()
+ await mkdir(join(commonDir, 'logs'), { recursive: true })
+ await writeFile(join(commonDir, 'logs', 'HEAD'), 'baseline\n')
+
+ const received: WorktreeBasePollEvent[][] = []
+ const target = makeTarget('git-common', commonDir)
+ const poller = await startWorktreeBaseDirectoryPoller(
+ target,
+ () => target.repos,
+ (events) => received.push(events),
+ { pollIntervalMs: POLL_MS, platform: 'linux' }
+ )
+ cleanups.push(() => poller.unsubscribe())
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ await writeFile(join(commonDir, 'logs', 'HEAD'), 'baseline\namended\n')
+ await waitForEvents(received, (flat) =>
+ flat.some(
+ (event) => event.type === 'update' && event.path === join(commonDir, 'logs', 'HEAD')
+ )
+ )
+ })
+
it('reports primary-checkout HEAD changes via polling', async () => {
const commonDir = await makeRoot()
const received: WorktreeBasePollEvent[][] = []
diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts
index d312c8ab431..7f580421aaa 100644
--- a/src/main/ipc/worktree-base-directory-watcher.test.ts
+++ b/src/main/ipc/worktree-base-directory-watcher.test.ts
@@ -14,6 +14,8 @@ vi.mock('./worktree-base-directory-poller', () => ({
}))
vi.mock('./worktree-remote', () => ({
+ notifyWorktreeGitStatusMetadataChanged: vi.fn(),
+ notifyWorktreeHeadIdentitiesChanged: vi.fn(),
notifyWorktreesChanged: vi.fn()
}))
@@ -21,8 +23,17 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn()
}))
+vi.mock('./worktree-head-identity-reader', () => ({
+ readGitCommonHeadIdentities: vi.fn(async () => [])
+}))
+
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
-import { notifyWorktreesChanged } from './worktree-remote'
+import {
+ notifyWorktreeGitStatusMetadataChanged,
+ notifyWorktreeHeadIdentitiesChanged,
+ notifyWorktreesChanged
+} from './worktree-remote'
+import { readGitCommonHeadIdentities } from './worktree-head-identity-reader'
import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller'
import {
disposeWorktreeBaseDirectoryWatchers,
@@ -83,6 +94,7 @@ describe('worktree base directory watcher', () => {
watcherCallbacks.clear()
unsubscribeMocks.clear()
vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined)
+ vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([])
vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementation(
async (target, _getRepos, onEvents) => {
const unsubscribe = vi.fn(async () => {})
@@ -151,6 +163,140 @@ describe('worktree base directory watcher', () => {
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
})
+ it('debounces linked index bursts as status-only without structural notification', async () => {
+ await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
+
+ emit(PROJECT_GIT_COMMON_DIR, [
+ { type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') },
+ { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') },
+ { type: 'delete', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') }
+ ])
+ await vi.advanceTimersByTimeAsync(300)
+
+ expect(notifyWorktreesChanged).not.toHaveBeenCalled()
+ expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
+ expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
+ })
+
+ it('keeps linked HEAD and lock metadata structural', async () => {
+ await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
+
+ emit(PROJECT_GIT_COMMON_DIR, [
+ { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') },
+ { type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'locked') }
+ ])
+ await vi.advanceTimersByTimeAsync(300)
+
+ expect(notifyWorktreesChanged).toHaveBeenCalledTimes(1)
+ expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
+ expect(notifyWorktreeGitStatusMetadataChanged).not.toHaveBeenCalled()
+ })
+
+ it('emits head identities for status-only head moves without structural fanout', async () => {
+ const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104')
+ vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
+ { worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' }
+ ])
+ await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
+ await vi.advanceTimersByTimeAsync(0)
+
+ // External commit --amend: only logs/HEAD moves, no index write.
+ vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
+ { worktreePath: linkedWorktree, head: 'bbb222', branch: 'refs/heads/feature' }
+ ])
+ emit(PROJECT_GIT_COMMON_DIR, [
+ {
+ type: 'update',
+ path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD')
+ }
+ ])
+ await vi.advanceTimersByTimeAsync(300)
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(notifyWorktreesChanged).not.toHaveBeenCalled()
+ expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
+ expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledTimes(1)
+ expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1', [
+ { worktreePath: linkedWorktree, head: 'bbb222', branch: 'refs/heads/feature' }
+ ])
+ })
+
+ it('does not emit head identities when a status-only burst leaves heads unchanged', async () => {
+ const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104')
+ vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
+ { worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' }
+ ])
+ await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
+ await vi.advanceTimersByTimeAsync(0)
+
+ emit(PROJECT_GIT_COMMON_DIR, [
+ { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') }
+ ])
+ await vi.advanceTimersByTimeAsync(300)
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
+ expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled()
+ })
+
+ it('re-baselines head identities silently on structural notifications', async () => {
+ const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104')
+ vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
+ { worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' }
+ ])
+ await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
+ await vi.advanceTimersByTimeAsync(0)
+
+ // Branch switch: structural path owns the refresh via the full listing.
+ vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
+ { worktreePath: linkedWorktree, head: 'ccc333', branch: 'refs/heads/other' }
+ ])
+ emit(PROJECT_GIT_COMMON_DIR, [
+ { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') }
+ ])
+ await vi.advanceTimersByTimeAsync(300)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled()
+
+ // A later status-only event diffs against the re-baselined heads.
+ emit(PROJECT_GIT_COMMON_DIR, [
+ { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') }
+ ])
+ await vi.advanceTimersByTimeAsync(300)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled()
+ })
+
+ it('never reads head identities for SSH watches', async () => {
+ const remoteCallbacks = new Map void>()
+ const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => {
+ remoteCallbacks.set(root, callback)
+ return vi.fn()
+ })
+ vi.mocked(getSshFilesystemProvider).mockReturnValue({
+ stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })),
+ realpath: vi.fn(async (path: string) => path),
+ readFile: vi.fn(async () => ({ content: '', isBinary: false })),
+ watch: remoteWatch
+ } as never)
+
+ await syncWorktreeBaseDirectoryWatchers(
+ makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never,
+ makeWindow() as never
+ )
+ remoteCallbacks.get('/home/alice/project/.git')?.([
+ {
+ kind: 'update',
+ absolutePath: '/home/alice/project/.git/worktrees/wt/logs/HEAD'
+ }
+ ] as never[])
+ await vi.advanceTimersByTimeAsync(300)
+
+ expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
+ expect(readGitCommonHeadIdentities).not.toHaveBeenCalled()
+ expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled()
+ })
+
it('does not install local desktop watchers for runtime or folder repos', async () => {
await syncWorktreeBaseDirectoryWatchers(
makeStore([
@@ -197,6 +343,42 @@ describe('worktree base directory watcher', () => {
expect(remoteUnwatch).toHaveBeenCalled()
})
+ it('treats remote index renames as status-only and overflow as structural', async () => {
+ const remoteCallbacks = new Map void>()
+ const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => {
+ remoteCallbacks.set(root, callback)
+ return vi.fn()
+ })
+ vi.mocked(getSshFilesystemProvider).mockReturnValue({
+ stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })),
+ realpath: vi.fn(async (path: string) => path),
+ readFile: vi.fn(async () => ({ content: '', isBinary: false })),
+ watch: remoteWatch
+ } as never)
+
+ await syncWorktreeBaseDirectoryWatchers(
+ makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never,
+ makeWindow() as never
+ )
+
+ remoteCallbacks.get('/home/alice/project/.git')?.([
+ {
+ kind: 'rename',
+ oldAbsolutePath: '/home/alice/project/.git/worktrees/wt/index.lock',
+ absolutePath: '/home/alice/project/.git/worktrees/wt/index'
+ }
+ ] as never[])
+ await vi.advanceTimersByTimeAsync(300)
+ expect(notifyWorktreesChanged).not.toHaveBeenCalled()
+ expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
+
+ vi.mocked(notifyWorktreeGitStatusMetadataChanged).mockClear()
+ remoteCallbacks.get('/home/alice/project/.git')?.([{ kind: 'overflow' }] as never[])
+ await vi.advanceTimersByTimeAsync(300)
+ expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
+ expect(notifyWorktreeGitStatusMetadataChanged).not.toHaveBeenCalled()
+ })
+
it('unsubscribes roots that disappear after repo settings change', async () => {
const repo = makeRepo()
const store = makeStore([repo])
diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts
index db71349a7dd..d62eb2384ba 100644
--- a/src/main/ipc/worktree-base-directory-watcher.ts
+++ b/src/main/ipc/worktree-base-directory-watcher.ts
@@ -1,12 +1,18 @@
import type { BrowserWindow } from 'electron'
-import type { FsChangeEvent } from '../../shared/types'
import type { Store } from '../persistence'
-import { notifyWorktreesChanged } from './worktree-remote'
+import { notifyWorktreeGitStatusMetadataChanged, notifyWorktreesChanged } from './worktree-remote'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
- matchingWorktreeBaseRepoIds,
- type WorktreeBaseWatchTarget
-} from './worktree-base-directory-event-filter'
+ createWorktreeHeadIdentityRefreshState,
+ refreshWorktreeHeadIdentities,
+ type WorktreeHeadIdentityRefreshState
+} from './worktree-head-identity-refresh'
+import {
+ collectLocalWorktreeBaseChanges,
+ collectRemoteWorktreeBaseChanges,
+ type WorktreeBaseCollectedChanges
+} from './worktree-base-directory-change-collector'
+import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
import {
buildWorktreeBaseDirectoryWatchTargets,
clearWorktreeBaseDirectoryWatchTargetWarnings
@@ -17,7 +23,9 @@ type ActiveWatch = WorktreeBaseWatchTarget & {
mainWindow: BrowserWindow
subscription: { unsubscribe: () => Promise }
notifyTimer: ReturnType | null
- pendingRepoIds: Set
+ pendingStructureRepoIds: Set
+ pendingGitStatusRepoIds: Set
+ headIdentityRefresh: WorktreeHeadIdentityRefreshState
disposed: boolean
}
@@ -27,13 +35,20 @@ let syncGeneration = 0
let scheduledSync: ReturnType | null = null
let latestSyncContext: { mainWindow: BrowserWindow; store: Store } | null = null
-function scheduleNotification(watch: ActiveWatch, repoIds: readonly string[]): void {
+function scheduleNotification(
+ watch: ActiveWatch,
+ changes: { structureRepoIds?: readonly string[]; gitStatusRepoIds?: readonly string[] }
+): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
- watch.pendingRepoIds.clear()
+ watch.pendingStructureRepoIds.clear()
+ watch.pendingGitStatusRepoIds.clear()
return
}
- for (const repoId of repoIds) {
- watch.pendingRepoIds.add(repoId)
+ for (const repoId of changes.structureRepoIds ?? []) {
+ watch.pendingStructureRepoIds.add(repoId)
+ }
+ for (const repoId of changes.gitStatusRepoIds ?? []) {
+ watch.pendingGitStatusRepoIds.add(repoId)
}
if (watch.notifyTimer) {
clearTimeout(watch.notifyTimer)
@@ -41,26 +56,43 @@ function scheduleNotification(watch: ActiveWatch, repoIds: readonly string[]): v
watch.notifyTimer = setTimeout(() => {
watch.notifyTimer = null
if (watch.disposed || watch.mainWindow.isDestroyed()) {
- watch.pendingRepoIds.clear()
+ watch.pendingStructureRepoIds.clear()
+ watch.pendingGitStatusRepoIds.clear()
return
}
- const pending = [...watch.pendingRepoIds]
- watch.pendingRepoIds.clear()
- for (const repoId of pending) {
+ const pendingStructure = [...watch.pendingStructureRepoIds]
+ const pendingGitStatus = [...watch.pendingGitStatusRepoIds].filter(
+ (repoId) => !watch.pendingStructureRepoIds.has(repoId)
+ )
+ watch.pendingStructureRepoIds.clear()
+ watch.pendingGitStatusRepoIds.clear()
+ for (const repoId of pendingStructure) {
notifyWorktreesChanged(watch.mainWindow, repoId)
}
+ for (const repoId of pendingGitStatus) {
+ notifyWorktreeGitStatusMetadataChanged(watch.mainWindow, repoId)
+ }
+ if (supportsHeadIdentityRefresh(watch)) {
+ // Emit only when no structural notification fired this tick: structural
+ // paths already run the authoritative worktree listing; the silent pass
+ // just re-baselines so later status-only diffs stay accurate.
+ void refreshWorktreeHeadIdentities(
+ watch,
+ watch.headIdentityRefresh,
+ pendingStructure.length === 0
+ )
+ }
}, WATCH_DEBOUNCE_MS)
}
-function collectMatchingRepoIds(
- watch: ActiveWatch,
- eventType: 'create' | 'update' | 'delete',
- eventPath: string,
- repoIds: Set
-): void {
- for (const repoId of matchingWorktreeBaseRepoIds(watch, { type: eventType, path: eventPath })) {
- repoIds.add(repoId)
- }
+// Why: SSH common dirs would need per-signal network reads to diff heads;
+// remote background freshness stays on the structural path for now.
+function supportsHeadIdentityRefresh(watch: ActiveWatch): boolean {
+ return watch.kind === 'git-common' && !watch.connectionId
+}
+
+function hasCollectedChanges(changes: WorktreeBaseCollectedChanges): boolean {
+ return changes.structureRepoIds.length > 0 || changes.gitStatusRepoIds.length > 0
}
function handleLocalWatchEvents(
@@ -73,39 +105,29 @@ function handleLocalWatchEvents(
}
if (error) {
console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error)
- scheduleNotification(watch, [...watch.repos.keys()])
+ scheduleNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
return
}
- const repoIds = new Set()
- for (const event of events) {
- collectMatchingRepoIds(watch, event.type, event.path, repoIds)
- }
- if (repoIds.size > 0) {
- scheduleNotification(watch, [...repoIds])
+ const changes = collectLocalWorktreeBaseChanges(watch, events)
+ if (hasCollectedChanges(changes)) {
+ scheduleNotification(watch, changes)
}
}
-function handleRemoteWatchEvents(watch: ActiveWatch, events: FsChangeEvent[]): void {
+function handleRemoteWatchEvents(
+ watch: ActiveWatch,
+ events: Parameters[1]
+): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
- const repoIds = new Set()
- for (const event of events) {
- if (event.kind === 'overflow') {
- scheduleNotification(watch, [...watch.repos.keys()])
- return
- }
- if (event.kind === 'rename') {
- if (event.oldAbsolutePath) {
- collectMatchingRepoIds(watch, 'delete', event.oldAbsolutePath, repoIds)
- }
- collectMatchingRepoIds(watch, 'create', event.absolutePath, repoIds)
- continue
- }
- collectMatchingRepoIds(watch, event.kind, event.absolutePath, repoIds)
+ const changes = collectRemoteWorktreeBaseChanges(watch, events)
+ if (changes.overflow) {
+ scheduleNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
+ return
}
- if (repoIds.size > 0) {
- scheduleNotification(watch, [...repoIds])
+ if (hasCollectedChanges(changes)) {
+ scheduleNotification(watch, changes)
}
}
@@ -131,7 +153,9 @@ async function subscribeTarget(
mainWindow,
subscription: { unsubscribe: async () => unwatch() },
notifyTimer: null,
- pendingRepoIds: new Set(),
+ pendingStructureRepoIds: new Set(),
+ pendingGitStatusRepoIds: new Set(),
+ headIdentityRefresh: createWorktreeHeadIdentityRefreshState(),
disposed: false
}
return activeWatch
@@ -157,9 +181,17 @@ async function subscribeTarget(
mainWindow,
subscription,
notifyTimer: null,
- pendingRepoIds: new Set(),
+ pendingStructureRepoIds: new Set(),
+ pendingGitStatusRepoIds: new Set(),
+ headIdentityRefresh: createWorktreeHeadIdentityRefreshState(),
disposed: false
}
+ if (supportsHeadIdentityRefresh(activeWatch)) {
+ // Baseline eagerly so the first status-only signal — possibly hours after
+ // subscribe — diffs against subscribe-time heads instead of silently
+ // re-baselining past an external commit.
+ void refreshWorktreeHeadIdentities(activeWatch, activeWatch.headIdentityRefresh, false)
+ }
return activeWatch
}
@@ -199,6 +231,8 @@ async function removeWatch(key: string): Promise {
if (watch.notifyTimer) {
clearTimeout(watch.notifyTimer)
}
+ watch.pendingStructureRepoIds.clear()
+ watch.pendingGitStatusRepoIds.clear()
await watch.subscription.unsubscribe().catch((error) => {
console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error)
})
diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts
new file mode 100644
index 00000000000..8996c7b00c1
--- /dev/null
+++ b/src/main/ipc/worktree-git-common-polling.ts
@@ -0,0 +1,273 @@
+import { readdir, stat } from 'node:fs/promises'
+import { join } from 'node:path'
+import type {
+ WorktreeBasePollEvent,
+ WorktreeBaseSubscription
+} from './worktree-base-directory-poller'
+
+// Shared with the darwin primary-metadata poll so the platforms cannot drift
+// on which shallow leaves count as watchable metadata. `logs/HEAD` catches
+// head moves that rewrite no other watched leaf (commit --amend, reset
+// --soft); `config.worktree` carries the sparse flag.
+export const PRIMARY_CHECKOUT_METADATA_FILES = [
+ 'HEAD',
+ 'packed-refs',
+ 'index',
+ 'config.worktree',
+ 'logs/HEAD'
+]
+const LINKED_WORKTREE_STRUCTURAL_METADATA_FILES = ['HEAD', 'gitdir', 'locked', 'config.worktree']
+const LINKED_WORKTREE_INDEX_FILE = 'index'
+const LINKED_WORKTREE_HEAD_LOG_FILE = join('logs', 'HEAD')
+// Why: the entry-dir signature gate can miss same-granule index rewrites on
+// coarse-mtime filesystems; a periodic ungated re-stat bounds that miss the
+// same way the base poller's backstop rescan does.
+const INDEX_BACKSTOP_TICKS = 15
+
+function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number; size: number }): string {
+ return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}:${s.size}`
+}
+
+async function fileSignature(path: string): Promise {
+ try {
+ const s = await stat(path)
+ return s.isFile() ? statSignature(s) : null
+ } catch {
+ return null
+ }
+}
+
+async function pathSignature(path: string): Promise {
+ try {
+ const s = await stat(path)
+ // Why: omitting ctime keeps unrelated metadata churn from re-opening the
+ // index gate, which would make the HEAD regression test vacuous. The gate
+ // is load-bearing for index-event emission between backstop ticks; the
+ // renderer's status poll is the ultimate freshness net.
+ return `${s.mtimeMs}:${s.ino}:${s.size}`
+ } catch {
+ return null
+ }
+}
+
+type GitCommonEntrySnapshot = {
+ dirSignature: string | null
+ structuralSignatures: Map
+ indexSignature: string | null
+ headLogSignature: string | null
+}
+
+type GitCommonSnapshot = {
+ worktreesDirSignature: string | null
+ entries: Map
+ primarySignatures: Map
+}
+
+async function snapshotGitCommonEntry(
+ entryPath: string,
+ previous: GitCommonEntrySnapshot | undefined,
+ forceIndexRead: boolean
+): Promise {
+ const dirSignature = await pathSignature(entryPath)
+ const structuralSignatures = new Map()
+ await Promise.all(
+ LINKED_WORKTREE_STRUCTURAL_METADATA_FILES.map(async (name) => {
+ const signature = await fileSignature(join(entryPath, name))
+ if (signature !== null) {
+ structuralSignatures.set(name, signature)
+ }
+ })
+ )
+ // `logs/HEAD` lives in a subdirectory, so appends never bump the entry-dir
+ // mtime — it must be stat'd every tick rather than gated like `index`.
+ const headLogSignature = await fileSignature(join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE))
+ const shouldReadIndex = forceIndexRead || !previous || previous.dirSignature !== dirSignature
+ const indexSignature = shouldReadIndex
+ ? await fileSignature(join(entryPath, LINKED_WORKTREE_INDEX_FILE))
+ : previous.indexSignature
+ return { dirSignature, structuralSignatures, indexSignature, headLogSignature }
+}
+
+async function snapshotPrimaryCheckoutSignatures(
+ commonDirPath: string
+): Promise