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> { + const signatures = new Map() + await Promise.all( + PRIMARY_CHECKOUT_METADATA_FILES.map(async (name) => { + const signature = await fileSignature(join(commonDirPath, name)) + if (signature !== null) { + signatures.set(name, signature) + } + }) + ) + return signatures +} + +async function snapshotGitCommon( + commonDirPath: string, + previous?: GitCommonSnapshot, + includePrimary = true, + forceIndexRead = false +): Promise { + const entriesByPath = new Map() + const worktreesDir = join(commonDirPath, 'worktrees') + const worktreesDirSignature = await pathSignature(worktreesDir) + const primarySignatures = includePrimary + ? await snapshotPrimaryCheckoutSignatures(commonDirPath) + : new Map() + let entries + try { + entries = await readdir(worktreesDir, { withFileTypes: true }) + } catch { + // Missing worktrees dir is normal for repos without linked worktrees. + return { + worktreesDirSignature, + entries: entriesByPath, + primarySignatures + } + } + await Promise.all( + entries.map(async (entry) => { + if (!entry.isDirectory()) { + return + } + const entryPath = join(worktreesDir, entry.name) + entriesByPath.set( + entryPath, + await snapshotGitCommonEntry(entryPath, previous?.entries.get(entryPath), forceIndexRead) + ) + }) + ) + return { + worktreesDirSignature, + entries: entriesByPath, + primarySignatures + } +} + +function classifySignatureDiff( + prevSignature: string | null | undefined, + nextSignature: string | null | undefined +): 'create' | 'update' | 'delete' | null { + if (prevSignature == null && nextSignature == null) { + return null + } + if (prevSignature == null) { + return 'create' + } + if (nextSignature == null) { + return 'delete' + } + return prevSignature === nextSignature ? null : 'update' +} + +function diffSignatureMaps( + prev: Map, + next: Map, + resolvePath: (name: string) => string +): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + const names = new Set([...prev.keys(), ...next.keys()]) + for (const name of names) { + const type = classifySignatureDiff(prev.get(name), next.get(name)) + if (type) { + events.push({ type, path: resolvePath(name) }) + } + } + return events +} + +function diffGitCommon( + commonDirPath: string, + prev: GitCommonSnapshot, + next: GitCommonSnapshot +): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + const worktreesDir = join(commonDirPath, 'worktrees') + const worktreesDirDiff = classifySignatureDiff( + prev.worktreesDirSignature, + next.worktreesDirSignature + ) + if (worktreesDirDiff) { + events.push({ type: worktreesDirDiff, path: worktreesDir }) + } + for (const [entryPath, entry] of next.entries) { + const prevEntry = prev.entries.get(entryPath) + if (!prevEntry) { + events.push({ type: 'create', path: entryPath }) + continue + } + events.push( + ...diffSignatureMaps(prevEntry.structuralSignatures, entry.structuralSignatures, (name) => + join(entryPath, name) + ) + ) + const indexDiff = classifySignatureDiff(prevEntry.indexSignature, entry.indexSignature) + if (indexDiff) { + events.push({ type: indexDiff, path: join(entryPath, LINKED_WORKTREE_INDEX_FILE) }) + } + const headLogDiff = classifySignatureDiff(prevEntry.headLogSignature, entry.headLogSignature) + if (headLogDiff) { + events.push({ type: headLogDiff, path: join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE) }) + } + } + for (const entryPath of prev.entries.keys()) { + if (!next.entries.has(entryPath)) { + events.push({ type: 'delete', path: entryPath }) + } + } + events.push( + ...diffSignatureMaps(prev.primarySignatures, next.primarySignatures, (name) => + join(commonDirPath, name) + ) + ) + return events +} + +export async function startGitCommonPolling( + commonDirPath: string, + onEvents: (events: WorktreeBasePollEvent[]) => void, + pollIntervalMs: number, + onFullScan?: () => void, + includePrimary = true +): Promise { + let disposed = false + let ticking = false + let tickCount = 0 + let snapshot = await snapshotGitCommon(commonDirPath, undefined, includePrimary) + + const timer = setInterval(() => { + if (disposed || ticking) { + return + } + ticking = true + tickCount++ + const forceIndexRead = tickCount % INDEX_BACKSTOP_TICKS === 0 + onFullScan?.() + void snapshotGitCommon(commonDirPath, snapshot, includePrimary, forceIndexRead) + .then((next) => { + if (disposed) { + return + } + const events = diffGitCommon(commonDirPath, snapshot, next) + snapshot = next + if (events.length > 0) { + onEvents(events) + } + }) + .catch(() => { + // Transient fs error: keep the previous snapshot and retry next tick. + }) + .finally(() => { + ticking = false + }) + }, pollIntervalMs) + timer.unref?.() + + return { + unsubscribe: async () => { + disposed = true + clearInterval(timer) + } + } +} diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 1529f9de1a4..39899b1e149 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -1,10 +1,14 @@ -import { readdir, stat } from 'node:fs/promises' +import { stat } from 'node:fs/promises' import { join } from 'node:path' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import type { WorktreeBasePollEvent, WorktreeBaseSubscription } from './worktree-base-directory-poller' +import { + PRIMARY_CHECKOUT_METADATA_FILES, + startGitCommonPolling +} from './worktree-git-common-polling' // Watches a repo's `/.git/worktrees` metadata plus the primary // checkout's shallow branch/index files — the only paths the git-common event @@ -20,8 +24,6 @@ import type { // Why: branch switches and commits made in the primary checkout rewrite these // top-level files (linked-worktree equivalents live under `worktrees/`). // Deliberately excludes FETCH_HEAD-style churn that carries no status change. -const PRIMARY_CHECKOUT_METADATA_FILES = ['HEAD', 'packed-refs', 'index'] - async function snapshotPrimaryCheckoutMetadata( commonDirPath: string ): Promise> { @@ -37,46 +39,22 @@ async function snapshotPrimaryCheckoutMetadata( return mtimes } -async function snapshotGitCommon(commonDirPath: string): Promise> { - const mtimes = new Map() - const worktreesDir = join(commonDirPath, 'worktrees') - let entries - try { - entries = await readdir(worktreesDir, { withFileTypes: true }) - } catch { - // Missing worktrees dir is normal for repos without linked worktrees. - return mtimes - } - for (const entry of entries) { - const entryPath = join(worktreesDir, entry.name) - try { - // Entry-dir mtime covers the metadata writes the old recursive watcher - // reacted to (HEAD/gitdir/locked are written via rename into the entry - // dir, which bumps its mtime). - mtimes.set(entryPath, (await stat(entryPath)).mtimeMs) - } catch { - // Entry removed between readdir and stat. - } - } - return mtimes -} - -function diffGitCommon( +function diffMtimeMap( prev: Map, next: Map ): WorktreeBasePollEvent[] { const events: WorktreeBasePollEvent[] = [] - for (const [entryPath, mtime] of next) { - const prevMtime = prev.get(entryPath) + for (const [path, mtime] of next) { + const prevMtime = prev.get(path) if (prevMtime === undefined) { - events.push({ type: 'create', path: entryPath }) + events.push({ type: 'create', path }) } else if (prevMtime !== mtime) { - events.push({ type: 'update', path: entryPath }) + events.push({ type: 'update', path }) } } - for (const entryPath of prev.keys()) { - if (!next.has(entryPath)) { - events.push({ type: 'delete', path: entryPath }) + for (const path of prev.keys()) { + if (!next.has(path)) { + events.push({ type: 'delete', path }) } } return events @@ -103,7 +81,7 @@ async function startSnapshotDiffPoller( if (disposed) { return } - const events = diffGitCommon(snapshot, next) + const events = diffMtimeMap(snapshot, next) snapshot = next if (events.length > 0) { onEvents(events) @@ -126,19 +104,6 @@ async function startSnapshotDiffPoller( } } -async function snapshotGitCommonAndPrimaryMetadata( - commonDirPath: string -): Promise> { - const [worktrees, primary] = await Promise.all([ - snapshotGitCommon(commonDirPath), - snapshotPrimaryCheckoutMetadata(commonDirPath) - ]) - for (const [path, mtime] of primary) { - worktrees.set(path, mtime) - } - return worktrees -} - async function startGitCommonNarrowWatch( target: WorktreeBaseWatchTarget, onEvents: (events: WorktreeBasePollEvent[]) => void, @@ -239,6 +204,8 @@ async function startGitCommonNarrowWatch( } if (!(await trySubscribe())) { + // Why: repos commonly start without linked worktrees; retrying the narrow + // subscription lets macOS upgrade to native events when the directory appears. armExistencePoll() } @@ -278,10 +245,5 @@ export async function startGitCommonWatch( } } } - return startSnapshotDiffPoller( - () => snapshotGitCommonAndPrimaryMetadata(target.path), - onEvents, - pollIntervalMs, - onFullScan - ) + return startGitCommonPolling(target.path, onEvents, pollIntervalMs, onFullScan) } diff --git a/src/main/ipc/worktree-head-identity-reader.test.ts b/src/main/ipc/worktree-head-identity-reader.test.ts new file mode 100644 index 00000000000..c722d171ee0 --- /dev/null +++ b/src/main/ipc/worktree-head-identity-reader.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { readGitCommonHeadIdentities } from './worktree-head-identity-reader' + +const OID_A = 'a'.repeat(40) +const OID_B = 'b'.repeat(40) +const OID_C = 'c'.repeat(40) + +describe('readGitCommonHeadIdentities', () => { + const roots: string[] = [] + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + }) + + async function makeCommonDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-head-reader-')) + roots.push(root) + const commonDir = join(root, 'checkout', '.git') + await mkdir(commonDir, { recursive: true }) + return commonDir + } + + async function writeLooseRef(commonDir: string, ref: string, oid: string): Promise { + const refPath = join(commonDir, ...ref.split('/')) + await mkdir(dirname(refPath), { recursive: true }) + await writeFile(refPath, `${oid}\n`) + } + + async function addLinkedWorktree( + commonDir: string, + name: string, + worktreePath: string, + headContent: string + ): Promise { + const entry = join(commonDir, 'worktrees', name) + await mkdir(entry, { recursive: true }) + await writeFile(join(entry, 'HEAD'), `${headContent}\n`) + await writeFile(join(entry, 'gitdir'), `${join(worktreePath, '.git')}\n`) + } + + it('resolves the primary checkout and linked worktrees from loose refs', async () => { + const commonDir = await makeCommonDir() + await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') + await writeLooseRef(commonDir, 'refs/heads/main', OID_A) + await writeLooseRef(commonDir, 'refs/heads/feature', OID_B) + const linkedPath = join(dirname(commonDir), '..', 'linked-wt') + await addLinkedWorktree(commonDir, 'linked-wt', linkedPath, 'ref: refs/heads/feature') + + const identities = await readGitCommonHeadIdentities(commonDir) + + expect(identities).toContainEqual({ + worktreePath: dirname(commonDir), + head: OID_A, + branch: 'refs/heads/main' + }) + expect(identities).toContainEqual({ + worktreePath: linkedPath, + head: OID_B, + branch: 'refs/heads/feature' + }) + }) + + it('falls back to packed-refs when the loose ref is absent', async () => { + const commonDir = await makeCommonDir() + await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') + await writeFile( + join(commonDir, 'packed-refs'), + `# pack-refs with: peeled fully-peeled sorted\n${OID_C} refs/heads/main\n^${OID_A}\n` + ) + + const identities = await readGitCommonHeadIdentities(commonDir) + + expect(identities).toEqual([ + { worktreePath: dirname(commonDir), head: OID_C, branch: 'refs/heads/main' } + ]) + }) + + it('reports detached HEAD as a raw oid with a null branch', async () => { + const commonDir = await makeCommonDir() + await writeFile(join(commonDir, 'HEAD'), `${OID_B}\n`) + + const identities = await readGitCommonHeadIdentities(commonDir) + + expect(identities).toEqual([{ worktreePath: dirname(commonDir), head: OID_B, branch: null }]) + }) + + it('skips unborn branches instead of emitting partial identities', async () => { + const commonDir = await makeCommonDir() + await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/unborn\n') + + expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + }) + + it('resolves relative gitdir entries against the metadata dir', async () => { + const commonDir = await makeCommonDir() + await writeLooseRef(commonDir, 'refs/heads/feature', OID_B) + const entry = join(commonDir, 'worktrees', 'rel-wt') + await mkdir(entry, { recursive: true }) + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/feature\n') + await writeFile(join(entry, 'gitdir'), `${join('..', '..', '..', '..', 'rel-wt', '.git')}\n`) + + const identities = await readGitCommonHeadIdentities(commonDir) + + expect(identities).toEqual([ + { + worktreePath: join(dirname(commonDir), '..', 'rel-wt'), + head: OID_B, + branch: 'refs/heads/feature' + } + ]) + }) + + it('rejects traversal-shaped symrefs instead of reading outside the common dir', async () => { + const commonDir = await makeCommonDir() + // A crafted repo can put arbitrary content in HEAD; backslash segments + // would traverse on Windows where join treats them as separators too. + await writeFile(join(dirname(commonDir), '..', 'outside.txt'), 'secret\n') + for (const ref of [ + 'refs/heads/../../../outside.txt', + 'refs\\..\\..\\..\\outside.txt', + 'refs/heads/evil:name', + 'refs//heads' + ]) { + await writeFile(join(commonDir, 'HEAD'), `ref: ${ref}\n`) + expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + } + }) + + it('never emits resolved content that is not a hex object id', async () => { + const commonDir = await makeCommonDir() + await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') + await writeLooseRef(commonDir, 'refs/heads/main', 'not-an-object-id') + expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + + await writeFile(join(commonDir, 'HEAD'), 'this is not a detached oid\n') + expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + }) + + it('omits the primary row for non-standard common dir layouts', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-head-reader-')) + roots.push(root) + const commonDir = join(root, 'bare-repo') + await mkdir(commonDir, { recursive: true }) + await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') + await writeLooseRef(commonDir, 'refs/heads/main', OID_A) + + expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + }) +}) diff --git a/src/main/ipc/worktree-head-identity-reader.ts b/src/main/ipc/worktree-head-identity-reader.ts new file mode 100644 index 00000000000..e99de115480 --- /dev/null +++ b/src/main/ipc/worktree-head-identity-reader.ts @@ -0,0 +1,161 @@ +import { readdir, readFile } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join } from 'node:path' +import type { WorktreeHeadIdentity } from '../../shared/types' + +// Why: the whole point of this reader is replacing `git worktree list` fanout +// with bounded metadata-file reads, so head freshness never re-creates the +// spawn pressure that stalled terminal input. Keep it spawn-free. + +const MAX_SYMREF_DEPTH = 5 + +async function readTrimmedFile(path: string): Promise { + try { + return (await readFile(path, 'utf8')).trim() + } catch { + return null + } +} + +// packed-refs lines are ` `; `#` headers and `^` peel lines skipped. +async function readPackedRefs(commonDirPath: string): Promise> { + const refs = new Map() + const content = await readTrimmedFile(join(commonDirPath, 'packed-refs')) + if (content === null) { + return refs + } + for (const line of content.split('\n')) { + if (!line || line.startsWith('#') || line.startsWith('^')) { + continue + } + const separator = line.indexOf(' ') + if (separator <= 0) { + continue + } + refs.set(line.slice(separator + 1).trim(), line.slice(0, separator)) + } + return refs +} + +// Why: ref content comes from repo files an attacker can craft. Git forbids +// `\` and `:` in ref names, and on Windows `join` also treats `\` as a +// separator — both must be rejected before splicing the ref into a file path. +function isSafeRefName(ref: string): boolean { + if (ref.length === 0 || ref.includes('\\') || ref.includes(':')) { + return false + } + return !ref.split('/').some((part) => part === '..' || part === '') +} + +// SHA-1 (40) or SHA-256 (64) object id. Anything else read from disk is not a +// head and must never be emitted — this also caps what any path escape could leak. +const OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/ + +function asObjectId(value: string | null | undefined): string | null { + return value != null && OBJECT_ID_PATTERN.test(value) ? value : null +} + +async function resolveRefToOid( + commonDirPath: string, + ref: string, + packedRefs: () => Promise> +): Promise { + let current = ref + for (let depth = 0; depth < MAX_SYMREF_DEPTH; depth++) { + if (!isSafeRefName(current)) { + return null + } + // Branch refs are shared repo state, so loose files live in the common dir. + const loose = await readTrimmedFile(join(commonDirPath, ...current.split('/'))) + if (loose === null) { + return asObjectId((await packedRefs()).get(current)) + } + if (loose.startsWith('ref: ')) { + current = loose.slice('ref: '.length).trim() + continue + } + return asObjectId(loose) + } + return null +} + +async function readHeadIdentity( + commonDirPath: string, + headFilePath: string, + worktreePath: string, + packedRefs: () => Promise> +): Promise { + const head = await readTrimmedFile(headFilePath) + if (!head) { + return null + } + if (head.startsWith('ref: ')) { + const ref = head.slice('ref: '.length).trim() + const oid = await resolveRefToOid(commonDirPath, ref, packedRefs) + // Unborn branches (no commit yet) stay covered by the structural listing. + if (!oid) { + return null + } + return { worktreePath, head: oid, branch: ref } + } + const detachedOid = asObjectId(head) + return detachedOid ? { worktreePath, head: detachedOid, branch: null } : null +} + +/** Reads head/branch for the primary checkout and every linked worktree of a + * Git common dir using only metadata-file reads (HEAD, gitdir, loose refs, + * packed-refs) — no Git subprocess. Unresolvable entries are skipped so + * callers never overwrite store state with partial reads. */ +export async function readGitCommonHeadIdentities( + commonDirPath: string +): Promise { + let packedRefsPromise: Promise> | null = null + const packedRefs = (): Promise> => + (packedRefsPromise ??= readPackedRefs(commonDirPath)) + + const identities: WorktreeHeadIdentity[] = [] + // Only the standard `/.git` layout maps a common dir back to its + // primary checkout path; bare/custom GIT_DIR layouts have no primary row. + if (basename(commonDirPath) === '.git') { + const primary = await readHeadIdentity( + commonDirPath, + join(commonDirPath, 'HEAD'), + dirname(commonDirPath), + packedRefs + ) + if (primary) { + identities.push(primary) + } + } + + let entries + try { + entries = await readdir(join(commonDirPath, 'worktrees'), { withFileTypes: true }) + } catch { + return identities + } + for (const entry of entries) { + if (!entry.isDirectory()) { + continue + } + const entryPath = join(commonDirPath, 'worktrees', entry.name) + const gitdirContent = await readTrimmedFile(join(entryPath, 'gitdir')) + if (!gitdirContent) { + continue + } + // `gitdir` holds `/.git`, absolute or (with relative-path + // worktrees) relative to the entry dir. + const gitdirAbsolute = isAbsolute(gitdirContent) + ? gitdirContent + : join(entryPath, gitdirContent) + const identity = await readHeadIdentity( + commonDirPath, + join(entryPath, 'HEAD'), + dirname(gitdirAbsolute), + packedRefs + ) + if (identity) { + identities.push(identity) + } + } + return identities +} diff --git a/src/main/ipc/worktree-head-identity-refresh.ts b/src/main/ipc/worktree-head-identity-refresh.ts new file mode 100644 index 00000000000..a5c1e7d846f --- /dev/null +++ b/src/main/ipc/worktree-head-identity-refresh.ts @@ -0,0 +1,79 @@ +import type { BrowserWindow } from 'electron' +import { notifyWorktreeHeadIdentitiesChanged } from './worktree-remote' +import { readGitCommonHeadIdentities } from './worktree-head-identity-reader' + +type HeadIdentityWatchHost = { + path: string + repos: ReadonlyMap + mainWindow: BrowserWindow + disposed: boolean +} + +export type WorktreeHeadIdentityRefreshState = { + /** worktreePath → `${head} ${branch}` from the last metadata-file read. */ + baseline: Map | null + inFlight: boolean + queued: boolean + queuedEmit: boolean +} + +export function createWorktreeHeadIdentityRefreshState(): WorktreeHeadIdentityRefreshState { + return { baseline: null, inFlight: false, queued: false, queuedEmit: false } +} + +function headIdentitySignature(identity: { head: string; branch: string | null }): string { + return `${identity.head} ${identity.branch ?? ''}` +} + +/** Diffs metadata-file head reads against the previous baseline and notifies + * only actual head moves, so status-only churn (index rewrites from external + * `git status`) stays silent and never re-enters structural fanout. Passing + * `emit: false` re-baselines without notifying — structural ticks already + * run the authoritative worktree listing. */ +export async function refreshWorktreeHeadIdentities( + host: HeadIdentityWatchHost, + state: WorktreeHeadIdentityRefreshState, + emit: boolean +): Promise { + if (host.disposed || host.mainWindow.isDestroyed()) { + return + } + if (state.inFlight) { + state.queued = true + state.queuedEmit ||= emit + return + } + state.inFlight = true + try { + const identities = await readGitCommonHeadIdentities(host.path) + if (host.disposed || host.mainWindow.isDestroyed()) { + return + } + const baseline = state.baseline + state.baseline = new Map( + identities.map((identity) => [identity.worktreePath, headIdentitySignature(identity)]) + ) + if (!baseline || !emit) { + return + } + const changed = identities.filter( + (identity) => baseline.get(identity.worktreePath) !== headIdentitySignature(identity) + ) + if (changed.length === 0) { + return + } + for (const repoId of host.repos.keys()) { + notifyWorktreeHeadIdentitiesChanged(host.mainWindow, repoId, changed) + } + } catch (error) { + console.warn(`[worktree-base-watcher] head identity read failed for ${host.path}:`, error) + } finally { + state.inFlight = false + if (state.queued && !host.disposed) { + const queuedEmit = state.queuedEmit + state.queued = false + state.queuedEmit = false + void refreshWorktreeHeadIdentities(host, state, queuedEmit) + } + } +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 6a5db1be3de..8c8d4580d24 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -22,6 +22,7 @@ import type { LocalBaseRefUpdateSuggestion, Repo, Worktree, + WorktreeHeadIdentity, WorktreeMeta } from '../../shared/types' import { getPRForBranch } from '../github/client' @@ -1516,6 +1517,30 @@ export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string } } +export function notifyWorktreeGitStatusMetadataChanged( + mainWindow: BrowserWindow, + repoId: string +): void { + // Why: index churn is a Source Control freshness hint, not a worktree graph + // mutation; keep structural caches and runtime/mobile events untouched. + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('worktrees:gitStatusMetadataChanged', { repoId }) + } +} + +export function notifyWorktreeHeadIdentitiesChanged( + mainWindow: BrowserWindow, + repoId: string, + identities: WorktreeHeadIdentity[] +): void { + // Why: background worktrees have no active-scoped status refresh, so head + // moves detected from metadata files ride this targeted desktop event + // instead of re-entering the structural fanout or runtime/mobile events. + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('worktrees:headIdentitiesChanged', { repoId, identities }) + } +} + // Why: two-phase spinner. Main process fires `'fetching'` before waiting on // pre-create fetch work and `'creating'` immediately before `git worktree add`. // Renderer swaps its spinner label in response; fallback is the static diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 031f9cf703d..9f4dea40f92 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -511,6 +511,11 @@ describe('registerWorktreeHandlers', () => { expect(handlers['worktrees:resolveMrBase']).toBeDefined() }) + it('clears the branch rename failure-output handler before re-registering IPC handlers', () => { + expect(removeHandlerMock).toHaveBeenCalledWith('worktrees:getBranchRenameFailureOutput') + expect(handlers['worktrees:getBranchRenameFailureOutput']).toBeDefined() + }) + it('prefetches the local default create base through the runtime refresh cache', async () => { const repo = { id: 'repo-1', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 6aeb1144313..06397b341db 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -5,6 +5,7 @@ import { readFile, stat } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import type { Store } from '../persistence' import { isFolderRepo } from '../../shared/repo-kind' +import { readBranchRenameFailureOutputForDisplay } from '../agent-hooks/branch-rename-failure-output' import { isWorkspaceKey, parseWorkspaceKey, @@ -986,6 +987,7 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:listLineage') ipcMain.removeHandler('worktrees:updateLineage') ipcMain.removeHandler('worktrees:persistSortOrder') + ipcMain.removeHandler('worktrees:getBranchRenameFailureOutput') ipcMain.removeHandler('hooks:check') ipcMain.removeHandler('hooks:inspectSetupScriptImports') ipcMain.removeHandler('hooks:createIssueCommandRunner') @@ -2098,6 +2100,18 @@ export function registerWorktreeHandlers( } }) + // Why: the full generation-failure output is main-memory only (never in + // worktree metadata), so the rename-failed dialog pulls it on demand. + ipcMain.handle( + 'worktrees:getBranchRenameFailureOutput', + (_event, args: { worktreeId: string }) => { + if (typeof args?.worktreeId !== 'string' || args.worktreeId.length === 0) { + return null + } + return readBranchRenameFailureOutputForDisplay(args.worktreeId) + } + ) + ipcMain.handle( 'hooks:check', async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 528e804b0e8..03c5d400fb6 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4477,6 +4477,27 @@ describe('Store', () => { expect(updated.branchPrefix).toBe('git-username') }) + it('normalizes bot-author overrides on load and every settings write', async () => { + writeDataFile({ + settings: { + prBotAuthorOverrides: [' GretelFlux ', 'gretelflux', 42, '', 'another-bot'] + } + }) + const store = await createStore() + + expect(store.getSettings().prBotAuthorOverrides).toEqual(['another-bot', 'gretelflux']) + + const oversized = Array.from( + { length: 600 }, + (_, index) => ` bot-${String(index).padStart(4, '0')} ` + ) + const updated = store.updateSettings({ prBotAuthorOverrides: oversized }) + + expect(updated.prBotAuthorOverrides).toHaveLength(500) + expect(updated.prBotAuthorOverrides[0]).toBe('bot-0000') + expect(updated.prBotAuthorOverrides[499]).toBe('bot-0499') + }) + it('notifies settings listeners with changed keys only', async () => { const store = await createStore() const listener = vi.fn() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 1b3b9f891b8..170684aa83c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -112,6 +112,7 @@ import { parseWorkspaceSession } from '../shared/workspace-session-schema' import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice' +import { normalizePRBotAuthorOverrides } from '../shared/pr-bot-author-overrides' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostOrder, @@ -3136,6 +3137,9 @@ export class Store { // product intent was only to change the default, and the setting // stays user-toggleable. ...stripLegacyTerminalScrollbackBytes(parsed.settings), + prBotAuthorOverrides: normalizePRBotAuthorOverrides( + parsed.settings?.prBotAuthorOverrides + ), // Why: v1.3.42 renamed the cosmetic sidekick setting to pet. Carry // the old persisted flag forward once so enabled users don't lose it. experimentalPet: @@ -5332,6 +5336,13 @@ export class Store { if ('uiLanguage' in updates) { sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage) } + if ('prBotAuthorOverrides' in updates) { + // Why: every writer (desktop IPC, paired web RPC, and migrations) reaches + // this boundary, so the persisted list stays bounded and well-formed. + sanitizedUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides( + updates.prBotAuthorOverrides + ) + } const historyWithPreviousLayout = buildWorkspaceDirHistoryForUpdate( this.state.settings, sanitizedUpdates diff --git a/src/main/providers/ssh-filesystem-provider-watch.ts b/src/main/providers/ssh-filesystem-provider-watch.ts index 7beea078ac3..02b87f3d005 100644 --- a/src/main/providers/ssh-filesystem-provider-watch.ts +++ b/src/main/providers/ssh-filesystem-provider-watch.ts @@ -4,6 +4,51 @@ import type { FsChangeEvent } from '../../shared/types' export type WatchRegistration = { callbacks: Set<(events: FsChangeEvent[]) => void> setupPromise: Promise + setupAbortController: AbortController + ready: boolean + stopping: boolean + unwatchSent: boolean +} + +function createWatchAbortError(): Error { + const error = new Error('Request "fs.watch" was cancelled') as Error & { name: string } + error.name = 'AbortError' + return error +} + +async function awaitSetupWithOptionalAbort( + setupPromise: Promise, + signal?: AbortSignal +): Promise { + if (!signal) { + await setupPromise + return + } + if (signal.aborted) { + throw createWatchAbortError() + } + await new Promise((resolve, reject) => { + const onAbort = (): void => { + cleanup() + reject(createWatchAbortError()) + } + const onSettled = (error?: unknown): void => { + cleanup() + if (error === undefined) { + resolve() + } else { + reject(error) + } + } + const cleanup = (): void => { + signal.removeEventListener('abort', onAbort) + } + signal.addEventListener('abort', onAbort, { once: true }) + setupPromise.then( + () => onSettled(), + (error) => onSettled(error) + ) + }) } export async function registerSshFilesystemWatch(args: { @@ -12,37 +57,79 @@ export async function registerSshFilesystemWatch(args: { registrations: Map rootPath: string callback: (events: FsChangeEvent[]) => void + signal?: AbortSignal }): Promise<() => void> { if (args.disposed()) { throw new Error('SSH filesystem provider disposed') } + if (args.signal?.aborted) { + throw createWatchAbortError() + } + let registration = args.registrations.get(args.rootPath) if (registration) { + if (registration.stopping) { + throw createWatchAbortError() + } registration.callbacks.add(args.callback) - await registration.setupPromise - assertActiveWatch(args, registration) - return createSshFilesystemWatchUnsubscribe(args, registration) + try { + // Why: each caller may leave a shared setup independently; registration + // ownership decides whether the physical relay request should stop. + await awaitSetupWithOptionalAbort(registration.setupPromise, args.signal) + assertActiveWatch(args, registration) + return createSshFilesystemWatchUnsubscribe(args, registration) + } catch (error) { + releaseSshFilesystemWatchCallback(args, registration) + throw error + } } const callbacks = new Set<(events: FsChangeEvent[]) => void>([args.callback]) - const setupPromise = args.mux.request('fs.watch', { rootPath: args.rootPath }).then( - () => undefined, - (error) => { - if (args.registrations.get(args.rootPath) === registration) { - args.registrations.delete(args.rootPath) + const setupAbortController = new AbortController() + registration = { + callbacks, + setupAbortController, + ready: false, + stopping: false, + unwatchSent: false, + setupPromise: Promise.resolve() + } + const createdRegistration = registration + // Why: the shared registration, not its first caller, owns relay setup. + // This keeps a same-root joiner alive when the original caller disconnects. + registration.setupPromise = args.mux + .request('fs.watch', { rootPath: args.rootPath }, { signal: setupAbortController.signal }) + .then( + () => { + createdRegistration.ready = true + if ( + createdRegistration.stopping || + createdRegistration.callbacks.size === 0 || + args.disposed() || + args.registrations.get(args.rootPath) !== createdRegistration + ) { + if (args.registrations.get(args.rootPath) === createdRegistration) { + args.registrations.delete(args.rootPath) + } + sendSshFilesystemUnwatchOnce(args.mux, args.rootPath, createdRegistration) + } + }, + (error) => { + if (args.registrations.get(args.rootPath) === createdRegistration) { + args.registrations.delete(args.rootPath) + } + throw error } - throw error - } - ) - registration = { callbacks, setupPromise } + ) args.registrations.set(args.rootPath, registration) - await setupPromise - if (args.disposed() || args.registrations.get(args.rootPath) !== registration) { - notifySshFilesystemUnwatch(args.mux, args.rootPath) - throw new Error('SSH filesystem provider disposed') + try { + await awaitSetupWithOptionalAbort(registration.setupPromise, args.signal) + assertActiveWatch(args, registration) + return createSshFilesystemWatchUnsubscribe(args, registration) + } catch (error) { + releaseSshFilesystemWatchCallback(args, registration) + throw error } - - return createSshFilesystemWatchUnsubscribe(args, registration) } export function notifySshFilesystemUnwatch(mux: SshChannelMultiplexer, rootPath: string): void { @@ -74,13 +161,52 @@ function createSshFilesystemWatchUnsubscribe( registration: WatchRegistration ): () => void { return () => { - registration.callbacks.delete(args.callback) - if ( - registration.callbacks.size === 0 && - args.registrations.get(args.rootPath) === registration - ) { - args.registrations.delete(args.rootPath) - notifySshFilesystemUnwatch(args.mux, args.rootPath) - } + releaseSshFilesystemWatchCallback(args, registration) } } + +function releaseSshFilesystemWatchCallback( + args: { + mux: SshChannelMultiplexer + registrations: Map + rootPath: string + callback: (events: FsChangeEvent[]) => void + }, + registration: WatchRegistration +): void { + registration.callbacks.delete(args.callback) + if (registration.callbacks.size > 0 || args.registrations.get(args.rootPath) !== registration) { + return + } + registration.stopping = true + if (registration.ready) { + args.registrations.delete(args.rootPath) + sendSshFilesystemUnwatchOnce(args.mux, args.rootPath, registration) + } else { + // Keep the cancelling registration indexed until its request settles so a + // new same-root setup cannot race an old late success and be unwatched. + registration.setupAbortController.abort() + } +} + +export function stopSshFilesystemWatchRegistration( + mux: SshChannelMultiplexer, + rootPath: string, + registration: WatchRegistration +): void { + registration.stopping = true + registration.setupAbortController.abort() + sendSshFilesystemUnwatchOnce(mux, rootPath, registration) +} + +function sendSshFilesystemUnwatchOnce( + mux: SshChannelMultiplexer, + rootPath: string, + registration: WatchRegistration +): void { + if (registration.unwatchSent) { + return + } + registration.unwatchSent = true + notifySshFilesystemUnwatch(mux, rootPath) +} diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index cc3e890c338..d583a93d7cc 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -538,10 +538,99 @@ describe('SshFilesystemProvider', () => { const callback = vi.fn() const unsub = await provider.watch('/home/user/project', callback) - expect(mux.request).toHaveBeenCalledWith('fs.watch', { rootPath: '/home/user/project' }) + expect(mux.request).toHaveBeenCalledWith( + 'fs.watch', + { rootPath: '/home/user/project' }, + { signal: expect.any(AbortSignal) } + ) expect(typeof unsub).toBe('function') }) + it('uses a registration-owned cancellation signal for the mux fs.watch request', async () => { + mux.request.mockResolvedValue(undefined) + const controller = new AbortController() + const callback = vi.fn() + + await provider.watch('/home/user/project', callback, { signal: controller.signal }) + + expect(mux.request).toHaveBeenCalledWith( + 'fs.watch', + { rootPath: '/home/user/project' }, + { signal: expect.any(AbortSignal) } + ) + const physicalSignal = mux.request.mock.calls[0][2]?.signal + expect(physicalSignal).not.toBe(controller.signal) + expect(physicalSignal?.aborted).toBe(false) + }) + + it('rejects promptly when the watch signal aborts during setup', async () => { + let resolveWatch: () => void = () => {} + mux.request.mockImplementationOnce( + (_method, _params, options?: { signal?: AbortSignal }) => + new Promise((resolve, reject) => { + resolveWatch = resolve + options?.signal?.addEventListener( + 'abort', + () => { + const error = new Error('Request "fs.watch" was cancelled') as Error & { + name: string + } + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + ) + const controller = new AbortController() + const pendingWatch = provider.watch('/home/user/project', vi.fn(), { + signal: controller.signal + }) + + controller.abort() + + await expect(pendingWatch).rejects.toMatchObject({ name: 'AbortError' }) + expect(mux.request.mock.calls[0][2]?.signal?.aborted).toBe(true) + resolveWatch() + }) + + it('keeps shared setup alive when only its first caller aborts', async () => { + let resolveWatch: () => void = () => {} + let physicalSignal: AbortSignal | undefined + mux.request.mockImplementationOnce( + (_method, _params, options?: { signal?: AbortSignal }) => + new Promise((resolve) => { + resolveWatch = resolve + physicalSignal = options?.signal + }) + ) + const firstController = new AbortController() + const first = vi.fn() + const second = vi.fn() + + const firstWatch = provider.watch('/home/user/project', first, { + signal: firstController.signal + }) + const secondWatch = provider.watch('/home/user/project', second) + firstController.abort() + + await expect(firstWatch).rejects.toMatchObject({ name: 'AbortError' }) + expect(physicalSignal?.aborted).toBe(false) + + resolveWatch() + const unsubscribeSecond = await secondWatch + const notifHandler = mux.onNotification.mock.calls[0][0] + const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] + notifHandler('fs.changed', { events }) + + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledWith(events) + unsubscribeSecond() + expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { + rootPath: '/home/user/project' + }) + }) + it('forwards fs.changed notifications to watch callback', async () => { const callback = vi.fn() await provider.watch('/home/user/project', callback) @@ -560,7 +649,11 @@ describe('SshFilesystemProvider', () => { const unsubSecond = await provider.watch('/home/user/project', second) expect(mux.request).toHaveBeenCalledTimes(1) - expect(mux.request).toHaveBeenCalledWith('fs.watch', { rootPath: '/home/user/project' }) + expect(mux.request).toHaveBeenCalledWith( + 'fs.watch', + { rootPath: '/home/user/project' }, + { signal: expect.any(AbortSignal) } + ) const notifHandler = mux.onNotification.mock.calls[0][0] const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index cb95683f9fd..ed4520e8f5a 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -8,8 +8,8 @@ import { type SshRawTransferOptions } from './ssh-filesystem-file-upload' import { - notifySshFilesystemUnwatch, registerSshFilesystemWatch, + stopSshFilesystemWatchRegistration, type WatchRegistration } from './ssh-filesystem-provider-watch' import type { @@ -66,8 +66,8 @@ export class SshFilesystemProvider implements IFilesystemProvider { this.unsubscribeNotifications() this.unsubscribeNotifications = null } - for (const rootPath of this.watchListeners.keys()) { - notifySshFilesystemUnwatch(this.mux, rootPath) + for (const [rootPath, registration] of this.watchListeners) { + stopSshFilesystemWatchRegistration(this.mux, rootPath, registration) } this.watchListeners.clear() } @@ -316,13 +316,18 @@ export class SshFilesystemProvider implements IFilesystemProvider { })) as string[] } - async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> { + async watch( + rootPath: string, + callback: (events: FsChangeEvent[]) => void, + options?: { signal?: AbortSignal } + ): Promise<() => void> { return registerSshFilesystemWatch({ mux: this.mux, disposed: () => this.disposed, registrations: this.watchListeners, rootPath, - callback + callback, + signal: options?.signal }) } } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index d5cf5c60985..66642e38ad1 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -279,7 +279,11 @@ export type IFilesystemProvider = { rootPath: string, options?: { signal?: AbortSignal } ): Promise - watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> + watch( + rootPath: string, + callback: (events: FsChangeEvent[]) => void, + options?: { signal?: AbortSignal } + ): Promise<() => void> } export type FileUploadSession = { diff --git a/src/main/providers/windows-foreground-process-rows.test.ts b/src/main/providers/windows-foreground-process-rows.test.ts new file mode 100644 index 00000000000..61482224e50 --- /dev/null +++ b/src/main/providers/windows-foreground-process-rows.test.ts @@ -0,0 +1,99 @@ +// Regression guard: the Windows agent foreground-process scan re-forks +// powershell.exe (or the wmic fallback) on a ~1s/pane cadence. Electron's main +// process has no console, so a spawn without windowsHide pops a fresh conhost +// window per scan that flashes and steals keyboard focus from the foreground app +// (including Orca's own terminal). Both probes MUST pass windowsHide: true. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) + +vi.mock('child_process', () => ({ execFile: execFileMock })) + +import { + queryWindowsProcessDescendants, + resetWindowsProcessRowsSnapshotForTests +} from './windows-foreground-process-rows' + +type ExecFileCallback = (err: unknown, result: { stdout: string; stderr: string }) => void +type ExecFileCall = [string, string[], Record, ExecFileCallback] + +const POWERSHELL_ROWS_JSON = JSON.stringify([ + { + ProcessId: 100, + ParentProcessId: 50, + Name: 'powershell.exe', + CommandLine: 'powershell.exe', + ExecutablePath: 'C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe' + }, + { + ProcessId: 200, + ParentProcessId: 100, + Name: 'node.exe', + CommandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js', + ExecutablePath: 'C:/Program Files/nodejs/node.exe' + } +]) + +const WMIC_ROWS_VALUE = + 'CommandLine=powershell.exe\n' + + 'ExecutablePath=C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe\n' + + 'Name=powershell.exe\n' + + 'ParentProcessId=50\n' + + 'ProcessId=100\n\n' + + 'CommandLine=node C:/Users/dev/AppData/codex/bin/codex.js\n' + + 'ExecutablePath=C:/Program Files/nodejs/node.exe\n' + + 'Name=node.exe\n' + + 'ParentProcessId=100\n' + + 'ProcessId=200\n' + +/** Returns the options object passed to the mocked execFile for a given command. */ +function optionsForCommand(command: string): Record | undefined { + const call = execFileMock.mock.calls.find((args) => (args as ExecFileCall)[0] === command) as + | ExecFileCall + | undefined + return call?.[2] +} + +describe('windows foreground process rows spawn options', () => { + let platform: PropertyDescriptor | undefined + + beforeEach(() => { + execFileMock.mockReset() + resetWindowsProcessRowsSnapshotForTests() + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + it('hides the console window for the powershell process-table scan', async () => { + execFileMock.mockImplementation((_cmd: string, _args, _opts, cb: ExecFileCallback) => { + cb(null, { stdout: POWERSHELL_ROWS_JSON, stderr: '' }) + }) + + const candidates = await queryWindowsProcessDescendants(100) + + expect(candidates?.[0]?.pid).toBe(200) + expect(optionsForCommand('powershell.exe')).toMatchObject({ windowsHide: true }) + }) + + it('hides the console window for the wmic fallback scan', async () => { + execFileMock.mockImplementation((cmd: string, _args, _opts, cb: ExecFileCallback) => { + // Force the powershell probe to miss so the wmic fallback runs. + if (cmd === 'powershell.exe') { + cb(new Error('powershell unavailable'), { stdout: '', stderr: '' }) + return + } + cb(null, { stdout: WMIC_ROWS_VALUE, stderr: '' }) + }) + + const candidates = await queryWindowsProcessDescendants(100) + + expect(candidates?.[0]?.pid).toBe(200) + expect(optionsForCommand('wmic')).toMatchObject({ windowsHide: true }) + }) +}) diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index 6fea54165c8..97aea1dccc1 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -209,6 +209,7 @@ function collectDescendants( return descendants } +/** Runs the PowerShell/CIM whole-process-table scan; returns null when unavailable. */ async function queryWindowsProcessesWithPowerShell(): Promise { try { const { stdout } = await execFileAsync( @@ -217,7 +218,12 @@ async function queryWindowsProcessesWithPowerShell(): Promise { try { const { stdout } = await execFileAsync( @@ -240,7 +247,10 @@ async function queryWindowsProcessesWithWmic(): Promise { error: 'Grok auth file is invalid' }) }) + + it.each(['https://auth.x.ai', 'https://auth.x.ai::client'])( + 'prefers the %s issuer entry over an earlier alternate issuer', + async (preferredIssuer) => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://stale.example.com::client': { + key: 'stale-token', + user_id: 'stale-user', + email: 'stale@example.com', + expires_at: '2099-01-01T00:00:00.000Z' + }, + [preferredIssuer]: { + key: 'live-token', + user_id: 'live-user', + email: 'live@example.com', + team_id: 'team-1', + expires_at: '2099-06-01T00:00:00.000Z', + oidc_client_id: 'client-1' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ + status: 'ok', + session: { + accessToken: 'live-token', + userId: 'live-user', + email: 'live@example.com', + teamId: 'team-1', + expiresAtMs: Date.parse('2099-06-01T00:00:00.000Z'), + oidcClientId: 'client-1' + } + }) + } + ) + + it('falls back to the first tokenized entry when no auth.x.ai key exists', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://alternate.example.com::client': { + key: 'alt-token', + user_id: 'alt-user', + email: 'alt@example.com', + expires_at: '2099-01-01T00:00:00.000Z' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ + status: 'ok', + session: { + accessToken: 'alt-token', + userId: 'alt-user', + email: 'alt@example.com', + teamId: null, + expiresAtMs: Date.parse('2099-01-01T00:00:00.000Z'), + oidcClientId: null + } + }) + }) + + it('skips an expired auth.x.ai client entry when a fresh one follows it', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://auth.x.ai::old-client': { + key: 'expired-token', + expires_at: '2020-01-01T00:00:00.000Z' + }, + 'https://auth.x.ai::current-client': { + key: 'fresh-token', + expires_at: '2099-01-01T00:00:00.000Z' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toMatchObject({ + status: 'ok', + session: { accessToken: 'fresh-token' } + }) + }) + + it('does not resurrect an alternate issuer when an auth.x.ai entry is tokenless', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://alternate.example.com::client': { key: 'stale-token' }, + 'https://auth.x.ai::client': { user_id: 'signed-out-user' } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ status: 'missing' }) + }) }) diff --git a/src/main/rate-limits/grok-auth.ts b/src/main/rate-limits/grok-auth.ts index 10ba629ff34..e055c022975 100644 --- a/src/main/rate-limits/grok-auth.ts +++ b/src/main/rate-limits/grok-auth.ts @@ -29,6 +29,8 @@ type GrokAuthEntry = { oidc_client_id?: string } +type TokenizedGrokAuthEntry = GrokAuthEntry & { key: string } + export type GrokAuthReadResult = | { status: 'missing' } | { status: 'error'; error: string } @@ -43,7 +45,7 @@ function getGrokAuthReadError(err: unknown): string { return 'Unable to read Grok auth file' } -function parseAuthEntry(value: unknown): GrokAuthEntry | null { +function parseAuthEntry(value: unknown): TokenizedGrokAuthEntry | null { if (typeof value !== 'object' || value === null) { return null } @@ -51,7 +53,7 @@ function parseAuthEntry(value: unknown): GrokAuthEntry | null { if (typeof entry.key !== 'string' || entry.key.length === 0) { return null } - return entry + return entry as TokenizedGrokAuthEntry } function parseExpiresAtMs(iso: string | undefined): number | null { @@ -62,6 +64,24 @@ function parseExpiresAtMs(iso: string | undefined): number | null { return Number.isFinite(ms) ? ms : null } +// Why: stale alternate issuers can precede the default xAI OAuth session in auth.json. +const PREFERRED_GROK_AUTH_ISSUER = 'https://auth.x.ai' + +function sessionFromAuthEntry(authEntry: TokenizedGrokAuthEntry): GrokAuthSession { + return { + accessToken: authEntry.key, + userId: typeof authEntry.user_id === 'string' ? authEntry.user_id : null, + email: typeof authEntry.email === 'string' ? authEntry.email : null, + teamId: typeof authEntry.team_id === 'string' ? authEntry.team_id : null, + expiresAtMs: parseExpiresAtMs(authEntry.expires_at), + oidcClientId: typeof authEntry.oidc_client_id === 'string' ? authEntry.oidc_client_id : null + } +} + +function isPreferredGrokAuthKey(key: string): boolean { + return key === PREFERRED_GROK_AUTH_ISSUER || key.startsWith(`${PREFERRED_GROK_AUTH_ISSUER}::`) +} + export function readGrokAuthSession(): GrokAuthReadResult { const path = getGrokAuthPath() if (!existsSync(path)) { @@ -72,23 +92,32 @@ export function readGrokAuthSession(): GrokAuthReadResult { if (typeof parsed !== 'object' || parsed === null) { return { status: 'error', error: 'Grok auth file is invalid' } } - for (const entry of Object.values(parsed)) { + let preferredKeySeen = false + let expiredPreferred: GrokAuthSession | null = null + let fallback: GrokAuthSession | null = null + for (const [key, entry] of Object.entries(parsed as Record)) { + const isPreferred = isPreferredGrokAuthKey(key) + preferredKeySeen ||= isPreferred const authEntry = parseAuthEntry(entry) - if (!authEntry?.key) { + if (!authEntry) { continue } - return { - status: 'ok', - session: { - accessToken: authEntry.key, - userId: typeof authEntry.user_id === 'string' ? authEntry.user_id : null, - email: typeof authEntry.email === 'string' ? authEntry.email : null, - teamId: typeof authEntry.team_id === 'string' ? authEntry.team_id : null, - expiresAtMs: parseExpiresAtMs(authEntry.expires_at), - oidcClientId: - typeof authEntry.oidc_client_id === 'string' ? authEntry.oidc_client_id : null + const session = sessionFromAuthEntry(authEntry) + if (isPreferred) { + if (isGrokAccessTokenFresh(session)) { + return { status: 'ok', session } } + expiredPreferred ??= session + continue } + if (!fallback) { + fallback = session + } + } + // Why: alternate issuers are compatibility fallbacks only when no default entry exists. + const selectedSession = expiredPreferred ?? (preferredKeySeen ? null : fallback) + if (selectedSession) { + return { status: 'ok', session: selectedSession } } // Why: a token-less file (e.g. after grok logout) means signed out, not a // failure — 'error' would keep a status-bar alert visible for that user. diff --git a/src/main/runtime/file-watcher-host.test.ts b/src/main/runtime/file-watcher-host.test.ts index 6abea97bac7..e64a7e9d145 100644 --- a/src/main/runtime/file-watcher-host.test.ts +++ b/src/main/runtime/file-watcher-host.test.ts @@ -1,236 +1,301 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FsChangeEvent } from '../../shared/types' +import type { + WatcherProcessCallback, + WatcherProcessHooks, + WatcherProcessSubscription +} from '../ipc/parcel-watcher-process' +import { WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' +import { + WATCHER_IGNORE_DIRS, + buildParcelWatcherIgnoreOptions +} from '../ipc/filesystem-watcher-ignore' -type MockWorker = { - terminated: boolean - postedMessages: unknown[] - workerData: unknown - on(event: string, listener: (arg?: unknown) => void): MockWorker - once(event: string, listener: (arg?: unknown) => void): MockWorker - off(event: string, listener: (arg?: unknown) => void): MockWorker - postMessage(message: unknown): void - terminate(): Promise - emit(event: string, arg?: unknown): void - listenerCount(event: string): number +const { + forgetRuntimeWatcherProcessRootMock, + resetRuntimeWatcherProcessForTestMock, + subscribeViaRuntimeWatcherProcessMock +} = vi.hoisted(() => ({ + forgetRuntimeWatcherProcessRootMock: vi.fn(), + resetRuntimeWatcherProcessForTestMock: vi.fn(), + subscribeViaRuntimeWatcherProcessMock: vi.fn() +})) + +vi.mock('../ipc/parcel-watcher-process', () => ({ + forgetRuntimeWatcherProcessRoot: forgetRuntimeWatcherProcessRootMock, + resetRuntimeWatcherProcessForTest: resetRuntimeWatcherProcessForTestMock, + subscribeViaRuntimeWatcherProcess: subscribeViaRuntimeWatcherProcessMock +})) + +import { + resetRuntimeRootWatchersForTest, + watchFileExplorerInWatcherProcess +} from './file-watcher-host' + +type InstalledWatch = { + callback: WatcherProcessCallback + hooks: WatcherProcessHooks + unsubscribe: ReturnType Promise>> } -const workerState = vi.hoisted(() => { - const instances: MockWorker[] = [] - class MockWorkerImpl { - terminated = false - postedMessages: unknown[] = [] - workerData: unknown - private listeners = new Map void; once: boolean }[]>() - - constructor(_workerPath: string, options: { workerData?: unknown }) { - this.workerData = options.workerData - instances.push(this as unknown as MockWorker) +function installSuccessfulWatch(): InstalledWatch { + let callback: WatcherProcessCallback | undefined + let hooks: WatcherProcessHooks | undefined + const unsubscribe = vi.fn(async () => undefined) + subscribeViaRuntimeWatcherProcessMock.mockImplementationOnce( + async ( + _rootPath: string, + nextCallback: WatcherProcessCallback, + _options: unknown, + nextHooks: WatcherProcessHooks + ): Promise => { + callback = nextCallback + hooks = nextHooks + return { unsubscribe } } - - on(event: string, listener: (arg?: unknown) => void): this { - const list = this.listeners.get(event) ?? [] - list.push({ listener, once: false }) - this.listeners.set(event, list) - return this - } - - once(event: string, listener: (arg?: unknown) => void): this { - const list = this.listeners.get(event) ?? [] - list.push({ listener, once: true }) - this.listeners.set(event, list) - return this - } - - off(event: string, listener: (arg?: unknown) => void): this { - const list = this.listeners.get(event) ?? [] - this.listeners.set( - event, - list.filter((entry) => entry.listener !== listener) - ) - return this - } - - postMessage(message: unknown): void { - this.postedMessages.push(message) - } - - async terminate(): Promise { - this.terminated = true - return 0 - } - - emit(event: string, arg?: unknown): void { - const entries = this.listeners.get(event)?.slice() ?? [] - for (const entry of entries) { - if (entry.once) { - this.off(event, entry.listener) - } - entry.listener(arg) + ) + return { + get callback() { + if (!callback) { + throw new Error('watch callback not installed') } - } - - listenerCount(event: string): number { - return this.listeners.get(event)?.length ?? 0 - } + return callback + }, + get hooks() { + if (!hooks) { + throw new Error('watch hooks not installed') + } + return hooks + }, + unsubscribe } - return { instances, MockWorkerImpl } -}) - -vi.mock('electron', () => ({ - app: { isPackaged: false } -})) - -vi.mock('worker_threads', () => ({ - Worker: workerState.MockWorkerImpl -})) - -import { watchFileExplorerInWorker } from './file-watcher-host' - -function lastWorker(): MockWorker { - const worker = workerState.instances.at(-1) - if (!worker) { - throw new Error('no worker spawned') - } - return worker } -describe('watchFileExplorerInWorker', () => { +describe('watchFileExplorerInWatcherProcess', () => { beforeEach(() => { - workerState.instances.length = 0 + forgetRuntimeWatcherProcessRootMock.mockReset() + resetRuntimeWatcherProcessForTestMock.mockReset() + subscribeViaRuntimeWatcherProcessMock.mockReset() }) afterEach(() => { + resetRuntimeRootWatchersForTest() vi.restoreAllMocks() }) - it('resolves to an unsubscribe fn once the worker reports ready', async () => { - const promise = watchFileExplorerInWorker('/repo', vi.fn()) - const worker = lastWorker() - expect(worker.workerData).toMatchObject({ rootPath: '/repo' }) + it('installs a bounded watch in the shared runtime watcher process', async () => { + const watch = installSuccessfulWatch() - worker.emit('message', { type: 'ready' }) - const dispose = await promise - expect(typeof dispose).toBe('function') - }) + const dispose = await watchFileExplorerInWatcherProcess('/repo', vi.fn()) - it('forwards worker events to the callback only after ready', async () => { - const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() - const promise = watchFileExplorerInWorker('/repo', onEvents) - const worker = lastWorker() - worker.emit('message', { type: 'ready' }) - await promise - - const events: FsChangeEvent[] = [ - { kind: 'update', absolutePath: '/repo/a.txt', isDirectory: false } - ] - worker.emit('message', { type: 'events', events }) - expect(onEvents).toHaveBeenCalledWith(events) - }) - - it('rejects if the worker errors before the crawl goes live', async () => { - const promise = watchFileExplorerInWorker('/repo', vi.fn()) - const worker = lastWorker() - worker.emit('message', { type: 'error', message: 'addon missing' }) - - await expect(promise).rejects.toThrow('addon missing') - expect(worker.terminated).toBe(true) - }) - - it('rejects if the worker exits before ready', async () => { - const promise = watchFileExplorerInWorker('/repo', vi.fn()) - const worker = lastWorker() - worker.emit('exit', 1) - - await expect(promise).rejects.toThrow(/exited before ready/) - }) - - it('emits an overflow if a live worker crashes', async () => { - const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() - const promise = watchFileExplorerInWorker('/repo', onEvents) - const worker = lastWorker() - worker.emit('message', { type: 'ready' }) - await promise - - worker.emit('error', new Error('boom')) - expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: '/repo' }]) - }) - - it('unsubscribes and waits for a clean worker exit without force-terminating', async () => { - const promise = watchFileExplorerInWorker('/repo', vi.fn()) - const worker = lastWorker() - worker.emit('message', { type: 'ready' }) - const dispose = await promise - - const disposed = dispose() - expect(worker.postedMessages).toContainEqual({ type: 'unsubscribe' }) - // The worker unsubscribes its native watcher, closes its port and exits on - // its own — no force terminate, which is what corrupts the native watcher. - worker.emit('exit', 0) - await disposed - expect(worker.terminated).toBe(false) - expect(worker.listenerCount('exit')).toBe(1) - - // Idempotent: a second dispose does nothing further. - await dispose() - expect( - worker.postedMessages.filter((m) => (m as { type?: string }).type === 'unsubscribe') - ).toHaveLength(1) - }) - - it('shares pending dispose work across racing callers', async () => { - const promise = watchFileExplorerInWorker('/repo', vi.fn()) - const worker = lastWorker() - worker.emit('message', { type: 'ready' }) - const dispose = await promise + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledWith( + '/repo', + expect.any(Function), + buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS), + expect.objectContaining({ + delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 200 }, + onInterruption: expect.any(Function), + onOverflow: expect.any(Function), + onTerminalError: expect.any(Function), + signal: expect.any(AbortSignal) + }) + ) const firstDispose = dispose() const secondDispose = dispose() expect(secondDispose).toBe(firstDispose) - expect( - worker.postedMessages.filter((m) => (m as { type?: string }).type === 'unsubscribe') - ).toHaveLength(1) - - worker.emit('exit', 0) - await Promise.all([firstDispose, secondDispose]) - expect(worker.terminated).toBe(false) + await firstDispose + expect(watch.unsubscribe).toHaveBeenCalledTimes(1) }) - it('force-terminates the worker only if it fails to exit within the timeout', async () => { - vi.useFakeTimers() - try { - const promise = watchFileExplorerInWorker('/repo', vi.fn()) - const worker = lastWorker() - worker.emit('message', { type: 'ready' }) - const dispose = await promise - - const disposed = dispose() - expect(worker.postedMessages).toContainEqual({ type: 'unsubscribe' }) - expect(worker.listenerCount('exit')).toBe(2) - // Worker is wedged and never emits exit: the backstop must terminate it. - await vi.advanceTimersByTimeAsync(10_000) - await disposed - expect(worker.terminated).toBe(true) - expect(worker.listenerCount('exit')).toBe(1) - } finally { - vi.useRealTimers() - } - }) - - it('stops forwarding events after dispose', async () => { + it('maps child-enriched watcher events without host-process stat work', async () => { + const watch = installSuccessfulWatch() const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() - const promise = watchFileExplorerInWorker('/repo', onEvents) - const worker = lastWorker() - worker.emit('message', { type: 'ready' }) - const dispose = await promise - const disposed = dispose() - worker.emit('exit', 0) - await disposed - onEvents.mockClear() + await watchFileExplorerInWatcherProcess('/repo', onEvents) + + watch.callback(null, [ + { type: 'update', path: '/repo/file.txt', isDirectory: false }, + { type: 'create', path: '/repo/dir', isDirectory: true }, + { type: 'delete', path: '/repo/gone.txt' } + ]) + + expect(onEvents).toHaveBeenCalledWith([ + { kind: 'update', absolutePath: '/repo/file.txt', isDirectory: false }, + { kind: 'create', absolutePath: '/repo/dir', isDirectory: true }, + { kind: 'delete', absolutePath: '/repo/gone.txt', isDirectory: undefined } + ]) + }) + + it('turns child overflow and recoverable watcher errors into a conservative refresh', async () => { + const watch = installSuccessfulWatch() + const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() + vi.spyOn(console, 'error').mockImplementation(() => undefined) + await watchFileExplorerInWatcherProcess('/repo', onEvents) + + watch.hooks.onOverflow?.() + watch.callback(new Error('Events were dropped by the FSEvents client.'), []) + + expect(onEvents).toHaveBeenNthCalledWith(1, [{ kind: 'overflow', absolutePath: '/repo' }]) + expect(onEvents).toHaveBeenNthCalledWith(2, [{ kind: 'overflow', absolutePath: '/repo' }]) + }) + + it('refreshes only after the child has resubscribed', async () => { + const watch = installSuccessfulWatch() + const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() + await watchFileExplorerInWatcherProcess('/repo', onEvents) + + watch.hooks.onInterruption?.() + + expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: '/repo' }]) + }) + + it('keeps same-root subscribers alive while a failed shard is replaced', async () => { + const watch = installSuccessfulWatch() + const replacement = installSuccessfulWatch() + const firstEvents = vi.fn<(events: FsChangeEvent[]) => void>() + const secondEvents = vi.fn<(events: FsChangeEvent[]) => void>() + const firstError = vi.fn() + const secondError = vi.fn() + await watchFileExplorerInWatcherProcess('/repo', firstEvents, firstError) + await watchFileExplorerInWatcherProcess('/repo', secondEvents, secondError) + + watch.hooks.onTerminalError?.( + new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') + ) + await vi.waitFor(() => expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(2)) + watch.callback(null, [{ type: 'update', path: '/repo/late.txt' }]) + replacement.callback(null, [{ type: 'update', path: '/repo/recovered.txt' }]) + + expect(firstError).not.toHaveBeenCalled() + expect(secondError).not.toHaveBeenCalled() + expect(firstEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: '/repo' }]) + expect(firstEvents).toHaveBeenCalledWith([ + { kind: 'update', absolutePath: '/repo/recovered.txt', isDirectory: undefined } + ]) + expect(secondEvents).toHaveBeenCalledTimes(2) + }) + + it('ends subscribers only after isolated recovery also fails', async () => { + const watch = installSuccessfulWatch() + subscribeViaRuntimeWatcherProcessMock.mockRejectedValueOnce(new Error('isolated failure')) + const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() + const onTerminalError = vi.fn() + await watchFileExplorerInWatcherProcess('/repo', onEvents, onTerminalError) + + watch.hooks.onTerminalError?.( + new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') + ) + + await vi.waitFor(() => + expect(onTerminalError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'isolated failure' }) + ) + ) + expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: '/repo' }]) + }) + + it('cancels a pending physical watch when its only client aborts', async () => { + let physicalSignal: AbortSignal | undefined + subscribeViaRuntimeWatcherProcessMock.mockImplementation( + (_rootPath, _callback, _options, hooks: WatcherProcessHooks) => + new Promise((_resolve, reject) => { + physicalSignal = hooks.signal + hooks.signal?.addEventListener( + 'abort', + () => reject(new Error('physical watch aborted')), + { once: true } + ) + }) + ) + const controller = new AbortController() + const pending = watchFileExplorerInWatcherProcess('/repo', vi.fn(), vi.fn(), controller.signal) + + controller.abort() + + await expect(pending).rejects.toThrow('aborted') + expect(physicalSignal?.aborted).toBe(true) + }) + + it('keeps pending setup alive when another same-root client still owns it', async () => { + let physicalSignal: AbortSignal | undefined + let resolvePhysical: ((subscription: WatcherProcessSubscription) => void) | undefined + const unsubscribe = vi.fn(async () => undefined) + subscribeViaRuntimeWatcherProcessMock.mockImplementation( + (_rootPath, _callback, _options, hooks: WatcherProcessHooks) => { + physicalSignal = hooks.signal + return new Promise((resolve) => { + resolvePhysical = resolve + }) + } + ) + const controller = new AbortController() + const first = watchFileExplorerInWatcherProcess('/repo', vi.fn(), vi.fn(), controller.signal) + const second = watchFileExplorerInWatcherProcess('/repo', vi.fn()) + + controller.abort() + await expect(first).rejects.toThrow('aborted') + expect(physicalSignal?.aborted).toBe(false) + + resolvePhysical?.({ unsubscribe }) + await expect(second).resolves.toEqual(expect.any(Function)) + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(1) + }) + + it('shares one native subscription per root across same-root callers', async () => { + const firstRoot = installSuccessfulWatch() + const secondRoot = installSuccessfulWatch() + + await watchFileExplorerInWatcherProcess('/repo-a', vi.fn()) + await watchFileExplorerInWatcherProcess('/repo-a', vi.fn()) + await watchFileExplorerInWatcherProcess('/repo-b', vi.fn()) + + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(2) + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenNthCalledWith( + 1, + '/repo-a', + expect.any(Function), + expect.any(Object), + expect.any(Object) + ) + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenNthCalledWith( + 2, + '/repo-b', + expect.any(Function), + expect.any(Object), + expect.any(Object) + ) + expect(firstRoot.unsubscribe).not.toHaveBeenCalled() + expect(secondRoot.unsubscribe).not.toHaveBeenCalled() + }) + + it('stops forwarding events after the last subscriber disposes', async () => { + const watch = installSuccessfulWatch() + const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() + const dispose = await watchFileExplorerInWatcherProcess('/repo', onEvents) + + await dispose() + watch.callback(null, [{ type: 'update', path: '/repo/a.txt' }]) + watch.hooks.onInterruption?.() - worker.emit('message', { - type: 'events', - events: [{ kind: 'update', absolutePath: '/repo/a.txt' }] - }) expect(onEvents).not.toHaveBeenCalled() }) + + it('propagates an initial watcher process subscription failure', async () => { + subscribeViaRuntimeWatcherProcessMock.mockRejectedValue(new Error('watcher unavailable')) + + await expect(watchFileExplorerInWatcherProcess('/repo', vi.fn())).rejects.toThrow( + 'watcher unavailable' + ) + }) + + it('does not retry a root-specific initial subscription failure', async () => { + subscribeViaRuntimeWatcherProcessMock.mockRejectedValue( + new WatcherProcessFailure('root unavailable', 'subscription', 'subscribe_failed') + ) + + await expect(watchFileExplorerInWatcherProcess('/missing', vi.fn())).rejects.toThrow( + 'root unavailable' + ) + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/main/runtime/file-watcher-host.ts b/src/main/runtime/file-watcher-host.ts index f368ea270e2..68e5711150d 100644 --- a/src/main/runtime/file-watcher-host.ts +++ b/src/main/runtime/file-watcher-host.ts @@ -1,160 +1,286 @@ -// Why: spawns the file-watcher worker thread and adapts it to the synchronous -// `watchFileExplorer` contract (a promise that resolves to an unsubscribe fn -// once the recursive crawl is live). Running @parcel/watcher in the worker -// keeps its blocking initial crawl off the main process's libuv pool so a huge -// non-git tree can't wedge the `serve` runtime (issue #5308). -import { Worker } from 'node:worker_threads' -import { join } from 'node:path' -import { app } from 'electron' import type { FsChangeEvent } from '../../shared/types' -import type { FileWatcherHostMessage, FileWatcherWorkerMessage } from './file-watcher-worker' -// Why: shares the explorer watcher's canonical ignore list, pre-shaped so the -// macOS daemon-side exclusion-path cap (8) is respected — over the cap ALL -// exclusions silently fail and fseventsd delivers full node_modules churn. +import { + forgetRuntimeWatcherProcessRoot, + resetRuntimeWatcherProcessForTest, + subscribeViaRuntimeWatcherProcess, + type WatcherProcessEvent, + type WatcherProcessSubscription +} from '../ipc/parcel-watcher-process' +import { isWatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from '../ipc/filesystem-watcher-ignore' const RUNTIME_FILE_WATCH_IGNORE_OPTIONS = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) +const RUNTIME_FILE_WATCH_EVENT_LIMIT = 200 +const RUNTIME_FILE_WATCH_CRAWL_TIMEOUT_MS = 60_000 -// Why: clean teardown is async (the worker awaits subscription.unsubscribe() -// before closing its port and exiting). Wait this long for the worker to exit on -// its own before force-terminating, so the native watcher thread isn't freed -// mid-flight. -const WORKER_TEARDOWN_TIMEOUT_MS = 5000 -type WorkerExitWaitResult = 'exit' | 'timeout' - -function getFileWatcherWorkerPath(): string { - if (app.isPackaged) { - return join(process.resourcesPath, 'app.asar', 'out', 'main', 'file-watcher-worker.js') - } - return join(__dirname, 'file-watcher-worker.js') +type RuntimeFileWatchSubscriber = { + onEvents: (events: FsChangeEvent[]) => void + onTerminalError: (error: Error) => void } -function waitForWorkerExit(worker: Worker, timeoutMs: number): Promise { - return new Promise((resolve) => { +type RuntimeRootWatch = { + rootPath: string + subscribers: Set + start: Promise + subscription: WatcherProcessSubscription | null + abortController: AbortController + generation: number + closed: boolean +} + +// Why: paired clients watching the same worktree share one native subscription. +// Different roots are assigned across the bounded crash-isolated child pool. +const runtimeRootWatches = new Map() + +function overflowEvent(rootPath: string): FsChangeEvent[] { + return [{ kind: 'overflow', absolutePath: rootPath }] +} + +function mapWatcherEvents(events: readonly WatcherProcessEvent[]): FsChangeEvent[] { + return events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + isDirectory: event.isDirectory + })) +} + +function emitToSubscribers(root: RuntimeRootWatch, events: FsChangeEvent[]): void { + if (root.closed) { + return + } + for (const subscriber of root.subscribers) { + subscriber.onEvents(events) + } +} + +function terminateRootWatch(root: RuntimeRootWatch, error: Error): void { + if (root.closed) { + return + } + root.closed = true + root.generation++ + root.abortController.abort() + if (runtimeRootWatches.get(root.rootPath) === root) { + runtimeRootWatches.delete(root.rootPath) + } + forgetRuntimeWatcherProcessRoot(root.rootPath) + const subscribers = Array.from(root.subscribers) + root.subscribers.clear() + for (const subscriber of subscribers) { + subscriber.onTerminalError(error) + } +} + +function closeInitialRootWatch(root: RuntimeRootWatch): void { + root.closed = true + root.generation++ + root.abortController.abort() + if (runtimeRootWatches.get(root.rootPath) === root) { + runtimeRootWatches.delete(root.rootPath) + } + forgetRuntimeWatcherProcessRoot(root.rootPath) + root.subscribers.clear() +} + +function shouldRetryInitialWatch(error: unknown): boolean { + return ( + isWatcherProcessFailure(error) && + error.code !== 'entry_missing' && + error.code !== 'subscribe_aborted' && + error.code !== 'supervisor_disposed' && + (error.scope === 'supervisor' || error.code === 'subscribe_timeout') + ) +} + +function subscribeRuntimeRootWatch(root: RuntimeRootWatch): Promise { + const generation = ++root.generation + const emitOverflow = (): void => { + if (root.generation === generation) { + emitToSubscribers(root, overflowEvent(root.rootPath)) + } + } + return subscribeViaRuntimeWatcherProcess( + root.rootPath, + (err, events) => { + if (root.generation !== generation || root.closed) { + return + } + if (err) { + console.error('[runtime-files.watch] watcher error', { + rootPath: root.rootPath, + error: err.message + }) + emitOverflow() + return + } + emitToSubscribers(root, mapWatcherEvents(events)) + }, + RUNTIME_FILE_WATCH_IGNORE_OPTIONS, + { + delivery: { + includeDirectoryMetadata: true, + maxEventsPerBatch: RUNTIME_FILE_WATCH_EVENT_LIMIT + }, + onInterruption: emitOverflow, + onOverflow: emitOverflow, + onTerminalError: (error) => recoverRuntimeRootWatch(root, generation, error), + // Why: the transport bounds initial setup at 15 seconds, but a later + // crash-resubscribe has no request deadline and must not freeze its shard forever. + subscribeTimeoutMs: RUNTIME_FILE_WATCH_CRAWL_TIMEOUT_MS, + signal: root.abortController.signal + } + ).then(async (subscription) => { + if (root.closed || root.generation !== generation) { + await subscription.unsubscribe() + return + } + root.subscription = subscription + }) +} + +async function startInitialRuntimeRootWatch(root: RuntimeRootWatch): Promise { + try { + await subscribeRuntimeRootWatch(root) + } catch (firstError) { + if (root.closed || !shouldRetryInitialWatch(firstError)) { + closeInitialRootWatch(root) + throw firstError + } + try { + await subscribeRuntimeRootWatch(root) + emitToSubscribers(root, overflowEvent(root.rootPath)) + } catch (isolatedError) { + closeInitialRootWatch(root) + throw isolatedError + } + } +} + +function recoverRuntimeRootWatch( + root: RuntimeRootWatch, + failedGeneration: number, + terminalError: Error +): void { + if (root.closed || root.generation !== failedGeneration) { + return + } + root.subscription = null + emitToSubscribers(root, overflowEvent(root.rootPath)) + root.start = subscribeRuntimeRootWatch(root).catch((recoveryError: unknown) => { + if (!root.closed) { + terminateRootWatch(root, recoveryError instanceof Error ? recoveryError : terminalError) + } + }) +} + +function createRuntimeRootWatch(rootPath: string): RuntimeRootWatch { + const root: RuntimeRootWatch = { + rootPath, + subscribers: new Set(), + start: Promise.resolve(), + subscription: null, + abortController: new AbortController(), + generation: 0, + closed: false + } + runtimeRootWatches.set(rootPath, root) + root.start = startInitialRuntimeRootWatch(root) + return root +} + +async function releaseRuntimeRootWatch( + root: RuntimeRootWatch, + subscriber: RuntimeFileWatchSubscriber +): Promise { + root.subscribers.delete(subscriber) + if (root.closed || root.subscribers.size > 0) { + return + } + root.closed = true + root.generation++ + root.abortController.abort() + if (runtimeRootWatches.get(root.rootPath) === root) { + runtimeRootWatches.delete(root.rootPath) + } + await root.subscription?.unsubscribe() +} + +/** + * Start a recursive runtime-file watch in the crash-isolated process pool. + * The child owns crawl, event collapse, and stat metadata so neither native + * faults nor event storms can consume the serve process's libuv pool. + */ +export async function watchFileExplorerInWatcherProcess( + rootPath: string, + callback: (events: FsChangeEvent[]) => void, + onTerminalError: (error: Error) => void = () => undefined, + signal?: AbortSignal +): Promise<() => Promise> { + const root = runtimeRootWatches.get(rootPath) ?? createRuntimeRootWatch(rootPath) + const subscriber: RuntimeFileWatchSubscriber = { onEvents: callback, onTerminalError } + root.subscribers.add(subscriber) + try { + await waitForRuntimeRootStart(root, subscriber, signal) + } catch (error) { + root.subscribers.delete(subscriber) + throw error + } + if (root.closed) { + throw new Error('file watcher closed during setup') + } + + let releasePromise: Promise | undefined + return (): Promise => { + releasePromise ??= releaseRuntimeRootWatch(root, subscriber) + return releasePromise + } +} + +function waitForRuntimeRootStart( + root: RuntimeRootWatch, + subscriber: RuntimeFileWatchSubscriber, + signal: AbortSignal | undefined +): Promise { + if (!signal) { + return root.start + } + if (signal.aborted) { + void releaseRuntimeRootWatch(root, subscriber) + return Promise.reject(new Error('file watcher subscription aborted')) + } + return new Promise((resolve, reject) => { let settled = false - let timer: ReturnType | undefined - let onExit: (() => void) | undefined - const finish = (result: WorkerExitWaitResult): void => { + const finish = (callback: () => void): void => { if (settled) { return } settled = true - if (timer) { - clearTimeout(timer) - } - if (onExit) { - worker.off('exit', onExit) - } - resolve(result) + signal.removeEventListener('abort', handleAbort) + callback() } - - onExit = () => finish('exit') - worker.once('exit', onExit) - timer = setTimeout(() => finish('timeout'), timeoutMs) + const handleAbort = (): void => { + void releaseRuntimeRootWatch(root, subscriber) + finish(() => reject(new Error('file watcher subscription aborted'))) + } + signal.addEventListener('abort', handleAbort, { once: true }) + root.start.then( + () => finish(resolve), + (error: unknown) => finish(() => reject(error)) + ) }) } -/** Start a recursive file watch in a worker thread. Resolves to an unsubscribe - * function once the worker reports the crawl is live; rejects if the worker - * fails to start the watch. */ -export function watchFileExplorerInWorker( - rootPath: string, - callback: (events: FsChangeEvent[]) => void -): Promise<() => Promise> { - return new Promise((resolve, reject) => { - const worker = new Worker(getFileWatcherWorkerPath(), { - workerData: { rootPath, ignoreOptions: RUNTIME_FILE_WATCH_IGNORE_OPTIONS } - }) - - let ready = false - let disposed = false - let exited = false - let disposePromise: Promise | undefined - - const runDispose = async (): Promise => { - if (disposed) { - return - } - disposed = true - if (exited) { - return - } - // Ask the worker to unsubscribe its native watcher and exit on its own. - // Why: worker.terminate() force-frees the worker's V8 env while - // @parcel/watcher's native watch thread / inflight async work is still - // live, which faults inside napi (Watcher::findCallback, - // PromiseRunner::onWorkComplete). Only terminate as a backstop if the - // worker wedges and never exits. - try { - worker.postMessage({ type: 'unsubscribe' } satisfies FileWatcherHostMessage) - } catch { - // Worker already gone — the exit wait and timeout backstop cover it. - } - const exitResult = await waitForWorkerExit(worker, WORKER_TEARDOWN_TIMEOUT_MS) - if (exitResult === 'timeout' && !exited) { - await worker.terminate().then( - () => undefined, - () => undefined - ) - } - } - - // Why: racing dispose callers must share the same worker-exit drain instead - // of letting later calls resolve while teardown is still in flight. - const dispose = (): Promise => { - disposePromise ??= runDispose() - return disposePromise - } - - worker.on('message', (message: FileWatcherWorkerMessage) => { - if (message.type === 'ready') { - ready = true - resolve(dispose) - return - } - if (message.type === 'events') { - if (!disposed) { - callback(message.events) - } - return - } - if (message.type === 'error') { - if (!ready) { - // The crawl never went live — fail the watch so the caller knows. - disposed = true - void worker.terminate() - reject(new Error(message.message)) - return - } - // Already live: a mid-stream watcher error. Tell the renderer to - // refresh; the worker also emits an overflow event alongside this. - console.error('[runtime-files.watch] worker error', { rootPath, error: message.message }) - } - }) - - worker.on('error', (err) => { - if (!ready) { - disposed = true - reject(err) - return - } - // A live worker crashed: surface an overflow so the renderer re-reads, - // rather than silently going stale. - console.error('[runtime-files.watch] worker crashed', { rootPath, err }) - if (!disposed) { - callback([{ kind: 'overflow', absolutePath: rootPath }]) - } - }) - - worker.on('exit', (code) => { - exited = true - if (!ready && !disposed) { - disposed = true - reject(new Error(`file watcher worker exited before ready (code ${code})`)) - } - }) - }) +export function resetRuntimeRootWatchersForTest(): void { + for (const root of runtimeRootWatches.values()) { + root.closed = true + root.generation++ + root.abortController.abort() + root.subscribers.clear() + void root.subscription?.unsubscribe() + } + runtimeRootWatches.clear() + resetRuntimeWatcherProcessForTest() } diff --git a/src/main/runtime/file-watcher-worker.ts b/src/main/runtime/file-watcher-worker.ts deleted file mode 100644 index 9fd8e17cb77..00000000000 --- a/src/main/runtime/file-watcher-worker.ts +++ /dev/null @@ -1,141 +0,0 @@ -// Why: on Linux/Windows @parcel/watcher uses a brute-force backend that -// recursively walks the whole tree on a libuv threadpool thread before -// subscribe() resolves. On a huge tree backed by slow storage (a home dir on -// NFS opened as a worktree) that crawl can run for minutes. Running it here, in -// a dedicated worker thread, keeps it off the main/`serve` process's libuv pool -// so it can never starve static-asset serving, RPC crypto, or other clients -// (issue #5308). The worker owns the subscribe, the per-event stat fanout, and -// the event batching; the main thread only relays results. -import { stat } from 'node:fs/promises' -import { parentPort, workerData } from 'node:worker_threads' -import type * as ParcelWatcher from '@parcel/watcher' -import type { FsChangeEvent } from '../../shared/types' -import type { ParcelWatcherIgnoreOptions } from '../ipc/filesystem-watcher-ignore' - -const RUNTIME_FILE_WATCH_EVENT_STAT_LIMIT = 200 -const RUNTIME_FILE_WATCH_STAT_CONCURRENCY = 8 - -type FileWatcherWorkerData = { - rootPath: string - ignoreOptions: ParcelWatcherIgnoreOptions -} - -// Messages the worker sends back to the host. -export type FileWatcherWorkerMessage = - | { type: 'ready' } - | { type: 'events'; events: FsChangeEvent[] } - | { type: 'error'; message: string } - -// Messages the host sends to the worker. -export type FileWatcherHostMessage = { type: 'unsubscribe' } - -const data = workerData as FileWatcherWorkerData - -if (!parentPort) { - throw new Error('File watcher worker must run with a parent port.') -} - -const port = parentPort - -/** Report a watcher failure to the host and ask the renderer to refresh from - * scratch (the overflow event), so a mid-stream error never leaves the - * explorer silently stale. */ -function reportWatchError(err: unknown): void { - port.postMessage({ - type: 'error', - message: err instanceof Error ? err.message : String(err) - } satisfies FileWatcherWorkerMessage) - port.postMessage({ - type: 'events', - events: [{ kind: 'overflow', absolutePath: data.rootPath }] - } satisfies FileWatcherWorkerMessage) -} - -/** Run an async mapper over items with a bounded number in flight at once, so a - * large batch can't occupy every libuv threadpool thread in this worker. */ -async function mapWithConcurrency( - items: readonly T[], - limit: number, - mapper: (item: T) => Promise -): Promise { - const results = Array.from({ length: items.length }) - let cursor = 0 - const worker = 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) }, worker)) - return results -} - -async function main(): Promise { - let watcher: typeof ParcelWatcher - try { - watcher = await import('@parcel/watcher') - } catch (err) { - port.postMessage({ - type: 'error', - message: err instanceof Error ? err.message : String(err) - } satisfies FileWatcherWorkerMessage) - return - } - - const subscription = await watcher.subscribe( - data.rootPath, - (err, events) => { - if (err) { - reportWatchError(err) - return - } - // Why: large watcher batches usually mean a generated directory or branch - // switch. Avoid stat fanout and ask the renderer to refresh. - if (events.length > RUNTIME_FILE_WATCH_EVENT_STAT_LIMIT) { - port.postMessage({ - type: 'events', - events: [{ kind: 'overflow', absolutePath: data.rootPath }] - } satisfies FileWatcherWorkerMessage) - return - } - void mapWithConcurrency( - events, - RUNTIME_FILE_WATCH_STAT_CONCURRENCY, - async (event): Promise => { - let isDirectory = false - try { - isDirectory = (await stat(event.path)).isDirectory() - } catch { - isDirectory = false - } - return { kind: event.type, absolutePath: event.path, isDirectory } - } - ) - .then((mapped) => { - port.postMessage({ type: 'events', events: mapped } satisfies FileWatcherWorkerMessage) - }) - // Why: without this, a throwing postMessage / stat becomes an unhandled - // rejection that crashes the worker silently. Surface it instead. - .catch((err: unknown) => reportWatchError(err)) - }, - data.ignoreOptions as ParcelWatcher.Options - ) - - // The crawl finished and the subscription is live. - port.postMessage({ type: 'ready' } satisfies FileWatcherWorkerMessage) - - port.on('message', (message: FileWatcherHostMessage) => { - if (message.type === 'unsubscribe') { - void subscription.unsubscribe().finally(() => { - port.close() - }) - } - }) -} - -void main().catch((err: unknown) => { - port.postMessage({ - type: 'error', - message: err instanceof Error ? err.message : String(err) - } satisfies FileWatcherWorkerMessage) -}) diff --git a/src/main/runtime/orca-runtime-files-watch.test.ts b/src/main/runtime/orca-runtime-files-watch.test.ts index 014b7b8e774..7986e0fc9b0 100644 --- a/src/main/runtime/orca-runtime-files-watch.test.ts +++ b/src/main/runtime/orca-runtime-files-watch.test.ts @@ -4,11 +4,18 @@ import type * as FsPromises from 'node:fs/promises' import type * as FilesystemAuth from '../ipc/filesystem-auth' import type { FsChangeEvent } from '../../shared/types' -const { resolveAuthorizedPathMock, statMock, watchMock, watchInWorkerMock } = vi.hoisted(() => ({ +const { + resolveAuthorizedPathMock, + statMock, + watchMock, + watchInWatcherProcessMock, + getSshFilesystemProviderMock +} = vi.hoisted(() => ({ resolveAuthorizedPathMock: vi.fn(), statMock: vi.fn(), watchMock: vi.fn(), - watchInWorkerMock: vi.fn() + watchInWatcherProcessMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn() })) vi.mock('fs', async () => { @@ -27,9 +34,9 @@ vi.mock('fs/promises', async () => { } }) -// The local (non-Windows, non-SSH) watch path now delegates to a worker thread. +// The local (non-Windows, non-SSH) watch path delegates to the isolated watcher process. vi.mock('./file-watcher-host', () => ({ - watchFileExplorerInWorker: watchInWorkerMock + watchFileExplorerInWatcherProcess: watchInWatcherProcessMock })) vi.mock('../ipc/filesystem-auth', async () => { @@ -40,6 +47,10 @@ vi.mock('../ipc/filesystem-auth', async () => { } }) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) + import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files' function createRuntimeFileCommands(rootPath: string) { @@ -67,15 +78,16 @@ function createRuntimeFileCommands(rootPath: string) { describe('RuntimeFileCommands file watching', () => { const originalPlatform = process.platform - // Why: Windows runtime watches intentionally use fs.watch instead of the worker path. - const posixWorkerIt = process.platform === 'win32' ? it.skip : it + // Why: Windows runtime watches intentionally use fs.watch instead of the watcher child. + const posixWatcherProcessIt = process.platform === 'win32' ? it.skip : it beforeEach(() => { vi.useFakeTimers() resolveAuthorizedPathMock.mockReset() statMock.mockReset() watchMock.mockReset() - watchInWorkerMock.mockReset() + watchInWatcherProcessMock.mockReset() + getSshFilesystemProviderMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -112,8 +124,8 @@ describe('RuntimeFileCommands file watching', () => { const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents) expect(watchMock).toHaveBeenCalledWith('C:\\repo', { recursive: true }, expect.any(Function)) - // Windows path does not go through the worker. - expect(watchInWorkerMock).not.toHaveBeenCalled() + // Windows path does not go through the watcher child. + expect(watchInWatcherProcessMock).not.toHaveBeenCalled() const emit = listener as (() => void) | null expect(emit).not.toBeNull() @@ -130,48 +142,61 @@ describe('RuntimeFileCommands file watching', () => { expect(close).toHaveBeenCalledTimes(1) }) - // Issue #5308: the local recursive watch runs in a worker thread so - // @parcel/watcher's blocking initial crawl can't starve the serve runtime. - posixWorkerIt('delegates local recursive watching to the worker thread', async () => { + // Issues #5308/#8212: the local recursive watch runs out of process so the + // blocking initial crawl and native faults cannot take down the serve runtime. + posixWatcherProcessIt('delegates local recursive watching to the watcher process', async () => { resolveAuthorizedPathMock.mockResolvedValue('/home5/Brian') statMock.mockResolvedValue({ isDirectory: () => true }) const captured: { cb?: (events: FsChangeEvent[]) => void } = {} - const workerDispose = vi.fn() - watchInWorkerMock.mockImplementation((_rootPath, cb) => { + const watcherDispose = vi.fn() + watchInWatcherProcessMock.mockImplementation((_rootPath, cb) => { captured.cb = cb - return Promise.resolve(workerDispose) + return Promise.resolve(watcherDispose) }) const onEvents = vi.fn() const { commands } = createRuntimeFileCommands('/home5/Brian') - const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents) + const controller = new AbortController() + const unsubscribe = await commands.watchFileExplorer( + 'id:wt-1', + onEvents, + vi.fn(), + controller.signal + ) - expect(watchInWorkerMock).toHaveBeenCalledWith('/home5/Brian', expect.any(Function)) + expect(watchInWatcherProcessMock).toHaveBeenCalledWith( + '/home5/Brian', + expect.any(Function), + expect.any(Function), + controller.signal + ) - // Events surfaced by the worker reach the caller. + // Events surfaced by the watcher process reach the caller. captured.cb?.([{ kind: 'update', absolutePath: '/home5/Brian/a.txt', isDirectory: false }]) expect(onEvents).toHaveBeenCalledWith([ { kind: 'update', absolutePath: '/home5/Brian/a.txt', isDirectory: false } ]) - // Unsubscribe tears the worker down (dispose runs on the shutdown-drain + // Unsubscribe tears the subscription down (dispose runs on the shutdown-drain // microtask, so await the drain before asserting). unsubscribe() await awaitRuntimeFileWatcherUnsubscribes() - expect(workerDispose).toHaveBeenCalledTimes(1) + expect(watcherDispose).toHaveBeenCalledTimes(1) }) - posixWorkerIt('propagates a worker watch failure to the caller', async () => { + posixWatcherProcessIt('propagates a watcher process failure to the caller', async () => { resolveAuthorizedPathMock.mockResolvedValue('/repo') statMock.mockResolvedValue({ isDirectory: () => true }) - watchInWorkerMock.mockRejectedValue(new Error('worker_failed')) + watchInWatcherProcessMock.mockRejectedValue(new Error('watcher_process_failed')) const { commands } = createRuntimeFileCommands('/repo') - await expect(commands.watchFileExplorer('id:wt-1', vi.fn())).rejects.toThrow('worker_failed') + await expect(commands.watchFileExplorer('id:wt-1', vi.fn())).rejects.toThrow( + 'watcher_process_failed' + ) }) - posixWorkerIt('tracks worker unsubscribe work so shutdown can await it', async () => { + posixWatcherProcessIt('tracks watcher unsubscribe work so shutdown can await it', async () => { resolveAuthorizedPathMock.mockResolvedValue('/repo') statMock.mockResolvedValue({ isDirectory: () => true }) @@ -182,7 +207,7 @@ describe('RuntimeFileCommands file watching', () => { resolveDispose = resolve }) ) - watchInWorkerMock.mockResolvedValue(disposeMock) + watchInWatcherProcessMock.mockResolvedValue(disposeMock) const { commands } = createRuntimeFileCommands('/repo') const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn()) @@ -201,4 +226,37 @@ describe('RuntimeFileCommands file watching', () => { await drainPromise expect(drained).toBe(true) }) + + it('forwards the abort signal into SSH-backed file explorer watches', async () => { + const watch = vi.fn(async () => () => {}) + getSshFilesystemProviderMock.mockReturnValue({ watch }) + const store = { getRepo: vi.fn(() => ({ connectionId: 'ssh-1' })) } + const commands = new RuntimeFileCommands({ + getRuntimeId: () => 'runtime-1', + requireStore: () => store, + resolveWorktreeSelector: vi.fn(async () => ({ + id: 'wt-1', + repoId: 'repo-1', + path: '/remote/repo' + })), + resolveRuntimeFileTarget: vi.fn(async () => ({ + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: '/remote/repo' + }, + connectionId: 'ssh-1' + })), + resolveRuntimeGitTarget: vi.fn(), + openFile: vi.fn() + } as never) + const controller = new AbortController() + + await commands.watchFileExplorer('id:wt-1', vi.fn(), vi.fn(), controller.signal) + + expect(watch).toHaveBeenCalledWith('/remote/repo', expect.any(Function), { + signal: controller.signal + }) + expect(watchInWatcherProcessMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index 8412a7740be..d43e48c4695 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -18,7 +18,7 @@ const { renameMock, resolveAuthorizedPathMock, statMock, - watchInWorkerMock, + watchInWatcherProcessMock, checkRgAvailableMock, getLocalGitOptionsForRegisteredWorktreeMock, wslAwareSpawnMock, @@ -32,7 +32,7 @@ const { renameMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), statMock: vi.fn(), - watchInWorkerMock: vi.fn(), + watchInWatcherProcessMock: vi.fn(), wslAwareSpawnMock: vi.fn(), watchMock: vi.fn() })) @@ -61,7 +61,7 @@ vi.mock('fs/promises', async () => { }) vi.mock('./file-watcher-host', () => ({ - watchFileExplorerInWorker: watchInWorkerMock + watchFileExplorerInWatcherProcess: watchInWatcherProcessMock })) vi.mock('../ipc/filesystem-auth', async () => { @@ -199,7 +199,7 @@ describe('RuntimeFileCommands', () => { renameMock.mockReset() resolveAuthorizedPathMock.mockReset() statMock.mockReset() - watchInWorkerMock.mockReset() + watchInWatcherProcessMock.mockReset() watchMock.mockReset() checkRgAvailableMock.mockReset() vi.mocked(getSshFilesystemProvider).mockReset() @@ -441,7 +441,7 @@ describe('RuntimeFileCommands', () => { expect(close).toHaveBeenCalledTimes(1) }) - it('delegates local recursive watching to the worker thread', async () => { + it('delegates local recursive watching to the watcher process', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' @@ -449,11 +449,16 @@ describe('RuntimeFileCommands', () => { resolveAuthorizedPathMock.mockResolvedValue('/repo') statMock.mockResolvedValue({ isDirectory: () => true }) const dispose = vi.fn() - watchInWorkerMock.mockResolvedValue(dispose) + watchInWatcherProcessMock.mockResolvedValue(dispose) const { commands } = createRuntimeFileCommands() const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn()) - expect(watchInWorkerMock).toHaveBeenCalledWith('/repo', expect.any(Function)) + expect(watchInWatcherProcessMock).toHaveBeenCalledWith( + '/repo', + expect.any(Function), + expect.any(Function), + undefined + ) unsubscribe() await awaitRuntimeFileWatcherUnsubscribes() diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index e8a74e3591c..acdf3616f47 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -44,7 +44,7 @@ import type { RuntimeFileReadResult, RuntimeTerminalPathResolution } from '../../shared/runtime-types' -import { watchFileExplorerInWorker } from './file-watcher-host' +import { watchFileExplorerInWatcherProcess } from './file-watcher-host' import { wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' import { isENOENT, resolveAuthorizedPath } from '../ipc/filesystem-auth' @@ -921,7 +921,9 @@ export class RuntimeFileCommands { async watchFileExplorer( worktreeSelector: string, - callback: (events: FsChangeEvent[]) => void + callback: (events: FsChangeEvent[]) => void, + onTerminalError: (error: Error) => void = () => undefined, + signal?: AbortSignal ): Promise<() => void> { const target = await this.resolveFileExplorerPath(worktreeSelector, '') const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null @@ -929,7 +931,9 @@ export class RuntimeFileCommands { if (!provider) { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.watch(target.path, callback) + // Why: the RPC layer already threads AbortSignal for local watches; SSH + // must cancel the remote fs.watch request instead of waiting it out. + return provider.watch(target.path, callback, { signal }) } const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) @@ -940,9 +944,14 @@ export class RuntimeFileCommands { if (process.platform === 'win32') { return watchWindowsRuntimeFileExplorer(rootPath, callback) } - // Why: the watcher runs in a worker thread so @parcel/watcher's blocking - // recursive crawl can't starve the main/`serve` process (issue #5308). - const dispose = await watchFileExplorerInWorker(rootPath, callback) + // Why: the forked watcher keeps the blocking crawl and native faults out + // of the main/`serve` process (issues #5308 and #8212). + const dispose = await watchFileExplorerInWatcherProcess( + rootPath, + callback, + onTerminalError, + signal + ) return () => { trackRuntimeFileWatcherUnsubscribe(rootPath, dispose) } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5aef2a7dd58..45f582a552b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -4,6 +4,7 @@ import { detectAgentStatusFromTitle, isClaudeManagementTitle, + isCursorAgentTitle, isCursorNativeAgentTitle, isShellProcess, normalizeTerminalTitle @@ -740,6 +741,7 @@ import type { ClaudeAccountService } from '../claude-accounts/service' import type { CodexAccountService } from '../codex-accounts/service' import type { RateLimitService } from '../rate-limits/service' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' +import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides' import type { RateLimitState } from '../../shared/rate-limit-types' import type { VoiceSettings } from '../../shared/speech-types' import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service' @@ -853,6 +855,7 @@ type RuntimeStore = { compactWorktreeCards?: GlobalSettings['compactWorktreeCards'] minimaxGroupId?: GlobalSettings['minimaxGroupId'] minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] + prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides'] gitlabProjects?: GlobalSettings['gitlabProjects'] mobileAutoRestoreFitMs?: number | null mobileEmulatorEnabled?: boolean @@ -965,24 +968,6 @@ function isCursorAgentOrchestrationTarget( return [leaf.lastOscTitle, leaf.paneTitle, tabTitle].some(isCursorAgentTitle) } -function isCursorAgentTitle(title: string | null | undefined): boolean { - if (typeof title !== 'string') { - return false - } - const trimmed = title.trim() - const lower = trimmed.toLowerCase() - if ( - lower === 'cursor agent' || - lower === 'cursor ready' || - lower === 'cursor - action required' - ) { - return true - } - // Why: display labels can mention Cursor in another agent's task text. Only - // treat the controlled synthetic Cursor spinner title as Cursor identity. - return /^[\u2800-\u28ff] Cursor Agent$/u.test(trimmed) -} - type RuntimePtyWorktreeRecord = { ptyId: string worktreeId: string @@ -2630,6 +2615,7 @@ export class OrcaRuntimeService { | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' + | 'prBotAuthorOverrides' > { if (!this.store?.getSettings) { throw new Error('runtime_unavailable') @@ -2651,7 +2637,8 @@ export class OrcaRuntimeService { experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, compactWorktreeCards: settings.compactWorktreeCards === true, minimaxGroupId: settings.minimaxGroupId ?? '', - minimaxUsageModels: settings.minimaxUsageModels ?? 'general' + minimaxUsageModels: settings.minimaxUsageModels ?? 'general', + prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [] } } @@ -2673,6 +2660,7 @@ export class OrcaRuntimeService { | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' + | 'prBotAuthorOverrides' > ): Pick< GlobalSettings, @@ -2692,6 +2680,7 @@ export class OrcaRuntimeService { | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' + | 'prBotAuthorOverrides' > { if (!this.store?.getSettings || !this.store.updateSettings) { throw new Error('runtime_unavailable') @@ -2707,6 +2696,18 @@ export class OrcaRuntimeService { return this.getClientSettings() } + updateClientPRBotAuthorOverride(args: { author: string; isBot: boolean }) { + if (!this.store?.getSettings || !this.store.updateSettings) { + throw new Error('runtime_unavailable') + } + const current = this.store.getSettings().prBotAuthorOverrides + this.store.updateSettings( + { prBotAuthorOverrides: applyPRBotAuthorOverride(current, args.author, args.isBot) }, + { notifyListeners: true } + ) + return this.getClientSettings() + } + listAutomations(): Automation[] { if (!this.store?.listAutomations) { throw new Error('runtime_unavailable') diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap index 2f8c48d1a6c..c55426c4216 100644 --- a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -22,7 +22,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # with subject like "Failed: " — never silently exit. # Include BOTH taskId and dispatchId in the payload so a late completion # from a failed retry cannot complete the current dispatch. - orca orchestration send --to term_COORD \\ + orca orchestration send --to term_COORD --from term_WORKER \\ --type worker_done --subject "" \\ --body "<3-sentence summary: what you did, what you found, what's left>" \\ --task-id task_SNAP --dispatch-id ctx_SNAP \\ @@ -39,7 +39,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # attributes the heartbeat to the specific dispatch context, not just # the task, so a straggler heartbeat from a previously-failed dispatch # cannot mask a hung retry. - orca orchestration send --to term_COORD \\ + orca orchestration send --to term_COORD --from term_WORKER \\ --type heartbeat --subject "alive" \\ --task-id task_SNAP --dispatch-id ctx_SNAP \\ --phase "" @@ -56,20 +56,20 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # blocks on \`check --wait\` until the coordinator replies, then prints the # reply body. Use it anywhere you would otherwise have reached for # AskUserQuestion. - orca orchestration ask --to term_COORD \\ + orca orchestration ask --to term_COORD --from term_WORKER \\ --question "" \\ --options "" \\ --timeout-ms 600000 # Escalate a blocker or failure (pre-completion, when you need the # coordinator to do something before you can continue): - orca orchestration send --to term_COORD \\ + orca orchestration send --to term_COORD --from term_WORKER \\ --type escalation --subject "Blocked: " \\ --body "
" \\ --task-id task_SNAP # Check for messages from the coordinator: - orca orchestration check + orca orchestration check --terminal term_WORKER === AFTER YOU SEND worker_done === diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 1e94018c931..8563c3a1e87 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -76,6 +76,7 @@ function insertWorkerDone( from?: string dispatchId?: string filesModified?: string[] + senderPaneKey?: string } ): void { const dispatch = db.getDispatchContext(params.taskId) @@ -83,8 +84,9 @@ function insertWorkerDone( if (!dispatchId) { throw new Error(`No dispatch for task ${params.taskId}`) } + const from = params.from ?? dispatch?.assignee_handle ?? 'term_unknown' db.insertMessage({ - from: params.from ?? dispatch?.assignee_handle ?? 'term_unknown', + from, to: params.to ?? 'coord', subject: 'Done', type: 'worker_done', @@ -92,7 +94,10 @@ function insertWorkerDone( taskId: params.taskId, dispatchId, ...(params.filesModified ? { filesModified: params.filesModified } : {}) - }) + }), + senderPaneKey: + params.senderPaneKey ?? + (from === dispatch?.assignee_handle ? (dispatch.assignee_pane_key ?? undefined) : undefined) }) } @@ -595,20 +600,22 @@ describe('Coordinator', () => { expect(db.getDispatchContextById(activeCtx.id)?.status).toBe('completed') }) - it('accepts worker_done payload provenance after an assignee handle changes', async () => { + it('accepts worker_done pane provenance after an assignee handle changes', async () => { db = new OrchestrationDb(':memory:') const runtime = createMockRuntime() const logs: string[] = [] const task = db.createTask({ spec: 'owned work' }) - const ctx = db.createDispatchContext(task.id, 'term_owner') + const leafId = '11111111-1111-4111-8111-111111111111' + const ctx = db.createDispatchContext(task.id, 'term_owner', `tab_before:${leafId}`) db.insertMessage({ from: 'term_reminted', to: 'coord', subject: 'Done after restart', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: ctx.id }) + payload: JSON.stringify({ taskId: task.id, dispatchId: ctx.id }), + senderPaneKey: `tab_after:${leafId}` }) const coordinator = new Coordinator(db, runtime, { @@ -622,7 +629,7 @@ describe('Coordinator', () => { expect(result.status).toBe('completed') expect(db.getTask(task.id)?.status).toBe('completed') expect(db.getDispatchContextById(ctx.id)?.status).toBe('completed') - expect(logs.some((m) => m.includes('accepting payload provenance'))).toBe(true) + expect(logs.some((m) => m.includes('Task') && m.includes('completed'))).toBe(true) }) it('can be stopped', async () => { diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index e09e48942d3..73307eddd35 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -484,6 +484,7 @@ export class Coordinator { // part of its instructions). taskSpec: strippedSpec, coordinatorHandle: this.opts.coordinatorHandle, + workerHandle: targetHandle, devMode: process.env.ORCA_USER_DATA_PATH?.includes('orca-dev'), // Why (§3.2): drift section fires only when behind > 0. The preamble // builder gates on this itself; passing the object unconditionally lets diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index ddbdb54a0f6..80a842da530 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -46,6 +46,22 @@ function generateId(prefix: string): string { return `${prefix}_${randomBytes(6).toString('hex')}` } +function addLifecycleRejectionMarker(payload: string | null, reason: string): string { + let parsed: Record = {} + try { + const value: unknown = payload ? JSON.parse(payload) : {} + if (value && typeof value === 'object' && !Array.isArray(value)) { + parsed = value as Record + } + } catch { + // Authority reconciliation only reaches this path with object payloads. + } + return JSON.stringify({ + ...parsed, + _orcaLifecycleRejection: { code: 'sender_not_assignee', reason } + }) +} + // Why: v1 → v2 added `'heartbeat'` to messages.type CHECK + `last_heartbeat_at` // column (preamble-hardening PR). v2 → v3 adds `delivered_at` column so // push-on-idle can distinguish queued-but-undelivered from user-acknowledged @@ -354,6 +370,27 @@ export class OrchestrationDb { .all(toHandle) as MessageRow[] } + convertLifecycleMessageToRejection(messageId: string, reason: string): MessageRow | undefined { + const message = this.getMessageById(messageId) + if (!message || (message.type !== 'worker_done' && message.type !== 'heartbeat')) { + return message + } + + const originalBody = message.body ? `\n\nOriginal body:\n${message.body}` : '' + const body = `Orca rejected this ${message.type}: ${reason}${originalBody}` + const payload = addLifecycleRejectionMarker(message.payload, reason) + // Why: rejected lifecycle signals must remain auditable without reaching + // later read paths as actionable completion or liveness events. + this.db + .prepare( + `UPDATE messages + SET priority = 'high', subject = ?, body = ?, payload = ? + WHERE id = ?` + ) + .run(`Rejected ${message.type}: ${message.subject}`, body, payload, messageId) + return this.getMessageById(messageId) + } + // Why: push-on-idle delivery must not replay messages that were already // injected into the PTY. `read` flips only when a check-caller consumes a // message, so delivered-but-unread rows would otherwise be re-injected on diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts index f932aec359c..ea8ae94bc19 100644 --- a/src/main/runtime/orchestration/formatter.test.ts +++ b/src/main/runtime/orchestration/formatter.test.ts @@ -71,7 +71,9 @@ describe('formatMessageBanner', () => { it('includes reply hint with message ID', () => { const banner = formatMessageBanner(makeMessage({ id: 'msg_xyz789' })) - expect(banner).toContain('[Reply: orca orchestration reply --id msg_xyz789 --body "..."]') + expect(banner).toContain( + '[Reply: orca orchestration reply --id msg_xyz789 --from term_coord --body "..."]' + ) }) it('ends with a separator line', () => { diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts index f5526553d3b..a86f3c61edf 100644 --- a/src/main/runtime/orchestration/formatter.ts +++ b/src/main/runtime/orchestration/formatter.ts @@ -25,7 +25,11 @@ export function formatMessageBanner(msg: MessageRow): string { lines.push(`[Payload: ${msg.payload}]`) } - lines.push(`[Reply: orca orchestration reply --id ${msg.id} --body "..."]`) + // Why: injected reply commands must retain the receiving pane's identity + // even when an older shell lacks Orca's terminal environment variables. + lines.push( + `[Reply: orca orchestration reply --id ${msg.id} --from ${msg.to_handle} --body "..."]` + ) lines.push(SEPARATOR) return lines.join('\n') diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 5f7f07ca0ec..81c2345c8b2 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -31,6 +31,7 @@ describe('isGroupAddress', () => { expect(isGroupAddress('@claude')).toBe(true) expect(isGroupAddress('@droid')).toBe(true) expect(isGroupAddress('@grok')).toBe(true) + expect(isGroupAddress('@cursor')).toBe(true) expect(isGroupAddress('@worktree:wt_1')).toBe(true) }) @@ -218,6 +219,88 @@ describe('resolveGroupAddress', () => { expect(result).toEqual(['term_exe', 'term_cmd']) }) + + // Why: these are the titles cursor-agent natively emits and the ones Orca synthesizes + // from Cursor hooks, so each must resolve. + it('matches native and Orca-synthesized Cursor titles', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_native', { title: 'Cursor Agent' }), + makeSummary('term_working', { title: '⠋ Cursor Agent' }), + makeSummary('term_idle', { title: 'Cursor ready' }), + makeSummary('term_permission', { title: 'Cursor - action required' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual(['term_native', 'term_working', 'term_idle', 'term_permission']) + }) + + it('is case-insensitive for @cursor', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_b', { title: 'CURSOR READY' }) + ] + + const result = resolveGroupAddress('@CuRsOr', 'coordinator', terminals, noStatus) + + expect(result).toEqual(['term_b']) + }) + + // Why: this is the false positive that a whole-token matcher cannot catch. Claude + // and Codex put their task summary in the OSC title, and none of Claude's status + // prefixes carries its own name, so "cursor" as ordinary editor vocabulary is the + // only agent token such a title has. Routing @cursor there would write into a live + // non-Cursor agent's prompt. + it('does not match another agent whose task title mentions a text cursor', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_claude_working', { + title: '⠋ preserve cursor visibility across replays' + }), + makeSummary('term_claude_idle', { title: '✳ Fix the text cursor blink' }), + makeSummary('term_claude_dot', { title: '. fix cursor position' }), + makeSummary('term_claude_star', { title: '* cursor rendering done' }), + makeSummary('term_codex', { title: '⠋ Codex: fix cursor offsets' }), + makeSummary('term_grok', { title: '⠋ - restoring cursor state - grok' }), + makeSummary('term_shell', { title: 'Terminal Cursor and Orca slows down' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual([]) + }) + + // Why: pin the deliberate tradeoff. A renamed Cursor pane outranks its OSC title, so it + // is group-unaddressable and resolves to nothing instead of resolving loosely. A silent + // miss (address it by handle) is preferred over delivering into another agent's prompt. + it('does not match a renamed Cursor terminal or the bare process name', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_renamed', { title: 'Cursor - reviewer' }), + makeSummary('term_worker', { title: 'cursor worker 2' }), + makeSummary('term_process', { title: 'cursor-agent' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual([]) + }) + + it('does not match cursor paths, hyphenated compounds, or dotted tokens', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_rules', { title: '~/cursor-rules' }), + makeSummary('term_path', { title: '/tmp/cursor' }), + makeSummary('term_worker', { title: 'my-cursor-worker' }), + makeSummary('term_file', { title: 'render-cursor-after-bracketed-paste' }), + makeSummary('term_dotted', { title: 'cursor.ts' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual([]) + }) }) describe('unknown groups', () => { diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index 39ba290bdfa..5d24f68a107 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -1,3 +1,4 @@ +import { isCursorAgentTitle } from '../../../shared/agent-title-core' import { buildAgentNameRe } from '../../../shared/agent-name-token-match' import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' @@ -13,20 +14,31 @@ const AGENT_NAME_GROUPS = [ 'mimo', 'gemini', 'droid', - 'grok' + 'grok', + 'cursor' ] as const -export type GroupAddress = - | '@all' - | '@idle' - | `@${(typeof AGENT_NAME_GROUPS)[number]}` - | `@worktree:${string}` +type AgentNameGroup = (typeof AGENT_NAME_GROUPS)[number] + +export type GroupAddress = '@all' | '@idle' | `@${AgentNameGroup}` | `@worktree:${string}` export function isGroupAddress(to: string): boolean { return to.startsWith('@') } +// Why: a name token identifies an agent only when the name is a coined word. `cursor` is +// also ordinary vocabulary in another agent's task-summary title ("fix the text cursor +// blink"), so token-matching it would route @cursor into a live Claude/Codex prompt. Names +// with that ambiguity register the identity predicate delivery already applies to them. +const GROUP_TITLE_MATCHERS: Partial boolean>> = { + cursor: isCursorAgentTitle +} + function titleMatchesAgentNameGroup(title: string, agentName: string): boolean { + const identityMatcher = GROUP_TITLE_MATCHERS[agentName as AgentNameGroup] + if (identityMatcher) { + return identityMatcher(title) + } // Why: reuse the shared whole-token matcher so orchestration groups honor the // same Windows launcher-suffix rule (e.g. `grok.exe`) as the rest of Orca's // agent-title detection, instead of maintaining a divergent regex here. diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts index 3ba68a67910..8fed046d421 100644 --- a/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts @@ -7,7 +7,7 @@ describe('lifecycle reconciliation', () => { afterEach(() => db?.close()) - it('completes an active dispatch from payload IDs after its terminal handle is reminted', () => { + it('rejects handle churn when neither side has stable pane identity', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) const dispatch = db.createDispatchContext(task.id, 'term_before_restart') @@ -20,14 +20,13 @@ describe('lifecycle reconciliation', () => { payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) }) - expect(reconcileLifecycleMessage(db, message, (line) => logs.push(line))).toEqual({ - action: 'completed', - taskId: task.id, - dispatchId: dispatch.id + expect(reconcileLifecycleMessage(db, message, (line) => logs.push(line))).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' }) - expect(db.getTask(task.id)?.status).toBe('completed') - expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') - expect(logs.some((line) => line.includes('accepting payload provenance'))).toBe(true) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + expect(logs.some((line) => line.includes('expected handle'))).toBe(true) }) // Real leaf UUIDs: pane keys are `${tabId}:${leafUuid}` and only the leaf @@ -71,7 +70,7 @@ describe('lifecycle reconciliation', () => { expect(db.getTask(task.id)?.status).toBe('completed') }) - it('completes worker_done when a pane key is unparseable (legacy format)', () => { + it('rejects mismatched opaque pane keys instead of treating them as legacy', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w:${LEAF_A}`) @@ -84,10 +83,194 @@ describe('lifecycle reconciliation', () => { senderPaneKey: 'tab_w:42' }) - expect(reconcileLifecycleMessage(db, message).action).toBe('completed') + expect(reconcileLifecycleMessage(db, message).action).toBe('rejected') + expect(db.getTask(task.id)?.status).toBe('dispatched') }) - it('ignores worker_done sent from a different pane', () => { + it('rejects worker_done from a foreign pane that claims the assignee handle', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) + const message = db.insertMessage({ + from: 'term_owner', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + senderPaneKey: `tab_w2:${LEAF_B}` + }) + + expect(reconcileLifecycleMessage(db, message)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee', + reason: expect.stringContaining('expected handle term_owner') + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getMessageById(message.id)).toMatchObject({ + type: 'worker_done', + priority: 'high', + subject: 'Rejected worker_done: Done', + body: expect.stringContaining('Orca rejected this worker_done') + }) + const persisted = db.getMessageById(message.id) + expect(JSON.parse(persisted?.payload ?? '{}')).toMatchObject({ + taskId: task.id, + dispatchId: dispatch.id, + _orcaLifecycleRejection: { code: 'sender_not_assignee' } + }) + const rereadLogs: string[] = [] + expect( + persisted && reconcileLifecycleMessage(db, persisted, (line) => rereadLogs.push(line)) + ).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + // Why: the send-path conversion logs nothing (no-op logger), so the + // coordinator's re-read must still surface the rejection to its log stream. + expect(rereadLogs.some((line) => line.includes('worker_done rejected'))).toBe(true) + }) + + it('does not let a caller-supplied rejection marker turn completion into success', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const message = db.insertMessage({ + from: 'term_worker', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + _orcaLifecycleRejection: { + code: 'sender_not_assignee', + reason: 'caller supplied' + } + }), + senderPaneKey: `tab_w:${LEAF_A}` + }) + + expect(reconcileLifecycleMessage(db, message)).toEqual({ + action: 'rejected', + code: 'sender_not_assignee', + reason: 'caller supplied' + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + + it('rejects a coordinator completion for a pane-bound dispatch', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const message = db.insertMessage({ + from: 'term_coordinator', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + }) + + expect(reconcileLifecycleMessage(db, message)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + + it('uses exact handle equality only for a legacy dispatch without a pane key', () => { + db = new OrchestrationDb(':memory:') + const acceptedTask = db.createTask({ spec: 'legacy work' }) + const acceptedDispatch = db.createDispatchContext(acceptedTask.id, 'term_legacy') + const accepted = db.insertMessage({ + from: 'term_legacy', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: acceptedTask.id, dispatchId: acceptedDispatch.id }) + }) + expect(reconcileLifecycleMessage(db, accepted).action).toBe('completed') + + const rejectedTask = db.createTask({ spec: 'other legacy work' }) + const rejectedDispatch = db.createDispatchContext(rejectedTask.id, 'term_other_legacy') + const rejected = db.insertMessage({ + from: 'term_foreign', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: rejectedTask.id, dispatchId: rejectedDispatch.id }) + }) + expect(reconcileLifecycleMessage(db, rejected)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(rejectedTask.id)?.status).toBe('dispatched') + }) + + it('does not release a dependent when a foreign completion wins the arrival race', () => { + db = new OrchestrationDb(':memory:') + const parent = db.createTask({ spec: 'parent' }) + const child = db.createTask({ spec: 'child', deps: [parent.id] }) + const dispatch = db.createDispatchContext(parent.id, 'term_worker', `tab_w:${LEAF_A}`) + const payload = JSON.stringify({ taskId: parent.id, dispatchId: dispatch.id }) + + const foreign = db.insertMessage({ + from: 'term_coordinator', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload, + senderPaneKey: `tab_c:${LEAF_B}` + }) + expect(reconcileLifecycleMessage(db, foreign)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(child.id)?.status).toBe('pending') + + const owner = db.insertMessage({ + from: 'term_worker_reminted', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload, + senderPaneKey: `tab_w_after:${LEAF_A}` + }) + expect(reconcileLifecycleMessage(db, owner).action).toBe('completed') + expect(db.getTask(child.id)?.status).toBe('ready') + }) + + it('does not let a foreign replay overwrite an authorized completion', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const payload = JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + const owner = db.insertMessage({ + from: 'term_worker', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload, + senderPaneKey: `tab_w:${LEAF_A}` + }) + expect(reconcileLifecycleMessage(db, owner).action).toBe('completed') + const result = db.getTask(task.id)?.result + + const replay = db.insertMessage({ + from: 'term_foreign', + to: 'term_coordinator', + subject: 'Forged replay', + type: 'worker_done', + payload, + senderPaneKey: `tab_f:${LEAF_B}` + }) + expect(reconcileLifecycleMessage(db, replay)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(task.id)?.result).toBe(result) + }) + + it('surfaces worker_done sent from a different pane as rejected', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) @@ -101,15 +284,16 @@ describe('lifecycle reconciliation', () => { senderPaneKey: `tab_w2:${LEAF_B}` }) - expect(reconcileLifecycleMessage(db, message, (line) => logs.push(line))).toEqual({ - action: 'ignored' + expect(reconcileLifecycleMessage(db, message, (line) => logs.push(line))).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' }) expect(db.getTask(task.id)?.status).toBe('dispatched') expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') - expect(logs.some((line) => line.includes(`expected pane tab_w1:${LEAF_A}`))).toBe(true) + expect(logs.some((line) => line.includes('worker_done rejected'))).toBe(true) }) - it('ignores a heartbeat sent from a different pane without recording liveness', () => { + it('surfaces a heartbeat sent from a different pane without recording liveness', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) @@ -122,7 +306,44 @@ describe('lifecycle reconciliation', () => { senderPaneKey: `tab_w2:${LEAF_B}` }) - expect(reconcileLifecycleMessage(db, heartbeat)).toEqual({ action: 'ignored' }) + expect(reconcileLifecycleMessage(db, heartbeat)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getDispatchContextById(dispatch.id)?.last_heartbeat_at).toBeNull() + expect(db.getMessageById(heartbeat.id)).toMatchObject({ + type: 'heartbeat', + subject: 'Rejected heartbeat: alive' + }) + // Why: the coordinator's re-read of the already-converted heartbeat must + // still log the rejection, since the send-path conversion logged nothing. + const persisted = db.getMessageById(heartbeat.id) + const rereadLogs: string[] = [] + expect( + persisted && reconcileLifecycleMessage(db, persisted, (line) => rereadLogs.push(line)) + ).toMatchObject({ + action: 'rejected' + }) + expect(rereadLogs.some((line) => line.includes('Heartbeat rejected'))).toBe(true) + }) + + it('surfaces a foreign heartbeat that claims the assignee handle', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) + const heartbeat = db.insertMessage({ + from: 'term_owner', + to: 'term_coordinator', + subject: 'alive', + type: 'heartbeat', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + senderPaneKey: `tab_w2:${LEAF_B}` + }) + + expect(reconcileLifecycleMessage(db, heartbeat)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) expect(db.getDispatchContextById(dispatch.id)?.last_heartbeat_at).toBeNull() }) diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.ts index d784840a28c..96917babaf6 100644 --- a/src/main/runtime/orchestration/lifecycle-reconciliation.ts +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.ts @@ -2,17 +2,29 @@ import type { OrchestrationDb } from './db' import type { MessageRow } from './types' import { parsePaneKey } from '../../../shared/stable-pane-id' -// Why: the tab half of a pane key changes when a pane is broken out into its -// own tab, so only the leaf UUID is identity. Reject only when both keys -// parse and name different leaves; an unparseable (e.g. legacy numeric) key -// degrades to payload authority rather than stranding a completion. -function isForeignPane(assigneePaneKey: string, senderPaneKey: string): boolean { +// Why: the tab half can change on pane break-out, while opaque legacy keys +// have no safe equivalence beyond exact equality. +function isSamePane(assigneePaneKey: string, senderPaneKey: string): boolean { if (assigneePaneKey === senderPaneKey) { - return false + return true } const assigneeLeaf = parsePaneKey(assigneePaneKey)?.leafId const senderLeaf = parsePaneKey(senderPaneKey)?.leafId - return Boolean(assigneeLeaf && senderLeaf && assigneeLeaf !== senderLeaf) + return Boolean(assigneeLeaf && senderLeaf && assigneeLeaf === senderLeaf) +} + +function hasLifecycleAuthority( + dispatch: { assignee_handle: string | null; assignee_pane_key: string | null }, + msg: MessageRow +): boolean { + if (dispatch.assignee_pane_key) { + return Boolean( + msg.sender_pane_key && isSamePane(dispatch.assignee_pane_key, msg.sender_pane_key) + ) + } + // Why: rows created before pane identity existed can only use the exact + // handle recorded at dispatch; payload knowledge alone is not authority. + return dispatch.assignee_handle === msg.from_handle } export type LifecycleReconciliationResult = @@ -21,9 +33,16 @@ export type LifecycleReconciliationResult = // read); senders must not wake waiters for it, unlike `ignored` rows that // stay unread and still need delivery. | { action: 'suppressed' } + | LifecycleRejectionResult | { action: 'completed'; taskId: string; dispatchId: string } | { action: 'heartbeat_recorded'; dispatchId: string } +export type LifecycleRejectionResult = { + action: 'rejected' + code: 'sender_not_assignee' + reason: string +} + type LogFn = (msg: string) => void const noopLog: LogFn = () => {} @@ -42,6 +61,27 @@ function parseObjectPayload(msg: MessageRow, onInvalidJson: () => void): Record< } } +function getPersistedLifecycleRejection( + payload: Record +): LifecycleRejectionResult | undefined { + const rejection = payload._orcaLifecycleRejection + if ( + !rejection || + typeof rejection !== 'object' || + (rejection as { code?: unknown }).code !== 'sender_not_assignee' || + typeof (rejection as { reason?: unknown }).reason !== 'string' + ) { + return undefined + } + // Why: the marker is reserved persistence state; treating it as a rejection + // also prevents caller-supplied markers from turning lifecycle sends into success. + return { + action: 'rejected', + code: 'sender_not_assignee', + reason: (rejection as { reason: string }).reason + } +} + export function reconcileLifecycleMessage( db: OrchestrationDb, msg: MessageRow, @@ -75,6 +115,13 @@ function reconcileHeartbeatMessage( const payload = parseObjectPayload(msg, () => { onLog(`Heartbeat from ${msg.from_handle} has invalid JSON payload; ignored`) }) + const persistedRejection = getPersistedLifecycleRejection(payload) + if (persistedRejection) { + // Why: the send-path reconcile converts with a no-op logger, so the + // coordinator's re-read is the only chance to surface the rejection. + onLog(`Heartbeat rejected: ${persistedRejection.reason}`) + return persistedRejection + } const dispatchId = payload.dispatchId if (typeof dispatchId !== 'string' || dispatchId.length === 0) { onLog(`Heartbeat from ${msg.from_handle} missing dispatchId; ignored`) @@ -90,17 +137,13 @@ function reconcileHeartbeatMessage( return { action: 'suppressed' } } - if ( - dispatch.assignee_pane_key && - msg.sender_pane_key && - isForeignPane(dispatch.assignee_pane_key, msg.sender_pane_key) - ) { + if (!hasLifecycleAuthority(dispatch, msg)) { // Why: a wrong-pane heartbeat must not refresh liveness — it would mask // a hung assignee behind another agent's timer. - onLog( - `Heartbeat for dispatch ${dispatchId} came from pane ${msg.sender_pane_key}, expected pane ${dispatch.assignee_pane_key}; ignored` - ) - return { action: 'ignored' } + const reason = buildLifecycleAuthorityRejectionReason(dispatchId, dispatch, msg) + onLog(`Heartbeat rejected: ${reason}`) + db.convertLifecycleMessageToRejection(msg.id, reason) + return { action: 'rejected', code: 'sender_not_assignee', reason } } // Why: dispatchId-specific writes let the DB ignore late heartbeats for @@ -119,6 +162,13 @@ function reconcileWorkerDoneMessage( const payload = parseObjectPayload(msg, () => { onLog(`Warning: invalid payload in worker_done from ${msg.from_handle}`) }) + const persistedRejection = getPersistedLifecycleRejection(payload) + if (persistedRejection) { + // Why: the send-path reconcile converts with a no-op logger, so the + // coordinator's re-read is the only chance to surface the rejection. + onLog(`Warning: worker_done rejected: ${persistedRejection.reason}`) + return persistedRejection + } const taskId = payload.taskId if (typeof taskId !== 'string' || taskId.length === 0) { @@ -151,26 +201,11 @@ function reconcileWorkerDoneMessage( ) return { action: 'ignored' } } - if (dispatch.assignee_handle !== msg.from_handle) { - // Why: pane leaves are the remint-stable identity behind handles. When - // both sides carry one, a foreign leaf is a different pane completing - // someone else's task — reject it; the same leaf is the same pane after - // a handle remint or tab break-out. Without pane data (older CLI, - // sessions without ORCA_PANE_KEY) payload IDs stay the completion - // authority. - if ( - dispatch.assignee_pane_key && - msg.sender_pane_key && - isForeignPane(dispatch.assignee_pane_key, msg.sender_pane_key) - ) { - onLog( - `Warning: worker_done for dispatch ${dispatchId} came from pane ${msg.sender_pane_key}, expected pane ${dispatch.assignee_pane_key}; ignored` - ) - return { action: 'ignored' } - } - onLog( - `Warning: worker_done for dispatch ${dispatchId} came from ${msg.from_handle}, expected ${dispatch.assignee_handle ?? ''}; accepting payload provenance` - ) + if (!hasLifecycleAuthority(dispatch, msg)) { + const reason = buildLifecycleAuthorityRejectionReason(dispatchId, dispatch, msg) + onLog(`Warning: worker_done rejected: ${reason}`) + db.convertLifecycleMessageToRejection(msg.id, reason) + return { action: 'rejected', code: 'sender_not_assignee', reason } } // Why: `orchestration.send` can release the DB lock before waking the // coordinator; the later coordinator read still needs to observe completion. @@ -204,6 +239,18 @@ function reconcileWorkerDoneMessage( return { action: 'completed', taskId, dispatchId } } +function buildLifecycleAuthorityRejectionReason( + dispatchId: string, + dispatch: { assignee_handle: string | null; assignee_pane_key: string | null }, + msg: MessageRow +): string { + return ( + `dispatch ${dispatchId} expected handle ${dispatch.assignee_handle ?? ''}, ` + + `pane ${dispatch.assignee_pane_key ?? ''}; received handle ${msg.from_handle}, ` + + `pane ${msg.sender_pane_key ?? ''}` + ) +} + function suppressEarlierHeartbeats( db: OrchestrationDb, workerDone: MessageRow, diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index 23fac4843ab..198437e4cd7 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -8,6 +8,7 @@ function baseParams(overrides: Partial[ dispatchId: 'ctx_def456', taskSpec: 'Implement the login form', coordinatorHandle: 'term_coord', + workerHandle: 'term_worker', ...overrides } } @@ -46,6 +47,7 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('--dispatch-id ctx_def456') expect(result).toContain('--files-modified "path/a,path/b"') expect(result).toContain('--report-path ""') + expect(result).toMatch(/orchestration send --to term_coord --from term_worker/) }) it( @@ -83,11 +85,12 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('--task-id task_abc123') expect(result).toContain('--dispatch-id ctx_def456') expect(result).toContain('--phase ""') + expect(result).toMatch(/orchestration send --to term_coord --from term_worker/) }) it('includes ask block with BEHAVIOR RULE #1 forbidding AskUserQuestion', () => { const result = buildDispatchPreamble(baseParams()) - expect(result).toContain('orchestration ask') + expect(result).toMatch(/orchestration ask --to term_coord --from term_worker/) expect(result).toContain('--question') expect(result).toContain('--timeout-ms 600000') // Why: the exact phrase is asserted so the rule can't be trimmed away by @@ -103,6 +106,16 @@ describe('buildDispatchPreamble', () => { expect(occurrences).toBe(3) }) + it('binds every injected worker command to the dispatched terminal', () => { + const result = buildDispatchPreamble(baseParams()) + + expect(result).toMatch(/orchestration ask --to term_coord --from term_worker/) + expect(result).toMatch( + /orchestration send --to term_coord --from term_worker \\\n --type escalation/ + ) + expect(result).toContain('orchestration check --terminal term_worker') + }) + it('tells prompt-returning workers to idle without post-done polling', () => { const result = buildDispatchPreamble(baseParams()) const section = afterWorkerDoneSection(result) @@ -168,6 +181,7 @@ describe('buildDispatchPreamble', () => { dispatchId: 'ctx_x', taskSpec: 'do stuff', coordinatorHandle: 'term_c', + workerHandle: 'term_w', baseDrift: { base: 'origin/main', behind: 7, @@ -190,6 +204,7 @@ describe('buildDispatchPreamble', () => { dispatchId: 'ctx_x', taskSpec: 'do stuff', coordinatorHandle: 'term_c', + workerHandle: 'term_w', baseDrift: { base: 'origin/main', behind: 0, @@ -206,7 +221,8 @@ describe('buildDispatchPreamble', () => { taskId: 'task_x', dispatchId: 'ctx_x', taskSpec: 'do stuff', - coordinatorHandle: 'term_c' + coordinatorHandle: 'term_c', + workerHandle: 'term_w' }) expect(result).not.toContain('--- BASE DRIFT ---') @@ -219,6 +235,7 @@ describe('buildDispatchPreamble', () => { dispatchId: 'ctx_x', taskSpec: 'do stuff', coordinatorHandle: 'term_c', + workerHandle: 'term_w', baseDrift: { base: 'origin/main', behind: 3, @@ -241,7 +258,8 @@ describe('buildDispatchPreamble', () => { taskId: 'task_SNAP', dispatchId: 'ctx_SNAP', taskSpec: 'TASK_BODY', - coordinatorHandle: 'term_COORD' + coordinatorHandle: 'term_COORD', + workerHandle: 'term_WORKER' }) expect(result).toMatchSnapshot() }) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index fdee65505e5..9aaa488318f 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -8,6 +8,7 @@ export type PreambleParams = { dispatchId: string taskSpec: string coordinatorHandle: string + workerHandle: string devMode?: boolean // Why: populated by the coordinator's dispatch pre-flight (§3.1) only // when the target worktree is behind its tracking remote. When absent @@ -68,7 +69,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # with subject like "Failed: " — never silently exit. # Include BOTH taskId and dispatchId in the payload so a late completion # from a failed retry cannot complete the current dispatch. - ${cli} orchestration send --to ${params.coordinatorHandle} \\ + ${cli} orchestration send --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ --type worker_done --subject "" \\ --body "<3-sentence summary: what you did, what you found, what's left>" \\ --task-id ${params.taskId} --dispatch-id ${params.dispatchId} \\ @@ -85,7 +86,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # attributes the heartbeat to the specific dispatch context, not just # the task, so a straggler heartbeat from a previously-failed dispatch # cannot mask a hung retry. - ${cli} orchestration send --to ${params.coordinatorHandle} \\ + ${cli} orchestration send --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ --type heartbeat --subject "alive" \\ --task-id ${params.taskId} --dispatch-id ${params.dispatchId} \\ --phase "" @@ -102,20 +103,20 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # blocks on \`check --wait\` until the coordinator replies, then prints the # reply body. Use it anywhere you would otherwise have reached for # AskUserQuestion. - ${cli} orchestration ask --to ${params.coordinatorHandle} \\ + ${cli} orchestration ask --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ --question "" \\ --options "" \\ --timeout-ms 600000 # Escalate a blocker or failure (pre-completion, when you need the # coordinator to do something before you can continue): - ${cli} orchestration send --to ${params.coordinatorHandle} \\ + ${cli} orchestration send --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ --type escalation --subject "Blocked: " \\ --body "
" \\ --task-id ${params.taskId} # Check for messages from the coordinator: - ${cli} orchestration check + ${cli} orchestration check --terminal ${params.workerHandle} ${postDoneInstructions}` diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts new file mode 100644 index 00000000000..5c1823a4e8e --- /dev/null +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -0,0 +1,267 @@ +import { z } from 'zod' +import { + isFeatureInteractionId, + type FeatureInteractionId +} from '../../../../shared/feature-interactions' +import { isFeatureTipId } from '../../../../shared/feature-tips' +import { + normalizeTuiAgentArgsRecord, + normalizeTuiAgentEnvRecord +} from '../../../../shared/tui-agent-launch-defaults' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isTaskProvider } from '../../../../shared/task-providers' +import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' +import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides' +import { normalizeWorktreeCardProperties } from '../../../../shared/worktree-card-properties' +import type { TaskProvider } from '../../../../shared/types' + +const NullableString = z.string().nullable() +const StringArray = z.array(z.string()) +const TaskProviderParam = z.custom(isTaskProvider, { + message: 'Unknown task provider' +}) +const FeatureTipIds = z.array(z.custom(isFeatureTipId, { message: 'Unknown feature tip id' })) +const UnknownRecord = z.record(z.string(), z.unknown()) +const UnknownRecordArray = z.array(UnknownRecord) +const LegacyWorktreeCardProperty = z.enum([ + 'status', + 'unread', + 'ci', + 'branch', + 'issue', + 'linear-issue', + 'pr', + 'automation', + 'comment', + 'ports', + 'inline-agents' +]) +const WorktreeCardProperties = z + .array(LegacyWorktreeCardProperty) + .transform((value) => normalizeWorktreeCardProperties(value)) +const AgentActivityDisplayMode = z.enum(['compact', 'full']) +const StatusBarItem = z.enum([ + 'claude', + 'codex', + 'gemini', + 'antigravity', + 'opencode-go', + 'kimi', + 'minimax', + 'grok', + 'ssh', + 'resource-usage', + 'ports' +]) +const WorkspaceStatusDefinition = z.object({ + id: z.string(), + label: z.string(), + color: z.string().optional(), + icon: z.string().optional() +}) +const TaskResumeState = z + .object({ + githubMode: z.enum(['items', 'project']).optional(), + githubItemsPreset: z.string().nullable().optional(), + githubItemsQuery: z.string().optional(), + githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), + linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), + linearQuery: z.string().optional() + }) + .strict() +const WorkspaceCleanupDismissal = z + .object({ + worktreeId: z.string(), + dismissedAt: z.number().finite(), + fingerprint: z.string(), + classifierVersion: z.number().finite() + }) + .strict() +const WorkspaceCleanup = z + .object({ + dismissals: z.record(z.string(), WorkspaceCleanupDismissal) + }) + .strict() +const FeatureInteractionRecord = z + .object({ + firstInteractedAt: z.number().finite().nonnegative(), + interactionCount: z.number().int().positive().optional() + }) + .strict() +const FeatureInteractions = z + .record(z.string(), FeatureInteractionRecord) + .superRefine((value, ctx) => { + for (const id of Object.keys(value)) { + if (!isFeatureInteractionId(id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Unknown feature interaction id: ${id}`, + path: [id] + }) + } + } + }) +export const FeatureInteractionIdParam = z.custom(isFeatureInteractionId, { + message: 'Unknown feature interaction id' +}) +export const PRBotAuthorOverrideUpdate = z + .object({ author: z.string(), isBot: z.boolean() }) + .strict() +const GitHubProjectRef = z + .object({ + owner: z.string(), + ownerType: z.enum(['organization', 'user']), + number: z.number().int() + }) + .strict() +const GitHubProjectSettings = z + .object({ + pinned: z.array(GitHubProjectRef), + recent: z.array( + GitHubProjectRef.extend({ + lastOpenedAt: z.string() + }).strict() + ), + lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()), + activeProject: GitHubProjectRef.nullable() + }) + .strict() + +export const SettingsUpdate = z + .object({ + defaultTuiAgent: z + .unknown() + .transform((value) => + value === null || value === 'blank' || isTuiAgent(value) ? value : undefined + ) + .optional(), + disabledTuiAgents: z + .unknown() + .transform((value) => normalizeDisabledTuiAgents(value)) + .optional(), + agentDefaultArgs: z + .unknown() + .transform((value) => normalizeTuiAgentArgsRecord(value)) + .optional(), + agentDefaultEnv: z + .unknown() + .transform((value) => normalizeTuiAgentEnvRecord(value)) + .optional(), + defaultTaskSource: TaskProviderParam.optional(), + visibleTaskProviders: z.array(TaskProviderParam).optional(), + defaultTaskViewPreset: z + .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) + .optional(), + experimentalNewWorktreeCardStyle: z.boolean().optional(), + agentStatusHooksEnabled: z.boolean().optional(), + defaultRepoSelection: z.array(z.string()).nullable().optional(), + defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), + compactWorktreeCards: z.boolean().optional(), + minimaxGroupId: z.string().optional(), + minimaxUsageModels: z.string().optional(), + githubProjects: GitHubProjectSettings.optional(), + prBotAuthorOverrides: z + .unknown() + .transform((value) => normalizePRBotAuthorOverrides(value)) + .optional() + }) + .strict() + .default({}) + +export const UiUpdate = z + .object({ + lastActiveRepoId: NullableString.optional(), + lastActiveWorktreeId: NullableString.optional(), + sidebarWidth: z.number().finite().optional(), + rightSidebarOpen: z.boolean().optional(), + rightSidebarTab: z + .enum(['explorer', 'search', 'vault', 'source-control', 'checks', 'ports']) + .optional(), + rightSidebarExplorerView: z.enum(['files', 'search']).optional(), + rightSidebarWidth: z.number().finite().optional(), + markdownTocPanelWidth: z.number().finite().optional(), + groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(), + showWorkspaceLineage: z.boolean().optional(), + sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(), + projectOrderBy: z.enum(['manual', 'recent']).optional(), + showActiveOnly: z.boolean().optional(), + hideSleepingWorkspaces: z.boolean().optional(), + showSleepingWorkspaces: z.boolean().optional(), + showInactiveWorkspaces: z.boolean().optional(), + workspaceHostScope: z.string().optional(), + visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), + workspaceHostOrder: z.array(z.string()).optional(), + hideDefaultBranchWorkspace: z.boolean().optional(), + hideAutomationGeneratedWorkspaces: z.boolean().optional(), + filterRepoIds: StringArray.optional(), + collapsedGroups: StringArray.optional(), + uiZoomLevel: z.number().finite().optional(), + editorFontZoomLevel: z.number().finite().optional(), + worktreeCardProperties: WorktreeCardProperties.optional(), + _worktreeCardModeDefaulted: z.boolean().optional(), + agentActivityDisplayMode: AgentActivityDisplayMode.optional(), + workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(), + workspaceBoardOpacity: z.number().finite().optional(), + workspaceBoardColumnWidth: z.number().finite().optional(), + syncTaskStatusFromWorkspaceBoard: z.boolean().optional(), + _workspaceStatusesDefaultOrderMigrated: z.boolean().optional(), + _workspaceStatusesReorderedDefaultRepaired: z.boolean().optional(), + _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), + _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(), + statusBarItems: z.array(StatusBarItem).optional(), + _portsStatusBarDefaultAdded: z.boolean().optional(), + _kimiStatusBarDefaultAdded: z.boolean().optional(), + _minimaxStatusBarDefaultAdded: z.boolean().optional(), + _antigravityStatusBarDefaultAdded: z.boolean().optional(), + _grokStatusBarDefaultAdded: z.boolean().optional(), + statusBarVisible: z.boolean().optional(), + usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), + dismissedUpdateVersion: NullableString.optional(), + lastUpdateCheckAt: z.number().finite().nullable().optional(), + pendingUpdateNudgeId: NullableString.optional(), + dismissedUpdateNudgeId: NullableString.optional(), + notificationPermissionRequested: z.boolean().optional(), + updateReassuranceSeen: z.boolean().optional(), + acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(), + browserDefaultUrl: NullableString.optional(), + browserDefaultSearchEngine: z + .enum(['google', 'duckduckgo', 'bing', 'kagi']) + .nullable() + .optional(), + browserDefaultZoomLevel: z.number().finite().optional(), + browserKagiSessionLink: NullableString.optional(), + windowBounds: z + .object({ + x: z.number().finite(), + y: z.number().finite(), + width: z.number().finite(), + height: z.number().finite() + }) + .nullable() + .optional(), + windowMaximized: z.boolean().optional(), + _sortBySmartMigrated: z.boolean().optional(), + _inlineAgentsDefaultedForExperiment: z.boolean().optional(), + _inlineAgentsDefaultedForAllUsers: z.boolean().optional(), + trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), + setupScriptPromptDismissedRepoIds: StringArray.optional(), + projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), + usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), + usageEmptyStateDismissed: z.boolean().optional(), + petVisible: z.boolean().optional(), + petId: z.string().optional(), + customPets: UnknownRecordArray.optional(), + petSize: z.number().finite().optional(), + sidekickVisible: z.boolean().optional(), + sidekickId: z.string().optional(), + customSidekicks: UnknownRecordArray.optional(), + sidekickSize: z.number().finite().optional(), + taskResumeState: TaskResumeState.optional(), + workspaceCleanup: WorkspaceCleanup.optional(), + featureTipsSeenIds: FeatureTipIds.optional(), + featureInteractions: FeatureInteractions.optional(), + contextualToursSeenIds: StringArray.optional(), + contextualToursAutoEligible: z.boolean().optional() + }) + .strict() + .default({}) diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 01a3f784e17..a3f3dbe88c2 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -117,6 +117,63 @@ describe('client UI RPC methods', () => { }) }) + it('normalizes manual bot-author overrides before persisting', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateClientSettings: vi.fn(() => ({})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + await dispatcher.dispatch( + makeRequest('settings.update', { + prBotAuthorOverrides: [' GretelFlux ', 'gretelflux', 42, '', 'another-bot'] + }) + ) + + expect(runtime.updateClientSettings).toHaveBeenCalledWith({ + prBotAuthorOverrides: ['another-bot', 'gretelflux'] + }) + }) + + it('caps oversized bot-author override payloads', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateClientSettings: vi.fn(() => ({})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + await dispatcher.dispatch( + makeRequest('settings.update', { + prBotAuthorOverrides: Array.from( + { length: 600 }, + (_, i) => `bot-${String(i).padStart(4, '0')}` + ) + }) + ) + + const [update] = vi.mocked(runtime.updateClientSettings).mock.calls[0]! + expect((update as { prBotAuthorOverrides: string[] }).prBotAuthorOverrides).toHaveLength(500) + }) + + it('routes bot-author deltas to the runtime-owned atomic update', async () => { + const settings = { prBotAuthorOverrides: ['alice', 'bob'] } + const runtime = { + getRuntimeId: () => 'test-runtime', + updateClientPRBotAuthorOverride: vi.fn(() => settings) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('settings.updatePRBotAuthorOverride', { author: ' Bob ', isBot: true }) + ) + + expect(runtime.updateClientPRBotAuthorOverride).toHaveBeenCalledWith({ + author: ' Bob ', + isBot: true + }) + expect(response).toMatchObject({ ok: true, result: { settings } }) + }) + it('returns the runtime host persisted UI state', async () => { const ui: PersistedUIState = { ...getDefaultUIState(), diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index d262890d8d7..7945203f958 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -1,263 +1,11 @@ -import { z } from 'zod' -import { - isFeatureInteractionId, - type FeatureInteractionId -} from '../../../../shared/feature-interactions' -import { isFeatureTipId } from '../../../../shared/feature-tips' -import { - normalizeTuiAgentArgsRecord, - normalizeTuiAgentEnvRecord -} from '../../../../shared/tui-agent-launch-defaults' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isTaskProvider } from '../../../../shared/task-providers' -import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' -import { normalizeWorktreeCardProperties } from '../../../../shared/worktree-card-properties' -import type { PersistedUIState, TaskProvider } from '../../../../shared/types' +import type { PersistedUIState } from '../../../../shared/types' import { defineMethod, type RpcMethod } from '../core' - -const NullableString = z.string().nullable() -const StringArray = z.array(z.string()) -const TaskProviderParam = z.custom(isTaskProvider, { - message: 'Unknown task provider' -}) -const FeatureTipIds = z.array(z.custom(isFeatureTipId, { message: 'Unknown feature tip id' })) -const UnknownRecord = z.record(z.string(), z.unknown()) -const UnknownRecordArray = z.array(UnknownRecord) -const LegacyWorktreeCardProperty = z.enum([ - 'status', - 'unread', - 'ci', - 'branch', - 'issue', - 'linear-issue', - 'pr', - 'automation', - 'comment', - 'ports', - 'inline-agents' -]) -const WorktreeCardProperties = z - .array(LegacyWorktreeCardProperty) - .transform((value) => normalizeWorktreeCardProperties(value)) -const AgentActivityDisplayMode = z.enum(['compact', 'full']) -const StatusBarItem = z.enum([ - 'claude', - 'codex', - 'gemini', - 'antigravity', - 'opencode-go', - 'kimi', - 'minimax', - 'grok', - 'ssh', - 'resource-usage', - 'ports' -]) -const WorkspaceStatusDefinition = z.object({ - id: z.string(), - label: z.string(), - color: z.string().optional(), - icon: z.string().optional() -}) -const TaskResumeState = z - .object({ - githubMode: z.enum(['items', 'project']).optional(), - githubItemsPreset: z.string().nullable().optional(), - githubItemsQuery: z.string().optional(), - githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), - linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), - linearQuery: z.string().optional() - }) - .strict() -const WorkspaceCleanupDismissal = z - .object({ - worktreeId: z.string(), - dismissedAt: z.number().finite(), - fingerprint: z.string(), - classifierVersion: z.number().finite() - }) - .strict() -const WorkspaceCleanup = z - .object({ - dismissals: z.record(z.string(), WorkspaceCleanupDismissal) - }) - .strict() -const FeatureInteractionRecord = z - .object({ - firstInteractedAt: z.number().finite().nonnegative(), - interactionCount: z.number().int().positive().optional() - }) - .strict() -const FeatureInteractions = z - .record(z.string(), FeatureInteractionRecord) - .superRefine((value, ctx) => { - for (const id of Object.keys(value)) { - if (!isFeatureInteractionId(id)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Unknown feature interaction id: ${id}`, - path: [id] - }) - } - } - }) -const FeatureInteractionIdParam = z.custom(isFeatureInteractionId, { - message: 'Unknown feature interaction id' -}) -const GitHubProjectRef = z - .object({ - owner: z.string(), - ownerType: z.enum(['organization', 'user']), - number: z.number().int() - }) - .strict() -const GitHubProjectSettings = z - .object({ - pinned: z.array(GitHubProjectRef), - recent: z.array( - GitHubProjectRef.extend({ - lastOpenedAt: z.string() - }).strict() - ), - lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()), - activeProject: GitHubProjectRef.nullable() - }) - .strict() - -const SettingsUpdate = z - .object({ - defaultTuiAgent: z - .unknown() - .transform((value) => - value === null || value === 'blank' || isTuiAgent(value) ? value : undefined - ) - .optional(), - disabledTuiAgents: z - .unknown() - .transform((value) => normalizeDisabledTuiAgents(value)) - .optional(), - agentDefaultArgs: z - .unknown() - .transform((value) => normalizeTuiAgentArgsRecord(value)) - .optional(), - agentDefaultEnv: z - .unknown() - .transform((value) => normalizeTuiAgentEnvRecord(value)) - .optional(), - defaultTaskSource: TaskProviderParam.optional(), - visibleTaskProviders: z.array(TaskProviderParam).optional(), - defaultTaskViewPreset: z - .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) - .optional(), - experimentalNewWorktreeCardStyle: z.boolean().optional(), - agentStatusHooksEnabled: z.boolean().optional(), - defaultRepoSelection: z.array(z.string()).nullable().optional(), - defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), - compactWorktreeCards: z.boolean().optional(), - minimaxGroupId: z.string().optional(), - minimaxUsageModels: z.string().optional(), - githubProjects: GitHubProjectSettings.optional() - }) - .strict() - .default({}) - -const UiUpdate = z - .object({ - lastActiveRepoId: NullableString.optional(), - lastActiveWorktreeId: NullableString.optional(), - sidebarWidth: z.number().finite().optional(), - rightSidebarOpen: z.boolean().optional(), - rightSidebarTab: z - .enum(['explorer', 'search', 'vault', 'source-control', 'checks', 'ports']) - .optional(), - rightSidebarExplorerView: z.enum(['files', 'search']).optional(), - rightSidebarWidth: z.number().finite().optional(), - markdownTocPanelWidth: z.number().finite().optional(), - groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(), - showWorkspaceLineage: z.boolean().optional(), - sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(), - projectOrderBy: z.enum(['manual', 'recent']).optional(), - showActiveOnly: z.boolean().optional(), - hideSleepingWorkspaces: z.boolean().optional(), - showSleepingWorkspaces: z.boolean().optional(), - showInactiveWorkspaces: z.boolean().optional(), - workspaceHostScope: z.string().optional(), - visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), - workspaceHostOrder: z.array(z.string()).optional(), - hideDefaultBranchWorkspace: z.boolean().optional(), - hideAutomationGeneratedWorkspaces: z.boolean().optional(), - filterRepoIds: StringArray.optional(), - collapsedGroups: StringArray.optional(), - uiZoomLevel: z.number().finite().optional(), - editorFontZoomLevel: z.number().finite().optional(), - worktreeCardProperties: WorktreeCardProperties.optional(), - _worktreeCardModeDefaulted: z.boolean().optional(), - agentActivityDisplayMode: AgentActivityDisplayMode.optional(), - workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(), - workspaceBoardOpacity: z.number().finite().optional(), - workspaceBoardColumnWidth: z.number().finite().optional(), - syncTaskStatusFromWorkspaceBoard: z.boolean().optional(), - _workspaceStatusesDefaultOrderMigrated: z.boolean().optional(), - _workspaceStatusesReorderedDefaultRepaired: z.boolean().optional(), - _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), - _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(), - statusBarItems: z.array(StatusBarItem).optional(), - _portsStatusBarDefaultAdded: z.boolean().optional(), - _kimiStatusBarDefaultAdded: z.boolean().optional(), - _minimaxStatusBarDefaultAdded: z.boolean().optional(), - _antigravityStatusBarDefaultAdded: z.boolean().optional(), - _grokStatusBarDefaultAdded: z.boolean().optional(), - statusBarVisible: z.boolean().optional(), - usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), - dismissedUpdateVersion: NullableString.optional(), - lastUpdateCheckAt: z.number().finite().nullable().optional(), - pendingUpdateNudgeId: NullableString.optional(), - dismissedUpdateNudgeId: NullableString.optional(), - notificationPermissionRequested: z.boolean().optional(), - updateReassuranceSeen: z.boolean().optional(), - acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(), - browserDefaultUrl: NullableString.optional(), - browserDefaultSearchEngine: z - .enum(['google', 'duckduckgo', 'bing', 'kagi']) - .nullable() - .optional(), - browserDefaultZoomLevel: z.number().finite().optional(), - browserKagiSessionLink: NullableString.optional(), - windowBounds: z - .object({ - x: z.number().finite(), - y: z.number().finite(), - width: z.number().finite(), - height: z.number().finite() - }) - .nullable() - .optional(), - windowMaximized: z.boolean().optional(), - _sortBySmartMigrated: z.boolean().optional(), - _inlineAgentsDefaultedForExperiment: z.boolean().optional(), - _inlineAgentsDefaultedForAllUsers: z.boolean().optional(), - trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), - setupScriptPromptDismissedRepoIds: StringArray.optional(), - projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), - usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), - usageEmptyStateDismissed: z.boolean().optional(), - petVisible: z.boolean().optional(), - petId: z.string().optional(), - customPets: UnknownRecordArray.optional(), - petSize: z.number().finite().optional(), - sidekickVisible: z.boolean().optional(), - sidekickId: z.string().optional(), - customSidekicks: UnknownRecordArray.optional(), - sidekickSize: z.number().finite().optional(), - taskResumeState: TaskResumeState.optional(), - workspaceCleanup: WorkspaceCleanup.optional(), - featureTipsSeenIds: FeatureTipIds.optional(), - featureInteractions: FeatureInteractions.optional(), - contextualToursSeenIds: StringArray.optional(), - contextualToursAutoEligible: z.boolean().optional() - }) - .strict() - .default({}) +import { + FeatureInteractionIdParam, + PRBotAuthorOverrideUpdate, + SettingsUpdate, + UiUpdate +} from './client-ui-schemas' export const CLIENT_UI_METHODS: RpcMethod[] = [ defineMethod({ @@ -270,6 +18,13 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [ params: SettingsUpdate, handler: (params, { runtime }) => ({ settings: runtime.updateClientSettings(params) }) }), + defineMethod({ + name: 'settings.updatePRBotAuthorOverride', + params: PRBotAuthorOverrideUpdate, + handler: (params, { runtime }) => ({ + settings: runtime.updateClientPRBotAuthorOverride(params) + }) + }), defineMethod({ name: 'ui.get', params: null, diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 7282489c706..9f535d9a637 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -128,7 +128,12 @@ describe('file RPC methods', () => { await vi.waitFor(() => { expect(replies).toHaveLength(1) }) - expect(runtime.watchFileExplorer).toHaveBeenCalledWith('id:wt-1', expect.any(Function)) + expect(runtime.watchFileExplorer).toHaveBeenCalledWith( + 'id:wt-1', + expect.any(Function), + expect.any(Function), + undefined + ) expect(replies[0]).toMatchObject({ ok: true, streaming: true, @@ -172,17 +177,63 @@ describe('file RPC methods', () => { } }) + it('ends a ready file-watch stream when its watcher fails terminally', async () => { + let onTerminalError: ((error: Error) => void) | undefined + const unwatch = vi.fn() + const cleanups = new Map void>() + let emitWatchChange: + | ((events: { kind: 'overflow'; absolutePath: string }[]) => void) + | undefined + const runtime = { + getRuntimeId: () => 'test-runtime', + watchFileExplorer: vi.fn(async (_worktree, callback, nextTerminalError) => { + emitWatchChange = callback + onTerminalError = nextTerminalError + return unwatch + }), + registerSubscriptionCleanup: vi.fn((id, cleanup) => cleanups.set(id, cleanup)), + cleanupSubscription: vi.fn((id) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const replies: { result?: { type?: string; message?: string } }[] = [] + + const dispatch = dispatcher.dispatchStreaming( + makeRequest('files.watch', { worktree: 'id:wt-1' }), + (response) => replies.push(JSON.parse(response)) + ) + await vi.waitFor(() => expect(replies[0]?.result?.type).toBe('ready')) + + emitWatchChange?.([{ kind: 'overflow', absolutePath: '/repo' }]) + onTerminalError?.(new Error('file watcher process crashed repeatedly')) + await dispatch + + expect(replies.map((reply) => reply.result?.type)).toEqual(['ready', 'changed', 'error', 'end']) + expect(replies[2]?.result?.message).toContain('crashed repeatedly') + expect(unwatch).toHaveBeenCalledTimes(1) + expect(cleanups.size).toBe(0) + }) + it('tears down a file watch that resolves after the connection already closed', async () => { type WatchCallback = ( events: { kind: 'update'; absolutePath: string; isDirectory?: boolean }[] ) => void const unwatch = vi.fn() let resolveWatch: (value: () => void) => void = () => {} - const watchFileExplorer = vi.fn((_worktree: string, _callback: WatchCallback) => { - return new Promise<() => void>((resolve) => { - resolveWatch = resolve - }) - }) + const watchFileExplorer = vi.fn( + ( + _worktree: string, + _callback: WatchCallback, + _onTerminalError?: (error: Error) => void, + _signal?: AbortSignal + ) => + new Promise<() => void>((resolve) => { + resolveWatch = resolve + }) + ) const runtime = { getRuntimeId: () => 'test-runtime', watchFileExplorer, @@ -200,6 +251,7 @@ describe('file RPC methods', () => { await vi.waitFor(() => { expect(watchFileExplorer).toHaveBeenCalled() }) + expect(watchFileExplorer.mock.calls[0]?.[3]).toBe(abortController.signal) abortController.abort() await dispatch diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 37224f18ed5..b1fb70234a6 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -392,6 +392,7 @@ export const FILE_METHODS: RpcAnyMethod[] = [ await new Promise((resolve, reject) => { let settled = false let unwatch: (() => void) | null = null + let terminalError: Error | null = null const eventBatcher = createFileWatchEventBatcher(params.worktree, emit) const finish = (): void => { if (settled) { @@ -402,11 +403,32 @@ export const FILE_METHODS: RpcAnyMethod[] = [ resolve() } const cleanup = (): void => { - eventBatcher.flush() - eventBatcher.dispose() + // Mark cleanup complete before emitting so a synchronous transport + // abort cannot re-enter this function and duplicate error/end. + finish() + if (terminalError) { + // Why: recovery emits overflow before giving up. Flush that final + // refresh so clients never end on a knowingly stale snapshot. + eventBatcher.flush() + eventBatcher.dispose() + emit({ type: 'error', message: terminalError.message }) + } else { + eventBatcher.flush() + eventBatcher.dispose() + } unwatch?.() emit({ type: 'end' }) - finish() + } + const handleTerminalError = (error: Error): void => { + if (settled || terminalError) { + return + } + terminalError = error + if (unwatch) { + runtime.cleanupSubscription(subscriptionId) + } else { + cleanup() + } } const handleAbort = (): void => { if (unwatch) { @@ -420,9 +442,14 @@ export const FILE_METHODS: RpcAnyMethod[] = [ } signal?.addEventListener('abort', handleAbort, { once: true }) void runtime - .watchFileExplorer(params.worktree, (events) => { - eventBatcher.push(events) - }) + .watchFileExplorer( + params.worktree, + (events) => { + eventBatcher.push(events) + }, + handleTerminalError, + signal + ) .then((nextUnwatch) => { if (signal?.aborted || settled) { // Why: the connection can close while watch setup is still @@ -438,7 +465,9 @@ export const FILE_METHODS: RpcAnyMethod[] = [ }) .catch((error) => { if (!settled) { + settled = true signal?.removeEventListener('abort', handleAbort) + eventBatcher.dispose() reject(error) } }) diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 6308b425784..fc7536c2e4f 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -105,6 +105,100 @@ describe('orchestration RPC methods', () => { expect(db.getMessageById(result.message.id)?.sender_pane_key).toBe('tab_a:leaf_a') }) + it('recovers missing sender pane identity from the resolved handle', async () => { + setup() + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_worker:leaf_worker') + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + const result = (await call('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'hello' + })) as { message: { id: string } } + + expect(runtime.getTerminalPaneKey).toHaveBeenCalledWith('term_worker') + expect(db.getMessageById(result.message.id)?.sender_pane_key).toBe('tab_worker:leaf_worker') + }) + + it('completes an identity-less injected send through its explicit worker handle', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_worker' ? 'tab_worker:leaf_worker' : null + ) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + await call('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + }) + + expect(db.getTask(task.id)?.status).toBe('completed') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') + }) + + it('rejects an identity-less lifecycle send resolved through the coordinator handle', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + const dependent = db.createTask({ spec: 'dependent', deps: [task.id] }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' ? 'tab_coord:leaf_coord' : null + ) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + await call('orchestration.send', { + from: 'term_coord', + to: 'term_coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + }) + + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getTask(dependent.id)?.status).toBe('pending') + }) + + it('does not replace a foreign sender pane with its claimed assignee handle pane', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_worker:leaf_worker') + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + const result = (await call('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'Done', + type: 'worker_done', + senderPaneKey: 'tab_foreign:leaf_foreign', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + })) as { + message: { id: string; type: string; subject: string } + lifecycle: { action: string; code: string; reason: string } + } + + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(result.lifecycle).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee', + reason: expect.stringContaining('expected handle term_worker') + }) + expect(result.message).toMatchObject({ + type: 'worker_done', + subject: 'Rejected worker_done: Done' + }) + expect(db.getUnreadMessages('term_coord')).toEqual([ + expect.objectContaining({ id: result.message.id, type: 'worker_done' }) + ]) + expect(runtime.notifyMessageArrived).toHaveBeenCalledWith('term_coord', 'worker_done') + }) + it('does not wake waiters for a heartbeat suppressed at send time', async () => { setup() const task = db.createTask({ spec: 'work' }) @@ -372,6 +466,23 @@ describe('orchestration RPC methods', () => { expect(result.messages[0].to_handle).toBe('term_b') }) + it('fans out @cursor by title match without claiming a cursor-mentioning title', async () => { + setupWithTerminals([ + makeSummary('term_a', { title: 'Codex' }), + makeSummary('term_b', { title: 'Cursor ready' }), + makeSummary('term_c', { title: '✳ Fix the text cursor blink' }) + ]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@cursor', + subject: 'cursor only' + })) as { messages: { to_handle: string }[]; recipients: number } + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe('term_b') + }) + it('fans out @worktree: to matching worktree', async () => { setupWithTerminals([ makeSummary('term_a', { worktreeId: 'wt_1' }), @@ -495,9 +606,9 @@ describe('orchestration RPC methods', () => { }) describe('orchestration.check', () => { - function createDispatchedTask(assigneeHandle = 'term_worker') { + function createDispatchedTask(assigneeHandle = 'term_worker', assigneePaneKey?: string) { const task = db.createTask({ spec: 'manual check work' }) - const dispatch = db.createDispatchContext(task.id, assigneeHandle) + const dispatch = db.createDispatchContext(task.id, assigneeHandle, assigneePaneKey) return { task, dispatch } } @@ -507,6 +618,7 @@ describe('orchestration RPC methods', () => { taskId?: string dispatchId?: string filesModified?: string[] + senderPaneKey?: string }): void { const payload: Record = {} if (params.taskId !== undefined) { @@ -524,7 +636,8 @@ describe('orchestration RPC methods', () => { to: params.to ?? 'term_coord', subject: 'Done', type: 'worker_done', - payload: JSON.stringify(payload) + payload: JSON.stringify(payload), + senderPaneKey: params.senderPaneKey }) } @@ -653,11 +766,13 @@ describe('orchestration RPC methods', () => { it('completes worker_done by payload IDs when the sender handle changed', async () => { setup() - const { task, dispatch } = createDispatchedTask('term_owner') + const leafId = '11111111-1111-4111-8111-111111111111' + const { task, dispatch } = createDispatchedTask('term_owner', `tab_before:${leafId}`) insertWorkerDone({ from: 'term_reminted', taskId: task.id, - dispatchId: dispatch.id + dispatchId: dispatch.id, + senderPaneKey: `tab_after:${leafId}` }) const result = (await call('orchestration.check', { @@ -693,6 +808,34 @@ describe('orchestration RPC methods', () => { expect(db.getDispatchContextById(activeDispatch.id)?.status).toBe('dispatched') }) + it('returns a persisted foreign completion as a rejection diagnostic', async () => { + setup() + const { task, dispatch } = createDispatchedTask('term_worker', 'tab_worker:leaf_worker') + insertWorkerDone({ + from: 'term_foreign', + taskId: task.id, + dispatchId: dispatch.id, + senderPaneKey: 'tab_foreign:leaf_foreign' + }) + + const result = (await call('orchestration.check', { + terminal: 'term_coord', + types: 'worker_done' + })) as { count: number; messages: { type: string; subject: string; body: string }[] } + + expect(result).toMatchObject({ + count: 1, + messages: [ + { + type: 'worker_done', + subject: 'Rejected worker_done: Done', + body: expect.stringContaining('expected handle term_worker') + } + ] + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + it('records heartbeat returned by unread manual check', async () => { setup() const { task, dispatch } = createDispatchedTask() diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 4232633f1dd..9caaf1ac57a 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -209,6 +209,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => { const db = runtime.getOrchestrationDb() const from = params.from ?? 'unknown' + // Why: older live shells may lack ORCA_PANE_KEY, but the runtime still + // knows the pane behind their resolved handle; persist that authority. + const senderPaneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(from) ?? undefined if (!isGroupAddress(params.to)) { // Point-to-point — existing single-recipient behavior @@ -221,7 +224,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ priority: params.priority as MessagePriority, threadId: params.threadId, payload: params.payload, - senderPaneKey: params.senderPaneKey + senderPaneKey }) // Why: worker_done/heartbeat sent via `send` must release the dispatch // lock before waking recipients — a coordinator woken by delivery may @@ -234,6 +237,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ if (reconciled.action === 'suppressed') { return { message: msg } } + if (reconciled.action === 'rejected') { + const rejection = db.getMessageById(msg.id) ?? msg + runtime.deliverPendingMessagesForHandle(params.to) + runtime.notifyMessageArrived(params.to, rejection.type) + return { message: rejection, lifecycle: reconciled } + } } runtime.deliverPendingMessagesForHandle(params.to) runtime.notifyMessageArrived(params.to, msg.type) @@ -263,7 +272,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ priority: params.priority as MessagePriority, threadId, payload: params.payload, - senderPaneKey: params.senderPaneKey + senderPaneKey }) ) for (const message of messages) { @@ -304,22 +313,26 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ ? db.getAllMessagesForHandle(handle, undefined, typeFilter) : db.getUnreadMessages(handle, typeFilter) + let visibleMessages = messages if (consumeUnread && messages.length > 0) { // Why: manual coordinators can consume lifecycle messages before // the coordinator loop sees them, but unread `check` is still an // authoritative read path for worker_done/heartbeat. - for (const message of messages) { - reconcileLifecycleMessage(db, message) - } + visibleMessages = messages.map((message) => { + const reconciled = reconcileLifecycleMessage(db, message) + return reconciled.action === 'rejected' + ? (db.getMessageById(message.id) ?? message) + : message + }) db.markAsRead(messages.map((m) => m.id)) } if (params.inject) { - const formatted = messages.map(formatMessageBanner).join('\n\n') - return { messages, formatted, count: messages.length } + const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') + return { messages: visibleMessages, formatted, count: visibleMessages.length } } - return { messages, count: messages.length } + return { messages: visibleMessages, count: visibleMessages.length } } if (signal?.aborted) { @@ -481,6 +494,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ dispatchId: 'ctx_dryrun', taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', + workerHandle: params.to ?? 'worker', devMode: params.devMode }) return { dispatch: null, injected: false, dryRun: true, preamble } @@ -504,7 +518,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ if (!hasAgent) { throw new Error( `Cannot dispatch --inject to terminal ${to}: no recognized agent detected. ` + - 'Start an agent CLI (e.g. claude, codex, gemini, droid) in the terminal first, ' + + 'Start an agent CLI (e.g. claude, codex, gemini, droid, cursor) in the terminal first, ' + 'or dispatch without --inject and send the prompt manually.' ) } @@ -525,6 +539,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ dispatchId: ctx.id, taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', + workerHandle: to, devMode: params.devMode }) @@ -576,6 +591,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ dispatchId: ctx?.id ?? 'ctx_preview', taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', + workerHandle: ctx?.assignee_handle ?? 'worker', devMode: params.devMode }) return { dispatch: ctx ?? null, preamble } diff --git a/src/main/runtime/windows-mobile-firewall.test.ts b/src/main/runtime/windows-mobile-firewall.test.ts new file mode 100644 index 00000000000..4a7cc8a1219 --- /dev/null +++ b/src/main/runtime/windows-mobile-firewall.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getWebSocketPort, + inspectWindowsMobileFirewall, + repairWindowsMobileFirewall, + type WindowsMobileFirewallEnvironment +} from './windows-mobile-firewall' + +function environment( + runPowerShell: WindowsMobileFirewallEnvironment['runPowerShell'], + overrides: Partial = {} +): WindowsMobileFirewallEnvironment { + return { + platform: 'win32', + isPackaged: true, + executablePath: "C:\\Users\\O'Brien\\Orca\\Orca.exe", + systemRoot: 'C:\\Windows', + runPowerShell, + ...overrides + } +} + +describe('windows mobile firewall', () => { + it('inspects the exact executable, port, and selected interface profile', async () => { + const runPowerShell = vi.fn().mockResolvedValue( + JSON.stringify({ + ruleAllowed: true, + privateFirewallEnabled: true, + networkCategory: 'Private' + }) + ) + + await expect( + inspectWindowsMobileFirewall(6768, '192.168.0.108', environment(runPowerShell)) + ).resolves.toEqual({ + supported: true, + port: 6768, + ruleAllowed: true, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + }) + + const script = runPowerShell.mock.calls[0]![0] as string + expect(script).toContain("LocalPort | Where-Object { [string]$_ -eq 'Any'") + expect(script).toContain("[string]$_ -eq '6768'") + expect(script).toContain("C:\\Users\\O''Brien\\Orca\\Orca.exe") + expect(script).toContain("$profile -match 'Private'") + expect(script).toContain("Get-NetIPAddress -IPAddress '192.168.0.108'") + }) + + it('does not support non-Windows or unpackaged development builds', async () => { + const runPowerShell = vi.fn() + await expect( + inspectWindowsMobileFirewall( + 6768, + undefined, + environment(runPowerShell, { platform: 'darwin' }) + ) + ).resolves.toEqual({ supported: false }) + await expect( + repairWindowsMobileFirewall(6768, environment(runPowerShell, { isPackaged: false })) + ).resolves.toEqual({ ok: false, reason: 'unsupported' }) + expect(runPowerShell).not.toHaveBeenCalled() + }) + + it('returns an actionable status when inspection is unavailable', async () => { + await expect( + inspectWindowsMobileFirewall( + 6768, + undefined, + environment(vi.fn().mockRejectedValue(new Error('managed policy'))) + ) + ).resolves.toEqual({ + supported: true, + port: 6768, + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'unknown', + inspectionAvailable: false + }) + }) + + it('repairs only Orca mobile pairing on private networks after elevation', async () => { + const runPowerShell = vi.fn().mockResolvedValue('{"launched":true,"exitCode":0}') + await expect(repairWindowsMobileFirewall(6769, environment(runPowerShell))).resolves.toEqual({ + ok: true + }) + + const outerScript = runPowerShell.mock.calls[0]![0] as string + const encoded = outerScript.match(/'-EncodedCommand', '([^']+)'/)?.[1] + expect(encoded).toBeTruthy() + const repairScript = Buffer.from(encoded!, 'base64').toString('utf16le') + expect(repairScript).toContain("-Name 'Orca.MobilePairing'") + expect(repairScript).toContain('-Profile Private') + expect(repairScript).toContain('-Protocol TCP') + expect(repairScript).toContain('-LocalPort 6769') + expect(repairScript).toContain("-Program 'C:\\Users\\O''Brien\\Orca\\Orca.exe'") + expect(repairScript).toContain('-EdgeTraversalPolicy Block') + }) + + it('distinguishes a cancelled UAC prompt from repair failure', async () => { + await expect( + repairWindowsMobileFirewall( + 6768, + environment(vi.fn().mockResolvedValue('{"launched":false,"nativeErrorCode":1223}')) + ) + ).resolves.toEqual({ ok: false, reason: 'cancelled' }) + }) + + it('extracts only explicit valid websocket ports', () => { + expect(getWebSocketPort('ws://0.0.0.0:6768')).toBe(6768) + expect(getWebSocketPort('ws://0.0.0.0')).toBeNull() + expect(getWebSocketPort('not a url')).toBeNull() + }) +}) diff --git a/src/main/runtime/windows-mobile-firewall.ts b/src/main/runtime/windows-mobile-firewall.ts new file mode 100644 index 00000000000..b6419da9f5d --- /dev/null +++ b/src/main/runtime/windows-mobile-firewall.ts @@ -0,0 +1,225 @@ +import { execFile } from 'node:child_process' +import { win32 } from 'node:path' +import type { + WindowsMobileFirewallRepairResult, + WindowsMobileFirewallStatus, + WindowsNetworkCategory +} from '../../shared/windows-mobile-firewall' + +const FIREWALL_RULE_NAME = 'Orca.MobilePairing' +const FIREWALL_RULE_DISPLAY_NAME = 'Orca Mobile Pairing' +const POWERSHELL_TIMEOUT_MS = 10_000 +const ELEVATION_TIMEOUT_MS = 5 * 60_000 + +type PowerShellRunner = (script: string, timeoutMs: number) => Promise + +export type WindowsMobileFirewallEnvironment = { + platform: NodeJS.Platform + isPackaged: boolean + executablePath: string + systemRoot?: string + runPowerShell?: PowerShellRunner +} + +type FirewallInspection = { + ruleAllowed: boolean + privateFirewallEnabled: boolean + networkCategory: string +} + +type ElevationResult = { + launched: boolean + exitCode?: number + nativeErrorCode?: number +} + +export async function inspectWindowsMobileFirewall( + port: number | null, + address: string | undefined, + environment: WindowsMobileFirewallEnvironment = defaultEnvironment() +): Promise { + if (!isSupported(environment, port)) { + return { supported: false } + } + + try { + const stdout = await getRunner(environment)( + buildInspectionScript(port, environment.executablePath, address), + POWERSHELL_TIMEOUT_MS + ) + const result = JSON.parse(stdout.trim()) as FirewallInspection + return { + supported: true, + port, + ruleAllowed: result.ruleAllowed === true, + privateFirewallEnabled: result.privateFirewallEnabled !== false, + networkCategory: parseNetworkCategory(result.networkCategory), + inspectionAvailable: true + } + } catch { + // Why: firewall inspection is advisory; unavailable PowerShell or managed + // policy must not block pairing or remove the explicit repair option. + return unavailableStatus(port) + } +} + +export async function repairWindowsMobileFirewall( + port: number | null, + environment: WindowsMobileFirewallEnvironment = defaultEnvironment() +): Promise { + if (!isSupported(environment, port)) { + return { ok: false, reason: 'unsupported' } + } + + const powershellPath = getWindowsPowerShellPath(environment.systemRoot) + const elevatedScript = buildRepairScript(port, environment.executablePath) + const outerScript = buildElevationScript(powershellPath, encodePowerShell(elevatedScript)) + try { + const stdout = await getRunner(environment)(outerScript, ELEVATION_TIMEOUT_MS) + const result = JSON.parse(stdout.trim()) as ElevationResult + if (!result.launched && result.nativeErrorCode === 1223) { + return { ok: false, reason: 'cancelled' } + } + return result.launched && result.exitCode === 0 ? { ok: true } : { ok: false, reason: 'failed' } + } catch { + return { ok: false, reason: 'failed' } + } +} + +export function getWebSocketPort(endpoint: string | null): number | null { + if (!endpoint) { + return null + } + try { + const parsed = new URL(endpoint) + const port = Number(parsed.port) + return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null + } catch { + return null + } +} + +function defaultEnvironment(): WindowsMobileFirewallEnvironment { + return { + platform: process.platform, + // Why: direct helper calls must fail closed; the IPC integration opts in + // only after Electron confirms this is a packaged Windows build. + isPackaged: false, + executablePath: process.execPath, + systemRoot: process.env.SystemRoot + } +} + +function isSupported( + environment: WindowsMobileFirewallEnvironment, + port: number | null +): port is number { + // Why: development Electron paths are transient and must never be persisted + // into an elevated firewall rule that outlives the checkout. + return environment.platform === 'win32' && environment.isPackaged && port !== null +} + +function unavailableStatus(port: number): WindowsMobileFirewallStatus { + return { + supported: true, + port, + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'unknown', + inspectionAvailable: false + } +} + +function parseNetworkCategory(value: string): WindowsNetworkCategory { + if (value === 'Private') { + return 'private' + } + if (value === 'Public') { + return 'public' + } + if (value === 'DomainAuthenticated') { + return 'domain' + } + return 'unknown' +} + +function quotePowerShell(value: string): string { + return `'${value.replaceAll("'", "''")}'` +} + +function buildInspectionScript(port: number, executablePath: string, address?: string): string { + const addressLookup = address + ? ` +try { + $ip = Get-NetIPAddress -IPAddress ${quotePowerShell(address)} -ErrorAction Stop | Select-Object -First 1 + $profile = Get-NetConnectionProfile -InterfaceIndex $ip.InterfaceIndex -ErrorAction Stop | Select-Object -First 1 + if ($profile) { $networkCategory = [string]$profile.NetworkCategory } +} catch {}` + : '' + return `$ErrorActionPreference = 'Stop' +$ruleAllowed = $false +$rules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' }) +foreach ($rule in $rules) { + $portFilter = $rule | Get-NetFirewallPortFilter + $protocol = [string]$portFilter.Protocol + $profile = [string]$rule.Profile + $portMatches = @($portFilter.LocalPort | Where-Object { [string]$_ -eq 'Any' -or [string]$_ -eq '${port}' }).Count -gt 0 + if (($protocol -eq 'Any' -or $protocol -eq 'TCP' -or $protocol -eq '6') -and ($profile -eq 'Any' -or $profile -match 'Private') -and $portMatches) { + $ruleAllowed = $true + } +} +$privateFirewallEnabled = [bool](Get-NetFirewallProfile -Name Private).Enabled +$networkCategory = 'Unknown'${addressLookup} +[pscustomobject]@{ + ruleAllowed = $ruleAllowed + privateFirewallEnabled = $privateFirewallEnabled + networkCategory = $networkCategory +} | ConvertTo-Json -Compress` +} + +function buildRepairScript(port: number, executablePath: string): string { + return `$ErrorActionPreference = 'Stop' +Get-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -ErrorAction SilentlyContinue | Remove-NetFirewallRule +New-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -DisplayName ${quotePowerShell(FIREWALL_RULE_DISPLAY_NAME)} -Description 'Allows Orca Mobile to connect to this Orca desktop on private networks.' -Direction Inbound -Action Allow -Enabled True -Profile Private -Protocol TCP -LocalPort ${port} -Program ${quotePowerShell(executablePath)} -EdgeTraversalPolicy Block | Out-Null` +} + +function buildElevationScript(powershellPath: string, encodedRepairScript: string): string { + return `$ErrorActionPreference = 'Stop' +try { + $process = Start-Process -FilePath ${quotePowerShell(powershellPath)} -ArgumentList @('-NoProfile', '-NonInteractive', '-EncodedCommand', '${encodedRepairScript}') -Verb RunAs -Wait -PassThru + [pscustomobject]@{ launched = $true; exitCode = $process.ExitCode } | ConvertTo-Json -Compress +} catch { + [pscustomobject]@{ launched = $false; nativeErrorCode = $_.Exception.NativeErrorCode } | ConvertTo-Json -Compress +}` +} + +function encodePowerShell(script: string): string { + return Buffer.from(script, 'utf16le').toString('base64') +} + +function getWindowsPowerShellPath(systemRoot = 'C:\\Windows'): string { + return win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') +} + +function getRunner(environment: WindowsMobileFirewallEnvironment): PowerShellRunner { + return environment.runPowerShell ?? createPowerShellRunner(environment.systemRoot) +} + +function createPowerShellRunner(systemRoot?: string): PowerShellRunner { + const powershellPath = getWindowsPowerShellPath(systemRoot) + return (script, timeoutMs) => + new Promise((resolve, reject) => { + execFile( + powershellPath, + ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodePowerShell(script)], + { encoding: 'utf8', timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024 }, + (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + } + ) + }) +} diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 8cc46e8b151..244ae80a48c 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -108,11 +108,12 @@ describe('isRelayAlreadyInstalled', () => { ).rejects.toBe(sessionLimitError) }) - it('checks for relay.js AND .install-complete in addition to the dir', async () => { + it('checks for both relay process artifacts and .install-complete', async () => { mockExec.mockResolvedValueOnce('OK') await isRelayAlreadyInstalled(conn, '/r') const cmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(cmd).toContain('relay.js') + expect(cmd).toContain('relay-watcher.js') expect(cmd).toContain('.install-complete') }) }) diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index 5ebac3a07fc..a669f0b7875 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -126,10 +126,9 @@ export function computeRemoteRelayDir( /** * Probe whether a fully-installed relay already exists at remoteRelayDir. * - * "Fully installed" means: the directory exists, contains relay.js, AND - * contains the .install-complete sentinel written at the end of a successful - * install. A directory missing .install-complete is either mid-install (lock - * held) or a crashed-install partial — either way we re-run the deploy. + * "Fully installed" means: the directory contains relay.js, its isolated + * relay-watcher.js child, and the .install-complete sentinel written at the + * end of a successful install. Missing artifacts force a complete re-deploy. */ export async function isRelayAlreadyInstalled( conn: SshConnection, diff --git a/src/main/ssh/ssh-remote-linear-argument-error.ts b/src/main/ssh/ssh-remote-cli-argument-error.ts similarity index 100% rename from src/main/ssh/ssh-remote-linear-argument-error.ts rename to src/main/ssh/ssh-remote-cli-argument-error.ts diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 01ab8b266a7..339f4aef594 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -59,11 +59,13 @@ export function probeRelayInstalledCommand( remoteRelayDir: string ): string { const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js') + const relayWatcherJs = joinRemotePath(host, remoteRelayDir, 'relay-watcher.js') const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete') if (!isWindowsRemoteHost(host)) { return ( `test -d ${shellEscape(remoteRelayDir)} ` + `&& test -f ${shellEscape(relayJs)} ` + + `&& test -f ${shellEscape(relayWatcherJs)} ` + `&& test -f ${shellEscape(installComplete)} ` + `&& echo OK || echo MISSING` ) @@ -72,8 +74,9 @@ export function probeRelayInstalledCommand( [ `$dir = ${powerShellLiteral(remoteRelayDir)}`, `$relay = ${powerShellLiteral(relayJs)}`, + `$watcher = ${powerShellLiteral(relayWatcherJs)}`, `$complete = ${powerShellLiteral(installComplete)}`, - "if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }" + "if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }" ].join('; ') ) } diff --git a/src/main/ssh/ssh-remote-linear-cli.ts b/src/main/ssh/ssh-remote-linear-cli.ts index 3367c217256..4a5c5c4b416 100644 --- a/src/main/ssh/ssh-remote-linear-cli.ts +++ b/src/main/ssh/ssh-remote-linear-cli.ts @@ -2,14 +2,12 @@ import type { RpcDispatcher } from '../runtime/rpc/dispatcher' import type { RpcResponse } from '../runtime/rpc/core' import { getRemoteLinearReadHelp } from './ssh-remote-linear-read-help' import { tryDispatchRemoteLinearReadCli } from './ssh-remote-linear-read-cli' -import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-linear-argument-error' +import type { ParsedRemoteCli } from './ssh-remote-cli-argument-error' import { getRemoteLinearWriteHelp, tryDispatchRemoteLinearWriteCli } from './ssh-remote-linear-write-cli' -export { RemoteCliArgumentError } - export function getRemoteLinearHelp(parsed: ParsedRemoteCli): string | null { const helpPath = remoteLinearHelpPath(parsed) if (!helpPath) { diff --git a/src/main/ssh/ssh-remote-linear-read-cli.ts b/src/main/ssh/ssh-remote-linear-read-cli.ts index 82a6fedb2ad..7e64081c5c0 100644 --- a/src/main/ssh/ssh-remote-linear-read-cli.ts +++ b/src/main/ssh/ssh-remote-linear-read-cli.ts @@ -6,7 +6,7 @@ import { } from '../../shared/linear-agent-access' import type { RpcDispatcher } from '../runtime/rpc/dispatcher' import type { RpcResponse } from '../runtime/rpc/core' -import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-linear-argument-error' +import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' import { LINEAR_ISSUE_FLAGS, diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index f54c2157c55..1741772d789 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -11,7 +11,8 @@ vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/host/user-data' })) -import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { OrchestrationDb } from '../runtime/orchestration/db' +import { OrcaRuntimeService } from '../runtime/orca-runtime' import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' @@ -91,6 +92,7 @@ describe('runRemoteOrcaCli', () => { liveLeafCount: 1 }), getOrchestrationDb: () => db, + getTerminalPaneKey: () => null, deliverPendingMessagesForHandle: vi.fn(), notifyMessageArrived: vi.fn(), linearIssueContext: vi.fn(async (request: unknown) => ({ @@ -147,6 +149,191 @@ describe('runRemoteOrcaCli', () => { expect(db.getUnreadMessages('term_windows')[0]?.from_handle).toBe('term_ssh') }) + it('forwards remote pane identity through the legacy orchestration fallback', async () => { + const { runtime, db } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { + argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'], + cwd: '/home/alice/repo', + env: { + ORCA_TERMINAL_HANDLE: 'term_ssh', + ORCA_PANE_KEY: 'tab_ssh:leaf_ssh' + } + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(0) + expect(db.insertMessage).toHaveBeenCalledWith( + expect.objectContaining({ senderPaneKey: 'tab_ssh:leaf_ssh' }) + ) + }) + + it('returns a non-zero status for lifecycle rejection through the legacy fallback', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + const task = db.createTask({ spec: 'remote work' }) + const dispatch = db.createDispatchContext(task.id, 'term_ssh', 'tab_owner:leaf_owner') + + try { + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--from', + 'term_ssh', + '--to', + 'term_coord', + '--subject', + 'Done', + '--type', + 'worker_done', + '--payload', + JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + '--json' + ], + cwd: '/home/alice/repo', + env: { ORCA_PANE_KEY: 'tab_foreign:leaf_foreign' } + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + result: { + message: { type: 'worker_done', subject: 'Rejected worker_done: Done' }, + lifecycle: { action: 'rejected', code: 'sender_not_assignee' } + } + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + } finally { + db.close() + } + }) + + it('preserves structured lifecycle payload flags through the legacy fallback', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + const task = db.createTask({ spec: 'remote work' }) + const dispatch = db.createDispatchContext(task.id, 'term_ssh', 'tab_owner:leaf_owner') + + try { + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--to', + 'term_coord', + '--subject', + 'Done', + '--type', + 'worker_done', + '--task-id', + task.id, + '--dispatch-id', + dispatch.id, + '--files-modified', + 'src/a.ts, src/b.ts', + '--json' + ], + cwd: '/home/alice/repo', + env: { + ORCA_TERMINAL_HANDLE: 'term_ssh', + ORCA_PANE_KEY: 'tab_owner:leaf_owner' + } + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(0) + expect(db.getTask(task.id)).toMatchObject({ + status: 'completed', + result: expect.stringContaining('src/a.ts') + }) + } finally { + db.close() + } + }) + + it('rejects identity-less lifecycle sends in the legacy fallback', async () => { + const { runtime, db } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--to', + 'term_coord', + '--subject', + 'Done', + '--type', + 'worker_done', + '--json' + ], + cwd: '/home/alice/repo', + env: {} + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { code: 'no_active_sender_terminal' } + }) + expect(db.insertMessage).not.toHaveBeenCalled() + }) + + it('rejects mixed raw and structured payload flags in the legacy fallback', async () => { + const { runtime, db } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--from', + 'term_ssh', + '--to', + 'term_coord', + '--subject', + 'Done', + '--payload', + '{"taskId":"task_1"}', + '--task-id', + 'task_1', + '--json' + ], + cwd: '/home/alice/repo', + env: {} + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { code: 'invalid_argument', message: expect.stringContaining('structured payload') } + }) + expect(db.insertMessage).not.toHaveBeenCalled() + }) + it('accepts equals-style orchestration flags in the remote shim', async () => { const { runtime, db } = createRuntime() diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index d555b5c2707..205723321da 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -10,19 +10,16 @@ import { type RemoteOrcaCliRequest, type RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' +import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' +import { getRemoteLinearHelp, tryDispatchRemoteLinearCli } from './ssh-remote-linear-cli' import { - RemoteCliArgumentError, - getRemoteLinearHelp, - tryDispatchRemoteLinearCli -} from './ssh-remote-linear-cli' + getRemoteOrchestrationPayload, + hasRemoteLifecycleRejection, + resolveRemoteOrchestrationSender +} from './ssh-remote-orchestration-send' export type { RemoteOrcaCliRequest, RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' -type ParsedRemoteCli = { - commandPath: string[] - flags: Map -} - // Why: these commands run a foreground/interactive process attached to the // caller's TTY (or a local tmux pane), which a buffered one-shot relay bridge // cannot host. Everything else routes through the full host CLI. @@ -116,7 +113,9 @@ async function runLegacyRemoteOrcaCli( return { stdout: formatted.stdout, stderr: formatted.stderr, - exitCode: response.ok ? 0 : 1 + // Why: the legacy SSH bridge bypasses the local CLI handler that turns + // a persisted lifecycle rejection into an unsuccessful command. + exitCode: response.ok && !hasRemoteLifecycleRejection(response.result) ? 0 : 1 } } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -174,17 +173,22 @@ async function dispatchRemoteCli( worktree: optionalString(parsed.flags, 'worktree'), limit: optionalNumber(parsed.flags, 'limit') }) - case 'orchestration send': + case 'orchestration send': { + const type = optionalString(parsed.flags, 'type') return await call(dispatcher, 'orchestration.send', { - from: resolveHandle(parsed.flags, env, 'from'), + from: resolveRemoteOrchestrationSender(parsed.flags, env, type), to: requiredString(parsed.flags, 'to'), subject: requiredString(parsed.flags, 'subject'), body: optionalString(parsed.flags, 'body'), - type: optionalString(parsed.flags, 'type'), + type, priority: optionalString(parsed.flags, 'priority'), threadId: optionalString(parsed.flags, 'thread-id'), - payload: optionalString(parsed.flags, 'payload') + payload: getRemoteOrchestrationPayload(parsed.flags), + // Why: the legacy in-process bridge must preserve the same pane + // authority as the full host CLI passthrough. + senderPaneKey: env.ORCA_PANE_KEY || undefined }) + } case 'orchestration check': return await call(dispatcher, 'orchestration.check', { terminal: resolveHandle(parsed.flags, env, 'terminal'), diff --git a/src/main/ssh/ssh-remote-orchestration-send.test.ts b/src/main/ssh/ssh-remote-orchestration-send.test.ts new file mode 100644 index 00000000000..6efd9a18891 --- /dev/null +++ b/src/main/ssh/ssh-remote-orchestration-send.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { + getRemoteOrchestrationPayload, + hasRemoteLifecycleRejection, + resolveRemoteOrchestrationSender +} from './ssh-remote-orchestration-send' + +describe('remote orchestration send compatibility', () => { + it('recognizes only structured lifecycle rejections', () => { + expect(hasRemoteLifecycleRejection({ lifecycle: { action: 'rejected' } })).toBe(true) + expect(hasRemoteLifecycleRejection({ lifecycle: null })).toBe(false) + expect(hasRemoteLifecycleRejection({ lifecycle: { action: 'completed' } })).toBe(false) + expect(hasRemoteLifecycleRejection(null)).toBe(false) + }) + + it('prefers an explicit sender over the remote terminal environment', () => { + expect( + resolveRemoteOrchestrationSender( + new Map([['from', 'term_explicit']]), + { ORCA_TERMINAL_HANDLE: 'term_env' }, + 'worker_done' + ) + ).toBe('term_explicit') + }) + + it.each(['worker_done', 'heartbeat'])('fails closed for an identity-less %s', (type) => { + expect(() => resolveRemoteOrchestrationSender(new Map(), {}, type)).toThrowError( + expect.objectContaining({ code: 'no_active_sender_terminal' }) + ) + }) + + it('preserves the legacy unknown sender for non-lifecycle messages', () => { + expect(resolveRemoteOrchestrationSender(new Map(), {}, 'status')).toBe('unknown') + }) + + it('serializes every structured payload field with local CLI semantics', () => { + const payload = getRemoteOrchestrationPayload( + new Map([ + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['files-modified', 'src/a.ts, src/b.ts,'], + ['report-path', 'report.md'], + ['phase', 'reviewing'] + ]) + ) + + expect(JSON.parse(payload ?? '{}')).toEqual({ + taskId: 'task_1', + dispatchId: 'ctx_1', + filesModified: ['src/a.ts', 'src/b.ts'], + reportPath: 'report.md', + phase: 'reviewing' + }) + }) + + it('passes through a raw payload when no structured fields are present', () => { + expect(getRemoteOrchestrationPayload(new Map([['payload', '{"ok":true}']]))).toBe('{"ok":true}') + }) + + it('rejects mixed raw and structured payloads', () => { + expect(() => + getRemoteOrchestrationPayload( + new Map([ + ['payload', '{"taskId":"task_1"}'], + ['task-id', 'task_1'] + ]) + ) + ).toThrowError(expect.objectContaining({ code: 'invalid_argument' })) + }) +}) diff --git a/src/main/ssh/ssh-remote-orchestration-send.ts b/src/main/ssh/ssh-remote-orchestration-send.ts new file mode 100644 index 00000000000..5e844a9c586 --- /dev/null +++ b/src/main/ssh/ssh-remote-orchestration-send.ts @@ -0,0 +1,83 @@ +import { RemoteCliArgumentError } from './ssh-remote-cli-argument-error' + +type RemoteFlags = Map + +export function hasRemoteLifecycleRejection(result: unknown): boolean { + if (!result || typeof result !== 'object') { + return false + } + const lifecycle = (result as { lifecycle?: unknown }).lifecycle + return ( + lifecycle !== null && + typeof lifecycle === 'object' && + (lifecycle as { action?: unknown }).action === 'rejected' + ) +} + +export function resolveRemoteOrchestrationSender( + flags: RemoteFlags, + env: Record, + type: string | undefined +): string { + const explicit = optionalString(flags, 'from') + const envHandle = env.ORCA_TERMINAL_HANDLE || undefined + if ((type === 'worker_done' || type === 'heartbeat') && !explicit && !envHandle) { + // Why: the fallback must not turn missing remote identity into the + // synthetic "unknown" handle for lifecycle authority decisions. + throw new RemoteCliArgumentError( + 'no_active_sender_terminal', + 'Could not determine the sender terminal for this orchestration command. ' + + 'Pass --from or run the command inside a live Orca terminal with ORCA_TERMINAL_HANDLE set.' + ) + } + return explicit ?? envHandle ?? 'unknown' +} + +export function getRemoteOrchestrationPayload(flags: RemoteFlags): string | undefined { + const rawPayload = optionalString(flags, 'payload') + const taskId = optionalString(flags, 'task-id') + const dispatchId = optionalString(flags, 'dispatch-id') + const filesModified = optionalString(flags, 'files-modified') + const reportPath = optionalString(flags, 'report-path') + const phase = optionalString(flags, 'phase') + const hasStructuredPayload = [taskId, dispatchId, filesModified, reportPath, phase].some( + (value) => value !== undefined + ) + if (!hasStructuredPayload) { + return rawPayload + } + if (rawPayload !== undefined) { + throw new RemoteCliArgumentError( + 'invalid_argument', + 'Use either --payload or structured payload flags, not both.' + ) + } + + // Why: the fallback receives the same preamble commands as the full CLI; + // preserving these flags keeps lifecycle payloads valid over broken installs. + const payload: Record = {} + if (taskId) { + payload.taskId = taskId + } + if (dispatchId) { + payload.dispatchId = dispatchId + } + if (filesModified) { + payload.filesModified = filesModified + .split(',') + .map((file) => file.trim()) + .filter(Boolean) + } + if (reportPath) { + payload.reportPath = reportPath + } + if (phase) { + payload.phase = phase + } + return JSON.stringify(payload) +} + +function optionalString(flags: RemoteFlags, name: string): string | undefined { + const value = flags.get(name) + return typeof value === 'string' && value.length > 0 ? value : undefined +} diff --git a/src/main/ssh/ssh-remote-platform.test.ts b/src/main/ssh/ssh-remote-platform.test.ts index fc89d7382ce..69c922dc64f 100644 --- a/src/main/ssh/ssh-remote-platform.test.ts +++ b/src/main/ssh/ssh-remote-platform.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getRemoteHostPlatform, joinRemotePath } from './ssh-remote-platform' +import { + assertSafeRemotePathSegment, + getRemoteHostPlatform, + joinRemotePath +} from './ssh-remote-platform' import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' import { execCommand } from './ssh-relay-deploy-helpers' import type { SshConnection } from './ssh-connection' @@ -33,6 +37,47 @@ describe('joinRemotePath', () => { }) }) +describe('assertSafeRemotePathSegment', () => { + it('accepts ordinary names under both path flavors', () => { + expect(() => assertSafeRemotePathSegment('report copy.txt', 'posix')).not.toThrow() + expect(() => assertSafeRemotePathSegment('report copy.txt', 'windows')).not.toThrow() + }) + + it.each(['.', '..', '../secret', 'child/name', 'nul\0byte'])( + 'rejects invalid segment %j under both path flavors', + (segment) => { + expect(() => assertSafeRemotePathSegment(segment, 'posix')).toThrow( + 'Unsafe remote path segment' + ) + expect(() => assertSafeRemotePathSegment(segment, 'windows')).toThrow( + 'Unsafe remote path segment' + ) + } + ) + + it('preserves valid POSIX names that Windows would reinterpret', () => { + expect(() => assertSafeRemotePathSegment('notes\\2026\nfinal.txt', 'posix')).not.toThrow() + }) + + it.each([ + '..\\..\\.ssh\\orca_drop', + 'report.txt:orca', + 'question?.txt', + 'trailing.', + 'trailing ', + 'NUL', + 'con.txt', + 'CONIN$', + 'CLOCK$.log', + 'COM1.log', + 'LPT¹' + ])('rejects Win32-special segment %j', (segment) => { + expect(() => assertSafeRemotePathSegment(segment, 'windows')).toThrow( + 'Unsafe remote path segment' + ) + }) +}) + describe('detectRemoteHostPlatform', () => { it('uses uname when the remote is POSIX', async () => { vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Darwin arm64') diff --git a/src/main/ssh/ssh-remote-platform.ts b/src/main/ssh/ssh-remote-platform.ts index f1e9b56af96..eb5f094cafc 100644 --- a/src/main/ssh/ssh-remote-platform.ts +++ b/src/main/ssh/ssh-remote-platform.ts @@ -84,6 +84,44 @@ export function normalizeWindowsRemotePath(path: string): string { return path.replace(/\\/g, '/') } +export function assertSafeRemotePathSegment(segment: string, pathFlavor: RemotePathFlavor): void { + if ( + !segment || + segment === '.' || + segment === '..' || + segment.includes('/') || + segment.includes('\0') + ) { + throw new Error(`Unsafe remote path segment: ${JSON.stringify(segment)}`) + } + if (pathFlavor === 'posix') { + return + } + + // Why: Win32 canonicalizes names and exposes devices/NTFS streams through + // otherwise ordinary-looking path segments, bypassing no-clobber checks. + const normalized = normalizeWindowsRemotePath(segment) + const deviceStem = normalized.split('.')[0]?.toUpperCase() + if ( + normalized.includes('/') || + hasUnsafeWindowsPathChar(normalized) || + /[ .]$/u.test(normalized) || + /^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|CLOCK\$|COM[1-9¹²³]|LPT[1-9¹²³])$/u.test(deviceStem ?? '') + ) { + throw new Error(`Unsafe remote path segment: ${JSON.stringify(segment)}`) + } +} + +function hasUnsafeWindowsPathChar(value: string): boolean { + for (let i = 0; i < value.length; i += 1) { + const char = value[i] + if (value.charCodeAt(i) <= 31 || (char !== undefined && '<>:"|?*'.includes(char))) { + return true + } + } + return false +} + export function normalizeRemoteHome(rawHome: string, host: RemoteHostPlatform): string { const home = rawHome.trim() return isWindowsRemoteHost(host) ? normalizeWindowsRemotePath(home).replace(/\/+$/, '') : home diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 7939af31aae..d99177285af 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -24,6 +24,31 @@ describe('desktop startup ordering', () => { expect(Math.max(rpcStartIndex, legacyRpcStartIndex)).toBeGreaterThanOrEqual(0) }) + it('shows the desktop window without waiting for WSL registration reconciliation', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const barrierStart = source.indexOf("ipcMain.handle('app:awaitFirstWindowStartupServices'") + const barrierEnd = source.indexOf("ipcMain.handle(\n 'app:startupDiagnostic'", barrierStart) + const barrier = source.slice(barrierStart, barrierEnd) + const reconciliationStart = source.indexOf( + 'managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' + ) + const serveStart = source.indexOf('if (serveOptions) {', reconciliationStart) + const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) + const serveEnd = source.indexOf('return', serveReady) + const desktopWindowStart = source.indexOf('Promise.resolve(openMainWindow())') + const serveStartup = source.slice(serveStart, serveEnd) + const desktopStartup = source.slice(serveEnd, desktopWindowStart) + + expect(reconciliationStart).toBeGreaterThanOrEqual(0) + expect(serveStart).toBeGreaterThan(reconciliationStart) + expect(serveEnd).toBeGreaterThan(serveStart) + expect(desktopWindowStart).toBeGreaterThan(reconciliationStart) + expect(serveStartup).toContain('await managedWslCliReconciliationReady') + expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady') + expect(barrier).toContain('managedWslCliStartupBarrierReady') + expect(barrier).not.toContain('managedWslCliReconciliationReady') + }) + it('does not run the rate-limit quota fetch before the first window can show results', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const attachIndex = source.indexOf('rateLimits.attach(window)') diff --git a/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts b/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts new file mode 100644 index 00000000000..e59201e6a02 --- /dev/null +++ b/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest' +import { + WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS, + createWslCliReconciliationStartupBarrier +} from './wsl-cli-reconciliation-startup-barrier' + +describe('createWslCliReconciliationStartupBarrier', () => { + it('resolves as soon as reconciliation finishes', async () => { + vi.useFakeTimers() + let resolveReconciliation!: () => void + + try { + const reconciliation = new Promise((resolve) => { + resolveReconciliation = resolve + }) + const barrier = createWslCliReconciliationStartupBarrier(reconciliation) + let barrierSettled = false + void barrier.then(() => { + barrierSettled = true + }) + + await vi.advanceTimersByTimeAsync(1) + expect(barrierSettled).toBe(false) + + resolveReconciliation() + await expect(barrier).resolves.toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('fails open when reconciliation exceeds the startup budget', async () => { + vi.useFakeTimers() + let resolveReconciliation!: () => void + + try { + const reconciliation = new Promise((resolve) => { + resolveReconciliation = resolve + }) + const barrier = createWslCliReconciliationStartupBarrier(reconciliation) + let barrierSettled = false + void barrier.then(() => { + barrierSettled = true + }) + + await vi.advanceTimersByTimeAsync(WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS - 1) + expect(barrierSettled).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + await expect(barrier).resolves.toBeUndefined() + resolveReconciliation() + await reconciliation + } finally { + vi.useRealTimers() + } + }) + + it('leaves reconciliation running after the startup budget expires', async () => { + vi.useFakeTimers() + let resolveWork!: () => void + let completed = false + + try { + const work = new Promise((resolve) => { + resolveWork = resolve + }).then(() => { + completed = true + }) + const barrier = createWslCliReconciliationStartupBarrier(work, { timeoutMs: 10 }) + + await vi.advanceTimersByTimeAsync(10) + await expect(barrier).resolves.toBeUndefined() + expect(completed).toBe(false) + + resolveWork() + await work + expect(completed).toBe(true) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts b/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts new file mode 100644 index 00000000000..5f70ad7eeb0 --- /dev/null +++ b/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts @@ -0,0 +1,33 @@ +export const WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS = 2_000 + +type WslCliReconciliationStartupBarrierOptions = { + timeoutMs?: number +} + +/** + * Briefly gates restored terminals while managed WSL registrations reconcile. + */ +export function createWslCliReconciliationStartupBarrier( + reconciliation: Promise, + options: WslCliReconciliationStartupBarrierOptions = {} +): Promise { + const timeoutMs = options.timeoutMs ?? WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS + let timeout: ReturnType | null = null + const settled = reconciliation + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + if (timeout) { + clearTimeout(timeout) + } + }) + + // Why: reconciliation may outlive a slow or unavailable WSL distro; restored + // terminals should wait briefly without turning WSL discovery into an app hang. + return Promise.race([ + settled, + new Promise((resolve) => { + timeout = setTimeout(resolve, timeoutMs) + }) + ]) +} diff --git a/src/main/text-generation/agent-failure-output.test.ts b/src/main/text-generation/agent-failure-output.test.ts new file mode 100644 index 00000000000..b4c24617d9e --- /dev/null +++ b/src/main/text-generation/agent-failure-output.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { + captureAgentGenerationFailureOutput, + formatAgentGenerationFailureOutputForDisplay +} from './agent-failure-output' + +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) + +describe('captureAgentGenerationFailureOutput', () => { + it('returns null when both streams are blank', () => { + expect(captureAgentGenerationFailureOutput('Pi', 1, ' \n', '\t')).toBeNull() + }) + + it('keeps short streams verbatim', () => { + const capture = captureAgentGenerationFailureOutput('Pi', 1, 'out', 'err') + expect(capture).toEqual({ label: 'Pi', exitCode: 1, stdout: 'out', stderr: 'err' }) + }) + + it('bounds an oversized stream to its head and tail with an omission marker', () => { + const oversized = `${'h'.repeat(20_000)}${'m'.repeat(40_000)}${'t'.repeat(50_000)}` + const capture = captureAgentGenerationFailureOutput('Pi', 1, '', oversized) + const stderr = capture?.stderr ?? '' + expect(stderr.startsWith('h'.repeat(16 * 1024))).toBe(true) + expect(stderr.endsWith('t'.repeat(48 * 1024))).toBe(true) + expect(stderr).toContain('characters omitted') + expect(stderr.length).toBeLessThan(oversized.length) + }) +}) + +describe('formatAgentGenerationFailureOutputForDisplay', () => { + it('renders a header plus the streams that had content, stderr first', () => { + const text = formatAgentGenerationFailureOutputForDisplay({ + label: 'Pi', + exitCode: 1, + stdout: 'partial result', + stderr: 'No API key found for github-copilot.' + }) + expect(text).toBe( + 'Pi exited with code 1.\n\n[stderr]\nNo API key found for github-copilot.\n\n[stdout]\npartial result' + ) + }) + + it('omits a blank stream section and reports an unknown exit code', () => { + const text = formatAgentGenerationFailureOutputForDisplay({ + label: 'Claude', + exitCode: null, + stdout: 'Not logged in · Please run /login', + stderr: ' ' + }) + expect(text).toBe( + 'Claude exited with code unknown.\n\n[stdout]\nNot logged in · Please run /login' + ) + }) + + it('strips ANSI, OSC, and bidi-override characters while keeping line structure', () => { + const text = formatAgentGenerationFailureOutputForDisplay({ + label: 'Pi', + exitCode: 1, + stdout: '', + stderr: `${ESC}]0;title${BEL}${ESC}[91mline one${ESC}[0m\r\nsafe ${String.fromCharCode(0x202e)}evil${String.fromCharCode(0x202c)} tail\rline three` + }) + expect(text).toBe('Pi exited with code 1.\n\n[stderr]\nline one\nsafe evil tail\nline three') + }) + + it('keeps later diagnostics when an OSC sequence is unterminated', () => { + const text = formatAgentGenerationFailureOutputForDisplay({ + label: 'Pi', + exitCode: 1, + stdout: '', + stderr: `${ESC}]0;unfinished title\nNo API key found.\nUse /login to log in.` + }) + expect(text).toContain('No API key found.\nUse /login to log in.') + }) +}) diff --git a/src/main/text-generation/agent-failure-output.ts b/src/main/text-generation/agent-failure-output.ts new file mode 100644 index 00000000000..00c60cb1f97 --- /dev/null +++ b/src/main/text-generation/agent-failure-output.ts @@ -0,0 +1,74 @@ +import { stripAnsiControlSequences } from '../../shared/commit-message-agent-output' + +/** Full (bounded) output of a failed agent-CLI generation run, kept for + * on-demand display only — never persisted or synced. */ +export type AgentGenerationFailureOutput = { + label: string + exitCode: number | null + stdout: string + stderr: string +} + +// Why: bounding both ends keeps a runaway 4 MiB capture out of memory and IPC +// while preserving where CLIs put their errors (some front-load them, some +// print them last). +const STREAM_HEAD_LIMIT = 16 * 1024 +const STREAM_TAIL_LIMIT = 48 * 1024 + +export function captureAgentGenerationFailureOutput( + label: string, + exitCode: number | null, + stdout: string, + stderr: string +): AgentGenerationFailureOutput | null { + if (!/\S/.test(stdout) && !/\S/.test(stderr)) { + return null + } + return { + label, + exitCode, + stdout: boundStream(stdout), + stderr: boundStream(stderr) + } +} + +function boundStream(value: string): string { + if (value.length <= STREAM_HEAD_LIMIT + STREAM_TAIL_LIMIT) { + return value + } + const omitted = value.length - STREAM_HEAD_LIMIT - STREAM_TAIL_LIMIT + const bounded = `${value.slice(0, STREAM_HEAD_LIMIT)}\n… (${omitted} characters omitted) …\n${value.slice( + value.length - STREAM_TAIL_LIMIT + )}` + // Why: V8 otherwise retains the multi-megabyte parent through sliced strings + // stored in the capture map; this no-op replacement materializes a flat copy. + return bounded.replace(/$/u, '') +} + +/** Renders the capture as one copyable text block: a header line, then the + * streams that had content. */ +export function formatAgentGenerationFailureOutputForDisplay( + output: AgentGenerationFailureOutput +): string { + const sections = [`${output.label} exited with code ${output.exitCode ?? 'unknown'}.`] + const stderr = sanitizeStreamForDisplay(output.stderr) + if (stderr) { + sections.push(`[stderr]\n${stderr}`) + } + const stdout = sanitizeStreamForDisplay(output.stdout) + if (stdout) { + sections.push(`[stdout]\n${stdout}`) + } + return sections.join('\n\n') +} + +// Why: the block renders in a
 and gets pasted into bug reports; ANSI and
+// non-printing control/format characters (incl. bidi overrides) must not
+// survive, but line structure must.
+function sanitizeStreamForDisplay(value: string): string {
+  return stripAnsiControlSequences(value.replace(/\r\n?/g, '\n'))
+    .replace(/[\p{Cc}\p{Cf}]/gu, (character) =>
+      character === '\n' || character === '\t' ? character : ''
+    )
+    .trim()
+}
diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts
index 8217d193bb9..4464bb8f4ed 100644
--- a/src/main/text-generation/commit-message-text-generation.test.ts
+++ b/src/main/text-generation/commit-message-text-generation.test.ts
@@ -615,7 +615,7 @@ describe('generateCommitMessageFromContext', () => {
     })
   })
 
-  it('does not expose unstructured raw CLI failure output', async () => {
+  it('exposes raw CLI failure output only after path sanitization', async () => {
     const result = await generateCommitMessageFromContext(
       {
         branch: 'main',
@@ -642,7 +642,7 @@ describe('generateCommitMessageFromContext', () => {
 
     expect(result).toEqual({
       success: false,
-      error: 'agent CLI command failed with code 1.'
+      error: 'agent CLI command failed with code 1: raw failure output with [path]'
     })
   })
 
@@ -673,7 +673,7 @@ describe('generateCommitMessageFromContext', () => {
 
     expect(result).toEqual({
       success: false,
-      error: 'agent CLI command failed: fatal: [path] failed'
+      error: 'agent CLI command failed with code 1: ERROR: fatal: [path] failed'
     })
   })
 
@@ -704,11 +704,11 @@ describe('generateCommitMessageFromContext', () => {
 
     expect(result).toEqual({
       success: false,
-      error: 'agent CLI command failed: failed at [path]'
+      error: 'agent CLI command failed with code 1: ERROR: failed at [path]'
     })
   })
 
-  it('treats empty stdout plus stderr on exit 0 as an agent CLI failure', async () => {
+  it('reports an empty result with the stderr excerpt when exit 0 produces no stdout', async () => {
     const result = await generateCommitMessageFromContext(
       {
         branch: 'main',
@@ -735,10 +735,275 @@ describe('generateCommitMessageFromContext', () => {
 
     expect(result).toEqual({
       success: false,
-      error: 'agent CLI command failed: No payment method'
+      error: 'agent returned an empty message. CLI output: Error: No payment method'
     })
   })
 
+  it('surfaces pi auth failure detail end-to-end through the adjusted path sanitizer', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr: [
+            'No API key found for github-copilot.',
+            '',
+            'Use /login to log into a provider via OAuth or API key. See:',
+            '  /private/tmp/pi-exit1-repro/node_modules/@earendil-works/pi-coding-agent/docs/providers.md',
+            '  /private/tmp/pi-exit1-repro/node_modules/@earendil-works/pi-coding-agent/docs/models.md'
+          ].join('\n'),
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error:
+        'Pi CLI command failed with code 1: No API key found for github-copilot. Use /login to log into a provider via OAuth or API key. See: … [path]'
+    })
+  })
+
+  it('preserves slash-commands while redacting multi-segment paths in failure detail', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'custom',
+        model: '',
+        customAgentCommand: 'agent'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr: 'ERROR: run /login then check /Users/name/repo',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error: 'agent CLI command failed with code 1: ERROR: run /login then check [path]'
+    })
+  })
+
+  it('redacts a filesystem path embedded in a pi HTTP 401 payload', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr: '401: {"message":"Invalid key loaded from /Users/name/.config/pi/auth.json"}',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error: 'Pi CLI command failed with code 1: 401: {"message":"Invalid key loaded from [path]"}'
+    })
+  })
+
+  it('redacts a Windows drive path with JSON-escaped backslashes in a payload', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr: '401: {"message":"Invalid key loaded from C:\\\\Users\\\\name\\\\auth.json"}',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error: 'Pi CLI command failed with code 1: 401: {"message":"Invalid key loaded from [path]"}'
+    })
+  })
+
+  it('keeps a scheme:// remedy URL intact while still redacting paths', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr:
+            '401: Visit https://console.anthropic.com/settings/keys then check /Users/name/.config/pi/auth.json',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error:
+        'Pi CLI command failed with code 1: 401: Visit https://console.anthropic.com/settings/keys then check [path]'
+    })
+  })
+
+  it('redacts key=/path shapes in provider bodies', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr: '401: {"message":"rejected credential_path=/Users/name/.config/pi/auth.json"}',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error: 'Pi CLI command failed with code 1: 401: {"message":"rejected credential_path=[path]"}'
+    })
+  })
+
+  it.each([
+    [
+      'comma-prefixed list path',
+      '401: {"message":"candidate list a,/Users/name/creds rejected"}',
+      'Pi CLI command failed with code 1: 401: {"message":"candidate list a,[path] rejected"}'
+    ],
+    [
+      'non-drive colon-prefixed path',
+      '401: {"message":"slot 1:/Users/name/alt failed"}',
+      'Pi CLI command failed with code 1: 401: {"message":"slot 1:[path] failed"}'
+    ]
+  ])('redacts a %s in provider bodies', async (_shape, stderr, expected) => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr,
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({ success: false, error: expected })
+  })
+
+  it('passes the live colonless 400 gateway payload through the sanitizer unmangled', async () => {
+    const result = await generateCommitMessageFromContext(
+      {
+        branch: 'main',
+        stagedSummary: 'M\tREADME.md',
+        stagedPatch: '+hello'
+      },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr:
+            '400 {"type":"error","error":{"type":"invalid_request_error","message":"Third-party apps now draw from your extra usage, not your plan limits. Add more at claude.ai/settings/usage and keep going."},"request_id":"req_011CcsZLJ5ZiLLNvpxcxDuU4"}',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result.success).toBe(false)
+    if (result.success) {
+      throw new Error('expected a failure result')
+    }
+    expect(result.error.startsWith('Pi CLI command failed with code 1: 400 {"type":"error"')).toBe(
+      true
+    )
+    // The bare domain link survives path redaction so the remedy stays usable.
+    expect(result.error).toContain('claude.ai/settings/usage')
+    expect(result.error.length).toBeLessThanOrEqual(300)
+  })
+
   it('preserves the structured subject and body when formatting the final response', async () => {
     const result = await generateCommitMessageFromContext(
       {
@@ -1492,7 +1757,99 @@ describe('generateBranchNameFromContext', () => {
 
     expect(result).toEqual({
       success: false,
-      error: 'Generated branch name was empty after sanitization.'
+      error: 'Generated branch name was empty after sanitization.',
+      failureOutput: { label: 'agent', exitCode: 0, stdout: '!!! ___', stderr: '' }
+    })
+  })
+
+  it('carries the full CLI output on failures for the local on-demand view', async () => {
+    const result = await generateBranchNameFromContext(
+      { firstPrompt: 'Fix login flow' },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: 'partial',
+          stderr: 'No API key found for github-copilot.',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result.success).toBe(false)
+    if (result.success) {
+      throw new Error('expected a failure result')
+    }
+    expect(result.failureOutput).toEqual({
+      label: 'Pi',
+      exitCode: 1,
+      stdout: 'partial',
+      stderr: 'No API key found for github-copilot.'
+    })
+  })
+
+  it('does not persist stdout-only branch failure detail that may echo the prompt', async () => {
+    const result = await generateBranchNameFromContext(
+      { firstPrompt: 'Customer secret in the first prompt' },
+      {
+        agentId: 'custom',
+        model: '',
+        customAgentCommand: 'agent'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: 'Customer secret in the first prompt',
+          stderr: '',
+          exitCode: 1,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toEqual({
+      success: false,
+      error: 'agent CLI command failed with code 1.',
+      failureOutput: {
+        label: 'agent',
+        exitCode: 1,
+        stdout: 'Customer secret in the first prompt',
+        stderr: ''
+      }
+    })
+  })
+
+  it('describes a signal-terminated generator without a null exit code', async () => {
+    const result = await generateBranchNameFromContext(
+      { firstPrompt: 'Fix login flow' },
+      {
+        agentId: 'pi',
+        model: 'github-copilot/gpt-5.5'
+      },
+      {
+        kind: 'remote',
+        cwd: '/repo',
+        missingBinaryLocation: 'remote PATH',
+        execute: async () => ({
+          stdout: '',
+          stderr: 'Process killed by host',
+          exitCode: null,
+          timedOut: false
+        })
+      }
+    )
+
+    expect(result).toMatchObject({
+      success: false,
+      error: 'Pi CLI command was terminated before exiting: Process killed by host'
     })
   })
 
diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts
index 28fce4a9b4e..ff0c9e5fc24 100644
--- a/src/main/text-generation/commit-message-text-generation.ts
+++ b/src/main/text-generation/commit-message-text-generation.ts
@@ -17,8 +17,12 @@ import {
 } from '../../shared/pull-request-generation'
 import {
   cleanGeneratedCommitMessage,
-  extractAgentErrorMessage
+  excerptAgentFailureOutput
 } from '../../shared/commit-message-prompt'
+import {
+  captureAgentGenerationFailureOutput,
+  type AgentGenerationFailureOutput
+} from './agent-failure-output'
 import {
   buildBranchNamePrompt,
   sanitizeBranchSlug,
@@ -108,7 +112,14 @@ type ResolveCommitMessageSettingsResult =
 
 type InternalTextGenerationResult =
   | { success: true; rawOutput: string; agentLabel?: string }
-  | { success: false; error: string; canceled?: boolean }
+  | {
+      success: false
+      error: string
+      canceled?: boolean
+      /** Bounded full CLI output for on-demand local display. Stripped from
+       *  every renderer-bound result so it never crosses IPC wholesale. */
+      failureOutput?: AgentGenerationFailureOutput
+    }
 
 export type CommitMessageModelDiscoveryLocalOptions = {
   cwd?: string
@@ -148,20 +159,29 @@ function formatAgentCliFailureMessage(
   stdout: string,
   stderr: string,
   exitCode: number | null,
-  options?: { includeLocalMacDnsHint?: boolean }
+  options?: { includeLocalMacDnsHint?: boolean; includeStdoutDetail?: boolean }
 ): string {
-  const detail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr))
-  const message = detail
-    ? `${label} CLI command failed: ${detail}`
-    : `${label} CLI command failed with code ${exitCode}.`
+  const detail = sanitizeAgentFailureDetail(
+    excerptAgentFailureOutput(options?.includeStdoutDetail === false ? '' : stdout, stderr)
+  )
+  const message =
+    exitCode === null
+      ? detail
+        ? `${label} CLI command was terminated before exiting: ${detail}`
+        : `${label} CLI command was terminated before exiting.`
+      : detail
+        ? `${label} CLI command failed with code ${exitCode}: ${detail}`
+        : `${label} CLI command failed with code ${exitCode}.`
   return options?.includeLocalMacDnsHint === false
     ? message
     : withMacTailscaleDnsHint(message, detail)
 }
 
 function sanitizeAgentFailureDetail(detail: string | null): string | null {
+  // Cf covers bidi overrides (U+202E etc.) that could visually reorder the
+  // persisted, client-synced detail.
   const trimmed = detail
-    ?.replace(/\p{Cc}+/gu, ' ')
+    ?.replace(/[\p{Cc}\p{Cf}]+/gu, ' ')
     .replace(/\s+/g, ' ')
     .trim()
   if (!trimmed) {
@@ -174,11 +194,20 @@ function sanitizeAgentFailureDetail(detail: string | null): string | null {
       /\\\\[^\s"'`<>\\]+\\(?:[^\s"'`<>\\]+(?:\s+[^\s"'`<>\\]+)*(?=\\)\\)*[^\s"'`<>\\]+/g,
       '[path]'
     )
+    // Only backslashes may repeat: JSON provider bodies double them
+    // (`C:\\Users\\name\\…`), while a URL's `://` must stay single so remedy
+    // links like `https://…` survive redaction.
     .replace(
-      /[A-Za-z]:[\\/](?:[^\s"'`<>\\/|:*?]+(?:\s+[^\s"'`<>\\/|:*?]+)*(?=[\\/])[\\/])*[^\s"'`<>\\/|:*?]+/g,
+      /[A-Za-z]:(?:\\+|\/)(?:[^\s"'`<>\\/|:*?]+(?:\s+[^\s"'`<>\\/|:*?]+)*(?=[\\/])(?:\\+|\/))*[^\s"'`<>\\/|:*?]+/g,
       '[path]'
     )
-    .replace(/(^|[\s"'`(])\/(?:[^\s"'`<>/]+(?:\s+[^\s"'`<>/]+)*(?=\/)\/)*[^\s"'`<>/]+/g, '$1[path]')
+    // Why: require ≥2 segments (one internal `/`) so provider remedy tokens like
+    // `/login` survive while multi-segment paths (`/Users/name/repo`) still redact.
+    // `=:,` prefixes catch key=/path value:/path list,/path shapes in provider bodies.
+    .replace(
+      /(^|[\s"'`(=:,])\/(?:[^\s"'`<>/]+(?:\s+[^\s"'`<>/]+)*(?=\/)\/)+[^\s"'`<>/]+/g,
+      '$1[path]'
+    )
   return redacted.length > 240 ? `${redacted.slice(0, 240).trimEnd()}...` : redacted
 }
 
@@ -661,7 +690,15 @@ async function runLocalPlan(
         })
         return
       }
-      finalizeFromAgentOutput({ code, stdout, stderr, label, emptyResultName, finalize })
+      finalizeFromAgentOutput({
+        code,
+        stdout,
+        stderr,
+        label,
+        emptyResultName,
+        finalize,
+        includeStdoutDetail: operation !== 'branch-name'
+      })
     }
     child.stdout?.on('data', onStdoutData)
     child.stderr?.on('data', onStderrData)
@@ -686,8 +723,18 @@ function finalizeFromAgentOutput(args: {
   emptyResultName: string
   finalize: (result: InternalTextGenerationResult) => void
   includeLocalMacDnsHint?: boolean
+  includeStdoutDetail?: boolean
 }): void {
-  const { code, stdout, stderr, label, emptyResultName, finalize, includeLocalMacDnsHint } = args
+  const {
+    code,
+    stdout,
+    stderr,
+    label,
+    emptyResultName,
+    finalize,
+    includeLocalMacDnsHint,
+    includeStdoutDetail
+  } = args
   if (code !== 0) {
     console.error('[commit-message] Generator failed:', {
       label,
@@ -698,30 +745,34 @@ function finalizeFromAgentOutput(args: {
     finalize({
       success: false,
       error: formatAgentCliFailureMessage(label, stdout, stderr, code, {
-        includeLocalMacDnsHint
-      })
+        includeLocalMacDnsHint,
+        includeStdoutDetail
+      }),
+      failureOutput: captureAgentGenerationFailureOutput(label, code, stdout, stderr) ?? undefined
     })
     return
   }
   const cleaned = cleanGeneratedCommitMessage(stdout)
   if (!cleaned) {
-    const detail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr))
+    // stdout is the (empty) result here, not diagnostics, so only stderr is
+    // excerpted. The run exited 0, so this stays "returned an empty result"
+    // rather than misreporting a command failure.
+    const detail = sanitizeAgentFailureDetail(excerptAgentFailureOutput('', stderr))
     if (detail) {
-      console.error('[commit-message] Generator returned no stdout but reported an error:', {
+      console.error('[commit-message] Generator returned no stdout but wrote to stderr:', {
         label,
         exitCode: code,
         stdout,
         stderr
       })
-      finalize({
-        success: false,
-        error: formatAgentCliFailureMessage(label, stdout, stderr, code, {
-          includeLocalMacDnsHint
-        })
-      })
-      return
     }
-    finalize({ success: false, error: `${label} returned an empty ${emptyResultName}.` })
+    finalize({
+      success: false,
+      error: detail
+        ? `${label} returned an empty ${emptyResultName}. CLI output: ${detail}`
+        : `${label} returned an empty ${emptyResultName}.`,
+      failureOutput: captureAgentGenerationFailureOutput(label, code, stdout, stderr) ?? undefined
+    })
     return
   }
   finalize({
@@ -786,7 +837,9 @@ async function runRemotePlan(
       emptyResultName,
       finalize: resolve,
       // Why: remote agent output reflects the SSH target, not this Mac's DNS.
-      includeLocalMacDnsHint: false
+      includeLocalMacDnsHint: false,
+      // Branch failures persist into synced metadata; stdout may echo the prompt.
+      includeStdoutDetail: operation !== 'branch-name'
     })
   })
 }
@@ -795,7 +848,8 @@ function formatCommitMessageGenerationResult(
   result: InternalTextGenerationResult
 ): GenerateCommitMessageResult {
   if (!result.success) {
-    return result
+    // Keep the bulky local-only capture off the renderer-bound payload.
+    return { success: false, error: result.error, canceled: result.canceled }
   }
   let commitMessage: GeneratedCommitMessage
   try {
@@ -853,8 +907,11 @@ function formatPullRequestFieldsGenerationResult(
   context: PullRequestDraftContext
 ): GeneratePullRequestFieldsResult {
   if (!result.success) {
+    // Keep the bulky local-only capture off the renderer-bound payload.
     return {
-      ...result,
+      success: false,
+      error: result.error,
+      canceled: result.canceled,
       branchChangedByPreparation: context.branchChangedByPreparation
     }
   }
@@ -918,7 +975,12 @@ export async function generatePullRequestFieldsFromContext(
 
 export type GenerateBranchNameResult =
   | { success: true; slug: string; agentLabel?: string }
-  | { success: false; error: string; canceled?: boolean }
+  | {
+      success: false
+      error: string
+      canceled?: boolean
+      failureOutput?: AgentGenerationFailureOutput
+    }
 
 /**
  * Generate a short kebab-case branch name from the work the agent is starting.
@@ -960,7 +1022,14 @@ export async function generateBranchNameFromContext(
   }
   const slug = sanitizeBranchSlug(internalResult.rawOutput)
   if (!slug) {
-    return { success: false, error: 'Generated branch name was empty after sanitization.' }
+    return {
+      success: false,
+      error: 'Generated branch name was empty after sanitization.',
+      // What the model actually returned is the whole diagnosis here.
+      failureOutput:
+        captureAgentGenerationFailureOutput(planned.plan.label, 0, internalResult.rawOutput, '') ??
+        undefined
+    }
   }
   return { success: true, slug, agentLabel: internalResult.agentLabel }
 }
diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts
index fdb5d01a22c..597b1e795f4 100644
--- a/src/main/wsl.test.ts
+++ b/src/main/wsl.test.ts
@@ -1,7 +1,8 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import type * as childProcess from 'node:child_process'
 
-const { execFileSyncMock } = vi.hoisted(() => ({
+const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
+  execFileMock: vi.fn(),
   execFileSyncMock: vi.fn()
 }))
 
@@ -9,11 +10,21 @@ vi.mock('child_process', async (importOriginal) => {
   const actual = await importOriginal()
   return {
     ...actual,
+    execFile: execFileMock,
     execFileSync: execFileSyncMock
   }
 })
 
-import { toLinuxPath, toWindowsWslPath, parseWslPath, wslUncDirectoryExists } from './wsl'
+import {
+  _resetWslCachesForTests,
+  getCachedWslDistros,
+  listWslDistros,
+  listWslDistrosAsync,
+  parseWslPath,
+  toLinuxPath,
+  toWindowsWslPath,
+  wslUncDirectoryExists
+} from './wsl'
 
 function withPlatform(value: NodeJS.Platform, fn: () => T): T {
   const original = process.platform
@@ -25,6 +36,70 @@ function withPlatform(value: NodeJS.Platform, fn: () => T): T {
   }
 }
 
+async function withPlatformAsync(value: NodeJS.Platform, fn: () => Promise): Promise {
+  const original = process.platform
+  Object.defineProperty(process, 'platform', { configurable: true, value })
+  try {
+    return await fn()
+  } finally {
+    Object.defineProperty(process, 'platform', { configurable: true, value: original })
+  }
+}
+
+describe('WSL distro discovery cache', () => {
+  afterEach(() => {
+    execFileMock.mockReset()
+    execFileSyncMock.mockReset()
+    _resetWslCachesForTests()
+  })
+
+  it('retries asynchronous discovery after a transient wsl.exe failure', async () => {
+    vi.useFakeTimers()
+    execFileMock
+      .mockImplementationOnce((_command, _args, _options, callback) => {
+        callback(new Error('transient failure'), '')
+      })
+      .mockImplementationOnce((_command, _args, _options, callback) => {
+        callback(null, 'Ubuntu\n')
+      })
+
+    try {
+      await withPlatformAsync('win32', async () => {
+        await expect(listWslDistrosAsync()).resolves.toEqual([])
+        expect(getCachedWslDistros()).toBeNull()
+        // Brief negative caching bounds the wsl.exe spawn rate between retries.
+        await expect(listWslDistrosAsync()).resolves.toEqual([])
+        expect(execFileMock).toHaveBeenCalledTimes(1)
+        vi.advanceTimersByTime(15_000)
+        await expect(listWslDistrosAsync()).resolves.toEqual(['Ubuntu'])
+      })
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
+  it('retries synchronous discovery after a transient wsl.exe failure', () => {
+    vi.useFakeTimers()
+    execFileSyncMock.mockImplementationOnce(() => {
+      throw new Error('transient failure')
+    })
+    execFileSyncMock.mockReturnValueOnce('Ubuntu\n')
+
+    try {
+      withPlatform('win32', () => {
+        expect(listWslDistros()).toEqual([])
+        expect(getCachedWslDistros()).toBeNull()
+        expect(listWslDistros()).toEqual([])
+        expect(execFileSyncMock).toHaveBeenCalledTimes(1)
+        vi.advanceTimersByTime(15_000)
+        expect(listWslDistros()).toEqual(['Ubuntu'])
+      })
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+})
+
 describe('wsl path helpers', () => {
   it('parses WSL UNC paths on Windows', () => {
     const originalPlatform = process.platform
diff --git a/src/main/wsl.ts b/src/main/wsl.ts
index d668a064f66..1d27b694d2c 100644
--- a/src/main/wsl.ts
+++ b/src/main/wsl.ts
@@ -113,6 +113,12 @@ export function toWindowsWslPath(linuxPath: string, distro: string): string {
 
 const wslHomeCache = new Map()
 let wslDistroCache: string[] | null = null
+// Why: a wsl.exe failure must stay retryable (a transient error would
+// otherwise hide every distro until restart), but repeated failures cannot
+// re-spawn a blocking wsl.exe on every caller; brief negative caching bounds
+// the spawn rate on machines where WSL is absent or persistently broken.
+const WSL_DISTRO_LIST_FAILURE_TTL_MS = 15_000
+let wslDistroListFailedUntilMs = 0
 
 function normalizeWslListOutput(output: string): string[] {
   // Why: wsl.exe can emit UTF-16-looking NUL bytes when inherited through
@@ -138,6 +144,10 @@ export function listWslDistros(): string[] {
     return wslDistroCache
   }
 
+  if (Date.now() < wslDistroListFailedUntilMs) {
+    return []
+  }
+
   try {
     const output = execFileSync('wsl.exe', ['--list', '--quiet'], {
       encoding: 'utf-8',
@@ -147,8 +157,8 @@ export function listWslDistros(): string[] {
     wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro)
     return wslDistroCache
   } catch {
-    wslDistroCache = []
-    return wslDistroCache
+    wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS
+    return []
   }
 }
 
@@ -162,13 +172,17 @@ export async function listWslDistrosAsync(): Promise {
     return wslDistroCache
   }
 
+  if (Date.now() < wslDistroListFailedUntilMs) {
+    return []
+  }
+
   try {
     const output = await execFileUtf8('wsl.exe', ['--list', '--quiet'])
     wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro)
     return wslDistroCache
   } catch {
-    wslDistroCache = []
-    return wslDistroCache
+    wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS
+    return []
   }
 }
 
@@ -279,6 +293,7 @@ export function getCachedWslAvailability(): boolean | null {
 export function _resetWslCachesForTests(): void {
   wslHomeCache.clear()
   wslDistroCache = null
+  wslDistroListFailedUntilMs = 0
   wslAvailableCache = null
 }
 
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 657c2abffbd..0cac555347b 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -202,6 +202,7 @@ import type {
   UpdateStatus,
   Worktree,
   WorktreeBaseStatusEvent,
+  WorktreeHeadIdentity,
   WorktreeLineage,
   WorkspaceLineage,
   WorktreeMeta,
@@ -1182,7 +1183,14 @@ export type PreloadApi = {
       noParent?: boolean
     }) => Promise
     persistSortOrder: (args: { orderedIds: string[] }) => Promise
+    /** Full CLI output of the last branch auto-rename generation failure, held
+     *  in main memory only — null after a restart or once the failure clears. */
+    getBranchRenameFailureOutput: (args: { worktreeId: string }) => Promise
     onChanged: (callback: (data: { repoId: string }) => void) => () => void
+    onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void) => () => void
+    onHeadIdentitiesChanged: (
+      callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void
+    ) => () => void
     onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void) => () => void
     onRemoteBranchConflict: (
       callback: (data: WorktreeRemoteBranchConflictEvent) => void
@@ -2077,6 +2085,7 @@ export type PreloadApi = {
      *  IPC — call sparingly. */
     getSync: () => GlobalSettings | null
     set: (args: Partial) => Promise
+    updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }) => Promise
     listFonts: () => Promise
     previewGhosttyImport: () => Promise
     previewWarpThemeImport: (source: WarpThemeImportSource) => Promise
@@ -3140,6 +3149,21 @@ export type PreloadApi = {
           deviceId: string
         }
     >
+    getWindowsFirewallStatus: (args?: { address?: string }) => Promise<
+      | { supported: false }
+      | {
+          supported: true
+          port: number
+          ruleAllowed: boolean
+          privateFirewallEnabled: boolean
+          networkCategory: 'private' | 'public' | 'domain' | 'unknown'
+          inspectionAvailable: boolean
+        }
+    >
+    repairWindowsFirewall: () => Promise<
+      { ok: true } | { ok: false; reason: 'cancelled' | 'failed' | 'unsupported' }
+    >
+    openWindowsNetworkSettings: () => Promise
     getRuntimePairingUrl: (args?: { address?: string; rotate?: boolean }) => Promise<
       | { available: false }
       | {
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 711a428de8c..24b35e39ef1 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -53,6 +53,7 @@ import type {
   UpdateStatus,
   WorktreeBaseStatusEvent,
   WorktreeDefaultTabsLaunch,
+  WorktreeHeadIdentity,
   WorktreeRemoteBranchConflictEvent
 } from '../shared/types'
 import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker'
@@ -715,6 +716,9 @@ const api = {
 
     persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args),
 
+    getBranchRenameFailureOutput: (args) =>
+      ipcRenderer.invoke('worktrees:getBranchRenameFailureOutput', args),
+
     onChanged: (
       callback: (data: {
         repoId: string
@@ -729,6 +733,24 @@ const api = {
       return () => ipcRenderer.removeListener('worktrees:changed', listener)
     },
 
+    onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void): (() => void) => {
+      const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) =>
+        callback(data)
+      ipcRenderer.on('worktrees:gitStatusMetadataChanged', listener)
+      return () => ipcRenderer.removeListener('worktrees:gitStatusMetadataChanged', listener)
+    },
+
+    onHeadIdentitiesChanged: (
+      callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void
+    ): (() => void) => {
+      const listener = (
+        _event: Electron.IpcRendererEvent,
+        data: { repoId: string; identities: WorktreeHeadIdentity[] }
+      ) => callback(data)
+      ipcRenderer.on('worktrees:headIdentitiesChanged', listener)
+      return () => ipcRenderer.removeListener('worktrees:headIdentitiesChanged', listener)
+    },
+
     onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void): (() => void) => {
       const listener = (_event: Electron.IpcRendererEvent, data: WorktreeBaseStatusEvent) =>
         callback(data)
@@ -1862,6 +1884,9 @@ const api = {
     set: (args: Record): Promise =>
       ipcRenderer.invoke('settings:set', args),
 
+    updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise =>
+      ipcRenderer.invoke('settings:update-pr-bot-author-override', args),
+
     listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts'),
 
     previewGhosttyImport: (): Promise =>
@@ -4257,6 +4282,13 @@ const api = {
         }
     > => ipcRenderer.invoke('mobile:getPairingQR', args),
 
+    getWindowsFirewallStatus: (args?: { address?: string }) =>
+      ipcRenderer.invoke('mobile:getWindowsFirewallStatus', args),
+
+    repairWindowsFirewall: () => ipcRenderer.invoke('mobile:repairWindowsFirewall'),
+
+    openWindowsNetworkSettings: () => ipcRenderer.invoke('mobile:openWindowsNetworkSettings'),
+
     getRuntimePairingUrl: (args?: {
       address?: string
       rotate?: boolean
diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts
index 7d5354cf47a..63e399072e9 100644
--- a/src/relay/fs-handler.test.ts
+++ b/src/relay/fs-handler.test.ts
@@ -7,6 +7,7 @@ import * as fs from 'node:fs/promises'
 import * as path from 'node:path'
 import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from 'node:fs'
 import { tmpdir } from 'node:os'
+import { subscribeWithInProcessWatcher } from '../main/ipc/parcel-watcher-in-process-fallback'
 
 const { mockSubscribe } = vi.hoisted(() => ({
   mockSubscribe: vi.fn()
@@ -121,7 +122,11 @@ describe('FsHandler', () => {
     tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-fs-'))
     dispatcher = createMockDispatcher()
     const ctx = new RelayContext()
-    handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx)
+    handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx, {
+      dispose: vi.fn(),
+      forgetRoot: vi.fn(),
+      subscribe: subscribeWithInProcessWatcher
+    })
   })
 
   afterEach(async () => {
diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts
index 6074fad2e2b..38f0dec9b5d 100644
--- a/src/relay/fs-handler.ts
+++ b/src/relay/fs-handler.ts
@@ -1,5 +1,5 @@
 /* eslint-disable max-lines -- Why: relay filesystem request handling shares
-   path expansion, file IO, search, streaming reads, Space scans, and watch lifecycle state. */
+   path expansion, file IO, search, streaming reads, and Space scans. */
 import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'node:fs/promises'
 import { execFile } from 'node:child_process'
 import { tmpdir } from 'node:os'
@@ -34,17 +34,8 @@ import { RelayStreamRegistry } from './fs-stream-registry'
 import { scanWorkspaceSpaceDirectory } from './workspace-space-scan'
 import { buildRelayCommandEnv } from './relay-command-env'
 import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision'
-import {
-  WATCHER_IGNORE_DIRS,
-  buildParcelWatcherIgnoreOptions
-} from '../main/ipc/filesystem-watcher-ignore'
-
-type WatchState = {
-  rootPath: string
-  unwatchFn: (() => void) | null
-  setupPromise: Promise | null
-  clients: Map boolean>
-}
+import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry'
+import type { RelayWatcherProcessPool } from './relay-watcher-process-pool'
 
 async function isDirectoryEntry(
   dirPath: string,
@@ -85,15 +76,19 @@ function fileStatFromLstat(stats: Awaited>) {
 
 export class FsHandler {
   private dispatcher: RelayDispatcher
-  private watches = new Map()
+  private watchRegistry: RelayFilesystemWatchRegistry
   private streamRegistry = new RelayStreamRegistry()
   private listFilesScans = new ListFilesScanCoordinator()
 
-  constructor(dispatcher: RelayDispatcher, _context: RelayContext) {
+  constructor(
+    dispatcher: RelayDispatcher,
+    _context: RelayContext,
+    watcherPool?: RelayWatcherProcessPool
+  ) {
     this.dispatcher = dispatcher
+    this.watchRegistry = new RelayFilesystemWatchRegistry(dispatcher, watcherPool)
     this.registerHandlers()
-    this.dispatcher.onClientDetached?.((clientId) => {
-      this.releaseClientWatches(clientId)
+    this.dispatcher.onClientDetached?.(() => {
       // Why: a detached client's fs.streamAck frames will never arrive; wake
       // any pump parked on the ack window so it re-checks staleness and exits
       // instead of stranding its open file handle.
@@ -122,8 +117,12 @@ export class FsHandler {
     this.dispatcher.onRequest('fs.search', (p) => this.search(p))
     this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c))
     this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c))
-    this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context))
-    this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context))
+    this.dispatcher.onRequest('fs.watch', (p, context) =>
+      this.watchRegistry.watch(expandTilde(p.rootPath as string), context)
+    )
+    this.dispatcher.onNotification('fs.unwatch', (p, context) =>
+      this.watchRegistry.unwatch(expandTilde(p.rootPath as string), context)
+    )
     this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p))
     this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p))
   }
@@ -414,125 +413,8 @@ export class FsHandler {
     return scanWorkspaceSpaceDirectory(rootPath, context)
   }
 
-  private async watch(params: Record, context?: RequestContext) {
-    const rootPath = expandTilde(params.rootPath as string)
-
-    this.releaseStaleWatches()
-
-    const existing = this.watches.get(rootPath)
-    if (existing) {
-      if ([...existing.clients.values()].some((isStale) => !isStale())) {
-        existing.clients.set(context?.clientId ?? 0, context?.isStale ?? (() => false))
-        if (existing.setupPromise) {
-          await existing.setupPromise
-        }
-        return
-      }
-      existing.unwatchFn?.()
-      this.watches.delete(rootPath)
-    }
-
-    if (this.watches.size >= 20) {
-      throw new Error('Maximum number of file watchers reached')
-    }
-
-    const watchState: WatchState = {
-      rootPath,
-      unwatchFn: null,
-      setupPromise: null,
-      clients: new Map([[context?.clientId ?? 0, context?.isStale ?? (() => false)]])
-    }
-    this.watches.set(rootPath, watchState)
-
-    const setupPromise = (async () => {
-      const watcher = await import('@parcel/watcher')
-      const subscription = await watcher.subscribe(
-        rootPath,
-        (err, events) => {
-          if (err) {
-            this.dispatcher.notify('fs.changed', {
-              events: [{ kind: 'overflow', absolutePath: rootPath }]
-            })
-            return
-          }
-          const mapped = events.map((evt) => ({
-            kind: evt.type,
-            absolutePath: evt.path
-          }))
-          this.dispatcher.notify('fs.changed', { events: mapped })
-        },
-        // Why: align remote watchers with the shared nested exclusion so
-        // generated trees neither exhaust inotify nor trigger slow glob regexes.
-        buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
-      )
-      watchState.unwatchFn = () => {
-        void subscription.unsubscribe()
-      }
-      if (
-        [...watchState.clients.values()].every((isStale) => isStale()) ||
-        this.watches.get(rootPath) !== watchState
-      ) {
-        // Why: if the only requesting client reconnects while watcher setup is
-        // in flight, no client can later balance it with fs.unwatch. Tear down
-        // only this request's subscription so a newer replacement watch for the
-        // same root is not removed.
-        void subscription.unsubscribe()
-        if (this.watches.get(rootPath) === watchState) {
-          this.watches.delete(rootPath)
-        }
-      }
-    })()
-    watchState.setupPromise = setupPromise
-
-    try {
-      await setupPromise
-    } catch {
-      if (this.watches.get(rootPath) === watchState) {
-        this.watches.delete(rootPath)
-      }
-      // @parcel/watcher not available -- polling fallback would go here
-      process.stderr.write('[relay] File watcher not available, fs.changed events disabled\n')
-    }
-  }
-
-  private unwatch(params: Record, context?: RequestContext): void {
-    const rootPath = expandTilde(params.rootPath as string)
-    const state = this.watches.get(rootPath)
-    if (state) {
-      this.releaseWatchClient(rootPath, state, context?.clientId ?? 0)
-    }
-  }
-
-  private releaseClientWatches(clientId: number): void {
-    for (const [rootPath, state] of this.watches) {
-      this.releaseWatchClient(rootPath, state, clientId)
-    }
-  }
-
-  private releaseStaleWatches(): void {
-    for (const [rootPath, state] of this.watches) {
-      if ([...state.clients.values()].some((isStale) => !isStale())) {
-        continue
-      }
-      state.unwatchFn?.()
-      this.watches.delete(rootPath)
-    }
-  }
-
-  private releaseWatchClient(rootPath: string, state: WatchState, clientId: number): void {
-    state.clients.delete(clientId)
-    if (state.clients.size > 0) {
-      return
-    }
-    state.unwatchFn?.()
-    this.watches.delete(rootPath)
-  }
-
   dispose(): void {
-    for (const [, state] of this.watches) {
-      state.unwatchFn?.()
-    }
-    this.watches.clear()
+    this.watchRegistry.dispose()
     void this.streamRegistry.disposeAll()
   }
 }
diff --git a/src/relay/git-handler-staging.test.ts b/src/relay/git-handler-staging.test.ts
index d4469f37cda..b113afd30c4 100644
--- a/src/relay/git-handler-staging.test.ts
+++ b/src/relay/git-handler-staging.test.ts
@@ -1,5 +1,5 @@
 /**
- * Tests for GitHandler commit and bulk-staging operations.
+ * Tests for GitHandler commit and staging operations.
  *
  * Why: split from git-handler.test.ts to stay under the oxlint max-lines (300) limit.
  */
@@ -19,6 +19,32 @@ import {
   type RelayDispatcher
 } from './git-handler-test-setup'
 
+const PATHSPEC_SELECTED_FILE = '[k]eep.log'
+const PATHSPEC_MATCHING_FILE = 'keep.log'
+const PATHSPEC_MUTATION_CASES = [
+  {
+    mode: 'single-file',
+    stageMethod: 'git.stage',
+    unstageMethod: 'git.unstage',
+    selection: { filePath: PATHSPEC_SELECTED_FILE }
+  },
+  {
+    mode: 'bulk',
+    stageMethod: 'git.bulkStage',
+    unstageMethod: 'git.bulkUnstage',
+    selection: { filePaths: [PATHSPEC_SELECTED_FILE] }
+  }
+] as const
+
+function createPathspecCollisionChanges(dir: string): void {
+  gitInit(dir)
+  writeFileSync(path.join(dir, PATHSPEC_SELECTED_FILE), 'selected')
+  writeFileSync(path.join(dir, PATHSPEC_MATCHING_FILE), 'matching')
+  gitCommit(dir, 'initial')
+  writeFileSync(path.join(dir, PATHSPEC_SELECTED_FILE), 'selected modified')
+  writeFileSync(path.join(dir, PATHSPEC_MATCHING_FILE), 'matching modified')
+}
+
 describe('GitHandler — commit & staging', () => {
   let dispatcher: MockDispatcher
   let tmpDir: string
@@ -77,7 +103,38 @@ describe('GitHandler — commit & staging', () => {
     })
   })
 
-  describe('bulkStage and bulkUnstage', () => {
+  describe('stage and unstage', () => {
+    it.each(PATHSPEC_MUTATION_CASES)(
+      'treats $mode stage paths with Git glob characters as literals',
+      async ({ stageMethod, selection }) => {
+        createPathspecCollisionChanges(tmpDir)
+
+        await dispatcher.callRequest(stageMethod, { worktreePath: tmpDir, ...selection })
+
+        const output = execFileSync('git', ['diff', '--cached', '--name-only'], {
+          cwd: tmpDir,
+          encoding: 'utf-8'
+        })
+        expect(output.trim()).toBe(PATHSPEC_SELECTED_FILE)
+      }
+    )
+
+    it.each(PATHSPEC_MUTATION_CASES)(
+      'treats $mode unstage paths with Git glob characters as literals',
+      async ({ unstageMethod, selection }) => {
+        createPathspecCollisionChanges(tmpDir)
+        execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' })
+
+        await dispatcher.callRequest(unstageMethod, { worktreePath: tmpDir, ...selection })
+
+        const output = execFileSync('git', ['diff', '--cached', '--name-only'], {
+          cwd: tmpDir,
+          encoding: 'utf-8'
+        })
+        expect(output.trim()).toBe(PATHSPEC_MATCHING_FILE)
+      }
+    )
+
     it('stages multiple files', async () => {
       gitInit(tmpDir)
       writeFileSync(path.join(tmpDir, 'a.txt'), 'a')
diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts
index 1ae68d3ce88..d674af9949e 100644
--- a/src/relay/git-handler.test.ts
+++ b/src/relay/git-handler.test.ts
@@ -1192,7 +1192,7 @@ describe('GitHandler', () => {
       await Promise.all([first, second])
 
       expect(gitBufferSpy).toHaveBeenCalledTimes(2)
-      expect(gitSpy).toHaveBeenCalledWith(['add', '--', 'src/file.ts'], tmpDir)
+      expect(gitSpy).toHaveBeenCalledWith(['add', '--', ':(literal)src/file.ts'], tmpDir)
       const submodulePathReads = gitSpy.mock.calls.filter(
         ([args]) => args[0] === 'config' && args.includes('.gitmodules')
       )
diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts
index d21dcf3cce9..c6709cd3f2a 100644
--- a/src/relay/git-handler.ts
+++ b/src/relay/git-handler.ts
@@ -488,7 +488,7 @@ export class GitHandler {
     const worktreePath = params.worktreePath as string
     const filePath = params.filePath as string
     try {
-      await this.git(['add', '--', filePath], worktreePath)
+      await this.git(['add', '--', this.literalPathspec(filePath)], worktreePath)
     } finally {
       this.clearGitMutationReadCaches()
     }
@@ -512,7 +512,7 @@ export class GitHandler {
     const worktreePath = params.worktreePath as string
     const filePath = params.filePath as string
     try {
-      await this.git(['restore', '--staged', '--', filePath], worktreePath)
+      await this.git(['restore', '--staged', '--', this.literalPathspec(filePath)], worktreePath)
     } finally {
       this.clearGitMutationReadCaches()
     }
@@ -525,7 +525,10 @@ export class GitHandler {
     try {
       for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) {
         const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE)
-        await this.git(['add', '--', ...chunk], worktreePath)
+        await this.git(
+          ['add', '--', ...chunk.map((filePath) => this.literalPathspec(filePath))],
+          worktreePath
+        )
       }
     } finally {
       this.clearGitMutationReadCaches()
@@ -539,7 +542,10 @@ export class GitHandler {
     try {
       for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) {
         const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE)
-        await this.git(['restore', '--staged', '--', ...chunk], worktreePath)
+        await this.git(
+          ['restore', '--staged', '--', ...chunk.map((filePath) => this.literalPathspec(filePath))],
+          worktreePath
+        )
       }
     } finally {
       this.clearGitMutationReadCaches()
diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts
new file mode 100644
index 00000000000..5f1e46eecdd
--- /dev/null
+++ b/src/relay/relay-filesystem-watch-registry.test.ts
@@ -0,0 +1,170 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { WatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure'
+import type {
+  WatcherProcessCallback,
+  WatcherProcessHooks,
+  WatcherProcessSubscription
+} from '../main/ipc/parcel-watcher-process-subscription'
+import type { RelayDispatcher, RequestContext } from './dispatcher'
+import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry'
+import { createRelayWatcherProcessPool } from './relay-watcher-process-pool'
+
+type InstalledWatch = {
+  callback: WatcherProcessCallback
+  hooks: WatcherProcessHooks
+  unsubscribe: ReturnType Promise>>
+}
+
+class FakeWatcherPool {
+  readonly installed: InstalledWatch[] = []
+  readonly dispose = vi.fn()
+  readonly forgetRoot = vi.fn()
+
+  async subscribe(
+    _rootPath: string,
+    callback: WatcherProcessCallback,
+    _options: object,
+    hooks: WatcherProcessHooks
+  ): Promise {
+    const unsubscribe = vi.fn(async () => undefined)
+    this.installed.push({ callback, hooks, unsubscribe })
+    return { unsubscribe }
+  }
+}
+
+function createDispatcher() {
+  const detached = new Set<(clientId: number) => void>()
+  return {
+    notify: vi.fn(),
+    onClientDetached: vi.fn((listener: (clientId: number) => void) => {
+      detached.add(listener)
+      return () => detached.delete(listener)
+    })
+  }
+}
+
+function context(clientId: number): RequestContext {
+  return { clientId, isStale: () => false }
+}
+
+describe('RelayFilesystemWatchRegistry', () => {
+  let dispatcher: ReturnType
+  let pool: FakeWatcherPool
+  let registry: RelayFilesystemWatchRegistry
+
+  beforeEach(() => {
+    dispatcher = createDispatcher()
+    pool = new FakeWatcherPool()
+    registry = new RelayFilesystemWatchRegistry(dispatcher as unknown as RelayDispatcher, pool)
+  })
+
+  it('emits overflow around child replacement and resumes ordered event delivery', async () => {
+    await registry.watch('/repo', context(1))
+    const first = pool.installed[0]
+
+    first.callback(null, [{ type: 'create', path: '/repo/before.txt' }])
+    first.hooks.onInterruption?.()
+    first.callback(null, [{ type: 'update', path: '/repo/after-resubscribe.txt' }])
+
+    expect(dispatcher.notify.mock.calls).toEqual([
+      ['fs.changed', { events: [{ kind: 'create', absolutePath: '/repo/before.txt' }] }],
+      ['fs.changed', { events: [{ kind: 'overflow', absolutePath: '/repo' }] }],
+      ['fs.changed', { events: [{ kind: 'update', absolutePath: '/repo/after-resubscribe.txt' }] }]
+    ])
+  })
+
+  it('moves a terminal shard failure into recovery without dropping shared clients', async () => {
+    await registry.watch('/repo', context(1))
+    await registry.watch('/repo', context(2))
+    const first = pool.installed[0]
+    first.hooks.onTerminalError?.(
+      new WatcherProcessFailure(
+        'file watcher process crashed repeatedly',
+        'supervisor',
+        'supervisor_crash_fuse'
+      )
+    )
+    await Promise.resolve()
+
+    expect(pool.installed).toHaveLength(2)
+    pool.installed[1].callback(null, [{ type: 'create', path: '/repo/recovered.txt' }])
+    expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'fs.changed', {
+      events: [{ kind: 'overflow', absolutePath: '/repo' }]
+    })
+    expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'fs.changed', {
+      events: [{ kind: 'create', absolutePath: '/repo/recovered.txt' }]
+    })
+
+    registry.unwatch('/repo', context(1))
+    expect(pool.installed[1].unsubscribe).not.toHaveBeenCalled()
+    registry.unwatch('/repo', context(2))
+    expect(pool.installed[1].unsubscribe).toHaveBeenCalledTimes(1)
+  })
+
+  it('aborts a pending crawl only after the last same-root client leaves', async () => {
+    let sharedSignal: AbortSignal | undefined
+    let rejectSubscribe: ((error: Error) => void) | undefined
+    vi.spyOn(pool, 'subscribe').mockImplementation(
+      (_rootPath, _callback, _options, hooks): Promise =>
+        new Promise((_resolve, reject) => {
+          sharedSignal = hooks.signal
+          rejectSubscribe = reject
+          hooks.signal?.addEventListener(
+            'abort',
+            () =>
+              reject(
+                new WatcherProcessFailure(
+                  'file watcher subscription aborted',
+                  'subscription',
+                  'subscribe_aborted'
+                )
+              ),
+            { once: true }
+          )
+        })
+    )
+    const firstAbort = new AbortController()
+    const secondAbort = new AbortController()
+    const first = registry.watch('/repo', {
+      ...context(1),
+      signal: firstAbort.signal
+    })
+    const second = registry.watch('/repo', {
+      ...context(2),
+      signal: secondAbort.signal
+    })
+
+    firstAbort.abort()
+    await first
+    expect(sharedSignal?.aborted).toBe(false)
+
+    secondAbort.abort()
+    await second
+    expect(sharedSignal?.aborted).toBe(true)
+    expect(rejectSubscribe).toBeDefined()
+  })
+})
+
+describe('createRelayWatcherProcessPool', () => {
+  it('fails closed instead of loading the native watcher in the relay process', async () => {
+    const previousVitest = process.env.VITEST
+    process.env.VITEST = 'true'
+    const pool = createRelayWatcherProcessPool(
+      join(tmpdir(), `missing-relay-watcher-${process.pid}.js`)
+    )
+    try {
+      await expect(pool.subscribe('/repo', vi.fn(), {}, {})).rejects.toMatchObject({
+        code: 'entry_missing'
+      })
+    } finally {
+      pool.dispose()
+      if (previousVitest === undefined) {
+        delete process.env.VITEST
+      } else {
+        process.env.VITEST = previousVitest
+      }
+    }
+  })
+})
diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts
new file mode 100644
index 00000000000..a1ccda05826
--- /dev/null
+++ b/src/relay/relay-filesystem-watch-registry.ts
@@ -0,0 +1,312 @@
+import type { RelayDispatcher, RequestContext } from './dispatcher'
+import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch'
+import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure'
+import type {
+  WatcherProcessEvent,
+  WatcherProcessSubscription
+} from '../main/ipc/parcel-watcher-process'
+import {
+  WATCHER_IGNORE_DIRS,
+  buildParcelWatcherIgnoreOptions
+} from '../main/ipc/filesystem-watcher-ignore'
+import {
+  createRelayWatcherProcessPool,
+  type RelayWatcherProcessPool
+} from './relay-watcher-process-pool'
+
+const MAX_RELAY_WATCH_ROOTS = 20
+const RELAY_WATCH_CRAWL_TIMEOUT_MS = 60_000
+const RELAY_WATCH_OPTIONS = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
+
+type RelayWatchState = {
+  rootPath: string
+  clients: Map boolean>
+  setupPromise: Promise
+  subscription: WatcherProcessSubscription | null
+  abortController: AbortController
+  generation: number
+  closed: boolean
+}
+
+function overflowEvent(rootPath: string): Record {
+  return { events: [{ kind: 'overflow', absolutePath: rootPath }] }
+}
+
+function createWatchAbortError(): Error {
+  const error = new Error('Request "fs.watch" was cancelled')
+  error.name = 'AbortError'
+  return error
+}
+
+function shouldRetryInitialWatch(error: unknown): boolean {
+  return (
+    isWatcherProcessFailure(error) &&
+    error.code !== 'entry_missing' &&
+    error.code !== 'subscribe_aborted' &&
+    error.code !== 'supervisor_disposed' &&
+    (error.scope === 'supervisor' || error.code === 'subscribe_timeout')
+  )
+}
+
+export class RelayFilesystemWatchRegistry {
+  private readonly watches = new Map()
+
+  constructor(
+    private readonly dispatcher: RelayDispatcher,
+    private readonly watcherPool: RelayWatcherProcessPool = createRelayWatcherProcessPool()
+  ) {
+    this.dispatcher.onClientDetached?.((clientId) => this.releaseClientWatches(clientId))
+  }
+
+  async watch(rootPath: string, context?: RequestContext): Promise {
+    this.releaseStaleWatches()
+    const clientId = context?.clientId ?? 0
+    const isStale = context?.isStale ?? (() => false)
+    const existing = this.watches.get(rootPath)
+    if (existing) {
+      existing.clients.set(clientId, isStale)
+      await this.awaitSetupForClient(existing, clientId, context)
+      return
+    }
+
+    if (this.watches.size >= MAX_RELAY_WATCH_ROOTS) {
+      throw new Error('Maximum number of file watchers reached')
+    }
+
+    const state: RelayWatchState = {
+      rootPath,
+      clients: new Map([[clientId, isStale]]),
+      setupPromise: Promise.resolve(),
+      subscription: null,
+      abortController: new AbortController(),
+      generation: 0,
+      closed: false
+    }
+    this.watches.set(rootPath, state)
+    state.setupPromise = this.startInitialWatch(state)
+    await this.awaitSetupForClient(state, clientId, context)
+  }
+
+  unwatch(rootPath: string, context?: RequestContext): void {
+    const state = this.watches.get(rootPath)
+    if (state) {
+      this.releaseWatchClient(state, context?.clientId ?? 0)
+    }
+  }
+
+  dispose(): void {
+    for (const state of Array.from(this.watches.values())) {
+      this.closeWatch(state)
+    }
+    this.watcherPool.dispose()
+  }
+
+  private async startInitialWatch(state: RelayWatchState): Promise {
+    try {
+      await this.subscribeState(state)
+    } catch (firstError) {
+      if (!state.closed && shouldRetryInitialWatch(firstError)) {
+        try {
+          await this.subscribeState(state)
+          this.emitOverflow(state)
+          return
+        } catch (quarantineError) {
+          this.closeWatch(state)
+          throw quarantineError
+        }
+      }
+      this.closeWatch(state)
+      throw firstError
+    }
+  }
+
+  private subscribeState(state: RelayWatchState): Promise {
+    const generation = ++state.generation
+    const emitOverflow = (): void => {
+      if (state.generation === generation) {
+        this.emitOverflow(state)
+      }
+    }
+    return this.watcherPool
+      .subscribe(
+        state.rootPath,
+        (error, events) => {
+          if (state.closed || state.generation !== generation) {
+            return
+          }
+          if (error) {
+            process.stderr.write(
+              `[relay] File watcher error for ${state.rootPath}: ${error.message}\n`
+            )
+            emitOverflow()
+            return
+          }
+          this.emitEvents(state, events)
+        },
+        RELAY_WATCH_OPTIONS,
+        {
+          delivery: { maxEventsPerBatch: MAX_BATCHED_WATCHER_EVENTS },
+          onInterruption: emitOverflow,
+          onOverflow: emitOverflow,
+          onTerminalError: (error) => this.recoverWatch(state, generation, error),
+          signal: state.abortController.signal,
+          subscribeTimeoutMs: RELAY_WATCH_CRAWL_TIMEOUT_MS
+        }
+      )
+      .then(async (subscription) => {
+        if (
+          state.closed ||
+          state.generation !== generation ||
+          this.watches.get(state.rootPath) !== state
+        ) {
+          await subscription.unsubscribe()
+          return
+        }
+        state.subscription = subscription
+      })
+  }
+
+  private recoverWatch(state: RelayWatchState, failedGeneration: number, error: Error): void {
+    if (state.closed || state.generation !== failedGeneration) {
+      return
+    }
+    state.subscription = null
+    this.emitOverflow(state)
+    const recovery = this.subscribeState(state)
+    state.setupPromise = recovery
+    void recovery.catch((recoveryError: unknown) => {
+      if (!state.closed) {
+        const message = recoveryError instanceof Error ? recoveryError.message : error.message
+        process.stderr.write(
+          `[relay] File watcher disabled after bounded recovery for ${state.rootPath}: ${message}\n`
+        )
+        this.closeWatch(state)
+      }
+    })
+  }
+
+  private emitEvents(state: RelayWatchState, events: readonly WatcherProcessEvent[]): void {
+    if (state.closed || events.length === 0) {
+      return
+    }
+    this.dispatcher.notify('fs.changed', {
+      events: events.map((event) => ({
+        kind: event.type,
+        absolutePath: event.path,
+        ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory })
+      }))
+    })
+  }
+
+  private emitOverflow(state: RelayWatchState): void {
+    if (!state.closed) {
+      this.dispatcher.notify('fs.changed', overflowEvent(state.rootPath))
+    }
+  }
+
+  private async awaitSetupForClient(
+    state: RelayWatchState,
+    clientId: number,
+    context?: RequestContext
+  ): Promise {
+    try {
+      await this.awaitSetupWithAbort(state.setupPromise, context?.signal)
+    } catch (error) {
+      this.releaseWatchClient(state, clientId)
+      const expectedAbort =
+        (error instanceof Error && error.name === 'AbortError') ||
+        (isWatcherProcessFailure(error) && error.code === 'subscribe_aborted')
+      if (!expectedAbort && error instanceof Error) {
+        process.stderr.write(
+          `[relay] File watcher not available for ${state.rootPath}: ${error.message}\n`
+        )
+        throw error
+      }
+      return
+    }
+    if (context?.isStale()) {
+      this.releaseWatchClient(state, clientId)
+    }
+  }
+
+  private awaitSetupWithAbort(setupPromise: Promise, signal?: AbortSignal): Promise {
+    if (!signal) {
+      return setupPromise
+    }
+    if (signal.aborted) {
+      return Promise.reject(createWatchAbortError())
+    }
+    return new Promise((resolve, reject) => {
+      const onAbort = (): void => {
+        cleanup()
+        reject(createWatchAbortError())
+      }
+      const cleanup = (): void => signal.removeEventListener('abort', onAbort)
+      signal.addEventListener('abort', onAbort, { once: true })
+      setupPromise.then(
+        () => {
+          cleanup()
+          resolve()
+        },
+        (error) => {
+          cleanup()
+          reject(error)
+        }
+      )
+    })
+  }
+
+  private releaseClientWatches(clientId: number): void {
+    for (const state of Array.from(this.watches.values())) {
+      this.releaseWatchClient(state, clientId)
+    }
+  }
+
+  private releaseStaleWatches(): void {
+    for (const state of Array.from(this.watches.values())) {
+      for (const [clientId, isStale] of state.clients) {
+        if (isStale()) {
+          state.clients.delete(clientId)
+        }
+      }
+      if (state.clients.size === 0) {
+        this.closeWatch(state)
+      }
+    }
+  }
+
+  private releaseWatchClient(state: RelayWatchState, clientId: number): void {
+    state.clients.delete(clientId)
+    if (!state.closed && state.clients.size === 0) {
+      this.closeWatch(state)
+    }
+  }
+
+  private closeWatch(state: RelayWatchState): void {
+    if (state.closed) {
+      return
+    }
+    state.closed = true
+    state.generation++
+    state.abortController.abort()
+    state.clients.clear()
+    if (this.watches.get(state.rootPath) === state) {
+      this.watches.delete(state.rootPath)
+    }
+    const subscription = state.subscription
+    state.subscription = null
+    if (subscription) {
+      // Why: a child can die during unwatch; release quarantine history even
+      // when physical teardown reports that already-contained failure.
+      void subscription.unsubscribe().then(
+        () => this.watcherPool.forgetRoot(state.rootPath),
+        () => this.watcherPool.forgetRoot(state.rootPath)
+      )
+      return
+    }
+    void state.setupPromise.then(
+      () => this.watcherPool.forgetRoot(state.rootPath),
+      () => this.watcherPool.forgetRoot(state.rootPath)
+    )
+  }
+}
diff --git a/src/relay/relay-watcher-process-pool.ts b/src/relay/relay-watcher-process-pool.ts
new file mode 100644
index 00000000000..af6cb8285e2
--- /dev/null
+++ b/src/relay/relay-watcher-process-pool.ts
@@ -0,0 +1,26 @@
+import { join } from 'node:path'
+import { RuntimeWatcherProcessPool } from '../main/ipc/runtime-watcher-process-pool'
+import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-supervisor'
+
+export type RelayWatcherProcessPool = Pick<
+  RuntimeWatcherProcessPool,
+  'dispose' | 'forgetRoot' | 'subscribe'
+>
+
+export function getRelayWatcherProcessEntryPath(): string {
+  return join(__dirname, 'relay-watcher.js')
+}
+
+export function createRelayWatcherProcessPool(
+  entryPath = getRelayWatcherProcessEntryPath()
+): RelayWatcherProcessPool {
+  return new RuntimeWatcherProcessPool({
+    createSupervisor: () =>
+      new WatcherProcessSupervisor({
+        entryPath,
+        // Why: a leaked VITEST environment must never move the native addon
+        // back into the relay when its crash-isolation child is missing.
+        useInProcessVitestFallback: false
+      })
+  })
+}
diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts
index 4d19542b91c..219addc02a9 100644
--- a/src/relay/subprocess.test.ts
+++ b/src/relay/subprocess.test.ts
@@ -10,6 +10,7 @@ import { getEndpointFileName } from '../shared/agent-hook-listener'
 import { relayTestSocketPath } from './relay-test-socket-path'
 
 const RELAY_TS_ENTRY = path.resolve(__dirname, 'relay.ts')
+const WATCHER_TS_ENTRY = path.resolve(__dirname, '../main/ipc/parcel-watcher-process-entry.ts')
 let bundleDir: string
 let relayEntry: string
 const spawnedSocketDirs: string[] = []
@@ -24,7 +25,17 @@ beforeAll(async () => {
     target: 'node18',
     format: 'cjs',
     outfile: relayEntry,
-    external: ['node-pty', '@parcel/watcher'],
+    external: ['node-pty', '@parcel/watcher', 'electron'],
+    sourcemap: false
+  })
+  await build({
+    entryPoints: [WATCHER_TS_ENTRY],
+    bundle: true,
+    platform: 'node',
+    target: 'node18',
+    format: 'cjs',
+    outfile: path.join(bundleDir, 'relay-watcher.js'),
+    external: ['@parcel/watcher'],
     sourcemap: false
   })
 }, 30_000)
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 25a5a4e751b..1d4cbac0572 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -970,7 +970,8 @@
 .editor-header {
   display: flex;
   align-items: center;
-  gap: 12px;
+  /* Keep adjacent actions aligned with the app's compact toolbar spacing. */
+  gap: 4px;
   min-height: 36px;
   padding: 6px 14px;
   background: var(--editor-surface);
diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx
index 1cf6f7beafd..d076f0b325d 100644
--- a/src/renderer/src/components/GitHubItemDialog.tsx
+++ b/src/renderer/src/components/GitHubItemDialog.tsx
@@ -127,6 +127,7 @@ import {
   getPrCommentAudienceFilters,
   type PRCommentAudienceFilter
 } from '@/lib/pr-comment-audience'
+import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides'
 import {
   getPRCommentGroupCount,
   getPRCommentGroupId,
@@ -3124,10 +3125,14 @@ function ConversationTab({
   const [bodyEditing, setBodyEditing] = useState(false)
   const [bodySaving, setBodySaving] = useState(false)
   const canUseRepoMutationContext = canUseGitHubRepoContext(repoPath, sourceContext)
-  const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments])
+  const botAuthorOverrides = usePRBotAuthorOverrides()
+  const commentCounts = useMemo(
+    () => getPRCommentAudienceCounts(comments, botAuthorOverrides),
+    [botAuthorOverrides, comments]
+  )
   const visibleComments = useMemo(
-    () => filterPRCommentsByAudience(comments, commentFilter),
-    [commentFilter, comments]
+    () => filterPRCommentsByAudience(comments, commentFilter, botAuthorOverrides),
+    [botAuthorOverrides, commentFilter, comments]
   )
   const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments])
   const resolvedTimelineItems = timelineItems ?? EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS
diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx
index cbe4d886124..d807dacf89f 100644
--- a/src/renderer/src/components/PullRequestPage.tsx
+++ b/src/renderer/src/components/PullRequestPage.tsx
@@ -123,6 +123,7 @@ import {
   getPrCommentAudienceFilters,
   type PRCommentAudienceFilter
 } from '@/lib/pr-comment-audience'
+import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides'
 import {
   getPRCommentGroupCount,
   getPRCommentGroupId,
@@ -3247,10 +3248,14 @@ function ConversationTab({
     [repoOwnerSettings, sourceContext]
   )
   const repoAssignees = useRepoAssignees(repoPath, item.repoId, sourceSettings)
-  const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments])
+  const botAuthorOverrides = usePRBotAuthorOverrides()
+  const commentCounts = useMemo(
+    () => getPRCommentAudienceCounts(comments, botAuthorOverrides),
+    [botAuthorOverrides, comments]
+  )
   const visibleComments = useMemo(
-    () => filterPRCommentsByAudience(comments, commentFilter),
-    [commentFilter, comments]
+    () => filterPRCommentsByAudience(comments, commentFilter, botAuthorOverrides),
+    [botAuthorOverrides, commentFilter, comments]
   )
   const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments])
   const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, visibleComments)
diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx
index c42be0d7bac..3b538d44335 100644
--- a/src/renderer/src/components/TaskPage.tsx
+++ b/src/renderer/src/components/TaskPage.tsx
@@ -9736,13 +9736,21 @@ export default function TaskPage(): React.JSX.Element {
                                 
                               
                             ) : (
-                              
+                              
                             )}
                             {item.type !== 'pr' ? (
                               
diff --git a/src/renderer/src/components/agent/AgentCombobox.test.tsx b/src/renderer/src/components/agent/AgentCombobox.test.tsx
index 6547c3704bd..d6fe7590f49 100644
--- a/src/renderer/src/components/agent/AgentCombobox.test.tsx
+++ b/src/renderer/src/components/agent/AgentCombobox.test.tsx
@@ -1,6 +1,8 @@
 import { renderToStaticMarkup } from 'react-dom/server'
 import { describe, expect, it, vi } from 'vitest'
+import type { TuiAgent } from '../../../../shared/types'
 import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
+import { AGENT_FAVICON_ASSETS } from '@/lib/agent-favicon-assets'
 import AgentCombobox from './AgentCombobox'
 
 describe('AgentCombobox', () => {
@@ -37,4 +39,14 @@ describe('AgentCombobox', () => {
     expect(markup).not.toContain('https://www.google.com/s2/favicons')
     expect(markup).not.toContain(' {
+    // Why: previously loaded from Google's favicon service (#8451). Iterate the
+    // full asset map so missing files/key mismatches fail the test.
+    for (const agent of Object.keys(AGENT_FAVICON_ASSETS) as TuiAgent[]) {
+      const markup = renderToStaticMarkup()
+      expect(markup).toContain(`/shared/agent-icons/${agent}.png`)
+      expect(markup).not.toContain('https://www.google.com/s2/favicons')
+    }
+  })
 })
diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx
index c119abee471..dbeb8fca2bf 100644
--- a/src/renderer/src/components/editor/DiffViewer.tsx
+++ b/src/renderer/src/components/editor/DiffViewer.tsx
@@ -24,6 +24,7 @@ import { useDiffViewerLargeDiffLifecycle } from './useDiffViewerLargeDiffLifecyc
 import { getDiffViewerLargeDiffSaveAction } from './diff-viewer-large-diff-save-action'
 import type { DiffViewerProps } from './diff-viewer-props'
 import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
+import { useDiffEditorRegistration } from './diff-navigation-context'
 
 export default function DiffViewer({
   modelKey,
@@ -72,6 +73,7 @@ export default function DiffViewer({
     (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
 
   const diffEditorRef = useRef(null)
+  const { registerDiffEditor, unregisterDiffEditor } = useDiffEditorRegistration()
   const diffBodyRef = useRef(null)
   const lineNumberOptionsSubRef = useRef<{ dispose: () => void } | null>(null)
   const [modifiedEditor, setModifiedEditor] = useState(null)
@@ -244,10 +246,16 @@ export default function DiffViewer({
     // must not keep comment decorators or save handlers talking to disposed UI.
     lineNumberOptionsSubRef.current?.dispose()
     lineNumberOptionsSubRef.current = null
+    // Why: capture before nulling so we unregister the exact instance the
+    // navigator may still hold (identity guard no-ops a stale dispose).
+    const fallenBackEditor = diffEditorRef.current
     diffEditorRef.current = null
+    if (fallenBackEditor) {
+      unregisterDiffEditor(fallenBackEditor)
+    }
     setModifiedEditor(null)
     setPopover(null)
-  }, [])
+  }, [unregisterDiffEditor])
 
   const handleSubmitComment = async (body: string): Promise => {
     if (!popover) {
@@ -307,6 +315,7 @@ export default function DiffViewer({
   const handleMount: DiffOnMount = useCallback(
     (diffEditor, monaco) => {
       diffEditorRef.current = diffEditor
+      registerDiffEditor(diffEditor)
       lineNumberOptionsSubRef.current?.dispose()
       lineNumberOptionsSubRef.current = applyDiffEditorLineNumberOptions(diffEditor, sideBySide)
 
@@ -363,11 +372,12 @@ export default function DiffViewer({
         lineNumberOptionsSubRef.current?.dispose()
         lineNumberOptionsSubRef.current = null
         diffEditorRef.current = null
+        unregisterDiffEditor(diffEditor)
         setModifiedEditor(null)
         setPopover(null)
       })
     },
-    [editable, setupCopy, modelKey, filePath, sideBySide]
+    [editable, setupCopy, modelKey, filePath, sideBySide, registerDiffEditor, unregisterDiffEditor]
   )
 
   // Why: VS Code snapshots diff view state on deactivation, not on scroll events.
diff --git a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx
new file mode 100644
index 00000000000..77c78d0a472
--- /dev/null
+++ b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx
@@ -0,0 +1,165 @@
+// @vitest-environment happy-dom
+import { cleanup, render } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { OpenFile } from '@/store/slices/editor'
+
+const lifecycle = vi.hoisted(() => ({
+  events: [] as string[],
+  models: new Map()
+}))
+
+vi.mock('@/lib/lazy-with-retry', async () => {
+  const React = await import('react')
+  const { syncContentOnMount } = await import('./monaco-content-sync')
+  return {
+    lazyWithRetry: (factory: () => Promise) => {
+      if (!factory.toString().includes('/MonacoEditor.tsx')) {
+        return () => null
+      }
+      return function MockRetainedMonaco(props: { filePath: string; content: string }) {
+        /* oxlint-disable react-hooks/exhaustive-deps -- Mount-only by design: a prop-effect would hide a missing outer React remount. */
+        React.useEffect(() => {
+          lifecycle.events.push(`mount:${props.filePath}`)
+          const retained = lifecycle.models.get(props.filePath) ?? { content: '', undo: [] }
+          const model = {
+            getValue: () => retained.content,
+            getEOL: () => '\n',
+            getFullModelRange: () => ({
+              startLineNumber: 1,
+              startColumn: 1,
+              endLineNumber: 1,
+              endColumn: retained.content.length + 1
+            }),
+            pushEditOperations: (_selections: unknown[], operations: { text: string }[]) => {
+              retained.content = operations[0]?.text ?? retained.content
+            }
+          }
+          // Why: exercising the real mount reconciler makes outer key ordering
+          // observable without replacing Monaco's retained-model semantics.
+          syncContentOnMount(
+            {
+              getModel: () => model,
+              pushUndoStop: () => {
+                retained.undo.push('unexpected undo stop')
+                return true
+              }
+            } as never,
+            props.content
+          )
+          lifecycle.models.set(props.filePath, retained)
+          return () => {
+            lifecycle.events.push(`unmount:${props.filePath}`)
+          }
+        }, [])
+        /* oxlint-enable react-hooks/exhaustive-deps */
+        return null
+      }
+    }
+  }
+})
+vi.mock('@/store', () => ({
+  useAppStore: Object.assign(
+    (selector: (state: Record) => unknown) =>
+      selector({
+        worktreesByRepo: {},
+        openFile: vi.fn(),
+        openMarkdownPreview: vi.fn(),
+        openConflictReviewFile: vi.fn(),
+        openConflictReview: vi.fn(),
+        closeFile: vi.fn(),
+        setRightSidebarTab: vi.fn(),
+        setPendingEditorReveal: vi.fn(),
+        reloadOpenCheckRunDetailsTab: vi.fn()
+      }),
+    {
+      getState: () => ({
+        folderWorkspaces: [],
+        projectGroups: [],
+        repos: [],
+        settings: {},
+        worktreesByRepo: {},
+        openFile: vi.fn(),
+        openMarkdownPreview: vi.fn(),
+        openConflictReviewFile: vi.fn(),
+        openConflictReview: vi.fn(),
+        closeFile: vi.fn(),
+        setRightSidebarTab: vi.fn(),
+        setPendingEditorReveal: vi.fn(),
+        reloadOpenCheckRunDetailsTab: vi.fn()
+      })
+    }
+  )
+}))
+
+import { EditorContent } from './EditorContent'
+
+function file(filePath: string): OpenFile {
+  return {
+    id: filePath,
+    filePath,
+    relativePath: filePath.split('/').at(-1) ?? filePath,
+    worktreeId: 'repo::/repo',
+    language: 'typescript',
+    isDirty: false,
+    mode: 'edit'
+  }
+}
+
+function props(activeFile: OpenFile, content: string) {
+  return {
+    activeFile,
+    viewStateScopeId: 'same-pane',
+    fileContents: { [activeFile.id]: { content, isBinary: false } },
+    diffContents: {},
+    editBuffers: {},
+    openFiles: [activeFile],
+    worktreeEntries: [],
+    resolvedLanguage: 'typescript',
+    isMarkdown: false,
+    isMermaid: false,
+    isCsv: false,
+    isNotebook: false,
+    mdViewMode: 'rich' as const,
+    isChangesMode: false,
+    sideBySide: false,
+    pendingEditorReveal: null,
+    handleContentChange: vi.fn(),
+    handleContentChangeForFile: vi.fn(),
+    handleDirtyStateHint: vi.fn(),
+    handleSave: vi.fn(),
+    handleSaveForFile: vi.fn(),
+    reloadContent: vi.fn()
+  }
+}
+
+afterEach(() => {
+  cleanup()
+  lifecycle.events.length = 0
+  lifecycle.models.clear()
+})
+
+describe('EditorContent Monaco lifecycle boundary', () => {
+  it('unmounts the prior path before reconciling a stale retained target model', () => {
+    const first = file('/repo/first.ts')
+    const second = file('/repo/second.ts')
+    lifecycle.models.set(first.filePath, { content: 'first with edits', undo: ['first undo'] })
+    lifecycle.models.set(second.filePath, { content: 'stale second', undo: ['second undo'] })
+    const view = render()
+
+    view.rerender()
+
+    expect(lifecycle.events).toEqual([
+      'mount:/repo/first.ts',
+      'unmount:/repo/first.ts',
+      'mount:/repo/second.ts'
+    ])
+    expect(lifecycle.models.get(second.filePath)).toEqual({
+      content: 'fresh second',
+      undo: ['second undo']
+    })
+    expect(lifecycle.models.get(first.filePath)).toEqual({
+      content: 'first with edits',
+      undo: ['first undo']
+    })
+  })
+})
diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx
index abab01bf142..3e56b7c58da 100644
--- a/src/renderer/src/components/editor/EditorContent.tsx
+++ b/src/renderer/src/components/editor/EditorContent.tsx
@@ -313,10 +313,10 @@ export function EditorContent({
     // Why: Without a key, React reuses the same MonacoEditor instance when
     // switching tabs or split panes, just updating props. That means
     // useLayoutEffect cleanup (which snapshots scroll position) never fires.
-    // Keying on the visible pane identity forces unmount/remount so each split
-    // tab keeps its own viewport state even when the underlying file is shared.
+    // Keying on the visible pane and path forces remount before a retained target
+    // model mounts, so the old path cannot receive the new file's reconciliation.
      void handleCopyPath()}
-      onOpenDiffTargetFile={handleOpenDiffTargetFile}
-      onOpenPreviewToSide={handleOpenPreviewToSide}
-      onOpenMarkdownPreview={handleOpenMarkdownPreview}
-      onOpenContainingFolder={handleOpenContainingFolder}
-      onToggleSideBySide={() => setSideBySide((prev) => !prev)}
-      onEditorToggleChange={handleEditorToggleChange}
-      onToggleMarkdownTableOfContents={() =>
-        setMarkdownTableOfContentsVisible(
-          markdownDocumentStateFileId,
-          !isMarkdownTableOfContentsVisible
-        )
-      }
-      onToggleMarkdownFrontmatter={() =>
-        setMarkdownFrontmatterVisible(markdownDocumentStateFileId, !isMarkdownFrontmatterVisible)
-      }
-      onExportMarkdownToPdf={() =>
-        void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current })
-      }
-      onContentChange={handleContentChange}
-      onContentChangeForFile={handleContentChangeForFile}
-      onDirtyStateHint={handleDirtyStateHint}
-      onSave={handleSave}
-      onSaveForFile={handleSaveForFile}
-      onReloadContent={reloadContent}
-      onCloseMarkdownTableOfContents={() =>
-        setMarkdownTableOfContentsVisible(markdownDocumentStateFileId, false)
-      }
-      onCloseRenameDialog={closeRenameDialog}
-      onRenameConfirm={handleRenameConfirm}
-      markdownAnnotationsEnabled={markdownAnnotationsEnabled}
-    />
+    // Why: each split pane needs an isolated bridge between its diff editor and header controls.
+    
+       void handleCopyPath()}
+        onOpenDiffTargetFile={handleOpenDiffTargetFile}
+        onOpenPreviewToSide={handleOpenPreviewToSide}
+        onOpenMarkdownPreview={handleOpenMarkdownPreview}
+        onOpenContainingFolder={handleOpenContainingFolder}
+        onToggleSideBySide={() => setSideBySide((prev) => !prev)}
+        onEditorToggleChange={handleEditorToggleChange}
+        onToggleMarkdownTableOfContents={() =>
+          setMarkdownTableOfContentsVisible(
+            markdownDocumentStateFileId,
+            !isMarkdownTableOfContentsVisible
+          )
+        }
+        onToggleMarkdownFrontmatter={() =>
+          setMarkdownFrontmatterVisible(markdownDocumentStateFileId, !isMarkdownFrontmatterVisible)
+        }
+        onExportMarkdownToPdf={() =>
+          void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current })
+        }
+        onContentChange={handleContentChange}
+        onContentChangeForFile={handleContentChangeForFile}
+        onDirtyStateHint={handleDirtyStateHint}
+        onSave={handleSave}
+        onSaveForFile={handleSaveForFile}
+        onReloadContent={reloadContent}
+        onCloseMarkdownTableOfContents={() =>
+          setMarkdownTableOfContentsVisible(markdownDocumentStateFileId, false)
+        }
+        onCloseRenameDialog={closeRenameDialog}
+        onRenameConfirm={handleRenameConfirm}
+        markdownAnnotationsEnabled={markdownAnnotationsEnabled}
+      />
+    
   )
 }
 
diff --git a/src/renderer/src/components/editor/EditorPanelHeader.test.tsx b/src/renderer/src/components/editor/EditorPanelHeader.test.tsx
new file mode 100644
index 00000000000..0a03d312fdd
--- /dev/null
+++ b/src/renderer/src/components/editor/EditorPanelHeader.test.tsx
@@ -0,0 +1,106 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it, vi } from 'vitest'
+import type { OpenFile } from '@/store/slices/editor'
+import { EditorPanelHeader } from './EditorPanelHeader'
+
+vi.mock('@/store', () => ({
+  useAppStore: (selector: (state: Record) => unknown) =>
+    selector({
+      activeGroupIdByWorktree: {},
+      settings: {},
+      updateSettings: vi.fn()
+    })
+}))
+
+vi.mock('@/store/worktree-diff-comments-selector', () => ({
+  selectWorktreeDiffCommentsOrEmpty: () => []
+}))
+
+vi.mock('@/components/ui/tooltip', () => ({
+  TooltipProvider: ({
+    children,
+    delayDuration
+  }: {
+    children: React.ReactNode
+    delayDuration: number
+  }) => (
+    
+ {children} +
+ ), + Tooltip: ({ children }: { children: React.ReactNode }) => {children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => {children} +})) + +vi.mock('./EditorPanelHeaderPath', () => ({ + EditorPanelHeaderPath: () => null +})) + +vi.mock('./EditorPanelMarkdownActionsMenu', () => ({ + EditorPanelMarkdownActionsMenu: () => null +})) + +vi.mock('./diff-navigation-context', () => ({ + useDiffNavigation: () => ({ + changeCount: 2, + goToPreviousDiff: vi.fn(), + goToNextDiff: vi.fn() + }) +})) + +const activeFile: OpenFile = { + id: 'diff:/repo/file.ts', + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'repo::/repo', + language: 'typescript', + isDirty: false, + mode: 'diff' +} + +describe('EditorPanelHeader', () => { + it('shares one tooltip provider across the diff header controls', () => { + const html = renderToStaticMarkup( + + ) + + expect(html.match(/data-tooltip-provider/g)).toHaveLength(1) + expect(html.match(/data-tooltip="true"/g)).toHaveLength(3) + expect(html).toContain('data-delay-duration="300"') + expect(html).toContain('aria-label="Previous change"') + expect(html).toContain('aria-label="Next change"') + }) +}) diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx index 6cbb2b6f69b..c9f8c6e4d1b 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { Columns2, Eye, FileText, ListTree, Rows2 } from 'lucide-react' +import { ArrowDown, ArrowUp, Columns2, Eye, FileText, ListTree, Rows2 } from 'lucide-react' import { useAppStore } from '@/store' import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector' import type { OpenFile } from '@/store/slices/editor' @@ -14,6 +14,9 @@ import { DiffNotesSendMenu } from './DiffNotesSendMenu' import { EditorPanelMarkdownActionsMenu } from './EditorPanelMarkdownActionsMenu' import { translate } from '@/i18n/i18n' import { EditorPanelHeaderPath } from './EditorPanelHeaderPath' +import { useDiffNavigation } from './diff-navigation-context' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' type EditorPanelHeaderProps = { activeFile: OpenFile @@ -92,6 +95,9 @@ export function EditorPanelHeader({ () => diffComments.filter((comment) => comment.filePath === activeFile.relativePath), [activeFile.relativePath, diffComments] ) + const { changeCount, goToPreviousDiff, goToNextDiff } = useDiffNavigation() + const previousChangeShortcut = useShortcutKeyDetails('editor.previousChange') + const nextChangeShortcut = useShortcutKeyDetails('editor.nextChange') return (
@@ -178,6 +184,8 @@ export function EditorPanelHeader({ /> )} {isDiffSurface && ( + // Why: the adjacent diff controls use the same tooltip timing, so they + // share one provider instead of creating redundant Radix contexts. @@ -201,6 +209,58 @@ export function EditorPanelHeader({ )} + + + + + + {translate('auto.components.editor.EditorPanelHeader.2076ecfc9c', 'Previous change')} + {previousChangeShortcut.keys.length > 0 && ( + + )} + + + + + + + + {translate('auto.components.editor.EditorPanelHeader.631dab0df3', 'Next change')} + {nextChangeShortcut.keys.length > 0 && ( + + )} + + )} {hasEditorToggle && ( diff --git a/src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx b/src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx new file mode 100644 index 00000000000..790752921e2 --- /dev/null +++ b/src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const editorProps = vi.hoisted(() => ({ current: null as Record | null })) + +vi.mock('@monaco-editor/react', () => ({ + default: (props: Record) => { + editorProps.current = props + return null + }, + loader: { config: vi.fn() } +})) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + settings: { theme: 'dark', terminalFontSize: 13, terminalFontFamily: 'monospace' }, + editorFontZoomLevel: 0, + setPendingEditorReveal: vi.fn(), + setEditorCursorLine: vi.fn(), + addDiffComment: vi.fn(), + deleteDiffComment: vi.fn(), + updateDiffComment: vi.fn(), + scrollToDiffCommentId: null, + setScrollToDiffCommentId: vi.fn(), + worktreeDiffComments: {} + }) +})) +vi.mock('../diff-comments/useDiffCommentDecorator', () => ({ + useDiffCommentDecorator: vi.fn() +})) +vi.mock('./useContextualCopySetup', () => ({ + useContextualCopySetup: () => ({ setupCopy: vi.fn(), toastNode: null }) +})) + +import MonacoEditor from './MonacoEditor' + +afterEach(() => { + cleanup() + editorProps.current = null +}) + +describe('MonacoEditor content ownership', () => { + it('initializes the wrapper without a controlled value updater', () => { + render( + + ) + + expect(editorProps.current?.defaultValue).toBe('initial content') + expect(editorProps.current).not.toHaveProperty('value') + }) +}) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 6c608ceefa1..d8f92a00cf3 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -52,11 +52,13 @@ import { } from './monaco-markdown-selection-annotation' import { translate } from '@/i18n/i18n' import { handleMonacoLargeTextPaste } from './monaco-large-text-paste' +import { buildFileEditorWordWrapOptions } from './file-editor-word-wrap-options' import { clampMonacoAutoHeight, getMonacoAutoHeightForContent, isMonacoAutoHeightCapped } from './monaco-auto-height' +import { installMonacoE2EProbe } from './monaco-e2e-probe' type MonacoEditorProps = { fileId: string @@ -146,6 +148,8 @@ export default function MonacoEditor({ settings?.terminalFontSize ?? 13, editorFontZoomLevel ) + const editorFontFamily = settings?.terminalFontFamily || 'monospace' + const editorWordWrap = settings?.editorWordWrap const estimatedAutoHeight = useMemo(() => { if (!autoHeight) { return null @@ -329,6 +333,7 @@ export default function MonacoEditor({ (editorInstance, monaco) => { editorRef.current = editorInstance setMountedEditor(editorInstance) + const uninstallE2EProbe = installMonacoE2EProbe(editorInstance, filePath) let autoHeightSub: { dispose: () => void } | null = null let autoHeightFrame: number | null = null const updateAutoHeight = (): void => { @@ -504,6 +509,7 @@ export default function MonacoEditor({ } conflictDecorationsRef.current?.clear() conflictDecorationsRef.current = null + uninstallE2EProbe() editorRef.current = null setMountedEditor(null) setCommentPopover(null) @@ -707,14 +713,15 @@ export default function MonacoEditor({ // Update editor options when settings change useEffect(() => { - if (!editorRef.current || !settings) { + if (!editorRef.current) { return } editorRef.current.updateOptions({ fontSize: editorFontSize, - fontFamily: settings.terminalFontFamily || 'monospace' + fontFamily: editorFontFamily, + ...buildFileEditorWordWrapOptions(editorWordWrap) }) - }, [editorFontSize, settings]) + }, [editorFontFamily, editorFontSize, editorWordWrap]) useEffect(() => { markdownDocLinkDecorationsRef.current?.refresh() @@ -822,7 +829,9 @@ export default function MonacoEditor({ void + fireUpdate: () => void + goToDiff: ReturnType + disposeUpdate: ReturnType + containerNode: HTMLElement +} + +function createFakeEditor(initialCount: number): FakeDiffEditor { + let count = initialCount + let updateCallback: (() => void) | null = null + const disposeUpdate = vi.fn(() => { + updateCallback = null + }) + const containerNode = document.createElement('div') + const editor = { + getLineChanges: () => (count > 0 ? Array.from({ length: count }, () => ({})) : []), + goToDiff: vi.fn(), + getContainerDomNode: () => containerNode, + onDidUpdateDiff: (cb: () => void) => { + updateCallback = cb + return { + dispose: disposeUpdate + } + }, + setLineChanges: (next: number) => { + count = next + }, + fireUpdate: () => updateCallback?.(), + disposeUpdate, + containerNode + } as unknown as FakeDiffEditor + return editor +} + +let captured: DiffNavigationContextValue | null = null +let registration: DiffEditorRegistrationContextValue | null = null +let registrationRenderCount = 0 + +function Probe(): null { + captured = useDiffNavigation() + return null +} + +function RegistrationProbe(): null { + registration = useDiffEditorRegistration() + registrationRenderCount += 1 + return null +} + +describe('DiffNavigationProvider', () => { + let container: HTMLDivElement | null = null + let root: Root | null = null + + function mount(): void { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render( + + + + + ) + }) + } + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + container = null + root = null + captured = null + registration = null + registrationRenderCount = 0 + }) + + it('exposes the change count and routes nav actions to the registered editor', () => { + mount() + const editor = createFakeEditor(3) + act(() => registration?.registerDiffEditor(editor)) + + expect(captured?.changeCount).toBe(3) + + act(() => captured?.goToNextDiff()) + expect(editor.goToDiff).toHaveBeenCalledWith('next') + + act(() => captured?.goToPreviousDiff()) + expect(editor.goToDiff).toHaveBeenCalledWith('previous') + }) + + it('re-renders when onDidUpdateDiff flips the count 0 -> N (count is state)', () => { + mount() + const editor = createFakeEditor(0) + act(() => registration?.registerDiffEditor(editor)) + expect(captured?.changeCount).toBe(0) + + act(() => { + editor.setLineChanges(2) + editor.fireUpdate() + }) + expect(captured?.changeCount).toBe(2) + expect(registrationRenderCount).toBe(1) + }) + + it('ignores a stale unregister for an editor that is no longer current (identity guard)', () => { + mount() + const oldEditor = createFakeEditor(1) + const newEditor = createFakeEditor(4) + + // Fast-swap: new editor registers before the old one's dispose fires. + act(() => registration?.registerDiffEditor(oldEditor)) + act(() => registration?.registerDiffEditor(newEditor)) + expect(captured?.changeCount).toBe(4) + expect(oldEditor.disposeUpdate).toHaveBeenCalledOnce() + + // A stale update from the old editor must not flip the count back: registering + // the new editor disposed the old subscription, so its callback no longer fires. + act(() => { + oldEditor.setLineChanges(9) + oldEditor.fireUpdate() + }) + expect(captured?.changeCount).toBe(4) + + act(() => registration?.unregisterDiffEditor(oldEditor)) + + // New editor's count is intact and nav still routes to it. + expect(captured?.changeCount).toBe(4) + act(() => captured?.goToNextDiff()) + expect(newEditor.goToDiff).toHaveBeenCalledWith('next') + expect(oldEditor.goToDiff).not.toHaveBeenCalled() + }) + + it('disposes the active diff update subscription when the provider unmounts', () => { + mount() + const editor = createFakeEditor(1) + act(() => registration?.registerDiffEditor(editor)) + + act(() => root?.unmount()) + + expect(editor.disposeUpdate).toHaveBeenCalledOnce() + root = null + }) + + it('installs a capture-phase key listener on register and removes it on unregister', () => { + mount() + const editor = createFakeEditor(2) + const addSpy = vi.spyOn(editor.containerNode, 'addEventListener') + const removeSpy = vi.spyOn(editor.containerNode, 'removeEventListener') + + act(() => registration?.registerDiffEditor(editor)) + expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true) + + act(() => registration?.unregisterDiffEditor(editor)) + expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true) + }) +}) diff --git a/src/renderer/src/components/editor/diff-navigation-context.tsx b/src/renderer/src/components/editor/diff-navigation-context.tsx new file mode 100644 index 00000000000..9bd1175de45 --- /dev/null +++ b/src/renderer/src/components/editor/diff-navigation-context.tsx @@ -0,0 +1,126 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import type { editor } from 'monaco-editor' +import { installMonacoDiffChangeNavigationShortcut } from './editor-shortcuts' + +export type DiffEditorRegistrationContextValue = { + registerDiffEditor: (editor: editor.IStandaloneDiffEditor) => void + unregisterDiffEditor: (editor: editor.IStandaloneDiffEditor) => void +} + +export type DiffNavigationContextValue = { + goToPreviousDiff: () => void + goToNextDiff: () => void + changeCount: number +} + +const noop = (): void => {} + +// Why: registration stays separate from changeCount so diff recomputation only +// rerenders the header controls, not the heavy Monaco DiffViewer consumer. +const DiffEditorRegistrationContext = createContext({ + registerDiffEditor: noop, + unregisterDiffEditor: noop +}) + +const DiffNavigationContext = createContext({ + goToPreviousDiff: noop, + goToNextDiff: noop, + changeCount: 0 +}) + +function countChanges(diffEditor: editor.IStandaloneDiffEditor): number { + return diffEditor.getLineChanges()?.length ?? 0 +} + +export function DiffNavigationProvider({ + children +}: { + children: React.ReactNode +}): React.JSX.Element { + const editorRef = useRef(null) + const updateSubRef = useRef<{ dispose: () => void } | null>(null) + // Why: F7/Shift+F7 change navigation shares the registered editor with the + // header buttons, so the keyboard listener lives here rather than in DiffViewer. + const shortcutCleanupRef = useRef<(() => void) | null>(null) + // Why: changeCount must be state, not a ref — the header is a sibling consumer + // and only re-renders (enabling the buttons) when the value object identity + // changes on the 0 -> N flip once the diff computation lands. + const [changeCount, setChangeCount] = useState(0) + + const registerDiffEditor = useCallback((diffEditor: editor.IStandaloneDiffEditor) => { + editorRef.current = diffEditor + // Hold at most one update subscription; replace any prior editor's. + updateSubRef.current?.dispose() + updateSubRef.current = diffEditor.onDidUpdateDiff(() => { + // Why: ignore updates from an editor that is no longer current so a stale + // subscription in the fast-swap case can't write a wrong count. + if (editorRef.current === diffEditor) { + setChangeCount(countChanges(diffEditor)) + } + }) + // Hold at most one keyboard listener; replace any prior editor's. + shortcutCleanupRef.current?.() + shortcutCleanupRef.current = installMonacoDiffChangeNavigationShortcut(diffEditor) + setChangeCount(countChanges(diffEditor)) + }, []) + + const unregisterDiffEditor = useCallback((diffEditor: editor.IStandaloneDiffEditor) => { + // Why: identity guard for the fast-swap race — a stale dispose carrying the + // old editor must not wipe a freshly-registered new one. + if (editorRef.current !== diffEditor) { + return + } + updateSubRef.current?.dispose() + updateSubRef.current = null + shortcutCleanupRef.current?.() + shortcutCleanupRef.current = null + editorRef.current = null + setChangeCount(0) + }, []) + + const goToPreviousDiff = useCallback(() => { + editorRef.current?.goToDiff('previous') + }, []) + + const goToNextDiff = useCallback(() => { + editorRef.current?.goToDiff('next') + }, []) + + useEffect(() => { + return () => { + updateSubRef.current?.dispose() + updateSubRef.current = null + shortcutCleanupRef.current?.() + shortcutCleanupRef.current = null + } + }, []) + + const registrationValue = useMemo( + () => ({ registerDiffEditor, unregisterDiffEditor }), + [registerDiffEditor, unregisterDiffEditor] + ) + const navigationValue = useMemo( + () => ({ + goToPreviousDiff, + goToNextDiff, + changeCount + }), + [goToPreviousDiff, goToNextDiff, changeCount] + ) + + return ( + + + {children} + + + ) +} + +export function useDiffEditorRegistration(): DiffEditorRegistrationContextValue { + return useContext(DiffEditorRegistrationContext) +} + +export function useDiffNavigation(): DiffNavigationContextValue { + return useContext(DiffNavigationContext) +} diff --git a/src/renderer/src/components/editor/editor-shortcuts.test.ts b/src/renderer/src/components/editor/editor-shortcuts.test.ts index daacea7f647..51c89878aef 100644 --- a/src/renderer/src/components/editor/editor-shortcuts.test.ts +++ b/src/renderer/src/components/editor/editor-shortcuts.test.ts @@ -17,7 +17,11 @@ vi.mock('@/store', () => ({ } })) -import { installEditorFindShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts' +import { + installEditorFindShortcut, + installMonacoDiffChangeNavigationShortcut, + installMonacoEditorFindShortcut +} from './editor-shortcuts' type ShortcutFixture = { container: HTMLDivElement @@ -196,3 +200,85 @@ describe('installEditorFindShortcut', () => { dispose() }) }) + +describe('installMonacoDiffChangeNavigationShortcut', () => { + function createDiffNavigationFixture(): { + container: HTMLDivElement + dispose: () => void + input: HTMLTextAreaElement + goToDiff: ReturnType + onDownstreamKeyDown: ReturnType + } { + const container = document.createElement('div') + const input = document.createElement('textarea') + const goToDiff = vi.fn() + const onDownstreamKeyDown = vi.fn() + container.appendChild(input) + document.body.appendChild(container) + input.addEventListener('keydown', onDownstreamKeyDown) + + return { + container, + dispose: installMonacoDiffChangeNavigationShortcut({ + getContainerDomNode: () => container, + goToDiff + }), + input, + goToDiff, + onDownstreamKeyDown + } + } + + it.each([ + { key: 'F7', shiftKey: false, direction: 'next' }, + { key: 'F7', shiftKey: true, direction: 'previous' } + ] as const)('routes $key (shift=$shiftKey) to $direction', ({ key, shiftKey, direction }) => { + const fixture = createDiffNavigationFixture() + + const event = dispatchKeyDown(fixture.input, { key, code: key, shiftKey }) + + expect(event.defaultPrevented).toBe(true) + expect(fixture.goToDiff).toHaveBeenCalledWith(direction) + expect(fixture.onDownstreamKeyDown).not.toHaveBeenCalled() + fixture.dispose() + }) + + it('consumes repeats without navigating again', () => { + const fixture = createDiffNavigationFixture() + + const event = dispatchKeyDown(fixture.input, { + key: 'F7', + code: 'F7', + repeat: true + }) + + expect(event.defaultPrevented).toBe(true) + expect(fixture.goToDiff).not.toHaveBeenCalled() + expect(fixture.onDownstreamKeyDown).not.toHaveBeenCalled() + fixture.dispose() + }) + + it('honors custom bindings and removes the listener when disposed', () => { + shortcutState.keybindings = { 'editor.nextChange': ['Mod+G'] } + const fixture = createDiffNavigationFixture() + + const defaultEvent = dispatchKeyDown(fixture.input, { key: 'F7', code: 'F7' }) + const customEvent = dispatchKeyDown(fixture.input, { + key: 'g', + code: 'KeyG', + metaKey: true + }) + fixture.dispose() + const disposedEvent = dispatchKeyDown(fixture.input, { + key: 'g', + code: 'KeyG', + metaKey: true + }) + + expect(defaultEvent.defaultPrevented).toBe(false) + expect(customEvent.defaultPrevented).toBe(true) + expect(disposedEvent.defaultPrevented).toBe(false) + expect(fixture.goToDiff).toHaveBeenCalledTimes(1) + expect(fixture.goToDiff).toHaveBeenCalledWith('next') + }) +}) diff --git a/src/renderer/src/components/editor/editor-shortcuts.ts b/src/renderer/src/components/editor/editor-shortcuts.ts index 86a7e721226..825caf6f1ee 100644 --- a/src/renderer/src/components/editor/editor-shortcuts.ts +++ b/src/renderer/src/components/editor/editor-shortcuts.ts @@ -46,6 +46,39 @@ export function installEditorFindShortcut(target: HTMLElement, onFind: () => voi return () => target.removeEventListener('keydown', handleKeyDown, true) } +type MonacoDiffNavigationEditor = { + getContainerDomNode: () => HTMLElement + goToDiff: (target: 'next' | 'previous') => void +} + +export function installMonacoDiffChangeNavigationShortcut( + editor: MonacoDiffNavigationEditor +): () => void { + const handleKeyDown = (event: KeyboardEvent): void => { + let direction: 'next' | 'previous' | null = null + if (editorShortcutMatches('editor.nextChange', event)) { + direction = 'next' + } else if (editorShortcutMatches('editor.previousChange', event)) { + direction = 'previous' + } + if (!direction) { + return + } + // Why: capture-phase preventDefault/stopPropagation beats Monaco's built-in + // F7 accessible-review pane, like the find shortcut does for Cmd+F. + event.preventDefault() + event.stopPropagation() + // Consume matched repeats but navigate once per press (matches find shortcut). + if (!event.repeat) { + editor.goToDiff(direction) + } + } + + const target = editor.getContainerDomNode() + target.addEventListener('keydown', handleKeyDown, true) + return () => target.removeEventListener('keydown', handleKeyDown, true) +} + type MonacoFindShortcutEditor = { getAction: (id: string) => { run: () => void | Promise } | null getContainerDomNode: () => HTMLElement diff --git a/src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts b/src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts new file mode 100644 index 00000000000..c50d4389ab3 --- /dev/null +++ b/src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { buildFileEditorWordWrapOptions } from './file-editor-word-wrap-options' + +describe('buildFileEditorWordWrapOptions', () => { + it('keeps wrapping enabled for existing profiles without the preference', () => { + expect(buildFileEditorWordWrapOptions(undefined)).toEqual({ wordWrap: 'on' }) + expect(buildFileEditorWordWrapOptions(true)).toEqual({ wordWrap: 'on' }) + }) + + it('disables wrapping for horizontal file-editor scrolling', () => { + expect(buildFileEditorWordWrapOptions(false)).toEqual({ wordWrap: 'off' }) + }) +}) diff --git a/src/renderer/src/components/editor/file-editor-word-wrap-options.ts b/src/renderer/src/components/editor/file-editor-word-wrap-options.ts new file mode 100644 index 00000000000..af9bedfb2ef --- /dev/null +++ b/src/renderer/src/components/editor/file-editor-word-wrap-options.ts @@ -0,0 +1,8 @@ +import type { editor } from 'monaco-editor' + +export function buildFileEditorWordWrapOptions( + editorWordWrap: boolean | undefined +): Pick { + // Why: profiles saved before this preference existed must retain Orca's previous wrapped default. + return { wordWrap: editorWordWrap === false ? 'off' : 'on' } +} diff --git a/src/renderer/src/components/editor/monaco-content-sync.bench.ts b/src/renderer/src/components/editor/monaco-content-sync.bench.ts new file mode 100644 index 00000000000..ff31cdbc037 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-content-sync.bench.ts @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +import { bench, expect } from 'vitest' +import * as monaco from 'monaco-editor' +import { syncContentUpdate } from './monaco-content-sync' + +const SAMPLE_COUNT = 30 +const WARMUP_COUNT = 5 + +function percentile95(samples: readonly number[]): number { + const sorted = [...samples].sort((left, right) => left - right) + return sorted[Math.ceil(sorted.length * 0.95) - 1] ?? Number.POSITIVE_INFINITY +} + +async function settleBetweenArms(): Promise { + const gc = (globalThis as { gc?: () => void }).gc + if (!gc) { + throw new Error('Forced GC unavailable; run the benchmark with node --expose-gc') + } + gc() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +async function measureReconciliation( + initial: string, + appended: string, + rewritten: string +): Promise<{ appendSamples: number[]; replacementSamples: number[] }> { + const appendSamples: number[] = [] + const replacementSamples: number[] = [] + const measure = (next: string): number => { + const model = monaco.editor.createModel(initial, 'plaintext') + const editorInstance = { + getModel: () => model, + pushUndoStop: () => true + } as unknown as monaco.editor.IStandaloneCodeEditor + const startedAt = performance.now() + syncContentUpdate(editorInstance, next) + const duration = performance.now() - startedAt + model.dispose() + return duration + } + for (let index = 0; index < WARMUP_COUNT + SAMPLE_COUNT; index++) { + const appendDuration = measure(appended) + await settleBetweenArms() + const replacementDuration = measure(rewritten) + await settleBetweenArms() + if (index >= WARMUP_COUNT) { + appendSamples.push(appendDuration) + replacementSamples.push(replacementDuration) + } + } + return { appendSamples, replacementSamples } +} + +async function runReconciliationBenchmark( + label: string, + bytes: number, + maxAppendP95Ms: number +): Promise { + const base = `header\n${'x'.repeat(bytes - 8)}` + const appended = `${base}\n{"type":"tail"}` + const rewritten = `H${base.slice(1)}` + const { appendSamples, replacementSamples } = await measureReconciliation( + base, + appended, + rewritten + ) + const appendP95 = percentile95(appendSamples) + const replacementP95 = percentile95(replacementSamples) + console.log(`[monaco-content-sync] ${label} ${JSON.stringify({ appendP95, replacementP95 })}`) + expect(appendP95).toBeLessThan(maxAppendP95Ms) + expect(replacementP95 / appendP95).toBeGreaterThanOrEqual(2) +} + +bench( + '9 MiB append and replacement p95', + () => runReconciliationBenchmark('9 MiB model', 9 * 1024 * 1024, 50), + { iterations: 1, time: 1, warmupIterations: 0, warmupTime: 0 } +) + +bench( + '50 MiB append and replacement p95', + () => runReconciliationBenchmark('50 MiB model', 50 * 1024 * 1024, 100), + { iterations: 1, time: 1, warmupIterations: 0, warmupTime: 0 } +) diff --git a/src/renderer/src/components/editor/monaco-content-sync.test.ts b/src/renderer/src/components/editor/monaco-content-sync.test.ts new file mode 100644 index 00000000000..ccf958f122e --- /dev/null +++ b/src/renderer/src/components/editor/monaco-content-sync.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest' +import type { editor } from 'monaco-editor' +import { syncContentOnMount, syncContentUpdate } from './monaco-content-sync' + +function createHarness( + initialContent: string, + eol = '\n' +): { + editorInstance: editor.IStandaloneCodeEditor + getValue: ReturnType + getFullModelRange: ReturnType + pushEditOperations: ReturnType + pushUndoStop: ReturnType +} { + const getValue = vi.fn(() => initialContent) + const getFullModelRange = vi.fn(() => ({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: 3, + endColumn: 5 + })) + const pushEditOperations = vi.fn() + const model = { + getValue, + getEOL: () => eol, + getFullModelRange, + pushEditOperations + } as unknown as editor.ITextModel + const pushUndoStop = vi.fn() + const editorInstance = { + getModel: () => model, + pushUndoStop + } as unknown as editor.IStandaloneCodeEditor + return { editorInstance, getValue, getFullModelRange, pushEditOperations, pushUndoStop } +} + +describe('monaco content sync', () => { + it('inserts only an append-only suffix at the model end', () => { + const harness = createHarness('first\nsecond\nlast') + + syncContentUpdate(harness.editorInstance, 'first\nsecond\nlast\nnext') + + expect(harness.getValue).toHaveBeenCalledTimes(1) + expect(harness.getFullModelRange).toHaveBeenCalledTimes(1) + expect(harness.pushEditOperations).toHaveBeenCalledWith( + [], + [ + { + range: { + startLineNumber: 3, + startColumn: 5, + endLineNumber: 3, + endColumn: 5 + }, + text: '\nnext' + } + ], + expect.any(Function) + ) + expect(harness.pushUndoStop).toHaveBeenCalledTimes(2) + }) + + it('does nothing for identical content', () => { + const harness = createHarness('unchanged') + + syncContentUpdate(harness.editorInstance, 'unchanged') + + expect(harness.getValue).toHaveBeenCalledTimes(1) + expect(harness.getFullModelRange).not.toHaveBeenCalled() + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + + it.each([ + [ + 'CRLF file content into an LF model', + 'first\nsecond\nlast', + '\n', + 'first\r\nsecond\r\nlast\r\nnext', + '\nnext' + ], + [ + 'LF file content into a CRLF model', + 'first\r\nsecond\r\nlast', + '\r\n', + 'first\nsecond\nlast\nnext', + '\r\nnext' + ], + [ + 'mixed file content into an LF model', + 'first\nsecond\nlast', + '\n', + 'first\r\nsecond\rlast\nnext', + '\nnext' + ] + ])( + 'keeps %s on the suffix path', + (_label, initialContent, modelEol, nextContent, expectedSuffix) => { + const harness = createHarness(initialContent, modelEol) + + syncContentUpdate(harness.editorInstance, nextContent) + + expect(harness.pushEditOperations).toHaveBeenCalledWith( + [], + [expect.objectContaining({ text: expectedSuffix })], + expect.any(Function) + ) + expect(harness.pushUndoStop).toHaveBeenCalledTimes(2) + } + ) + + it.each([ + ['same-length rewrite', 'before', 'after!'], + ['prefix mismatch', 'before', 'changed content'], + ['truncation', 'longer content', 'short'] + ])('fully replaces a %s', (_label, initialContent, nextContent) => { + const harness = createHarness(initialContent) + + syncContentUpdate(harness.editorInstance, nextContent) + + expect(harness.getValue).toHaveBeenCalledTimes(1) + expect(harness.pushEditOperations).toHaveBeenCalledWith( + [], + [{ range: harness.getFullModelRange.mock.results[0]?.value, text: nextContent }], + expect.any(Function) + ) + expect(harness.pushUndoStop).toHaveBeenCalledTimes(2) + }) + + it('reconciles a stale retained model on mount without undo stops', () => { + const harness = createHarness('stale') + + expect(syncContentOnMount(harness.editorInstance, 'fresh')).toBe(true) + + expect(harness.pushEditOperations).toHaveBeenCalledTimes(1) + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + + it('does nothing on mount when content already matches', () => { + const harness = createHarness('same') + + expect(syncContentOnMount(harness.editorInstance, 'same')).toBe(false) + + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + + it('does nothing on mount when only raw file EOLs differ from the model', () => { + const harness = createHarness('first\nsecond') + + expect(syncContentOnMount(harness.editorInstance, 'first\r\nsecond')).toBe(false) + + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + + it('reconciles only the retained target model after a path switch', () => { + const priorPath = createHarness('prior path with user edits') + const targetPath = createHarness('stale target') + + syncContentOnMount(targetPath.editorInstance, 'fresh target') + + expect(targetPath.pushEditOperations).toHaveBeenCalledTimes(1) + expect(targetPath.pushUndoStop).not.toHaveBeenCalled() + expect(priorPath.pushEditOperations).not.toHaveBeenCalled() + expect(priorPath.pushUndoStop).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/monaco-content-sync.ts b/src/renderer/src/components/editor/monaco-content-sync.ts index 64b9ec662fe..b7894af432c 100644 --- a/src/renderer/src/components/editor/monaco-content-sync.ts +++ b/src/renderer/src/components/editor/monaco-content-sync.ts @@ -1,5 +1,15 @@ import type { editor } from 'monaco-editor' +function normalizeToModelEol(content: string, model: editor.ITextModel): string { + const eol = model.getEOL() + // Why: Monaco normalizes model line endings, while filesystem content keeps + // its raw EOLs. Compare the representation Monaco can actually retain. + if (eol === '\n' && !content.includes('\r')) { + return content + } + return content.replace(/\r\n|\r|\n/g, eol) +} + /** * Overwrite a Monaco model's text via pushEditOperations so the change * participates in the undo stack (unlike setValue, which blows it away). @@ -7,10 +17,11 @@ import type { editor } from 'monaco-editor' function replaceModelContent( editorInstance: editor.IStandaloneCodeEditor, model: editor.ITextModel, + currentContent: string, content: string, withUndoStops: boolean ): void { - if (model.getValue() === content) { + if (currentContent === content) { return } const fullRange = model.getFullModelRange() @@ -41,13 +52,15 @@ export function syncContentOnMount( if (!model) { return false } - if (model.getValue() === content) { + const currentContent = model.getValue() + const normalizedContent = normalizeToModelEol(content, model) + if (currentContent === normalizedContent) { return false } // Why: no undo stop on mount — the retained model's text was already the // user's last-known state, and adding an undo entry here would make Cmd+Z // revert to the pre-remount text, which is confusing. - replaceModelContent(editorInstance, model, content, false) + replaceModelContent(editorInstance, model, currentContent, normalizedContent, false) return true } @@ -67,5 +80,37 @@ export function syncContentUpdate( if (!model) { return } - replaceModelContent(editorInstance, model, content, true) + const currentContent = model.getValue() + const normalizedContent = normalizeToModelEol(content, model) + if (currentContent.length === normalizedContent.length) { + replaceModelContent(editorInstance, model, currentContent, normalizedContent, true) + return + } + if ( + normalizedContent.length > currentContent.length && + normalizedContent.startsWith(currentContent) + ) { + // Why: preserving the existing prefix lets Monaco retain viewport, + // selection, find-widget, and tokenization state above a live-file append. + const fullRange = model.getFullModelRange() + editorInstance.pushUndoStop() + model.pushEditOperations( + [], + [ + { + range: { + startLineNumber: fullRange.endLineNumber, + startColumn: fullRange.endColumn, + endLineNumber: fullRange.endLineNumber, + endColumn: fullRange.endColumn + }, + text: normalizedContent.slice(currentContent.length) + } + ], + () => null + ) + editorInstance.pushUndoStop() + return + } + replaceModelContent(editorInstance, model, currentContent, normalizedContent, true) } diff --git a/src/renderer/src/components/editor/monaco-e2e-probe.ts b/src/renderer/src/components/editor/monaco-e2e-probe.ts new file mode 100644 index 00000000000..f6284a51771 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-e2e-probe.ts @@ -0,0 +1,99 @@ +import type { editor, IRange, ISelection } from 'monaco-editor' + +export type MonacoE2ESnapshot = { + contentHeight: number + scrollHeight: number + scrollTop: number + visibleRanges: IRange[] + selection: ISelection | null + valueLength: number + valueTail: string + find: { + open: boolean + query: string + activeMatch: string + } +} + +export type MonacoE2EProbe = { + filePath: string + restoreLegacySetValueControl: () => void + restoreScrollTop: (scrollTop: number) => void + runLegacySetValueAppend: (suffix: string) => void + snapshot: () => MonacoE2ESnapshot +} + +export function installMonacoE2EProbe( + editorInstance: editor.IStandaloneCodeEditor, + filePath: string +): () => void { + if (import.meta.env.MODE !== 'e2e') { + return () => {} + } + let legacyControlOriginalValue: string | null = null + let legacyControlIncomingValue: string | null = null + const probe: MonacoE2EProbe = { + filePath, + runLegacySetValueAppend: (suffix: string): void => { + if (legacyControlOriginalValue !== null) { + throw new Error('Legacy control must be restored before running again') + } + legacyControlOriginalValue = editorInstance.getValue() + // Why: the former controlled wrapper retained a flat IPC-delivered prop + // while setValue rebuilt the model; preserve that ownership in the control. + legacyControlIncomingValue = new TextDecoder().decode( + new TextEncoder().encode(`${legacyControlOriginalValue}${suffix}`) + ) + // Why: reproduces the former controlled read-only wrapper's unconditional + // whole-model setValue without exposing a switch in production builds. + editorInstance.setValue(legacyControlIncomingValue) + }, + restoreLegacySetValueControl: (): void => { + if (legacyControlOriginalValue === null) { + return + } + editorInstance.setValue(legacyControlOriginalValue) + legacyControlOriginalValue = null + legacyControlIncomingValue = null + }, + restoreScrollTop: (scrollTop: number): void => { + // Why: the legacy setValue control can perturb Monaco's pixel rounding; + // paired fixed-path measurements must start from the recorded geometry. + editorInstance.setScrollTop(scrollTop) + }, + snapshot: (): MonacoE2ESnapshot => { + const container = editorInstance.getContainerDomNode() + const findWidget = container.querySelector('.find-widget') + const findInput = findWidget?.querySelector('.input') + const model = editorInstance.getModel() + const valueLength = model?.getValueLength() ?? 0 + const lastLineNumber = model?.getLineCount() ?? 1 + const lastLine = model?.getLineContent(lastLineNumber) ?? '' + const lastNonEmptyLine = + lastLine || lastLineNumber === 1 + ? lastLine + : (model?.getLineContent(lastLineNumber - 1) ?? '') + return { + contentHeight: editorInstance.getContentHeight(), + scrollHeight: editorInstance.getScrollHeight(), + scrollTop: editorInstance.getScrollTop(), + visibleRanges: [...editorInstance.getVisibleRanges()], + selection: editorInstance.getSelection(), + valueLength, + valueTail: lastNonEmptyLine.slice(-256), + find: { + open: findWidget?.getAttribute('aria-hidden') !== 'true', + query: findInput?.value ?? '', + activeMatch: + findWidget?.querySelector('.matchesCount')?.textContent?.trim() ?? '' + } + } + } + } + window.__monacoEditorE2E = probe + return () => { + if (window.__monacoEditorE2E === probe) { + delete window.__monacoEditorE2E + } + } +} diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index af22a1dff6c..64e0ca7344f 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -4,6 +4,7 @@ import type { MobileNetworkInterface } from '../settings/mobile-network-interfac import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons' import { NetworkInterfacePicker } from './NetworkInterfacePicker' import { getChannelTagline, type InstallCopy, type IosChannel } from './mobile-platform-copy' +import { WindowsFirewallNotice } from './WindowsFirewallNotice' export { HeroIntro } from './MobileHeroIntro' export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices' import { translate } from '@/i18n/i18n' @@ -239,6 +240,11 @@ export function HeroFlow({ {translate('auto.components.mobile.MobileHero.010dddcf27', 'Copy pairing code')}
+
= {}): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + mobile: { + getWindowsFirewallStatus: vi.fn().mockResolvedValue({ supported: false }), + repairWindowsFirewall: vi.fn().mockResolvedValue({ ok: true }), + openWindowsNetworkSettings: vi.fn().mockResolvedValue(true), + ...overrides + } + } + }) +} + +describe('WindowsFirewallNotice', () => { + it('stays hidden until a pairing code exists and Windows reports a missing rule', async () => { + const getWindowsFirewallStatus = vi.fn().mockResolvedValue({ + supported: true, + port: 6768, + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + }) + setMobileApi({ getWindowsFirewallStatus }) + const { rerender } = render( + + ) + expect(screen.queryByText(/allow phone connections through/i)).not.toBeInTheDocument() + + rerender() + expect(await screen.findByText(/allow phone connections through/i)).toBeInTheDocument() + expect(screen.getByText(/TCP port 6768/i)).toBeInTheDocument() + }) + + it('repairs only after explicit user action and hides after success', async () => { + const repairWindowsFirewall = vi.fn().mockResolvedValue({ ok: true }) + setMobileApi({ + getWindowsFirewallStatus: vi.fn().mockResolvedValue({ + supported: true, + port: 6768, + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + }), + repairWindowsFirewall + }) + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: /allow phone connections/i })) + expect(repairWindowsFirewall).toHaveBeenCalledTimes(1) + await waitFor(() => + expect(screen.queryByText(/allow phone connections through/i)).not.toBeInTheDocument() + ) + }) + + it('does not offer a firewall rule while the selected network is public', async () => { + const openWindowsNetworkSettings = vi.fn().mockResolvedValue(true) + setMobileApi({ + getWindowsFirewallStatus: vi.fn().mockResolvedValue({ + supported: true, + port: 6768, + ruleAllowed: true, + privateFirewallEnabled: false, + networkCategory: 'public', + inspectionAvailable: true + }), + openWindowsNetworkSettings + }) + const user = userEvent.setup() + render() + + expect(await screen.findByText(/marks this network as public/i)).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /allow phone connections/i }) + ).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: /open network settings/i })) + expect(openWindowsNetworkSettings).toHaveBeenCalledTimes(1) + }) + + it('does not offer a Private-profile rule on a managed domain network', async () => { + const getWindowsFirewallStatus = vi.fn().mockResolvedValue({ + supported: true, + port: 6768, + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'domain', + inspectionAvailable: true + }) + setMobileApi({ + getWindowsFirewallStatus + }) + + render() + + await waitFor(() => expect(getWindowsFirewallStatus).toHaveBeenCalledTimes(1)) + expect(screen.queryByRole('button', { name: /allow phone connections/i })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx new file mode 100644 index 00000000000..9fd06428246 --- /dev/null +++ b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useState } from 'react' +import { CircleAlert, Loader2, ShieldCheck } from 'lucide-react' +import { toast } from 'sonner' +import type { WindowsMobileFirewallStatus } from '../../../../shared/windows-mobile-firewall' +import { useMountedRef } from '../../hooks/useMountedRef' +import { translate } from '../../i18n/i18n' +import { Button } from '../ui/button' +import { cn } from '../../lib/utils' + +type WindowsFirewallNoticeProps = { + pairingReady: boolean + address?: string + className?: string +} + +export function WindowsFirewallNotice({ + pairingReady, + address, + className +}: WindowsFirewallNoticeProps): React.JSX.Element | null { + const [status, setStatus] = useState(null) + const [repairing, setRepairing] = useState(false) + const mountedRef = useMountedRef() + + const inspect = useCallback(async () => { + if (!pairingReady) { + setStatus(null) + return + } + try { + const next = await window.api.mobile.getWindowsFirewallStatus( + address ? { address } : undefined + ) + if (mountedRef.current) { + setStatus(next) + } + } catch { + if (mountedRef.current) { + setStatus(null) + } + } + }, [address, mountedRef, pairingReady]) + + useEffect(() => { + void inspect() + window.addEventListener('focus', inspect) + return () => window.removeEventListener('focus', inspect) + }, [inspect]) + + if (!status?.supported) { + return null + } + const firewallStatus = status + const networkIsPublic = firewallStatus.networkCategory === 'public' + // Why: a Private-profile allow rule cannot help on managed domain networks. + if (!pairingReady || firewallStatus.networkCategory === 'domain') { + return null + } + if (!networkIsPublic && (firewallStatus.ruleAllowed || !firewallStatus.privateFirewallEnabled)) { + return null + } + + async function repair(): Promise { + setRepairing(true) + try { + const result = await window.api.mobile.repairWindowsFirewall() + if (!mountedRef.current) { + return + } + if (result.ok) { + setStatus({ ...firewallStatus, ruleAllowed: true }) + toast.success( + translate( + 'auto.components.mobile.WindowsFirewallNotice.repair-success', + 'Windows Firewall now allows Orca Mobile on private networks' + ) + ) + return + } + if (result.reason !== 'cancelled') { + toast.error( + translate( + 'auto.components.mobile.WindowsFirewallNotice.repair-failed', + 'Could not add the Windows Firewall rule' + ) + ) + } + } catch { + if (mountedRef.current) { + toast.error( + translate( + 'auto.components.mobile.WindowsFirewallNotice.repair-failed', + 'Could not add the Windows Firewall rule' + ) + ) + } + } finally { + if (mountedRef.current) { + setRepairing(false) + } + } + } + + return ( +
+
+ +
+
+

+ {networkIsPublic + ? translate( + 'auto.components.mobile.WindowsFirewallNotice.public-title', + 'Windows marks this network as public' + ) + : translate( + 'auto.components.mobile.WindowsFirewallNotice.missing-title', + 'Allow phone connections through Windows Firewall' + )} +

+

+ {networkIsPublic + ? translate( + 'auto.components.mobile.WindowsFirewallNotice.public-description', + 'Change this trusted Wi-Fi network to Private before allowing Orca Mobile connections.' + ) + : translate( + 'auto.components.mobile.WindowsFirewallNotice.missing-description', + 'Windows may block the pairing server. Add a rule for this Orca app and TCP port {{port}} on Private networks.', + { port: firewallStatus.port } + )} +

+
+ {networkIsPublic ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx index f8a98da9d0a..5be9359802e 100644 --- a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx +++ b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx @@ -87,11 +87,11 @@ export function OrcaProfileSwitcher({ }, [fetchProfiles, loading, profiles.length]) // Why: the Orca Cloud account UX isn't ready for production users yet, so the - // trigger stays hidden in packaged builds. Dev builds still show it when - // cloud auth is configured, and a dev-only Settings > Dev Tools > Orca Cloud - // section mirrors it. import.meta.env.PROD is true only in packaged builds - // (false in dev and under vitest), so tests still exercise the render path. - if (import.meta.env.PROD) { + // trigger stays hidden in packaged builds. Dev builds still show it when cloud + // auth is configured. electron-vite build forces NODE_ENV=production, so PROD + // is baked true even for the e2e bundle; exempt MODE==='e2e' so the switcher + // specs can exercise the render path while real packaged prod builds stay hidden. + if (import.meta.env.PROD && import.meta.env.MODE !== 'e2e') { return null } diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index 70b53d8f9eb..1ff9669a8ef 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -20,6 +20,7 @@ import { Sparkles, RefreshCw, AlertTriangle, + Bot, MoreHorizontal, Pencil, SlidersHorizontal, @@ -59,9 +60,11 @@ import { getPRCommentAudienceCounts, getPRCommentAudienceEmptyLabel, isBotPRComment, + normalizePRCommentAuthorLogin, getPrCommentAudienceFilters, type PRCommentAudienceFilter } from '@/lib/pr-comment-audience' +import { setPRBotAuthorOverride, usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' import { getPRCommentGroupId, getPRCommentGroupRoot, @@ -1478,20 +1481,27 @@ export function isMutablePRConversationComment(comment: PRComment): boolean { function CommentMoreMenu({ comment, + botAuthorOverrides, onStartEdit, onDelete, onQueueForAgent }: { comment: PRComment + botAuthorOverrides: ReadonlySet onStartEdit?: () => void onDelete?: () => void | Promise onQueueForAgent?: () => void }): React.JSX.Element | null { + const authorLogin = normalizePRCommentAuthorLogin(comment.author) + const isOverriddenBot = authorLogin.length > 0 && botAuthorOverrides.has(authorLogin) + // Why: the override is an escape hatch for bots the heuristics miss, so hide + // the action when the author is already detected as a bot without it. + const hasMarkAsBot = authorLogin.length > 0 && (isOverriddenBot || !isBotPRComment(comment)) const hasGoToComment = Boolean(comment.url) const hasEdit = Boolean(onStartEdit) const hasDelete = Boolean(onDelete) const hasQueue = Boolean(onQueueForAgent) - if (!hasGoToComment && !hasEdit && !hasDelete && !hasQueue) { + if (!hasGoToComment && !hasEdit && !hasDelete && !hasQueue && !hasMarkAsBot) { return null } @@ -1549,6 +1559,25 @@ function CommentMoreMenu({ {translate('auto.components.right.sidebar.checks.panel.content.6cc6eace26', 'Delete')} ) : null} + {hasMarkAsBot ? ( + <> + {(hasQueue || hasGoToComment || hasEdit || hasDelete) && } + setPRBotAuthorOverride(comment.author, !isOverriddenBot)} + > + + {isOverriddenBot + ? translate( + 'auto.components.right.sidebar.checks.panel.content.b3195cba33', + 'Unmark author as bot' + ) + : translate( + 'auto.components.right.sidebar.checks.panel.content.f588b46a6c', + 'Mark author as bot' + )} + + + ) : null} ) @@ -1625,6 +1654,7 @@ function PRCommentActionBadge({ /** A single comment row — used for both root and reply comments. */ function CommentRow({ comment, + botAuthorOverrides, isReply, showResolve, showReply, @@ -1641,6 +1671,7 @@ function CommentRow({ onQueueForAgent }: { comment: PRComment + botAuthorOverrides: ReadonlySet isReply: boolean showResolve: boolean showReply?: boolean @@ -1656,7 +1687,7 @@ function CommentRow({ onDeleteComment?: (comment: PRComment) => void | Promise onQueueForAgent?: () => void }): React.JSX.Element { - const automated = isBotPRComment(comment) + const automated = isBotPRComment(comment, botAuthorOverrides) const canMutateComment = isMutablePRConversationComment(comment) const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(comment.body) @@ -1761,6 +1792,7 @@ function CommentRow({ replyingGroupId: string | null selectionControl?: React.ReactNode actionState: PRCommentGroupActionState @@ -1983,6 +2017,7 @@ function PRCommentGroupView({ group.kind === 'standalone' ? presentation.groupStandalone : presentation.groupThread ) const sharedRowProps = { + botAuthorOverrides, actionState, isQueued, replyDisabled, @@ -2060,6 +2095,7 @@ function PRCommentGroupView({ function ResolvedCommentGroupsSection({ groups, + botAuthorOverrides, replyingGroupId, replyDisabled, replyDisabledReason, @@ -2072,6 +2108,7 @@ function ResolvedCommentGroupsSection({ onDeleteComment }: { groups: PRCommentGroup[] + botAuthorOverrides: ReadonlySet replyingGroupId: string | null replyDisabled?: boolean replyDisabledReason?: string @@ -2104,6 +2141,7 @@ function ResolvedCommentGroupsSection({ (null) const shouldScrollAddCommentRef = useRef(false) - const commentCounts = React.useMemo(() => getPRCommentAudienceCounts(comments), [comments]) + const botAuthorOverrides = usePRBotAuthorOverrides() + const commentCounts = React.useMemo( + () => getPRCommentAudienceCounts(comments, botAuthorOverrides), + [botAuthorOverrides, comments] + ) const { isSelectingForAI, selectedGroupIds, @@ -2219,8 +2261,8 @@ export function PRCommentsList({ toggleGroupSelection } = usePRCommentsListSelection(comments, selectionContextKey, selectionClearRequest) const visibleComments = React.useMemo( - () => filterPRCommentsByAudience(comments, commentFilter), - [commentFilter, comments] + () => filterPRCommentsByAudience(comments, commentFilter, botAuthorOverrides), + [botAuthorOverrides, commentFilter, comments] ) const groups = React.useMemo(() => groupPRComments(visibleComments), [visibleComments]) const triageGroups = React.useMemo(() => partitionPRCommentGroupsForTriage(groups), [groups]) @@ -2301,6 +2343,7 @@ export function PRCommentsList({ void +type GitStatusMetadataChangedCallback = (data: { repoId: string }) => void type HookParams = { activeRepoId: string | null activeWorktreeId: string | null @@ -12,8 +13,12 @@ type HookParams = { async function renderHookOnce(params: HookParams): Promise<{ emitWorktreesChanged: (repoId: string) => void + emitGitStatusMetadataChanged: (repoId: string) => void emitCommandFinished: (worktreeId: string) => void onChangedSubscribe: ReturnType + onGitStatusMetadataChangedSubscribe: ReturnType + onChangedUnsubscribe: ReturnType + onGitStatusMetadataChangedUnsubscribe: ReturnType windowListeners: Map cleanups: (() => void)[] }> { @@ -35,17 +40,26 @@ async function renderHookOnce(params: HookParams): Promise<{ }) let worktreesChangedCallback: WorktreesChangedCallback | null = null + let gitStatusMetadataChangedCallback: GitStatusMetadataChangedCallback | null = null const onChangedUnsubscribe = vi.fn() + const onGitStatusMetadataChangedUnsubscribe = vi.fn() const onChangedSubscribe = vi.fn((callback: WorktreesChangedCallback) => { worktreesChangedCallback = callback return onChangedUnsubscribe }) + const onGitStatusMetadataChangedSubscribe = vi.fn( + (callback: GitStatusMetadataChangedCallback) => { + gitStatusMetadataChangedCallback = callback + return onGitStatusMetadataChangedUnsubscribe + } + ) const windowListeners = new Map() vi.stubGlobal('window', { api: { worktrees: { - onChanged: onChangedSubscribe + onChanged: onChangedSubscribe, + onGitStatusMetadataChanged: onGitStatusMetadataChangedSubscribe } }, addEventListener: vi.fn((type: string, listener: EventListener) => { @@ -70,11 +84,16 @@ async function renderHookOnce(params: HookParams): Promise<{ return { emitWorktreesChanged: (repoId: string) => worktreesChangedCallback?.({ repoId }), + emitGitStatusMetadataChanged: (repoId: string) => + gitStatusMetadataChangedCallback?.({ repoId }), emitCommandFinished: (worktreeId: string) => { const listener = windowListeners.get(ORCA_TERMINAL_COMMAND_FINISHED_EVENT) listener?.({ detail: { worktreeId } } as unknown as Event) }, onChangedSubscribe, + onGitStatusMetadataChangedSubscribe, + onChangedUnsubscribe, + onGitStatusMetadataChangedUnsubscribe, windowListeners, cleanups } @@ -102,6 +121,22 @@ describe('useGitStatusPushSignalRefresh', () => { expect(fetchStatus).toHaveBeenCalledTimes(1) }) + it('nudges status when the active repo reports git status metadata changes', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + harness.emitGitStatusMetadataChanged('repo-1') + expect(fetchStatus).toHaveBeenCalledTimes(1) + + harness.emitGitStatusMetadataChanged('repo-other') + expect(fetchStatus).toHaveBeenCalledTimes(1) + }) + it('nudges status when a terminal command finishes in the active worktree', async () => { const fetchStatus = vi.fn() const harness = await renderHookOnce({ @@ -129,6 +164,7 @@ describe('useGitStatusPushSignalRefresh', () => { ;(document as unknown as { visibilityState: string }).visibilityState = 'hidden' harness.emitWorktreesChanged('repo-1') + harness.emitGitStatusMetadataChanged('repo-1') harness.emitCommandFinished('wt-1') expect(fetchStatus).not.toHaveBeenCalled() }) @@ -143,10 +179,11 @@ describe('useGitStatusPushSignalRefresh', () => { }) expect(harness.onChangedSubscribe).not.toHaveBeenCalled() + expect(harness.onGitStatusMetadataChangedSubscribe).not.toHaveBeenCalled() expect(harness.windowListeners.size).toBe(0) }) - it('unsubscribes both signals on cleanup', async () => { + it('unsubscribes preload and command-finished signals on cleanup', async () => { const fetchStatus = vi.fn() const harness = await renderHookOnce({ activeRepoId: 'repo-1', @@ -160,5 +197,7 @@ describe('useGitStatusPushSignalRefresh', () => { cleanup() } expect(harness.windowListeners.size).toBe(0) + expect(harness.onChangedUnsubscribe).toHaveBeenCalledTimes(1) + expect(harness.onGitStatusMetadataChangedUnsubscribe).toHaveBeenCalledTimes(1) }) }) diff --git a/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts index e6e0ea6d87b..45959adc74e 100644 --- a/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts @@ -33,17 +33,27 @@ export function useGitStatusPushSignalRefresh({ // Why: remote web surfaces have no preload bridge; the fallback poll // still covers them. const subscribeToWorktreesChanged = window.api?.worktrees?.onChanged - if (!subscribeToWorktreesChanged) { + const subscribeToGitStatusMetadataChanged = window.api?.worktrees?.onGitStatusMetadataChanged + if (!subscribeToWorktreesChanged && !subscribeToGitStatusMetadataChanged) { return } - // Repo metadata (HEAD/index/worktrees) changed on disk. Hidden windows - // skip the nudge; the visibility interval refreshes immediately on reveal. - return subscribeToWorktreesChanged(({ repoId }) => { + const handleRepoSignal = ({ repoId }: { repoId: string }): void => { if (repoId !== activeRepoId || !isWindowVisible()) { return } fetchStatusRef.current() - }) + } + // Repo metadata changed on disk. Hidden windows skip the nudge; the + // visibility interval refreshes immediately on reveal. + const unsubs = [ + subscribeToWorktreesChanged?.(handleRepoSignal), + subscribeToGitStatusMetadataChanged?.(handleRepoSignal) + ].filter((unsubscribe): unsubscribe is () => void => typeof unsubscribe === 'function') + return () => { + for (const unsubscribe of unsubs) { + unsubscribe() + } + } }, [enabled, activeRepoId]) useEffect(() => { diff --git a/src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx b/src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx new file mode 100644 index 00000000000..63a963c215e --- /dev/null +++ b/src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +import { join } from 'node:path' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) => + selector({ settingsSearchQuery: '' }) +})) + +import { EditorWordWrapSetting } from './EditorWordWrapSetting' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null +}) + +function renderSetting(editorWordWrap: boolean | undefined, updateSettings = vi.fn()) { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render( + + ) + }) + return { container, updateSettings } +} + +describe('EditorWordWrapSetting', () => { + it('shows wrapping as on for profiles saved before the preference existed', () => { + const { container } = renderSetting(undefined) + const on = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'On' + ) + + expect(on?.getAttribute('aria-checked')).toBe('true') + }) + + it('shows wrapping as off when the preference is disabled', () => { + const { container } = renderSetting(false) + const off = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'Off' + ) + + expect(off?.getAttribute('aria-checked')).toBe('true') + }) + + it('persists the off choice for horizontal scrolling', () => { + const updateSettings = vi.fn() + const { container } = renderSetting(true, updateSettings) + const off = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'Off' + ) + + act(() => off?.click()) + + expect(updateSettings).toHaveBeenCalledWith({ editorWordWrap: false }) + }) + + it('persists the on choice without changing the diff preference', () => { + const updateSettings = vi.fn() + const { container } = renderSetting(false, updateSettings) + const on = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'On' + ) + + act(() => on?.click()) + + expect(updateSettings).toHaveBeenCalledWith({ editorWordWrap: true }) + }) +}) diff --git a/src/renderer/src/components/settings/EditorWordWrapSetting.tsx b/src/renderer/src/components/settings/EditorWordWrapSetting.tsx new file mode 100644 index 00000000000..b3dfbb853c4 --- /dev/null +++ b/src/renderer/src/components/settings/EditorWordWrapSetting.tsx @@ -0,0 +1,69 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { SearchableSetting } from './SearchableSetting' +import { Label } from '../ui/label' +import { SettingsSegmentedControl } from './SettingsFormControls' + +type EditorWordWrapSettingProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +export function EditorWordWrapSetting({ + settings, + updateSettings +}: EditorWordWrapSettingProps): React.JSX.Element { + return ( + +
+ +

+ {translate( + 'auto.components.settings.GeneralEditorSettingsSection.9b18de6eea', + 'Wrap long lines in file editors instead of requiring horizontal scrolling.' + )} +

+
+ updateSettings({ editorWordWrap: option === 'on' })} + options={[ + { + value: 'off', + label: translate( + 'auto.components.settings.GeneralEditorSettingsSection.bf16ef0af2', + 'Off' + ) + }, + { + value: 'on', + label: translate( + 'auto.components.settings.GeneralEditorSettingsSection.3f6892f307', + 'On' + ) + } + ]} + /> +
+ ) +} diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index a4a33f1102a..9cccdf166ee 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -17,6 +17,7 @@ import { } from './SettingsFormControls' import { translate } from '@/i18n/i18n' import { RichMarkdownSpellcheckSetting } from './RichMarkdownSpellcheckSetting' +import { EditorWordWrapSetting } from './EditorWordWrapSetting' export type AutoSaveDelayDraftState = { sourceDelayMs: number @@ -248,6 +249,8 @@ export function GeneralEditorSettingsSection({ /> + + { expect(matchesSettingsSearch('wsl', entries)).toBe(true) }) + it('includes file-editor word-wrap and horizontal-scroll keywords', () => { + const entries = getGeneralPaneSearchEntries() + + expect(matchesSettingsSearch('editor word wrap', entries)).toBe(true) + expect(matchesSettingsSearch('horizontal scroll', entries)).toBe(true) + }) + it('includes rich Markdown spellcheck keywords', () => { const entries = getGeneralPaneSearchEntries() diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 62f93109850..650d42c29ad 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -11,6 +11,7 @@ import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection' import { MobilePairingQrSection } from './MobilePairingQrSection' import { MobilePairedDevicesSection, type PairedDevice } from './MobilePairedDevicesSection' import { MobileAutoRestoreFitSection } from './MobileAutoRestoreFitSection' +import { WindowsFirewallNotice } from '../mobile/WindowsFirewallNotice' import { translate } from '@/i18n/i18n' export { getMobilePaneSearchEntries } from './mobile-pane-search' @@ -182,6 +183,8 @@ export function MobilePane(): React.JSX.Element { onClearCodeCopiedTimer={clearCodeCopiedResetTimer} /> + + [ ) ] }, + { + title: translate('auto.components.settings.general.search.e61157e926', 'Editor Word Wrap'), + description: translate( + 'auto.components.settings.general.search.005be5c699', + 'Wrap long lines in file editors instead of requiring horizontal scrolling.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.general.search.e1ee631696', 'editor'), + ...translateSearchKeyword('auto.components.settings.general.search.3ca5ab78a5', 'code') + ] + }, { title: translate('auto.components.settings.general.search.2760c9933f', 'Default Diff View'), description: translate( diff --git a/src/renderer/src/components/sidebar/AutoRenameFailedDialog.test.tsx b/src/renderer/src/components/sidebar/AutoRenameFailedDialog.test.tsx new file mode 100644 index 00000000000..49e2cb52ef0 --- /dev/null +++ b/src/renderer/src/components/sidebar/AutoRenameFailedDialog.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AutoRenameFailedDialog } from './AutoRenameFailedDialog' + +const getBranchRenameFailureOutput = + vi.fn<(args: { worktreeId: string }) => Promise>() +const writeClipboardText = vi.fn<(text: string) => Promise>() + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + vi.clearAllMocks() + ;(window as unknown as { api: unknown }).api = { + worktrees: { getBranchRenameFailureOutput }, + ui: { writeClipboardText } + } + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + document.body.innerHTML = '' +}) + +const EXCERPT_ERROR = 'Pi CLI command failed with code 1: No API key found for github-copilot.' + +async function renderDialog(error = EXCERPT_ERROR): Promise { + await act(async () => { + root.render( + {}} + worktreeId="wt-1" + worktreeName="staghorn" + error={error} + /> + ) + }) +} + +describe('AutoRenameFailedDialog full output', () => { + it('shows the full CLI output fetched from main when available', async () => { + getBranchRenameFailureOutput.mockResolvedValueOnce( + 'Pi exited with code 1.\n\n[stderr]\nNo API key found for github-copilot.\nUse /login to log in.' + ) + await renderDialog() + expect(getBranchRenameFailureOutput).toHaveBeenCalledWith({ worktreeId: 'wt-1' }) + expect(document.body.textContent).toContain('[stderr]') + expect(document.body.textContent).toContain('Use /login to log in.') + }) + + it('falls back to the persisted excerpt when main holds no capture', async () => { + getBranchRenameFailureOutput.mockResolvedValueOnce(null) + await renderDialog() + expect(document.body.textContent).toContain(EXCERPT_ERROR) + }) + + it('falls back to the persisted excerpt when the fetch rejects', async () => { + getBranchRenameFailureOutput.mockRejectedValueOnce(new Error('ipc unavailable')) + await renderDialog() + expect(document.body.textContent).toContain(EXCERPT_ERROR) + }) + + it('refetches full output when a retry changes the persisted error', async () => { + getBranchRenameFailureOutput.mockResolvedValueOnce('first run full output') + await renderDialog('first run excerpt') + expect(document.body.textContent).toContain('first run full output') + + getBranchRenameFailureOutput.mockResolvedValueOnce('second run full output') + await renderDialog('second run excerpt') + + expect(getBranchRenameFailureOutput).toHaveBeenCalledTimes(2) + expect(document.body.textContent).toContain('second run full output') + expect(document.body.textContent).not.toContain('first run full output') + }) +}) diff --git a/src/renderer/src/components/sidebar/AutoRenameFailedDialog.tsx b/src/renderer/src/components/sidebar/AutoRenameFailedDialog.tsx index 12fddd8ee40..caf4a5ab5eb 100644 --- a/src/renderer/src/components/sidebar/AutoRenameFailedDialog.tsx +++ b/src/renderer/src/components/sidebar/AutoRenameFailedDialog.tsx @@ -13,24 +13,30 @@ import { translate } from '@/i18n/i18n' type AutoRenameFailedDialogProps = { open: boolean onOpenChange: (open: boolean) => void + /** Worktree whose auto-rename failed; keys the full-output lookup. */ + worktreeId: string /** Sidebar name of the worktree whose auto-rename failed. */ worktreeName: string - /** Full failure message — usually formatted agent-CLI output (multi-line). */ + /** Persisted failure message — headline plus a sanitized output excerpt. */ error: string } /** - * Modal that surfaces the full auto-rename generation failure. The message is - * raw agent-CLI output that can run many lines, so it gets a dedicated scrollable - * surface rather than a tooltip — see the sibling SshDisconnectedDialog pattern. + * Modal that surfaces the auto-rename generation failure. It shows the full + * CLI output when main still holds it (in-memory, lost on restart), falling + * back to the persisted excerpt — either can run many lines, so it gets a + * dedicated scrollable surface rather than a tooltip — see the sibling + * SshDisconnectedDialog pattern. */ export function AutoRenameFailedDialog({ open, onOpenChange, + worktreeId, worktreeName, error }: AutoRenameFailedDialogProps): React.JSX.Element { const [copied, setCopied] = useState(false) + const [fullOutput, setFullOutput] = useState(null) const copiedResetTimerRef = useRef(null) useEffect(() => { @@ -41,12 +47,37 @@ export function AutoRenameFailedDialog({ } }, []) + useEffect(() => { + if (!open) { + return + } + let stale = false + setFullOutput(null) + window.api.worktrees + .getBranchRenameFailureOutput({ worktreeId }) + .then((output) => { + if (!stale) { + setFullOutput(output) + } + }) + .catch(() => { + if (!stale) { + setFullOutput(null) + } + }) + return () => { + stale = true + } + }, [error, open, worktreeId]) + + const detailText = fullOutput ?? error + const handleCopy = useCallback(async () => { try { // Why: Electron's clipboard IPC, not navigator.clipboard, which fails // silently inside Radix dialogs — and an inline icon swap (no toast), // matching the app's other inline copy buttons. - await window.api.ui.writeClipboardText(error) + await window.api.ui.writeClipboardText(detailText) setCopied(true) if (copiedResetTimerRef.current !== null) { window.clearTimeout(copiedResetTimerRef.current) @@ -58,7 +89,7 @@ export function AutoRenameFailedDialog({ } catch { /* best-effort */ } - }, [error]) + }, [detailText]) return ( @@ -113,7 +144,7 @@ export function AutoRenameFailedDialog({ {copied ? : }
-              {error}
+              {detailText}
             
diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx index 8a17e0f05c0..117951ba045 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx @@ -105,6 +105,13 @@ export function LinearAgentSkillSetupPrompt({ const [localDismissed, setLocalDismissed] = useState(() => readLocalDismissed(localDismissStorageKey) ) + const [previousDismissStorageKey, setPreviousDismissStorageKey] = useState(localDismissStorageKey) + // Why: dismissal is scoped to the selected runtime, so read the new key before + // paint rather than briefly showing the previous runtime's prompt state. + if (localDismissStorageKey !== previousDismissStorageKey) { + setPreviousDismissStorageKey(localDismissStorageKey) + setLocalDismissed(readLocalDismissed(localDismissStorageKey)) + } const skill = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, { enabled: linked, discoveryTarget: skillDiscoveryTarget, @@ -127,10 +134,6 @@ export function LinearAgentSkillSetupPrompt({ settings, agentRuntime ) - useEffect(() => { - setLocalDismissed(readLocalDismissed(localDismissStorageKey)) - }, [localDismissStorageKey]) - const writeCliStatusIfCurrent = useCallback( (requestIdentity: string, requestGeneration: number, write: () => void): void => { if ( diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index 3b919f7f8b6..0fa5a3a0539 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -5,6 +5,8 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import type { GlobalSettings, Repo } from '../../../../shared/types' +import { i18n } from '../../i18n/i18n' +import { PSEUDO_LOCALIZATION_LOCALE } from '../../i18n/pseudo-localization' const mocks = vi.hoisted(() => ({ state: {} as Record, @@ -189,8 +191,9 @@ describe('SidebarNav', () => { document.body.innerHTML = '' }) - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() + await i18n.changeLanguage('en') setSidebarState() }) @@ -225,6 +228,31 @@ describe('SidebarNav', () => { expect(shouldShowMobileButton({ showMobileButton: false })).toBe(false) }) + it('updates localized labels when the language changes after mount', async () => { + const container = await renderSidebarNav() + + expect(queryButtonByText(container, 'Automations')).not.toBeNull() + expect(queryButtonByText(container, 'Orca Mobile')).not.toBeNull() + + await act(async () => { + await i18n.changeLanguage('zh') + }) + + expect(queryButtonByText(container, '自动化')).not.toBeNull() + expect(queryButtonByText(container, 'Orca 手机端')).not.toBeNull() + }) + + it('updates labels when pseudo-localization is enabled after mount', async () => { + const container = await renderSidebarNav() + + await act(async () => { + await i18n.changeLanguage(PSEUDO_LOCALIZATION_LOCALE) + }) + + expect(queryButtonByText(container, '[Automations]')).not.toBeNull() + expect(queryButtonByText(container, '[Orca Mobile]')).not.toBeNull() + }) + it('shows the Automations entry by default for older settings', () => { expect(shouldShowAutomationsButton(null)).toBe(true) expect(shouldShowAutomationsButton({})).toBe(true) diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index aa33aac261c..3ba594b508a 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -1,5 +1,6 @@ import React from 'react' import { Bell, CalendarClock, Search, Smartphone } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import type { GlobalSettings } from '../../../../shared/types' @@ -34,6 +35,9 @@ export function shouldShowAutomationsButton( } const SidebarNav = React.memo(function SidebarNav() { + // Why: this memo boundary needs its own language subscription, while + // translate() preserves Orca's pseudo-localization behavior. + useTranslation() const worktreePaletteShortcutCombos = useShortcutKeyComboDetails('worktree.palette') const openAutomationsPage = useAppStore((s) => s.openAutomationsPage) const openActivityPage = useAppStore((s) => s.openActivityPage) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 20cf2861051..d49d080bee6 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -1941,6 +1941,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index 96b54f9a5c8..be04dd3e90d 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -188,6 +188,21 @@ describe('resolveAttention', () => { }) }) + it('uses a reset stateStartedAt for Command Code new prompts while still working', () => { + const entry = makeEntry({ + paneKey: 't:1', + state: 'working', + agentType: 'command-code', + stateStartedAt: NOW - 2_000, + updatedAt: NOW - 500, + stateHistory: [makeHistory('done', NOW - 30 * 60_000)] + }) + expect(resolveAttention([hookPane(entry)], NOW)).toEqual({ + cls: 3, + attentionTimestamp: NOW - 2_000 + }) + }) + it('falls back to current stateStartedAt when working has no prior attention history', () => { const entry = makeEntry({ paneKey: 't:1', diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index ae0d7542da3..8b47216cfa2 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -152,7 +152,18 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten // been working for an hour. Falls back to the current stateStartedAt // when stateHistory is empty (e.g. fresh after restart). const prior = mostRecentAttentionInHistory(entry.stateHistory) - ts = prior ?? entry.stateStartedAt + if (prior === null) { + ts = entry.stateStartedAt + } else if (entry.agentType === 'command-code') { + // Why: Command Code has no UserPromptSubmit hook, so a new prompt while + // still `working` only advances stateStartedAt (no new history row). It + // must beat the stale prior-attention timestamp. Other agents keep the + // prior-attention ordering — their real state transitions already mark + // the turn boundary, so scoping avoids reordering them. + ts = Math.max(prior, entry.stateStartedAt) + } else { + ts = prior + } } } else { // Title-heuristic fallback (no fresh hook entry for this pane). Hook diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx index ffd81e96c09..437e2c2b1e6 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx @@ -88,6 +88,26 @@ afterEach(() => { }) describe('TabBarCreateEntry keyboard navigation', () => { + it('publishes the query from the typing event without an extra effect commit', () => { + const onQueryChange = vi.fn() + mount( + + ) + + expect(onQueryChange).not.toHaveBeenCalled() + + setQuery('src/app.ts') + + expect(onQueryChange).toHaveBeenCalledTimes(1) + expect(onQueryChange).toHaveBeenCalledWith('src/app.ts') + }) + it('intercepts ArrowDown on a single-option list so it does not leak (guards >0 vs >1)', () => { entryOptionsMock.options = [fileOption('src/only-match.ts')] const onOpenEntry = vi.fn().mockResolvedValue(undefined) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index 619e906df18..dd2772f36ea 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -117,10 +117,6 @@ export default function TabBarCreateEntry({ [agentOptions, query] ) - useEffect(() => { - onQueryChange?.(query) - }, [onQueryChange, query]) - if (selectedIndexQuery !== query) { setSelectedIndexQuery(query) if (selectedIndex !== 0) { @@ -249,7 +245,11 @@ export default function TabBarCreateEntry({ ref={inputRef} value={query} onChange={(event) => { - setQuery(event.target.value) + const nextQuery = event.target.value + // Why: the parent query only changes in response to typing, so publish + // it in this event rather than a later effect after the render commits. + setQuery(nextQuery) + onQueryChange?.(nextQuery) setError(null) }} disabled={disabled} diff --git a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx index bd18107ac73..d2b8efd3efa 100644 --- a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx +++ b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx @@ -88,4 +88,32 @@ describe('CloseTerminalDialog', () => { expect(onConfirm).toHaveBeenCalledWith(true) }) + + it('resets the skip preference when the dialog closes and reopens', async () => { + const onConfirm = vi.fn() + const onCancel = vi.fn() + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + const render = async (open: boolean): Promise => { + await act(async () => { + root.render() + }) + } + + await render(true) + const checkbox = document.body.querySelector('[role="checkbox"]') + await act(async () => { + checkbox?.click() + }) + expect(checkbox?.getAttribute('aria-checked')).toBe('true') + + await render(false) + await render(true) + + expect(document.body.querySelector('[role="checkbox"]')?.getAttribute('aria-checked')).toBe( + 'false' + ) + }) }) diff --git a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx index a1f5c371e8d..771d5e15e77 100644 --- a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx +++ b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useState } from 'react' +import { useId, useState } from 'react' import { Dialog, DialogContent, @@ -27,12 +27,16 @@ export default function CloseTerminalDialog({ }): React.JSX.Element { const checkboxId = useId() const [dontAskAgain, setDontAskAgain] = useState(false) + const [previousOpen, setPreviousOpen] = useState(open) - useEffect(() => { + // Why: each reopen represents a fresh confirmation, so clear the old choice + // during render rather than briefly painting it while the dialog opens. + if (open !== previousOpen) { + setPreviousOpen(open) if (open) { setDontAskAgain(false) } - }, [open]) + } const isAgent = copyKind === 'agent' diff --git a/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.tsx b/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.tsx index d8b9ea64678..9416ba0d30f 100644 --- a/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.tsx +++ b/src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useState } from 'react' +import { useId, useState } from 'react' import { Dialog, DialogContent, @@ -23,14 +23,18 @@ export default function PinnedTabCloseDialog(): React.JSX.Element { const dismissPinnedTabClose = useAppStore((state) => state.dismissPinnedTabClose) const updateSettings = useAppStore((state) => state.updateSettings) const [dontAskAgain, setDontAskAgain] = useState(false) + const [previousRequest, setPreviousRequest] = useState(request) const tabLabel = request?.tabLabel.trim() - useEffect(() => { + // Why: a new store request is a new confirmation, so reset its checkbox before + // paint while keeping a cancelled request's state inert until the next open. + if (request !== previousRequest) { + setPreviousRequest(request) if (request !== null) { setDontAskAgain(false) } - }, [request]) + } const handleConfirm = (): void => { if (dontAskAgain) { diff --git a/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts new file mode 100644 index 00000000000..f38f3bda8bc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts @@ -0,0 +1,128 @@ +import type { IBufferLine } from '@xterm/xterm' +import { describe, expect, it, vi } from 'vitest' +import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links' +import { + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS, + TERMINAL_HTTP_URL_MAX_LENGTH +} from './terminal-http-link-limits' + +function bufferLine(text: string, onTranslateWithColumns: () => void): IBufferLine { + return { + isWrapped: false, + length: text.length, + translateToString: ( + _trimRight?: boolean, + startColumn = 0, + endColumn = text.length, + outColumns?: number[] + ) => { + if (outColumns) { + onTranslateWithColumns() + } + outColumns?.push(...Array.from({ length: text.length + 1 }, (_value, index) => index)) + return text.slice(startColumn, endColumn) + } + } as IBufferLine +} + +function bufferLineWithCellColumns(text: string): IBufferLine { + const columns: number[] = [] + let column = 0 + for (const character of text) { + columns.push(column) + column += character === '你' ? 2 : 1 + } + columns.push(column) + return { + isWrapped: false, + length: column, + translateToString: ( + _trimRight?: boolean, + startColumn = 0, + endColumn = text.length, + outColumns?: number[] + ) => { + outColumns?.push(...columns) + return text.slice(startColumn, endColumn) + } + } as IBufferLine +} + +describe('hard-wrapped terminal HTTP candidate bounds', () => { + it('builds each required column map once and rejects an overlength candidate early', () => { + const onTranslateWithColumns = vi.fn() + const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, (_value, index) => + bufferLine(`| ${index === 0 ? 'http://x' : 'aaaaaaaa'} |`, onTranslateWithColumns) + ) + + expect( + buildHardWrappedHttpLogicalLineCandidates( + { getLine: (y) => rows[y] }, + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS + ) + ).toEqual([]) + expect(onTranslateWithColumns).toHaveBeenCalledTimes( + Math.floor(TERMINAL_HTTP_URL_MAX_LENGTH / 'aaaaaaaa'.length) + 1 + ) + }) + + it('does not build column maps while rejecting rows without an HTTP scheme', () => { + const onTranslateWithColumns = vi.fn() + const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, () => + bufferLine(`| ${'a'.repeat(500)} |`, onTranslateWithColumns) + ) + + expect( + buildHardWrappedHttpLogicalLineCandidates( + { getLine: (y) => rows[y] }, + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS + ) + ).toEqual([]) + expect(onTranslateWithColumns).not.toHaveBeenCalled() + }) + + it('reconstructs framed URL rows aligned by cells when a fragment has wide characters', () => { + const rowTexts = ['|http://a/ab|', '|a你你你你你|', '|tailzzzzzzz|'] + const rows = rowTexts.map(bufferLineWithCellColumns) + + const candidates = buildHardWrappedHttpLogicalLineCandidates( + { getLine: (y) => rows[y] }, + rows.length + ) + + expect(candidates[0]?.text).toBe('http://a/aba你你你你你tailzzzzzzz') + }) + + it('reconstructs a maximum-length wide-character URL beyond the ASCII row bound', () => { + const scheme = 'http://' + const continuation = '你'.repeat(TERMINAL_HTTP_URL_MAX_LENGTH - scheme.length) + const rows = [ + bufferLineWithCellColumns(`|${scheme}|`), + ...continuation + .match(/.{1,3}/g)! + .map((fragment) => + bufferLineWithCellColumns(`|${fragment}${' '.repeat(7 - fragment.length * 2)}|`) + ) + ] + + expect(rows.length).toBeGreaterThan(Math.ceil(TERMINAL_HTTP_URL_MAX_LENGTH / scheme.length)) + expect( + buildHardWrappedHttpLogicalLineCandidates({ getLine: (y) => rows[y] }, rows.length)[0]?.text + ).toBe(scheme + continuation) + }) + + it('reconstructs a maximum-length URL with one-character continuation rows', () => { + const firstFragment = 'http://a' + const continuation = '/'.repeat(TERMINAL_HTTP_URL_MAX_LENGTH - firstFragment.length) + const rows = [ + bufferLineWithCellColumns(`|${firstFragment}|`), + ...continuation.split('').map((fragment) => bufferLineWithCellColumns(`|${fragment} |`)) + ] + const fullUrl = firstFragment + continuation + + expect(() => new URL(fullUrl)).not.toThrow() + expect( + buildHardWrappedHttpLogicalLineCandidates({ getLine: (y) => rows[y] }, rows.length)[0]?.text + ).toBe(fullUrl) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.ts b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.ts new file mode 100644 index 00000000000..30b1d3a9f3c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.ts @@ -0,0 +1,148 @@ +import type { IBufferLine } from '@xterm/xterm' +import { + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS, + TERMINAL_HTTP_URL_MAX_LENGTH +} from './terminal-http-link-limits' +import { translateLineWithColumns, type WrappedLogicalLine } from './wrapped-terminal-link-ranges' + +const HTTP_SCHEME_PATTERN = /https?:\/\//i +const HTTP_SCHEME_START_PATTERN = /^https?:\/\//i +const HTTP_FRAGMENT_PATTERN = /^[^\s"'!*(){}|\\^<>`│┃║╎╏┆┇┊┋]*/ +const VERTICAL_LAYOUT_FRAME_PATTERN = /[│┃║╎╏┆┇┊┋|]/ +const NON_LAYOUT_SUFFIX_PATTERN = /[^\s│┃║╎╏┆┇┊┋|]/ +const HARD_WRAP_CONTINUATION_SUFFIX_PATTERN = /[/?&=#%+:-]$/ +const MIN_HARD_WRAPPED_HTTP_ROWS = 3 +const MIN_HARD_WRAP_FILL_RATIO = 0.8 + +type TranslatedLine = ReturnType + +function buildCandidateFromStart( + buffer: { getLine(y: number): IBufferLine | undefined }, + startY: number, + currentY: number, + translatedLines: Map +): WrappedLogicalLine | null { + const startLine = buffer.getLine(startY) + if (!startLine) { + return null + } + const cachedStart = translatedLines.get(startY) + const startText = cachedStart?.text ?? startLine.translateToString(false) + const schemeIndex = startText.search(HTTP_SCHEME_PATTERN) + if (schemeIndex === -1 || !VERTICAL_LAYOUT_FRAME_PATTERN.test(startText.slice(0, schemeIndex))) { + return null + } + const translatedStart = cachedStart ?? translateLineWithColumns(startLine) + translatedLines.set(startY, translatedStart) + const schemeColumn = translatedStart.columns[schemeIndex] + if (schemeColumn === undefined) { + return null + } + + const continuationPrefix = translatedStart.text.slice(0, schemeIndex) + let text = '' + let rightFrameColumn: number | null = null + let previousRowCanContinue = true + let startRowFilled = false + const rows: WrappedLogicalLine['rows'] = [] + + for (let rowY = startY; rowY < startY + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS; rowY++) { + if (rowY > startY && !previousRowCanContinue) { + break + } + const line = buffer.getLine(rowY) + if (!line) { + break + } + const translated = + rowY === startY + ? translatedStart + : (translatedLines.get(rowY) ?? translateLineWithColumns(line)) + translatedLines.set(rowY, translated) + if (rowY > startY && translated.text.slice(0, schemeIndex) !== continuationPrefix) { + break + } + + const fragment = translated.text.slice(schemeIndex).match(HTTP_FRAGMENT_PATTERN)?.[0] ?? '' + if (!fragment || (rowY > startY && HTTP_SCHEME_START_PATTERN.test(fragment))) { + break + } + const fragmentEnd = schemeIndex + fragment.length + const layoutSuffix = translated.text.slice(fragmentEnd) + const rightFrameOffset = layoutSuffix.search(VERTICAL_LAYOUT_FRAME_PATTERN) + const currentRightFrameIndex = rightFrameOffset === -1 ? -1 : fragmentEnd + rightFrameOffset + const currentRightFrameColumn = translated.columns[currentRightFrameIndex] + if ( + currentRightFrameIndex === -1 || + currentRightFrameColumn === undefined || + (rightFrameColumn !== null && currentRightFrameColumn !== rightFrameColumn) || + NON_LAYOUT_SUFFIX_PATTERN.test(layoutSuffix) + ) { + break + } + rightFrameColumn ??= currentRightFrameColumn + + if (text.length + fragment.length > TERMINAL_HTTP_URL_MAX_LENGTH) { + return null + } + + rows.push({ + y: rowY, + text: fragment, + sourceText: translated.text, + columns: translated.columns.slice(schemeIndex, fragmentEnd + 1), + startIndex: text.length, + isWrapped: line.isWrapped, + lineLength: line.length + }) + text += fragment + + const contentWidth = currentRightFrameColumn - schemeColumn + const fragmentWidth = translated.columns[fragmentEnd]! - schemeColumn + const fillsRow = contentWidth > 0 && fragmentWidth / contentWidth >= MIN_HARD_WRAP_FILL_RATIO + if (rowY === startY) { + startRowFilled = fillsRow + } + previousRowCanContinue = HARD_WRAP_CONTINUATION_SUFFIX_PATTERN.test(fragment) || fillsRow + } + + if (rows.length > 1 && (rows.length < MIN_HARD_WRAPPED_HTTP_ROWS || !startRowFilled)) { + // Why: a short complete URL can legitimately end in `/`; one adjacent + // framed token is not enough evidence that the URL continued onto it. + rows.splice(1) + text = rows[0]?.text ?? '' + } + if (rows.at(-1)?.y === undefined || rows.at(-1)!.y < currentY) { + return null + } + return { + text, + rows, + fingerprint: `hard-http:${rows.map((row) => `${row.y}:${row.sourceText}`).join('\0')}` + } +} + +export function buildHardWrappedHttpLogicalLineCandidates( + buffer: { getLine(y: number): IBufferLine | undefined }, + bufferLineNumber: number +): WrappedLogicalLine[] { + const currentY = bufferLineNumber - 1 + const currentLine = buffer.getLine(currentY) + if (!currentLine || !VERTICAL_LAYOUT_FRAME_PATTERN.test(currentLine.translateToString(false))) { + // Why: cursor-positioned hard wraps require a stable vertical frame; most + // terminal clicks can avoid the bounded backward scan entirely. + return [] + } + const candidates: WrappedLogicalLine[] = [] + const translatedLines = new Map() + const minY = Math.max(0, currentY - TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS + 1) + for (let startY = currentY; startY >= minY; startY--) { + const candidate = buildCandidateFromStart(buffer, startY, currentY, translatedLines) + if (candidate) { + candidates.push(candidate) + } + } + return candidates.sort( + (left, right) => right.rows.length - left.rows.length || right.text.length - left.text.length + ) +} diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts index 8281975bcf8..e2d0dfca565 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts @@ -4,6 +4,7 @@ import { FIND_QUERY_MAX_BYTES } from '@/lib/find-query-bounds' import { matchFileSearchShortcut, matchSearchNavigate, + resolveTerminalKeyboardShortcutAction, runTerminalSearchNavigation } from './keyboard-handlers' @@ -91,6 +92,26 @@ describe('matchSearchNavigate', () => { }) }) +describe('resolveTerminalKeyboardShortcutAction', () => { + it('routes macOS Shift+Enter with the active Windows PTY host bytes', () => { + expect( + resolveTerminalKeyboardShortcutAction( + makeKeyEvent({ key: 'Enter', shiftKey: true }), + true, + 'false', + 0, + false, + undefined, + undefined, + undefined, + undefined, + () => 'alt-enter', + () => true + ) + ).toEqual({ type: 'sendInput', data: '\x1b\r' }) + }) +}) + describe('runTerminalSearchNavigation', () => { const searchState = { query: 'hello', caseSensitive: true, regex: false } diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index c41a0ea919d..61a092fd56f 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -31,12 +31,43 @@ import { recordTerminalUserInputForLeaf } from './terminal-input-activity' import { isLocalWindowsConptyPaneForCtrlArrow } from './terminal-ctrl-arrow-conpty' import { makePaneKey } from '../../../../shared/stable-pane-id' import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter' +import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform' import { markTerminalFollowOutput, markTerminalPinnedViewport, syncTerminalScrollIntentFromViewport } from '@/lib/pane-manager/terminal-scroll-intent' +export function resolveTerminalKeyboardShortcutAction( + event: Parameters[0], + isMac: Parameters[1], + macOptionAsAlt: Parameters[2], + optionKeyLocation: Parameters[3], + isWindows: Parameters[4], + keybindings: Parameters[5], + isLocalWindowsConptyPane: Parameters[6], + isKittyKeyboardActivePane: Parameters[7], + layoutBaseCharacterForCode: Parameters[8], + getWindowsShiftEnterEncoding: Parameters[9], + isWindowsTerminalHost: NonNullable[10]> +): ReturnType { + // Why: keep the host callback required at the production boundary so a + // caller cannot silently fall back to client-OS byte routing. + return resolveTerminalShortcutAction( + event, + isMac, + macOptionAsAlt, + optionKeyLocation, + isWindows, + keybindings, + isLocalWindowsConptyPane, + isKittyKeyboardActivePane, + layoutBaseCharacterForCode, + getWindowsShiftEnterEncoding, + isWindowsTerminalHost + ) +} + export function recordKeyboardCreatedTerminalPaneSplit( createdPane: unknown, args: { @@ -253,6 +284,21 @@ export function useTerminalKeyboardShortcuts({ return resolveWindowsShiftEnterEncodingForPane(state, paneKey) } + // Why: host metadata is live and can hydrate after the terminal mounts; + // resolve it only when Shift+Enter needs to choose a byte protocol. + const isActivePaneWindowsTerminalHost = (): boolean => { + const manager = managerRef.current + const activePane = manager?.getActivePane() ?? manager?.getPanes()[0] + return ( + resolveTerminalInputHostPlatform({ + clientPlatform: shortcutPlatform, + state: useAppStore.getState(), + worktreeId, + transport: activePane ? (paneTransportsRef.current.get(activePane.id) ?? null) : null + }) === 'win32' + ) + } + const onKeyDown = (e: KeyboardEvent): void => { const manager = managerRef.current if (!manager) { @@ -335,7 +381,7 @@ export function useTerminalKeyboardShortcuts({ return (paneKittyKeyboardModesRef?.current.get(activePane.id)?.flags ?? 0) > 0 } - const action = resolveTerminalShortcutAction( + const action = resolveTerminalKeyboardShortcutAction( e, isMac, macOptionAsAltRef.current, @@ -345,7 +391,8 @@ export function useTerminalKeyboardShortcuts({ isLocalWindowsConptyPane, isKittyKeyboardActivePane, getLayoutBaseCharacterForCode, - getActivePaneWindowsShiftEnterEncoding + getActivePaneWindowsShiftEnterEncoding, + isActivePaneWindowsTerminalHost ) if (!action) { return diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts index efdac51caef..185cc6460e6 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts @@ -1,4 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import en from '@/i18n/locales/en.json' +import es from '@/i18n/locales/es.json' +import ja from '@/i18n/locales/ja.json' +import ko from '@/i18n/locales/ko.json' +import zh from '@/i18n/locales/zh.json' import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor' import type * as Osc52ClipboardBlockedToastModule from './osc52-clipboard-blocked-toast' @@ -41,6 +46,7 @@ describe('showOsc52ClipboardBlockedToast', () => { showOsc52ClipboardBlockedToast() + expect(toastInfoMock.mock.calls[0]?.[1]?.description).toContain('Grok') const options = toastInfoMock.mock.calls[0]?.[1] expect(options).toMatchObject({ action: { @@ -67,4 +73,14 @@ describe('showOsc52ClipboardBlockedToast', () => { expect(toastInfoMock).toHaveBeenCalledTimes(1) }) + + it('mentions Grok in every supported locale', () => { + const locales = [en, es, ja, ko, zh] + + for (const locale of locales) { + expect( + locale.auto.components.terminal.pane.osc52.clipboard.blocked.toast['7cf51f74fd'] + ).toContain('Grok') + } + }) }) diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts index 127f40f7a99..fb0b5d73a60 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts @@ -19,7 +19,7 @@ export function showOsc52ClipboardBlockedToast(): void { { description: translate( 'auto.components.terminal.pane.osc52.clipboard.blocked.toast.7cf51f74fd', - 'Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, or fzf.' + 'Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, fzf, or Grok.' ), duration: 12_000, action: { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ae29c1e3589..6c72e259c72 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4593,7 +4593,7 @@ describe('connectPanePty', () => { } }) - it('pastes a startup draft when Codex renders its composer in the first observed output', async () => { + it('orders a startup draft behind xterm focus input when Codex renders its composer', async () => { const { connectPanePty } = await import('./pty-connection') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -4626,13 +4626,27 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(capturedDataCallback.current).not.toBeNull() + // A focused xterm emits CSI I after Codex enables focus reporting. The + // startup draft must use the same transport instead of racing a direct IPC. + ;( + pane.terminal.onData as unknown as { + mock: { calls: [(data: string) => void][] } + } + ).mock.calls[0]?.[0]('\x1b[I') + ;(mockStoreState.recordTerminalInput as ReturnType).mockClear() capturedDataCallback.current?.('\x1b[?2004h\x1b[2K› ') await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledWith( - 'pty-codex', + expect(transport.sendInputAccepted).toHaveBeenCalledWith( '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~' ) + expect(transport.sendInput.mock.calls.map(([data]) => data)).toEqual([ + '\x1b[I', + '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~' + ]) + expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() + expect(mockStoreState.recordTerminalInput).toHaveBeenCalledOnce() + expect(mockStoreState.recordTerminalInput).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1)) }) it('does not consume startup draft delivery before deferred connect starts', async () => { @@ -13412,6 +13426,9 @@ describe('connectPanePty', () => { it('attaches remote runtime PTY handles instead of creating a replacement terminal', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() + transport.attach.mockImplementation(() => { + transport.getPtyId.mockReturnValue('remote:env-1@@terminal-1') + }) transportFactoryQueue.push(transport) mockStoreState = { @@ -13435,7 +13452,8 @@ describe('connectPanePty', () => { expect(transport.attach).toHaveBeenCalledWith( expect.objectContaining({ existingPtyId: 'remote:terminal-1' }) ) - expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:terminal-1') + expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1') }) it('cold-spawns slept remote runtime PTYs instead of reattaching the preserved handle', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 19e486b542c..4b3cdb90ed9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -219,6 +219,7 @@ import { isWslUncPath } from '../../../../shared/wsl-paths' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { createDraftPasteReadyScanner } from '../../../../shared/draft-paste-ready-scanner' import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content' +import { writeTerminalPastePtyInput } from './terminal-pty-paste-writer' import { beginAgentStartupDeliveryAttempt, releaseAgentStartupDeliveryAttempt @@ -3942,6 +3943,7 @@ export function connectPanePty( let startupDraftReadinessArmed = false let startupDraftPasteSettled = !ownsStartupDraftPaste let startupDraftPasteInFlight = false + let startupDraftInputRecorded = false let startupDraftQuietTimer: ReturnType | null = null let startupDraftHardTimer: ReturnType | null = null const clearStartupDraftPasteTimers = (): void => { @@ -3985,7 +3987,18 @@ export function connectPanePty( startupDraftPasteAttempted = true cleanupStartupDraftPasteTimers() const settings = getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), deps.worktreeId) - void sendAgentDraftPasteContent(settings, ptyId, startupDraftPrompt) + // Why: xterm focus reports share this transport queue. Bypassing it can + // race CSI I against the draft on ConPTY and expose a literal `[I` prefix. + void sendAgentDraftPasteContent(settings, ptyId, startupDraftPrompt, async (data) => { + const accepted = await writeTerminalPastePtyInput(transport, data) + if (accepted && !startupDraftInputRecorded) { + // Why: this transport write bypasses xterm's user-input signal; keep + // the composed draft from being discarded by later hibernation. + startupDraftInputRecorded = true + recordTerminalInputForHibernation() + } + return accepted + }) .catch(() => false) .finally(() => { startupDraftPasteInFlight = false @@ -7156,12 +7169,13 @@ export function connectPanePty( onError: reportError } }) - bindActivePanePty(attachPtyId, { + const attachedPtyId = transport.getPtyId() ?? attachPtyId + bindActivePanePty(attachedPtyId, { updateTabPtyId: 'if-missing', sampleVisibleForegroundAgent: true }) if (attachPtyId === eagerLivePtyId) { - registerPaneSerializerFor(attachPtyId) + registerPaneSerializerFor(attachedPtyId) } } catch (err) { reportError(err instanceof Error ? err.message : String(err)) @@ -7211,9 +7225,10 @@ export function connectPanePty( onError: reportError } }) + const attachedPtyId = transport.getPtyId() ?? spawnedPtyId // Why: this path reuses a PTY spawned by an earlier mount, so no // later spawn event will bind this remounted pane's DOM/container. - bindActivePanePty(spawnedPtyId, { + bindActivePanePty(attachedPtyId, { updateTabPtyId: 'if-missing', sampleVisibleForegroundAgent: true }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index bb756c3039f..94042a34043 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -32,7 +32,10 @@ export type PtyBufferSnapshot = { pendingEscapeTailAnsi?: string } -export type LocalPtySessionMetadata = { cwd?: string; shellOverride?: string } +export type LocalPtySessionMetadata = { + cwd?: string + shellOverride?: string +} export type PtyConnectResult = { id: string @@ -116,6 +119,9 @@ export type PtyTransport = { isConnected: () => boolean getPtyId: () => string | null getConnectionId?: () => string | null | undefined + /** The runtime captured by this transport; legacy remote PTY ids do not + * encode their owner, and current worktree settings may have changed. */ + getRuntimeEnvironmentId?: () => string | null getLocalSessionMetadata?: () => LocalPtySessionMetadata | null /** Drop cross-chunk parser carries (partial OSC-9999 prefix). Called when a * model-restore marker reports dropped bytes — a carry spanning the gap diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 4f8fe658f01..67bb809a4d4 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -238,7 +238,18 @@ describe('createIpcPtyTransport', () => { const { createIpcPtyTransport } = await import('./pty-transport') const localTransport = createIpcPtyTransport({ cwd: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo', - shellOverride: 'wsl.exe' + shellOverride: 'wsl.exe', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo:wsl' + } + } }) const sshTransport = createIpcPtyTransport({ connectionId: 'ssh-1', @@ -253,6 +264,51 @@ describe('createIpcPtyTransport', () => { expect(sshTransport.getLocalSessionMetadata?.()).toBeNull() }) + it('keeps captured Windows and WSL metadata when existing PTYs reattach', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const currentWslForWindowsPty = createIpcPtyTransport({ + cwd: 'C:\\repo', + shellOverride: 'pwsh.exe', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo:wsl' + } + } + }) + const currentWindowsForWslPty = createIpcPtyTransport({ + cwd: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo', + shellOverride: 'wsl.exe', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo', + reason: 'project-override', + cacheKey: 'repo:windows' + } + } + }) + + currentWslForWindowsPty.attach({ existingPtyId: 'windows-pty', callbacks: {} }) + currentWindowsForWslPty.attach({ existingPtyId: 'wsl-pty', callbacks: {} }) + + expect(currentWslForWindowsPty.getLocalSessionMetadata?.()).toEqual({ + cwd: 'C:\\repo', + shellOverride: 'pwsh.exe' + }) + expect(currentWindowsForWslPty.getLocalSessionMetadata?.()).toEqual({ + cwd: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo', + shellOverride: 'wsl.exe' + }) + }) + it('sends the missing-cwd fallback flag only for local IPC spawns', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index c92e6e834b7..b2eccac5cbd 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -1013,8 +1013,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra if (connectionId) { return null } - // Why: paste/runtime diagnostics must follow the launched PTY session, - // not later project setting changes. + // Why: input routing and diagnostics must follow the launched PTY + // session, not later project setting changes. return { ...(cwd ? { cwd } : {}), ...(shellOverride ? { shellOverride } : {}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 85b075ef24a..1fc8a2148a9 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -167,7 +167,8 @@ describe('createRemoteRuntimePtyTransport', () => { }) expect(onError).not.toHaveBeenCalled() - expect(transport.getPtyId()).toBe('remote:terminal-1') + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1') + expect(transport.getRuntimeEnvironmentId?.()).toBe('env-1') await vi.waitFor(() => expect(latestSubscribePayload().capabilities).toEqual({ ackOutput: 1, @@ -190,6 +191,18 @@ describe('createRemoteRuntimePtyTransport', () => { }) }) + it('scopes the same legacy handle independently for each runtime environment', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const first = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' }) + const second = createRemoteRuntimePtyTransport('env-2', { worktreeId: 'wt-2' }) + + first.attach({ existingPtyId: 'remote:terminal-1', callbacks: {} }) + second.attach({ existingPtyId: 'remote:terminal-1', callbacks: {} }) + + expect(first.getPtyId()).toBe('remote:env-1@@terminal-1') + expect(second.getPtyId()).toBe('remote:env-2@@terminal-1') + }) + it('parks passive peers when another remote desktop owns the grid', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const { getFitOverrideForPty, setFitOverride } = diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 727b54e2683..6c8849a10c6 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -706,7 +706,9 @@ export function createRemoteRuntimePtyTransport( storedCallbacks.onError?.('Remote runtime terminal id is invalid.') return } - remotePtyId = options.existingPtyId + // Why: legacy restored ids omitted their runtime owner. Canonicalize at + // attach so renderer stores and lifecycle guards never share raw aliases. + remotePtyId = toRemoteRuntimePtyId(handle, currentRuntimeEnvironmentId) connected = true desiredViewport = { cols: options.cols ?? 80, @@ -857,6 +859,10 @@ export function createRemoteRuntimePtyTransport( return null }, + getRuntimeEnvironmentId() { + return currentRuntimeEnvironmentId + }, + async serializeBuffer(opts) { if (!connected || !handle) { return null diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts new file mode 100644 index 00000000000..ca11b713450 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('isTerminalHttpLinkActivation', () => { + it('leaves Alt+Cmd gestures to the child TUI on macOS', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + + expect( + isTerminalHttpLinkActivation({ metaKey: true, ctrlKey: false, altKey: false } as MouseEvent) + ).toBe(true) + expect( + isTerminalHttpLinkActivation({ metaKey: true, ctrlKey: false, altKey: true } as MouseEvent) + ).toBe(false) + }) + + it('leaves Alt+Ctrl gestures to the child TUI on other platforms', () => { + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + + expect( + isTerminalHttpLinkActivation({ metaKey: false, ctrlKey: true, altKey: false } as MouseEvent) + ).toBe(true) + expect( + isTerminalHttpLinkActivation({ metaKey: false, ctrlKey: true, altKey: true } as MouseEvent) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts new file mode 100644 index 00000000000..c9e941c874d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts @@ -0,0 +1,7 @@ +import { isTerminalLinkActivation } from './terminal-link-activation' + +export function isTerminalHttpLinkActivation(event: MouseEvent | undefined): boolean { + // Why: xterm deliberately forwards Alt-modified mouse gestures to the PTY, + // so plain HTTP link handling must leave those gestures to the child TUI. + return Boolean(event && !event.altKey && isTerminalLinkActivation(event)) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-limits.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-limits.ts new file mode 100644 index 00000000000..52cc2cf1e93 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-limits.ts @@ -0,0 +1,5 @@ +export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048 + +// Why: punctuation can form a valid one-character continuation row, so the +// URL length limit is also the only complete finite bound on framed rows. +export const TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS = TERMINAL_HTTP_URL_MAX_LENGTH diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts index c0112fa2c41..a9abcf86dfe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts @@ -3,6 +3,7 @@ import { armTerminalImePendingCandidateKeyRelease, clearTerminalImePendingCandidateKeyRelease, createTerminalImePendingCandidateKeyReleases, + isTerminalImeCandidateDigitKeyEvent, isTerminalImeCandidateSelectionKeyEvent, shouldApplyTerminalImePendingCandidateKeyRelease } from './terminal-ime-candidate-key-release-guard' @@ -19,6 +20,12 @@ describe('terminal IME candidate key release guard', () => { expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ', shiftKey: true }))).toBe(false) }) + it('recognizes unmodified digits but not Space as digit candidate selectors', () => { + expect(isTerminalImeCandidateDigitKeyEvent(event({ key: '2' }))).toBe(true) + expect(isTerminalImeCandidateDigitKeyEvent(event({ key: ' ' }))).toBe(false) + expect(isTerminalImeCandidateDigitKeyEvent(event({ key: '2', ctrlKey: true }))).toBe(false) + }) + it('arms a pending release guard from a suppressed candidate keydown', () => { const releases = createTerminalImePendingCandidateKeyReleases() armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts index 210d8de22d2..337a3c23682 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts @@ -22,6 +22,7 @@ const TERMINAL_IME_CANDIDATE_SELECTION_KEYS = new Set([ '8', '9' ]) +const TERMINAL_IME_CANDIDATE_DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']) function isTerminalImeCandidateSelectionKey(key: string): boolean { return TERMINAL_IME_CANDIDATE_SELECTION_KEYS.has(key) @@ -36,6 +37,13 @@ export function isTerminalImeCandidateSelectionKeyEvent(event: XtermBypassEvent) return isTerminalImeCandidateSelectionKey(event.key) } +/** Returns whether an event is an unmodified IME candidate digit selector. */ +export function isTerminalImeCandidateDigitKeyEvent(event: XtermBypassEvent): boolean { + return ( + isTerminalImeCandidateSelectionKeyEvent(event) && TERMINAL_IME_CANDIDATE_DIGITS.has(event.key) + ) +} + export function createTerminalImePendingCandidateKeyReleases(): TerminalImePendingCandidateKeyReleases { return new Map() } diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts new file mode 100644 index 00000000000..2aa8f2a0aff --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createTerminalImeLinuxCandidateState, + installTerminalImeLinuxCandidateState +} from './terminal-ime-linux-candidate-state' +import type { XtermBypassEvent } from './xterm-bypass-policy' + +/** Creates a terminal keyboard event with default modifier state. */ +function event(overrides: Partial): XtermBypassEvent { + return { + type: 'keydown', + key: '', + code: '', + defaultPrevented: false, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...overrides + } +} + +describe('createTerminalImeLinuxCandidateState', () => { + it('suppresses the next bare digit after an orphaned plain-letter keyup', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + + const orphanLetterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 }) + const orphanClassification = state.classifyKeyboardEvent(orphanLetterKeyup) + expect(orphanClassification.candidateDigitGuardActive).toBe(false) + state.observeKeyboardEvent(orphanLetterKeyup, orphanClassification) + + time += 10 + const digitKeydown = event({ key: '1', code: 'Digit1', keyCode: 49 }) + const digitKeydownClassification = state.classifyKeyboardEvent(digitKeydown) + expect(digitKeydownClassification.candidateDigitGuardActive).toBe(true) + state.observeKeyboardEvent(digitKeydown, digitKeydownClassification) + + const digitKeyup = event({ type: 'keyup', key: '1', code: 'Digit1', keyCode: 49 }) + const digitKeyupClassification = state.classifyKeyboardEvent(digitKeyup) + expect(digitKeyupClassification.candidateDigitGuardActive).toBe(false) + state.observeKeyboardEvent(digitKeyup, digitKeyupClassification) + + time += 10 + const secondDigit = event({ key: '2', code: 'Digit2', keyCode: 50 }) + expect(state.classifyKeyboardEvent(secondDigit).candidateDigitGuardActive).toBe(false) + }) + + it('does not suppress normal letter->digit typing when the letter had a matching keydown', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + + const letterKeydown = event({ key: 'a', code: 'KeyA', keyCode: 65 }) + const letterKeydownClassification = state.classifyKeyboardEvent(letterKeydown) + expect(letterKeydownClassification.candidateDigitGuardActive).toBe(false) + state.observeKeyboardEvent(letterKeydown, letterKeydownClassification) + + time += 10 + const letterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 }) + const letterKeyupClassification = state.classifyKeyboardEvent(letterKeyup) + expect(letterKeyupClassification.candidateDigitGuardActive).toBe(false) + state.observeKeyboardEvent(letterKeyup, letterKeyupClassification) + + time += 10 + const digitKeydown = event({ key: '1', code: 'Digit1', keyCode: 49 }) + expect(state.classifyKeyboardEvent(digitKeydown).candidateDigitGuardActive).toBe(false) + }) + + it('does not mistake a long-held letter release for an orphaned keyup', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + + const letterKeydown = event({ key: 'a', code: 'KeyA', keyCode: 65 }) + const letterKeydownClassification = state.classifyKeyboardEvent(letterKeydown) + state.observeKeyboardEvent(letterKeydown, letterKeydownClassification) + + time += 2_000 + const letterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 }) + const letterKeyupClassification = state.classifyKeyboardEvent(letterKeyup) + state.observeKeyboardEvent(letterKeyup, letterKeyupClassification) + + time += 10 + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1', keyCode: 49 })) + .candidateDigitGuardActive + ).toBe(false) + }) + + it('clears a pending physical key when modifiers change before release', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + const letterKeydown = event({ key: 'a', code: 'KeyA' }) + state.observeKeyboardEvent(letterKeydown, state.classifyKeyboardEvent(letterKeydown)) + const modifiedKeyup = event({ + type: 'keyup', + key: 'A', + code: 'KeyA', + shiftKey: true + }) + state.observeKeyboardEvent(modifiedKeyup, state.classifyKeyboardEvent(modifiedKeyup)) + + time += 10 + const orphanKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' }) + state.observeKeyboardEvent(orphanKeyup, state.classifyKeyboardEvent(orphanKeyup)) + time += 10 + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive + ).toBe(true) + }) + + it('does not mistake a shifted physical letter for an orphan after Shift releases first', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + for (const keyboardEvent of [ + event({ key: 'A', code: 'KeyA', shiftKey: true }), + event({ type: 'keyup', key: 'Shift', code: 'ShiftLeft' }), + event({ type: 'keyup', key: 'a', code: 'KeyA' }) + ]) { + state.observeKeyboardEvent(keyboardEvent, state.classifyKeyboardEvent(keyboardEvent)) + time += 10 + } + + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive + ).toBe(false) + }) + + it('cancels the orphan guard when another non-digit keydown intervenes', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + for (const keyboardEvent of [ + event({ type: 'keyup', key: 'a', code: 'KeyA' }), + event({ key: 'b', code: 'KeyB' }), + event({ type: 'keyup', key: 'b', code: 'KeyB' }) + ]) { + state.observeKeyboardEvent(keyboardEvent, state.classifyKeyboardEvent(keyboardEvent)) + time += 10 + } + + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive + ).toBe(false) + }) + + it('resets missed releases on blur and removes the listener on dispose', () => { + let time = 100 + const terminalElement = new EventTarget() + const removeEventListener = vi.spyOn(terminalElement, 'removeEventListener') + const state = installTerminalImeLinuxCandidateState(terminalElement, () => time) + const letterKeydown = event({ key: 'a', code: 'KeyA' }) + state.observeKeyboardEvent(letterKeydown, state.classifyKeyboardEvent(letterKeydown)) + + terminalElement.dispatchEvent(new Event('blur')) + time += 10 + const orphanKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' }) + state.observeKeyboardEvent(orphanKeyup, state.classifyKeyboardEvent(orphanKeyup)) + time += 10 + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive + ).toBe(true) + + state.dispose() + expect(removeEventListener).toHaveBeenCalledWith('blur', state.resetCandidateGuard, true) + }) + + it('shares pressed letters across pane focus handoffs and disposes the tracker once', () => { + let time = 100 + const rendererWindow = new EventTarget() + const removeWindowListener = vi.spyOn(rendererWindow, 'removeEventListener') + const firstTerminal = new EventTarget() + const secondTerminal = new EventTarget() + const firstState = installTerminalImeLinuxCandidateState( + firstTerminal, + () => time, + rendererWindow + ) + const secondState = installTerminalImeLinuxCandidateState( + secondTerminal, + () => time, + rendererWindow + ) + + const letterKeydown = event({ key: 'a', code: 'KeyA' }) + firstState.observeKeyboardEvent(letterKeydown, firstState.classifyKeyboardEvent(letterKeydown)) + firstTerminal.dispatchEvent(new Event('blur')) + const letterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' }) + secondState.observeKeyboardEvent(letterKeyup, secondState.classifyKeyboardEvent(letterKeyup)) + time += 10 + expect( + secondState.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })) + .candidateDigitGuardActive + ).toBe(false) + + firstState.dispose() + expect(removeWindowListener).not.toHaveBeenCalled() + secondState.dispose() + expect(removeWindowListener).toHaveBeenCalledWith('keyup', expect.any(Function)) + }) + + it('clears renderer-wide pressed letters when the window blurs', () => { + let time = 100 + const rendererWindow = new EventTarget() + const state = installTerminalImeLinuxCandidateState( + new EventTarget(), + () => time, + rendererWindow + ) + const letterKeydown = event({ key: 'a', code: 'KeyA' }) + state.observeKeyboardEvent(letterKeydown, state.classifyKeyboardEvent(letterKeydown)) + + rendererWindow.dispatchEvent(new Event('blur')) + const orphanKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' }) + state.observeKeyboardEvent(orphanKeyup, state.classifyKeyboardEvent(orphanKeyup)) + time += 10 + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive + ).toBe(true) + state.dispose() + }) + + it('does not suppress a digit after overlapping ordinary letter key presses', () => { + let time = 100 + const state = createTerminalImeLinuxCandidateState(() => time) + + for (const keyboardEvent of [ + event({ key: 'a', code: 'KeyA', keyCode: 65 }), + event({ key: 'b', code: 'KeyB', keyCode: 66 }), + event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 }), + event({ type: 'keyup', key: 'b', code: 'KeyB', keyCode: 66 }) + ]) { + const classification = state.classifyKeyboardEvent(keyboardEvent) + state.observeKeyboardEvent(keyboardEvent, classification) + time += 10 + } + + expect( + state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1', keyCode: 49 })) + .candidateDigitGuardActive + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts b/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts new file mode 100644 index 00000000000..5594bcb3cbe --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.ts @@ -0,0 +1,203 @@ +import type { XtermBypassEvent } from './xterm-bypass-policy' + +type TerminalImeLinuxCandidateState = { + /** Classifies an event before the state observes it. */ + classifyKeyboardEvent: (event: XtermBypassEvent) => { candidateDigitGuardActive: boolean } + /** Advances the state after the caller consumes an event classification. */ + observeKeyboardEvent: ( + event: XtermBypassEvent, + classification: { candidateDigitGuardActive: boolean } + ) => void + /** Drops all candidate and physical-key state. */ + reset: () => void + /** Drops only this pane's candidate window. */ + resetCandidateGuard: () => void +} + +type TerminalImeLinuxPhysicalKeyTracker = { + pressedCodes: Set + dispose: () => void +} + +const CANDIDATE_DIGIT_WINDOW_MS = 1500 +const ASCII_LOWERCASE_LETTER = /^[a-z]$/ +const ASCII_DIGIT = /^[0-9]$/ +const PHYSICAL_ASCII_LETTER_CODE = /^Key[A-Z]$/ +const physicalKeyTrackers = new WeakMap< + EventTarget, + TerminalImeLinuxPhysicalKeyTracker & { users: number } +>() + +function acquirePhysicalKeyTracker( + eventTarget: EventTarget | null +): TerminalImeLinuxPhysicalKeyTracker { + if (!eventTarget) { + return { pressedCodes: new Set(), dispose: () => undefined } + } + const existing = physicalKeyTrackers.get(eventTarget) + if (existing) { + existing.users += 1 + return { + pressedCodes: existing.pressedCodes, + dispose: () => releasePhysicalKeyTracker(eventTarget, existing) + } + } + + const pressedCodes = new Set() + const observeKeyboardEvent = (event: Event): void => { + const code = (event as Event & { code?: string }).code + if (!code || !PHYSICAL_ASCII_LETTER_CODE.test(code)) { + return + } + if (event.type === 'keydown') { + pressedCodes.add(code) + } else { + pressedCodes.delete(code) + } + } + const reset = (): void => pressedCodes.clear() + // Why: bubble-phase keyup cleanup runs after xterm's target handler, so the + // pane can still classify that release against the shared pressed-key set. + eventTarget.addEventListener('keydown', observeKeyboardEvent) + eventTarget.addEventListener('keyup', observeKeyboardEvent) + eventTarget.addEventListener('blur', reset) + const tracker = { + pressedCodes, + users: 1, + dispose: () => releasePhysicalKeyTracker(eventTarget, tracker), + observeKeyboardEvent, + reset + } + physicalKeyTrackers.set(eventTarget, tracker) + return tracker +} + +function releasePhysicalKeyTracker( + eventTarget: EventTarget, + tracker: TerminalImeLinuxPhysicalKeyTracker & { + users: number + observeKeyboardEvent?: EventListener + reset?: EventListener + } +): void { + tracker.users -= 1 + if (tracker.users > 0) { + return + } + if (tracker.observeKeyboardEvent && tracker.reset) { + eventTarget.removeEventListener('keydown', tracker.observeKeyboardEvent) + eventTarget.removeEventListener('keyup', tracker.observeKeyboardEvent) + eventTarget.removeEventListener('blur', tracker.reset) + } + tracker.pressedCodes.clear() + physicalKeyTrackers.delete(eventTarget) +} + +/** Returns whether an event is an unmodified lowercase Latin letter. */ +function isPlainAsciiLetterKey(event: XtermBypassEvent): boolean { + return ( + ASCII_LOWERCASE_LETTER.test(event.key) && + !event.ctrlKey && + !event.altKey && + !event.metaKey && + !event.shiftKey + ) +} + +/** Returns whether an event is an unmodified ASCII digit. */ +function isPlainAsciiDigitKey(event: XtermBypassEvent): boolean { + return ( + ASCII_DIGIT.test(event.key) && + !event.ctrlKey && + !event.altKey && + !event.metaKey && + !event.shiftKey + ) +} + +/** Tracks legacy desktop Linux IME candidate-selection event sequences. */ +export function createTerminalImeLinuxCandidateState( + now: () => number = () => Date.now(), + pendingPlainLetterKeydownsByCode: Set = new Set() +): TerminalImeLinuxCandidateState { + let candidateDigitUntil = 0 + + const resetCandidateGuard = (): void => { + candidateDigitUntil = 0 + } + + const reset = (): void => { + pendingPlainLetterKeydownsByCode.clear() + resetCandidateGuard() + } + + return { + reset, + resetCandidateGuard, + /** Classifies the current event before state is advanced for it. */ + classifyKeyboardEvent: (event) => { + const at = now() + return { + candidateDigitGuardActive: + event.type === 'keydown' && isPlainAsciiDigitKey(event) && candidateDigitUntil > at + } + }, + /** Records the current event after its classification is consumed. */ + observeKeyboardEvent: (event, classification) => { + const at = now() + if (classification.candidateDigitGuardActive) { + candidateDigitUntil = 0 + return + } + + if (candidateDigitUntil <= at) { + candidateDigitUntil = 0 + } + + if (event.type === 'keydown') { + if (!isPlainAsciiDigitKey(event)) { + candidateDigitUntil = 0 + } + const physicalCode = event.code + if (physicalCode && PHYSICAL_ASCII_LETTER_CODE.test(physicalCode)) { + pendingPlainLetterKeydownsByCode.add(physicalCode) + } + return + } + + if (event.type === 'keyup') { + const matchingPlainLetterKeydown = event.code + ? pendingPlainLetterKeydownsByCode.delete(event.code) + : false + if (isPlainAsciiLetterKey(event) && event.code) { + if (!matchingPlainLetterKeydown) { + // Why: some legacy Linux IME paths commit a single-letter preedit + // without composition/input events. The orphaned keyup is a narrow + // hint that the next bare digit belongs to the candidate picker. + candidateDigitUntil = at + CANDIDATE_DIGIT_WINDOW_MS + } + } + } + } + } +} + +/** Installs Linux IME candidate state with focus-loss cleanup. */ +export function installTerminalImeLinuxCandidateState( + terminalElement: EventTarget | null | undefined, + now: () => number = () => Date.now(), + rendererKeyboardEventTarget: EventTarget | null = typeof window === 'undefined' + ? (terminalElement ?? null) + : window +): TerminalImeLinuxCandidateState & { dispose: () => void } { + const physicalKeyTracker = acquirePhysicalKeyTracker(rendererKeyboardEventTarget) + const state = createTerminalImeLinuxCandidateState(now, physicalKeyTracker.pressedCodes) + terminalElement?.addEventListener('blur', state.resetCandidateGuard, true) + return { + ...state, + dispose: () => { + terminalElement?.removeEventListener('blur', state.resetCandidateGuard, true) + physicalKeyTracker.dispose() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts b/src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts new file mode 100644 index 00000000000..198f6d4a9a1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform' + +function state(overrides: Partial = {}): AppState { + return { + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + settings: { activeRuntimeEnvironmentId: null }, + sshConnectionStates: new Map(), + runtimeStatusByEnvironmentId: new Map(), + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + ...overrides + } as AppState +} + +describe('resolveTerminalInputHostPlatform', () => { + it('uses a paired runtime host platform instead of the macOS client', () => { + const worktreeId = 'repo::C:\\repo' + expect( + resolveTerminalInputHostPlatform({ + clientPlatform: 'darwin', + state: state({ + repos: [ + { + id: 'repo', + path: 'C:\\repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'runtime:windows-box' + } + ], + runtimeStatusByEnvironmentId: new Map([ + [ + 'windows-box', + { + status: { hostPlatform: 'win32' } + } as AppState['runtimeStatusByEnvironmentId'] extends Map ? T : never + ] + ]) + }), + worktreeId, + transport: null + }) + ).toBe('win32') + }) + + it('uses the active remote runtime PTY owner after worktree ownership changes', () => { + expect( + resolveTerminalInputHostPlatform({ + clientPlatform: 'darwin', + state: state({ + repos: [ + { + id: 'repo', + path: 'C:\\repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'runtime:linux-box' + } + ], + runtimeStatusByEnvironmentId: new Map([ + ['windows-box', { status: { hostPlatform: 'win32' } } as never], + ['linux-box', { status: { hostPlatform: 'linux' } } as never] + ]) + }), + worktreeId: 'repo::C:\\repo', + transport: { + getConnectionId: () => null, + getPtyId: () => 'remote:windows-box@@terminal-1' + } + }) + ).toBe('win32') + }) + + it('uses captured runtime ownership for a legacy remote PTY id', () => { + expect( + resolveTerminalInputHostPlatform({ + clientPlatform: 'darwin', + state: state({ + runtimeStatusByEnvironmentId: new Map([ + ['windows-box', { status: { hostPlatform: 'win32' } } as never] + ]) + }), + worktreeId: 'repo::/repo', + transport: { + getConnectionId: () => null, + getPtyId: () => 'remote:terminal-1', + getRuntimeEnvironmentId: () => 'windows-box' + } + }) + ).toBe('win32') + }) + + it('keeps a live local PTY on the client after worktree ownership changes', () => { + expect( + resolveTerminalInputHostPlatform({ + clientPlatform: 'darwin', + state: state({ + repos: [ + { + id: 'repo', + path: '/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'runtime:windows-box' + } + ], + runtimeStatusByEnvironmentId: new Map([ + ['windows-box', { status: { hostPlatform: 'win32' } } as never] + ]) + }), + worktreeId: 'repo::/repo', + transport: { + getConnectionId: () => null, + getPtyId: () => 'local-pty-1', + getLocalSessionMetadata: () => ({ cwd: '/repo' }) + } + }) + ).toBe('darwin') + }) + + it('normalizes a live WSL session to a Linux terminal host', () => { + expect( + resolveTerminalInputHostPlatform({ + clientPlatform: 'win32', + state: state(), + worktreeId: 'repo::C:\\repo', + transport: { + getConnectionId: () => null, + getPtyId: () => 'local-pty-1', + getLocalSessionMetadata: () => ({ + shellOverride: ' "C:\\Windows\\System32\\wsl.exe" -d Ubuntu-24.04' + }) + } + }) + ).toBe('linux') + }) + + it('keeps a live native Windows session after the worktree switches to WSL', () => { + expect( + resolveTerminalInputHostPlatform({ + clientPlatform: 'win32', + state: state({ + repos: [ + { + id: 'repo', + path: 'C:\\repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'runtime:linux-box' + } + ], + runtimeStatusByEnvironmentId: new Map([ + ['linux-box', { status: { hostPlatform: 'linux' } } as never] + ]) + }), + worktreeId: 'repo::C:\\repo', + transport: { + getConnectionId: () => null, + getPtyId: () => 'windows-pty-1', + getLocalSessionMetadata: () => ({ cwd: 'C:\\repo', shellOverride: 'pwsh.exe' }) + } + }) + ).toBe('win32') + }) + + it('uses SSH remote platform metadata', () => { + expect( + resolveTerminalInputHostPlatform({ + clientPlatform: 'darwin', + state: state({ + sshConnectionStates: new Map([['ssh-win', { remotePlatform: 'win32' } as never]]) + }), + worktreeId: 'repo::C:\\repo', + transport: { getConnectionId: () => 'ssh-win' } + }) + ).toBe('win32') + }) + + it('falls back to the client when SSH platform metadata is unavailable', () => { + expect( + resolveTerminalInputHostPlatform({ + clientPlatform: 'darwin', + state: state({ sshConnectionStates: new Map([['ssh-unknown', {} as never]]) }), + worktreeId: 'repo::/repo', + transport: { getConnectionId: () => 'ssh-unknown' } + }) + ).toBe('darwin') + }) + + it('falls back to the client when runtime platform metadata is unavailable', () => { + expect( + resolveTerminalInputHostPlatform({ + clientPlatform: 'darwin', + state: state(), + worktreeId: 'repo::/repo', + transport: { + getConnectionId: () => null, + getPtyId: () => 'remote:windows-box@@terminal-1', + getRuntimeEnvironmentId: () => 'windows-box' + } + }) + ).toBe('darwin') + }) + + it('uses the SSH execution host when the transport has no connection id', () => { + const worktreeId = 'repo::C:\\repo' + expect( + resolveTerminalInputHostPlatform({ + clientPlatform: 'darwin', + state: state({ + repos: [ + { + id: 'repo', + path: 'C:\\repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'ssh:ssh-win' + } + ], + sshConnectionStates: new Map([['ssh-win', { remotePlatform: 'win32' } as never]]) + }), + worktreeId, + transport: { getConnectionId: () => null } + }) + ).toBe('win32') + }) + + it('keeps the client platform for local terminals', () => { + expect( + resolveTerminalInputHostPlatform({ + clientPlatform: 'darwin', + state: state(), + worktreeId: 'repo::/repo', + transport: null + }) + ).toBe('darwin') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-input-host-platform.ts b/src/renderer/src/components/terminal-pane/terminal-input-host-platform.ts new file mode 100644 index 00000000000..bcd0e16eeca --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-input-host-platform.ts @@ -0,0 +1,73 @@ +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { isWslUncPath } from '../../../../shared/wsl-paths' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' +import type { AppState } from '@/store/types' +import type { PtyTransport } from './pty-transport-types' +import { isWslShellOverride } from './terminal-paste-runtime' + +type TerminalInputHostPlatformState = Pick< + AppState, + | 'repos' + | 'worktreesByRepo' + | 'folderWorkspaces' + | 'projectGroups' + | 'settings' + | 'sshConnectionStates' + | 'runtimeStatusByEnvironmentId' + | 'restoredRuntimeHostIdByWorkspaceSessionKey' +> + +export function resolveTerminalInputHostPlatform(args: { + clientPlatform: NodeJS.Platform + state: TerminalInputHostPlatformState + worktreeId: string + transport: + | (Pick & + Partial< + Pick + >) + | null +}): NodeJS.Platform { + const transportConnectionId = args.transport?.getConnectionId?.() + const connectionId = + transportConnectionId === undefined + ? getConnectionIdFromState(args.state, args.worktreeId) + : transportConnectionId + if (connectionId) { + return args.state.sshConnectionStates.get(connectionId)?.remotePlatform ?? args.clientPlatform + } + + // Why: a running pane keeps its spawn-time runtime even if the worktree's + // selected host changes later, so the live PTY identity is authoritative. + const ptyId = args.transport?.getPtyId?.() ?? null + const runtimeEnvironmentId = + args.transport?.getRuntimeEnvironmentId?.() ?? + (ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null) + if (runtimeEnvironmentId) { + return ( + args.state.runtimeStatusByEnvironmentId.get(runtimeEnvironmentId)?.status?.hostPlatform ?? + args.clientPlatform + ) + } + const localSessionMetadata = args.transport?.getLocalSessionMetadata?.() + if (ptyId !== null && localSessionMetadata != null) { + const isWslSession = + isWslUncPath(localSessionMetadata.cwd ?? '') || + isWslShellOverride(localSessionMetadata.shellOverride) + return args.clientPlatform === 'win32' && isWslSession ? 'linux' : args.clientPlatform + } + + const host = parseExecutionHostId(getExecutionHostIdForWorktree(args.state, args.worktreeId)) + if (host?.kind === 'ssh') { + return args.state.sshConnectionStates.get(host.targetId)?.remotePlatform ?? args.clientPlatform + } + if (host?.kind === 'runtime') { + return ( + args.state.runtimeStatusByEnvironmentId.get(host.environmentId)?.status?.hostPlatform ?? + args.clientPlatform + ) + } + return args.clientPlatform +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts new file mode 100644 index 00000000000..e595974bc18 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts @@ -0,0 +1,59 @@ +import type { IDisposable, Terminal } from '@xterm/xterm' +import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' + +const CAPTURE_LISTENER_OPTIONS = { capture: true } as const + +export function installTerminalLinkPtyMouseSuppression( + terminal: Terminal, + shouldSuppressMouseEvent: (event: MouseEvent) => boolean +): IDisposable { + const terminalElement = terminal.element + const ownerDocument = terminalElement?.ownerDocument + const ownerWindow = ownerDocument?.defaultView + let previousMouseEventsRequireAlt: boolean | null = null + let restoreQueued = false + + const restore = (): void => { + restoreQueued = false + if (previousMouseEventsRequireAlt === null) { + return + } + terminal.options.mouseEventsRequireAlt = previousMouseEventsRequireAlt + previousMouseEventsRequireAlt = null + ownerDocument?.removeEventListener('mouseup', queueRestore) + ownerWindow?.removeEventListener('blur', restore) + } + const queueRestore = (): void => { + if (restoreQueued || previousMouseEventsRequireAlt === null) { + return + } + restoreQueued = true + queueMicrotask(restore) + } + const handleMouseDown = (event: MouseEvent): void => { + if ( + event.button !== 0 || + !isTerminalHttpLinkActivation(event) || + !shouldSuppressMouseEvent(event) + ) { + return + } + restore() + previousMouseEventsRequireAlt = Boolean(terminal.options.mouseEventsRequireAlt) + // Why: xterm otherwise forwards the same Cmd/Ctrl link gesture to a mouse-aware + // TUI, letting the terminal and the child process both open the URL. + terminal.options.mouseEventsRequireAlt = true + ownerDocument?.addEventListener('mouseup', queueRestore) + ownerWindow?.addEventListener('blur', restore) + } + + terminalElement?.addEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS) + terminalElement?.addEventListener('mouseup', queueRestore, CAPTURE_LISTENER_OPTIONS) + return { + dispose: () => { + restore() + terminalElement?.removeEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS) + terminalElement?.removeEventListener('mouseup', queueRestore, CAPTURE_LISTENER_OPTIONS) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-mouse-buffer-position.ts b/src/renderer/src/components/terminal-pane/terminal-mouse-buffer-position.ts new file mode 100644 index 00000000000..ee741dbbc4f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-mouse-buffer-position.ts @@ -0,0 +1,29 @@ +import type { Terminal } from '@xterm/xterm' + +export function getTerminalBufferPositionForMouseEvent( + terminal: Terminal, + event: MouseEvent +): { x: number; y: number } | null { + const screenElement = terminal.element?.querySelector('.xterm-screen') + if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) { + return null + } + + const rect = screenElement.getBoundingClientRect() + const relativeX = event.clientX - rect.left + const relativeY = event.clientY - rect.top + if (relativeX < 0 || relativeY < 0 || relativeX >= rect.width || relativeY >= rect.height) { + return null + } + + const cellWidth = rect.width / terminal.cols + const cellHeight = rect.height / terminal.rows + if (cellWidth <= 0 || cellHeight <= 0) { + return null + } + + return { + x: Math.floor(relativeX / cellWidth) + 1, + y: Math.floor(relativeY / cellHeight) + terminal.buffer.active.viewportY + 1 + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-runtime.ts b/src/renderer/src/components/terminal-pane/terminal-paste-runtime.ts index 8989e8e96ce..2100c3bddce 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-runtime.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-runtime.ts @@ -85,7 +85,7 @@ function resolveWslRuntimeKey( return null } -function isWslShellOverride(shellOverride: string | null | undefined): boolean { +export function isWslShellOverride(shellOverride: string | null | undefined): boolean { const executable = getShellOverrideExecutableToken(shellOverride) const segmentStart = getShellOverridePathSegmentStart(executable) const name = executable.slice(segmentStart).toLowerCase() diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts index a9929951449..2cfe267669c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts @@ -66,12 +66,12 @@ describe('resolveTerminalShortcutAction', () => { ).toEqual({ type: 'focusPane', direction: 'next' }) }) - it('keeps shift-enter and delete helpers explicit', () => { + it('keeps inactive shift-enter and delete helpers explicit', () => { expect( resolveTerminalShortcutAction(event({ key: 'Enter', code: 'Enter', shiftKey: true }), true) ).toEqual({ type: 'sendInput', - data: '\x1b[13;2u' + data: '\x1b\r' }) expect(resolveTerminalShortcutAction(event({ key: 'Backspace', ctrlKey: true }), true)).toEqual( { type: 'sendInput', data: '\x17' } @@ -139,34 +139,30 @@ describe('resolveTerminalShortcutAction', () => { ).toEqual({ type: 'sendInput', data: '\x1b[13;2u' }) }) - it('preserves the Windows fallback for SSH, WSL, and remote panes', () => { - // Why: a Windows client cannot safely infer a remote peer's decoder, so the - // local Droid exception must not broaden main's existing remote behavior. - const isLocalWindowsConptyPane = vi.fn(() => false) + it('uses CSI-u for a non-Windows PTY reached from Windows only while Kitty is active', () => { const getWindowsShiftEnterEncoding = vi.fn(() => 'csi-u' as const) - for (let index = 0; index < 2; index += 1) { - expect( - resolveTerminalShortcutAction( - event({ key: 'Enter', code: 'Enter', shiftKey: true }), - false, - 'false', - 0, - true, - undefined, - isLocalWindowsConptyPane, - undefined, - undefined, - getWindowsShiftEnterEncoding - ) - ).toEqual({ type: 'sendInput', data: '\x1b\r' }) - } - expect(isLocalWindowsConptyPane).toHaveBeenCalledTimes(2) + const resolve = (kittyActive: boolean) => + resolveTerminalShortcutAction( + event({ key: 'Enter', code: 'Enter', shiftKey: true }), + false, + 'false', + 0, + true, + undefined, + undefined, + () => kittyActive, + undefined, + getWindowsShiftEnterEncoding, + () => false + ) + expect(resolve(true)).toEqual({ type: 'sendInput', data: '\x1b[13;2u' }) + expect(resolve(false)).toEqual({ type: 'sendInput', data: '\x1b\r' }) expect(getWindowsShiftEnterEncoding).not.toHaveBeenCalled() }) - it('always uses CSI-u Shift+Enter off Windows regardless of Windows encoding', () => { + it('uses CSI-u Shift+Enter off Windows only while Kitty keyboard is active', () => { for (const encoding of [() => 'csi-u' as const, () => 'alt-enter' as const, undefined]) { - expect( + const resolve = (kittyActive: boolean) => resolveTerminalShortcutAction( event({ key: 'Enter', code: 'Enter', shiftKey: true }), false, @@ -175,17 +171,20 @@ describe('resolveTerminalShortcutAction', () => { false, undefined, undefined, - undefined, + () => kittyActive, undefined, encoding ) - ).toEqual({ type: 'sendInput', data: '\x1b[13;2u' }) + expect(resolve(true)).toEqual({ type: 'sendInput', data: '\x1b[13;2u' }) + expect(resolve(false)).toEqual({ type: 'sendInput', data: '\x1b\r' }) } }) - it('keeps ConPTY and agent lookups off unrelated keystrokes', () => { + it('keeps host and agent lookups off unrelated keystrokes', () => { const isLocalWindowsConptyPane = vi.fn(() => true) + const isKittyKeyboardActivePane = vi.fn(() => true) const getWindowsShiftEnterEncoding = vi.fn(() => 'csi-u' as const) + const isWindowsTerminalHost = vi.fn(() => true) expect( resolveTerminalShortcutAction( @@ -196,13 +195,16 @@ describe('resolveTerminalShortcutAction', () => { true, undefined, isLocalWindowsConptyPane, + isKittyKeyboardActivePane, undefined, - undefined, - getWindowsShiftEnterEncoding + getWindowsShiftEnterEncoding, + isWindowsTerminalHost ) ).toBeNull() expect(isLocalWindowsConptyPane).not.toHaveBeenCalled() expect(getWindowsShiftEnterEncoding).not.toHaveBeenCalled() + expect(isWindowsTerminalHost).not.toHaveBeenCalled() + expect(isKittyKeyboardActivePane).not.toHaveBeenCalled() expect( resolveTerminalShortcutAction( @@ -213,15 +215,18 @@ describe('resolveTerminalShortcutAction', () => { true, undefined, isLocalWindowsConptyPane, + isKittyKeyboardActivePane, undefined, - undefined, - getWindowsShiftEnterEncoding + getWindowsShiftEnterEncoding, + isWindowsTerminalHost ) ).toEqual({ type: 'sendInput', data: '\x1b[13;2u' }) - expect(isLocalWindowsConptyPane).toHaveBeenCalledTimes(1) + expect(isLocalWindowsConptyPane).not.toHaveBeenCalled() expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(1) + expect(isWindowsTerminalHost).toHaveBeenCalledTimes(1) + expect(isKittyKeyboardActivePane).not.toHaveBeenCalled() - isLocalWindowsConptyPane.mockReturnValue(false) + isWindowsTerminalHost.mockReturnValue(false) expect( resolveTerminalShortcutAction( event({ key: 'Enter', code: 'Enter', shiftKey: true }), @@ -231,13 +236,37 @@ describe('resolveTerminalShortcutAction', () => { true, undefined, isLocalWindowsConptyPane, + isKittyKeyboardActivePane, undefined, - undefined, - getWindowsShiftEnterEncoding + getWindowsShiftEnterEncoding, + isWindowsTerminalHost ) - ).toEqual({ type: 'sendInput', data: '\x1b\r' }) - expect(isLocalWindowsConptyPane).toHaveBeenCalledTimes(2) + ).toEqual({ type: 'sendInput', data: '\x1b[13;2u' }) + expect(isLocalWindowsConptyPane).not.toHaveBeenCalled() expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(1) + expect(isWindowsTerminalHost).toHaveBeenCalledTimes(2) + expect(isKittyKeyboardActivePane).toHaveBeenCalledTimes(1) + }) + + it('honors Kitty negotiation for a Windows PTY reached from macOS', () => { + const getWindowsShiftEnterEncoding = vi.fn(() => 'alt-enter' as const) + const resolve = (kittyActive: boolean) => + resolveTerminalShortcutAction( + event({ key: 'Enter', code: 'Enter', shiftKey: true }), + true, + 'false', + 0, + false, + undefined, + undefined, + () => kittyActive, + undefined, + getWindowsShiftEnterEncoding, + () => true + ) + expect(resolve(true)).toEqual({ type: 'sendInput', data: '\x1b[13;2u' }) + expect(resolve(false)).toEqual({ type: 'sendInput', data: '\x1b\r' }) + expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(2) }) it('forwards Ctrl+Enter as the kitty CSI-u chord so TUIs can cue instead of send', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index f0f284d167f..c6d7fed2afd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -76,8 +76,8 @@ export function resolveTerminalShortcutAction( isWindows: boolean = false, keybindings?: KeybindingOverrides, // Why: lazily reports whether the active pane is a local native Windows - // ConPTY. Only consulted for Shift+Enter and Ctrl+Arrow, so execution-host - // lookup stays off every other keystroke. + // ConPTY. Only consulted for Ctrl+Arrow, so execution-host lookup stays off + // every other keystroke. isLocalWindowsConptyPane?: () => boolean, // Why: lazily reports whether the active pane's application has enabled the // kitty keyboard protocol (CSI > u). Gates the Option-as-Alt compensation @@ -90,7 +90,10 @@ export function resolveTerminalShortcutAction( layoutBaseCharacterForCode?: (code: string) => string | undefined, // Why: lazily resolves the active pane's Windows encoding. Only consulted for // Shift+Enter so agent-state lookup stays off every other keystroke. - getWindowsShiftEnterEncoding?: () => WindowsShiftEnterEncoding + getWindowsShiftEnterEncoding?: () => WindowsShiftEnterEncoding, + // Why: keybindings follow the client OS, but terminal byte protocols follow + // the PTY host. They differ for macOS clients attached to Windows runtimes. + isWindowsTerminalHost: () => boolean = () => isWindows ): TerminalShortcutAction | null { const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' if (!event.repeat) { @@ -150,15 +153,14 @@ export function resolveTerminalShortcutAction( event.shiftKey && event.key === 'Enter' ) { - // Why: Droid needs CSI-u but Codex needs Esc+CR; preserve legacy bytes for - // SSH/WSL/remote peers that cannot be safely classified from this client. - const useLocalWindowsCapability = isWindows && isLocalWindowsConptyPane?.() !== false - const encoding = useLocalWindowsCapability - ? (getWindowsShiftEnterEncoding?.() ?? 'alt-enter') - : isWindows - ? 'alt-enter' - : 'csi-u' - return { type: 'sendInput', data: encoding === 'csi-u' ? '\x1b[13;2u' : '\x1b\r' } + // Why: negotiated KKP is authoritative on every host; trusted pane + // evidence additionally preserves Droid's Windows encoding without KKP. + const windowsHost = isWindowsTerminalHost() + const hasTrustedWindowsCsiU = windowsHost && getWindowsShiftEnterEncoding?.() === 'csi-u' + // Why: CSI-u is application input, not a universal terminal sequence. + // Without trusted Windows agent evidence, require active KKP negotiation. + const canSendCsiU = hasTrustedWindowsCsiU || isKittyKeyboardActivePane?.() === true + return { type: 'sendInput', data: canSendCsiU ? '\x1b[13;2u' : '\x1b\r' } } if ( diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts new file mode 100644 index 00000000000..c8e85950da3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts @@ -0,0 +1,429 @@ +import type { IBufferLine, Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { handleTerminalWebLinkClick } from './terminal-web-link-click' +import { installHttpLinkClickFallback } from './terminal-url-link-hit-testing' + +const COLS = 157 +const ROWS = 59 +const INDENT = '' +const FULL_URL = [ + 'http://127.0.0.1:8765/orca-double-open-repro-wrapped/', + Array.from({ length: 79 }, (_value, index) => `seg${String(index + 1).padStart(4, '0')}`).join( + '/' + ), + '?marker=wrap-test&n=001&pad=', + 'x'.repeat(120) +].join('') +const URL_ROWS = Array.from({ length: Math.ceil(FULL_URL.length / COLS) }, (_value, index) => + FULL_URL.slice(index * COLS, (index + 1) * COLS) +) +const FRAMED_ROW_STARTS = [ + 0, + FULL_URL.indexOf('seg0008/'), + FULL_URL.indexOf('seg0022/'), + FULL_URL.indexOf('seg0036/'), + FULL_URL.indexOf('seg0050/'), + FULL_URL.indexOf('seg0064/'), + FULL_URL.indexOf('seg0078/'), + FULL_URL.indexOf('test&n=001'), + FULL_URL.length - 19 +] +const FRAMED_URL_ROWS = FRAMED_ROW_STARTS.map((start, index) => + FULL_URL.slice(start, FRAMED_ROW_STARTS[index + 1]) +) + +const openUrlMock = vi.fn() + +type ListenerRegistration = [string, EventListener, AddEventListenerOptions | boolean | undefined] + +function makeBufferLine( + fragment: string, + options: { cols?: number; prefix?: string; suffix?: string; isWrapped?: boolean } = {} +): IBufferLine { + const cols = options.cols ?? COLS + const prefix = options.prefix ?? INDENT + const suffix = options.suffix ?? '' + const text = `${prefix}${fragment}`.padEnd(cols - suffix.length) + suffix + return { + isWrapped: options.isWrapped ?? false, + length: cols, + translateToString: ( + _trimRight?: boolean, + startColumn = 0, + endColumn = text.length, + outColumns?: number[] + ) => { + if (outColumns) { + outColumns.splice( + 0, + outColumns.length, + ...Array.from( + { length: endColumn - startColumn + 1 }, + (_value, index) => index + startColumn + ) + ) + } + return text.slice(startColumn, endColumn) + } + } as IBufferLine +} + +function makeTerminal(options?: { + cols?: number + rows?: number + urlRows?: string[] + linePrefix?: string + lineSuffix?: string + softWrapped?: boolean +}): { + terminal: Terminal + registrations: ListenerRegistration[] + clearSelection: ReturnType +} { + const cols = options?.cols ?? COLS + const rows = options?.rows ?? ROWS + const urlRows = options?.urlRows ?? URL_ROWS + const registrations: ListenerRegistration[] = [] + const ownerWindow = { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + const ownerDocument = { + defaultView: ownerWindow, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + const screen = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: cols * 10, height: rows * 10 }) + } + const element = { + ownerDocument, + querySelector: vi.fn(() => screen), + addEventListener: vi.fn( + (name: string, listener: EventListener, options?: AddEventListenerOptions | boolean) => { + registrations.push([name, listener, options]) + } + ), + removeEventListener: vi.fn() + } + const clearSelection = vi.fn() + return { + terminal: { + cols, + rows, + options: { mouseEventsRequireAlt: false }, + element, + buffer: { + active: { + viewportY: 0, + getLine: (y: number) => + urlRows[y] && + makeBufferLine(urlRows[y], { + cols, + prefix: options?.linePrefix, + suffix: options?.lineSuffix, + isWrapped: (options?.softWrapped ?? true) && y > 0 + }) + } + }, + clearSelection + } as unknown as Terminal, + registrations, + clearSelection + } +} + +function mouseEventForRow(row: number, options: { altKey?: boolean } = {}): MouseEvent { + let defaultPrevented = false + return { + button: 0, + metaKey: true, + ctrlKey: false, + altKey: options.altKey ?? false, + shiftKey: true, + get defaultPrevented() { + return defaultPrevented + }, + clientX: 150, + clientY: row * 10 + 5, + preventDefault: vi.fn(() => { + defaultPrevented = true + }) + } as unknown as MouseEvent +} + +describe('hard-wrapped terminal HTTP clicks', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) + registerHttpLinkStoreAccessor(() => ({ + settings: { openLinksInApp: false }, + setActiveWorktree: vi.fn(), + createBrowserTab: vi.fn() + })) + openUrlMock.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('opens the full logical URL once when WebLinksAddon reports only the first row', () => { + const { terminal, registrations, clearSelection } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const event = mouseEventForRow(0) + + expect( + handleTerminalWebLinkClick(URL_ROWS[0], event, { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + + const fallback = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + expect(fallback).toBeDefined() + fallback!(event) + + expect(openUrlMock).toHaveBeenCalledTimes(1) + expect(openUrlMock).toHaveBeenCalledWith(FULL_URL) + expect(new URL(URL_ROWS[0]).pathname).toHaveLength(136) + expect(`${new URL(FULL_URL).pathname}${new URL(FULL_URL).search}`).toHaveLength(811) + expect(event.preventDefault).toHaveBeenCalled() + expect(clearSelection).toHaveBeenCalled() + disposable.dispose() + }) + + it('opens the same full URL from a continuation-row fallback click', () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const event = mouseEventForRow(3) + const fallback = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + + fallback!(event) + + expect(openUrlMock).toHaveBeenCalledTimes(1) + expect(openUrlMock).toHaveBeenCalledWith(FULL_URL) + disposable.dispose() + }) + + it('reconstructs a URL split across cursor-positioned rows inside a TUI frame', () => { + const { terminal } = makeTerminal({ + cols: 135, + urlRows: FRAMED_URL_ROWS, + linePrefix: ' │ ', + lineSuffix: '│ ', + softWrapped: false + }) + const event = mouseEventForRow(0) + + expect( + handleTerminalWebLinkClick(FRAMED_URL_ROWS[0], event, { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + + expect(openUrlMock).toHaveBeenCalledTimes(1) + expect(openUrlMock).toHaveBeenCalledWith(FULL_URL) + expect(new URL(FRAMED_URL_ROWS[0]).pathname).toHaveLength(88) + }) + + it('reconstructs a URL that fills each cursor-positioned TUI row up to its frame', () => { + const cols = 135 + const linePrefix = ' │ ' + const lineSuffix = '│ ' + const contentWidth = cols - linePrefix.length - lineSuffix.length + const fullWidthRows = Array.from( + { length: Math.ceil(FULL_URL.length / contentWidth) }, + (_value, index) => FULL_URL.slice(index * contentWidth, (index + 1) * contentWidth) + ) + const { terminal } = makeTerminal({ + cols, + urlRows: fullWidthRows, + linePrefix, + lineSuffix, + softWrapped: false + }) + + expect( + handleTerminalWebLinkClick(fullWidthRows[0], mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + + expect(openUrlMock).toHaveBeenCalledTimes(1) + expect(openUrlMock).toHaveBeenCalledWith(FULL_URL) + }) + + it('reconstructs supported URLs spanning more than twenty framed rows', () => { + const cols = 80 + const linePrefix = ' │ ' + const lineSuffix = '│ ' + const contentWidth = cols - linePrefix.length - lineSuffix.length + const longUrl = `http://example.com/${'a'.repeat(contentWidth * 20)}` + const urlRows = Array.from( + { length: Math.ceil(longUrl.length / contentWidth) }, + (_value, index) => longUrl.slice(index * contentWidth, (index + 1) * contentWidth) + ) + const { terminal } = makeTerminal({ + cols, + urlRows, + linePrefix, + lineSuffix, + softWrapped: false + }) + + expect(urlRows).toHaveLength(21) + expect( + handleTerminalWebLinkClick(urlRows[0], mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledWith(longUrl) + }) + + it('keeps nested HTTP URLs inside a wrapped query parameter', () => { + const cols = 80 + const linePrefix = ' │ ' + const lineSuffix = '│ ' + const contentWidth = cols - linePrefix.length - lineSuffix.length + const firstRow = `http://example.com/${'a'.repeat(contentWidth - 'http://example.com/'.length)}` + const nestedQuery = 'segment?redirect=https://nested.example/path' + const secondRow = `${nestedQuery}${'b'.repeat(contentWidth - nestedQuery.length)}` + const urlRows = [firstRow, secondRow, 'tail'] + const fullUrl = urlRows.join('') + const { terminal } = makeTerminal({ + cols, + urlRows, + linePrefix, + lineSuffix, + softWrapped: false + }) + + expect( + handleTerminalWebLinkClick(firstRow, mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledWith(fullUrl) + }) + + it('does not append an unrelated aligned TUI row to a complete URL', () => { + const { terminal, registrations } = makeTerminal({ + cols: 135, + urlRows: ['http://example.com/', 'next-token'], + linePrefix: ' │ ', + lineSuffix: '│ ', + softWrapped: false + }) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + + expect( + handleTerminalWebLinkClick('http://example.com/', mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith('http://example.com/') + + openUrlMock.mockReset() + const fallback = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + fallback!(mouseEventForRow(1)) + expect(openUrlMock).not.toHaveBeenCalled() + disposable.dispose() + }) + + it('does not join a complete URL to multiple unrelated framed rows', () => { + const unrelatedFilledRow = 'a'.repeat(103) + const { terminal } = makeTerminal({ + cols: 110, + urlRows: ['http://example.com/', unrelatedFilledRow, 'unrelated'], + linePrefix: ' │ ', + lineSuffix: '│ ', + softWrapped: false + }) + + expect( + handleTerminalWebLinkClick('http://example.com/', mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith('http://example.com/') + }) + + it('does not suppress a modifier-click when the buffer position is not an HTTP link', () => { + const { terminal, registrations } = makeTerminal({ urlRows: ['not-a-link'] }) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1] + + mouseDown!(mouseEventForRow(0)) + + expect(terminal.options.mouseEventsRequireAlt).toBe(false) + disposable.dispose() + }) + + it('leaves Alt-modified link gestures to the child TUI', () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1] + const event = mouseEventForRow(0, { altKey: true }) + + mouseDown!(event) + expect(terminal.options.mouseEventsRequireAlt).toBe(false) + expect( + handleTerminalWebLinkClick(URL_ROWS[0], event, { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(false) + expect(openUrlMock).not.toHaveBeenCalled() + disposable.dispose() + }) + + it('temporarily suppresses PTY mouse reporting at an HTTP link position', async () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1] + const mouseUp = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options !== undefined + )?.[1] + + mouseDown!(mouseEventForRow(0)) + expect(terminal.options.mouseEventsRequireAlt).toBe(true) + + mouseUp!(mouseEventForRow(0)) + await Promise.resolve() + expect(terminal.options.mouseEventsRequireAlt).toBe(false) + disposable.dispose() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index 67505d5f622..bfbbe29f723 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -1,8 +1,15 @@ import type { IBufferLine, IBufferRange, IDisposable, Terminal } from '@xterm/xterm' import { openHttpLink } from '@/lib/http-link-routing' -import { buildCandidateLogicalLinesForBufferPosition } from './terminal-file-link-hit-testing' -import { isTerminalLinkActivation } from './terminal-link-activation' -import { rangeForParsedFileLink } from './wrapped-terminal-link-ranges' +import { + buildCandidateLogicalLinesForBufferPosition, + dedupeLogicalLines +} from './terminal-file-link-hit-testing' +import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links' +import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' +import { installTerminalLinkPtyMouseSuppression } from './terminal-link-pty-mouse-suppression' +import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position' +import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' +import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges' type UrlLinkHitTestDeps = { worktreeId: string @@ -26,7 +33,7 @@ type ParsedTerminalHttpLink = { } const HTTP_SCHEME_PREFIXES = ['https://', 'http://'] as const -export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048 +export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLink[] { const links: ParsedTerminalHttpLink[] = [] @@ -56,7 +63,7 @@ function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean { // Why: desktop terminal links require an intentional Cmd/Ctrl gesture so // plain clicks remain available for cursor placement and selection. Mobile // tap routing is handled separately under mobile/src/terminal. - return isTerminalLinkActivation(event) + return isTerminalHttpLinkActivation(event) } function* iterateTerminalHttpUrlCandidates( @@ -185,56 +192,40 @@ function isAsciiWordCode(code: number): boolean { ) } -function getTerminalScreenElement(terminal: Terminal): HTMLElement | null { - return terminal.element?.querySelector('.xterm-screen') ?? null -} - -function getBufferPositionForTerminalMouseEvent( +export function openHttpLinkAtTerminalMouseEvent( terminal: Terminal, - event: MouseEvent -): { x: number; y: number } | null { - const screenElement = getTerminalScreenElement(terminal) - if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) { - return null + event: MouseEvent, + deps: UrlLinkHitTestDeps +): boolean { + if (event.button !== 0 || !isTerminalHttpLinkActivation(event)) { + return false } - - const rect = screenElement.getBoundingClientRect() - const relativeX = event.clientX - rect.left - const relativeY = event.clientY - rect.top - if (relativeX < 0 || relativeY < 0 || relativeX >= rect.width || relativeY >= rect.height) { - return null - } - - const cellWidth = rect.width / terminal.cols - const cellHeight = rect.height / terminal.rows - if (cellWidth <= 0 || cellHeight <= 0) { - return null - } - - return { - x: Math.floor(relativeX / cellWidth) + 1, - y: Math.floor(relativeY / cellHeight) + terminal.buffer.active.viewportY + 1 + const position = getTerminalBufferPositionForMouseEvent(terminal, event) + if (!position) { + return false } + return openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, deps) } export function installHttpLinkClickFallback( terminal: Terminal, deps: UrlLinkClickFallbackDeps ): IDisposable { + const ptyMouseSuppression = installTerminalLinkPtyMouseSuppression(terminal, (event) => { + const position = getTerminalBufferPositionForMouseEvent(terminal, event) + return Boolean( + position && findHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols) + ) + }) const handleMouseUp = (event: MouseEvent): void => { if (!isDesktopHttpLinkFallbackActivation(event)) { return } - const position = getBufferPositionForTerminalMouseEvent(terminal, event) - if (!position) { - return - } - // Why: xterm's WebLinksAddon only activates after hover state exists. This // direct mouseup fallback preserves modifier-clicks when the hover link was // never established, while defaultPrevented avoids duplicate opens. - const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, { + const opened = openHttpLinkAtTerminalMouseEvent(terminal, event, { worktreeId: deps.worktreeId, forceSystemBrowser: event.shiftKey, requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference @@ -249,6 +240,7 @@ export function installHttpLinkClickFallback( terminalElement?.addEventListener('mouseup', handleMouseUp) return { dispose: () => { + ptyMouseSuppression.dispose() terminalElement?.removeEventListener('mouseup', handleMouseUp) } } @@ -260,10 +252,30 @@ export function openHttpLinkAtBufferPosition( terminalColumns: number, deps: UrlLinkHitTestDeps ): boolean { - const logicalLines = buildCandidateLogicalLinesForBufferPosition(buffer, position.y) - if (logicalLines.length === 0) { + const url = findHttpLinkAtBufferPosition(buffer, position, terminalColumns) + if (!url) { return false } + openTerminalHttpLink(url, deps) + return true +} + +function findHttpLinkAtBufferPosition( + buffer: { getLine(y: number): IBufferLine | undefined }, + position: { x: number; y: number }, + terminalColumns: number +): string | null { + const nativeWrappedLogicalLine = buildWrappedLogicalLine(buffer, position.y) + const logicalLines = dedupeLogicalLines([ + ...(nativeWrappedLogicalLine && nativeWrappedLogicalLine.rows.length > 1 + ? [nativeWrappedLogicalLine] + : []), + ...buildHardWrappedHttpLogicalLineCandidates(buffer, position.y), + ...buildCandidateLogicalLinesForBufferPosition(buffer, position.y) + ]) + if (logicalLines.length === 0) { + return null + } for (const logicalLine of logicalLines) { for (const parsed of extractTerminalHttpLinks(logicalLine.text)) { @@ -271,12 +283,11 @@ export function openHttpLinkAtBufferPosition( if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) { continue } - openTerminalHttpLink(parsed.url, deps) - return true + return parsed.url } } - return false + return null } export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void { diff --git a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts new file mode 100644 index 00000000000..c512cc84126 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts @@ -0,0 +1,50 @@ +import type { Terminal } from '@xterm/xterm' +import type { LinkHandlerDeps } from './terminal-link-handlers' +import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' +import { handleOscLink } from './terminal-osc-link-routing' +import { + openHttpLinkAtTerminalMouseEvent, + type TerminalLinkRoutingPreferenceRequester +} from './terminal-url-link-hit-testing' + +type TerminalWebLinkClickDeps = Pick< + LinkHandlerDeps, + 'worktreeId' | 'worktreePath' | 'startupCwd' | 'runtimeEnvironmentId' | 'terminalHomePath' +> & { + terminal: Terminal | null + requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester +} + +export function handleTerminalWebLinkClick( + url: string, + event: MouseEvent | undefined, + deps: TerminalWebLinkClickDeps +): boolean { + if (!event || !isTerminalHttpLinkActivation(event)) { + return false + } + + let handled: boolean + if ( + deps.terminal && + openHttpLinkAtTerminalMouseEvent(deps.terminal, event, { + worktreeId: deps.worktreeId, + forceSystemBrowser: Boolean(event.shiftKey), + requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference + }) + ) { + // Why: WebLinksAddon only knows the physical row; Orca's logical hit-test + // preserves the complete URL rendered across hard-wrapped TUI rows. + event.preventDefault() + handled = true + } else { + handled = handleOscLink(url, event, deps) + } + + if (handled) { + // Why: link navigation can steal focus before xterm's mouseup cleanup; + // clearing selection also detaches its pending drag-selection listeners. + deps.terminal?.clearSelection() + } + return handled +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index d9a1a6f10df..a385723d417 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -31,6 +31,7 @@ import { import { createTerminalHandleLinkProvider } from './terminal-handle-links' import type { LinkHandlerDeps } from './terminal-link-handlers' import { handleOscLink } from './terminal-osc-link-routing' +import { handleTerminalWebLinkClick } from './terminal-web-link-click' import { installHttpLinkClickFallback, type TerminalLinkRoutingPreferenceRequester @@ -66,6 +67,7 @@ import { parseOsc7 } from './parse-osc7' import { guardParserHandler } from './terminal-parser-handler-guard' import { resolveTerminalJisYenInput } from './terminal-jis-yen-input' import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' +import { installTerminalImeLinuxCandidateState } from './terminal-ime-linux-candidate-state' import { armTerminalImePendingCandidateKeyRelease, clearTerminalImePendingCandidateKeyRelease, @@ -495,6 +497,7 @@ export function getPreviousVisibleForTerminalPane(args: { return args.previous.isVisible } +/** Wires mounted terminal panes to renderer state and terminal event handling. */ export function useTerminalPaneLifecycle({ tabId, worktreeId, @@ -890,9 +893,17 @@ export function useTerminalPaneLifecycle({ !isMac && navigator.userAgent.includes('Linux') && !/Android|CrOS/.test(navigator.userAgent) + const linuxImeCandidateState = isLinux + ? installTerminalImeLinuxCandidateState(pane.terminal.element) + : null const macNativeTextInputSourceTracker = isMac ? getMacNativeTextInputSourceTracker() : null const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element) - imeCompositionDisposablesRef.current.set(pane.id, imeCompositionTracker) + imeCompositionDisposablesRef.current.set(pane.id, { + dispose: () => { + imeCompositionTracker.dispose() + linuxImeCandidateState?.dispose() + } + }) // Why: only known macOS native text paths need xterm keydown bypass. // Source gates cover physical CJK/Vietnamese IME rewrites; synthetic // Unicode key events are detected by missing physical key identity. @@ -911,6 +922,13 @@ export function useTerminalPaneLifecycle({ } imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder) pane.terminal.attachCustomKeyEventHandler((e) => { + const linuxCandidateClassification = linuxImeCandidateState?.classifyKeyboardEvent(e) ?? { + candidateDigitGuardActive: false + } + /** Advances the fallback state after every terminal key event path. */ + const observeLinuxCandidateEvent = (): void => { + linuxImeCandidateState?.observeKeyboardEvent(e, linuxCandidateClassification) + } const now = Date.now() const pendingCandidateReleaseGuardActive = shouldApplyTerminalImePendingCandidateKeyRelease( @@ -924,6 +942,8 @@ export function useTerminalPaneLifecycle({ imeCompositionTracker.isCandidateKeyGuardActive() || pendingCandidateReleaseGuardActive, pendingCandidateKeyReleaseActive: pendingCandidateReleaseGuardActive, + linuxOrphanCandidateDigitGuardActive: + linuxCandidateClassification.candidateDigitGuardActive, isMac, isLinux } @@ -941,11 +961,13 @@ export function useTerminalPaneLifecycle({ now ) } + observeLinuxCandidateEvent() return false } clearTerminalImePendingCandidateKeyRelease(pendingTerminalImeCandidateKeyReleases, e) if (pendingTerminalInterruptKeyup && shouldSuppressTerminalInterruptKeyup(e)) { pendingTerminalInterruptKeyup = false + observeLinuxCandidateEvent() return false } if ( @@ -965,11 +987,13 @@ export function useTerminalPaneLifecycle({ } else { pendingTerminalInterruptKeyup = false } + observeLinuxCandidateEvent() return false } if (shouldSuppressTerminalModifierKeyboardEvent(e)) { // Why: stale Kitty keyboard reporting can encode standalone // modifier presses before Ctrl+C reaches the interrupt handler. + observeLinuxCandidateEvent() return false } @@ -983,6 +1007,7 @@ export function useTerminalPaneLifecycle({ // Keep it on xterm's onData path so PTY input guards still run. pane.terminal.input(jisYenInput.data) } + observeLinuxCandidateEvent() return false } @@ -998,13 +1023,16 @@ export function useTerminalPaneLifecycle({ if (imeNativeTextForwarder.claimKeyEvent(e)) { // Why: bypass xterm's kitty encoder for native text keydowns so the // committed glyph survives via the input event. + observeLinuxCandidateEvent() return false } - return !shouldBypassXtermKeyboardEvent(e, { + const shouldBypass = shouldBypassXtermKeyboardEvent(e, { isMac, hasSelection: pane.terminal.hasSelection() }) + observeLinuxCandidateEvent() + return !shouldBypass }) const linkProviderDisposable = pane.terminal.registerLinkProvider( @@ -1453,28 +1481,16 @@ export function useTerminalPaneLifecycle({ terminalTuiScrollSensitivity: () => normalizeTerminalTuiMouseWheelMultiplier(settingsRef.current?.terminalTuiScrollSensitivity), onLinkClick: (event, url) => { - if (!event) { - return - } const activePane = managerRef.current?.getActivePane() - const handled = handleOscLink(url, event, { + handleTerminalWebLinkClick(url, event, { ...linkDeps, + terminal: activePane?.terminal ?? null, startupCwd: activePane ? getPaneLinkCwd(activePane.id) : startupCwd, runtimeEnvironmentId: activePane ? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null) : null, requestOpenLinksInAppPreference }) - // Why: Cmd/Ctrl+click on a plain-text URL (WebLinksAddon) takes focus - // away from the terminal before the click's mouseup reaches - // ownerDocument. That leaves xterm's SelectionService drag-select - // mousemove listener attached, so subsequent mouse motion extends a - // phantom selection until the next click/Esc. Explicitly clearing the - // selection also detaches those listeners (see - // SelectionService._removeMouseDownListeners). - if (handled) { - managerRef.current?.getActivePane()?.terminal.clearSelection() - } }, formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint), // Why: TerminalPane instances stay mounted for hidden visited worktrees diff --git a/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts b/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts index b648950bcd0..b94affd030b 100644 --- a/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts +++ b/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts @@ -65,7 +65,7 @@ function translateLineWithCells(line: IBufferLine): { text: string; columns: num return { text, columns } } -function translateLineWithColumns(line: IBufferLine): { text: string; columns: number[] } { +export function translateLineWithColumns(line: IBufferLine): { text: string; columns: number[] } { const columns: number[] = [] const text = (line as TerminalBufferLineWithColumns).translateToString( false, diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts index 1f3a78326cd..8ea502f9eda 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts @@ -166,6 +166,10 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { // Post-compositionend guard: the tracker is already inactive but the // committing key's trailing press/release must still be absorbed. const linuxPostCompositionGuard = { ...linuxIdle, candidateKeyGuardActive: true } + const linuxOrphanCandidateDigitGuard = { + ...linuxIdle, + linuxOrphanCandidateDigitGuardActive: true + } const windowsComposing = { ...windowsIdle, compositionActive: true, @@ -314,6 +318,21 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { } }) + it('guards only digits for the orphaned-keyup fallback', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: '2', code: 'Digit2' }), + linuxOrphanCandidateDigitGuard + ) + ).toBe(true) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: ' ', code: 'Space' }), + linuxOrphanCandidateDigitGuard + ) + ).toBe(false) + }) + it('does not treat modified chords such as Ctrl+Space (IME toggle) as candidate keys', () => { expect( shouldSuppressTerminalImeKeyboardEvent( @@ -406,5 +425,20 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { ) ).toBe(false) }) + + it('prevents the default only for fallback digit keydowns', () => { + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: '2', code: 'Digit2' }), + linuxOrphanCandidateDigitGuard + ) + ).toBe(true) + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: ' ', code: 'Space' }), + linuxOrphanCandidateDigitGuard + ) + ).toBe(false) + }) }) }) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index 34e1240ef84..5f9f3a63d04 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -1,5 +1,8 @@ import { keybindingMatchesInput } from '../../../../shared/keybindings' -import { isTerminalImeCandidateSelectionKeyEvent } from './terminal-ime-candidate-key-release-guard' +import { + isTerminalImeCandidateDigitKeyEvent, + isTerminalImeCandidateSelectionKeyEvent +} from './terminal-ime-candidate-key-release-guard' // Why: when a CLI activates kitty progressive enhancement (CSI > N u), xterm's // KittyKeyboard encoder turns every modifier chord — including plain Cmd+C — @@ -42,6 +45,9 @@ export type XtermImeKeyboardOptions = { candidateKeyGuardActive: boolean /** True when the pending-release guard already matched this specific event. */ pendingCandidateKeyReleaseActive: boolean + /** True for the narrow Linux path where the IME emits an orphaned letter + * keyup but no composition/input events before its candidate digit. */ + linuxOrphanCandidateDigitGuardActive?: boolean // Required so no caller silently falls back to non-mac 229 suppression, // which re-swallows the first key after a macOS IME input-source switch. isMac: boolean @@ -81,6 +87,7 @@ function isXtermHandledKeyEvent(type: string): boolean { return type === 'keydown' || type === 'keyup' } +/** Returns whether xterm must not process an IME-owned keyboard event. */ export function shouldSuppressTerminalImeKeyboardEvent( event: XtermBypassEvent, options: XtermImeKeyboardOptions @@ -89,13 +96,17 @@ export function shouldSuppressTerminalImeKeyboardEvent( compositionActive, candidateKeyGuardActive, pendingCandidateKeyReleaseActive, + linuxOrphanCandidateDigitGuardActive = false, isMac, isLinux } = options + const suppressOrphanCandidateDigit = + isLinux && linuxOrphanCandidateDigitGuardActive && isTerminalImeCandidateDigitKeyEvent(event) const suppressCandidateKey = isLinux && (pendingCandidateKeyReleaseActive || - (candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event))) + (candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)) || + suppressOrphanCandidateDigit) if (event.type === 'keypress') { // Why: a suppressed candidate keydown is not preventDefault-ed by xterm, // so its native keypress still fires and _keyPress would forward the @@ -121,6 +132,7 @@ export function shouldSuppressTerminalImeKeyboardEvent( ) } +/** Returns whether a candidate keydown needs native default prevention. */ export function shouldPreventDefaultTerminalImeCandidateKey( event: XtermBypassEvent, options: XtermImeKeyboardOptions @@ -132,8 +144,9 @@ export function shouldPreventDefaultTerminalImeCandidateKey( return ( event.type === 'keydown' && options.isLinux && - options.candidateKeyGuardActive && - isTerminalImeCandidateSelectionKeyEvent(event) + ((options.candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)) || + (options.linuxOrphanCandidateDigitGuardActive === true && + isTerminalImeCandidateDigitKeyEvent(event))) ) } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts index 86f2971931e..923d287bb6a 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts @@ -90,4 +90,141 @@ describe('workspace cleanup late settlement reconciliation', () => { }) expect(toast.error).not.toHaveBeenCalled() }) + + it('reports later child settlements while a post-batch ancestor retry is stalled', async () => { + const firstParent = makeCandidate({ + worktreeId: 'repo-1::/repo/a', + displayName: 'first parent', + branch: 'first-parent', + path: '/repo/a' + }) + const firstChild = makeCandidate({ + worktreeId: 'repo-1::/repo/a/child-long', + displayName: 'first child', + branch: 'first-child', + path: '/repo/a/child-long' + }) + const secondParent = makeCandidate({ + worktreeId: 'repo-1::/repo/bb', + displayName: 'second parent', + branch: 'second-parent', + path: '/repo/bb' + }) + const secondChild = makeCandidate({ + worktreeId: 'repo-1::/repo/bb/c', + displayName: 'second child', + branch: 'second-child', + path: '/repo/bb/c' + }) + let resolveFirstChild: (result: { removedIds: string[]; failures: [] }) => void = () => {} + let resolveSecondChild: (result: { removedIds: string[]; failures: [] }) => void = () => {} + const removeCandidates = vi + .fn() + .mockImplementationOnce( + () => + new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { + resolveFirstChild = resolve + }) + ) + .mockImplementationOnce( + () => + new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { + resolveSecondChild = resolve + }) + ) + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce({ removedIds: [secondParent.worktreeId], failures: [] }) + const onLateResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [firstParent, firstChild, secondParent, secondChild], + removeCandidates, + onProgress: vi.fn(), + onResult: vi.fn(), + onLateResult, + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 + }) + + await vi.advanceTimersByTimeAsync(20) + await settleBackgroundRemoval() + resolveFirstChild({ removedIds: [firstChild.worktreeId], failures: [] }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(3, [firstParent.worktreeId], { + approvedCandidates: [firstParent] + }) + expect(onLateResult).toHaveBeenNthCalledWith(1, { + removedIds: [firstChild.worktreeId], + failures: [] + }) + + resolveSecondChild({ removedIds: [secondChild.worktreeId], failures: [] }) + await settleBackgroundRemoval() + + expect(onLateResult).toHaveBeenNthCalledWith(2, { + removedIds: [secondChild.worktreeId], + failures: [] + }) + expect(removeCandidates).toHaveBeenCalledTimes(3) + + await vi.advanceTimersByTimeAsync(10) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(4, [secondParent.worktreeId], { + approvedCandidates: [secondParent] + }) + expect(onLateResult).toHaveBeenCalledWith({ + removedIds: [secondParent.worktreeId], + failures: [] + }) + expect(toast.info).toHaveBeenCalledWith('Still removing workspaces: 1') + }) + + it('retries an unblocked ancestor when immediate late-result reporting throws', async () => { + const parent = makeCandidate({ + worktreeId: 'repo-1::/repo/parent', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child' + }) + let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {} + const removeCandidates = vi + .fn() + .mockImplementationOnce( + () => + new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { + resolveChild = resolve + }) + ) + .mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] }) + vi.mocked(toast.success).mockImplementationOnce(() => { + throw new Error('toast renderer failed') + }) + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress: vi.fn(), + onResult: vi.fn(), + onLateResult: vi.fn(), + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 + }) + + await vi.advanceTimersByTimeAsync(10) + await settleBackgroundRemoval() + resolveChild({ removedIds: [child.worktreeId], failures: [] }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], { + approvedCandidates: [parent] + }) + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts index 0201b66f322..9b785d3ed71 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts @@ -510,11 +510,15 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { approvedCandidates: [parent] }) expect(onResult).toHaveBeenCalledTimes(1) - expect(onLateResult).toHaveBeenCalledWith({ - removedIds: [child.worktreeId, parent.worktreeId], + expect(onLateResult).toHaveBeenNthCalledWith(1, { + removedIds: [child.worktreeId], failures: [] }) - expect(toast.success).toHaveBeenLastCalledWith('Removed workspaces: 2') + expect(onLateResult).toHaveBeenNthCalledWith(2, { + removedIds: [parent.worktreeId], + failures: [] + }) + expect(toast.success).toHaveBeenLastCalledWith('Removed workspaces: 1') }) it('hardens a provisional parent skip after the child late-fails post-batch', async () => { @@ -554,13 +558,18 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { rejectChild(new Error('remote removal failed')) await settleBackgroundRemoval() - expect(onLateResult).toHaveBeenCalledWith({ + expect(onLateResult).toHaveBeenNthCalledWith(1, { removedIds: [], failures: [ expect.objectContaining({ worktreeId: child.worktreeId, message: 'remote removal failed' - }), + }) + ] + }) + expect(onLateResult).toHaveBeenNthCalledWith(2, { + removedIds: [], + failures: [ expect.objectContaining({ worktreeId: parent.worktreeId, message: 'Skipped because a nested workspace could not be removed.' diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts index 1270bb658dc..d9a04bf8af7 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -11,11 +11,13 @@ import { isStrictWorkspaceCleanupDescendant, type SkippedWorkspaceCleanupAncestor } from './workspace-cleanup-ancestor-skips' -import { reconcilePostBatchLateSettlement } from './workspace-cleanup-post-batch-late-settlement' +import { createPostBatchLateSettlementReporter } from './workspace-cleanup-post-batch-late-settlement' import { getWorkspaceCleanupTimeoutFailure, trackWorkspaceCleanupLateSettlement, - waitForWorkspaceCleanupRemovalWithTimeout + waitForWorkspaceCleanupRemovalWithTimeout, + type WorkspaceCleanupLateSettlementReporter, + type WorkspaceCleanupLateSettlementTracker } from './workspace-cleanup-removal-settlement' import { showWorkspaceCleanupRemovalResultToasts } from './workspace-cleanup-removal-toasts' import { reclassifySkippedWorkspaceCleanupAncestors } from './workspace-cleanup-skipped-ancestor-reclassification' @@ -74,15 +76,12 @@ export function startWorkspaceCleanupBackgroundRemoval({ const removedIds: string[] = [] const failures: WorkspaceCleanupFailure[] = [] const failedCandidates: WorkspaceCleanupCandidate[] = [] - const detachLateResultReconcilers: (() => void)[] = [] + const lateSettlementTrackers: WorkspaceCleanupLateSettlementTracker[] = [] // Why: rows past the removal deadline are still removing; their skip fallout // and batch toasts must not present them as definitive failures. const provisionallyBlocked = new Set() const pendingSettlementFailures = new Set() const skippedAncestors: SkippedWorkspaceCleanupAncestor[] = [] - // Why: serialize post-batch late reconciles so concurrent child settlements - // do not interleave ancestor reclassification and retries. - let postBatchLateReconcileChain: Promise = Promise.resolve() let processedCount = 0 const emitProgress = (): void => { @@ -105,9 +104,13 @@ export function startWorkspaceCleanupBackgroundRemoval({ } } - const detachAllLateResultReconcilers = (): void => { - for (const detach of detachLateResultReconcilers) { - detach() + const detachAllLateResultReconcilers = ( + getReportAfterBatchResult?: ( + candidate: WorkspaceCleanupLateSettlementTracker['candidate'] + ) => WorkspaceCleanupLateSettlementReporter + ): void => { + for (const tracker of lateSettlementTrackers) { + tracker.detach(getReportAfterBatchResult?.(tracker.candidate)) } } @@ -161,31 +164,6 @@ export function startWorkspaceCleanupBackgroundRemoval({ } } - const enqueuePostBatchLateSettlement = ( - settledCandidate: WorkspaceCleanupCandidate, - timeoutFailure: WorkspaceCleanupFailure, - lateResult: WorkspaceCleanupRemoveResult - ): void => { - postBatchLateReconcileChain = postBatchLateReconcileChain - .then(async () => { - const reconciled = await reconcilePostBatchLateSettlement({ - lateResult, - settledCandidate, - timeoutFailure, - skippedAncestors, - failedCandidates, - failures, - provisionallyBlocked, - pendingSettlementFailures, - removeCandidates - }) - reportLateWorkspaceCleanupResult(reconciled, onLateResult) - }) - .catch((error: unknown) => { - console.error('Workspace cleanup post-batch late settlement failed', error) - }) - } - void (async () => { while (queue.length > 0) { const candidate = queue.shift() @@ -216,26 +194,19 @@ export function startWorkspaceCleanupBackgroundRemoval({ // its authoritative settlement without unblocking ancestors early. // After the batch ends, detach switches to the post-batch path which // still holds skip state so ancestors can reclassify. - detachLateResultReconcilers.push( - trackWorkspaceCleanupLateSettlement( - outcome.settlement, - candidate, - (lateResult) => { - removeArrayEntry(failures, timeoutFailure) - pendingSettlementFailures.delete(timeoutFailure) - provisionallyBlocked.delete(candidate) - removedIds.push(...lateResult.removedIds) - reportFailures(lateResult.failures) - if (lateResult.failures.length === 0) { - removeArrayEntry(failedCandidates, candidate) - } - resettleSkippedAncestors() - emitProgress() - }, - (lateResult) => { - enqueuePostBatchLateSettlement(candidate, timeoutFailure, lateResult) + lateSettlementTrackers.push( + trackWorkspaceCleanupLateSettlement(outcome.settlement, candidate, (lateResult) => { + removeArrayEntry(failures, timeoutFailure) + pendingSettlementFailures.delete(timeoutFailure) + provisionallyBlocked.delete(candidate) + removedIds.push(...lateResult.removedIds) + reportFailures(lateResult.failures) + if (lateResult.failures.length === 0) { + removeArrayEntry(failedCandidates, candidate) } - ) + resettleSkippedAncestors() + emitProgress() + }) ) continue } @@ -260,7 +231,19 @@ export function startWorkspaceCleanupBackgroundRemoval({ } } - detachAllLateResultReconcilers() + detachAllLateResultReconcilers( + createPostBatchLateSettlementReporter({ + skippedAncestors, + failedCandidates, + provisionallyBlocked, + removeCandidates, + removalTimeoutMs, + removalSettlementGraceMs, + reportResult: (lateResult, latePendingFailures) => { + reportLateWorkspaceCleanupResult(lateResult, onLateResult, latePendingFailures) + } + }) + ) const result = { removedIds, failures } try { onResult?.(result) @@ -286,14 +269,15 @@ export function startWorkspaceCleanupBackgroundRemoval({ function reportLateWorkspaceCleanupResult( result: WorkspaceCleanupRemoveResult, - onLateResult: WorkspaceCleanupBackgroundRemovalArgs['onLateResult'] + onLateResult: WorkspaceCleanupBackgroundRemovalArgs['onLateResult'], + pendingSettlementFailures?: ReadonlySet ): void { try { onLateResult?.(result) } catch (callbackError) { console.error('Workspace cleanup late result callback failed', callbackError) } - showWorkspaceCleanupRemovalResultToasts(result) + showWorkspaceCleanupRemovalResultToasts(result, pendingSettlementFailures) } function removeArrayEntry(entries: T[], entry: T): void { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-post-batch-late-settlement.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-post-batch-late-settlement.ts index 131ce504ed6..6239da304b2 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-post-batch-late-settlement.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-post-batch-late-settlement.ts @@ -9,59 +9,148 @@ import { isStrictWorkspaceCleanupDescendant, type SkippedWorkspaceCleanupAncestor } from './workspace-cleanup-ancestor-skips' +import { + getWorkspaceCleanupTimeoutFailure, + trackWorkspaceCleanupLateSettlement, + waitForWorkspaceCleanupRemovalWithTimeout, + type WorkspaceCleanupLateSettlementCandidate, + type WorkspaceCleanupLateSettlementReporter +} from './workspace-cleanup-removal-settlement' import { reclassifySkippedWorkspaceCleanupAncestors } from './workspace-cleanup-skipped-ancestor-reclassification' -export type PostBatchLateSettlementArgs = { - lateResult: WorkspaceCleanupRemoveResult - settledCandidate: WorkspaceCleanupCandidate - timeoutFailure: WorkspaceCleanupFailure +type WorkspaceCleanupRemoveCandidates = ( + worktreeIds: readonly string[], + options?: WorkspaceCleanupRemoveOptions +) => Promise + +type PostBatchLateSettlementState = { skippedAncestors: SkippedWorkspaceCleanupAncestor[] failedCandidates: WorkspaceCleanupCandidate[] failures: WorkspaceCleanupFailure[] provisionallyBlocked: Set - pendingSettlementFailures: Set - removeCandidates: ( - worktreeIds: readonly string[], - options?: WorkspaceCleanupRemoveOptions - ) => Promise + removeCandidates: WorkspaceCleanupRemoveCandidates | null + removalTimeoutMs: number + removalSettlementGraceMs: number } -// Why: once the batch detaches its mid-loop reconcilers, late child results must -// still reclassify ancestors and retry unblocked ones so rowFailures do not keep -// the provisional skip after the blocker settles. -export async function reconcilePostBatchLateSettlement({ - lateResult, - settledCandidate, - timeoutFailure, +export type PostBatchLateSettlementResultReporter = ( + result: WorkspaceCleanupRemoveResult, + pendingSettlementFailures?: ReadonlySet +) => void + +export type PostBatchLateSettlementReporterSelector = ( + candidate: WorkspaceCleanupLateSettlementCandidate +) => WorkspaceCleanupLateSettlementReporter + +export function createPostBatchLateSettlementReporter({ skippedAncestors, failedCandidates, - failures, provisionallyBlocked, - pendingSettlementFailures, - removeCandidates -}: PostBatchLateSettlementArgs): Promise { - removeArrayEntry(failures, timeoutFailure) - pendingSettlementFailures.delete(timeoutFailure) - provisionallyBlocked.delete(settledCandidate) - if (lateResult.failures.length === 0) { - removeArrayEntry(failedCandidates, settledCandidate) + removeCandidates, + removalTimeoutMs, + removalSettlementGraceMs, + reportResult +}: { + skippedAncestors: readonly SkippedWorkspaceCleanupAncestor[] + failedCandidates: readonly WorkspaceCleanupCandidate[] + provisionallyBlocked: ReadonlySet + removeCandidates: WorkspaceCleanupRemoveCandidates + removalTimeoutMs: number + removalSettlementGraceMs: number + reportResult: PostBatchLateSettlementResultReporter +}): PostBatchLateSettlementReporterSelector { + // Why: detached IPC promises can outlive the dialog; retain only unresolved + // blockers and provisional skips, not the completed batch's result arrays. + const retainedSkippedAncestors = skippedAncestors + .filter((entry) => entry.provisional) + .map((entry) => ({ ...entry, failure: { ...entry.failure } })) + const retainedCandidates = failedCandidates.filter( + (candidate) => + provisionallyBlocked.has(candidate) && + retainedSkippedAncestors.some( + (entry) => + entry.candidate.worktreeId === candidate.worktreeId || + isStrictWorkspaceCleanupDescendant(entry.candidate, candidate) + ) + ) + const state: PostBatchLateSettlementState = { + skippedAncestors: retainedSkippedAncestors, + failedCandidates: retainedCandidates, + failures: retainedSkippedAncestors.map((entry) => entry.failure), + provisionallyBlocked: new Set(retainedCandidates), + removeCandidates, + removalTimeoutMs, + removalSettlementGraceMs + } + let reconcileChain: Promise = Promise.resolve() + const reportLateSettlement: WorkspaceCleanupLateSettlementReporter = (candidate, result) => { + // Why: queue reconciliation before reporting so a presentation-layer error + // cannot suppress ancestor retries; the report still runs synchronously. + reconcileChain = reconcileChain + .then(async () => { + const reconciled = await reconcilePostBatchLateSettlement( + state, + candidate, + result, + reportLateSettlement + ) + if (reconciled.result.removedIds.length > 0 || reconciled.result.failures.length > 0) { + reportResult(reconciled.result, reconciled.pendingSettlementFailures) + } + }) + .catch((error: unknown) => { + console.error('Workspace cleanup post-batch late settlement failed', error) + }) + reportResult(result) + } + const retainedCandidateIds = new Set(retainedCandidates.map((candidate) => candidate.worktreeId)) + const reportWithoutReconciliation: WorkspaceCleanupLateSettlementReporter = ( + _candidate, + result + ) => { + reportResult(result) + } + return (candidate) => + retainedCandidateIds.has(candidate.worktreeId) + ? reportLateSettlement + : reportWithoutReconciliation +} + +async function reconcilePostBatchLateSettlement( + state: PostBatchLateSettlementState, + settledCandidateIdentity: WorkspaceCleanupLateSettlementCandidate, + lateResult: WorkspaceCleanupRemoveResult, + reportLateSettlement: WorkspaceCleanupLateSettlementReporter +): Promise<{ + result: WorkspaceCleanupRemoveResult + pendingSettlementFailures?: ReadonlySet +}> { + const settledCandidate = state.failedCandidates.find( + (candidate) => candidate.worktreeId === settledCandidateIdentity.worktreeId + ) + if (settledCandidate) { + state.provisionallyBlocked.delete(settledCandidate) + if (lateResult.failures.length === 0) { + removeArrayEntry(state.failedCandidates, settledCandidate) + } } - const removedIds = [...lateResult.removedIds] - const lateFailures = [...lateResult.failures] + const removedIds: string[] = [] + const lateFailures: WorkspaceCleanupFailure[] = [] + const pendingSettlementFailures = new Set() const findBlockingDescendants = ( candidate: WorkspaceCleanupCandidate ): WorkspaceCleanupCandidate[] => - failedCandidates.filter((failedCandidate) => + state.failedCandidates.filter((failedCandidate) => isStrictWorkspaceCleanupDescendant(candidate, failedCandidate) ) const { unblocked, updatedFailures } = reclassifySkippedWorkspaceCleanupAncestors({ - skippedAncestors, + skippedAncestors: state.skippedAncestors, findBlockingDescendants, - provisionallyBlocked, - failedCandidates, - failures + provisionallyBlocked: state.provisionallyBlocked, + failedCandidates: state.failedCandidates, + failures: state.failures }) lateFailures.push(...updatedFailures) @@ -71,41 +160,95 @@ export async function reconcilePostBatchLateSettlement({ for (const ancestor of unblocked) { const blockers = findBlockingDescendants(ancestor) if (blockers.length > 0) { - const provisional = blockers.every((blocker) => provisionallyBlocked.has(blocker)) + const provisional = blockers.every((blocker) => state.provisionallyBlocked.has(blocker)) const failure: WorkspaceCleanupFailure = { worktreeId: ancestor.worktreeId, displayName: ancestor.displayName, message: getSkippedAncestorMessage(provisional) } if (provisional) { - provisionallyBlocked.add(ancestor) + state.provisionallyBlocked.add(ancestor) } - failedCandidates.push(ancestor) - skippedAncestors.push({ candidate: ancestor, failure, provisional }) + state.failedCandidates.push(ancestor) + state.skippedAncestors.push({ candidate: ancestor, failure, provisional }) + state.failures.push(failure) lateFailures.push(failure) continue } + const removeCandidates = state.removeCandidates + if (!removeCandidates) { + continue + } + let removal: Promise try { - const result = await removeCandidates([ancestor.worktreeId], { + removal = removeCandidates([ancestor.worktreeId], { approvedCandidates: [ancestor] }) - removedIds.push(...result.removedIds) - if (result.failures.length > 0) { - failedCandidates.push(ancestor) - lateFailures.push(...result.failures) - } } catch (error: unknown) { - failedCandidates.push(ancestor) + state.failedCandidates.push(ancestor) lateFailures.push({ worktreeId: ancestor.worktreeId, displayName: ancestor.displayName, message: error instanceof Error ? error.message : String(error) }) + continue + } + const outcome = await waitForWorkspaceCleanupRemovalWithTimeout( + removal, + state.removalTimeoutMs, + state.removalSettlementGraceMs + ) + if (outcome.status === 'unresolved') { + const timeoutFailure = getWorkspaceCleanupTimeoutFailure(ancestor) + state.failedCandidates.push(ancestor) + state.provisionallyBlocked.add(ancestor) + lateFailures.push(timeoutFailure) + pendingSettlementFailures.add(timeoutFailure) + const tracker = trackWorkspaceCleanupLateSettlement(outcome.settlement, ancestor, () => {}) + tracker.detach(reportLateSettlement) + continue + } + const result = + outcome.status === 'fulfilled' + ? outcome.result + : { + removedIds: [], + failures: [ + { + worktreeId: ancestor.worktreeId, + displayName: ancestor.displayName, + message: + outcome.error instanceof Error ? outcome.error.message : String(outcome.error) + } + ] + } + removedIds.push(...result.removedIds) + if (result.failures.length > 0) { + state.failedCandidates.push(ancestor) + lateFailures.push(...result.failures) } } - return { removedIds, failures: lateFailures } + releaseSettledPostBatchState(state) + return { + result: { removedIds, failures: lateFailures }, + pendingSettlementFailures: + pendingSettlementFailures.size > 0 ? pendingSettlementFailures : undefined + } +} + +function releaseSettledPostBatchState(state: PostBatchLateSettlementState): void { + if (state.skippedAncestors.some((entry) => entry.provisional)) { + return + } + // Why: once no provisional ancestor can change, remaining late settlements + // only report themselves and must not pin removal or candidate state. + state.skippedAncestors.length = 0 + state.failedCandidates.length = 0 + state.failures.length = 0 + state.provisionallyBlocked.clear() + state.removeCandidates = null } function removeArrayEntry(entries: T[], entry: T): void { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts new file mode 100644 index 00000000000..8a14e9a17a5 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import { + trackWorkspaceCleanupLateSettlement, + type WorkspaceCleanupRemovalSettlement +} from './workspace-cleanup-removal-settlement' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +describe('workspace cleanup late settlement tracking', () => { + it('detaches the batch callback and reports with a compact candidate identity', async () => { + const candidate = makeCandidate() + let resolveSettlement: (settlement: WorkspaceCleanupRemovalSettlement) => void = () => {} + const settlement = new Promise((resolve) => { + resolveSettlement = resolve + }) + const reconcileBeforeBatchResult = vi.fn() + const reportAfterBatchResult = vi.fn() + const tracker = trackWorkspaceCleanupLateSettlement( + settlement, + candidate, + reconcileBeforeBatchResult + ) + + expect(tracker.candidate).toEqual({ + worktreeId: candidate.worktreeId, + displayName: candidate.displayName + }) + expect(tracker.candidate).not.toBe(candidate) + tracker.detach(reportAfterBatchResult) + resolveSettlement({ status: 'rejected', error: new Error('remote removal failed') }) + await settlement + await Promise.resolve() + + expect(reconcileBeforeBatchResult).not.toHaveBeenCalled() + expect(reportAfterBatchResult).toHaveBeenCalledWith( + { worktreeId: candidate.worktreeId, displayName: candidate.displayName }, + { + removedIds: [], + failures: [ + { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: 'remote removal failed' + } + ] + } + ) + expect(reportAfterBatchResult.mock.calls[0][0]).not.toBe(candidate) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.ts index adc58a2d736..d469d2b7486 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.ts @@ -13,6 +13,21 @@ export type WorkspaceCleanupRemovalWaitResult = | WorkspaceCleanupRemovalSettlement | { status: 'unresolved'; settlement: Promise } +export type WorkspaceCleanupLateSettlementCandidate = Pick< + WorkspaceCleanupCandidate, + 'worktreeId' | 'displayName' +> + +export type WorkspaceCleanupLateSettlementReporter = ( + candidate: WorkspaceCleanupLateSettlementCandidate, + result: WorkspaceCleanupRemoveResult +) => void + +export type WorkspaceCleanupLateSettlementTracker = { + candidate: WorkspaceCleanupLateSettlementCandidate + detach: (reportAfterBatchResult?: WorkspaceCleanupLateSettlementReporter) => void +} + export async function waitForWorkspaceCleanupRemovalWithTimeout( promise: Promise, timeoutMs: number, @@ -48,27 +63,49 @@ export function getWorkspaceCleanupTimeoutFailure( export function trackWorkspaceCleanupLateSettlement( settlement: Promise, candidate: WorkspaceCleanupCandidate, - reconcileBeforeBatchResult: (result: WorkspaceCleanupRemoveResult) => void, - reportAfterBatchResult: (result: WorkspaceCleanupRemoveResult) => void -): () => void { - // Why: a truly hung IPC promise can live for the renderer's lifetime; detach - // drops the reconcile closure (and the batch arrays it captures) at batch end. - let reconcile: ((result: WorkspaceCleanupRemoveResult) => void) | null = - reconcileBeforeBatchResult + reconcileBeforeBatchResult: (result: WorkspaceCleanupRemoveResult) => void +): WorkspaceCleanupLateSettlementTracker { + const candidateIdentity: WorkspaceCleanupLateSettlementCandidate = { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName + } + const state: { + active: boolean + reconcile: ((result: WorkspaceCleanupRemoveResult) => void) | null + report: WorkspaceCleanupLateSettlementReporter | null + } = { + active: true, + reconcile: reconcileBeforeBatchResult, + report: null + } settlement .then((outcome) => { - const result = toWorkspaceCleanupRemoveResult(candidate, outcome) + const reconcile = state.reconcile + const report = state.report + state.active = false + state.reconcile = null + state.report = null + const result = toWorkspaceCleanupRemoveResult(candidateIdentity, outcome) if (reconcile) { reconcile(result) return } - reportAfterBatchResult(result) + report?.(candidateIdentity, result) }) .catch((error: unknown) => { console.error('Workspace cleanup late settlement reporting failed', error) }) - return () => { - reconcile = null + return { + candidate: candidateIdentity, + detach: (reportAfterBatchResult) => { + if (!state.active) { + return + } + // Why: hung settlements retain only a two-field identity and the compact + // post-batch reporter, never the candidate or batch reconciliation closure. + state.reconcile = null + state.report = reportAfterBatchResult ?? null + } } } @@ -82,7 +119,7 @@ function toWorkspaceCleanupRemovalSettlement( } function toWorkspaceCleanupRemoveResult( - candidate: WorkspaceCleanupCandidate, + candidate: WorkspaceCleanupLateSettlementCandidate, settlement: WorkspaceCleanupRemovalSettlement ): WorkspaceCleanupRemoveResult { if (settlement.status === 'fulfilled') { diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index cbf24b7c1a6..676fa253529 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -3,6 +3,7 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { OnboardingFeatureSetupDeps } from '@/components/onboarding/onboarding-feature-setup' import type { languages } from 'monaco-editor' +import type { MonacoE2EProbe } from './components/editor/monaco-e2e-probe' declare module 'monaco-editor/esm/vs/basic-languages/python/python.js' { export const conf: languages.LanguageConfiguration @@ -71,6 +72,7 @@ declare global { parkDelayMs: number parkedTabIds: () => string[] } + __monacoEditorE2E?: MonacoE2EProbe } } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 38822767e1a..dee27778244 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -946,6 +946,8 @@ describe('useIpcEvents browser tab create routing', () => { repos: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, + onGitStatusMetadataChanged: () => () => {}, + onHeadIdentitiesChanged: () => () => {}, onBaseStatus: () => () => {}, onRemoteBranchConflict: () => () => {} }, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index cd72db3a44b..b93f3d1610b 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3,6 +3,7 @@ import { useEffect } from 'react' import { toast } from 'sonner' import { useAppStore } from '../store' import { shouldRetryPaneSpawnOnSshReconnect } from './ssh-reconnect-pane-retry' +import { applyWorktreeHeadIdentities } from './worktree-head-identity-apply' import { getWorktreeMapFromState, getRepoMapFromState } from '@/store/selectors' import { applyUIZoom } from '@/lib/ui-zoom' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -1141,6 +1142,22 @@ export function useIpcEvents(): void { ) ) + if (window.api.worktrees.onHeadIdentitiesChanged) { + unsubs.push( + window.api.worktrees.onHeadIdentitiesChanged((data) => { + if (isRuntimeEnvironmentActive()) { + // Why: local worktree events carry local repo ids (see onChanged). + return + } + const state = useAppStore.getState() + applyWorktreeHeadIdentities(data, { + getWorktreesForRepo: (repoId) => state.worktreesByRepo[repoId], + updateWorktreeGitIdentity: state.updateWorktreeGitIdentity + }) + }) + ) + } + unsubs.push( window.api.worktrees.onBaseStatus((event) => { if (isRuntimeEnvironmentActive()) { @@ -3029,6 +3046,9 @@ export function useIpcEvents(): void { const statusPayload = data.orchestration ? { ...resolvedPayload, orchestration: data.orchestration } : resolvedPayload + const statusPayloadWithTurnBoundary = data.promptInteractionKey + ? { ...statusPayload, promptInteractionKey: data.promptInteractionKey } + : statusPayload const existingStatus = store.agentStatusByPaneKey[data.paneKey] if (existingStatus && data.receivedAt < existingStatus.updatedAt) { // Why: the store rejects out-of-order status rows; keep notification and @@ -3075,7 +3095,7 @@ export function useIpcEvents(): void { const statusWorktreeId = data.worktreeId ?? owningWorktreeId store.setAgentStatus( data.paneKey, - statusPayload, + statusPayloadWithTurnBoundary, terminalTitle, { updatedAt: data.receivedAt, diff --git a/src/renderer/src/hooks/worktree-head-identity-apply.test.ts b/src/renderer/src/hooks/worktree-head-identity-apply.test.ts new file mode 100644 index 00000000000..3df3e92297e --- /dev/null +++ b/src/renderer/src/hooks/worktree-head-identity-apply.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Worktree } from '../../../shared/types' +import { applyWorktreeHeadIdentities } from './worktree-head-identity-apply' + +function makeWorktree(overrides: Partial): Worktree { + return { + id: 'repo-1::/repos/project/wt-a', + repoId: 'repo-1', + path: '/repos/project/wt-a', + branch: 'refs/heads/feature', + head: 'aaa111', + displayName: 'wt-a', + ...overrides + } as Worktree +} + +describe('applyWorktreeHeadIdentities', () => { + it('updates the matching background worktree row by path', () => { + const updateWorktreeGitIdentity = vi.fn() + applyWorktreeHeadIdentities( + { + repoId: 'repo-1', + identities: [ + { worktreePath: '/repos/project/wt-a', head: 'bbb222', branch: 'refs/heads/feature' } + ] + }, + { + getWorktreesForRepo: () => [makeWorktree({})], + updateWorktreeGitIdentity + } + ) + expect(updateWorktreeGitIdentity).toHaveBeenCalledTimes(1) + expect(updateWorktreeGitIdentity).toHaveBeenCalledWith('repo-1::/repos/project/wt-a', { + head: 'bbb222', + branch: 'refs/heads/feature' + }) + }) + + it('matches Windows-flavored paths that differ only by separators and casing', () => { + const updateWorktreeGitIdentity = vi.fn() + applyWorktreeHeadIdentities( + { + repoId: 'repo-1', + identities: [{ worktreePath: 'C:\\Repos\\Project\\wt-a', head: 'bbb222', branch: null }] + }, + { + getWorktreesForRepo: () => [ + makeWorktree({ id: 'repo-1::C:/repos/project/wt-a', path: 'C:/repos/project/wt-a' }) + ], + updateWorktreeGitIdentity + } + ) + expect(updateWorktreeGitIdentity).toHaveBeenCalledWith('repo-1::C:/repos/project/wt-a', { + head: 'bbb222', + branch: null + }) + }) + + it('skips identities whose worktree row does not exist', () => { + const updateWorktreeGitIdentity = vi.fn() + applyWorktreeHeadIdentities( + { + repoId: 'repo-1', + identities: [{ worktreePath: '/repos/project/removed', head: 'bbb222', branch: null }] + }, + { + getWorktreesForRepo: () => [makeWorktree({})], + updateWorktreeGitIdentity + } + ) + expect(updateWorktreeGitIdentity).not.toHaveBeenCalled() + }) + + it('no-ops for repos with no loaded worktrees', () => { + const updateWorktreeGitIdentity = vi.fn() + applyWorktreeHeadIdentities( + { + repoId: 'repo-unknown', + identities: [{ worktreePath: '/repos/project/wt-a', head: 'bbb222', branch: null }] + }, + { + getWorktreesForRepo: () => undefined, + updateWorktreeGitIdentity + } + ) + expect(updateWorktreeGitIdentity).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/worktree-head-identity-apply.ts b/src/renderer/src/hooks/worktree-head-identity-apply.ts new file mode 100644 index 00000000000..29be23b6022 --- /dev/null +++ b/src/renderer/src/hooks/worktree-head-identity-apply.ts @@ -0,0 +1,46 @@ +import type { Worktree, WorktreeHeadIdentity } from '../../../shared/types' +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' + +export type WorktreeHeadIdentityApplyDeps = { + getWorktreesForRepo: (repoId: string) => Worktree[] | undefined + updateWorktreeGitIdentity: ( + worktreeId: string, + identity: { head?: string; branch?: string | null } + ) => void +} + +function findWorktreeByPath(worktrees: Worktree[], worktreePath: string): Worktree | undefined { + const exact = worktrees.find((worktree) => worktree.path === worktreePath) + if (exact) { + return exact + } + // Both paths are Git-derived, but separator/casing normalization can differ + // between `git worktree list` output and gitdir-file contents on Windows. + const normalized = normalizeRuntimePathForComparison(worktreePath) + return worktrees.find( + (worktree) => normalizeRuntimePathForComparison(worktree.path) === normalized + ) +} + +/** Applies head/branch snapshots from the status-only watcher path to store + * rows. Unknown paths are skipped: row creation/removal is structural and + * stays owned by the full worktree listing. */ +export function applyWorktreeHeadIdentities( + data: { repoId: string; identities: WorktreeHeadIdentity[] }, + deps: WorktreeHeadIdentityApplyDeps +): void { + const worktrees = deps.getWorktreesForRepo(data.repoId) + if (!worktrees || worktrees.length === 0) { + return + } + for (const identity of data.identities) { + const worktree = findWorktreeByPath(worktrees, identity.worktreePath) + if (!worktree) { + continue + } + deps.updateWorktreeGitIdentity(worktree.id, { + head: identity.head, + branch: identity.branch + }) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index fee6ca8290b..ef7cf1a649b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -458,6 +458,13 @@ "all": "No comments yet." } } + }, + "bot": { + "author": { + "overrides": { + "6d5d52b53f": "Bot author override limit reached" + } + } } }, "resume": { @@ -2581,7 +2588,7 @@ "blocked": { "toast": { "97c98f1afe": "Open Setting", - "7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, or fzf.", + "7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, fzf, or Grok.", "89eaa3e80b": "Terminal clipboard write blocked" } } @@ -5402,7 +5409,9 @@ "bf16ef0af2": "Off", "3f6892f307": "On", "b82f86d7d2": "Rich Markdown Spellcheck", - "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "7ddd66fede": "Editor Word Wrap", + "9b18de6eea": "Wrap long lines in file editors instead of requiring horizontal scrolling." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "localhost, 127.0.0.1, *.internal", @@ -7717,7 +7726,9 @@ "defaultProjectRuntime": "Default Project Runtime", "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", "d2d2d929c0": "Rich Markdown Spellcheck", - "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "e61157e926": "Editor Word Wrap", + "005be5c699": "Wrap long lines in file editors instead of requiring horizontal scrolling." } }, "git": { @@ -9697,7 +9708,9 @@ "b13f85d75c": "Timeline", "f5cf324efa": "Comment display options", "5e6e5a13fa": "View", - "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked." + "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.", + "b3195cba33": "Unmark author as bot", + "f588b46a6c": "Mark author as bot" }, "empty": { "state": { @@ -10672,6 +10685,17 @@ "trigger-label": "Network address to advertise", "custom-option": "{{address}} (custom)", "add-custom": "Add custom address…" + }, + "WindowsFirewallNotice": { + "repair-success": "Windows Firewall now allows Orca Mobile on private networks", + "repair-failed": "Could not add the Windows Firewall rule", + "public-title": "Windows marks this network as public", + "missing-title": "Allow phone connections through Windows Firewall", + "public-description": "Change this trusted Wi-Fi network to Private before allowing Orca Mobile connections.", + "missing-description": "Windows may block the pairing server. Add a rule for this Orca app and TCP port {{port}} on Private networks.", + "open-settings": "Open network settings", + "waiting": "Waiting for Windows…", + "allow": "Allow phone connections" } }, "gitlab": { @@ -11469,7 +11493,9 @@ "c98ce191da": "This diff has no modified-side file to open", "9b80bbe1de": "Open file tab", "f0fd4174b5": "Open file tab to use rich markdown editing", - "a10d9b8337": "Open file" + "a10d9b8337": "Open file", + "2076ecfc9c": "Previous change", + "631dab0df3": "Next change" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "Export as PDF", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ef7e87b82fa..a5dfd69774a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -458,6 +458,13 @@ "all": "Aún no hay comentarios." } } + }, + "bot": { + "author": { + "overrides": { + "6d5d52b53f": "Se alcanzó el límite de autores de bots personalizados" + } + } } }, "resume": { @@ -2581,7 +2588,7 @@ "blocked": { "toast": { "97c98f1afe": "Abrir ajustes", - "7cf51f74fd": "Habilita las escrituras al portapapeles TUI en los ajustes de Terminal para copiar desde SSH, tmux, Neovim o fzf.", + "7cf51f74fd": "Habilita las escrituras al portapapeles TUI en los ajustes de Terminal para copiar desde SSH, tmux, Neovim, fzf o Grok.", "89eaa3e80b": "Escritura al portapapeles del terminal bloqueada" } } @@ -5402,7 +5409,9 @@ "bf16ef0af2": "Desactivado", "3f6892f307": "Activado", "b82f86d7d2": "Corrector ortográfico de Rich Markdown", - "5195f0b9ef": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown." + "5195f0b9ef": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown.", + "7ddd66fede": "Ajuste de línea en el editor", + "9b18de6eea": "Ajusta las líneas largas en editores de archivos en lugar de requerir desplazamiento horizontal." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "localhost, 127.0.0.1, *.internal", @@ -7680,7 +7689,9 @@ "defaultProjectRuntime": "Runtime de proyecto predeterminado", "defaultProjectRuntimeDescription": "Elige el runtime que heredarán los proyectos locales de Windows.", "d2d2d929c0": "Corrector ortográfico de Rich Markdown", - "4497e2e2bb": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown." + "4497e2e2bb": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown.", + "e61157e926": "Ajuste de línea en el editor", + "005be5c699": "Ajusta las líneas largas en editores de archivos en lugar de requerir desplazamiento horizontal." } }, "git": { @@ -9697,7 +9708,9 @@ "b13f85d75c": "Cronología", "f5cf324efa": "Opciones de visualización de comentarios", "5e6e5a13fa": "Vista", - "actionRequiredHint": "Este check requiere una acción manual en GitHub (por ejemplo, aprobar la ejecución del workflow) antes de que el merge quede desbloqueado." + "actionRequiredHint": "Este check requiere una acción manual en GitHub (por ejemplo, aprobar la ejecución del workflow) antes de que el merge quede desbloqueado.", + "b3195cba33": "Desmarcar autor como bot", + "f588b46a6c": "Marcar autor como bot" }, "empty": { "state": { @@ -10672,6 +10685,17 @@ "trigger-label": "Dirección de red para anunciar", "custom-option": "{{address}} (personalizada)", "add-custom": "Añadir dirección personalizada…" + }, + "WindowsFirewallNotice": { + "repair-success": "Windows Firewall now allows Orca Mobile on private networks", + "repair-failed": "Could not add the Windows Firewall rule", + "public-title": "Windows marks this network as public", + "missing-title": "Allow phone connections through Windows Firewall", + "public-description": "Change this trusted Wi-Fi network to Private before allowing Orca Mobile connections.", + "missing-description": "Windows may block the pairing server. Add a rule for this Orca app and TCP port {{port}} on Private networks.", + "open-settings": "Open network settings", + "waiting": "Waiting for Windows…", + "allow": "Allow phone connections" } }, "gitlab": { @@ -11469,7 +11493,9 @@ "c98ce191da": "Este diff no tiene ningún archivo del lado modificado para abrir", "9b80bbe1de": "Abrir pestaña de archivo", "f0fd4174b5": "Abre la pestaña de archivo para usar la edición enriquecida de Markdown", - "a10d9b8337": "Abrir archivo" + "a10d9b8337": "Abrir archivo", + "2076ecfc9c": "Cambio anterior", + "631dab0df3": "Cambio siguiente" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "Exportar como PDF", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e426b6df26c..4d1f89a2ffe 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -458,6 +458,13 @@ "all": "コメントはまだありません。" } } + }, + "bot": { + "author": { + "overrides": { + "6d5d52b53f": "ボット作成者の上書き数が上限に達しました" + } + } } }, "resume": { @@ -2581,7 +2588,7 @@ "blocked": { "toast": { "97c98f1afe": "設定を開く", - "7cf51f74fd": "SSH、tmux、Neovim、または fzf からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。", + "7cf51f74fd": "SSH、tmux、Neovim、fzf、または Grok からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。", "89eaa3e80b": "Terminal のクリップボードへの書き込みがブロックされました" } } @@ -5387,7 +5394,9 @@ "bf16ef0af2": "オフ", "3f6892f307": "オン", "b82f86d7d2": "Rich Markdown Spellcheck", - "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "7ddd66fede": "エディターのワードラップ", + "9b18de6eea": "水平スクロールを使わずに、ファイルエディターで長い行を折り返します。" }, "AdvancedNetworkSettingsSection": { "3e431564b5": "ローカルホスト、127.0.0.1、*.internal", @@ -7702,7 +7711,9 @@ "defaultProjectRuntime": "Default Project Runtime", "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", "d2d2d929c0": "Rich Markdown Spellcheck", - "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "e61157e926": "エディターのワードラップ", + "005be5c699": "水平スクロールを使わずに、ファイルエディターで長い行を折り返します。" } }, "git": { @@ -9697,7 +9708,9 @@ "b13f85d75c": "タイムライン", "f5cf324efa": "Comment display options", "5e6e5a13fa": "表示", - "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked." + "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.", + "b3195cba33": "作成者のボット指定を解除", + "f588b46a6c": "作成者をボットとして指定" }, "empty": { "state": { @@ -10672,6 +10685,17 @@ "trigger-label": "通知するネットワークアドレス", "custom-option": "{{address}}(カスタム)", "add-custom": "カスタムアドレスを追加…" + }, + "WindowsFirewallNotice": { + "repair-success": "Windows Firewall now allows Orca Mobile on private networks", + "repair-failed": "Could not add the Windows Firewall rule", + "public-title": "Windows marks this network as public", + "missing-title": "Allow phone connections through Windows Firewall", + "public-description": "Change this trusted Wi-Fi network to Private before allowing Orca Mobile connections.", + "missing-description": "Windows may block the pairing server. Add a rule for this Orca app and TCP port {{port}} on Private networks.", + "open-settings": "Open network settings", + "waiting": "Waiting for Windows…", + "allow": "Allow phone connections" } }, "gitlab": { @@ -11469,7 +11493,9 @@ "c98ce191da": "この差分には開くための変更側ファイルがありません", "9b80bbe1de": "ファイルタブを開く", "f0fd4174b5": "ファイル タブを開いてリッチ markdown 編集を使用する", - "a10d9b8337": "ファイルを開く" + "a10d9b8337": "ファイルを開く", + "2076ecfc9c": "前の変更", + "631dab0df3": "次の変更" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "PDFとしてエクスポート", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c616c5ac106..e8b00931e85 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -458,6 +458,13 @@ "all": "아직 댓글이 없습니다." } } + }, + "bot": { + "author": { + "overrides": { + "6d5d52b53f": "봇 작성자 재정의 한도에 도달했습니다" + } + } } }, "resume": { @@ -2581,7 +2588,7 @@ "blocked": { "toast": { "97c98f1afe": "설정 열기", - "7cf51f74fd": "SSH, tmux, Neovim 또는 fzf에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.", + "7cf51f74fd": "SSH, tmux, Neovim, fzf 또는 Grok에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.", "89eaa3e80b": "Terminal 클립보드 쓰기가 차단되었습니다." } } @@ -5387,7 +5394,9 @@ "bf16ef0af2": "끄다", "3f6892f307": "~에", "b82f86d7d2": "Rich Markdown Spellcheck", - "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "7ddd66fede": "편집기 자동 줄 바꿈", + "9b18de6eea": "가로 스크롤 대신 파일 편집기에서 긴 줄을 자동으로 줄 바꿈합니다." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "로컬호스트, 127.0.0.1, *.internal", @@ -7665,7 +7674,9 @@ "defaultProjectRuntime": "기본 프로젝트 런타임", "defaultProjectRuntimeDescription": "로컬 Windows 프로젝트가 상속할 런타임을 선택합니다.", "d2d2d929c0": "Rich Markdown Spellcheck", - "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "e61157e926": "편집기 자동 줄 바꿈", + "005be5c699": "가로 스크롤 대신 파일 편집기에서 긴 줄을 자동으로 줄 바꿈합니다." } }, "git": { @@ -9697,7 +9708,9 @@ "b13f85d75c": "타임라인", "f5cf324efa": "Comment display options", "5e6e5a13fa": "보기", - "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked." + "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.", + "b3195cba33": "작성자의 봇 지정 해제", + "f588b46a6c": "작성자를 봇으로 지정" }, "empty": { "state": { @@ -10672,6 +10685,17 @@ "trigger-label": "알릴 네트워크 주소", "custom-option": "{{address}} (사용자 지정)", "add-custom": "사용자 지정 주소 추가…" + }, + "WindowsFirewallNotice": { + "repair-success": "Windows Firewall now allows Orca Mobile on private networks", + "repair-failed": "Could not add the Windows Firewall rule", + "public-title": "Windows marks this network as public", + "missing-title": "Allow phone connections through Windows Firewall", + "public-description": "Change this trusted Wi-Fi network to Private before allowing Orca Mobile connections.", + "missing-description": "Windows may block the pairing server. Add a rule for this Orca app and TCP port {{port}} on Private networks.", + "open-settings": "Open network settings", + "waiting": "Waiting for Windows…", + "allow": "Allow phone connections" } }, "gitlab": { @@ -11469,7 +11493,9 @@ "c98ce191da": "이 차이점에는 열 수 있는 수정된 측면 파일이 없습니다.", "9b80bbe1de": "파일 탭 열기", "f0fd4174b5": "풍부한 markdown 편집을 사용하려면 파일 탭을 엽니다.", - "a10d9b8337": "파일 열기" + "a10d9b8337": "파일 열기", + "2076ecfc9c": "이전 변경", + "631dab0df3": "다음 변경" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "PDF로 내보내기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d330fe0a51a..8dfef228efa 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -458,6 +458,13 @@ "all": "还没有评论。" } } + }, + "bot": { + "author": { + "overrides": { + "6d5d52b53f": "已达到机器人作者覆盖上限" + } + } } }, "resume": { @@ -2581,7 +2588,7 @@ "blocked": { "toast": { "97c98f1afe": "打开设置", - "7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim 或 fzf 进行复制。", + "7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim、fzf 或 Grok 进行复制。", "89eaa3e80b": "终端剪贴板写入被阻止" } } @@ -5387,7 +5394,9 @@ "bf16ef0af2": "关闭", "3f6892f307": "开启", "b82f86d7d2": "Rich Markdown Spellcheck", - "5195f0b9ef": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。" + "5195f0b9ef": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。", + "7ddd66fede": "编辑器自动换行", + "9b18de6eea": "在文件编辑器中自动换行长行,而无需水平滚动。" }, "AdvancedNetworkSettingsSection": { "3e431564b5": "本地主机,127.0.0.1,*.internal", @@ -7665,7 +7674,9 @@ "defaultProjectRuntime": "默认项目运行时", "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。", "d2d2d929c0": "Rich Markdown Spellcheck", - "4497e2e2bb": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。" + "4497e2e2bb": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。", + "e61157e926": "编辑器自动换行", + "005be5c699": "在文件编辑器中自动换行长行,而无需水平滚动。" } }, "git": { @@ -9697,7 +9708,9 @@ "b13f85d75c": "时间线", "f5cf324efa": "评论显示选项", "5e6e5a13fa": "查看", - "actionRequiredHint": "此检查需要在 GitHub 上执行手动操作(例如批准工作流运行)后才能解除合并阻止。" + "actionRequiredHint": "此检查需要在 GitHub 上执行手动操作(例如批准工作流运行)后才能解除合并阻止。", + "b3195cba33": "取消将作者标记为机器人", + "f588b46a6c": "将作者标记为机器人" }, "empty": { "state": { @@ -10672,6 +10685,17 @@ "trigger-label": "要公布的网络地址", "custom-option": "{{address}}(自定义)", "add-custom": "添加自定义地址…" + }, + "WindowsFirewallNotice": { + "repair-success": "Windows Firewall now allows Orca Mobile on private networks", + "repair-failed": "Could not add the Windows Firewall rule", + "public-title": "Windows marks this network as public", + "missing-title": "Allow phone connections through Windows Firewall", + "public-description": "Change this trusted Wi-Fi network to Private before allowing Orca Mobile connections.", + "missing-description": "Windows may block the pairing server. Add a rule for this Orca app and TCP port {{port}} on Private networks.", + "open-settings": "Open network settings", + "waiting": "Waiting for Windows…", + "allow": "Allow phone connections" } }, "gitlab": { @@ -11469,7 +11493,9 @@ "c98ce191da": "此差异没有可打开的修改端文件", "9b80bbe1de": "打开文件选项卡", "f0fd4174b5": "打开文件选项卡以使用丰富的 Markdown 编辑", - "a10d9b8337": "打开文件" + "a10d9b8337": "打开文件", + "2076ecfc9c": "上一个更改", + "631dab0df3": "下一个更改" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "导出为 PDF", diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index d43401e502f..f9536dc1c34 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -14,6 +14,7 @@ import { } from './agent-icon-glyphs' import { translate } from '@/i18n/i18n' import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { AGENT_FAVICON_ASSETS } from './agent-favicon-assets' export type AgentCatalogEntry = { id: TuiAgent @@ -113,7 +114,8 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => id: 'omp', label: translate('auto.lib.agent.catalog.09973b4d84', 'OMP'), cmd: 'omp', - faviconDomain: 'omp.sh', + // Why: no faviconDomain — omp renders the hand-authored OmpIcon glyph, so a + // favicon fallback would never be reached. homepageUrl: 'https://omp.sh' }, { @@ -340,20 +342,29 @@ export function AgentIcon({ return } const catalogEntry = getAgentCatalog().find((a) => a.id === agent) - if (catalogEntry?.iconUrl) { + // Why: prefer the favicon bundled at build time so the icon renders without a + // live network request — Google's favicon service is unreachable in some + // regions and offline, which left these icons broken (#8451). + const bundledFaviconUrl = AGENT_FAVICON_ASSETS[agent] + // Why: one resolved src for guard + attribute so empty `iconUrl` cannot pass + // a truthy `||` check while `??` still renders a broken ``. + const iconSrc = catalogEntry?.iconUrl ?? bundledFaviconUrl + if (iconSrc) { return ( ) } if (catalogEntry?.faviconDomain) { - // Why: agents without a published SVG icon use their site favicon via - // Google's favicon service — same source the README uses for the agent badge list. + // Why: agents without a published SVG icon or bundled favicon fall back to + // their site favicon via Google's favicon service — same source the README + // uses for the agent badge list. return ( boolean | Promise + export async function sendAgentDraftPasteContent( settings: Pick | null | undefined, ptyId: string, - content: string + content: string, + writePty?: AgentDraftPtyInputWriter ): Promise { if (content.length > AGENT_DRAFT_PASTE_MAX_BYTES) { return false @@ -29,10 +32,11 @@ export async function sendAgentDraftPasteContent( stopAfterBytes: AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES }) if (!directMeasurement.exceededLimit) { - return await sendRuntimePtyInputVerified( + return await writeAgentDraftPtyInput( settings, ptyId, - [BRACKETED_PASTE_START, sanitizeTerminalPasteText(content), BRACKETED_PASTE_END].join('') + [BRACKETED_PASTE_START, sanitizeTerminalPasteText(content), BRACKETED_PASTE_END].join(''), + writePty ) } @@ -46,16 +50,16 @@ export async function sendAgentDraftPasteContent( for (const chunk of iterateAgentDraftPasteContentChunks(content)) { let accepted = false try { - accepted = await sendRuntimePtyInputVerified(settings, ptyId, chunk) + accepted = await writeAgentDraftPtyInput(settings, ptyId, chunk, writePty) } catch { if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { - await closeAgentDraftBracketedPaste(settings, ptyId) + await closeAgentDraftBracketedPaste(settings, ptyId, writePty) } return false } if (!accepted) { if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { - await closeAgentDraftBracketedPaste(settings, ptyId) + await closeAgentDraftBracketedPaste(settings, ptyId, writePty) } return false } @@ -182,14 +186,24 @@ function yieldToAgentDraftPastePreflight(): Promise { return new Promise((resolve) => globalThis.setTimeout(resolve, 0)) } +async function writeAgentDraftPtyInput( + settings: Pick | null | undefined, + ptyId: string, + data: string, + writePty?: AgentDraftPtyInputWriter +): Promise { + return writePty ? await writePty(data) : await sendRuntimePtyInputVerified(settings, ptyId, data) +} + async function closeAgentDraftBracketedPaste( settings: Pick | null | undefined, - ptyId: string + ptyId: string, + writePty?: AgentDraftPtyInputWriter ): Promise { try { // Why: once the opener reached the PTY, a failed content chunk should not // leave the target TUI in bracketed-paste mode. - await sendRuntimePtyInputVerified(settings, ptyId, BRACKETED_PASTE_END) + await writeAgentDraftPtyInput(settings, ptyId, BRACKETED_PASTE_END, writePty) } catch { // The original write already failed; callers only need the paste to fail closed. } diff --git a/src/renderer/src/lib/agent-favicon-assets.ts b/src/renderer/src/lib/agent-favicon-assets.ts new file mode 100644 index 00000000000..fcfaa89c82d --- /dev/null +++ b/src/renderer/src/lib/agent-favicon-assets.ts @@ -0,0 +1,57 @@ +import type { TuiAgent } from '../../../shared/types' +import grokUrl from '../../../shared/agent-icons/grok.png?url' +import mimoCodeUrl from '../../../shared/agent-icons/mimo-code.png?url' +import anteUrl from '../../../shared/agent-icons/ante.png?url' +import geminiUrl from '../../../shared/agent-icons/gemini.png?url' +import antigravityUrl from '../../../shared/agent-icons/antigravity.png?url' +import gooseUrl from '../../../shared/agent-icons/goose.png?url' +import ampUrl from '../../../shared/agent-icons/amp.png?url' +import kiroUrl from '../../../shared/agent-icons/kiro.png?url' +import crushUrl from '../../../shared/agent-icons/crush.png?url' +import augUrl from '../../../shared/agent-icons/aug.png?url' +import autohandUrl from '../../../shared/agent-icons/autohand.png?url' +import clineUrl from '../../../shared/agent-icons/cline.png?url' +import codebuffUrl from '../../../shared/agent-icons/codebuff.png?url' +import commandCodeUrl from '../../../shared/agent-icons/command-code.png?url' +import continueUrl from '../../../shared/agent-icons/continue.png?url' +import cursorUrl from '../../../shared/agent-icons/cursor.png?url' +import kimiUrl from '../../../shared/agent-icons/kimi.png?url' +import mistralVibeUrl from '../../../shared/agent-icons/mistral-vibe.png?url' +import qwenCodeUrl from '../../../shared/agent-icons/qwen-code.png?url' +import rovoUrl from '../../../shared/agent-icons/rovo.png?url' +import hermesUrl from '../../../shared/agent-icons/hermes.png?url' +import devinUrl from '../../../shared/agent-icons/devin.png?url' +import openclawUrl from '../../../shared/agent-icons/openclaw.png?url' + +// Why: these agents have no hand-authored SVG glyph, so previously their icons +// loaded live from Google's favicon service. That service is unreachable in some +// regions (e.g. mainland China) and offline, leaving broken images across the +// agent settings page, tab title bar, and status bar (#8451). Bundle the favicon +// PNGs at build time so the icons render without any network dependency. +// The PNGs live in src/shared/agent-icons so mobile (Metro) can bundle the same +// files; see mobile/src/components/mobile-agent-icon-assets.ts. +export const AGENT_FAVICON_ASSETS: Partial> = { + grok: grokUrl, + 'mimo-code': mimoCodeUrl, + ante: anteUrl, + gemini: geminiUrl, + antigravity: antigravityUrl, + goose: gooseUrl, + amp: ampUrl, + kiro: kiroUrl, + crush: crushUrl, + aug: augUrl, + autohand: autohandUrl, + cline: clineUrl, + codebuff: codebuffUrl, + 'command-code': commandCodeUrl, + continue: continueUrl, + cursor: cursorUrl, + kimi: kimiUrl, + 'mistral-vibe': mistralVibeUrl, + 'qwen-code': qwenCodeUrl, + rovo: rovoUrl, + hermes: hermesUrl, + devin: devinUrl, + openclaw: openclawUrl +} diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 820741031d7..327f080dc1a 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -533,6 +533,19 @@ describe('getAgentLabel', () => { expect(getAgentLabel('Aider idle')).toBe('Aider') expect(getAgentLabel('Devin working')).toBe('Devin') }) + + // Why: `cursor` is ordinary editor vocabulary in another agent's task title, so a + // bare token is not Cursor identity — a Claude tab working on cursor code was being + // mislabeled Cursor on the worktree card. Match Cursor's closed title set only. + it('labels Cursor by identity, not a bare "cursor" token in another agent title', () => { + expect(getAgentLabel('Cursor Agent')).toBe('Cursor') + expect(getAgentLabel('⠋ Cursor Agent')).toBe('Cursor') + expect(getAgentLabel('Cursor ready')).toBe('Cursor') + expect(getAgentLabel('Cursor - action required')).toBe('Cursor') + expect(getAgentLabel('⠋ preserve cursor visibility across replays')).toBe('Claude Code') + expect(getAgentLabel('⠋ Codex: fix cursor offsets')).toBe('Codex') + expect(getAgentLabel('Terminal Cursor and Orca slows down')).toBeNull() + }) }) describe('isClaudeAgent', () => { @@ -542,6 +555,13 @@ describe('isClaudeAgent', () => { expect(isClaudeAgent('OpenClaude ready')).toBe(false) }) + // Why: a braille Claude title merely mentioning a text cursor is still Claude, so its + // prompt-cache paths must fire; only Cursor's own identity titles are excluded. + it('counts cursor-mentioning Claude braille titles as Claude, excludes real Cursor', () => { + expect(isClaudeAgent('⠋ preserve cursor visibility across replays')).toBe(true) + expect(isClaudeAgent('⠋ Cursor Agent')).toBe(false) + }) + it('does not classify non-prefix Claude mentions as Claude agent titles', () => { expect(isClaudeAgent('ask claude later')).toBe(false) expect(getAgentLabel('ask claude later')).toBeNull() diff --git a/src/renderer/src/lib/pr-bot-author-overrides.test.ts b/src/renderer/src/lib/pr-bot-author-overrides.test.ts new file mode 100644 index 00000000000..3516cd73341 --- /dev/null +++ b/src/renderer/src/lib/pr-bot-author-overrides.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { applyPRBotAuthorOverride } from '../../../shared/pr-bot-author-overrides' + +const store = vi.hoisted(() => ({ + settings: { prBotAuthorOverrides: [] as string[] }, + pending: [] as (() => void)[], + apiUpdate: vi.fn(), + warning: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { warning: store.warning } })) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(vi.fn(), { + getState: () => ({ settings: store.settings }), + setState: (next: { settings: typeof store.settings }) => { + store.settings = next.settings + } + }) +})) + +import { setPRBotAuthorOverride } from './pr-bot-author-overrides' + +describe('PR bot author override updates', () => { + beforeEach(() => { + store.settings = { prBotAuthorOverrides: [] } + store.pending = [] + store.apiUpdate.mockReset() + store.warning.mockReset() + store.apiUpdate.mockImplementation( + (args: { author: string; isBot: boolean }) => + new Promise((resolve) => { + store.pending.push(() => { + store.settings = { + prBotAuthorOverrides: applyPRBotAuthorOverride( + store.settings.prBotAuthorOverrides, + args.author, + args.isBot + ) + } + resolve(store.settings) + }) + }) + ) + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { api: { settings: { updatePRBotAuthorOverride: store.apiUpdate } } } + }) + }) + + it('serializes rapid updates while the authoritative store merges each delta', async () => { + setPRBotAuthorOverride('alice', true) + setPRBotAuthorOverride('bob', true) + + await vi.waitFor(() => expect(store.apiUpdate).toHaveBeenCalledTimes(1)) + store.pending.shift()?.() + await vi.waitFor(() => expect(store.apiUpdate).toHaveBeenCalledTimes(2)) + store.pending.shift()?.() + await vi.waitFor(() => expect(store.settings.prBotAuthorOverrides).toEqual(['alice', 'bob'])) + }) + + it('continues processing updates after an atomic settings write fails', async () => { + store.apiUpdate + .mockRejectedValueOnce(new Error('settings unavailable')) + .mockResolvedValueOnce({ prBotAuthorOverrides: ['bob'] }) + + setPRBotAuthorOverride('alice', true) + setPRBotAuthorOverride('bob', true) + + await vi.waitFor(() => expect(store.apiUpdate).toHaveBeenCalledTimes(2)) + expect(store.settings.prBotAuthorOverrides).toEqual(['bob']) + }) + + it('warns without evicting an existing override when the limit is reached', async () => { + const current = Array.from({ length: 500 }, (_, index) => `bot-${index}`) + store.apiUpdate.mockResolvedValue({ prBotAuthorOverrides: current }) + + setPRBotAuthorOverride('new-bot', true) + + await vi.waitFor(() => + expect(store.warning).toHaveBeenCalledWith('Bot author override limit reached') + ) + expect(store.settings.prBotAuthorOverrides).toEqual(current) + }) +}) diff --git a/src/renderer/src/lib/pr-bot-author-overrides.ts b/src/renderer/src/lib/pr-bot-author-overrides.ts new file mode 100644 index 00000000000..cf8ade2fea7 --- /dev/null +++ b/src/renderer/src/lib/pr-bot-author-overrides.ts @@ -0,0 +1,45 @@ +import { useMemo } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { createBotAuthorOverrideSet, normalizePRCommentAuthorLogin } from './pr-comment-audience' +import { MAX_PR_BOT_AUTHOR_OVERRIDES } from '../../../shared/pr-bot-author-overrides' + +let overrideUpdateQueue = Promise.resolve() + +/** Normalized lookup of author logins the user manually marked as bots. */ +export function usePRBotAuthorOverrides(): ReadonlySet { + const overrides = useAppStore((s) => s.settings?.prBotAuthorOverrides) + return useMemo(() => createBotAuthorOverrideSet(overrides), [overrides]) +} + +/** Adds or removes a manual bot override for the given comment author. */ +export function setPRBotAuthorOverride(author: string, isBot: boolean): void { + const normalized = normalizePRCommentAuthorLogin(author) + if (!normalized) { + return + } + // Why: settings writes are asynchronous; serialize read-modify-write updates + // so marking two authors quickly cannot make the later write drop the first. + overrideUpdateQueue = overrideUpdateQueue + .then(async () => { + // Why: the authoritative store owns the read-modify-write so concurrent + // desktop and paired-web clients cannot overwrite each other's updates. + const settings = await window.api.settings.updatePRBotAuthorOverride({ + author: normalized, + isBot + }) + useAppStore.setState({ settings }) + const current = createBotAuthorOverrideSet(settings.prBotAuthorOverrides) + if (isBot && !current.has(normalized) && current.size >= MAX_PR_BOT_AUTHOR_OVERRIDES) { + toast.warning( + translate( + 'auto.lib.pr.bot.author.overrides.6d5d52b53f', + 'Bot author override limit reached' + ) + ) + } + }) + // Why: one failed settings write must not poison every later override update. + .catch(() => undefined) +} diff --git a/src/renderer/src/lib/pr-comment-audience.test.ts b/src/renderer/src/lib/pr-comment-audience.test.ts index 04b8cafce96..fb48a97e6f9 100644 --- a/src/renderer/src/lib/pr-comment-audience.test.ts +++ b/src/renderer/src/lib/pr-comment-audience.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + createBotAuthorOverrideSet, filterPRCommentsByAudience, getPRCommentAudienceCounts, isBotPRComment @@ -37,4 +38,30 @@ describe('pr comment audience filtering', () => { expect(filterPRCommentsByAudience(comments, 'human').map((item) => item.id)).toEqual([1]) expect(filterPRCommentsByAudience(comments, 'bot').map((item) => item.id)).toEqual([2, 3]) }) + + it('classifies manually overridden authors as bots regardless of heuristics', () => { + const overrides = createBotAuthorOverrideSet([' GretelFlux ', '', 'other-bot-account']) + + expect(isBotPRComment(comment({ author: 'gretelflux' }))).toBe(false) + expect(isBotPRComment(comment({ author: 'gretelflux' }), overrides)).toBe(true) + expect(isBotPRComment(comment({ author: ' Gretelflux ' }), overrides)).toBe(true) + expect(isBotPRComment(comment({ author: 'yasinkavakli' }), overrides)).toBe(false) + }) + + it('applies overrides to counts and filters', () => { + const overrides = createBotAuthorOverrideSet(['gretelflux']) + const comments = [ + comment({ id: 1, author: 'yasinkavakli' }), + comment({ id: 2, author: 'gretelflux' }), + comment({ id: 3, author: 'github-actions[bot]' }) + ] + + expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 3, human: 1, bot: 2 }) + expect(filterPRCommentsByAudience(comments, 'human', overrides).map((item) => item.id)).toEqual( + [1] + ) + expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((item) => item.id)).toEqual([ + 2, 3 + ]) + }) }) diff --git a/src/renderer/src/lib/pr-comment-audience.ts b/src/renderer/src/lib/pr-comment-audience.ts index f83aa327928..dc731095277 100644 --- a/src/renderer/src/lib/pr-comment-audience.ts +++ b/src/renderer/src/lib/pr-comment-audience.ts @@ -1,7 +1,13 @@ import type { PRComment } from '../../../shared/types' +import { + createBotAuthorOverrideSet, + normalizePRCommentAuthorLogin +} from '../../../shared/pr-bot-author-overrides' import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { translate } from '@/i18n/i18n' +export { createBotAuthorOverrideSet, normalizePRCommentAuthorLogin } + export type PRCommentAudienceFilter = 'all' | 'human' | 'bot' export const getPrCommentAudienceFilters = createLocalizedCatalog( @@ -44,12 +50,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 } @@ -60,9 +72,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, @@ -72,13 +85,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/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index 7b80f59f5b9..52086493559 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -1736,6 +1736,115 @@ describe('runtime file client', () => { expect(unsubscribe).toHaveBeenCalled() }) + it('surfaces terminal watcher errors and allows a fresh subscription after stream end', async () => { + const onError = vi.fn() + const unsubscribe = vi.fn() + let callbacks: { onResponse: (response: unknown) => void; onClose: () => void } | undefined + runtimeEnvironmentSubscribe.mockImplementation((_args, nextCallbacks) => { + callbacks = nextCallbacks + return Promise.resolve({ unsubscribe, sendBinary: vi.fn() }) + }) + + await subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + vi.fn(), + onError + ) + callbacks?.onResponse({ + id: 'rpc-error', + ok: true, + result: { type: 'error', message: 'file watcher process crashed repeatedly' }, + _meta: { runtimeId: 'remote-runtime' } + }) + callbacks?.onResponse({ + id: 'rpc-end', + ok: true, + result: { type: 'end' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'file watcher process crashed repeatedly' }) + ) + expect(unsubscribe).toHaveBeenCalledTimes(1) + + await subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + vi.fn(), + vi.fn() + ) + expect(runtimeEnvironmentSubscribe).toHaveBeenCalledTimes(2) + callbacks?.onResponse({ + id: 'rpc-second-end', + ok: true, + result: { type: 'end' }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + it('evicts a terminal watch before notifying error listeners that retry', async () => { + const callbacks: { + onResponse: (response: unknown) => void + onClose: () => void + }[] = [] + runtimeEnvironmentSubscribe.mockImplementation((_args, nextCallbacks) => { + callbacks.push(nextCallbacks) + return Promise.resolve({ unsubscribe: vi.fn(), sendBinary: vi.fn() }) + }) + const retryPayload = vi.fn() + let retryPromise: Promise<() => void> | undefined + const context = { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + } + await subscribeRuntimeFileChanges(context, vi.fn(), () => { + retryPromise = subscribeRuntimeFileChanges(context, retryPayload, vi.fn()) + }) + + callbacks[0].onResponse({ + id: 'rpc-error', + ok: true, + result: { type: 'error', message: 'terminal watcher failure' }, + _meta: { runtimeId: 'remote-runtime' } + }) + await retryPromise + const subscribeCallCount = runtimeEnvironmentSubscribe.mock.calls.length + callbacks[0].onResponse({ + id: 'rpc-end', + ok: true, + result: { type: 'end' }, + _meta: { runtimeId: 'remote-runtime' } + }) + callbacks[1]?.onResponse({ + id: 'rpc-changed', + ok: true, + result: { + type: 'changed', + worktree: 'id:wt-1', + events: [{ kind: 'update', absolutePath: '/remote/repo/retry.txt' }] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + callbacks[1]?.onResponse({ + id: 'rpc-retry-end', + ok: true, + result: { type: 'end' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + expect(subscribeCallCount).toBe(2) + expect(retryPayload).toHaveBeenCalledTimes(1) + }) + it('shares one remote file watch subscription across listeners for the same worktree', async () => { const firstPayload = vi.fn() const secondPayload = vi.fn() diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index 3265ad343b0..6c70cd9e63c 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -101,6 +101,7 @@ type RuntimeImportResult = type RuntimeFileWatchEvent = | { type: 'ready'; subscriptionId: string } | { type: 'changed'; worktree: string; events: FsChangedPayload['events'] } + | { type: 'error'; message: string } | { type: 'end' } const REMOTE_UPLOAD_BASE64_CHUNK_CHARS = 512 * 1024 @@ -886,7 +887,7 @@ function createSharedRuntimeFileWatch( }, { onResponse: (response) => { - handleSharedRuntimeFileWatchResponse(shared, worktreePath, response) + handleSharedRuntimeFileWatchResponse(key, shared, worktreePath, response) }, onError: (error) => { notifySharedRuntimeFileWatchError(shared, new Error(error.message)) @@ -922,6 +923,7 @@ function createSharedRuntimeFileWatch( } function handleSharedRuntimeFileWatchResponse( + key: string, shared: SharedRuntimeFileWatch, worktreePath: string, response: unknown @@ -943,6 +945,30 @@ function handleSharedRuntimeFileWatchResponse( for (const listener of Array.from(shared.listeners)) { listener.onPayload({ worktreePath, events: event.events }) } + } else if (event.type === 'error') { + // Why: error listeners may synchronously retry. Evict the terminal watch + // before callbacks run so the retry cannot join a stream awaiting `end`. + if (sharedRuntimeFileWatches.get(key) === shared) { + sharedRuntimeFileWatches.delete(key) + } + shared.closed = true + const listeners = Array.from(shared.listeners) + shared.listeners.clear() + for (const listener of listeners) { + listener.onError?.(new Error(event.message)) + } + } else if (event.type === 'end') { + // Why: shared-control completes without onClose; evict and release its + // transport handle so later listeners start cleanly without retained state. + if (sharedRuntimeFileWatches.get(key) === shared) { + sharedRuntimeFileWatches.delete(key) + } + shared.closed = true + const unsubscribe = shared.unsubscribe + shared.unsubscribe = null + shared.remoteSubscriptionId = null + shared.listeners.clear() + unsubscribe?.() } } catch (err) { notifySharedRuntimeFileWatchError(shared, err instanceof Error ? err : new Error(String(err))) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 7090ac68bc1..cab8071cfa9 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -1697,6 +1697,81 @@ describe('applyWebSessionTabsSnapshot', () => { }) }) + it('bumps sort epoch for mirrored Command Code same-state turn starts', () => { + const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID) + const initialPatch = applyWebSessionTabsSnapshot( + makeState(), + makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'Command Code', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'working', + prompt: 'same prompt', + updatedAt: NOW - 1_000, + stateStartedAt: NOW - 1_000, + agentType: 'command-code', + paneKey: hostPaneKey, + terminalTitle: 'Command Code', + stateHistory: [], + promptInteractionKey: 'command-code-transcript-a' + } + } + ]), + ENV, + NOW + ) as Partial + const initialState = { ...makeState(), ...initialPatch } + const mirroredId = initialPatch.tabsByWorktree?.[WT]?.[0]?.id + const mirroredPaneKey = makePaneKey(mirroredId!, LEAF_ID) + + const patch = applyWebSessionTabsSnapshot( + initialState, + makeSnapshot( + [ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'Command Code', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'working', + prompt: 'same prompt', + updatedAt: NOW, + stateStartedAt: NOW, + agentType: 'command-code', + paneKey: hostPaneKey, + terminalTitle: 'Command Code', + stateHistory: [], + promptInteractionKey: 'command-code-transcript-b' + } + } + ], + { snapshotVersion: 2 } + ), + ENV, + NOW + ) as Partial + + expect(patch.agentStatusByPaneKey?.[mirroredPaneKey]).toMatchObject({ + prompt: 'same prompt', + stateStartedAt: NOW, + promptInteractionKey: 'command-code-transcript-b' + }) + expect(patch.agentStatusEpoch).toBe((initialState.agentStatusEpoch ?? 0) + 1) + expect(patch.sortEpoch).toBe((initialState.sortEpoch ?? 0) + 1) + }) + it('hydrates multiple initial host snapshots in one merged patch', () => { const secondWorktree = 'repo::/other-worktree' const patch = applyWebSessionTabsSnapshots( diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index e28072d5357..df81b919276 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -716,7 +716,8 @@ function buildMirroredAgentStatusPatch( sortRelevantChange || !existing || existing.state !== entry.state || - !isAgentStatusFresh(existing, now) + !isAgentStatusFresh(existing, now) || + isMirroredCommandCodeTurnBump(existing, entry) } if (!changed) { @@ -1297,6 +1298,7 @@ function agentStatusEntryEqual(a: AgentStatusEntry | undefined, b: AgentStatusEn a.interactivePrompt === b.interactivePrompt && a.lastAssistantMessage === b.lastAssistantMessage && a.interrupted === b.interrupted && + a.promptInteractionKey === b.promptInteractionKey && sameAgentStateHistory(a.stateHistory, b.stateHistory) ) } @@ -1305,6 +1307,19 @@ function isAgentStatusFresh(entry: Pick, now: num return now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS } +function isMirroredCommandCodeTurnBump( + existing: AgentStatusEntry | undefined, + entry: AgentStatusEntry +): boolean { + return ( + existing?.agentType === 'command-code' && + entry.agentType === 'command-code' && + existing.state === 'working' && + entry.state === 'working' && + entry.stateStartedAt > existing.stateStartedAt + ) +} + function sameStringRecord( a: Readonly> | undefined, b: Readonly> | undefined diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index cf3b7cd9b12..1aa12701207 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -664,6 +664,101 @@ describe('agent status tool + assistant fields', () => { expect(store.getState().agentStatusEpoch).toBe(firstEpoch + 1) expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1) }) + + it('bumps sort epoch when Command Code starts a new prompt while still working', () => { + vi.useFakeTimers() + const store = createTestStore() + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'working', prompt: 'first task', agentType: 'command-code' }, + 'command-code', + { updatedAt: 1_000, stateStartedAt: 1_000 } + ) + const firstSortEpoch = store.getState().sortEpoch + + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'working', prompt: 'second task', agentType: 'command-code' }, + 'command-code', + { updatedAt: 2_000, stateStartedAt: 2_000 } + ) + + const entry = store.getState().agentStatusByPaneKey['tab-1:1'] + expect(entry.prompt).toBe('second task') + expect(entry.stateStartedAt).toBe(2_000) + expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1) + }) + + it('bumps sort epoch when Command Code reruns the same prompt with a new turn key', () => { + vi.useFakeTimers() + const store = createTestStore() + store.getState().setAgentStatus( + 'tab-1:1', + { + state: 'working', + prompt: 'same task', + agentType: 'command-code', + promptInteractionKey: 'command-code-transcript-a' + }, + 'command-code', + { updatedAt: 1_000, stateStartedAt: 1_000 } + ) + const firstSortEpoch = store.getState().sortEpoch + + store.getState().setAgentStatus( + 'tab-1:1', + { + state: 'working', + prompt: 'same task', + agentType: 'command-code', + promptInteractionKey: 'command-code-transcript-b' + }, + 'command-code', + { updatedAt: 2_000, stateStartedAt: 2_000 } + ) + + const entry = store.getState().agentStatusByPaneKey['tab-1:1'] + expect(entry.prompt).toBe('same task') + expect(entry.promptInteractionKey).toBe('command-code-transcript-b') + expect(entry.stateStartedAt).toBe(2_000) + expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1) + }) + + it('bumps sort epoch when main advances Command Code stateStartedAt without a renderer-visible key change', () => { + vi.useFakeTimers() + const store = createTestStore() + // First turn carries no interaction key (e.g. transcript read failed), so + // the renderer stores no promptInteractionKey to compare against. + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'working', prompt: 'same task', agentType: 'command-code' }, + 'command-code', + { updatedAt: 1_000, stateStartedAt: 1_000 } + ) + const firstSortEpoch = store.getState().sortEpoch + + // Main detected a new turn via interaction-key change and reset stateStartedAt, + // but the renderer can't see the key change (no key, identical prompt text). + // The authoritative stateStartedAt advance must still re-sort. + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'working', prompt: 'same task', agentType: 'command-code' }, + 'command-code', + { updatedAt: 2_000, stateStartedAt: 2_000 } + ) + + const entry = store.getState().agentStatusByPaneKey['tab-1:1'] + expect(entry.stateStartedAt).toBe(2_000) + expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1) + }) }) describe('agent status PR refresh handoff', () => { diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 99ae087f5b9..d135a5853ac 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -23,6 +23,7 @@ import { resolveAgentStatusIdentity, shouldSuppressInheritedTerminalStatus } from '../../../../shared/agent-status-identity' +import { isCommandCodeNewTurnWhileWorking } from '../../../../shared/command-code-turn-boundary' import type { TerminalTab } from '../../../../shared/types' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { @@ -133,7 +134,10 @@ export type AgentStatusSlice = { /** Update or insert an agent status entry from a status payload. */ setAgentStatus: ( paneKey: string, - payload: ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext }, + payload: ParsedAgentStatusPayload & { + orchestration?: AgentStatusOrchestrationContext + promptInteractionKey?: string + }, terminalTitle?: string, timing?: { updatedAt?: number; stateStartedAt?: number }, routing?: { tabId?: string; worktreeId?: string; terminalHandle?: string }, @@ -1229,14 +1233,6 @@ export const createAgentStatusSlice: StateCreator ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -31,6 +33,11 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) +vi.mock('@/runtime/runtime-terminal-stream', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, parseRemoteRuntimePtyId: vi.fn(actual.parseRemoteRuntimePtyId) } +}) + const mockApi = { worktrees: { list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined) }, pty: { kill: vi.fn().mockResolvedValue(undefined) } @@ -40,12 +47,17 @@ const mockApi = { globalThis.window = { api: mockApi } import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' const WT1 = 'repo1::/path/wt1' const WT2 = 'repo1::/path/wt2' const TAB1 = 'tab-wt1' const TAB2 = 'tab-wt2' const PTY1 = 'pty-wt1' +const PTY1_SPLIT = 'pty-wt1-split' +const REMOTE_PTY1 = 'remote:env-1@@terminal-wt1' +const REMOTE_HANDLE1 = 'terminal-wt1' +const REMOTE_PTY2_SAME_HANDLE = 'remote:env-2@@terminal-wt1' const PTY2 = 'pty-wt2' function seedMaps(store: ReturnType): void { @@ -60,8 +72,22 @@ function seedMaps(store: ReturnType): void { [WT1]: [makeTab({ id: TAB1, worktreeId: WT1, ptyId: PTY1 })], [WT2]: [makeTab({ id: TAB2, worktreeId: WT2, ptyId: PTY2 })] }, - // Drives the doomed-ptyId derivation in buildWorktreePurgeState. - ptyIdsByTabId: { [TAB1]: [PTY1], [TAB2]: [PTY2] }, + // A slept/stale tab has already left the live index, so purge must recover + // its owned PTY ids from the durable tab/layout wake hints instead. + ptyIdsByTabId: { [TAB1]: [], [TAB2]: [PTY2] }, + terminalLayoutsByTabId: { + [TAB1]: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-1' }, + second: { type: 'leaf', leafId: 'leaf-2' } + }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-1': PTY1_SPLIT, 'leaf-2': REMOTE_PTY1 } + } + }, lastKnownRelayPtyIdByTabId: { [TAB1]: PTY1, [TAB2]: PTY2 }, pendingInitialCwdByTabId: { [TAB1]: '/path/wt1', [TAB2]: '/path/wt2' }, pendingIssueCommandSplitByTabId: { @@ -92,6 +118,18 @@ function seedMaps(store: ReturnType): void { source: 'local', updatedAt: 2 } + }, + suppressedPtyExitIds: { + [PTY1]: true, + [PTY1_SPLIT]: true, + [REMOTE_PTY1]: true, + [PTY2]: true + }, + pendingCodexPaneRestartIds: { + [PTY1]: true, + [PTY1_SPLIT]: true, + [REMOTE_PTY1]: true, + [PTY2]: true } }) } @@ -114,6 +152,12 @@ describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previo expect(s.pendingStartupByTabId[TAB1]).toBeUndefined() expect(s.codexRestartNoticeByPtyId[PTY1]).toBeUndefined() expect(s.migrationUnsupportedByPtyId[PTY1]).toBeUndefined() + expect(s.suppressedPtyExitIds[PTY1]).toBeUndefined() + expect(s.suppressedPtyExitIds[PTY1_SPLIT]).toBeUndefined() + expect(s.suppressedPtyExitIds[REMOTE_PTY1]).toBeUndefined() + expect(s.pendingCodexPaneRestartIds[PTY1]).toBeUndefined() + expect(s.pendingCodexPaneRestartIds[PTY1_SPLIT]).toBeUndefined() + expect(s.pendingCodexPaneRestartIds[REMOTE_PTY1]).toBeUndefined() // Surviving worktree's tab/pty: every entry retained (no over-eviction). expect(s.lastKnownRelayPtyIdByTabId[TAB2]).toBe(PTY2) @@ -123,5 +167,74 @@ describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previo expect(s.pendingStartupByTabId[TAB2]).toEqual({ command: 'start-b' }) expect(s.codexRestartNoticeByPtyId[PTY2]).toBeDefined() expect(s.migrationUnsupportedByPtyId[PTY2]).toBeDefined() + expect(s.suppressedPtyExitIds[PTY2]).toBe(true) + expect(s.pendingCodexPaneRestartIds[PTY2]).toBe(true) + }) + + it('keeps environment-scoped remote guard identities independent', () => { + const store = createTestStore() + seedMaps(store) + store.setState((s) => ({ + ptyIdsByTabId: { + ...s.ptyIdsByTabId, + [TAB2]: [...s.ptyIdsByTabId[TAB2], REMOTE_PTY2_SAME_HANDLE] + }, + suppressedPtyExitIds: { + ...s.suppressedPtyExitIds, + [REMOTE_PTY2_SAME_HANDLE]: true + }, + pendingCodexPaneRestartIds: { + ...s.pendingCodexPaneRestartIds, + [REMOTE_PTY2_SAME_HANDLE]: true + } + })) + + store.getState().purgeWorktreeTerminalState([WT1]) + const s = store.getState() + + expect(s.suppressedPtyExitIds[REMOTE_PTY1]).toBeUndefined() + expect(s.pendingCodexPaneRestartIds[REMOTE_PTY1]).toBeUndefined() + expect(s.suppressedPtyExitIds[REMOTE_PTY2_SAME_HANDLE]).toBe(true) + expect(s.pendingCodexPaneRestartIds[REMOTE_PTY2_SAME_HANDLE]).toBe(true) + }) + + it('retains a raw remote handle that is also a surviving local PTY id', () => { + const store = createTestStore() + seedMaps(store) + store.setState((s) => ({ + ptyIdsByTabId: { + ...s.ptyIdsByTabId, + [TAB2]: [...s.ptyIdsByTabId[TAB2], REMOTE_HANDLE1] + }, + pendingCodexPaneRestartIds: { + ...s.pendingCodexPaneRestartIds, + [REMOTE_HANDLE1]: true + }, + suppressedPtyExitIds: { + ...s.suppressedPtyExitIds, + [REMOTE_HANDLE1]: true + } + })) + + store.getState().purgeWorktreeTerminalState([WT1]) + const s = store.getState() + + expect(s.suppressedPtyExitIds[REMOTE_PTY1]).toBeUndefined() + expect(s.pendingCodexPaneRestartIds[REMOTE_PTY1]).toBeUndefined() + expect(s.suppressedPtyExitIds[REMOTE_HANDLE1]).toBe(true) + expect(s.pendingCodexPaneRestartIds[REMOTE_HANDLE1]).toBe(true) + }) + + it('does not parse aliases for environment-scoped remote guard cleanup', () => { + const store = createTestStore() + seedMaps(store) + const parseRemotePtyId = vi.mocked(parseRemoteRuntimePtyId) + parseRemotePtyId.mockClear() + + store.getState().purgeWorktreeTerminalState([WT1]) + + // Why: guard keys stay scoped to their environment, so purge can delete + // exact identities without parsing aliases or scanning surviving terminals. + expect(parseRemotePtyId).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 4538b91212e..45f22711a45 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -2794,7 +2794,6 @@ export const createRepoSlice: StateCreator = (set, // Kill PTYs for all worktrees belonging to this repo const worktreeIds = getKnownRepoWorktreeIds(get(), projectId, ownerHostId) const killedTabIds = new Set() - const killedPtyIds = new Set() if (target.kind === 'environment') { await Promise.allSettled( worktreeIds.map((worktreeId) => @@ -2812,7 +2811,6 @@ export const createRepoSlice: StateCreator = (set, for (const tab of tabs) { killedTabIds.add(tab.id) for (const ptyId of get().ptyIdsByTabId[tab.id] ?? []) { - killedPtyIds.add(ptyId) if (!ptyId.startsWith('remote:')) { window.api.pty.kill(ptyId) } @@ -2853,7 +2851,6 @@ export const createRepoSlice: StateCreator = (set, const nextLayouts = { ...s.terminalLayoutsByTabId } const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } - const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds } for (const wId of worktreeIds) { delete nextTabs[wId] } @@ -2862,9 +2859,6 @@ export const createRepoSlice: StateCreator = (set, delete nextPtyIdsByTabId[tabId] delete nextRuntimePaneTitlesByTabId[tabId] } - for (const ptyId of killedPtyIds) { - nextSuppressedPtyExitIds[ptyId] = true - } // Why: editor state is worktree-scoped. Removing a repo must also // remove open editor files and per-worktree active-file tracking for // all worktrees that belonged to the repo, otherwise orphaned entries @@ -2918,7 +2912,6 @@ export const createRepoSlice: StateCreator = (set, tabsByWorktree: nextTabs, ptyIdsByTabId: nextPtyIdsByTabId, runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, - suppressedPtyExitIds: nextSuppressedPtyExitIds, terminalLayoutsByTabId: nextLayouts, activeTabId: s.activeTabId && killedTabIds.has(s.activeTabId) ? null : s.activeTabId, openFiles: nextOpenFiles, diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index a7cbac5cca2..30a19084ec4 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -3356,7 +3356,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { } }, ptyIdsByTabId: { - 'tab-1': ['remote:env-1@@terminal-1', 'remote:env-1@@terminal-2'] + 'tab-1': ['remote:env-1@@terminal-1', 'remote:env-1@@terminal-2', 'terminal-1'] } }) store @@ -3392,7 +3392,15 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { expect(mockApi.runtimeEnvironments.call).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'terminal.stop' }) ) - expect(store.getState().ptyIdsByTabId['tab-1']).toEqual(['remote:env-1@@terminal-2']) + expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([ + 'remote:env-1@@terminal-2', + 'terminal-1' + ]) + expect(mockUnregisterPtyDataHandlers).toHaveBeenCalledWith(['remote:env-1@@terminal-1']) + expect(mockUnregisterPtyDataHandlers).not.toHaveBeenCalledWith(['terminal-1']) + expect(store.getState().suppressedPtyExitIds['remote:env-1@@terminal-1']).toBe(true) + expect(store.getState().suppressedPtyExitIds['remote:runtime-1@@terminal-1']).toBeUndefined() + expect(store.getState().suppressedPtyExitIds['terminal-1']).toBeUndefined() expect(mockApi.pty.kill).not.toHaveBeenCalled() }) @@ -3923,7 +3931,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { tabsByWorktree: { [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })] }, - ptyIdsByTabId: { 'tab-1': [] } + ptyIdsByTabId: { 'tab-1': ['remote:runtime-1@@pty-1'] } }) store.getState().setAgentStatus( 'tab-1:live', @@ -3943,14 +3951,15 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { sleepingPaneKeys: ['tab-1:live'], expectedRuntimePtyIds: ['pty-1'] }) - expect(store.getState().suppressedPtyExitIds['pty-1']).toBe(true) - - store.getState().updateTabPtyId('tab-1', 'pty-1') - + expect(store.getState().suppressedPtyExitIds['remote:runtime-1@@pty-1']).toBe(true) expect(store.getState().suppressedPtyExitIds['pty-1']).toBeUndefined() + + store.getState().updateTabPtyId('tab-1', 'remote:runtime-1@@pty-1') + + expect(store.getState().suppressedPtyExitIds['remote:runtime-1@@pty-1']).toBeUndefined() }) - it('suppresses wrapped remote PTY exits before exact runtime stop resolves', async () => { + it('suppresses only the scoped remote PTY identity before exact runtime stop resolves', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' let sawWrappedSuppressedDuringStop = false @@ -4014,10 +4023,57 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) expect(sawWrappedSuppressedDuringStop).toBe(true) - expect(sawRawSuppressedDuringStop).toBe(true) + expect(store.getState().suppressedPtyExitIds['remote:runtime-1@@terminal-1']).toBeUndefined() + expect(sawRawSuppressedDuringStop).toBe(false) + expect(mockUnregisterPtyDataHandlers).toHaveBeenCalledWith(['remote:env-1@@terminal-1']) + expect(mockUnregisterPtyDataHandlers).not.toHaveBeenCalledWith(['terminal-1']) }) - it('clears raw and wrapped remote exit suppression when a remote PTY wakes live again', () => { + it('still kills a local renderer PTY whose id matches a remote exact-stop handle', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => + Promise.resolve( + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { + id: 'rpc-default', + ok: true, + result: + args.method === 'terminal.stopExact' + ? { + stoppedPtyIds: ['terminal-1'], + livePtyIds: ['terminal-1'], + postStopVerified: true + } + : {}, + _meta: { runtimeId: 'remote-runtime' } + } + ) + ) + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt })] + }, + ptyIdsByTabId: { + 'tab-1': ['remote:runtime-1@@terminal-1', 'terminal-1'] + } + }) + + await store.getState().shutdownWorktreeTerminals(wt, { + expectedRuntimePtyIds: ['terminal-1'] + }) + + expect(mockApi.pty.kill).toHaveBeenCalledWith('terminal-1', { keepHistory: false }) + expect(mockUnregisterPtyDataHandlers).toHaveBeenCalledWith([ + 'remote:runtime-1@@terminal-1', + 'terminal-1' + ]) + }) + + it('does not consume a colliding raw PTY guard when a scoped remote PTY wakes', () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -4036,7 +4092,51 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { store.getState().updateTabPtyId('tab-1', 'remote:env-1@@terminal-1') expect(store.getState().suppressedPtyExitIds['remote:env-1@@terminal-1']).toBeUndefined() - expect(store.getState().suppressedPtyExitIds['terminal-1']).toBeUndefined() + expect(store.getState().suppressedPtyExitIds['terminal-1']).toBe(true) + }) + + it('migrates legacy remote lifecycle state to the scoped PTY identity on attach', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const legacyPtyId = 'remote:terminal-1' + const scopedPtyId = 'remote:env-1@@terminal-1' + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, ptyId: legacyPtyId })] + }, + ptyIdsByTabId: { 'tab-1': [legacyPtyId] }, + suppressedPtyExitIds: { [legacyPtyId]: true }, + pendingCodexPaneRestartIds: { [legacyPtyId]: true }, + codexRestartNoticeByPtyId: { + [legacyPtyId]: { previousAccountLabel: 'old', nextAccountLabel: 'new' } + }, + migrationUnsupportedByPtyId: { + [legacyPtyId]: { + ptyId: legacyPtyId, + paneKey: 'tab-1:leaf-1', + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1 + } + } + }) + + store.getState().updateTabPtyId('tab-1', scopedPtyId) + const state = store.getState() + + expect(state.ptyIdsByTabId['tab-1']).toEqual([scopedPtyId]) + expect(state.tabsByWorktree[wt][0]?.ptyId).toBe(scopedPtyId) + expect(state.suppressedPtyExitIds[legacyPtyId]).toBeUndefined() + expect(state.suppressedPtyExitIds[scopedPtyId]).toBeUndefined() + expect(state.pendingCodexPaneRestartIds).toEqual({ [scopedPtyId]: true }) + expect(state.codexRestartNoticeByPtyId[scopedPtyId]).toEqual({ + previousAccountLabel: 'old', + nextAccountLabel: 'new' + }) + expect(state.migrationUnsupportedByPtyId[scopedPtyId]?.ptyId).toBe(scopedPtyId) }) it('commits the pre-stop sleeping record when exact-stop exit clears live status', async () => { diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 7678d96f3ff..736e1152231 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -186,6 +186,8 @@ describe('removeProject cascade', () => { tab1: ['pty1'], tab2: ['pty2'] }, + suppressedPtyExitIds: { pty1: true, pty2: true }, + pendingCodexPaneRestartIds: { pty1: true, pty2: true }, terminalLayoutsByTabId: { tab1: makeLayout(), tab2: makeLayout() @@ -212,9 +214,20 @@ describe('removeProject cascade', () => { expect(mockApi.pty.kill).toHaveBeenCalledWith('pty1') expect(mockApi.pty.kill).toHaveBeenCalledWith('pty2') - // Killed PTY IDs are suppressed - expect(s.suppressedPtyExitIds['pty1']).toBe(true) - expect(s.suppressedPtyExitIds['pty2']).toBe(true) + // The tabs are gone before async exit events can close them, so retaining + // their one-shot guards would leak ephemeral PTY ids for the renderer session. + expect(s.suppressedPtyExitIds['pty1']).toBeUndefined() + expect(s.suppressedPtyExitIds['pty2']).toBeUndefined() + expect(s.pendingCodexPaneRestartIds['pty1']).toBeUndefined() + expect(s.pendingCodexPaneRestartIds['pty2']).toBeUndefined() + + store.getState().clearTabPtyId('tab1', 'pty1') + store.getState().clearTabPtyId('tab2', 'pty2') + + // Why: exit IPC can arrive after repo purge but before the mounted pane + // unmounts. A late exit must not recreate an index for a tab with no owner. + expect(store.getState().ptyIdsByTabId['tab1']).toBeUndefined() + expect(store.getState().ptyIdsByTabId['tab2']).toBeUndefined() }) }) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index a674e3a1138..817597d4132 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -71,7 +71,7 @@ import { } from '@/components/terminal-pane/terminal-layout-leaf-ids' import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { parseRemoteRuntimePtyId, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { createBrowserUuid } from '@/lib/browser-uuid' import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' @@ -456,6 +456,8 @@ export type TerminalSlice = { /** Agent-completion source marker for focus-return auto-ack. Kept separate * from unreadTerminalPanes so generic terminal bells still show until interact. */ unreadAgentCompletionPanes: Record + // Remote guard keys must use renderer-visible, environment-scoped PTY ids; + // raw runtime handles are only valid at the RPC boundary. suppressedPtyExitIds: Record pendingCodexPaneRestartIds: Record codexRestartNoticeByPtyId: Record< @@ -1868,9 +1870,16 @@ export const createTerminalSlice: StateCreator const isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) set((s) => { const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] - const nextPtyIds = existingPtyIds.includes(ptyId) - ? existingPtyIds - : [...existingPtyIds, ptyId] + const remote = parseRemoteRuntimePtyId(ptyId) + const legacyRemotePtyId = remote?.environmentId ? toRemoteRuntimePtyId(remote.handle) : null + const hasLegacyPtyBinding = legacyRemotePtyId + ? existingPtyIds.includes(legacyRemotePtyId) + : false + const nextPtyIds = hasLegacyPtyBinding + ? [...new Set(existingPtyIds.map((id) => (id === legacyRemotePtyId ? ptyId : id)))] + : existingPtyIds.includes(ptyId) + ? existingPtyIds + : [...existingPtyIds, ptyId] let nextTabsByWorktree = s.tabsByWorktree for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { const index = tabs.findIndex((t) => t.id === tabId) @@ -1891,7 +1900,8 @@ export const createTerminalSlice: StateCreator // paths. In split panes, later pane spawns must not steal that // primary binding from the original pane or remount/close flows can // reattach the tab to the wrong PTY and appear to "reset" panes. - const nextTabPtyId = tab.ptyId ?? nextPtyIds[0] ?? null + const currentTabPtyId = tab.ptyId === legacyRemotePtyId ? ptyId : tab.ptyId + const nextTabPtyId = currentTabPtyId ?? nextPtyIds[0] ?? null const nextPendingActivationSpawn = consumePendingActivationSpawn(tab.pendingActivationSpawn) if (tab.pendingActivationSpawn || tab.ptyId !== nextTabPtyId) { const nextTabs = [...tabs] @@ -1917,9 +1927,45 @@ export const createTerminalSlice: StateCreator const shouldBumpSortEpoch = isFirstPty && isActiveWorktree && !wasActivationSpawn const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds } delete nextSuppressedPtyExitIds[ptyId] - const remoteRuntimePtyHandle = parseRemoteRuntimePtyId(ptyId)?.handle - if (remoteRuntimePtyHandle) { - delete nextSuppressedPtyExitIds[remoteRuntimePtyHandle] + if (legacyRemotePtyId) { + delete nextSuppressedPtyExitIds[legacyRemotePtyId] + } + const hasLegacyPendingRestart = legacyRemotePtyId + ? legacyRemotePtyId in s.pendingCodexPaneRestartIds + : false + const hasLegacyRestartNotice = legacyRemotePtyId + ? legacyRemotePtyId in s.codexRestartNoticeByPtyId + : false + const hasLegacyMigrationUnsupported = legacyRemotePtyId + ? legacyRemotePtyId in s.migrationUnsupportedByPtyId + : false + const nextPendingCodexPaneRestartIds = hasLegacyPendingRestart + ? { ...s.pendingCodexPaneRestartIds } + : s.pendingCodexPaneRestartIds + const nextCodexRestartNoticeByPtyId = hasLegacyRestartNotice + ? { ...s.codexRestartNoticeByPtyId } + : s.codexRestartNoticeByPtyId + const nextMigrationUnsupportedByPtyId = hasLegacyMigrationUnsupported + ? { ...s.migrationUnsupportedByPtyId } + : s.migrationUnsupportedByPtyId + if (legacyRemotePtyId) { + if (hasLegacyPendingRestart) { + nextPendingCodexPaneRestartIds[ptyId] = true + delete nextPendingCodexPaneRestartIds[legacyRemotePtyId] + } + if (hasLegacyRestartNotice) { + const legacyNotice = nextCodexRestartNoticeByPtyId[legacyRemotePtyId] + nextCodexRestartNoticeByPtyId[ptyId] ??= legacyNotice + delete nextCodexRestartNoticeByPtyId[legacyRemotePtyId] + } + if (hasLegacyMigrationUnsupported) { + const legacyMigrationUnsupported = nextMigrationUnsupportedByPtyId[legacyRemotePtyId] + nextMigrationUnsupportedByPtyId[ptyId] ??= { + ...legacyMigrationUnsupported, + ptyId + } + delete nextMigrationUnsupportedByPtyId[legacyRemotePtyId] + } } return { ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), @@ -1932,6 +1978,9 @@ export const createTerminalSlice: StateCreator [tabId]: ptyId }, suppressedPtyExitIds: nextSuppressedPtyExitIds, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, + migrationUnsupportedByPtyId: nextMigrationUnsupportedByPtyId, ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) } }) @@ -1994,7 +2043,13 @@ export const createTerminalSlice: StateCreator break } const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } - nextPtyIdsByTabId[tabId] = remainingPtyIds + if (worktreeId) { + nextPtyIdsByTabId[tabId] = remainingPtyIds + } else { + // Why: repo purge can remove the owning tab before its asynchronous + // exit arrives. Do not resurrect an orphan PTY index for a retired tab. + delete nextPtyIdsByTabId[tabId] + } const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } if (ptyId) { @@ -2044,8 +2099,12 @@ export const createTerminalSlice: StateCreator const expectedRuntimePtyIds = sortedUniquePtyIds( opts.expectedRuntimePtyId ? [opts.expectedRuntimePtyId] : [] ) - const shutdownPtyIds = sortedUniquePtyIds([opts.ptyId, ...expectedRuntimePtyIds]) + const rendererShutdownPtyIds = [opts.ptyId] const state = get() + const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(state, worktreeId) + // Why: pane transports emit renderer PTY ids, never raw exact-stop handles. + // Guard only the identity that can actually deliver an exit callback. + const exitGuardPtyIds = [opts.ptyId] const tab = (state.tabsByWorktree[worktreeId] ?? []).find( (candidate) => candidate.id === opts.tabId ) @@ -2108,7 +2167,7 @@ export const createTerminalSlice: StateCreator const rollbackTargetShutdownState = (): void => { set((s) => { const next = { ...s.suppressedPtyExitIds } - for (const ptyId of shutdownPtyIds) { + for (const ptyId of exitGuardPtyIds) { delete next[ptyId] } const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey } @@ -2127,7 +2186,7 @@ export const createTerminalSlice: StateCreator set((s) => ({ suppressedPtyExitIds: { ...s.suppressedPtyExitIds, - ...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const)) + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) }, sleepingAgentSessionsByPaneKey: { ...s.sleepingAgentSessionsByPaneKey, @@ -2136,7 +2195,6 @@ export const createTerminalSlice: StateCreator })) if (expectedRuntimePtyIds.length > 0) { - const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) if (!runtimeEnvironmentId) { rollbackTargetShutdownState() throw new Error('missing_runtime_for_exact_terminal_stop') @@ -2179,11 +2237,11 @@ export const createTerminalSlice: StateCreator rollbackTargetShutdownState() throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') } - unregisterPtyDataHandlers(shutdownPtyIds) + unregisterPtyDataHandlers(rendererShutdownPtyIds) } else if (!opts.ptyId.startsWith('remote:')) { // Why: pty.kill can flush final data before exit; unregister first so // pane hibernation cannot fire phantom notifications from stale handlers. - const handlerSnapshots = unregisterPtyDataHandlers(shutdownPtyIds) + const handlerSnapshots = unregisterPtyDataHandlers(rendererShutdownPtyIds) try { await window.api.pty.kill(opts.ptyId, { keepHistory: true }) } catch (err) { @@ -2195,7 +2253,7 @@ export const createTerminalSlice: StateCreator set((s) => { const existingPtyIds = s.ptyIdsByTabId[opts.tabId] ?? [] - const shutdownPtyIdSet = new Set(shutdownPtyIds) + const shutdownPtyIdSet = new Set(rendererShutdownPtyIds) const remainingPtyIds = existingPtyIds.filter((ptyId) => !shutdownPtyIdSet.has(ptyId)) const nextTabsByWorktree = { ...s.tabsByWorktree } const tabs = nextTabsByWorktree[worktreeId] ?? [] @@ -2210,7 +2268,7 @@ export const createTerminalSlice: StateCreator } const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } - for (const ptyId of shutdownPtyIds) { + for (const ptyId of exitGuardPtyIds) { delete nextCodexRestartNoticeByPtyId[ptyId] } const nextLastKnownRelay = @@ -2253,7 +2311,7 @@ export const createTerminalSlice: StateCreator lastKnownRelayPtyIdByTabId: nextLastKnownRelay, suppressedPtyExitIds: { ...s.suppressedPtyExitIds, - ...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const)) + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) }, codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, ...(nextRuntimePaneTitlesByTabId !== s.runtimePaneTitlesByTabId @@ -2276,8 +2334,12 @@ export const createTerminalSlice: StateCreator opts?.shutdownReason ?? (keepIdentifiers ? 'manual-sleep' : 'remove-worktree') const tabs = get().tabsByWorktree[worktreeId] ?? [] const ptyIds = tabs.flatMap((tab) => get().ptyIdsByTabId[tab.id] ?? []) + const rendererShutdownPtyIds = sortedUniquePtyIds(ptyIds) const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds) - const shutdownPtyIds = sortedUniquePtyIds([...ptyIds, ...expectedRuntimePtyIds]) + const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) + // Why: expectedRuntimePtyIds are raw RPC handles. Only renderer-bound ids + // can emit pane exit callbacks, so they are the complete guard identity set. + const exitGuardPtyIds = rendererShutdownPtyIds const sleepingAgentSessionRecords = keepIdentifiers ? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, { paneKeys: opts?.sleepingPaneKeys, @@ -2300,12 +2362,12 @@ export const createTerminalSlice: StateCreator // the "phantom alerts" users see after shutting down worktrees. // Removing the data handlers first ensures the final flush is a no-op. if (expectedRuntimePtyIds.length === 0) { - unregisterPtyDataHandlers(shutdownPtyIds) + unregisterPtyDataHandlers(rendererShutdownPtyIds) // Why: parked-tab byte watchers observe the same flush through dispatcher // sidecars, which the call above does not touch — dispose them now or a // just-slept/deleted worktree still gets unread marks and delayed // bell/completion OS notifications from its teardown bytes. - disposeParkedTerminalWatchersForPtyIds(shutdownPtyIds) + disposeParkedTerminalWatchersForPtyIds(rendererShutdownPtyIds) } // Why (ordering invariant — DESIGN_DOC §3.3.c): on sleep, capture every @@ -2332,7 +2394,6 @@ export const createTerminalSlice: StateCreator } } - const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) if (expectedRuntimePtyIds.length > 0) { if (!runtimeEnvironmentId) { throw new Error('missing_runtime_for_exact_terminal_stop') @@ -2340,7 +2401,7 @@ export const createTerminalSlice: StateCreator set((s) => ({ suppressedPtyExitIds: { ...s.suppressedPtyExitIds, - ...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const)) + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) } })) let stopResult: { @@ -2367,7 +2428,7 @@ export const createTerminalSlice: StateCreator } catch (err) { set((s) => { const next = { ...s.suppressedPtyExitIds } - for (const ptyId of shutdownPtyIds) { + for (const ptyId of exitGuardPtyIds) { delete next[ptyId] } return { suppressedPtyExitIds: next } @@ -2382,7 +2443,7 @@ export const createTerminalSlice: StateCreator ) { set((s) => { const next = { ...s.suppressedPtyExitIds } - for (const ptyId of shutdownPtyIds) { + for (const ptyId of exitGuardPtyIds) { delete next[ptyId] } return { suppressedPtyExitIds: next } @@ -2392,14 +2453,14 @@ export const createTerminalSlice: StateCreator if (stopResult.postStopVerified !== true) { set((s) => { const next = { ...s.suppressedPtyExitIds } - for (const ptyId of shutdownPtyIds) { + for (const ptyId of exitGuardPtyIds) { delete next[ptyId] } return { suppressedPtyExitIds: next } }) throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') } - unregisterPtyDataHandlers(shutdownPtyIds) + unregisterPtyDataHandlers(rendererShutdownPtyIds) } set((s) => { @@ -2420,7 +2481,7 @@ export const createTerminalSlice: StateCreator : { ...s.runtimePaneTitlesByTabId } const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds, - ...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const)) + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) } // Why: pendingCodexPaneRestartIds is keyed by ptyId — under sleep we // preserve it so a mid-restart marker survives wake against the same @@ -2431,7 +2492,7 @@ export const createTerminalSlice: StateCreator ? s.pendingCodexPaneRestartIds : { ...s.pendingCodexPaneRestartIds } const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } - for (const ptyId of shutdownPtyIds) { + for (const ptyId of exitGuardPtyIds) { if (!keepIdentifiers) { delete nextPendingCodexPaneRestartIds[ptyId] } @@ -2585,7 +2646,7 @@ export const createTerminalSlice: StateCreator }) get().clearPaneForegroundAgentByWorktree(worktreeId) - if (ptyIds.length === 0 && expectedRuntimePtyIds.length === 0) { + if (rendererShutdownPtyIds.length === 0 && expectedRuntimePtyIds.length === 0) { return } @@ -2599,8 +2660,7 @@ export const createTerminalSlice: StateCreator } await Promise.allSettled( - ptyIds - .filter((ptyId) => !expectedRuntimePtyIds.includes(ptyId)) + rendererShutdownPtyIds .filter((ptyId) => !ptyId.startsWith('remote:')) .map((ptyId) => window.api.pty.kill(ptyId, { keepHistory: keepIdentifiers })) ) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 1534b3a80cf..dfd32303e25 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1904,11 +1904,25 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial() - // Why: some terminal/agent maps are keyed by ptyId, not tabId. Collect the - // doomed panes' ptyIds now (while ptyIdsByTabId is still populated) so this - // bulk path can evict them the same way shutdownWorktreeTerminals does on the - // single-removeWorktree path. + // Why: some terminal/agent maps are keyed by ptyId, not tabId. Collect every + // durable wake hint too, because slept panes have already left the live index. const doomedPtyIds = new Set() + const addDoomedPtyId = (ptyId: string | null | undefined): void => { + if (!ptyId) { + return + } + doomedPtyIds.add(ptyId) + } + const addDoomedTabPtyIds = (tabId: string, tabPtyId: string | null | undefined): void => { + for (const ptyId of s.ptyIdsByTabId?.[tabId] ?? []) { + addDoomedPtyId(ptyId) + } + addDoomedPtyId(tabPtyId) + addDoomedPtyId(s.lastKnownRelayPtyIdByTabId?.[tabId]) + for (const ptyId of Object.values(s.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId ?? {})) { + addDoomedPtyId(ptyId) + } + } const doomedBrowserWorkspaceIds = new Set() const doomedPageIds = new Set() const removedFileIds = new Set() @@ -1917,9 +1931,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial { expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }]) }) + it('hydrates bot-author overrides from paired runtime settings', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: 'call-1', + ok: true, + result: { settings: { prBotAuthorOverrides: [' GretelFlux ', 'gretelflux'] } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const settings = await globals.window.api.settings.get() + + expect(settings.prBotAuthorOverrides).toEqual(['gretelflux']) + expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }]) + }) + it('forwards compact worktree card updates to a paired runtime', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] vi.doMock('./web-runtime-client', () => ({ @@ -572,6 +601,105 @@ describe('web settings preload API', () => { } ]) }) + + it('forwards normalized bot-author overrides to a paired runtime', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: 'call-1', + ok: true, + result: { settings: { prBotAuthorOverrides: ['gretelflux'] } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const settings = await globals.window.api.settings.set({ + prBotAuthorOverrides: [' GretelFlux ', 'gretelflux'] + }) + + expect(settings.prBotAuthorOverrides).toEqual(['gretelflux']) + expect(runtimeCalls).toEqual([ + { method: 'settings.update', params: { prBotAuthorOverrides: ['gretelflux'] } } + ]) + }) + + it('atomically updates a bot-author override through a paired runtime', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: 'call-1', + ok: true, + result: { settings: { prBotAuthorOverrides: ['gretelflux'] } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const settings = await globals.window.api.settings.updatePRBotAuthorOverride({ + author: 'gretelflux', + isBot: true + }) + + expect(settings.prBotAuthorOverrides).toEqual(['gretelflux']) + expect(runtimeCalls).toEqual([ + { + method: 'settings.updatePRBotAuthorOverride', + params: { author: 'gretelflux', isBot: true } + } + ]) + }) + + it('does not claim a paired bot-author update succeeded when the runtime rejects it', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(): Promise> { + return Promise.reject(new Error('runtime unavailable')) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.settings.updatePRBotAuthorOverride({ + author: 'gretelflux', + isBot: true + }) + ).rejects.toThrow('runtime unavailable') + + const stored = JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}') as { + prBotAuthorOverrides?: string[] + } + expect(stored.prBotAuthorOverrides).toBeUndefined() + }) }) describe('web MiniMax preload API', () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 961cdba113d..8879d729d16 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -54,6 +54,10 @@ import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-resul import { EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS } from '../../../shared/pty-delivery-diagnostics' import { createE2EConfig } from '../../../shared/e2e-config' import { relativePathInsideRoot } from '../../../shared/cross-platform-path' +import { + applyPRBotAuthorOverride, + normalizePRBotAuthorOverrides +} from '../../../shared/pr-bot-author-overrides' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostScope, @@ -590,6 +594,7 @@ function createWebPreloadApi(): Partial { writeJson(SETTINGS_STORAGE_KEY, next) return syncRuntimeBackedSettings(sanitizedUpdates, next) }, + updatePRBotAuthorOverride: (args) => updateRuntimePRBotAuthorOverride(args), listFonts: () => Promise.resolve([]), onChanged: () => noopUnsubscribe } satisfies Partial as unknown as WebSettingsApi, @@ -745,6 +750,9 @@ function createWebPreloadApi(): Partial { mobile: { listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }), getPairingQR: () => Promise.resolve({ available: false }), + getWindowsFirewallStatus: () => Promise.resolve({ supported: false }), + repairWindowsFirewall: () => Promise.resolve({ ok: false, reason: 'unsupported' }), + openWindowsNetworkSettings: () => Promise.resolve(false), getRuntimePairingUrl: () => Promise.resolve({ available: false }), listDevices: () => Promise.resolve({ devices: [] }), revokeDevice: () => Promise.resolve({ revoked: false }), @@ -1457,7 +1465,12 @@ function createWorktreesApi(): NonNullable['worktrees']> { persistSortOrder: async ({ orderedIds }) => { await callRuntimeResult('worktree.persistSortOrder', { orderedIds }) }, + // Why: the capture lives in desktop main memory and is not exposed over + // pairing; the dialog falls back to the persisted excerpt on web clients. + getBranchRenameFailureOutput: async () => null, onChanged: () => noopUnsubscribe, + onGitStatusMetadataChanged: () => noopUnsubscribe, + onHeadIdentitiesChanged: () => noopUnsubscribe, onBaseStatus: () => noopUnsubscribe, onRemoteBranchConflict: () => noopUnsubscribe } @@ -3117,6 +3130,11 @@ async function getRuntimeBackedStoredSettings(): Promise { if (typeof result.settings.minimaxUsageModels === 'string') { runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels } + if (Array.isArray(result.settings.prBotAuthorOverrides)) { + runtimeSettings.prBotAuthorOverrides = normalizePRBotAuthorOverrides( + result.settings.prBotAuthorOverrides + ) + } const next = mergeSettings(local, runtimeSettings) writeJson(SETTINGS_STORAGE_KEY, next) return next @@ -3146,6 +3164,11 @@ async function syncRuntimeBackedSettings( if (typeof updates.minimaxUsageModels === 'string') { runtimeUpdates.minimaxUsageModels = updates.minimaxUsageModels } + if (Array.isArray(updates.prBotAuthorOverrides)) { + runtimeUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides( + updates.prBotAuthorOverrides + ) + } if (Object.keys(runtimeUpdates).length === 0) { return localNext } @@ -3164,6 +3187,36 @@ async function syncRuntimeBackedSettings( } } +async function updateRuntimePRBotAuthorOverride(args: { + author: string + isBot: boolean +}): Promise { + const local = getStoredSettings() + if (requireActiveEnvironmentOrNull()) { + // Why: a paired client must not report a successful mark that the + // authoritative runtime failed to persist and will later overwrite. + const result = await callRuntimeResult<{ settings: Partial }>( + 'settings.updatePRBotAuthorOverride', + args, + 15_000 + ) + const next = mergeSettings(local, { + prBotAuthorOverrides: normalizePRBotAuthorOverrides(result.settings.prBotAuthorOverrides) + }) + writeJson(SETTINGS_STORAGE_KEY, next) + return next + } + const next = mergeSettings(local, { + prBotAuthorOverrides: applyPRBotAuthorOverride( + local.prBotAuthorOverrides, + args.author, + args.isBot + ) + }) + writeJson(SETTINGS_STORAGE_KEY, next) + return next +} + function getStoredOnboarding(): OnboardingState { const storedRaw = window.localStorage.getItem(ONBOARDING_STORAGE_KEY) if (storedRaw) { diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index 5ab927d30c7..7f2f1308d89 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -5,6 +5,7 @@ import { extractAllOscTitles, extractLastOscTitle, getAgentLabel, + isCursorAgentTitle, MAX_OSC_TITLE_CHARS, normalizeTerminalTitle } from './agent-detection' @@ -14,6 +15,7 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from './agent-title-owner' +import { SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title' afterEach(() => { vi.restoreAllMocks() @@ -177,3 +179,50 @@ describe('Pi-compatible title detection', () => { } ) }) + +describe('Cursor agent title identity', () => { + // Why: the accepted vocabulary is the set of labels Orca actually synthesizes for Cursor. + // Pin it to that profile so renaming a label there cannot silently drop @cursor to zero + // recipients (and desync the auto-Enter suppression that shares this predicate). + it('accepts every label Orca synthesizes for Cursor', () => { + const profile = SYNTHETIC_AGENT_TITLE_PROFILES.cursor + + for (const label of [ + profile.workingLabel, + `⠋ ${profile.workingLabel}`, + profile.idleLabel, + profile.permissionLabel + ]) { + expect(isCursorAgentTitle(label)).toBe(true) + } + }) + + it.each([ + 'Cursor Agent', + ' cursor agent ', + '⠋ Cursor Agent', + '⣿ Cursor Agent', + 'Cursor ready', + 'Cursor - action required' + ])('accepts the native or Orca-synthesized Cursor title %j', (title) => { + expect(isCursorAgentTitle(title)).toBe(true) + }) + + // Why: "cursor" is ordinary editor vocabulary in another agent's task-summary title, + // so a whole-token name match is not Cursor identity. + it.each([ + '⠋ fix the text cursor blink', + '✳ Fix the text cursor blink', + '. fix cursor position', + '* cursor rendering done', + 'Terminal Cursor and Orca slows down', + 'cursor-agent', + 'cursor.exe', + '~/cursor-rules', + '', + null, + undefined + ])('rejects the non-Cursor title %j', (title) => { + expect(isCursorAgentTitle(title)).toBe(false) + }) +}) diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index dbb13895a1a..891f37093db 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -11,6 +11,7 @@ export type { AgentStatus } from './agent-title-core' export { isClaudeManagementTitle, + isCursorAgentTitle, isCursorNativeAgentTitle, isGeminiTerminalTitle, isPiTerminalTitle, diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 083783328d3..a593d3bcae9 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -1639,6 +1639,44 @@ describe('shared agent-hook-listener', () => { expect(event?.payload.interactivePrompt).toBeUndefined() }) + it('surfaces the Grok tool-failure error and clears stale tool fields', () => { + normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'pre_tool_use', + toolName: 'run_terminal_command', + toolInput: { command: 'pnpm build' } + } + }, + 'production' + ) + const failed = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'post_tool_use_failure', + toolName: 'run_terminal_command', + toolInput: { command: 'pnpm build' }, + error: 'command exited with code 1' + } + }, + '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 exited with code 1' + }) + expect(failed?.payload.toolName).toBeUndefined() + expect(failed?.payload.toolInput).toBeUndefined() + }) + it('maps Grok StopFailure to done', () => { normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 59724b64322..ce3e2f720a4 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -692,6 +692,17 @@ function toolUpdate( } } +/** Clear the active-tool metadata (name/input/prompt) so a just-failed tool + * stops looking in-flight. Why: the compact sidebar prioritizes current-tool + * metadata over the failure message, so a completed failed tool must no + * longer look active or the error text stays hidden behind the tool name. */ +function clearActiveToolFieldsUpdate(): ToolSnapshot { + return toolUpdate( + { toolName: undefined, toolInput: undefined, interactivePrompt: undefined }, + { hasToolInputField: true } + ) +} + /** True for the AskUserQuestion tool across the casing variants different * agents emit (`AskUserQuestion` / `ask_user_question` / `askUserQuestion`). * Why: this is the structured "pick an option" prompt whose full input the @@ -1375,10 +1386,11 @@ function extractClaudeToolFields( hookPayload: Record ): ToolSnapshot { const update: ToolSnapshot = {} - if ( + if (eventName === 'PostToolUseFailure') { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else if ( eventName === 'PreToolUse' || eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' || eventName === 'PermissionRequest' ) { const toolName = readString(hookPayload, 'tool_name') @@ -1600,12 +1612,20 @@ function extractCursorToolFields( eventName === 'postToolUse' || eventName === 'postToolUseFailure' ) { - const toolName = readString(hookPayload, 'tool_name') - const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) - const update: ToolSnapshot = toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } - ) + const update: ToolSnapshot = {} + if (eventName === 'postToolUseFailure') { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else { + const toolName = readString(hookPayload, 'tool_name') + const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) + Object.assign( + update, + toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } + ) + ) + } if (eventName === 'postToolUse') { const responseText = extractToolResponseText(hookPayload.tool_output) if (responseText) { @@ -1750,10 +1770,11 @@ function extractCopilotToolFields( hookPayload: Record ): ToolSnapshot { const update: ToolSnapshot = {} - if ( + if (eventName === 'PostToolUseFailure' || eventName === 'ErrorOccurred') { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else if ( eventName === 'PreToolUse' || eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' || eventName === 'PermissionRequest' ) { const copilotToolCall = readCopilotToolCall(hookPayload) @@ -2016,29 +2037,40 @@ function extractGrokToolFields( grokHome?: string ): ToolSnapshot { if (isGrokEvent(eventName, 'pre_tool_use', 'post_tool_use', 'post_tool_use_failure')) { - const toolName = - readString(hookPayload, 'toolName') ?? - readString(hookPayload, 'tool_name') ?? - readString(hookPayload, 'name') - const rawInput = - hookPayload.toolInput ?? hookPayload.tool_input ?? hookPayload.input ?? hookPayload.arguments - const toolInput = - deriveToolInputPreview(toolName, rawInput) ?? deriveFallbackToolInputPreview(rawInput) - // Why: Grok's ask_user_question is auto-allowed and arrives as PreToolUse - // (not PermissionRequest). Capture the full question payload so the live - // card path can render options instead of only a waiting Notification. - const interactivePrompt = deriveInteractivePrompt(toolName, rawInput, eventName) - const update: ToolSnapshot = toolUpdate( - { toolName, toolInput, interactivePrompt }, - { - hasToolInputField: hasAnyOwnField(hookPayload, [ - 'toolInput', - 'tool_input', - 'input', - 'arguments' - ]) - } - ) + const update: ToolSnapshot = {} + if (isGrokEvent(eventName, 'post_tool_use_failure')) { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else { + const toolName = + readString(hookPayload, 'toolName') ?? + readString(hookPayload, 'tool_name') ?? + readString(hookPayload, 'name') + const rawInput = + hookPayload.toolInput ?? + hookPayload.tool_input ?? + hookPayload.input ?? + hookPayload.arguments + const toolInput = + deriveToolInputPreview(toolName, rawInput) ?? deriveFallbackToolInputPreview(rawInput) + // Why: Grok's ask_user_question is auto-allowed and arrives as PreToolUse + // (not PermissionRequest). Capture the full question payload so the live + // card path can render options instead of only a waiting Notification. + const interactivePrompt = deriveInteractivePrompt(toolName, rawInput, eventName) + Object.assign( + update, + toolUpdate( + { toolName, toolInput, interactivePrompt }, + { + hasToolInputField: hasAnyOwnField(hookPayload, [ + 'toolInput', + 'tool_input', + 'input', + 'arguments' + ]) + } + ) + ) + } if (isGrokEvent(eventName, 'post_tool_use', 'post_tool_use_failure')) { const responseText = extractToolResponseText(hookPayload.toolResponse) ?? diff --git a/src/shared/agent-icons/amp.png b/src/shared/agent-icons/amp.png new file mode 100644 index 00000000000..9b57e20ff0f Binary files /dev/null and b/src/shared/agent-icons/amp.png differ diff --git a/src/shared/agent-icons/ante.png b/src/shared/agent-icons/ante.png new file mode 100644 index 00000000000..c3dadcc2cec Binary files /dev/null and b/src/shared/agent-icons/ante.png differ diff --git a/src/shared/agent-icons/antigravity.png b/src/shared/agent-icons/antigravity.png new file mode 100644 index 00000000000..be2992716b5 Binary files /dev/null and b/src/shared/agent-icons/antigravity.png differ diff --git a/src/shared/agent-icons/aug.png b/src/shared/agent-icons/aug.png new file mode 100644 index 00000000000..1664844ad7f Binary files /dev/null and b/src/shared/agent-icons/aug.png differ diff --git a/src/shared/agent-icons/autohand.png b/src/shared/agent-icons/autohand.png new file mode 100644 index 00000000000..0c3326a5705 Binary files /dev/null and b/src/shared/agent-icons/autohand.png differ diff --git a/src/shared/agent-icons/cline.png b/src/shared/agent-icons/cline.png new file mode 100644 index 00000000000..f4bd762dfee Binary files /dev/null and b/src/shared/agent-icons/cline.png differ diff --git a/src/shared/agent-icons/codebuff.png b/src/shared/agent-icons/codebuff.png new file mode 100644 index 00000000000..b81a6fa9c21 Binary files /dev/null and b/src/shared/agent-icons/codebuff.png differ diff --git a/src/shared/agent-icons/command-code.png b/src/shared/agent-icons/command-code.png new file mode 100644 index 00000000000..2066461b996 Binary files /dev/null and b/src/shared/agent-icons/command-code.png differ diff --git a/src/shared/agent-icons/continue.png b/src/shared/agent-icons/continue.png new file mode 100644 index 00000000000..cd2599d6e63 Binary files /dev/null and b/src/shared/agent-icons/continue.png differ diff --git a/src/shared/agent-icons/copilot.png b/src/shared/agent-icons/copilot.png new file mode 100644 index 00000000000..52126e047d0 Binary files /dev/null and b/src/shared/agent-icons/copilot.png differ diff --git a/src/shared/agent-icons/crush.png b/src/shared/agent-icons/crush.png new file mode 100644 index 00000000000..6d880c1305d Binary files /dev/null and b/src/shared/agent-icons/crush.png differ diff --git a/src/shared/agent-icons/cursor.png b/src/shared/agent-icons/cursor.png new file mode 100644 index 00000000000..426cbfa4d0d Binary files /dev/null and b/src/shared/agent-icons/cursor.png differ diff --git a/src/shared/agent-icons/devin.png b/src/shared/agent-icons/devin.png new file mode 100644 index 00000000000..3eee8d0d8d0 Binary files /dev/null and b/src/shared/agent-icons/devin.png differ diff --git a/src/shared/agent-icons/droid.png b/src/shared/agent-icons/droid.png new file mode 100644 index 00000000000..014b63332be Binary files /dev/null and b/src/shared/agent-icons/droid.png differ diff --git a/src/shared/agent-icons/gemini.png b/src/shared/agent-icons/gemini.png new file mode 100644 index 00000000000..84976ebc7fd Binary files /dev/null and b/src/shared/agent-icons/gemini.png differ diff --git a/src/shared/agent-icons/goose.png b/src/shared/agent-icons/goose.png new file mode 100644 index 00000000000..2da26f064ce Binary files /dev/null and b/src/shared/agent-icons/goose.png differ diff --git a/src/shared/agent-icons/grok.png b/src/shared/agent-icons/grok.png new file mode 100644 index 00000000000..954e5b40da2 Binary files /dev/null and b/src/shared/agent-icons/grok.png differ diff --git a/src/shared/agent-icons/hermes.png b/src/shared/agent-icons/hermes.png new file mode 100644 index 00000000000..7d5ba85e54e Binary files /dev/null and b/src/shared/agent-icons/hermes.png differ diff --git a/src/shared/agent-icons/kilo.png b/src/shared/agent-icons/kilo.png new file mode 100644 index 00000000000..053dba3fd76 Binary files /dev/null and b/src/shared/agent-icons/kilo.png differ diff --git a/src/shared/agent-icons/kimi.png b/src/shared/agent-icons/kimi.png new file mode 100644 index 00000000000..3640a259f8d Binary files /dev/null and b/src/shared/agent-icons/kimi.png differ diff --git a/src/shared/agent-icons/kiro.png b/src/shared/agent-icons/kiro.png new file mode 100644 index 00000000000..4a6cc6bbf8c Binary files /dev/null and b/src/shared/agent-icons/kiro.png differ diff --git a/src/shared/agent-icons/mimo-code.png b/src/shared/agent-icons/mimo-code.png new file mode 100644 index 00000000000..45cb84ff516 Binary files /dev/null and b/src/shared/agent-icons/mimo-code.png differ diff --git a/src/shared/agent-icons/mistral-vibe.png b/src/shared/agent-icons/mistral-vibe.png new file mode 100644 index 00000000000..f0f799cf546 Binary files /dev/null and b/src/shared/agent-icons/mistral-vibe.png differ diff --git a/src/shared/agent-icons/openclaude.png b/src/shared/agent-icons/openclaude.png new file mode 100644 index 00000000000..1e660b74090 Binary files /dev/null and b/src/shared/agent-icons/openclaude.png differ diff --git a/src/shared/agent-icons/openclaw.png b/src/shared/agent-icons/openclaw.png new file mode 100644 index 00000000000..03feb5b7638 Binary files /dev/null and b/src/shared/agent-icons/openclaw.png differ diff --git a/src/shared/agent-icons/opencode.png b/src/shared/agent-icons/opencode.png new file mode 100644 index 00000000000..1f45b1faee0 Binary files /dev/null and b/src/shared/agent-icons/opencode.png differ diff --git a/src/shared/agent-icons/qwen-code.png b/src/shared/agent-icons/qwen-code.png new file mode 100644 index 00000000000..9a6346691d4 Binary files /dev/null and b/src/shared/agent-icons/qwen-code.png differ diff --git a/src/shared/agent-icons/rovo.png b/src/shared/agent-icons/rovo.png new file mode 100644 index 00000000000..08a2af624f8 Binary files /dev/null and b/src/shared/agent-icons/rovo.png differ diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 7b544ecc783..d7004fdb554 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -156,6 +156,8 @@ export type AgentStatusEntry = { /** Provider-owned conversation/session id captured from hook payloads. * Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */ providerSession?: AgentProviderSessionMetadata + /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ + promptInteractionKey?: string } export type MigrationUnsupportedPtyEntry = { @@ -222,6 +224,8 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { stateStartedAt: number orchestration?: AgentStatusOrchestrationContext providerSession?: AgentProviderSessionMetadata + /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ + promptInteractionKey?: string } /** Maximum character length for the toolName field. */ diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts index 1bafae3cbd4..15dc12d65ad 100644 --- a/src/shared/agent-title-core.ts +++ b/src/shared/agent-title-core.ts @@ -108,3 +108,25 @@ export function isClaudeManagementTitle(title: string): boolean { export function isCursorNativeAgentTitle(title: string): boolean { return title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER } + +// Why: `cursor` is also an ordinary editor noun that other agents type into their own +// task-summary titles, so a name token is not identity. Cursor's identifying titles are +// a closed set (the native literal plus the labels Orca synthesizes from Cursor hooks), +// so match that vocabulary instead. +export function isCursorAgentTitle(title: string | null | undefined): boolean { + if (typeof title !== 'string') { + return false + } + const trimmed = title.trim() + const lower = trimmed.toLowerCase() + if ( + lower === CURSOR_NATIVE_TITLE_LOWER || + lower === 'cursor ready' || + lower === 'cursor - action required' + ) { + return true + } + // Why: display labels can mention Cursor in another agent's task text. Only + // treat the controlled synthetic Cursor spinner title as Cursor identity. + return /^[\u2800-\u28ff] Cursor Agent$/u.test(trimmed) +} diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index 89dbded12f8..9f7746b01bc 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -5,6 +5,7 @@ import { HERMES_AGENT_NAME_RE, containsBrailleSpinner, isClaudeManagementTitle, + isCursorAgentTitle, isGeminiTerminalTitle, isPiAgentTitle, titleHasAgentName @@ -30,7 +31,9 @@ export function isClaudeAgent(title: string): boolean { return true } if (containsBrailleSpinner(title)) { - return !lower.includes('cursor') && !lower.includes('openclaude') + // Why: named non-Claude agents carry braille spinners too. Gate Cursor by its + // identity title, not the token, so a Claude title mentioning a cursor stays Claude. + return !isCursorAgentTitle(title) && !lower.includes('openclaude') } const trimmedTitle = title.trimStart() @@ -93,8 +96,9 @@ export function getAgentLabel(title: string): string | null { if (titleHasAgentName(title, 'aider')) { return 'Aider' } - // Why: match explicit names before Claude's generic braille heuristic. - if (titleHasAgentName(title, 'cursor')) { + // Why: `cursor` is ordinary editor vocabulary, not identity. Match Cursor's closed + // title set (mirrors @cursor routing), before `isClaudeAgent` claims the braille frame. + if (isCursorAgentTitle(title)) { return 'Cursor' } if (DROID_AGENT_NAME_RE.test(title)) { diff --git a/src/shared/command-code-turn-boundary.test.ts b/src/shared/command-code-turn-boundary.test.ts new file mode 100644 index 00000000000..28f276b32c3 --- /dev/null +++ b/src/shared/command-code-turn-boundary.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { isCommandCodeNewTurnWhileWorking } from './command-code-turn-boundary' + +describe('isCommandCodeNewTurnWhileWorking', () => { + it('returns true when Command Code gets a new transcript prompt while still working', () => { + expect( + isCommandCodeNewTurnWhileWorking({ + agentType: 'command-code', + previousState: 'working', + incomingState: 'working', + previousPrompt: 'first task', + incomingPrompt: 'second task', + hasExplicitPrompt: true + }) + ).toBe(true) + }) + + it('returns true when the prompt interaction key changes', () => { + expect( + isCommandCodeNewTurnWhileWorking({ + agentType: 'command-code', + previousState: 'working', + incomingState: 'working', + previousPrompt: 'same text', + incomingPrompt: 'same text', + hasExplicitPrompt: true, + previousPromptInteractionKey: 'command-code-transcript-a', + incomingPromptInteractionKey: 'command-code-transcript-b' + }) + ).toBe(true) + }) + + it('returns false for same-turn tool pings that keep the same prompt', () => { + expect( + isCommandCodeNewTurnWhileWorking({ + agentType: 'command-code', + previousState: 'working', + incomingState: 'working', + previousPrompt: 'run pwd', + incomingPrompt: 'run pwd', + hasExplicitPrompt: true + }) + ).toBe(false) + }) + + it('returns false when main-process hooks explicitly deny prompt evidence', () => { + expect( + isCommandCodeNewTurnWhileWorking({ + agentType: 'command-code', + previousState: 'working', + incomingState: 'working', + previousPrompt: 'first', + incomingPrompt: 'second', + hasExplicitPrompt: false + }) + ).toBe(false) + }) + + it('returns false for non-Command Code agents', () => { + expect( + isCommandCodeNewTurnWhileWorking({ + agentType: 'codex', + previousState: 'working', + incomingState: 'working', + previousPrompt: 'first', + incomingPrompt: 'second', + hasExplicitPrompt: true + }) + ).toBe(false) + }) +}) diff --git a/src/shared/command-code-turn-boundary.ts b/src/shared/command-code-turn-boundary.ts new file mode 100644 index 00000000000..6120f55a436 --- /dev/null +++ b/src/shared/command-code-turn-boundary.ts @@ -0,0 +1,56 @@ +import type { AgentType } from './agent-status-types' + +type CommandCodeTurnBoundaryInput = { + agentType: AgentType | undefined + previousState?: string + incomingState: string + previousPrompt?: string + incomingPrompt: string + hasExplicitPrompt?: boolean + previousPromptInteractionKey?: string + incomingPromptInteractionKey?: string +} + +/** Command Code has no UserPromptSubmit hook; a new transcript prompt is the turn boundary. */ +export function isCommandCodeNewTurnWhileWorking({ + agentType, + previousState, + incomingState, + previousPrompt, + incomingPrompt, + hasExplicitPrompt, + previousPromptInteractionKey, + incomingPromptInteractionKey +}: CommandCodeTurnBoundaryInput): boolean { + if (agentType !== 'command-code') { + return false + } + if (previousState !== 'working' || incomingState !== 'working') { + return false + } + + const nextPrompt = incomingPrompt.trim() + if (nextPrompt.length === 0) { + return false + } + + if ( + incomingPromptInteractionKey !== undefined && + previousPromptInteractionKey !== undefined && + incomingPromptInteractionKey !== previousPromptInteractionKey + ) { + return true + } + + if (nextPrompt === (previousPrompt ?? '').trim()) { + return false + } + + // Main-process hooks can require explicit prompt evidence; renderer IPC does + // not carry the flag yet, so a prompt text change is enough there. + if (hasExplicitPrompt === false) { + return false + } + + return true +} diff --git a/src/shared/commit-message-agent-output.ts b/src/shared/commit-message-agent-output.ts index 5c5be10e25c..cbda4a42bde 100644 --- a/src/shared/commit-message-agent-output.ts +++ b/src/shared/commit-message-agent-output.ts @@ -88,182 +88,110 @@ function isCommitFenceInfoCharacter(code: number): boolean { ) } -function stripAnsiControlSequences(value: string): string { - return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g'), '') -} - -const AGENT_ERROR_CODE_SCAN_LIMIT = 8192 -const ERROR_CODE_MARKER = 'error code:' - -// Why: agent CLIs (Codex, Claude) prefix their stdout/stderr with config -// preamble, the echoed prompt, and hook lifecycle messages. When something -// fails, the actionable error is buried far below all of that. This pulls -// out the real message so the user sees something legible instead of a -// dump of the agent's runtime state. -export function extractAgentErrorMessage(stdout: string, stderr: string): string | null { - const combined = `${stdout}\n${stderr}` - // Pass 1: look for an `ERROR:`/`Error:` line carrying a JSON payload. - // Walk from the end so the most recent (and usually most meaningful) - // error wins when an agent prints multiple. - const errorPayload = findLastAgentErrorPayload(combined) - if (errorPayload !== null) { - const payload = errorPayload.trim() - if (payload.startsWith('{')) { - try { - const parsed = JSON.parse(payload) as { - message?: string - error?: { message?: string } - } - const inner = parsed.error?.message ?? parsed.message - if (typeof inner === 'string' && inner.trim().length > 0) { - return inner.trim() - } - } catch { - // Fall through to using the raw payload below. - } - } - if (payload.length > 0) { - return payload - } - } - - const compact = extractCompactedErrorCodeTail(combined) - const errorCodeMatch = compact ? /\bError code:\s*\d+\s*-\s*(.+)$/i.exec(compact) : null - if (errorCodeMatch) { - const payload = errorCodeMatch[1].trim() - const messageMatch = /['"]message['"]\s*:\s*['"]([^'"]+)['"]/i.exec(payload) - if (messageMatch?.[1]?.trim()) { - return messageMatch[1].trim() - } - if (payload.length > 0) { - return payload - } - } - - return null -} - -function extractCompactedErrorCodeTail(combined: string): string | null { - const markerStart = lastIndexOfAsciiIgnoreCase(combined, ERROR_CODE_MARKER) - if (markerStart === -1) { - return null - } - - return compactAgentErrorTail( - combined.slice(markerStart, markerStart + AGENT_ERROR_CODE_SCAN_LIMIT) +export function stripAnsiControlSequences(value: string): string { + const esc = String.fromCharCode(27) + const bel = String.fromCharCode(7) + // CSI (colors/cursor) and OSC (titles/hyperlinks) both appear in raw CLI + // failure output once it is shown verbatim instead of parsed. + return value.replace( + new RegExp( + `${esc}(?:\\[[0-?]*[ -/]*[@-~]|\\][^${bel}${esc}\\r\\n]*(?:${bel}|${esc}\\\\))`, + 'g' + ), + '' ) } -function lastIndexOfAsciiIgnoreCase(value: string, needle: string): number { - if (needle.length === 0 || value.length < needle.length) { - return -1 +function stripAnsiIfPresent(value: string): string { + return value.includes(String.fromCharCode(27)) ? stripAnsiControlSequences(value) : value +} + +// Only the two ends of the output are read, like glancing at the first and +// last lines of a long log. +const FAILURE_EXCERPT_SCAN_WINDOW = 8192 +const FAILURE_EXCERPT_HEAD_LINE_COUNT = 2 +// Why: when both ends are shown, the tail gets the larger budget because most +// CLIs print the operative error last; the head budget covers CLIs that +// front-load it. A lone excerpt keeps the whole toast/persistence budget. +const FAILURE_EXCERPT_HEAD_BUDGET = 100 +const FAILURE_EXCERPT_TAIL_BUDGET = 130 +const FAILURE_EXCERPT_SINGLE_BUDGET = 240 + +// Why: agent CLIs share no error format, and per-CLI parsing rots every time a +// vendor rewords a message. Orca deliberately does NOT interpret failure +// output — it excerpts it positionally (first lines plus last line) so every +// CLI's real failure text reaches the user. Callers must still sanitize the +// excerpt before display or persistence. +export function excerptAgentFailureOutput(stdout: string, stderr: string): string | null { + // stderr is where CLIs put diagnostics; stdout is the fallback for the ones + // that report failures inline (and often echoes the prompt, so it never + // overrides a non-blank stderr). + const source = /\S/.test(stderr) ? stderr : stdout + if (!/\S/.test(source)) { + return null } - for (let index = value.length - needle.length; index >= 0; index -= 1) { - if (equalsAsciiIgnoreCaseAt(value, needle, index)) { - return index + + if (source.length <= FAILURE_EXCERPT_SCAN_WINDOW) { + const lines = collectExcerptLines(source, Number.POSITIVE_INFINITY) + if (lines.length === 0) { + return null } - } - return -1 -} - -function equalsAsciiIgnoreCaseAt(value: string, needle: string, index: number): boolean { - for (let offset = 0; offset < needle.length; offset += 1) { - if ( - toAsciiLowerCode(value.charCodeAt(index + offset)) !== - toAsciiLowerCode(needle.charCodeAt(offset)) - ) { - return false + if (lines.length <= FAILURE_EXCERPT_HEAD_LINE_COUNT + 1) { + return truncateExcerptPart(lines.join(' '), FAILURE_EXCERPT_SINGLE_BUDGET) } - } - return true -} - -function compactAgentErrorTail(value: string): string { - let compact = '' - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (code === 13 || code === 10) { - if (code === 13 && value.charCodeAt(index + 1) === 10) { - index += 1 - } - const next = findNextNonWhitespaceCode(value, index + 1) - if (shouldDropWrappedLineBreak(compact.charCodeAt(compact.length - 1), next)) { - index = next.index - 1 - continue - } - if (compact.length > 0 && compact.charCodeAt(compact.length - 1) !== 32) { - compact += ' ' - } - index = next.index - 1 - continue - } - if (isAsciiWhitespace(code)) { - if (compact.length > 0 && compact.charCodeAt(compact.length - 1) !== 32) { - compact += ' ' - } - continue - } - compact += value[index] - } - return compact.trim() -} - -function findNextNonWhitespaceCode(value: string, start: number): { code: number; index: number } { - for (let index = start; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (!isAsciiWhitespace(code)) { - return { code, index } - } - } - return { code: Number.NaN, index: value.length } -} - -function shouldDropWrappedLineBreak(previous: number, next: { code: number }): boolean { - return isAsciiLetter(previous) && (isAsciiLetter(next.code) || next.code === 95) -} - -function isAsciiWhitespace(code: number): boolean { - return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32 -} - -function isAsciiLetter(code: number): boolean { - const lower = toAsciiLowerCode(code) - return lower >= 97 && lower <= 122 -} - -function toAsciiLowerCode(code: number): number { - return code >= 65 && code <= 90 ? code + 32 : code -} - -function findLastAgentErrorPayload(combined: string): string | null { - let lineEnd = combined.length - - for (let index = combined.length - 1; index >= -1; index--) { - if (index >= 0) { - const code = combined.charCodeAt(index) - if (code !== 10 && code !== 13) { - continue - } - if (code === 10 && index > 0 && combined.charCodeAt(index - 1) === 13) { - continue - } - } - - // Why: agent logs can include paste-sized echoed prompts. Error lines are - // short diagnostics, so bound per-line ANSI stripping before matching. - const rawLine = combined.slice( - index + 1, - Math.min(lineEnd, index + 1 + AGENT_ERROR_CODE_SCAN_LIMIT) + return composeTwoEndExcerpt( + lines.slice(0, FAILURE_EXCERPT_HEAD_LINE_COUNT), + lines.at(-1) ?? null ) - const line = rawLine.includes(String.fromCharCode(27)) - ? stripAnsiControlSequences(rawLine) - : rawLine - const match = /^\s*(?:ERROR|Error(?:\s+during\s+[^:]+)?)\s*:\s*(.+)$/i.exec(line) - if (match?.[1]) { - return match[1] - } - lineEnd = index > 0 && combined.charCodeAt(index - 1) === 13 ? index - 1 : index } - return null + const headLines = collectExcerptLines( + source.slice(0, FAILURE_EXCERPT_SCAN_WINDOW), + FAILURE_EXCERPT_HEAD_LINE_COUNT + ) + const tailLine = + collectExcerptLinesFromEnd(source.slice(source.length - FAILURE_EXCERPT_SCAN_WINDOW), 1)[0] ?? + null + if (headLines.length === 0) { + return tailLine ? truncateExcerptPart(tailLine, FAILURE_EXCERPT_SINGLE_BUDGET) : null + } + return composeTwoEndExcerpt(headLines, tailLine) +} + +function composeTwoEndExcerpt(headLines: string[], tailLine: string | null): string { + const headPart = truncateExcerptPart(headLines.join(' '), FAILURE_EXCERPT_HEAD_BUDGET) + // Repeated lines (spinner/retry frames) would otherwise show twice. + if (tailLine === null || headLines.includes(tailLine)) { + return headPart + } + return `${headPart} … ${truncateExcerptPart(tailLine, FAILURE_EXCERPT_TAIL_BUDGET)}` +} + +function truncateExcerptPart(value: string, budget: number): string { + return value.length > budget ? `${value.slice(0, budget).trimEnd()}…` : value +} + +function collectExcerptLines(text: string, max: number): string[] { + // Bare `\r` is a boundary too: progress bars redraw with carriage returns. + const lines = text.split(/\r\n|\r|\n/) + const collected: string[] = [] + for (let index = 0; index < lines.length && collected.length < max; index += 1) { + const line = stripAnsiIfPresent(lines[index]).trim() + if (line.length > 0) { + collected.push(line) + } + } + return collected +} + +function collectExcerptLinesFromEnd(text: string, max: number): string[] { + const lines = text.split(/\r\n|\r|\n/) + const collected: string[] = [] + for (let index = lines.length - 1; index >= 0 && collected.length < max; index -= 1) { + const line = stripAnsiIfPresent(lines[index]).trim() + if (line.length > 0) { + collected.push(line) + } + } + return collected } diff --git a/src/shared/commit-message-prompt.test.ts b/src/shared/commit-message-prompt.test.ts index 08d4648359a..407b604f76d 100644 --- a/src/shared/commit-message-prompt.test.ts +++ b/src/shared/commit-message-prompt.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { buildCommitPrompt, cleanGeneratedCommitMessage, - extractAgentErrorMessage, + excerptAgentFailureOutput, planCustomCommand, STAGED_DIFF_BYTE_BUDGET, tokenizeCustomCommandTemplate, @@ -136,108 +136,114 @@ describe('cleanGeneratedCommitMessage', () => { }) }) -describe('extractAgentErrorMessage', () => { - it('returns the inner message from a Codex JSON error payload', () => { - const stderr = [ - '--------', - 'workdir: C:\\Storage\\Projects\\bagplanner', - 'model: gpt-5.3-codex-spark', - 'reasoning effort: medium', - '--------', - 'user', - 'You are generating a single git commit message...', - 'hook: SessionStart', - 'hook: SessionStart Completed', - 'ERROR: {"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The \'gpt-5.3-codex-spark\' model is not supported when using Codex with a ChatGPT account."}}' - ].join('\n') - expect(extractAgentErrorMessage('', stderr)).toBe( - "The 'gpt-5.3-codex-spark' model is not supported when using Codex with a ChatGPT account." +describe('excerptAgentFailureOutput', () => { + // Real Codex failure shape: config preamble first, operative ERROR line last. + const codexErrorLine = + 'ERROR: {"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The \'gpt-5.3-codex-spark\' model is not supported when using Codex with a ChatGPT account."}}' + const codexStderr = [ + '--------', + 'workdir: C:\\Storage\\Projects\\bagplanner', + 'model: gpt-5.3-codex-spark', + 'reasoning effort: medium', + '--------', + 'user', + 'You are generating a single git commit message...', + 'hook: SessionStart', + 'hook: SessionStart Completed', + codexErrorLine + ].join('\n') + + it('excerpts both ends so a tail-anchored Codex error stays visible', () => { + expect(excerptAgentFailureOutput('', codexStderr)).toBe( + `-------- workdir: C:\\Storage\\Projects\\bagplanner … ${codexErrorLine.slice(0, 130).trimEnd()}…` ) }) - it('returns the payload for non-JSON error lines', () => { - const out = 'preamble line\nERROR: {bad json oops' - expect(extractAgentErrorMessage(out, '')).toBe('{bad json oops') - }) + // Real pi 0.80.6 auth failure: primary line and remedy first, doc paths last. + const piAuthStderr = [ + 'No API key found for github-copilot.', + '', + 'Use /login to log into a provider via OAuth or API key. See:', + ' /private/tmp/pi-exit1-repro/node_modules/@earendil-works/pi-coding-agent/docs/providers.md', + ' /private/tmp/pi-exit1-repro/node_modules/@earendil-works/pi-coding-agent/docs/models.md' + ].join('\n') - it('uses the last ERROR line when several are emitted', () => { - const out = ['ERROR: first failure', 'retry message', 'ERROR: second failure'].join('\n') - expect(extractAgentErrorMessage(out, '')).toBe('second failure') - }) - - it('uses the last ERROR line in large CRLF output without line-array splitting', () => { - const splitSpy = vi.spyOn(String.prototype, 'split') - const out = `${'preamble\r\n'.repeat(10_000)}ERROR: first failure\r\nretry\r\nERROR: second failure\r\n` - - expect(extractAgentErrorMessage(out, '')).toBe('second failure') - - const usedLineSplit = splitSpy.mock.calls.some( - ([separator]) => separator instanceof RegExp && separator.source === '\\r?\\n' + it('keeps a head-anchored pi auth failure visible', () => { + expect(excerptAgentFailureOutput('', piAuthStderr)).toBe( + 'No API key found for github-copilot. Use /login to log into a provider via OAuth or API key. See: … /private/tmp/pi-exit1-repro/node_modules/@earendil-works/pi-coding-agent/docs/models.md' ) - expect(usedLineSplit).toBe(false) }) - it('matches an `Error:` line emitted on stdout', () => { - expect(extractAgentErrorMessage('Error: model unavailable\n', '')).toBe('model unavailable') - }) - - it('matches ANSI-colored `Error:` lines emitted by CLIs', () => { + it('prefers stderr and never excerpts an echoed prompt from stdout', () => { expect( - extractAgentErrorMessage('', '\u001b[91m\u001b[1mError: \u001b[0mNo payment method\n') - ).toBe('No payment method') - }) - - it('matches tool-specific `Error during ...:` lines', () => { - expect( - extractAgentErrorMessage( - '', - 'Error during droid execution: Authentication failed. Please log into Factory.\n' + excerptAgentFailureOutput( + 'You are generating a single git commit message for /secret/repo', + 'No API key found for openai.' ) - ).toBe('Authentication failed. Please log into Factory.') + ).toBe('No API key found for openai.') }) - it('matches wrapped provider error-code payloads with quoted message fields', () => { - const stdout = [ - "Error code: 401 - {'error': {'message': 'The API Key appears to be invalid or ma", - "y have expired. Please verify your credentials and try again.', 'type': 'invalid", - "_authentication_error'}}" - ].join('\n') - expect(extractAgentErrorMessage(stdout, '')).toBe( - 'The API Key appears to be invalid or may have expired. Please verify your credentials and try again.' + it('falls back to stdout when stderr is blank', () => { + expect(excerptAgentFailureOutput('Not logged in · Please run /login', ' \n')).toBe( + 'Not logged in · Please run /login' ) }) - it('extracts wrapped provider error-code payloads from large output without replacement passes', () => { - const replaceSpy = vi.spyOn(String.prototype, 'replace') - const echoedPrompt = 'echoed pasted prompt '.repeat(10_000) - const stdout = [ - `${echoedPrompt}Error code: 401 - {'error': {'message': 'The API Key appears to be invalid or ma`, - "y have expired. Please verify your credentials and try again.', 'type': 'invalid", - "_authentication_error'}}" - ].join('\r\n') + it('returns null when both streams are blank', () => { + expect(excerptAgentFailureOutput(' \n\t', '')).toBeNull() + }) - expect(extractAgentErrorMessage(stdout, '')).toBe( - 'The API Key appears to be invalid or may have expired. Please verify your credentials and try again.' + it('joins up to three lines without an ellipsis', () => { + expect(excerptAgentFailureOutput('', 'one\ntwo\nthree\n')).toBe('one two three') + }) + + it('does not parse or unwrap JSON payloads', () => { + expect(excerptAgentFailureOutput('', '401: {"message":"Invalid API key provided"}')).toBe( + '401: {"message":"Invalid API key provided"}' ) - expect(replaceSpy).not.toHaveBeenCalled() }) - it('does not strip ANSI across a paste-sized single-line log before error-code fallback', () => { - const replaceSpy = vi.spyOn(String.prototype, 'replace') - const pastedLog = 'x'.repeat(20_000) - const stdout = `${pastedLog}Error code: 400 - {'message': 'bad request'}` - - expect(extractAgentErrorMessage(stdout, '')).toBe('bad request') - expect(replaceSpy).not.toHaveBeenCalled() + it('strips ANSI colors and OSC titles', () => { + const esc = String.fromCharCode(27) + const bel = String.fromCharCode(7) + expect( + excerptAgentFailureOutput( + '', + `${esc}]0;pi${bel}${esc}[91mError: no payment method${esc}[0m\n` + ) + ).toBe('Error: no payment method') }) - it('returns null when no ERROR line is present', () => { - expect(extractAgentErrorMessage('plain log\nmore log\n', '')).toBeNull() + it('treats bare `\\r` progress frames as line boundaries', () => { + expect(excerptAgentFailureOutput('', 'Fetching 50%\rFetching 100%\rConnection error.')).toBe( + 'Fetching 50% Fetching 100% Connection error.' + ) }) - it('returns the JSON payload `message` field when no nested error is set', () => { - const out = 'ERROR: {"message":"top-level only"}' - expect(extractAgentErrorMessage(out, '')).toBe('top-level only') + it('handles CRLF output', () => { + expect(excerptAgentFailureOutput('', 'one\r\ntwo\r\n')).toBe('one two') + }) + + it('collapses repeated retry lines instead of echoing them twice', () => { + expect(excerptAgentFailureOutput('', 'Retrying request…\n'.repeat(10))).toBe( + 'Retrying request… Retrying request…' + ) + }) + + it('truncates an overlong single line to the persistence budget', () => { + const line = `Error: ${'m'.repeat(300)}` + expect(excerptAgentFailureOutput('', line)).toBe(`Error: ${'m'.repeat(233)}…`) + }) + + it('reads the head and tail windows of oversized multi-line output', () => { + const stderr = `first line\n${'filler line\n'.repeat(3000)}last: operative error` + expect(excerptAgentFailureOutput('', stderr)).toBe( + 'first line filler line … last: operative error' + ) + }) + + it('bounds the excerpt for a giant single-line stream', () => { + expect(excerptAgentFailureOutput('', 'x'.repeat(20_000))).toBe(`${'x'.repeat(100)}…`) }) }) diff --git a/src/shared/commit-message-prompt.ts b/src/shared/commit-message-prompt.ts index 183b161128a..4a034d2d53f 100644 --- a/src/shared/commit-message-prompt.ts +++ b/src/shared/commit-message-prompt.ts @@ -19,7 +19,7 @@ Staged diff: export { cleanGeneratedCommitMessage, - extractAgentErrorMessage + excerptAgentFailureOutput } from './commit-message-agent-output' /** Builds the final prompt sent to the agent. The custom suffix is appended verbatim diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index e185c4d073f..343e0ffe173 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -52,6 +52,10 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true) }) + it('keeps file-editor word wrapping enabled by default', () => { + expect(getDefaultSettings('/tmp').editorWordWrap).toBe(true) + }) + it('keeps rich Markdown spellcheck enabled by default', () => { expect(getDefaultSettings('/tmp').richMarkdownSpellcheckEnabled).toBe(true) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index bab0da51a05..4fc945111d0 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -208,6 +208,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { editorAutoSave: false, editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, editorMinimapEnabled: false, + editorWordWrap: true, richMarkdownSpellcheckEnabled: true, markdownReviewToolsEnabled: true, primarySelectionMiddleClickPaste: getDefaultPrimarySelectionMiddleClickPaste(), @@ -306,6 +307,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { diffDefaultView: 'inline', diffWordWrap: false, combinedDiffFileTreeVisibleByDefault: false, + prBotAuthorOverrides: [], promptCacheTimerEnabled: false, promptCacheTtlMs: 300_000, codexManagedAccounts: [], diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 41b3fe75901..7437d19dd82 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -140,6 +140,24 @@ describe('keybindings', () => { }) }) + it('binds F7 / Shift+F7 for diff-change navigation and matches their events', () => { + // Opt-in actions accept bare / Shift-only function keys... + expect(normalizeKeybindingListForAction('editor.nextChange', 'F7')).toEqual(['F7']) + expect(normalizeKeybindingListForAction('editor.previousChange', 'Shift+F7')).toEqual([ + 'Shift+F7' + ]) + // ...but they stay unsafe for actions that do not opt in. + expect(normalizeKeybinding('F7')).toMatchObject({ ok: false }) + expect(normalizeKeybinding('Shift+F7')).toMatchObject({ ok: false }) + + const f7 = { key: 'F7', code: 'F7', control: false, meta: false, alt: false, shift: false } + const shiftF7 = { ...f7, shift: true } + expect(keybindingMatchesAction('editor.nextChange', f7, 'darwin')).toBe(true) + expect(keybindingMatchesAction('editor.nextChange', shiftF7, 'darwin')).toBe(false) + expect(keybindingMatchesAction('editor.previousChange', shiftF7, 'darwin')).toBe(true) + expect(keybindingMatchesAction('editor.previousChange', f7, 'darwin')).toBe(false) + }) + it('formats keybindings with platform labels', () => { expect(formatKeybindingList(['Mod+Shift+J'], 'darwin')).toBe('⌘⇧J') expect(formatKeybindingList(['Mod+Shift+J'], 'linux')).toBe('Ctrl+Shift+J') diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index a7750fafbbe..daa8721a973 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -90,6 +90,8 @@ export type KeybindingActionId = | 'editor.save' | 'editor.markdownPreview' | 'editor.copyContext' + | 'editor.previousChange' + | 'editor.nextChange' | 'fileExplorer.undo' | 'fileExplorer.redo' | 'fileExplorer.copyPath' @@ -826,6 +828,26 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ searchKeywords: ['shortcut', 'editor', 'copy', 'context'], defaultBindings: platformBindings(['Mod+Alt+C']) }, + // Why: F7 / Shift+F7 gives VS Code / JetBrains diff-change navigation. Function + // keys are safe as bare / Shift bindings, so both opt into allowBareKeybindings. + { + id: 'editor.previousChange', + title: 'Go to Previous Change', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'previous'], + defaultBindings: platformBindings(['Shift+F7']), + allowBareKeybindings: true + }, + { + id: 'editor.nextChange', + title: 'Go to Next Change', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'next'], + defaultBindings: platformBindings(['F7']), + allowBareKeybindings: true + }, { id: 'fileExplorer.undo', title: 'Undo file operation', @@ -1089,6 +1111,10 @@ function hasModifier( return Boolean(input.shift ?? input.shiftKey) } +function isFunctionKeyToken(key: string): boolean { + return /^F([1-9]|1[0-9]|2[0-4])$/.test(key) +} + function normalizeKeyToken(token: string): string | null { if (token === ' ') { return 'Space' @@ -1104,6 +1130,10 @@ function normalizeKeyToken(token: string): string | null { if (upper.length === 1 && upper >= '0' && upper <= '9') { return upper } + // Function keys F1–F24 (event.key/event.code report them verbatim, e.g. F7). + if (isFunctionKeyToken(upper)) { + return upper + } const simple: Record = { '[': 'BracketLeft', @@ -1303,22 +1333,31 @@ function canonicalizeParsedKeybinding(parsed: ParsedKeybinding): string { } function isSafeBareKey(parsed: ParsedKeybinding): boolean { - if (parsed.shift || parsed.mod || parsed.meta || parsed.control || parsed.alt) { + if (parsed.mod || parsed.meta || parsed.control || parsed.alt) { return false } - return [ - 'Backspace', - 'Delete', - 'Enter', - 'Escape', - 'Tab', - 'ArrowLeft', - 'ArrowRight', - 'ArrowUp', - 'ArrowDown', - 'PageUp', - 'PageDown' - ].includes(parsed.key) + // Function keys produce no text, so they're safe standalone or with Shift + // (e.g. F7 / Shift+F7 for diff-change navigation). Shift is only permitted + // alongside a function key — Shift+letter stays unsafe. + if (parsed.shift) { + return isFunctionKeyToken(parsed.key) + } + return ( + isFunctionKeyToken(parsed.key) || + [ + 'Backspace', + 'Delete', + 'Enter', + 'Escape', + 'Tab', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'PageUp', + 'PageDown' + ].includes(parsed.key) + ) } function normalizeKeybindingWithOptions( diff --git a/src/shared/pr-bot-author-overrides.test.ts b/src/shared/pr-bot-author-overrides.test.ts new file mode 100644 index 00000000000..46476f0c5d1 --- /dev/null +++ b/src/shared/pr-bot-author-overrides.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_PR_BOT_AUTHOR_OVERRIDES, + MAX_PR_COMMENT_AUTHOR_LOGIN_LENGTH, + applyPRBotAuthorOverride, + normalizePRBotAuthorOverrides +} from './pr-bot-author-overrides' + +describe('PR bot author override normalization', () => { + it('bounds inspected entries before normalizing untrusted arrays', () => { + let reads = 0 + const values = Array.from( + { length: MAX_PR_BOT_AUTHOR_OVERRIDES + 20 }, + (_, index) => `bot-${index}` + ) + const tracked = new Proxy(values, { + get(target, property, receiver) { + if (typeof property === 'string' && /^\d+$/.test(property)) { + reads += 1 + } + return Reflect.get(target, property, receiver) + } + }) + + expect(normalizePRBotAuthorOverrides(tracked)).toHaveLength(MAX_PR_BOT_AUTHOR_OVERRIDES) + expect(reads).toBe(MAX_PR_BOT_AUTHOR_OVERRIDES) + }) + + it('drops overlong author logins instead of retaining oversized settings data', () => { + const overlong = 'x'.repeat(MAX_PR_COMMENT_AUTHOR_LOGIN_LENGTH + 1) + + expect(normalizePRBotAuthorOverrides(['regular-bot', overlong])).toEqual(['regular-bot']) + }) + + it('adds and removes against the authoritative list without evicting at the cap', () => { + expect(applyPRBotAuthorOverride(['alice'], 'bob', true)).toEqual(['alice', 'bob']) + expect(applyPRBotAuthorOverride(['alice', 'bob'], 'alice', false)).toEqual(['bob']) + + const full = Array.from({ length: MAX_PR_BOT_AUTHOR_OVERRIDES }, (_, index) => `bot-${index}`) + expect(applyPRBotAuthorOverride(full, 'new-bot', true)).toEqual([...full].sort()) + }) +}) diff --git a/src/shared/pr-bot-author-overrides.ts b/src/shared/pr-bot-author-overrides.ts new file mode 100644 index 00000000000..e96e0574004 --- /dev/null +++ b/src/shared/pr-bot-author-overrides.ts @@ -0,0 +1,71 @@ +// Helpers for the `prBotAuthorOverrides` setting — comment author logins the +// user manually marked as bots. Shared so desktop renderer, main-process RPC, +// and mobile classify comment authors identically. + +export const MAX_PR_BOT_AUTHOR_OVERRIDES = 500 +// GitLab permits longer usernames than GitHub; 255 keeps both providers covered. +export const MAX_PR_COMMENT_AUTHOR_LOGIN_LENGTH = 255 + +/** Normalized author login used to match manual bot overrides. */ +export function normalizePRCommentAuthorLogin(author: string): string { + if (author.length > MAX_PR_COMMENT_AUTHOR_LOGIN_LENGTH) { + return '' + } + return author.trim().toLowerCase() +} + +/** Builds a lookup set from the persisted `prBotAuthorOverrides` setting. */ +export function createBotAuthorOverrideSet( + logins: Iterable | null | undefined +): ReadonlySet { + const set = new Set() + const iterator = (logins ?? [])[Symbol.iterator]() + let inspected = 0 + // Why: callers may pass untrusted settings; bound reads as well as retained output. + while (inspected < MAX_PR_BOT_AUTHOR_OVERRIDES) { + const next = iterator.next() + if (next.done) { + break + } + inspected += 1 + const login = next.value + if (typeof login !== 'string') { + continue + } + const normalized = normalizePRCommentAuthorLogin(login) + if (normalized) { + set.add(normalized) + } + } + return set +} + +/** Sanitizes an untrusted settings update value into a sorted, deduped login list. */ +export function normalizePRBotAuthorOverrides(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + return [...createBotAuthorOverrideSet(value)].sort() +} + +/** Atomically derives the next persisted list from the authoritative setting. */ +export function applyPRBotAuthorOverride( + current: Iterable | null | undefined, + author: string, + isBot: boolean +): string[] { + const overrides = new Set(createBotAuthorOverrideSet(current)) + const normalized = normalizePRCommentAuthorLogin(author) + if (!normalized || overrides.has(normalized) === isBot) { + return [...overrides].sort() + } + if (isBot) { + if (overrides.size >= MAX_PR_BOT_AUTHOR_OVERRIDES) { + return [...overrides].sort() + } + overrides.add(normalized) + } else { + overrides.delete(normalized) + } + return [...overrides].sort() +} diff --git a/src/shared/terminal-title-agent-type.test.ts b/src/shared/terminal-title-agent-type.test.ts index 6c9918394fa..d28dd43f503 100644 --- a/src/shared/terminal-title-agent-type.test.ts +++ b/src/shared/terminal-title-agent-type.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { + isClaudeAgent, isGrokRotatingWorkingTitle, - resolveExplicitTerminalTitleAgentType + resolveExplicitTerminalTitleAgentType, + resolveTerminalTitleAgentType } from './terminal-title-agent-type' describe('isGrokRotatingWorkingTitle', () => { @@ -57,4 +59,44 @@ describe('resolveExplicitTerminalTitleAgentType', () => { expect(resolveExplicitTerminalTitleAgentType('Terminal 1')).toBeNull() expect(resolveExplicitTerminalTitleAgentType('zsh')).toBeNull() }) + + // Why: `cursor` is ordinary editor vocabulary, so a name token is not identity. + // A Claude/Codex tab working on cursor code must not commit to Cursor identity. + it('resolves Cursor by its identity titles, never a bare cursor token', () => { + expect(resolveExplicitTerminalTitleAgentType('Cursor Agent')).toBe('cursor') + expect(resolveExplicitTerminalTitleAgentType('⠋ Cursor Agent')).toBe('cursor') + expect(resolveExplicitTerminalTitleAgentType('Cursor ready')).toBe('cursor') + expect(resolveExplicitTerminalTitleAgentType('Cursor - action required')).toBe('cursor') + // A Claude tab whose task text mentions a text cursor is not Cursor identity. + expect( + resolveExplicitTerminalTitleAgentType('⠋ preserve cursor visibility across replays') + ).toBeNull() + expect(resolveExplicitTerminalTitleAgentType('~/cursor-rules')).toBeNull() + }) +}) + +describe('resolveTerminalTitleAgentType', () => { + // Why: the activity facet keeps Claude's braille prefix as Claude — but only when + // the "cursor" it mentions is task text, not Cursor's own identity title. + it('labels cursor-mentioning agent tabs by their true agent, real Cursor as cursor', () => { + expect(resolveTerminalTitleAgentType('⠋ Cursor Agent')).toBe('cursor') + expect(resolveTerminalTitleAgentType('Cursor Agent')).toBe('cursor') + expect(resolveTerminalTitleAgentType('Cursor ready')).toBe('cursor') + expect(resolveTerminalTitleAgentType('Cursor - action required')).toBe('cursor') + expect(resolveTerminalTitleAgentType('⠋ preserve cursor visibility across replays')).toBe( + 'claude' + ) + expect(resolveTerminalTitleAgentType('⠋ Codex: fix cursor offsets')).toBe('codex') + }) +}) + +// Why: this module carries its own isClaudeAgent copy parallel to agent-title-identity.ts; +// both got the identical isCursorAgentTitle guard, so pin this copy directly to catch drift. +describe('isClaudeAgent', () => { + it('excludes real Cursor identity titles, keeps cursor-mentioning Claude braille titles', () => { + expect(isClaudeAgent('⠋ Cursor Agent')).toBe(false) + expect(isClaudeAgent('Cursor ready')).toBe(false) + expect(isClaudeAgent('⠋ preserve cursor visibility across replays')).toBe(true) + expect(isClaudeAgent('⠋ OpenClaude')).toBe(false) + }) }) diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index e40098ee46e..ae7c97c09e7 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -4,6 +4,7 @@ import { HERMES_AGENT_NAME_RE, titleHasAgentName } from './agent-name-token-match' +import { isCursorAgentTitle } from './agent-title-core' import { getPiCompatibleSyntheticAgentLabel, isLegacyPiCompatibleTitle @@ -97,9 +98,9 @@ export function isClaudeAgent(title: string): boolean { return true } if (containsBrailleSpinner(title)) { - // Why: named non-Claude agents can carry braille spinners too; Claude-only - // prompt-cache paths must not fire for those explicit agent titles. - return !lower.includes('cursor') && !lower.includes('openclaude') + // Why: named non-Claude agents carry braille spinners too. Gate Cursor by its + // identity title, not the token, so a Claude title mentioning a cursor stays Claude. + return !isCursorAgentTitle(title) && !lower.includes('openclaude') } // Why: permission/action-required Claude titles can omit the usual prefixes. // Token-match so cwd/worktree titles like "claude-scratch" do not become @@ -179,14 +180,9 @@ export function getAgentLabel(title: string): string | null { if (titleHasAgentName(title, 'aider')) { return 'Aider' } - // Why: the cursor-agent native title is the literal string "Cursor Agent" - // (verified against the 2026.04.17 release) — Orca synthesizes the same - // label from hook events so the braille-spinner + agent-name path lights - // up working/permission/idle transitions in the renderer. Match before - // `isClaudeAgent` because Claude's generic braille heuristic would - // otherwise claim every "⠋ Cursor Agent" frame as Claude. Token-match so a - // cwd like "~/cursor-rules" can't masquerade as a Cursor agent. - if (titleHasAgentName(title, 'cursor')) { + // Why: `cursor` is ordinary editor vocabulary, not identity. Match Cursor's closed + // title set (mirrors @cursor routing), before `isClaudeAgent` claims the braille frame. + if (isCursorAgentTitle(title)) { return 'Cursor' } // Why: synthesized "⠋ Droid" working title needs to be matched before Claude's braille heuristic. diff --git a/src/shared/types.ts b/src/shared/types.ts index 069cbd22f9d..0260ae1f4b6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -434,6 +434,17 @@ export type GitWorktreeInfo = { isMainWorktree: boolean } +/** Head/branch snapshot read from Git metadata files without spawning Git. + * Carries background-worktree freshness when status-only churn includes a + * real head move (external commit/amend/reset) that must not re-enter the + * structural `worktrees:changed` fanout. */ +export type WorktreeHeadIdentity = { + worktreePath: string + head: string + /** Full ref (e.g. `refs/heads/main`), or null for a detached HEAD. */ + branch: string | null +} + // ─── Worktree (app-level, enriched) ────────────────────────────────── export type WorkspaceStatus = string @@ -2513,6 +2524,8 @@ export type GlobalSettings = { editorAutoSave: boolean editorAutoSaveDelayMs: number editorMinimapEnabled: boolean + /** Defaults on for profiles saved before file-editor wrapping became configurable. */ + editorWordWrap?: boolean /** Persisted opt-out for browser spellcheck noise in rich Markdown editing surfaces. */ richMarkdownSpellcheckEnabled?: boolean /** Whether local markdown review note controls and the review panel are shown. */ @@ -2712,6 +2725,11 @@ export type GlobalSettings = { diffDefaultView: 'inline' | 'side-by-side' diffWordWrap: boolean combinedDiffFileTreeVisibleByDefault: boolean + /** Comment author logins the user manually marked as bots (stored lowercased). + * Why: some review bots use regular user accounts that defeat both provider + * metadata and login heuristics, so the Humans/Bots comment filter needs a + * user-supplied escape hatch. */ + prBotAuthorOverrides: string[] notifications: NotificationSettings /** When true, a countdown timer is shown after a Claude agent becomes idle, * indicating time remaining before the prompt cache expires. Disabled by default. */ diff --git a/src/shared/windows-mobile-firewall.ts b/src/shared/windows-mobile-firewall.ts new file mode 100644 index 00000000000..f6570031723 --- /dev/null +++ b/src/shared/windows-mobile-firewall.ts @@ -0,0 +1,16 @@ +export type WindowsNetworkCategory = 'private' | 'public' | 'domain' | 'unknown' + +export type WindowsMobileFirewallStatus = + | { supported: false } + | { + supported: true + port: number + ruleAllowed: boolean + privateFirewallEnabled: boolean + networkCategory: WindowsNetworkCategory + inspectionAvailable: boolean + } + +export type WindowsMobileFirewallRepairResult = + | { ok: true } + | { ok: false; reason: 'cancelled' | 'failed' | 'unsupported' } diff --git a/tests/e2e/agent-session-live-force-exit-resume.spec.ts b/tests/e2e/agent-session-live-force-exit-resume.spec.ts index 2bbf4c86aaa..82c49660195 100644 --- a/tests/e2e/agent-session-live-force-exit-resume.spec.ts +++ b/tests/e2e/agent-session-live-force-exit-resume.spec.ts @@ -196,6 +196,11 @@ test('resumes a live agent record after force-exit restart when pane PTY ownersh } ) + // Drive the product's own quit-capture path (what the 60s timer / beforeunload + // run) so the live record is flushed deterministically instead of racing the + // debounced session writer before the poll below. + await page.evaluate(() => window.__store?.getState().captureAllSleepingAgentSessions()) + await expect .poll(() => persistedLiveRecordExists(session.userDataDir), { timeout: 15_000, diff --git a/tests/e2e/agent-session-log-tail-stability.spec.ts b/tests/e2e/agent-session-log-tail-stability.spec.ts new file mode 100644 index 00000000000..04e8fa48899 --- /dev/null +++ b/tests/e2e/agent-session-log-tail-stability.spec.ts @@ -0,0 +1,397 @@ +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' + +const ANCHOR_TOKEN = 'E2E_LIVE_LOG_STABLE_ANCHOR' +const INITIAL_PAYLOAD_BYTES = 9 * 1024 * 1024 +const APPEND_CADENCE_MS = 5_000 +const SETTLEMENT_MS = 500 + +type CrashProbe = { processGone: { reason: string; exitCode: number } | null } +type MemorySample = { + jsHeapMb: number + privateMb: number + workingSetMb: number +} + +declare global { + // eslint-disable-next-line no-var -- main-process global probe for this spec + var __liveLogCrashProbe: CrashProbe | undefined +} + +test.describe('Agent Session History live log', () => { + test.use({ + orcaAppExtraArgs: ['--js-flags=--expose-gc', '--enable-precise-memory-info'] + }) + + test('keeps a long View Log viewport stable while the transcript grows', async ({ + electronApp, + orcaPage, + testRepoPath + }, testInfo) => { + test.setTimeout(10 * 60_000) + const fixture = await seedSyntheticSession(electronApp, testRepoPath) + await armMainProcessCrashProbe(electronApp) + await electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.webContents.setZoomFactor(1) + }) + + await orcaPage.setViewportSize({ width: 520, height: 720 }) + await openSessionHistory(orcaPage) + const title = orcaPage.getByText(fixture.title, { exact: true }).first() + await expect(title).toBeVisible({ timeout: 30_000 }) + await title.click() + await orcaPage.getByText('View Log', { exact: true }).click() + await expect + .poll(() => readProbeOrNull(orcaPage), { timeout: 120_000 }) + .toMatchObject({ filePath: fixture.filePath, valueLength: fixture.initialLength }) + await orcaPage.setViewportSize({ width: 900, height: 720 }) + await orcaPage.evaluate(async () => { + const state = window.__store?.getState() + state?.setRightSidebarOpen(false) + state?.setEditorFontZoomLevel(0) + await state?.updateSettings({ terminalFontSize: 13 }) + }) + + let baseline = await restoreAnchorState(orcaPage, true) + console.log(`[live-log-stability] geometry ${JSON.stringify(baseline)}`) + // Why: containment is proven by the full 9 MiB model length, which is + // font-independent. Word-wrap pixel geometry varies ~10% across runner font + // metrics (macOS baseline 2,775,880px; Linux CI ~2,498,292px), so keep only a + // generous content-height floor as a collapsed/truncated-render smoke check. + expect(baseline.valueLength).toBe(fixture.initialLength) + expect(baseline.contentHeight).toBeGreaterThanOrEqual(2_000_000) + expect(baseline.visibleRanges.length).toBeGreaterThan(0) + expect(baseline.find).toMatchObject({ open: true, query: ANCHOR_TOKEN }) + expect(baseline.find.activeMatch).not.toBe('') + + const suffixMemory: { before: MemorySample; after: MemorySample; settled: MemorySample }[] = [] + const replacementMemory: { + before: MemorySample + after: MemorySample + settled: MemorySample + }[] = [] + + for (let batch = 0; batch < 3; batch++) { + const equivalentState = baseline + replacementMemory.push(await measureLegacyControl(electronApp, orcaPage, baseline, batch)) + baseline = await restoreAnchorState(orcaPage, baseline.find.open, equivalentState.scrollTop) + expect(baseline.contentHeight).toBe(equivalentState.contentHeight) + expect(baseline.valueLength).toBe(equivalentState.valueLength) + expect(baseline.valueTail).toBe(equivalentState.valueTail) + expect(baseline.visibleRanges).toEqual(equivalentState.visibleRanges) + expect(baseline.selection).toEqual(equivalentState.selection) + expect(baseline.find).toEqual(equivalentState.find) + expect(Math.abs(baseline.scrollTop - equivalentState.scrollTop)).toBeLessThanOrEqual(2) + const suffix = `${JSON.stringify({ type: 'e2e_append', batch, text: 'tail-only' })}\n` + await forceGcAndSettle(orcaPage) + const before = await readMemory(electronApp, orcaPage) + appendFileSync(fixture.filePath, suffix) + fixture.initialLength += suffix.length + await orcaPage.waitForTimeout(APPEND_CADENCE_MS) + await expect + .poll(() => readProbe(orcaPage), { timeout: 30_000 }) + .toMatchObject({ valueLength: fixture.initialLength }) + const after = await readMemory(electronApp, orcaPage) + await forceGcAndSettle(orcaPage) + const settled = await readMemory(electronApp, orcaPage) + suffixMemory.push({ before, after, settled }) + const current = await readProbe(orcaPage) + console.log(`[live-log-stability] anchor ${JSON.stringify({ batch, baseline, current })}`) + expect(current.visibleRanges).toEqual(baseline.visibleRanges) + expect(current.valueTail).toBe(suffix.trimEnd()) + expect(current.selection).toEqual(baseline.selection) + expect(current.find).toEqual(baseline.find) + expect(Math.abs(current.scrollTop - baseline.scrollTop)).toBeLessThanOrEqual(2) + expect(await orcaPage.evaluate(() => 2 + 2)).toBe(4) + expect((await readMainProcessCrashProbe(electronApp)).processGone).toBeNull() + baseline = current + if (batch === 0) { + await orcaPage.keyboard.press('Escape') + await expect(orcaPage.locator('.monaco-editor .find-widget')).toHaveAttribute( + 'aria-hidden', + 'true' + ) + baseline = await readProbe(orcaPage) + expect(baseline.find.open).toBe(false) + } + } + + console.log( + `[live-log-stability] memory ${JSON.stringify({ suffixMemory, replacementMemory })}` + ) + assertMemoryBudget(suffixMemory, replacementMemory) + await testInfo.attach('synthetic-live-log-stable', { + body: await orcaPage.screenshot(), + contentType: 'image/png' + }) + }) +}) + +async function seedSyntheticSession( + electronApp: ElectronApplication, + cwd: string +): Promise<{ filePath: string; initialLength: number; title: string }> { + const userData = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const title = `Synthetic live log ${Date.now()}` + const sessionId = `e2e-live-log-${Date.now()}` + const sessionsDir = path.join( + userData, + 'codex-runtime-home', + 'home', + 'sessions', + '2026', + '07', + '12' + ) + mkdirSync(sessionsDir, { recursive: true }) + const filePath = path.join(sessionsDir, `rollout-${sessionId}.jsonl`) + const records = [ + JSON.stringify({ + timestamp: '2026-07-12T12:00:00.000Z', + type: 'session_meta', + payload: { id: sessionId, cwd } + }), + JSON.stringify({ + timestamp: '2026-07-12T12:00:01.000Z', + type: 'response_item', + payload: { type: 'message', role: 'user', content: [{ type: 'text', text: title }] } + }), + JSON.stringify({ type: 'synthetic', text: 'x'.repeat(INITIAL_PAYLOAD_BYTES / 2) }), + JSON.stringify({ type: 'anchor', text: ANCHOR_TOKEN }), + JSON.stringify({ type: 'synthetic', text: 'y'.repeat(INITIAL_PAYLOAD_BYTES / 2) }) + ] + const content = `${records.join('\n')}\n` + writeFileSync(filePath, content) + return { filePath, initialLength: content.length, title } +} + +async function openSessionHistory(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setRightSidebarOpen(true) + store.getState().setRightSidebarTab('vault') + }) + await page.evaluate(async () => window.api.aiVault.listSessions({ force: true })) + await page.getByRole('button', { name: 'Refresh Session History' }).click() +} + +async function focusAnchorWithFind(page: Page): Promise { + const modifier = process.platform === 'darwin' ? 'Meta' : 'Control' + await page.locator('.monaco-editor').click() + await page.keyboard.press(`${modifier}+f`) + const findInput = page.locator('.monaco-editor .find-widget .input').first() + await expect(findInput).toBeVisible() + await findInput.fill(ANCHOR_TOKEN) + await page.keyboard.press('Enter') +} + +async function restoreAnchorState( + page: Page, + findOpen: boolean, + targetScrollTop?: number +): Promise>> { + await focusAnchorWithFind(page) + await expect + .poll( + async () => { + const snapshot = await readProbe(page) + return (snapshot.selection as { startLineNumber?: number } | null)?.startLineNumber + }, + { timeout: 60_000 } + ) + .toBe(4) + await expect + .poll(async () => { + const range = (await readProbe(page)).visibleRanges[0] as { + startLineNumber?: number + endLineNumber?: number + } + return (range.startLineNumber ?? 0) <= 4 && (range.endLineNumber ?? 0) >= 4 + }) + .toBe(true) + if (!findOpen) { + await page.keyboard.press('Escape') + await expect(page.locator('.monaco-editor .find-widget')).toHaveAttribute('aria-hidden', 'true') + } + if (targetScrollTop !== undefined) { + await page.evaluate((scrollTop) => { + window.__monacoEditorE2E?.restoreScrollTop(scrollTop) + }, targetScrollTop) + await expect.poll(async () => (await readProbe(page)).scrollTop).toBe(targetScrollTop) + } + return readProbe(page) +} + +async function readProbe(page: Page): Promise<{ + contentHeight: number + filePath: string + scrollTop: number + selection: unknown + valueLength: number + valueTail: string + visibleRanges: unknown[] + find: { open: boolean; query: string; activeMatch: string } +}> { + return page.evaluate(() => { + const probe = window.__monacoEditorE2E + if (!probe) { + throw new Error('Monaco E2E probe unavailable') + } + return { filePath: probe.filePath, ...probe.snapshot() } + }) +} + +async function armMainProcessCrashProbe(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({ BrowserWindow }) => { + const probe: CrashProbe = { processGone: null } + globalThis.__liveLogCrashProbe = probe + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.on('render-process-gone', (_event, details) => { + probe.processGone = { reason: details.reason, exitCode: details.exitCode ?? -1 } + }) + } + }) +} + +async function readMainProcessCrashProbe(electronApp: ElectronApplication): Promise { + return electronApp.evaluate(() => globalThis.__liveLogCrashProbe ?? { processGone: null }) +} + +async function forceGcAndSettle(page: Page): Promise { + await page.evaluate(() => { + const gc = (window as unknown as { gc?: () => void }).gc + if (!gc) { + throw new Error('Forced GC unavailable; launch must include --js-flags=--expose-gc') + } + gc() + gc() + }) + await page.waitForTimeout(SETTLEMENT_MS) +} + +async function readMemory(electronApp: ElectronApplication, page: Page): Promise { + const jsHeapMb = await page.evaluate(() => { + const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory + ?.usedJSHeapSize + if (!memory) { + throw new Error('Precise renderer JS heap metrics unavailable') + } + return memory / 1024 / 1024 + }) + const metric = await electronApp.evaluate(({ app, BrowserWindow }) => { + const rendererPid = BrowserWindow.getAllWindows()[0]?.webContents.getOSProcessId() + const rendererMetric = app.getAppMetrics().find((candidate) => candidate.pid === rendererPid) + if (!rendererMetric) { + throw new Error(`Renderer app metric unavailable for pid ${rendererPid ?? 'unknown'}`) + } + return { + pid: rendererMetric.pid, + privateMb: rendererMetric.memory.privateBytes + ? rendererMetric.memory.privateBytes / 1024 + : null, + workingSetMb: rendererMetric.memory.workingSetSize / 1024 + } + }) + const privateMb = metric.privateMb ?? readPrivateMemoryMb(metric.pid) + return { jsHeapMb, privateMb, workingSetMb: metric.workingSetMb } +} + +function readPrivateMemoryMb(pid: number): number { + if (process.platform === 'linux') { + const status = readFileSync(`/proc/${pid}/status`, 'utf8') + const privateKb = /RssAnon:\s+(\d+) kB/.exec(status)?.[1] + if (!privateKb) { + throw new Error(`Unable to parse renderer RssAnon for pid ${pid}`) + } + return Number(privateKb) / 1024 + } + if (process.platform !== 'darwin') { + throw new Error('app.getAppMetrics privateBytes unavailable on Windows') + } + // Electron omits MemoryInfo.privateBytes on macOS; footprint's + // phys_footprint is the OS private-memory equivalent for the same renderer PID. + const output = execFileSync('footprint', ['-p', String(pid), '-f', 'bytes', '--noCategories'], { + encoding: 'utf8' + }) + const bytes = /phys_footprint:\s+(\d+) B/.exec(output)?.[1] + if (!bytes) { + throw new Error(`Unable to parse renderer private footprint for pid ${pid}`) + } + return Number(bytes) / 1024 / 1024 +} + +async function measureLegacyControl( + electronApp: ElectronApplication, + page: Page, + baseline: Awaited>, + batch: number +): Promise<{ before: MemorySample; after: MemorySample; settled: MemorySample }> { + await forceGcAndSettle(page) + const before = await readMemory(electronApp, page) + await page.evaluate((index) => { + window.__monacoEditorE2E?.runLegacySetValueAppend( + `${JSON.stringify({ type: 'legacy_set_value_control', index, text: 'tail-only' })}\n` + ) + }, batch) + await page.waitForTimeout(APPEND_CADENCE_MS) + const after = await readMemory(electronApp, page) + const disrupted = await readProbe(page) + console.log( + `[live-log-stability] legacy-control ${JSON.stringify({ batch, baseline, disrupted })}` + ) + expect(hasInstabilitySignal(baseline, disrupted)).toBe(true) + await forceGcAndSettle(page) + const settled = await readMemory(electronApp, page) + await page.evaluate(() => window.__monacoEditorE2E?.restoreLegacySetValueControl()) + await expect.poll(() => readProbe(page)).toMatchObject({ valueLength: baseline.valueLength }) + expect((await readMainProcessCrashProbe(electronApp)).processGone).toBeNull() + return { before, after, settled } +} + +function hasInstabilitySignal( + baseline: Awaited>, + current: Awaited> +): boolean { + return ( + JSON.stringify(current.selection) !== JSON.stringify(baseline.selection) || + JSON.stringify(current.find) !== JSON.stringify(baseline.find) || + JSON.stringify(current.visibleRanges) !== JSON.stringify(baseline.visibleRanges) || + Math.abs(current.scrollTop - baseline.scrollTop) > 2 + ) +} + +function assertMemoryBudget( + suffix: { before: MemorySample; after: MemorySample; settled: MemorySample }[], + replacement: { before: MemorySample; after: MemorySample; settled: MemorySample }[] +): void { + expect(suffix).toHaveLength(replacement.length) + expect(suffix.length).toBeGreaterThan(0) + for (const sample of suffix) { + for (const field of ['jsHeapMb', 'workingSetMb', 'privateMb'] as const) { + const retainedAllowance = Math.max(20, sample.before[field] * 0.1) + expect(sample.settled[field] - sample.before[field]).toBeLessThanOrEqual(retainedAllowance) + } + } + for (const [index, sample] of suffix.entries()) { + const pairedReplacement = replacement[index] + expect(pairedReplacement).toBeDefined() + for (const field of ['jsHeapMb', 'workingSetMb', 'privateMb'] as const) { + const suffixPeak = sample.after[field] - sample.before[field] + const replacementPeak = pairedReplacement.after[field] - pairedReplacement.before[field] + expect(suffixPeak).toBeLessThanOrEqual(replacementPeak) + } + } +} + +async function readProbeOrNull(page: Page): Promise> | null> { + return page.evaluate(() => { + const probe = window.__monacoEditorE2E + return probe ? { filePath: probe.filePath, ...probe.snapshot() } : null + }) +} diff --git a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts index c6a9d6e35a4..c533a395e22 100644 --- a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts @@ -90,6 +90,7 @@ export async function runRendererBackpressureRevisitScenario< deps, maxMedianKeyLatencyMs, maxRendererSchedulerQueuedChars, + maxRevisitLatencyMs, maxTimerDriftMs, maxWorstKeyLatencyMs, mainRendererPressureTargetChars, @@ -102,6 +103,7 @@ export async function runRendererBackpressureRevisitScenario< deps: RevisitPressureDeps maxMedianKeyLatencyMs: number maxRendererSchedulerQueuedChars: number + maxRevisitLatencyMs: number maxTimerDriftMs: number maxWorstKeyLatencyMs: number mainRendererPressureTargetChars: number @@ -205,7 +207,10 @@ export async function runRendererBackpressureRevisitScenario< 1 )}ms heldAckChars=${ackGate?.heldAckChars ?? 0}` }) - expect(revisitLatencyMs).toBeLessThan(maxWorstKeyLatencyMs) + // Why: this printf is measured after a worktree switch/focus while the + // background panes are still ACK-gate-held, so it gets its own under-load + // bound rather than the unloaded worst-key budget. + expect(revisitLatencyMs).toBeLessThan(maxRevisitLatencyMs) await deps.releaseTerminalAckGate(orcaPage) await deps.focusPane(orcaPage, loadPanes[0]?.paneKey ?? revisitPane.paneKey) diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index 99e31cd96db..1744fec4fa0 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -131,6 +131,11 @@ const MAX_WORST_KEY_LATENCY_MS = 300 // a catastrophic-hang detector. Mirrors ssh-docker-relay-perf's 2s worst-key // tolerance and the hidden-pressure scenario's relaxed worst budget. const MAX_WORST_KEY_LATENCY_UNDER_LOAD_MS = 3_000 +// Why: the post-revisit printf is sampled while the background panes are still +// ACK-gate-held and through a whole-buffer serialize poll, so it inherits the +// same environment-dominated worst-case as typing under load; the unloaded +// 300ms budget has no margin for this switch-and-focus moment. +const MAX_REVISIT_LATENCY_UNDER_LOAD_MS = 3_000 // Why: GitHub's two-worker Electron shards can briefly starve renderer timers // without visible typing lag. Keep this as a smoke gate, not a CPU lottery. const MAX_TIMER_DRIFT_MS = 250 @@ -604,6 +609,7 @@ test.describe('Artificial OpenCode terminal load', () => { mainRendererPressureTargetChars: MAIN_RENDERER_PRESSURE_TARGET_CHARS, maxMedianKeyLatencyMs: MAX_MEDIAN_KEY_LATENCY_MS, maxRendererSchedulerQueuedChars: MAX_RENDERER_SCHEDULER_QUEUED_CHARS, + maxRevisitLatencyMs: MAX_REVISIT_LATENCY_UNDER_LOAD_MS, maxTimerDriftMs: MAX_TIMER_DRIFT_MS, maxWorstKeyLatencyMs: MAX_WORST_KEY_LATENCY_MS, orcaPage, diff --git a/tests/e2e/chinese-ime-chat-input-repro.spec.ts b/tests/e2e/chinese-ime-chat-input-repro.spec.ts index 8994e235cdd..3cdac524671 100644 --- a/tests/e2e/chinese-ime-chat-input-repro.spec.ts +++ b/tests/e2e/chinese-ime-chat-input-repro.spec.ts @@ -367,6 +367,18 @@ async function dispatchCandidateSelectionKey( }) } +async function dispatchOrphanLetterKeyup(session: CDPSession): Promise { + // Why: legacy Sogou/fcitx can emit only the release after committing a + // single-letter preedit, leaving no composition or input event to classify. + await session.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'a', + code: 'KeyA', + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65 + }) +} + async function dispatchSogouEmptyCompositionUpdate(page: Page): Promise { // Why: Sogou/fcitx emits empty compositionupdate data while its candidate // popup is still open (#6765); Orca's tracker must not flip inactive on it. @@ -654,11 +666,24 @@ test.describe('Chinese IME terminal chat input repro', () => { }) .toBe('再见') + // Some legacy desktop Linux paths omit every composition/input event and + // expose only an orphaned Latin release before the candidate digit. + await dispatchOrphanLetterKeyup(session) + await dispatchCandidateSelectionKey(session, { key: '4', code: 'Digit4', keyCode: 52 }) + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, { + timeout: 5_000, + message: 'orphan-keyup candidate digit leaked into the submitted terminal input' + }) + .toBe('') + await attachImeEvidence(orcaPage, testInfo, 'sogou-after-orphan-keyup-digit-suppression') + const promptState = await readPromptState(orcaPage) expect( promptState?.submitted, 'candidate Space/digit selectors and pinyin preedit must not leak into the PTY' - ).toEqual(['你', '你好', '再见']) + ).toEqual(['你', '你好', '再见', '']) } finally { await attachImeEvidence(orcaPage, testInfo, 'sogou-final-ime-evidence').catch(() => undefined) await session?.detach().catch(() => undefined) diff --git a/tests/e2e/combined-diff-scroll-restore.spec.ts b/tests/e2e/combined-diff-scroll-restore.spec.ts index a32ec947af2..039bdf7f13a 100644 --- a/tests/e2e/combined-diff-scroll-restore.spec.ts +++ b/tests/e2e/combined-diff-scroll-restore.spec.ts @@ -423,7 +423,10 @@ test.describe('Combined diff scroll restore', () => { await clickVisibleDiffLine(orcaPage) const afterLineClick = await waitForStableViewportAnchor(orcaPage) - expect(afterLineClick.key).toBe(afterSwitch.key) + // Assert the viewport barely moved rather than an exact anchor key: sections + // are ~viewport-sized, so a sub-pixel focus scroll from the click can flip the + // topmost-visible key by one without meaningfully moving the scroll position. + expect(Math.abs(afterLineClick.scrollTop - afterSwitch.scrollTop)).toBeLessThan(40) expect(Math.abs(afterLineClick.top - afterSwitch.top)).toBeLessThan(80) } finally { rmSync(fixture.repoPath, { recursive: true, force: true }) diff --git a/tests/e2e/helpers/terminal-hidden-parking.ts b/tests/e2e/helpers/terminal-hidden-parking.ts new file mode 100644 index 00000000000..4b15f656ec2 --- /dev/null +++ b/tests/e2e/helpers/terminal-hidden-parking.ts @@ -0,0 +1,65 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' +import { getActiveTabId } from './store' +import { waitForActiveTerminalManager, waitForPaneIdentitySnapshot } from './terminal' + +function resolveParkWaitTimeoutMs(parkDelayMs?: number): number { + const delay = parkDelayMs ?? (Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500) + return Math.max(20_000, delay * 10) +} + +// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so a +// missing manager is the observable signal that the tab's xterm was parked. +export async function waitForTabParked( + page: Page, + tabId: string, + options?: { parkDelayMs?: number } +): Promise { + const parkWaitStartedAt = Date.now() + await expect + .poll(async () => page.evaluate((id) => window.__paneManagers?.get(id) !== undefined, tabId), { + timeout: resolveParkWaitTimeoutMs(options?.parkDelayMs), + message: `terminal tab ${tabId} did not park (pane manager still mounted)` + }) + .toBe(false) + return Date.now() - parkWaitStartedAt +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + return tabId +} + +// Why: #8262 exempts the single most-recently-hidden tab from cold-park to keep +// the just-left view instantly warm, so a lone hidden tab never parks. Opening +// one more tab hides the current view (which then holds that exemption) and +// leaves the older `targetTabId` free to cold-park. Returns waitForTabParked's +// elapsed-ms so callers keep their parking annotations. +export async function parkHiddenTabBehindDecoy( + page: Page, + worktreeId: string, + targetTabId: string, + options?: { parkDelayMs?: number } +): Promise { + await createActiveTerminalTab(page, worktreeId) + return waitForTabParked(page, targetTabId, options) +} diff --git a/tests/e2e/renderer-crash-recovery-terminal-input.spec.ts b/tests/e2e/renderer-crash-recovery-terminal-input.spec.ts index 621842e54e8..6e4fc04b4e1 100644 --- a/tests/e2e/renderer-crash-recovery-terminal-input.spec.ts +++ b/tests/e2e/renderer-crash-recovery-terminal-input.spec.ts @@ -139,7 +139,28 @@ test.describe('Renderer crash recovery keeps terminal input alive', () => { // should reattach to the same session rather than spawn a fresh shell. const postPtyIds = await mainGetStorePtyIds(electronApp) - const transportAlive = await mainProbeTransportPaste(electronApp, `PASTE_POST_${cycle}_OK`) + // Why: the post-crash transport reattach is async — a single fire-and-forget + // probe can race the reconnect window. Poll over the recovery budget so a + // transient unbound-transport window does not fail the test; a transport + // still dead after the full budget is the real frozen-pane bug reported below. + let transportAlive = false + try { + await expect + .poll( + async () => { + transportAlive = await mainProbeTransportPaste( + electronApp, + `PASTE_POST_${cycle}_OK`, + 5_000 + ) + return transportAlive + }, + { timeout: RECOVERY_TIMEOUT_MS } + ) + .toBe(true) + } catch { + transportAlive = false + } const directAlive = postPtyIds.length > 0 && (await mainProbeDirectWrite(electronApp, postPtyIds[0], `DIRECT_POST_${cycle}_OK`)) diff --git a/tests/e2e/rich-markdown-link-bubble-stacking.spec.ts b/tests/e2e/rich-markdown-link-bubble-stacking.spec.ts index f84691db5db..090c8cb071d 100644 --- a/tests/e2e/rich-markdown-link-bubble-stacking.spec.ts +++ b/tests/e2e/rich-markdown-link-bubble-stacking.spec.ts @@ -102,7 +102,9 @@ test.describe('Rich markdown link bubble stacking', () => { expect(overlap.overlapWidth).toBeGreaterThan(8) expect(overlap.topElementIsBubble).toBe(true) - const editButton = bubble.getByTitle('Edit link') + // The Edit action exposes its label via aria-label (a shadcn Button + + // Radix tooltip), not a title attribute, so match by accessible name. + const editButton = bubble.getByRole('button', { name: 'Edit link' }) const editButtonBounds = await editButton.boundingBox() const explorerBounds = await explorer.boundingBox() expect(editButtonBounds).not.toBeNull() diff --git a/tests/e2e/terminal-codex-hidden-startup-background.spec.ts b/tests/e2e/terminal-codex-hidden-startup-background.spec.ts index 7c1f832771a..ff074c323de 100644 --- a/tests/e2e/terminal-codex-hidden-startup-background.spec.ts +++ b/tests/e2e/terminal-codex-hidden-startup-background.spec.ts @@ -13,12 +13,6 @@ import { import { getTerminalContent, waitForActiveTerminalManager } from './helpers/terminal' import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT } from '../../src/renderer/src/constants/terminal' -type HiddenOutputDebugSnapshot = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number - hiddenRendererMode2031ReplyCount: number -} - type CodexStartupBackgroundTarget = { clip: { x: number; y: number; width: number; height: number } cellWidth: number @@ -28,13 +22,6 @@ type CodexStartupBackgroundTarget = { modelBackgroundCells: number } -type CodexStartupBackgroundWindow = Window & { - __terminalPtyOutputDebug?: { - reset: () => void - snapshot: () => HiddenOutputDebugSnapshot - } -} - const COMPOSER_BG = { red: 72, green: 72, blue: 72 } function codexLikeStartupCommand(marker: string): string { @@ -97,18 +84,6 @@ async function waitForHiddenTabPtyId(page: Page, tabId: string): Promise return ptyId } -async function readHiddenOutputDebug(page: Page): Promise { - return page.evaluate(() => { - return (window as CodexStartupBackgroundWindow).__terminalPtyOutputDebug?.snapshot() ?? null - }) -} - -async function resetHiddenOutputDebug(page: Page): Promise { - await page.evaluate(() => { - ;(window as CodexStartupBackgroundWindow).__terminalPtyOutputDebug?.reset() - }) -} - async function mainSnapshotContains(page: Page, ptyId: string, text: string): Promise { return page.evaluate( async ({ targetPtyId, expectedText }) => { @@ -266,7 +241,6 @@ test.describe('Codex hidden startup composer background', () => { const marker = `CODEX_STARTUP_BG_${Date.now()}` const command = codexLikeStartupCommand(marker) - await resetHiddenOutputDebug(orcaPage) const hiddenTabId = await orcaPage.evaluate( ({ worktreeId, command, eventName }) => { const store = window.__store @@ -312,12 +286,10 @@ test.describe('Codex hidden startup composer background', () => { message: 'Hidden Codex startup background never reached the main buffer snapshot' }) .toBe(true) - await expect - .poll(async () => (await readHiddenOutputDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { - timeout: 10_000, - message: 'Codex-marked hidden startup did not take the skipped renderer path' - }) - .toBeGreaterThan(0) + // Why: the renderer skip counter is dead under the Phase-4 main-side delivery + // gate (#7214) — hidden bytes are dropped in main before reaching the renderer. + // The main-buffer snapshot above proves the hidden output was handled; the + // reveal restore below proves it repaints when the worktree first shows. await switchToWorktree(orcaPage, secondWorktreeId) await expect diff --git a/tests/e2e/terminal-hidden-view-parking.spec.ts b/tests/e2e/terminal-hidden-view-parking.spec.ts index 4bd2e143e13..fe393656337 100644 --- a/tests/e2e/terminal-hidden-view-parking.spec.ts +++ b/tests/e2e/terminal-hidden-view-parking.spec.ts @@ -16,6 +16,7 @@ import { waitForActiveTerminalManager, waitForPaneIdentitySnapshot } from './helpers/terminal' +import { parkHiddenTabBehindDecoy, waitForTabParked } from './helpers/terminal-hidden-parking' // Why: the parking wiring registers this handle (dev/exposeStore builds only) // so tests can detect that hidden-view parking is compiled in and which delay @@ -160,19 +161,6 @@ async function readTerminalTabViewState(page: Page, tabId: string): Promise { - const parkWaitStartedAt = Date.now() - await expect - .poll(async () => (await readTerminalTabViewState(page, tabId)).hasManager, { - timeout: Math.max(20_000, PARKING_DELAY_MS * 10), - message: `terminal tab ${tabId} did not park (pane manager still mounted)` - }) - .toBe(false) - return Date.now() - parkWaitStartedAt -} - async function activateTerminalTab(page: Page, tabId: string): Promise { await page.evaluate((targetTabId) => { const store = window.__store @@ -306,15 +294,18 @@ test.describe('Terminal hidden view parking', () => { .toContain(finalMarker) const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) - const parkDetectedAfterMs = await waitForTabParked(orcaPage, tabAId) + const parkDetectedAfterMs = await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabAId, { + parkDelayMs: PARKING_DELAY_MS + }) const wiring = await readParkingWiring(orcaPage) testInfo.annotations.push({ type: 'terminal-parking', description: `parkDelayMs=${wiring.parkDelayMs ?? PARKING_DELAY_MS} parkDetectedAfterMs=${parkDetectedAfterMs}` }) - // Why: parking must be scoped to the hidden tab — the visible tab keeps - // a live pane manager and xterm. + // Why: parking must be scoped to the parked tab — tab B (hidden more + // recently, so #8262 keeps it warm) still holds a live pane manager and + // xterm while tab A tore down. const tabBState = await readTerminalTabViewState(orcaPage, tabBId) expect(tabBState.hasManager).toBe(true) expect(tabBState.paneCount).toBeGreaterThan(0) @@ -370,7 +361,9 @@ test.describe('Terminal hidden view parking', () => { const { worktreeId, tabAId, tabAPtyId } = setup await createActiveTerminalTab(orcaPage, worktreeId) - await waitForTabParked(orcaPage, tabAId) + await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabAId, { + parkDelayMs: PARKING_DELAY_MS + }) const runId = randomUUID() const parkedTitle = `Parked side effects ${runId}` @@ -443,8 +436,12 @@ test.describe('Terminal hidden view parking', () => { .toBe(tabBId) // Why: tab A parking proves the machinery ran past the delay in this app - // instance, so the tab C assertion below is not vacuously green. - await waitForTabParked(orcaPage, tabAId) + // instance, so the tab C assertion below is not vacuously green. A decoy + // takes the #8262 last-active exemption (tab C is excluded, not a candidate) + // so tab A is the one that cold-parks. + await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabAId, { + parkDelayMs: PARKING_DELAY_MS + }) await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) // Premise guard: nothing consumed the pending startup while hidden. @@ -483,8 +480,11 @@ test.describe('Terminal hidden view parking', () => { .toContain(marker) // Tab B stays visible whenever tab A is parked; toggling the active tab - // between them is the deterministic hide/reveal driver. + // between them is the deterministic hide/reveal driver. The decoy tab + // absorbs the #8262 last-active exemption each cycle (hidden after B) so + // tab A — not the just-hidden view — is the one that cold-parks. const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + const decoyTabId = await createActiveTerminalTab(orcaPage, worktreeId) // One park/reveal cycle to run the frame through the snapshot restore for a // baseline. Why not compare against the visible-before-park content: an @@ -495,7 +495,9 @@ test.describe('Terminal hidden view parking', () => { // both sides pass through identical machinery, so any later diff is drift. const runOneParkRevealCycle = async (cycle: number): Promise => { await activateTerminalTab(orcaPage, tabBId) - await waitForTabParked(orcaPage, tabAId) + // Hide tab B behind the decoy so B (not A) holds the #8262 exemption. + await activateTerminalTab(orcaPage, decoyTabId) + await waitForTabParked(orcaPage, tabAId, { parkDelayMs: PARKING_DELAY_MS }) await activateTerminalTab(orcaPage, tabAId) await waitForActiveTerminalManager(orcaPage, 30_000) const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1) diff --git a/tests/e2e/terminal-history-size-typing-latency.spec.ts b/tests/e2e/terminal-history-size-typing-latency.spec.ts index 3f0f0d0a1f1..559bf87494e 100644 --- a/tests/e2e/terminal-history-size-typing-latency.spec.ts +++ b/tests/e2e/terminal-history-size-typing-latency.spec.ts @@ -163,6 +163,12 @@ function median(values: number[]): number { return sorted[Math.floor(sorted.length / 2)] ?? 0 } +function percentile(values: number[], fraction: number): number { + const sorted = [...values].sort((a, b) => a - b) + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1)) + return sorted[index] ?? 0 +} + type PhaseLatency = { label: string bufferRows: number @@ -281,9 +287,12 @@ test.describe('Terminal typing latency vs scrollback history size', () => { phase.medianMs, `${phase.label}: median latency regressed with history size` ).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) + // Why: the fill keeps the session dirty so a daemon checkpoint + // serialization can land inside a single sample; guard the p90 so one + // environment-dominated spike is not a failure while median stays strict. expect( - phase.worstMs, - `${phase.label}: worst latency regressed with history size` + percentile(phase.samples, 0.9), + `${phase.label}: sustained (p90) latency regressed with history size` ).toBeLessThan(MAX_WORST_KEY_LATENCY_MS) } } finally { diff --git a/tests/e2e/terminal-pane-close-layout-consistency.spec.ts b/tests/e2e/terminal-pane-close-layout-consistency.spec.ts index db837c317cb..a1cafa94b7c 100644 --- a/tests/e2e/terminal-pane-close-layout-consistency.spec.ts +++ b/tests/e2e/terminal-pane-close-layout-consistency.spec.ts @@ -12,6 +12,7 @@ import { waitForActiveTerminalManager, waitForPaneIdentitySnapshot } from './helpers/terminal' +import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking' /** * Repro hunt for the "ghost blank pane" field incident: a split pane whose PTY @@ -210,17 +211,6 @@ async function activateTerminalTab(page: Page, tabId: string): Promise { .toBe(tabId) } -// Why: TerminalPane unmount deletes its __paneManagers entry — that absence is -// the observable "parked" signal (same detection as the parking spec). -async function waitForTabParked(page: Page, tabId: string): Promise { - await expect - .poll(async () => page.evaluate((id) => window.__paneManagers?.get(id) !== undefined, tabId), { - timeout: Math.max(20_000, PARKING_DELAY_MS * 10), - message: `terminal tab ${tabId} did not park (pane manager still mounted)` - }) - .toBe(false) -} - async function waitForTabRemounted(page: Page, tabId: string): Promise { await expect .poll(async () => page.evaluate((id) => window.__paneManagers?.get(id) !== undefined, tabId), { @@ -298,7 +288,9 @@ test.describe('terminal pane close vs hidden/park lifecycle keeps layout consist const { worktreeId, tabId } = await setUpSplitTab(orcaPage) await createActiveTerminalTab(orcaPage, worktreeId) await closeLastPaneOnTab(orcaPage, tabId) - await waitForTabParked(orcaPage, tabId) + await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { + parkDelayMs: PARKING_DELAY_MS + }) await activateTerminalTab(orcaPage, tabId) await waitForTabRemounted(orcaPage, tabId) await expectLayoutConsistent(orcaPage, tabId, 1, 'close-while-hidden-mounted') @@ -307,7 +299,9 @@ test.describe('terminal pane close vs hidden/park lifecycle keeps layout consist test('close immediately after reveal remount, before panes settle', async ({ orcaPage }) => { const { worktreeId, tabId } = await setUpSplitTab(orcaPage) await createActiveTerminalTab(orcaPage, worktreeId) - await waitForTabParked(orcaPage, tabId) + await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { + parkDelayMs: PARKING_DELAY_MS + }) await activateTerminalTab(orcaPage, tabId) await waitForTabRemounted(orcaPage, tabId) // Close as soon as the manager exists — panes may still be attaching. @@ -326,7 +320,9 @@ test.describe('terminal pane close vs hidden/park lifecycle keeps layout consist await closeLastPaneOnTab(orcaPage, tabId) await expectLayoutConsistent(orcaPage, tabId, 1, 'pre-park close') await createActiveTerminalTab(orcaPage, worktreeId) - await waitForTabParked(orcaPage, tabId) + await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { + parkDelayMs: PARKING_DELAY_MS + }) await activateTerminalTab(orcaPage, tabId) await waitForTabRemounted(orcaPage, tabId) await expectLayoutConsistent(orcaPage, tabId, 1, 'post-park-reveal') @@ -347,7 +343,9 @@ test.describe('terminal pane close vs hidden/park lifecycle keeps layout consist test('split pane shell exits while the tab is parked', async ({ orcaPage }) => { const { worktreeId, tabId, splitPtyId } = await setUpSplitTab(orcaPage) await createActiveTerminalTab(orcaPage, worktreeId) - await waitForTabParked(orcaPage, tabId) + await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { + parkDelayMs: PARKING_DELAY_MS + }) await sendToTerminal(orcaPage, splitPtyId, 'exit\r') await orcaPage.waitForTimeout(PARKING_DELAY_MS) await activateTerminalTab(orcaPage, tabId) diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index c6c40afe6fc..9c3fea54d02 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -518,19 +518,27 @@ test.describe('Terminal Shortcuts', () => { await waitForPaneCount(orcaPage, 1, 30_000) }) - test('Shift+Enter writes the platform newline chord for terminal TUIs', async ({ - orcaPage, - electronApp - }) => { + test('Shift+Enter follows the pane Kitty keyboard state', async ({ orcaPage, electronApp }) => { await installMainProcessPtyWriteSpy(electronApp) - await waitForActivePanePtyId(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) - await pressAndExpectWrite( - orcaPage, - electronApp, - 'Shift+Enter', - process.platform === 'win32' ? '\x1b\r' : '\x1b[13;2u' - ) + await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b\r') + if (process.platform === 'win32') { + return + } + + // Why: exercise the production PTY-output tracker, not xterm's renderer- + // local flag state, so the test covers the bytes the shortcut policy sees. + await execInTerminal(orcaPage, ptyId, "printf '\\033[>1u'") + await expect.poll(() => getKittyKeyboardFlags(orcaPage)).toBe(1) + await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b[13;2u') + + // The shell is only standing in for a KKP-aware TUI and does not consume the + // CSI-u input above. Send an idempotent "set flags to 0" reset rather than a + // stack pop so it doesn't race the shell line editor still holding that input. + await execInTerminal(orcaPage, ptyId, "\x03printf '\\033[=0u'") + await expect.poll(() => getKittyKeyboardFlags(orcaPage)).toBe(0) + await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b\r') }) test('Droid gets CSI-u Shift+Enter on Windows without changing Antigravity', async ({ @@ -769,14 +777,8 @@ test.describe('Terminal Shortcuts', () => { // Ctrl+Backspace → \x17 (unix-word-rubout). await pressAndExpectWrite(orcaPage, electronApp, 'Control+Backspace', '\x17') - // Shift+Enter stays distinct; Windows keeps Esc+CR unless the active agent - // explicitly requires CSI-u (currently Droid, #7620). - await pressAndExpectWrite( - orcaPage, - electronApp, - 'Shift+Enter', - process.platform === 'win32' ? '\x1b\r' : '\x1b[13;2u' - ) + // The shell has not enabled KKP, so Shift+Enter must not leak CSI-u text. + await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b\r') // --- send-input chords (macOS-only) ---