diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 76595f338a6..3023d414abe 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -75,8 +75,12 @@ jobs: npm install -g node-gyp@11.5.0 echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" - run: pnpm install --frozen-lockfile + # Why: without --config, bare vitest ignores config/vitest.config.ts (there + # is no root config) and falls back to the 5s default timeout with no + # Windows worker cap, so the real csc.exe launcher-compile tests time out + # on hosted Windows. Use the shared config so this job matches pnpm test. - run: >- - pnpm vitest run + pnpm vitest run --config config/vitest.config.ts config/scripts/build-windows-cli-launcher.test.mjs src/main/ssh/ssh-remote-cli-launcher.test.ts config/scripts/computer-e2e-workflow.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 47bf6b83a40..1e3fb4e6bdb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,6 +15,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + # Why: the freshness registry is derived from immutable release tags, + # so shallow PR checkouts cannot verify historical official identities. + fetch-depth: 0 + persist-credentials: false - name: Install native build tools run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh @@ -82,6 +87,9 @@ jobs: - name: Verify bundled skill guides run: pnpm run verify:bundled-skill-guides + - name: Verify skill freshness manifest + run: pnpm run verify:skill-bundle-manifest + # Why: project-owned type declarations must live in .ts so tsc # actually checks them. TypeScript's skipLibCheck: true (inherited # from @electron-toolkit/tsconfig) silently widens unresolved names diff --git a/.github/workflows/skill-update-roundtrip.yml b/.github/workflows/skill-update-roundtrip.yml new file mode 100644 index 00000000000..6c9418e1f64 --- /dev/null +++ b/.github/workflows/skill-update-roundtrip.yml @@ -0,0 +1,50 @@ +name: Skill update round trip + +on: + pull_request: + paths: + - 'skills/**' + - 'resources/skills/**' + - 'config/scripts/verify-skill-update-roundtrip.mjs' + - 'src/main/skills/skill-freshness-eligibility.ts' + - 'src/shared/skill-freshness.ts' + - '.github/workflows/skill-update-roundtrip.yml' + merge_group: + push: + branches: + - main + +jobs: + roundtrip: + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + shape: [symlink, copy] + autocrlf: ['false', 'true'] + skills-cli: ['1.5.17'] + include: + - os: ubuntu-latest + shape: symlink + autocrlf: 'false' + skills-cli: latest + continue-on-error: ${{ matrix.skills-cli == 'latest' }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + - name: Verify targeted update convergence and copy behavior + env: + GITHUB_TOKEN: ${{ github.token }} + SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }} + run: >- + node config/scripts/verify-skill-update-roundtrip.mjs + --cli=${{ matrix.skills-cli }} + --autocrlf=${{ matrix.autocrlf }} + --shape=${{ matrix.shape }} diff --git a/README.md b/README.md index 81b9a5c5aea..d07692e1edd 100644 --- a/README.md +++ b/README.md @@ -238,10 +238,9 @@ Pair with your desktop app to monitor and steer your agents from your phone. - **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements. -- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group. +- **WeChat:** First WeChat group is full, now you can join the second one. - WeChat QR code for the Orca community - Backup WeChat QR code for the Orca community + WeChat QR code for the Orca community - **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues). - **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out. diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 0dd6a3d85c7..98beb0c1c1f 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -18,6 +18,12 @@ const featureWallResources = { from: 'resources/onboarding/feature-wall', to: 'onboarding/feature-wall' } +// Why: freshness detection needs immutable identity metadata from this exact +// app build, but never needs the skill package bytes or a runtime network read. +const skillFreshnessResources = { + from: 'resources/skills', + to: 'skills' +} // Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged // apps. Keeping relay assets as extraResources makes them real directories // instead of paths hidden inside app.asar. @@ -31,7 +37,11 @@ const relayExtraResource = { // do not fall through to a developer checkout's node_modules. const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources() -const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources] +const commonExtraResources = [ + relayExtraResource, + ...packagedRuntimeNodeModuleResources, + skillFreshnessResources +] const macSpeechNativeResource = { from: 'node_modules/sherpa-onnx-darwin-${arch}', to: 'node_modules/sherpa-onnx-darwin-${arch}' @@ -66,6 +76,10 @@ module.exports = { // authoring sources too would duplicate content without a runtime consumer. '!skill-guides{,/**/*}', '!tests{,/**/*}', + // Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE); + // it is gitignored, but exclude it defensively so a stray local capture at + // package time never bloats app.asar. + '!pr-evidence{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', '!out/**/*.test.js', @@ -75,7 +89,8 @@ module.exports = { '!tsconfig.json', // Why: feature-wall media is copied via extraResources so runtime can read // it from process.resourcesPath; exclude the source copy from app.asar. - '!resources/onboarding/feature-wall/**' + '!resources/onboarding/feature-wall/**', + '!resources/skills/**' ], // Why: the CLI entry-point lives in out/cli/ but imports shared modules // from out/shared/ and local hook mutators from out/main/. These paths must be diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 9965e738a8a..448b4beac63 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -711,15 +711,18 @@ "ssh", "runtime" ], - "coverageNotes": "A live macOS Electron test proves exact local PTY disappearance after a parked-tab close. Deterministic unit/store tests cover daemon and SSH routing, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending.", + "coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Deterministic tests cover daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending.", "motivatingLinks": [ - "https://github.com/stablyai/orca/pull/8628" + "https://github.com/stablyai/orca/pull/8628", + "https://github.com/stablyai/orca/pull/8706" ], - "invariant": "Close permanently removes the owned provider session and resume authority even when no TerminalPane is mounted; detach and park preserve both; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", - "oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; unit tests assert canonical owner dedupe, one teardown owner, exact pane tombstones, chained detach transfer, and restart alias restoration.", + "invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", + "oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.", "commands": [ "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", - "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1" + "pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts", + "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1", + "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1" ], "testFiles": [ "src/main/agent-hooks/server-pane-authority.test.ts", @@ -727,12 +730,18 @@ "src/main/ipc/agent-pane-authority-ownership.test.ts", "src/main/ipc/pty-management.test.ts", "src/main/persistence.test.ts", + "src/main/pty-descendant-termination.test.ts", + "src/main/daemon/session.test.ts", + "src/main/daemon/terminal-host.test.ts", + "src/main/providers/local-pty-provider.test.ts", + "src/main/runtime/worktree-teardown.test.ts", "src/renderer/src/store/slices/agent-pane-authority.test.ts", "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts", "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", - "tests/e2e/terminal-parked-close-retirement.spec.ts" + "tests/e2e/terminal-parked-close-retirement.spec.ts", + "tests/e2e/agent-descendant-process-kill.spec.ts" ], "assertionRefs": [ { @@ -754,6 +763,39 @@ "exact pane retirement removes resume and launch authority while preserving siblings", "chained detach keeps physical hooks and resume authority routed to the current owner until that owner closes" ] + }, + { + "file": "src/main/pty-descendant-termination.test.ts", + "assertions": [ + "20 same-turn process-table requests execute one fresh scan while later arrivals start one shared successor inside their own deadline", + "snapshot and escalation readers stop at their deadline", + "production ps reads force locale-independent C timestamps", + "the source scan timestamp survives request resolution and capture-second identities are never escalated with SIGKILL", + "cyclic-looking duplicate PID rows terminate with each descendant visited once and duplicate escalation identities stay unsignalled", + "descendant signals are suppressed after the caller loses root ownership" + ] + }, + { + "file": "src/main/daemon/terminal-host.test.ts", + "assertions": [ + "agent immediate kill rejects reattach while descendant capture is pending", + "a naturally exited session id remains reserved until capture finishes without force-killing its retired PID", + "graceful teardown upgrades to immediate both during and after descendant capture", + "duplicate immediate kill starts one descendant sweep" + ] + }, + { + "file": "src/main/runtime/worktree-teardown.test.ts", + "assertions": [ + "owned provider shutdowns start together so process-table snapshots can coalesce within a batch", + "inventories above 32 sessions never exceed 32 concurrent provider shutdowns" + ] + }, + { + "file": "tests/e2e/agent-descendant-process-kill.spec.ts", + "assertions": [ + "a detached-pgid descendant is alive before agent PTY kill and absent afterward" + ] } ], "evidenceRuns": [ @@ -765,11 +807,29 @@ "result": "passed", "durationSeconds": 40.3, "summary": "A fresh E2E build launched an isolated Electron profile, parked a live terminal, closed it through closeTab, and observed its exact PTY disappear." + }, + { + "date": "2026-07-14", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 37.7, + "summary": "A current-main integrated fresh-build run proved a detached-pgid child was alive before agent PTY kill and absent afterward on the deadline-safe, root-ownership-gated implementation." + }, + { + "date": "2026-07-15", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 78, + "summary": "The cycle-safe, target-indexed, bounded-fanout review head passed from a cold full build; the live detached-pgid descendant test body completed in 4.8 seconds." } ], "runtimeBudget": { "p95Seconds": 60, - "scope": "fresh E2E build plus one isolated local Electron parked-close test" + "scope": "fresh E2E build plus isolated local Electron parked-close and descendant-kill tests" }, "flakeHistory": { "status": "unknown", @@ -780,8 +840,8 @@ "evidence": "The test exercises the original parked-view failure shape and passed with the retirement boundary; an archived intentional-break run is not yet attached." }, "performanceBudget": { - "required": false, - "evidence": "The close path is user-triggered and bounded by canonical live-owner indexing; kill-all scale remains covered by terminal-session.kill-all-surface-cleanup." + "required": true, + "evidence": "The close path is user-triggered and bounded by canonical live-owner indexing. Production ps has a 1s kill timeout; 20 same-turn requests execute one fresh process-table read, while requests arriving after a scan starts immediately share one successor so their deadline is not consumed waiting and no unusable ps starts after timeout. Completed tables are never reused. Bulk worktree shutdown runs in 32-wide batches so each batch can coalesce its initial scan without unbounded provider fanout. Descendant traversal uses a visited set and index cursor; a Node 24 local 100,000-wide synthetic tree fell from 861ms to 16.7ms, and escalation indexes only the requested descendant PIDs instead of duplicating the full process table. Escalation uses the same bounded coordinator, and kill-all store scale remains covered by terminal-session.kill-all-surface-cleanup." }, "promotionCriteria": [ "Accumulate 100 clean runs or 14 days on required CI platforms.", @@ -791,7 +851,10 @@ "knownGaps": [ "The live Electron proof currently covers macOS local PTYs only.", "Disconnected SSH relay death still requires reconnect-aware provider ownership.", - "Daemon owner leases and durable retry inventory remain follow-up hardening." + "Daemon owner leases and durable retry inventory remain follow-up hardening.", + "Windows ConPTY, SSH-hosted PTYs, app-quit killAll, and daemon dispose retain foreground-tree-only teardown.", + "A process born in the capture second is SIGTERMed but not SIGKILLed because ps cannot prove its recycled-PID identity.", + "A descendant orphaned before or during root ownership loss requires the separate crash-orphan sweep and is not recovered from a stale kill-time snapshot." ], "demotionRule": "Keep experimental or demote to protection none if exact PTY disappearance flakes, a sibling/detached pane is retired, or late hooks can recreate closed authority." }, @@ -1887,7 +1950,6 @@ "daemon stream", "main PTY batching", "runtime path provenance", - "runtime serve-sim detection", "runtime terminal wait detection", "renderer ACK", "xterm scheduler", @@ -1908,7 +1970,7 @@ "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.", + "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse 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": [ "https://github.com/stablyai/orca/pull/6836", "https://github.com/stablyai/orca/pull/6858", @@ -1916,17 +1978,15 @@ "https://github.com/stablyai/orca/pull/7054" ], "invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata, detectors, and tail checks, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.", - "oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, idle serve-sim detection with split-JSON continuity, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", + "oracle": "The current executable slice injects main-process renderer backlog pressure, then asserts unchanged path-provenance history reuse for pathless output, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts" ], "testFiles": [ "src/main/ipc/pty.test.ts", "src/main/runtime/orca-runtime-path-candidate-history.test.ts", - "src/main/emulator/serve-sim-state-watcher.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/orca-runtime-tail-wait-memo.test.ts" ], @@ -1946,13 +2006,6 @@ "copies history when new output adds a path candidate" ] }, - { - "file": "src/main/emulator/serve-sim-state-watcher.test.ts", - "assertions": [ - "does not buffer repeated brace-free PTY output while waiting for metadata", - "detects serve-sim metadata split across PTY chunks and releases the partial object" - ] - }, { "file": "src/main/runtime/orca-runtime-tail-wait-memo.test.ts", "assertions": [ @@ -1990,15 +2043,6 @@ "durationSeconds": 3.49, "summary": "1 test file passed, 2 tests passed on the exact rebased tree based on main@dc468f0ded." }, - { - "date": "2026-07-10", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts", - "result": "passed", - "durationSeconds": 0.37, - "summary": "1 test file passed, 6 tests passed on pushed commit f0e3a185593b." - }, { "date": "2026-07-10", "runner": "local", @@ -2015,11 +2059,11 @@ }, "flakeHistory": { "status": "unknown", - "evidence": "Daemon stream backpressure and focused main/renderer backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion." + "evidence": "Focused main-process backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion." }, "redGreenEvidence": { "status": "partial", - "evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary output leaves the niche serve-sim detector idle while split JSON still emits and releases its partial buffer, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; restoring the old serve-sim buffer failed its scale test after 180ms with 4,096 buffer writes; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion." + "evidence": "Tests assert pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs daemon-stream contract coverage and broader hidden-output/input-latency perf artifacts before promotion." }, "performanceBudget": { "required": true, @@ -2031,9 +2075,8 @@ "Keep stress variants non-blocking until stable runtime history exists." ], "knownGaps": [ - "Current command asserts daemon stream write(false)/drain/cleanup at the batcher contract layer and main pending renderer caps at the IPC contract layer.", + "Daemon stream write(false)/drain/cleanup and bounded queued-tail contracts are not registered in the current command.", "Runtime provenance coverage is deterministic and does not include a live high-throughput provider artifact.", - "Serve-sim detector coverage is deterministic and does not include a live external simulator helper.", "Terminal-wait scale evidence is deterministic and does not yet include a live saturated-tail event-loop artifact.", "Current command does not prove renderer parse pressure, scheduler queue depth, event-loop delay, or active key latency.", "Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved." @@ -3833,6 +3876,7 @@ "layer": "runtime-rpc-contract", "surfaces": [ "mobile terminal", + "native-chat terminal overlay", "runtime RPC", "binary multiplex", "JSON subscribe fallback", @@ -3854,21 +3898,25 @@ "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.", + "coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. PR #5824 adds a platform-neutral mobile decision gate proving chat-covered terminal streams pause and resume only after the mounted WebView is ready; live Android restore evidence remains required. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", "https://github.com/stablyai/orca/pull/7009" ], - "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.", - "oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. JSON fallback parity/deprecation remains an explicit gap.", + "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes; a terminal covered by native chat must have no live output subscription and must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.", + "oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. The mobile native-chat decision test asserts an active stream pauses while covered and resumes only for a ready active terminal. JSON fallback parity and live Android scrollback restoration remain explicit gaps.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts", + "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts" ], "testFiles": [ "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", "src/main/runtime/rpc/terminal-output-batching.test.ts", - "src/main/runtime/rpc/terminal-multiplex.test.ts" + "src/main/runtime/rpc/terminal-multiplex.test.ts", + "mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts" ], "assertionRefs": [ { @@ -3893,6 +3941,21 @@ "oversized live output frames are bounded for subscribed binary streams", "multibyte live output flushes when encoded bytes reach the batch budget" ] + }, + { + "file": "mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "assertions": [ + "an active terminal stream pauses while native chat covers its WebView", + "the stream resumes only for the ready active terminal after chat closes", + "non-terminal and already-settled stream states perform no lifecycle work" + ] + }, + { + "file": "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts", + "assertions": [ + "a late WebView-ready transition converts a cold-start lease-only stream back to output", + "ordinary WebView readiness does not rerender the mobile session route" + ] } ], "evidenceRuns": [ @@ -3904,6 +3967,15 @@ "result": "passed", "durationSeconds": 3.8, "summary": "3 test file(s) passed, 28 tests passed on main@1282f5c2d in a clean checkout." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "result": "passed", + "durationSeconds": 0.2, + "summary": "The focused mobile native-chat suite passed with 3 terminal-stream lifecycle assertions in the staged PR #5824 worktree." } ], "runtimeBudget": { @@ -3928,7 +4000,7 @@ "Record byte counts and order/cursor artifacts on failure." ], "knownGaps": [ - "Current command covers binary runtime/mobile stream contracts only.", + "The pure mobile decision gate does not yet prove live Android WebView scrollback restore after a chat toggle.", "Legacy JSON subscribe parity is undecided." ], "demotionRule": "Cannot promote while a supported stream path has uncapped snapshot or live-output buffering." @@ -4290,63 +4362,200 @@ "remote-runtime", "ssh" ], - "coverageNotes": "Deterministic tests cover a four-child healthy runtime pool, a one-child healthy SSH relay pool, and at most four bounded quarantine children, with 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. The relay policy test maps a standard repository's base, Git common directory, and worktree roots to one healthy supervisor, then proves a shared-child failure recovers those roots in separate quarantine supervisors with overflow and resumed events. A built-entry macOS harness drives the real supervisor, kills its child with SIGSEGV, then requires automatic resubscription and a later event. SSH 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.", + "coverageNotes": "Deterministic tests cover one shared healthy child per runtime process plus at most four bounded fault-quarantine children, native desktop, paired-runtime, and WSL snapshot processes sharing the same eight-physical-child reservation with typed serialized event-driven capacity recovery, including recursive re-wait when crash recovery reclaims an announced slot, one quarantine attempt per watch lifetime, generation-scoped cancellation, bounded termination with removable deadline waiters, replacement-crawl errors that cannot be blessed by a late readiness ack, pre-ready paired-web cancellation registered before an unbounded capacity wait, already-resolved and late physical-exit retry of a rejected paired-web teardown, physical desktop-install and local/SSH PTY teardown, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. Destructive local and SSH removal fences both ID-derived and resolved-cwd terminal roots, including sibling-root/cwd combinations with reverse admission rollback, closes descendant relay watches before parent deletion, enumerates authoritative provider/cwd ownership, falls back to daemon spawn cwd before OSC 7, and keeps daemon/relay immediate shutdown pending until native exit or a bounded fail-closed error; a dead relay PID discovered during attach also settles concurrent shutdown before its stale entry is reaped, while Windows runtime watcher deadlines retain one removable physical-close waiter, treat Node's error-close path as positive physical-exit proof, and clear root ownership on late close. The relay policy test maps a standard repository's base, Git common directory, and worktree roots to one healthy supervisor, then proves a shared-child failure recovers those roots in separate quarantine supervisors with overflow and resumed events. Abortable capacity, quarantine, runtime-root, relay, SSH, and relay pre-install setup waits attach one reaction to each shared promise and explicitly remove 10,000 cancelled caller closures while one anchor remains. Built-entry macOS harnesses kill the desktop/runtime and SSH relay watcher children, then require automatic resubscription and later events while the host process and relay PTY survive. SSH bundles the child boundary beside relay.js, requires both artifacts for install completeness, and preserves registration-owned same-root cancellation.", "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 roots use at most four shared watcher children while healthy, while each SSH relay process uses at most one; either pool uses 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 each SSH target's relay, PTYs, Git, and other filesystem operations remain isolated from other targets and never load the watcher native addon or share its fault boundary.", - "oracle": "Fill the four-child healthy runtime pool with multiple roots, and map a standard SSH repository's base, Git common directory, and worktree roots to one relay supervisor. 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.", + "invariant": "Local runtime and SSH relay roots share one watcher child per host process while healthy and use at most four quarantine children after shard failure; native desktop, paired-runtime, and direct WSL snapshot children consume the same process-wide eight-child reservation and retry transient exhaustion without permanently disabling a root. Cancellation remains owned by its child generation and settles only after acknowledgement or bounded physical exit; paired-web retains cleanup ownership and can cancel before readiness even while capacity is exhausted, retries a failed unwatch after already-resolved or late physical exit without duplicating stream termination, replacements never overlap a still-retiring generation, and aborted or timed-out callers detach immediately even while queued behind same-root relay teardown. Destructive local and SSH cleanup holds one containment-aware watcher/terminal removal fence through descendant watcher and PTY physical exit plus parent Git/filesystem deletion; daemon and relay kill requests retain ownership until the actual subprocess exit callback or an equivalent positive dead-PID proof and propagate rejected signals or a bounded failure otherwise. A crash-fused shared shard moves affected roots into the bounded quarantine pool, a second fused quarantine ends those watch lifetimes, interrupted setup emits overflow after recovery, metadata work 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 global watcher-child reservation, require native desktop, paired-runtime, and WSL setup to retain typed transient capacity and retry after one serialized release, then cancel pending desktop and paired-client watches and require no retry. Hold one live anchor while aborting 10,000 callers at the capacity, quarantine-assignment, runtime-root, relay-setup, and SSH-registration layers; require exactly one explicit waiter and one base-promise reaction to remain, then zero after settlement. Hold an old relay unsubscribe, queue one same-root setup leader and one anchor, then abort 10,000 joiners; require every cancellation to settle before teardown releases, exactly one anchor waiter, and only one later physical install. Advance the former paired-web callback deadline while capacity remains full, require cancellation ownership to stay live, publish the server token before ready, and require files.unwatch to abort setup before a later slot release can install a native root. Reject the first ready-watch unwatch with a physical-exit promise, resolve that exit later, and require a fresh physical unwatch attempt, registry release, and exactly one stream end. Fill one healthy shared child with multiple roots, map a standard SSH repository's base, Git common directory, and worktree roots to one relay supervisor, then inject IPC disconnect-before-exit, queued and active cancellation, operations during termination, kill-without-exit, repeated unkillable generations, initial and recovery timeouts, and shard failure. During local and SSH deletion, attempt a late terminal spawn whose worktree identity and cwd name different roots, enumerate a non-prefix remote PTY by authoritative worktree ownership, withhold daemon/relay exit callbacks, make attach positively prove a concurrent-shutdown PID dead, and make the provider unavailable; require every acquired admission to roll back on rejection, dead-PID proof to settle physical exit before reaping, timeout to retain ownership, and Git/filesystem deletion never to start. Require bounded watcher process count, physical-exit settlement, healthy-root recovery, stale-generation rejection, overflow plus later events, and fail-closed destructive cleanup. 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 exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/files-watch-cleanup.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-pending-setup-waiters.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-parent-removal.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.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/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.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/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.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/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.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 src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.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/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.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 src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.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/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.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 src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.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/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.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 src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.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/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.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 src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts", "pnpm run build:electron-vite", + "node config/scripts/runtime-file-watcher-resource-probe.mjs", "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 exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-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 exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/git-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/daemon/daemon-pty-adapter.test.ts", + "src/main/ipc/filesystem-watcher.test.ts", + "src/main/ipc/filesystem-watcher-native-capacity.test.ts", + "src/main/ipc/parcel-watcher-child-registry.test.ts", + "src/main/ipc/parcel-watcher-child-launch.test.ts", "src/main/ipc/parcel-watcher-process.test.ts", + "src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts", + "src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts", + "src/main/ipc/parcel-watcher-supervisor-capacity.test.ts", + "src/main/ipc/parcel-watcher-disconnect-termination.test.ts", + "src/main/ipc/runtime-watcher-pending-assignment.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/ipc/worktrees.test.ts", + "src/main/ipc/watcher-removal-gate.test.ts", + "src/main/ipc/pty.test.ts", + "src/main/providers/local-pty-provider.test.ts", + "src/main/daemon/pty-subprocess.test.ts", + "src/main/daemon/session.test.ts", + "src/main/daemon/terminal-host.test.ts", + "src/main/daemon/terminal-host-session-reaping-leak.test.ts", "src/main/runtime/file-watcher-host.test.ts", + "src/main/runtime/orca-runtime-files-watch.test.ts", + "src/main/runtime/worktree-teardown.test.ts", + "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/rpc/methods/files.test.ts", + "src/main/runtime/rpc/methods/files-watch-cancellation.test.ts", + "src/main/runtime/rpc/methods/files-watch-cleanup.test.ts", "src/main/providers/ssh-filesystem-provider.test.ts", + "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts", + "src/main/providers/ssh-pty-provider.test.ts", "src/renderer/src/runtime/runtime-file-client.test.ts", + "src/renderer/src/web/web-runtime-client.test.ts", + "src/shared/cross-platform-path.test.ts", + "src/shared/physical-exit-tracker.test.ts", + "src/shared/promise-settlement-waiters.test.ts", "config/scripts/runtime-file-watcher-fault-harness.mjs", + "config/scripts/runtime-file-watcher-resource-probe.mjs", "src/relay/relay-filesystem-watch-registry.test.ts", + "src/relay/relay-watcher-pending-setup-waiters.test.ts", + "src/relay/relay-watcher-parent-removal.test.ts", + "src/relay/relay-watcher-setup-wait.test.ts", "src/relay/fs-handler.test.ts", + "src/relay/pty-handler.test.ts", + "src/relay/git-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/filesystem-watcher.test.ts", + "assertions": [ + "a transient WSL child-capacity rejection automatically retries on release and renderer unwatch cancels the wait", + "failed SSH worktree removal restores every live renderer listener, emits overflow, and resumes precise events" + ] + }, + { + "file": "src/main/ipc/filesystem-watcher-native-capacity.test.ts", + "assertions": [ + "typed native child-capacity exhaustion automatically retries after a serialized slot release", + "renderer unwatch cancels a pending native capacity retry before subscription restarts" + ] + }, + { + "file": "src/main/ipc/parcel-watcher-child-registry.test.ts", + "assertions": [ + "capacity notification closes the release-before-listen race, supports cancellation, fires at most once, and serializes reserving waiters per released slot" + ] + }, + { + "file": "src/main/ipc/parcel-watcher-child-launch.test.ts", + "assertions": [ + "native launcher reports typed transient capacity exhaustion without forking another child" + ] + }, + { + "file": "src/shared/physical-exit-tracker.test.ts", + "assertions": [ + "timed-out physical-exit waiters detach while active and later retries still settle on exact exit" + ] + }, + { + "file": "src/shared/promise-settlement-waiters.test.ts", + "assertions": [ + "one shared-promise reaction serves an anchor plus 10,000 abortable callers, and all cancelled closures detach immediately", + "timeout and already-aborted paths remove timers, listeners, and ownership while preserving the active anchor" + ] + }, + { + "file": "src/main/daemon/daemon-pty-adapter.test.ts", + "assertions": [ + "daemon process enumeration falls back to spawn cwd before OSC 7 reports a live cwd", + "force-killed keep-history sleep remains cold-restorable across repeated wake cycles after physical exit" + ] + }, { "file": "src/main/ipc/parcel-watcher-process.test.ts", "assertions": [ "initial-subscribe crashes complete with an interruption refresh", + "watch errors before readiness reject setup and physically cancel the native crawl", + "watch errors during replacement crawl terminate recovery and a late readiness ack cannot revive the failed root", "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", + "a crash-resubscribe crawl deadline and concurrent unsubscribe settle only after physical shard exit, or report physical-exit failure at the termination deadline", + "a close-only asynchronous spawn failure settles physical termination without waiting for the exit deadline", + "production runtime-pool disposal rejects later subscriptions without forking an overlapping child", + "Vitest cleanup resets rather than re-disposes the reusable runtime pool", + "native child launch preserves typed transient capacity exhaustion instead of reporting permanent process unavailability", "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/parcel-watcher-unsubscribe-timeout.test.ts", + "assertions": [ + "a canary-independent native-unsubscribe deadline restarts the shard and restores healthy siblings after physical child exit", + "an unkillable child rejects the native unsubscribe with its retained physical-exit promise only at the bounded exit deadline" + ] + }, + { + "file": "src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts", + "assertions": [ + "10,000 cancelled capacity callers leave exactly one live anchor and zero waiters after the released slot settles" + ] + }, + { + "file": "src/main/ipc/parcel-watcher-supervisor-capacity.test.ts", + "assertions": [ + "one released physical slot launches one shared supervisor child for every waiting root, while partial/all cancellation and disposal remove the shared capacity barrier", + "a waiter recursively re-enters the event-driven barrier when crash recovery reclaims its announced slot" + ] + }, + { + "file": "src/main/ipc/parcel-watcher-disconnect-termination.test.ts", + "assertions": [ + "pending-crawl cancellation after disconnect remains pending until the exact child exits", + "ready and pending-crawl cancellation force disconnected-child exit even while a healthy sibling root remains", + "a cancellation deadline joins active disconnect termination without duplicate sibling resubscription", + "an exit-deadline failure replaces the early cancellation and remains visible to synchronous unsubscribe until physical exit" + ] + }, + { + "file": "src/main/ipc/runtime-watcher-pending-assignment.test.ts", + "assertions": [ + "10,000 cancelled quarantine-assignment callers leave exactly one live anchor and zero waiters after grant", + "an already-aborted first caller synchronously releases pending assignment ownership" + ] + }, { "file": "src/main/ipc/runtime-watcher-process-pool.test.ts", "assertions": [ - "healthy roots spread across at most four shared supervisors before sharing", + "healthy roots share one supervisor until a fault requires quarantine", "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" + "an older same-root teardown cannot erase a newer watch lease's assignment", + "test reset explicitly reopens the production-disposed pool", + "a zero-waiter aborted assignment cannot poison a same-turn replacement subscribe" ] }, { @@ -4371,15 +4580,126 @@ "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" + "app shutdown physically aborts pending native setup and releases a late native handle", + "a cancelled pending install retains physical-exit failure across an immediate second destructive close", + "desktop terminal failure remains root-owned even when the cleared subscription later unsubscribes cleanly", + "Windows drive and UNC watcher ownership is found across path casing and separator variants", + "Windows event payloads retain the physical root spelling instead of the comparison key", + "failed local worktree removal restores every live renderer listener and emits overflow" + ] + }, + { + "file": "src/main/ipc/filesystem-watcher-wsl.test.ts", + "assertions": [ + "unsubscribe and startup abort remain pending until the WSL watcher process physically closes", + "a termination timeout or rejected kill retains the WSL watcher owner for later physical-exit proof", + "WSL watcher processes consume the shared eight-child reservation until physical close, including synchronous startup-write cleanup" + ] + }, + { + "file": "src/main/ipc/worktrees.test.ts", + "assertions": [ + "desktop IPC deletion never starts Git removal when physical PTY teardown cannot be proven", + "normal and orphan/leftover deletion paths perform strict PTY teardown before local or SSH Git/filesystem removal", + "SSH deletion fails closed before Git when the remote PTY provider is unavailable", + "failed IPC deletion finishes the watcher fence as aborted so logical subscriptions can recover" + ] + }, + { + "file": "src/main/ipc/watcher-removal-gate.test.ts", + "assertions": [ + "Windows-equivalent spellings share one removal fence while distinct POSIX backslash roots do not", + "desktop and runtime removal entry points cannot own overlapping destructive roots concurrently", + "parent removal waits descendant terminal spawns and rejects later descendant installs" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "renderer PTY spawn is rejected before provider creation while destructive worktree removal holds the shared terminal gate", + "renderer and runtime spawns fence both the worktree-ID root and resolved cwd, including cross-worktree cwd ownership with rollback", + "manual and runtime-controller local PTY shutdown retain listener and provider ownership until physical exit", + "did-finish-load orphan cleanup disposes data delivery but retains the onExit listener and tracker through physical exit", + "a failed local PTY kill retains retryable provider ownership until a later physical exit" + ] + }, + { + "file": "src/main/providers/local-pty-provider.test.ts", + "assertions": [ + "local shutdown remains pending and owned until node-pty reports physical exit", + "local process enumeration retains the spawn cwd for destructive ownership fallback", + "destructive cleanup escalates an existing graceful shutdown without losing its shared exit proof", + "POSIX graceful shutdown escalates to one bounded SIGKILL when the child ignores SIGTERM", + "Windows graceful-to-destructive cleanup treats the first ConPTY kill as final and never double-closes the handle", + "orphan cleanup racing immediate shutdown shares one kill and remains owned until the queued physical exit", + "timeout and kill failure preserve ownership for a later successful retry" + ] + }, + { + "file": "src/main/daemon/pty-subprocess.test.ts", + "assertions": [ + "rejected graceful and force kills propagate without marking the wrapper dead so retries can signal the same child" + ] + }, + { + "file": "src/main/daemon/session.test.ts", + "assertions": [ + "graceful daemon termination force-kills after its deadline but retains session ownership and late output until physical exit", + "rejected graceful and force kills reopen termination state for a later retry" + ] + }, + { + "file": "src/main/daemon/terminal-host.test.ts", + "assertions": [ + "immediate daemon kill remains pending and listed until the subprocess exit callback", + "an unconfirmed immediate kill rejects at the bounded deadline, retains ownership, and blocks same-id replacement" + ] + }, + { + "file": "src/main/daemon/terminal-host-session-reaping-leak.test.ts", + "assertions": [ + "immediate daemon kill reaps the emulator and session only after physical exit" ] }, { "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", + "concurrent pre-ready cancellations join one native root release before an immediate replacement subscribes", + "10,000 cancelled runtime-root callers detach while one setup anchor remains", + "root-scoped destructive cleanup joins an in-progress subscriber teardown", "shard failure preserves existing subscribers through isolated recovery and rejects stale-generation events", - "only failed isolated recovery ends subscribers, after a conservative overflow" + "only failed isolated recovery ends subscribers, after a conservative overflow", + "initial setup failure remains discoverable by root-scoped destructive cleanup until physical exit" + ] + }, + { + "file": "src/main/runtime/orca-runtime-files-watch.test.ts", + "assertions": [ + "Windows runtime cleanup waits for the native close event, fails closed at a bounded deadline, and clears retained ownership on late close without retry listeners", + "a Windows watcher error before cleanup uses Node's proven native error-close as physical-exit proof, emits final overflow and terminal error, and releases deletion ownership", + "pre-publication local runtime setup failure remains owned through destructive cleanup", + "failed deletion re-arms runtime watches immediately after ordinary teardown or after delayed physical exit, without reviving a logically stopped watch" + ] + }, + { + "file": "src/main/runtime/worktree-teardown.test.ts", + "assertions": [ + "overlapping runtime, provider, and registry sweeps stay behind one physical PTY-stop promise", + "destructive teardown rejects instead of releasing deletion when physical stop misses its deadline", + "cwd-owned and floating PTYs are swept while legacy rows without cwd retain prefix ownership", + "remote provider enumeration matches authoritative worktree ownership without touching the local-only registry" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "destructive worktree teardown awaits controller stopAndWait instead of treating fire-and-forget kill as physical exit", + "runtime worktree deletion never starts Git removal when a known PTY cannot be physically stopped", + "SSH runtime deletion shuts down an authoritatively owned remote PTY before remote Git removal", + "a delayed physical-exit retry remains scoped to its registered cleanup owner and cannot tear down a same-id replacement", + "failed CLI worktree deletion restores watcher ownership after releasing the install fence", + "cross-layer teardown failure joins every bounded watcher close before restoration begins" ] }, { @@ -4389,12 +4709,43 @@ "terminal watcher loss flushes a final overflow change before error/end and unregisters cleanup" ] }, + { + "file": "src/main/runtime/rpc/methods/files-watch-cancellation.test.ts", + "assertions": [ + "the server publishes a cleanup token before ready and files.unwatch aborts pending setup without delivering queued crawl events" + ] + }, + { + "file": "src/main/runtime/rpc/methods/files-watch-cleanup.test.ts", + "assertions": [ + "a failed paired-web unwatch retries after already-resolved or late physical exit and emits stream end exactly once" + ] + }, + { + "file": "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts", + "assertions": [ + "10,000 cancelled SSH callers detach while one registration-owned setup anchor remains" + ] + }, { "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" + "provider disposal cancels pending setup and sends one remote unwatch", + "equivalent Windows spellings share one provider generation and terminal failure reaches the surviving owner" + ] + }, + { + "file": "src/main/providers/ssh-pty-provider.test.ts", + "assertions": [ + "remote process enumeration preserves authoritative worktree ownership while scoping relay PTY ids to the SSH connection" + ] + }, + { + "file": "src/shared/cross-platform-path.test.ts", + "assertions": [ + "comparison keys preserve literal POSIX backslashes while folding Windows drive and UNC spellings" ] }, { @@ -4406,6 +4757,25 @@ "a later same-root listener opens a fresh server subscription instead of reusing a dead stream" ] }, + { + "file": "src/relay/relay-watcher-parent-removal.test.ts", + "assertions": [ + "POSIX and Windows parent deletion closes and terminally notifies descendant watches before removal while preserving path siblings", + "parent deletion waits for descendant setup publication and already-retiring descendant teardown before removal" + ] + }, + { + "file": "src/relay/relay-watcher-pending-setup-waiters.test.ts", + "assertions": [ + "10,000 aborted relay setup callers detach while one same-root teardown anchor remains and only one replacement installs" + ] + }, + { + "file": "src/relay/relay-watcher-setup-wait.test.ts", + "assertions": [ + "10,000 cancelled relay setup callers leave exactly one explicit anchor and one base-promise reaction" + ] + }, { "file": "src/relay/relay-filesystem-watch-registry.test.ts", "assertions": [ @@ -4414,19 +4784,48 @@ "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", + "relay-side removal sends every client a terminal failure, closes every watch, and fences new same-root watcher or PTY setup even when deletion fails", "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" + "shared watches retain the twenty-root budget and release after every client leaves", + "recursive relay deletion closes and terminally notifies same-root watches before removing the directory" + ] + }, + { + "file": "src/relay/pty-handler.test.ts", + "assertions": [ + "immediate relay shutdown stays pending with the PTY map entry retained until onExit", + "a missing exit rejects at the bounded deadline, preserves the physical owner, and a retry joins without duplicate SIGKILL", + "Windows relay graceful, fallback, immediate, stale-spawn, exit-dispose, and handler-dispose paths issue at most one ConPTY kill", + "relay PTY admission succeeds when post-Node-18 array copy methods are unavailable", + "attach dead-PID proof settles a concurrent immediate shutdown before the stale PTY entry is reaped", + "concurrent spawn and revive admission is atomic, capped at fifty physical PTYs, and same-id revive is deduplicated", + "worktree removal waits explicit and default-cwd PTY creation, rejects later creation, and physically stops only authoritative matching PTYs before deletion", + "relay spawn and revive fence distinct worktree-ID and cwd roots, rolling back earlier admissions in reverse when either root is removing" + ] + }, + { + "file": "src/renderer/src/web/web-runtime-client.test.ts", + "assertions": [ + "a stopped pre-ready paired-web watch retains its callback beyond the former deadline and uses the early server token to unwatch", + "transient shared-socket loss replays an active file watch under a new transport id while preserving its logical handle and never revives a stopped watch" + ] + }, + { + "file": "src/relay/git-handler.test.ts", + "assertions": [ + "remote Git worktree removal executes inside the relay filesystem watcher fence" ] }, { "file": "src/relay/subprocess.test.ts", "assertions": [ - "the real relay bundle serves PTY, filesystem, and Git requests with its adjacent watcher child" + "the real relay bundle serves PTY, filesystem, and Git requests with its adjacent watcher child", + "the Node-18-targeted relay bundle contains no unsupported array copy methods" ] }, { @@ -4441,6 +4840,15 @@ "every release platform packages and gates the hashed watcher child" ] }, + { + "file": "config/scripts/runtime-file-watcher-resource-probe.mjs", + "assertions": [ + "five healthy roots share one exact watcher PID within the per-child RSS budget", + "four quarantine roots use exactly four child PIDs and remain within the global eight-child reservation cap", + "CPU and RSS are sampled for every exact child PID and every PID exits during cleanup", + "per-child and aggregate RSS and sampled CPU ceilings fail the probe before the physical child cap can hide excess resource use" + ] + }, { "file": "config/scripts/runtime-file-watcher-fault-harness.mjs", "assertions": [ @@ -4459,13 +4867,202 @@ ], "evidenceRuns": [ { - "date": "2026-07-12", + "date": "2026-07-13", "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", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.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/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 4.5, - "summary": "Five relay registry, handler, bundled subprocess, install, and package contract files passed with 114 tests." + "durationSeconds": 11.98, + "summary": "Thirty-nine watcher, cancellation, desktop/serve, paired-web, WSL, SSH/relay, removal, and PTY physical-exit files passed with 2,008 tests. Capacity, quarantine, runtime-root, relay, and SSH setup layers each prove that 10,000 aborted callers detach while one anchor remains and zero waiters remain after settlement." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.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/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 11.59, + "summary": "Thirty-four watcher, cancellation, desktop/serve, paired-web, WSL, removal, PTY physical-exit, provider, renderer, and path files passed with 1,999 tests; paired-web cancellation now owns a server token before ready and aborts pending setup without a late native install." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-resource-probe.mjs", + "result": "passed", + "durationSeconds": 0.58, + "summary": "Five healthy roots shared one child at 50,688 KiB RSS and 4.9% sampled CPU; four quarantine children totaled 202,368 KiB RSS and 29.5% sampled CPU, all budgets and the eight-child cap passed, and exact-PID cleanup was true." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.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/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.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 src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 10.01, + "summary": "Twenty-seven watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,886 tests. The suite includes nested removal fencing, renderer/runtime cwd-only admission, legacy provider rows, rejected kill retries, transient WSL capacity, and synchronous-startup reservation cleanup." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-resource-probe.mjs", + "result": "passed", + "durationSeconds": 0.56, + "summary": "Five healthy roots shared one child at 50,176 KiB RSS and 6.0% sampled CPU; four quarantine children totaled 200,112 KiB RSS and 18.0% sampled CPU. The global eight-child cap and all per-child/aggregate budgets passed, and exact-PID cleanup was true." + }, + { + "date": "2026-07-13", + "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/git-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": 10.01, + "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 226 tests." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run build:electron-vite", + "result": "passed", + "durationSeconds": 24.41, + "summary": "The production Electron main, preload, renderer, daemon, and watcher child entries built successfully." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.1, + "summary": "The Node host survived watcher-child SIGSEGV, replaced and resubscribed the child, and delivered a post-crash event." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.46, + "summary": "The Electron-as-Node host survived watcher-child SIGSEGV, replaced and resubscribed the child, and delivered a post-crash event." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run build:relay", + "result": "passed", + "durationSeconds": 0.38, + "summary": "Relay and adjacent watcher artifacts built for Linux, macOS, and Windows on x64 and arm64, plus the WSL hook relay." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/relay-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.12, + "summary": "The relay and existing PTY survived watcher-child SIGSEGV; replacement delivered overflow and a precise post-crash event." + }, + { + "date": "2026-07-13", + "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/parcel-watcher-disconnect-termination.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/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.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 src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts", + "result": "passed", + "durationSeconds": 9.12, + "summary": "Twenty-five watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,762 tests. The suite covers operations joining in-flight termination, kill failures retaining ownership, bounded timeouts, atomic relay spawn/revive caps, and PTY-removal admission." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-resource-probe.mjs", + "result": "passed", + "durationSeconds": 0.56, + "summary": "Five healthy roots shared one child at 50,096 KiB RSS and 3.4% sampled CPU; four quarantine children totaled 200,576 KiB RSS and 25.0% sampled CPU. The eight-child physical cap and every per-child/aggregate CPU and RSS budget passed, and exact-PID cleanup was true." + }, + { + "date": "2026-07-13", + "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/git-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": 10.69, + "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 226 tests, including watcher/PTY removal fencing and recursive deletion failure behavior." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run build:electron-vite", + "result": "passed", + "durationSeconds": 24, + "summary": "The production Electron main, preload, renderer, daemon, and watcher child entries built successfully." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.01, + "summary": "The Node host survived watcher-child SIGSEGV, replaced and resubscribed the child, and delivered a post-crash event." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.46, + "summary": "The Electron-as-Node host survived watcher-child SIGSEGV, replaced and resubscribed the child, and delivered a post-crash event." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run build:relay", + "result": "passed", + "durationSeconds": 0.39, + "summary": "Relay and adjacent watcher artifacts built for Linux, macOS, and Windows on x64 and arm64, plus the WSL hook relay." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/relay-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.12, + "summary": "The relay and existing PTY survived watcher-child SIGSEGV; replacement delivered overflow and a precise post-crash event." + }, + { + "date": "2026-07-13", + "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/parcel-watcher-disconnect-termination.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/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.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 src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler.test.ts", + "result": "passed", + "durationSeconds": 9.03, + "summary": "Twenty-three watcher, desktop/serve removal, terminal-spawn gate, daemon/SSH physical PTY exit, provider ownership, RPC, renderer, and cross-platform path files passed with 1,630 tests. The suite includes same-turn watcher replacement, authoritative SSH teardown before Git, bounded kill-without-exit failures that retain ownership, and local/SSH deletion fail-closed behavior." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/runtime-file-watcher-resource-probe.mjs", + "result": "passed", + "durationSeconds": 0.55, + "summary": "Five healthy roots shared one child at 50,160 KiB RSS and 5.6% sampled CPU; four quarantine children totaled 199,968 KiB RSS and 16.7% sampled CPU, hard per-child and aggregate RSS/CPU budgets passed, the eight-child reservation cap rejected the next launch, and every exact PID exited during cleanup." + }, + { + "date": "2026-07-13", + "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/git-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": 9.79, + "summary": "Six relay registry, filesystem/Git handler, bundled subprocess, install, and package contract files passed with 225 tests, including terminal removal notification and recursive deletion fencing." }, { "date": "2026-07-12", @@ -4477,13 +5074,13 @@ "summary": "The relay PID and existing PTY survived watcher-child SIGSEGV; overflow and precise events resumed from a replacement child." }, { - "date": "2026-07-12", + "date": "2026-07-13", "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", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.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/ipc/worktrees.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.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 src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.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." + "durationSeconds": 8.85, + "summary": "18 focused supervisor, disconnect termination, pool, child-entry, delivery, desktop/WSL cleanup and IPC removal, removal-gate, runtime-host/commands and physical PTY teardown, RPC, SSH ownership, renderer/paired-web clients, and cross-platform path files passed with 1,193 tests." }, { "date": "2026-07-12", @@ -4527,7 +5124,7 @@ }, "performanceBudget": { "required": true, - "evidence": "Local runtime operation remains capped at four shared child processes, while each SSH relay is capped at one healthy child instead of spreading a standard repository's three roots across three children. Based on the measured 51.9 MiB per child, normal SSH watcher overhead is bounded near 51.9 MiB rather than 155.7 MiB before relay and PTY overhead. 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 for local runtime; 8 healthy or 40 total per SSH relay), each subscription retains at most one active plus one bounded pending batch, and no polling or serve-process stat fanout is added." + "evidence": "The committed resource probe reproducibly measures five healthy roots sharing one child (50,160 KiB RSS, 5.6% sampled CPU) and four quarantine children (199,968 KiB total RSS, 16.7% sampled CPU), enforces 128 MiB/50% per-child and 512 MiB/100% quarantine aggregate budgets, derives a 1 GiB/400% ceiling across the global eight-physical-child cap, and waits for every exact PID during cleanup. A relay policy test separately proves a standard SSH repository's three roots share one healthy child. Shared pending setup attaches one base-promise reaction per generation; quantitative owner tests abort 10,000 callers at each affected layer and require exactly one anchor, then zero explicit waiters after settlement. The shared supervisor capacity barrier is event-driven, one-shot, abortable, and serialized per released physical slot, waking every same-child desktop, paired-runtime, or SSH relay root without consuming crash-recovery attempts; direct WSL capacity retains the same event-driven desktop retry. Relay PTY admission adds at most two deduplicated path checks per spawn/revive and releases both without polling or retained listeners, while dead-PID reaping settles an existing tracker without allocating another timer. Fault quarantine 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 eight healthy or 32 quarantined operations), each subscription retains at most one active plus one bounded pending batch, and no additional 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.", @@ -4536,11 +5133,11 @@ ], "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 one-child SSH 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.", + "The committed resource probe covers the one-child healthy pool, four-child fault-quarantine ceiling, RSS/CPU sampling, global physical-child cap, and exact-PID cleanup, but 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." + "Windows native runtime watches bypass this child path; WSL reservation/release is deterministic-contract tested but not live fault-injected, and SSH registration ownership is 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 local runtime watcher children or one child in any SSH relay, if total healthy plus quarantine operation exceeds eight locally or five in an SSH relay, 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." + "demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds one runtime watcher child, if total physical operation exceeds eight children including retiring generations, 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." } ] } diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 4af5a68dd5a..0d14e2373b6 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -4,9 +4,14 @@ import { dirname, join, resolve } from 'node:path' import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' -const itWindows = process.platform === 'win32' ? it : it.skip const itCrossHost = process.platform === 'win32' ? it.skip : it const projectRoot = resolve(import.meta.dirname, '../..') +// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows; +// keep the larger allowance scoped to the real compiler integration test. +function itWindows(name, test) { + const runner = process.platform === 'win32' ? it : it.skip + runner(name, { timeout: 15_000 }, test) +} describe('Windows CLI launcher', () => { itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => { diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 7d4fd9af56b..d5671498468 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -29,7 +29,9 @@ describe('electron-builder config', () => { '!native{,/**/*}', '!skills{,/**/*}', '!skill-guides{,/**/*}', + '!resources/skills/**', '!tests{,/**/*}', + '!pr-evidence{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', '!out/**/*.test.js' @@ -38,6 +40,12 @@ describe('electron-builder config', () => { }) it('keeps runtime resources available through extraResources', () => { + for (const platform of ['mac', 'linux', 'win']) { + expect(electronBuilderConfig[platform].extraResources).toContainEqual({ + from: 'resources/skills', + to: 'skills' + }) + } expect(electronBuilderConfig.mac.extraResources).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs new file mode 100644 index 00000000000..c3542d6f0e2 --- /dev/null +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -0,0 +1,522 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { constants } from 'node:fs' +import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { isDeepStrictEqual } from 'node:util' + +const SCHEMA_VERSION = 1 +const SCRIPT_DIR = import.meta.dirname +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..') +const SKILLS_ROOT = path.join(REPO_ROOT, 'skills') +const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills') +const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json') +const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json') +const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json') + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function compareCodeUnits(left, right) { + return left === right ? 0 : left < right ? -1 : 1 +} + +function gitObjectSha(kind, bytes) { + return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest() +} + +function normalizeText(bytes) { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8') +} + +function classifyFile(bytes) { + if (bytes.includes(0)) { + return 'binary' + } + try { + normalizeText(bytes) + return 'text' + } catch { + return 'binary' + } +} + +function assertSafeRelativePath(relativePath) { + if ( + path.isAbsolute(relativePath) || + relativePath === '..' || + relativePath.startsWith(`..${path.sep}`) + ) { + throw new Error(`Unsafe skill package path: ${relativePath}`) + } +} + +function describeFile(manifestPath, bytes, executable) { + const classification = classifyFile(bytes) + const exactSha256 = sha256(bytes) + const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null + return { + path: manifestPath, + size: bytes.length, + executable, + classification, + exactSha256, + textNormalizedSha256, + identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256, + gitBlobSha: gitObjectSha('blob', bytes).toString('hex') + } +} + +function gitTreeSha(entries) { + const root = { directories: new Map(), files: [] } + for (const entry of entries) { + const parts = entry.path.split('/') + const filename = parts.pop() + let directory = root + for (const part of parts) { + let child = directory.directories.get(part) + if (!child) { + child = { directories: new Map(), files: [] } + directory.directories.set(part, child) + } + directory = child + } + directory.files.push({ filename, ...entry }) + } + + function hashDirectory(directory) { + const children = [ + ...[...directory.directories].map(([name, child]) => ({ + mode: '40000', + name, + hash: hashDirectory(child) + })), + ...directory.files.map((file) => ({ + mode: file.executable ? '100755' : '100644', + name: file.filename, + hash: Buffer.from(file.gitBlobSha, 'hex') + })) + ].sort((left, right) => { + const leftName = left.mode === '40000' ? `${left.name}/` : left.name + const rightName = right.mode === '40000' ? `${right.name}/` : right.name + return Buffer.from(leftName).compare(Buffer.from(rightName)) + }) + const body = Buffer.concat( + children.map(({ mode, name, hash }) => + Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash]) + ) + ) + return gitObjectSha('tree', body) + } + + return hashDirectory(root).toString('hex') +} + +async function collectPackageFiles(packageRoot) { + const files = [] + const caseFoldedPaths = new Map() + + async function visit(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + // Why: build-time Node and packaged Electron may ship different ICU data; + // package identity order must use the same locale-independent comparison. + entries.sort((left, right) => compareCodeUnits(left.name, right.name)) + for (const entry of entries) { + const absolutePath = path.join(directory, entry.name) + const relativePath = path.relative(packageRoot, absolutePath) + assertSafeRelativePath(relativePath) + const manifestPath = relativePath.split(path.sep).join('/') + const foldedPath = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(foldedPath) + if (collision && collision !== manifestPath) { + throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`) + } + caseFoldedPaths.set(foldedPath, manifestPath) + const fileStat = await lstat(absolutePath) + if (fileStat.isSymbolicLink()) { + throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`) + } + if (fileStat.isDirectory()) { + await visit(absolutePath) + continue + } + if (!fileStat.isFile()) { + throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`) + } + // Why: Windows observation cannot see execute bits, so an executable file in + // a shipped skill would misclassify every pristine Windows install as unrecognized. + if ((fileStat.mode & 0o111) !== 0) { + throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`) + } + files.push(describeFile(manifestPath, await readFile(absolutePath), false)) + } + } + + await visit(packageRoot) + return sortManifestFiles(files) +} + +function collectGitSkillTreeEntries(treeSha) { + const output = execFileSync('git', ['ls-tree', '-r', '-z', treeSha]) + .toString('utf8') + .split('\0') + .filter(Boolean) + const packages = new Map() + for (const line of output) { + const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line) + if (!match) { + throw new Error(`Unexpected git tree entry in ${treeSha}: ${line}`) + } + const [, mode, type, objectSha, sourcePath] = match + const separator = sourcePath.indexOf('/') + if (separator <= 0 || separator === sourcePath.length - 1) { + throw new Error(`Unsupported shipped skill path in ${treeSha}: ${sourcePath}`) + } + const name = sourcePath.slice(0, separator) + const manifestPath = sourcePath.slice(separator + 1) + const entries = packages.get(name) ?? [] + entries.push({ mode, type, objectSha, manifestPath }) + packages.set(name, entries) + } + return packages +} + +function readGitBlobs(objectShas) { + const uniqueShas = [...new Set(objectShas)] + if (uniqueShas.length === 0) { + return new Map() + } + // Why: released history spans hundreds of tags. Batch mode avoids a Git + // subprocess per historical file while remaining available on Git 2.25. + const output = execFileSync('git', ['cat-file', '--batch'], { + input: `${uniqueShas.join('\n')}\n`, + maxBuffer: 64 * 1024 * 1024 + }) + const blobs = new Map() + let offset = 0 + for (const requestedSha of uniqueShas) { + const headerEnd = output.indexOf(10, offset) + if (headerEnd < 0) { + throw new Error(`Missing git cat-file header for ${requestedSha}`) + } + const header = output.subarray(offset, headerEnd).toString('utf8') + const match = /^([a-f0-9]+) blob (\d+)$/.exec(header) + if (!match || match[1] !== requestedSha) { + throw new Error(`Unexpected git cat-file header for ${requestedSha}: ${header}`) + } + const size = Number(match[2]) + const contentStart = headerEnd + 1 + const contentEnd = contentStart + size + if (contentEnd >= output.length || output[contentEnd] !== 10) { + throw new Error(`Truncated git blob for ${requestedSha}`) + } + blobs.set(requestedSha, Buffer.from(output.subarray(contentStart, contentEnd))) + offset = contentEnd + 1 + } + return blobs +} + +function collectGitPackageFiles(treeSha, name, entries, blobs) { + const caseFoldedPaths = new Map() + const files = entries.map(({ mode, type, objectSha, manifestPath }) => { + if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) { + throw new Error(`Unsupported shipped skill entry in ${treeSha}: ${name}/${manifestPath}`) + } + assertSafeRelativePath(manifestPath) + const foldedPath = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(foldedPath) + if (collision && collision !== manifestPath) { + throw new Error(`Case-colliding skill paths in ${treeSha}: ${collision} and ${manifestPath}`) + } + caseFoldedPaths.set(foldedPath, manifestPath) + const bytes = blobs.get(objectSha) + if (!bytes) { + throw new Error(`Missing git blob ${objectSha} for ${name}/${manifestPath}`) + } + return describeFile(manifestPath, bytes, mode === '100755') + }) + // Why: git ls-tree emits git byte-order, not the canonical walk order. + return sortManifestFiles(files) +} + +// Why: snapshot matching compares files by array index, so every producer — +// working-tree walk, git history, and runtime observation — must emit one +// canonical order. This mirrors the sorted depth-first filesystem walk. +function compareManifestPaths(left, right) { + const leftParts = left.split('/') + const rightParts = right.split('/') + const shared = Math.min(leftParts.length, rightParts.length) + for (let index = 0; index < shared; index += 1) { + const order = compareCodeUnits(leftParts[index], rightParts[index]) + if (order !== 0) { + return order + } + } + return leftParts.length - rightParts.length +} + +function sortManifestFiles(files) { + return [...files].sort((left, right) => compareManifestPaths(left.path, right.path)) +} + +function packageDigest(files) { + return sha256( + Buffer.from( + JSON.stringify( + files.map((file) => ({ + path: file.path, + executable: file.executable, + classification: file.classification, + identitySha256: file.identitySha256 + })) + ), + 'utf8' + ) + ) +} + +function releaseTags() { + return execFileSync( + 'git', + ['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'], + { encoding: 'utf8' } + ) + .split('\n') + .filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag)) +} + +function skillsTreeShasAtRefs(refs) { + if (refs.length === 0) { + return [] + } + const output = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], { + input: `${refs.map((ref) => `${ref}:skills`).join('\n')}\n`, + encoding: 'utf8' + }) + const lines = output.trimEnd().split('\n') + if (lines.length !== refs.length) { + throw new Error(`Expected ${refs.length} skills tree identities, received ${lines.length}`) + } + return lines.map((line, index) => { + if (line.endsWith(' missing')) { + return null + } + const match = /^([a-f0-9]+) tree$/.exec(line) + if (!match) { + throw new Error(`Unexpected skills tree identity at ${refs[index]}: ${line}`) + } + return match[1] + }) +} + +function buildReleasedHistory() { + const registry = { schemaVersion: SCHEMA_VERSION, skills: {} } + const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] } + const tags = releaseTags() + const treeShas = skillsTreeShasAtRefs(tags) + const distinctTreeShas = [...new Set(treeShas.filter(Boolean))] + const packagesByTree = new Map( + distinctTreeShas.map((treeSha) => [treeSha, collectGitSkillTreeEntries(treeSha)]) + ) + const blobs = readGitBlobs( + [...packagesByTree.values()].flatMap((packages) => + [...packages.values()].flatMap((entries) => entries.map((entry) => entry.objectSha)) + ) + ) + let previousSkillsTreeSha = null + for (const [index, tag] of tags.entries()) { + const skillsTreeSha = treeShas[index] + if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) { + continue + } + previousSkillsTreeSha = skillsTreeSha + const revisions = {} + const packages = packagesByTree.get(skillsTreeSha) + if (!packages) { + throw new Error(`Missing released skill tree ${skillsTreeSha} at ${tag}`) + } + for (const name of [...packages.keys()].sort(compareCodeUnits)) { + const entries = packages.get(name) + const filesWithGitHashes = collectGitPackageFiles(skillsTreeSha, name, entries, blobs) + if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) { + continue + } + const digest = packageDigest(filesWithGitHashes) + const snapshots = registry.skills[name] ?? [] + const latest = snapshots.at(-1) + if (!latest || latest.packageDigest !== digest) { + const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file) + snapshots.push({ + releaseRevision: (latest?.releaseRevision ?? 0) + 1, + packageDigest: digest, + gitTreeSha: gitTreeSha(filesWithGitHashes), + files + }) + registry.skills[name] = snapshots + } + revisions[name] = snapshots.at(-1).releaseRevision + } + if (Object.keys(revisions).length > 0) { + mapping.releases.push({ appVersion: tag.slice(1), skills: revisions }) + } + } + return { registry, mapping } +} + +async function buildArtifacts(appVersion) { + const { registry, mapping } = buildReleasedHistory() + const releasedSnapshotCounts = Object.fromEntries( + Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length]) + ) + const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(compareCodeUnits) + const currentSkills = [] + for (const name of skillDirectories) { + const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name)) + if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) { + throw new Error(`Skill package ${name} has no top-level SKILL.md`) + } + const digest = packageDigest(filesWithGitHashes) + const snapshots = registry.skills[name] ?? [] + const latest = snapshots.at(-1) + let snapshot = latest + if (!latest || latest.packageDigest !== digest) { + const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file) + snapshot = { + releaseRevision: (latest?.releaseRevision ?? 0) + 1, + packageDigest: digest, + gitTreeSha: gitTreeSha(filesWithGitHashes), + files + } + snapshots.push(snapshot) + registry.skills[name] = snapshots + } + currentSkills.push({ + name, + sourcePath: `skills/${name}`, + appVersion, + ...snapshot + }) + } + return { + currentManifest: { + schemaVersion: SCHEMA_VERSION, + appVersion, + skills: currentSkills + }, + snapshotRegistry: registry, + releaseMapping: mapping, + releasedSnapshotCounts + } +} + +// Why: released snapshots are the detection ground truth for existing installs, +// so a generation-logic change must not rewrite them silently. Only the one +// unreleased working-tree append per skill may change between runs. +function assertReleasedHistoryPreserved(committedRegistry, artifacts) { + if (!committedRegistry || committedRegistry.schemaVersion !== SCHEMA_VERSION) { + return + } + for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) { + const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0 + const regenerated = artifacts.snapshotRegistry.skills[name] ?? [] + if (releasedCount < Math.max(0, committedSnapshots.length - 1)) { + throw new Error( + `Released snapshot history is incomplete for ${name}. ` + + 'Fetch all release tags before regenerating skill artifacts.' + ) + } + const protectedCount = Math.min(committedSnapshots.length, releasedCount) + for (let index = 0; index < protectedCount; index += 1) { + const committed = committedSnapshots[index] + const rebuilt = regenerated[index] + if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) { + throw new Error( + `Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` + + 'Released snapshots are append-only; a deliberate identity migration must update this check.' + ) + } + } + } +} + +async function readCommittedRegistry() { + try { + return JSON.parse(await readFile(SNAPSHOT_REGISTRY_PATH, 'utf8')) + } catch { + return null + } +} + +function serialized(value) { + return `${JSON.stringify(value, null, 2)}\n` +} + +async function writeArtifacts(artifacts) { + await mkdir(OUTPUT_ROOT, { recursive: true }) + await Promise.all([ + writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)), + writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)), + writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping)) + ]) +} + +async function verifyArtifacts(artifacts) { + const expected = [ + [CURRENT_MANIFEST_PATH, artifacts.currentManifest], + [SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry], + [RELEASE_MAPPING_PATH, artifacts.releaseMapping] + ] + const stale = [] + for (const [filePath, value] of expected) { + try { + await access(filePath, constants.R_OK) + if ((await readFile(filePath, 'utf8')) !== serialized(value)) { + stale.push(filePath) + } + } catch { + stale.push(filePath) + } + } + if (stale.length > 0) { + throw new Error( + `Generated skill artifacts are stale:\n${stale + .map((filePath) => path.relative(REPO_ROOT, filePath)) + .join('\n')}\nRun pnpm generate:skill-bundle-manifest.` + ) + } +} + +async function main() { + const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8')) + const artifacts = await buildArtifacts(packageJson.version) + assertReleasedHistoryPreserved(await readCommittedRegistry(), artifacts) + await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} + +export { + assertReleasedHistoryPreserved, + buildArtifacts, + buildReleasedHistory, + classifyFile, + collectPackageFiles, + describeFile, + gitTreeSha, + normalizeText, + packageDigest, + sortManifestFiles, + verifyArtifacts, + writeArtifacts +} diff --git a/config/scripts/generate-skill-bundle-manifest.test.mjs b/config/scripts/generate-skill-bundle-manifest.test.mjs new file mode 100644 index 00000000000..cf4a8a100e5 --- /dev/null +++ b/config/scripts/generate-skill-bundle-manifest.test.mjs @@ -0,0 +1,199 @@ +import { execFileSync } from 'node:child_process' +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + assertReleasedHistoryPreserved, + classifyFile, + collectPackageFiles, + describeFile, + gitTreeSha, + normalizeText, + packageDigest, + sortManifestFiles +} from './generate-skill-bundle-manifest.mjs' + +const temporaryDirectories = [] + +async function createPackage() { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('skill bundle manifest generator', () => { + it('folds platform line endings for text identity', () => { + const lf = Buffer.from('first\nsecond\n') + const crlf = Buffer.from('first\r\nsecond\r\n') + + expect(classifyFile(lf)).toBe('text') + expect(normalizeText(crlf)).toEqual(lf) + }) + + it('classifies null-containing and invalid UTF-8 content as binary', () => { + expect(classifyFile(Buffer.from([0, 1, 2]))).toBe('binary') + expect(classifyFile(Buffer.from([0xc3, 0x28]))).toBe('binary') + }) + + it('uses normalized text identity but exact executable identity', () => { + const skillFile = describeFile('SKILL.md', Buffer.from('line one\r\nline two\r\n'), false) + const executable = describeFile('run.sh', Buffer.from('#!/bin/sh\r\necho ok\r\n'), true) + + expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256) + expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256) + expect(executable.exactSha256).not.toBe(executable.textNormalizedSha256) + expect(executable.identitySha256).toBe(executable.exactSha256) + expect(packageDigest([skillFile, executable])).toMatch(/^[a-f0-9]{64}$/) + }) + + it('orders git-history files identically to the filesystem walk', async () => { + const packageRoot = await createPackage() + await mkdir(path.join(packageRoot, 'sub')) + for (const name of ['apple.md', 'sub.md', 'Zebra.md', path.join('sub', 'inner.txt')]) { + await writeFile(path.join(packageRoot, name), `${name}\n`) + } + const walked = await collectPackageFiles(packageRoot) + + // Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; index-based + // snapshot matching requires history and observation to share one order. + const gitOrdered = ['Zebra.md', 'apple.md', 'sub.md', 'sub/inner.txt'].map((manifestPath) => + walked.find((file) => file.path === manifestPath) + ) + + expect(sortManifestFiles(gitOrdered)).toEqual(walked) + expect(packageDigest(sortManifestFiles(gitOrdered))).toBe(packageDigest(walked)) + expect(walked.map((file) => file.path)).toEqual([ + 'Zebra.md', + 'apple.md', + 'sub/inner.txt', + 'sub.md' + ]) + }) + + it('rejects rewrites of released snapshots and allows floating-tail replacement', () => { + const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest }) + const artifacts = { + releasedSnapshotCounts: { 'orca-cli': 2 }, + snapshotRegistry: { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'ccc')] } + } + } + + expect(() => + assertReleasedHistoryPreserved( + { schemaVersion: 1, skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb')] } }, + artifacts + ) + ).not.toThrow() + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] } + }, + artifacts + ) + ).not.toThrow() + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'rewritten')] } + }, + artifacts + ) + ).toThrow('Released snapshot history changed for orca-cli at revision 2') + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { + 'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }] + } + }, + artifacts + ) + ).toThrow('Released snapshot history changed for orca-cli at revision 2') + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { + 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] + } + }, + { ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } } + ) + ).toThrow('Released snapshot history is incomplete for orca-cli') + expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow() + }) + + it.runIf(process.platform !== 'win32')( + 'rejects executable files in shipped skill packages', + async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n') + await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n') + await chmod(path.join(packageRoot, 'run.sh'), 0o755) + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow( + 'Executable file is not allowed in a shipped skill: run.sh' + ) + } + ) + + it.runIf(process.platform === 'linux')('rejects case-colliding paths', async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill') + await writeFile(path.join(packageRoot, 'Readme.md'), 'one') + await writeFile(path.join(packageRoot, 'README.md'), 'two') + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow('Case-colliding skill paths') + }) + + it.runIf(process.platform !== 'win32')('rejects symlinks inside shipped packages', async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill') + await symlink('SKILL.md', path.join(packageRoot, 'linked.md')) + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow( + 'Symlink is not allowed in a shipped skill' + ) + }) + + it('computes the same Git tree identity as Git', async () => { + const packageRoot = path.resolve('skills', 'orca-cli') + const files = await collectPackageFiles(packageRoot) + const expected = execFileSync('git', ['ls-tree', 'HEAD:skills', 'orca-cli'], { + encoding: 'utf8' + }) + .trim() + .split(/\s+/)[2] + + expect(gitTreeSha(files)).toBe(expected) + }) + + it('matches Git when a directory and file share a name prefix', async () => { + const packageRoot = await createPackage() + await mkdir(path.join(packageRoot, 'sub')) + await writeFile(path.join(packageRoot, 'sub', 'inner.txt'), 'nested\n') + await writeFile(path.join(packageRoot, 'sub.md'), 'sibling\n') + const files = await collectPackageFiles(packageRoot) + execFileSync('git', ['init', '--quiet'], { cwd: packageRoot }) + execFileSync('git', ['add', '-A'], { cwd: packageRoot }) + const expected = execFileSync('git', ['write-tree'], { + cwd: packageRoot, + encoding: 'utf8' + }).trim() + + expect(gitTreeSha(files)).toBe(expected) + }) +}) diff --git a/config/scripts/runtime-file-watcher-resource-probe.mjs b/config/scripts/runtime-file-watcher-resource-probe.mjs new file mode 100644 index 00000000000..67d2df02a63 --- /dev/null +++ b/config/scripts/runtime-file-watcher-resource-probe.mjs @@ -0,0 +1,257 @@ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { build } from 'esbuild' + +const ENTRY_PATH = resolve('out/main/parcel-watcher-process-entry.js') +const POOL_SOURCE = resolve('src/main/ipc/runtime-watcher-process-pool.ts') +const FAILURE_SOURCE = resolve('src/main/ipc/parcel-watcher-process-failure.ts') +const REGISTRY_SOURCE = resolve('src/main/ipc/parcel-watcher-child-registry.ts') +const MAX_CHILD_RSS_KIB = 128 * 1024 +const MAX_CHILD_CPU_PERCENT = 50 +const MAX_QUARANTINE_RSS_KIB = 512 * 1024 +const MAX_QUARANTINE_CPU_PERCENT = 100 +const PHYSICAL_CHILD_CAP = 8 +const WAIT_TIMEOUT_MS = 15_000 +const execFileAsync = promisify(execFile) +const require = createRequire(import.meta.url) + +async function main() { + if (!existsSync(ENTRY_PATH)) { + throw new Error(`Missing ${ENTRY_PATH}; run pnpm run build:electron-vite first`) + } + + const createdRoots = [] + const exactPids = new Set() + let bundleDir + let pool + let result + try { + bundleDir = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-resource-')) + const { RuntimeWatcherProcessPool, WatcherProcessFailure, reserveWatcherChild } = + await loadProbe(bundleDir) + const roots = [] + for (let index = 0; index < 5; index++) { + const createdRoot = await mkdtemp(join(tmpdir(), `orca-watcher-resource-${index}-`)) + createdRoots.push(createdRoot) + roots.push(await realpath(createdRoot)) + } + + pool = new RuntimeWatcherProcessPool() + await Promise.all(roots.map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {}))) + const healthyPids = activeChildPids(pool) + assertPidCount(healthyPids, 1, 'five healthy roots') + healthyPids.forEach((pid) => exactPids.add(pid)) + const healthyResources = await sampleResources(healthyPids) + assertResourceBudget(healthyResources, 'healthy pool', { + maxTotalRssKiB: MAX_CHILD_RSS_KIB, + maxTotalCpuPercent: MAX_CHILD_CPU_PERCENT + }) + assertRegistryCapacity(reserveWatcherChild, 7) + + const sharedSlot = [...pool.activeSlots][0] + const failure = new WatcherProcessFailure( + 'resource probe synthetic shared-shard fault', + 'supervisor', + 'supervisor_crash_fuse' + ) + for (const record of sharedSlot.supervisor.records.values()) { + record.hooks.onTerminalError?.(failure) + } + await waitFor(() => !isPidAlive(healthyPids[0]), 'healthy child exit') + + await Promise.all( + roots.slice(0, 4).map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {})) + ) + const quarantinePids = activeChildPids(pool) + assertPidCount(quarantinePids, 4, 'four fault-quarantine roots') + quarantinePids.forEach((pid) => exactPids.add(pid)) + const quarantineResources = await sampleResources(quarantinePids) + assertResourceBudget(quarantineResources, 'quarantine pool', { + maxTotalRssKiB: MAX_QUARANTINE_RSS_KIB, + maxTotalCpuPercent: MAX_QUARANTINE_CPU_PERCENT + }) + // Four live quarantine children leave four reservations before the global + // eight-physical-child ceiling rejects the next launch. + assertRegistryCapacity(reserveWatcherChild, 4) + + result = { + hostPid: process.pid, + healthy: summarizeResources(healthyResources), + quarantine: summarizeResources(quarantineResources), + physicalChildCap: PHYSICAL_CHILD_CAP, + budgets: { + maxChildRssKiB: MAX_CHILD_RSS_KIB, + maxChildCpuPercent: MAX_CHILD_CPU_PERCENT, + maxQuarantineRssKiB: MAX_QUARANTINE_RSS_KIB, + maxQuarantineCpuPercent: MAX_QUARANTINE_CPU_PERCENT, + maxPhysicalRssKiB: MAX_CHILD_RSS_KIB * PHYSICAL_CHILD_CAP, + maxPhysicalCpuPercent: MAX_CHILD_CPU_PERCENT * PHYSICAL_CHILD_CAP + }, + exactPidCleanup: true + } + } finally { + pool?.dispose() + await Promise.all( + [...exactPids].map((pid) => waitFor(() => !isPidAlive(pid), `watcher PID ${pid} cleanup`)) + ) + await Promise.all([ + ...createdRoots.map((rootPath) => rm(rootPath, { recursive: true, force: true })), + bundleDir ? rm(bundleDir, { recursive: true, force: true }) : Promise.resolve() + ]) + } + console.log(JSON.stringify(result)) +} + +async function loadProbe(bundleDir) { + const outfile = join(bundleDir, 'watcher-resource-probe.cjs') + await build({ + stdin: { + contents: [ + `export { RuntimeWatcherProcessPool } from ${JSON.stringify(POOL_SOURCE)}`, + `export { WatcherProcessFailure } from ${JSON.stringify(FAILURE_SOURCE)}`, + `export { reserveWatcherChild } from ${JSON.stringify(REGISTRY_SOURCE)}` + ].join('\n'), + resolveDir: process.cwd(), + sourcefile: 'runtime-file-watcher-resource-probe-entry.ts', + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + external: ['@parcel/watcher', 'electron'], + logLevel: 'silent' + }) + return require(outfile) +} + +function activeChildPids(pool) { + return [...pool.activeSlots] + .map((slot) => slot.supervisor.child?.pid) + .filter((pid) => Number.isInteger(pid)) +} + +function assertPidCount(pids, expected, label) { + if (new Set(pids).size !== expected || pids.some((pid) => !isPidAlive(pid))) { + throw new Error( + `${label} used ${new Set(pids).size} live watcher children; expected ${expected}` + ) + } +} + +function assertRegistryCapacity(reserveWatcherChild, availableReservations) { + const releases = [] + try { + for (let index = 0; index < availableReservations; index++) { + const release = reserveWatcherChild() + if (!release) { + throw new Error(`Watcher child registry rejected reservation ${index + 1}`) + } + releases.push(release) + } + const unexpectedReservation = reserveWatcherChild() + if (unexpectedReservation) { + unexpectedReservation() + throw new Error('Watcher child registry exceeded its global physical-process cap') + } + } finally { + releases.forEach((release) => release()) + } +} + +async function sampleResources(pids) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)) + return Promise.all(pids.map((pid) => sampleProcess(pid))) +} + +async function sampleProcess(pid) { + if (process.platform === 'win32') { + const command = + `$p=Get-Process -Id ${pid};` + + `[pscustomobject]@{rssKiB=[math]::Round($p.WorkingSet64/1KB);cpuSeconds=$p.CPU}|ConvertTo-Json -Compress` + const first = JSON.parse( + (await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim() + ) + await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)) + const second = JSON.parse( + (await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim() + ) + return { + pid, + rssKiB: second.rssKiB, + cpuPercent: Math.max(0, (second.cpuSeconds - first.cpuSeconds) * 400) + } + } + const { stdout } = await execFileAsync('ps', ['-o', 'rss=', '-o', '%cpu=', '-p', String(pid)], { + env: { ...process.env, LC_ALL: 'C' } + }) + const [rssKiB, cpuPercent] = stdout.trim().split(/\s+/).map(Number) + return { pid, rssKiB, cpuPercent } +} + +function assertResourceBudget(resources, label, { maxTotalRssKiB, maxTotalCpuPercent }) { + for (const resource of resources) { + if (!Number.isFinite(resource.rssKiB) || resource.rssKiB <= 0) { + throw new Error(`Missing RSS evidence for watcher PID ${resource.pid}`) + } + if (resource.rssKiB > MAX_CHILD_RSS_KIB) { + throw new Error( + `Watcher PID ${resource.pid} RSS ${resource.rssKiB} KiB exceeded ${MAX_CHILD_RSS_KIB} KiB` + ) + } + if (!Number.isFinite(resource.cpuPercent) || resource.cpuPercent < 0) { + throw new Error(`Missing CPU evidence for watcher PID ${resource.pid}`) + } + if (resource.cpuPercent > MAX_CHILD_CPU_PERCENT) { + throw new Error( + `Watcher PID ${resource.pid} CPU ${resource.cpuPercent}% exceeded ${MAX_CHILD_CPU_PERCENT}%` + ) + } + } + const summary = summarizeResources(resources) + if (summary.totalRssKiB > maxTotalRssKiB) { + throw new Error( + `${label} RSS ${summary.totalRssKiB} KiB exceeded aggregate budget ${maxTotalRssKiB} KiB` + ) + } + if (summary.sampledCpuPercent > maxTotalCpuPercent) { + throw new Error( + `${label} CPU ${summary.sampledCpuPercent}% exceeded aggregate budget ${maxTotalCpuPercent}%` + ) + } +} + +function summarizeResources(resources) { + return { + childCount: resources.length, + pids: resources.map(({ pid }) => pid), + totalRssKiB: resources.reduce((total, { rssKiB }) => total + rssKiB, 0), + sampledCpuPercent: resources.reduce((total, { cpuPercent }) => total + cpuPercent, 0) + } +} + +function isPidAlive(pid) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function waitFor(predicate, label) { + const deadline = Date.now() + WAIT_TIMEOUT_MS + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${label}`) + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)) + } +} + +await main() diff --git a/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs b/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs deleted file mode 100644 index 512191599d0..00000000000 --- a/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -// Benchmark: main-process retained-Set growth of the serve-sim state watcher's -// external-helper dedupe across worktree switches. -// -// emitIfExternal() records one entry per (worktreeId, deviceUdid, pid, wsUrl, -// streamUrl) tuple in `seenExternalKeys` so a helper is only auto-tabbed once. -// Before the fix, forgetWorktree() pruned ptyToWorktree and ptyBuffers but never -// touched seenExternalKeys, so its keys accumulated for the renderer/main -// process lifetime — only cleared at app shutdown (stop()). This script -// simulates N bind→detect→forget cycles and reports retained Set size with the -// prune disabled vs enabled. -import { performance } from 'node:perf_hooks' - -const CYCLES = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_CYCLES ?? '5000', 10) -const HELPERS_PER_WORKTREE = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_HELPERS ?? '2', 10) - -for (const [name, value] of [ - ['ORCA_SEEN_KEYS_BENCH_CYCLES', CYCLES], - ['ORCA_SEEN_KEYS_BENCH_HELPERS', HELPERS_PER_WORKTREE] -]) { - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`${name} must be a positive integer, received ${value}`) - } -} - -// Mirror of the watcher's dedupe bookkeeping (serve-sim-state-watcher.ts): a -// Set of `${worktreeId}::${instanceKey}` keys, plus the prefix-prune the fix -// adds to forgetWorktree(). -function makeWatcher({ prune }) { - const seenExternalKeys = new Set() - return { - set: seenExternalKeys, - emitIfExternal(worktreeId, instanceKey) { - const dedupeKey = `${worktreeId}::${instanceKey}` - if (seenExternalKeys.has(dedupeKey)) { - return false - } - seenExternalKeys.add(dedupeKey) - return true - }, - forgetWorktree(worktreeId) { - if (!prune) { - return - } - const prefix = `${worktreeId}::` - for (const key of seenExternalKeys) { - if (key.startsWith(prefix)) { - seenExternalKeys.delete(key) - } - } - } - } -} - -function run({ prune }) { - const watcher = makeWatcher({ prune }) - const start = performance.now() - for (let cycle = 0; cycle < CYCLES; cycle++) { - const worktreeId = `worktree-${cycle}` - for (let h = 0; h < HELPERS_PER_WORKTREE; h++) { - // A realistic instance key: device udid :: pid :: wsUrl :: streamUrl. - const instanceKey = `udid-${cycle}-${h}::pid-${1000 + h}::ws://127.0.0.1:${3100 + h}/ws::http://127.0.0.1:${3100 + h}/stream.mjpeg` - watcher.emitIfExternal(worktreeId, instanceKey) - } - watcher.forgetWorktree(worktreeId) - } - return { retained: watcher.set.size, elapsedMs: performance.now() - start } -} - -console.log( - `serve-sim seenExternalKeys leak benchmark: ${CYCLES} worktree open/forget cycles, ` + - `${HELPERS_PER_WORKTREE} helper(s)/worktree\n` -) - -const before = run({ prune: false }) -const after = run({ prune: true }) - -// Each key is ~120-160 bytes (worktreeId + udid + pid + two URLs). -const APPROX_BYTES_PER_KEY = 140 -const leakedKb = (before.retained * APPROX_BYTES_PER_KEY) / 1024 - -console.log( - `before (no prune) retained=${before.retained} keys ` + - `(~${leakedKb.toFixed(0)} KiB) time=${before.elapsedMs.toFixed(1)}ms` -) -console.log( - `after (prune) retained=${after.retained} keys ` + - `(~0 KiB) time=${after.elapsedMs.toFixed(1)}ms` -) -console.log( - `\nBounded the dedupe Set to live worktrees: ${before.retained} → ${after.retained} ` + - `retained keys after ${CYCLES} worktree switches.` -) diff --git a/config/scripts/verify-skill-update-roundtrip.mjs b/config/scripts/verify-skill-update-roundtrip.mjs new file mode 100644 index 00000000000..bfb10a50494 --- /dev/null +++ b/config/scripts/verify-skill-update-roundtrip.mjs @@ -0,0 +1,249 @@ +import { execFileSync } from 'node:child_process' +import { + chmod, + cp, + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs' + +function option(name) { + return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3) +} + +const cliVersion = option('cli') +const autocrlf = option('autocrlf') +const shape = option('shape') +// Why: PR branch names are untrusted workflow input. Keep them out of the +// generated shell command and pass them to Node through the environment. +const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE +const ref = option('ref') ?? process.env.SKILL_UPDATE_REF +if ( + !cliVersion || + (autocrlf !== 'true' && autocrlf !== 'false') || + (shape !== 'symlink' && shape !== 'copy') || + !source || + !ref || + !/^[^/\s]+\/[^/\s]+$/.test(source) +) { + throw new Error( + 'Usage: verify-skill-update-roundtrip.mjs --cli= --autocrlf=true|false --shape=symlink|copy --source= --ref=' + ) +} + +const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-')) +const home = path.join(sandbox, 'home') +const stateHome = path.join(home, '.state') +const fakeBin = path.join(sandbox, 'bin') +const targetName = 'orca-cli' +const controlName = 'orchestration' +const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8')) +const registry = JSON.parse(await readFile('resources/skills/snapshot-registry.json', 'utf8')) +const releaseMapping = JSON.parse(await readFile('resources/skills/release-mapping.json', 'utf8')) + +function currentSkill(name) { + const skill = manifest.skills.find((entry) => entry.name === name) + if (!skill) { + throw new Error(`Current manifest is missing ${name}`) + } + return skill +} + +function historicalRelease(name) { + const current = currentSkill(name) + for (const release of releaseMapping.releases.toReversed()) { + const revision = release.skills[name] + if (typeof revision !== 'number' || revision >= current.releaseRevision) { + continue + } + const snapshot = registry.skills[name]?.find((entry) => entry.releaseRevision === revision) + if (snapshot) { + return { tag: `v${release.appVersion}`, snapshot } + } + } + throw new Error(`No historical released snapshot is available for ${name}`) +} + +async function materializePackage(name, tag, destination) { + const prefix = `skills/${name}/` + const entries = execFileSync('git', ['ls-tree', '-r', '-z', tag, '--', `skills/${name}`]) + .toString('utf8') + .split('\0') + .filter(Boolean) + if (entries.length === 0) { + throw new Error(`${tag} does not contain ${name}`) + } + for (const entry of entries) { + const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(entry) + if (!match || match[2] !== 'blob') { + throw new Error(`Unsupported historical tree entry: ${entry}`) + } + const relativePath = match[4].slice(prefix.length) + const destinationPath = path.join(destination, ...relativePath.split('/')) + await mkdir(path.dirname(destinationPath), { recursive: true }) + await writeFile(destinationPath, execFileSync('git', ['cat-file', 'blob', match[3]])) + if (process.platform !== 'win32' && match[1] === '100755') { + await chmod(destinationPath, 0o755) + } + } +} + +async function seedPlacement(name, tag) { + const canonical = path.join(home, '.agents', 'skills', name) + await materializePackage(name, tag, canonical) + const providerRoot = path.join(home, '.claude', 'skills') + const provider = path.join(providerRoot, name) + await mkdir(providerRoot, { recursive: true }) + await (shape === 'copy' + ? cp(canonical, provider, { recursive: true }) + : symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir')) +} + +async function installFakeAgentCommands() { + await mkdir(fakeBin, { recursive: true }) + for (const name of ['codex', 'claude']) { + const executable = path.join(fakeBin, process.platform === 'win32' ? `${name}.cmd` : name) + await writeFile( + executable, + process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n' + ) + if (process.platform !== 'win32') { + await chmod(executable, 0o755) + } + } +} + +async function packageDigestAt(pathValue) { + return packageDigest(await collectPackageFiles(pathValue)) +} + +async function assertCurrentCanonical(name) { + const expected = currentSkill(name).packageDigest + const canonical = path.join(home, '.agents', 'skills', name) + if ((await packageDigestAt(canonical)) !== expected) { + throw new Error(`${name} canonical placement did not update to the PR content`) + } +} + +function execSkills(args) { + const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx' + const cliArgs = ['--yes', `skills@${cliVersion}`, ...args] + execFileSync( + executable, + process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs, + { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + CODEX_HOME: path.join(home, '.codex'), + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), + XDG_STATE_HOME: stateHome, + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.autocrlf', + GIT_CONFIG_VALUE_0: autocrlf, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`, + CI: '1' + }, + stdio: 'inherit' + } + ) +} + +try { + const targetHistorical = historicalRelease(targetName) + const controlHistorical = historicalRelease(controlName) + await installFakeAgentCommands() + await mkdir(path.join(home, '.codex'), { recursive: true }) + await mkdir(path.join(home, '.claude'), { recursive: true }) + await seedPlacement(targetName, targetHistorical.tag) + await seedPlacement(controlName, controlHistorical.tag) + const targetProvider = path.join(home, '.claude', 'skills', targetName) + const controlCanonical = path.join(home, '.agents', 'skills', controlName) + const controlProvider = path.join(home, '.claude', 'skills', controlName) + const targetProviderBefore = await packageDigestAt(await realpath(targetProvider)) + const controlBefore = await packageDigestAt(controlCanonical) + const controlProviderBefore = await packageDigestAt(await realpath(controlProvider)) + + const timestamp = new Date().toISOString() + const lock = { + version: 3, + skills: { + [targetName]: { + source, + sourceType: 'github', + sourceUrl: `https://github.com/${source}.git`, + ref, + skillPath: `skills/${targetName}/SKILL.md`, + skillFolderHash: targetHistorical.snapshot.gitTreeSha, + installedAt: timestamp, + updatedAt: timestamp + }, + [controlName]: { + source, + sourceType: 'github', + sourceUrl: `https://github.com/${source}.git`, + ref, + skillPath: `skills/${controlName}/SKILL.md`, + skillFolderHash: controlHistorical.snapshot.gitTreeSha, + installedAt: timestamp, + updatedAt: timestamp + } + } + } + const lockPath = path.join(stateHome, 'skills', '.skill-lock.json') + await mkdir(path.dirname(lockPath), { recursive: true }) + await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`) + + // Why: this is the exact user-visible rail. A bare update would include + // unrelated vendors, while this command must leave the control skill alone. + execSkills(['update', targetName, '--global']) + await assertCurrentCanonical(targetName) + const targetProviderAfter = await packageDigestAt(await realpath(targetProvider)) + const targetProviderStat = await lstat(targetProvider) + if (shape === 'symlink' && !targetProviderStat.isSymbolicLink()) { + throw new Error(`${targetName} provider alias was replaced with an independent copy`) + } + if (shape === 'symlink' && targetProviderAfter !== currentSkill(targetName).packageDigest) { + throw new Error(`${targetName} provider alias did not converge with the canonical update`) + } + if ( + shape === 'copy' && + targetProviderAfter !== targetProviderBefore && + targetProviderAfter !== currentSkill(targetName).packageDigest + ) { + throw new Error('Independent provider copy changed to an unexpected package identity') + } + if (shape === 'copy') { + // Why: hosted 1.5.17 replaces copies with aliases while equivalent local runs + // retain the copy. Both prove this input topology must remain ineligible. + const outcome = targetProviderStat.isSymbolicLink() + ? 'converged to an alias' + : targetProviderAfter === targetProviderBefore + ? 'remained a historical copy' + : 'converged as a copy' + console.log(`[skill-update-roundtrip] independent copy ${outcome}`) + } + if ((await packageDigestAt(controlCanonical)) !== controlBefore) { + throw new Error('Targeted update changed the non-targeted control skill') + } + if ((await packageDigestAt(await realpath(controlProvider))) !== controlProviderBefore) { + throw new Error('Targeted update changed the non-targeted control provider placement') + } + const controlProviderStat = await lstat(controlProvider) + if (shape === 'symlink' && !controlProviderStat.isSymbolicLink()) { + throw new Error('Targeted update changed the non-targeted control topology') + } +} finally { + await rm(sandbox, { recursive: true, force: true }) +} diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index ee1674f4124..417924fc997 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 5.6m + + downloads: 5.8m @@ -15,7 +15,7 @@ downloads downloads - 5.6m - 5.6m + 5.8m + 5.8m diff --git a/docs/assets/star-history.png b/docs/assets/star-history.png index 39d06509590..e07ed23112f 100644 Binary files a/docs/assets/star-history.png and b/docs/assets/star-history.png differ diff --git a/docs/assets/wechat-qr-backup.jpg b/docs/assets/wechat-qr-backup.jpg deleted file mode 100644 index a6392babbce..00000000000 Binary files a/docs/assets/wechat-qr-backup.jpg and /dev/null differ diff --git a/docs/assets/wechat-qr.jpg b/docs/assets/wechat-qr.jpg new file mode 100644 index 00000000000..0b4598e0f88 Binary files /dev/null and b/docs/assets/wechat-qr.jpg differ diff --git a/docs/assets/wechat-qr.png b/docs/assets/wechat-qr.png deleted file mode 100644 index bf270d857e8..00000000000 Binary files a/docs/assets/wechat-qr.png and /dev/null differ diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 6e2161840d5..5e6638196da 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -17,7 +17,7 @@

El orquestador de IA para desarrolladores 100x.
- Ejecuta Claude Code, OpenClaude, Codex u OpenCode en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar. + Ejecuta Codex, Claude Code, OpenCode u Pi en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar.

Descargar Orca

diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 07f5054d4cd..e2516f34d12 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -17,7 +17,7 @@

100x ビルダーのための AI オーケストレーター。
- Claude Code、OpenClaude、Codex、OpenCode を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。 + Codex、Claude Code、OpenCode、Pi を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。

Orca をダウンロード

diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 070b0acda1d..c03089e9091 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -17,7 +17,7 @@

100x 빌더를 위한 AI 오케스트레이터.
- Claude Code, OpenClaude, Codex, OpenCode를 나란히 실행하세요 — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다. + Codex, Claude Code, OpenCode, Pi를 나란히 실행하세요. — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.

Orca 다운로드

@@ -164,7 +164,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 - **[풍부한 리포지토리 미리보기](https://www.onorca.dev/docs/editing/markdown)** — Markdown, 이미지, PDF, 리포지토리 문서를 워크스페이스에서 미리 볼 수 있습니다. - **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 워크플로에 실제 상호작용이 필요할 때 에이전트가 데스크톱 앱과 화면에 보이는 UI를 직접 조작하게 하세요. - **[알림과 읽지 않음 상태](https://www.onorca.dev/docs/notifications)** — 에이전트가 완료되거나 주의가 필요할 때 알림을 받고, 스레드를 읽지 않음으로 표시해 나중에 다시 확인하세요. -- **그리고 훨씬 더 많은 기능** — 매일 출시하기 때문에 이 목록은 항상 뒤처져 있습니다. 진짜 기능 목록은 [체인지로그](https://github.com/stablyai/orca/releases)입니다. +- **그리고 훨씬 더 많은 기능** — 새로운 기능이 매일 출시되므로 이 목록은 늘 한 발 늦습니다. 진짜 기능은 [체인지로그](https://github.com/stablyai/orca/releases)에서 확인하세요. --- @@ -237,7 +237,7 @@ yay -S stably-orca-bin - **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요. - **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요. - **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요. -- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일의 릴리스를 따라와 주세요. +- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요. --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index c0351670195..0116866f048 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -17,7 +17,7 @@

面向 100x 构建者的 AI 编排器。
- 并排运行 Claude Code、OpenClaude、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。 + 并排运行 Codex、Claude Code、OpenCode 或 Pi — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。

下载 Orca

@@ -235,10 +235,9 @@ yay -S stably-orca-bin - **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。 - **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。 -- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。 +- **微信:** 第一个微信群已满,现在可以加入第二个群。 - Orca 社区微信群二维码 - Orca 社区备用微信群二维码 + Orca 社区微信群二维码 - **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。 - **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。 diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md index c943bc9ce5f..b740982febb 100644 --- a/docs/reference/git-compatibility.md +++ b/docs/reference/git-compatibility.md @@ -35,7 +35,7 @@ authority. | Capability | Preferred behavior | Compatibility behavior | | ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `worktree-list-z` | NUL-delimited worktree paths | Line-block parser for Git before `worktree list -z` | +| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 | | `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo | | `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca | | `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 | diff --git a/docs/reference/ssh-gssapi-kerberos.md b/docs/reference/ssh-gssapi-kerberos.md new file mode 100644 index 00000000000..e7594151667 --- /dev/null +++ b/docs/reference/ssh-gssapi-kerberos.md @@ -0,0 +1,39 @@ +# Kerberos / GSSAPI SSH Authentication + +Orca's ssh2-based SSH stack cannot speak `gssapi-with-mic` — the ssh2 library +has no GSSAPI userauth support, and adding it would mean forking ssh2's +protocol layer plus shipping the `kerberos` native module (MIT krb5 / Heimdal / +Windows SSPI) as a prebuilt Electron dependency on three platforms. Instead, +hosts that need Kerberos ride the existing **system OpenSSH transport** — the +same parallel transport already used for `ProxyCommand`/`ProxyJump` hosts — +which delegates GSSAPI, ticket lookup (`kinit` cache, Windows domain logon), +and credential delegation to the platform's own `ssh` binary on macOS, Linux, +and Windows (Win32-OpenSSH uses SSPI). + +## Transport selection + +Two tiers, deliberately asymmetric because RHEL-family distros ship +`GSSAPIAuthentication yes` in the global `/etc/ssh/ssh_config`, which makes +`ssh -G` report GSSAPI enabled for *every* host: + +1. **Proactive** — a target with `gssapiAuthentication: true` (parsed from an + explicit `GSSAPIAuthentication yes` in the host's `~/.ssh/config` block, or + set on the target directly) tries the system-ssh probe first. If that fails + (e.g. no ticket), the connect falls through to the normal ssh2 key/agent + path where passphrase/password prompts remain available — OpenSSH semantics + allow other auth methods alongside GSSAPI. +2. **Auth-failure fallback** — when ssh2 exhausts key/agent auth and the + `ssh -G`-resolved config enables GSSAPI (`isGssapiSystemSshFallbackCandidate` + in `ssh-connection-utils.ts`), the connection retries over system ssh + *before* prompting the user for credentials. Kerberos-only hosts on + distro-default configs connect this way; hosts where keys work never leave + the ssh2 path. + +Manual (non-ssh-config) targets flagged for GSSAPI get an explicit +`-o GSSAPIAuthentication=yes` in `system-ssh-args.ts`; config-backed targets +inherit the option from their own `Host` block since the system binary re-reads +ssh_config. + +Both tiers work headless (`orca serve`): the system-ssh probe needs no +credential callbacks, and GSSAPI itself is non-interactive once a ticket +exists. diff --git a/docs/reference/terminal-session-lifecycle.md b/docs/reference/terminal-session-lifecycle.md index 29161cd6fdf..0b11fd15557 100644 --- a/docs/reference/terminal-session-lifecycle.md +++ b/docs/reference/terminal-session-lifecycle.md @@ -46,6 +46,22 @@ persisted physical-to-owner pane-key alias for detach, plus a live Electron parked-close gate. Focused tests cover local, SSH, split, parked, shared, paired-host, ordinary runtime, pane-transfer, and late-spawn paths. +`orca terminal close --tab` extends that same ownership boundary to automation. +With an attached renderer, main sends a request/reply close, the renderer runs +the canonical retirement path, persists a fresh host-partitioned session, and +forces the main store to disk before acknowledging success. Without a renderer, +the runtime applies the equivalent immutable projection to the persisted +aggregate tab, removes every split binding and resume record, safely selects a +surviving browser/editor/terminal surface, stops exact live or durable SSH PTYs, +and flushes before returning. The ordinary `terminal close` pane/session +contract is unchanged. + +This durability boundary does not use replay fences or tombstones. Renderer +session writes are ordered through the same IPC sender, and a pending debounced +write rebuilds from the latest store state, so the forced fresh snapshot is not +followed by a captured pre-close topology patch. Headless closes update the +single main-owned session projection directly. + Origin-aware defensive expiry remains a follow-up. Immediate deletion on explicit close fixes the incident without applying a wall-clock policy that could invalidate intentional long-lived `worktree-sleep` checkpoints. @@ -74,8 +90,20 @@ could invalidate intentional long-lived `worktree-sleep` checkpoints. - Changing terminal output, snapshot, replay, query-response, or hidden-delivery behavior. -- Killing processes that deliberately daemonize away from the terminal process - group. This contract covers the PTY and its attached foreground process tree. +- Killing processes that deliberately daemonize away from a plain user + terminal's process group (nohup-style survivors remain user intent there). + For **agent sessions**, close/kill additionally terminates the snapshotted + descendant tree — including detached-pgid children the PTY's SIGHUP cannot + reach — via `pty-descendant-termination.ts` (bounded fresh snapshot with + same-turn coalescing, SIGTERM, grace window, then identity-safe SIGKILL). + Completed process tables are never reused as signal targets, and identity + checks use C-locale timestamps from the source scan. Later requests start a + fresh same-turn-coalesced successor inside their own deadline instead of + waiting behind older scans. A session is marked as terminating before capture + and keeps request ownership through natural exit, so reattach, duplicate kill, + and graceful-to-immediate upgrade cannot race the snapshot; descendant signals + still require the exact root session/handle to be live. Windows and SSH-hosted + PTYs keep the previous foreground-tree contract for now. - Changing agent-provider resume commands or permission flags. - Making a UI close wait for a remote process to exit before the tab disappears. - Replacing worktree sleep with tab close. Sleep remains resumable by design. diff --git a/mobile/app/h/[hostId]/edit.tsx b/mobile/app/h/[hostId]/edit.tsx new file mode 100644 index 00000000000..790c755cd47 --- /dev/null +++ b/mobile/app/h/[hostId]/edit.tsx @@ -0,0 +1,382 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + View, + Text, + TextInput, + StyleSheet, + Pressable, + ActivityIndicator, + KeyboardAvoidingView, + Platform, + ScrollView +} from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { ChevronLeft } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' +import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store' +import { + displayHostEndpoint, + endpointPort, + endpointScheme, + normalizeHostEndpoint +} from '../../../src/transport/host-endpoint' +import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context' +import type { HostProfile } from '../../../src/transport/types' + +export default function EditHostScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const { hostId } = useLocalSearchParams<{ hostId: string }>() + const primeHosts = usePrimeHosts() + const forceReconnectHost = useForceReconnect() + + const [host, setHost] = useState(null) + const [loadError, setLoadError] = useState(null) + const [name, setName] = useState('') + const [address, setAddress] = useState('') + const [saveError, setSaveError] = useState(null) + const [saving, setSaving] = useState(false) + // Why: setSaving is async, so a second trigger before the re-render could + // still read stale state and re-enter handleSave; the ref closes that race. + const savingRef = useRef(false) + + const load = useCallback(async () => { + if (!hostId) { + setLoadError('Missing host.') + return + } + try { + const hosts = await loadHosts() + const found = hosts.find((h) => h.id === hostId) ?? null + if (!found) { + setLoadError('This host was removed from this phone.') + setHost(null) + return + } + setHost(found) + setName(found.name) + setAddress(displayHostEndpoint(found.endpoint)) + setLoadError(null) + } catch (err) { + setLoadError(err instanceof Error ? err.message : 'Failed to load host.') + setHost(null) + } + }, [hostId]) + + useEffect(() => { + void load() + }, [load]) + + const fallbackPort = host ? endpointPort(host.endpoint) : undefined + const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws' + + const normalizedEndpoint = useMemo( + () => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }), + [address, fallbackPort, fallbackScheme] + ) + + const nameTrimmed = name.trim() + const nameChanged = host != null && nameTrimmed.length > 0 && nameTrimmed !== host.name + const endpointChanged = + host != null && normalizedEndpoint.ok && normalizedEndpoint.endpoint !== host.endpoint + const canSave = + host != null && + nameTrimmed.length > 0 && + normalizedEndpoint.ok && + (nameChanged || endpointChanged) && + !saving + + async function handleSave() { + if (!host || !hostId || savingRef.current) { + return + } + const nextName = name.trim() + if (!nextName) { + setSaveError('Enter a name.') + return + } + if (!normalizedEndpoint.ok) { + setSaveError(normalizedEndpoint.error) + return + } + + const willRename = nextName !== host.name + const willUpdateEndpoint = normalizedEndpoint.endpoint !== host.endpoint + if (!willRename && !willUpdateEndpoint) { + router.back() + return + } + + savingRef.current = true + setSaving(true) + setSaveError(null) + try { + // Why: a single mutateStoredHosts pass so name + endpoint commit + // atomically — a mid-save failure can never persist one without the + // other, and a host removed mid-edit throws instead of no-oping. + await updateHostNameAndEndpoint(host.id, { + ...(willRename ? { name: nextName } : {}), + ...(willUpdateEndpoint ? { endpoint: normalizedEndpoint.endpoint } : {}) + }) + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save host.') + savingRef.current = false + setSaving(false) + return + } + + try { + // Why: the write already committed above; a re-prime failure here + // must not be reported as a save failure — the next loadHosts() call + // elsewhere in the app picks up the fresh state regardless. + const hosts = await loadHosts() + primeHosts(hosts) + } catch { + // best-effort re-prime; persisted data is unaffected + } + + savingRef.current = false + setSaving(false) + router.back() + + if (willUpdateEndpoint) { + // Why: reconnect is a follow-on side effect of a save that already + // committed — its failure or a hang must not be reported as a save + // failure or block navigating back. + void forceReconnectHost(host.id).catch(() => {}) + } + } + + return ( + + + router.back()} + accessibilityRole="button" + accessibilityLabel="Back" + > + + + Edit host + [ + styles.saveButton, + (!canSave || pressed) && styles.saveButtonDisabled + ]} + onPress={() => void handleSave()} + disabled={!canSave} + accessibilityRole="button" + accessibilityLabel="Save host" + > + {saving ? ( + + ) : ( + Save + )} + + + + {loadError ? ( + + {loadError} + router.back()}> + Go back + + + ) : !host ? ( + + + + ) : ( + + + + Change the display name or connection address. Address edits only switch where this + phone connects — they do not re-pair. Use this when the same desktop is reachable at a + different IP (for example home LAN vs Tailscale). + + + Name + { + setName(value) + setSaveError(null) + }} + placeholder="Host name" + placeholderTextColor={colors.textMuted} + autoCapitalize="words" + autoCorrect={false} + returnKeyType="next" + /> + + Address + { + setAddress(value) + setSaveError(null) + }} + placeholder="192.168.1.10:6768" + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + keyboardType="url" + returnKeyType="done" + onSubmitEditing={() => { + if (canSave) { + void handleSave() + } + }} + /> + + Accepts IP, host:port, or ws:// / wss://. Missing port defaults to the current port + (or 6768). + + + {normalizedEndpoint.ok ? ( + + Connects to {normalizedEndpoint.endpoint} + + ) : address.trim().length > 0 ? ( + {normalizedEndpoint.error} + ) : null} + + {saveError ? {saveError} : null} + + + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + flex: { + flex: 1 + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingBottom: spacing.md, + gap: spacing.sm + }, + backButton: { + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center' + }, + heading: { + flex: 1, + color: colors.textPrimary, + fontSize: 20, + fontWeight: '700' + }, + saveButton: { + minWidth: 64, + height: 34, + paddingHorizontal: spacing.md, + borderRadius: radii.button, + backgroundColor: colors.surfaceBright, + alignItems: 'center', + justifyContent: 'center' + }, + saveButtonDisabled: { + opacity: 0.4 + }, + saveButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + form: { + paddingHorizontal: spacing.lg, + gap: spacing.sm + }, + help: { + color: colors.textSecondary, + fontSize: typography.bodySize, + lineHeight: 20, + marginBottom: spacing.sm + }, + label: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '500', + marginTop: spacing.sm, + textTransform: 'uppercase', + letterSpacing: 0.4 + }, + input: { + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.row, + color: colors.textPrimary, + fontSize: typography.bodySize, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? 12 : 10 + }, + hint: { + color: colors.textMuted, + fontSize: typography.metaSize, + lineHeight: 16 + }, + preview: { + marginTop: spacing.sm, + color: colors.textSecondary, + fontSize: typography.metaSize, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : typography.monoFamily + }, + previewError: { + marginTop: spacing.sm, + color: colors.statusRed, + fontSize: typography.bodySize + }, + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize, + marginTop: spacing.md + }, + errorState: { + flex: 1, + paddingHorizontal: spacing.lg, + paddingTop: spacing.xl, + gap: spacing.md + }, + loadingState: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + secondaryButton: { + alignSelf: 'flex-start', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radii.button, + backgroundColor: colors.bgRaised + }, + secondaryButtonText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '500' + } +}) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index d4d1b35896f..0251da70490 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -26,10 +26,8 @@ import { Bot, ChevronDown, ChevronLeft, - ChevronRight, ChevronsRight, Copy, - Eraser, Folder, File, FileText, @@ -174,6 +172,7 @@ import { type MobileNewTabAgentSettings } from '../../../../src/session/mobile-new-tab-agent-options' import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' +import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate' import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste' import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' @@ -198,6 +197,15 @@ import { isDictationSetupRequiredError } from '../../../../src/dictation/mobile-dictation-setup' import { TerminalPaneView } from '../../../../src/session/TerminalPaneView' +import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay' +import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet' +import { useMobileNativeChatController } from '../../../../src/session/use-mobile-native-chat-controller' +import { useMobileNativeChatReadability } from '../../../../src/session/use-mobile-native-chat-readability' +import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease' +import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions' +import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream' +import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream' +import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe' import { activateMobileSessionTab, focusMobileTerminal @@ -249,8 +257,6 @@ import type { TerminalGestureInputQueue } from './mobile-session-route-types' -type TerminalLiveAccessoryInput = ReturnType - const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 function MarkdownReader({ @@ -1167,11 +1173,48 @@ export default function SessionScreen() { }, [clearToastHideTimer] ) + const showNativeChatSendError = useCallback( + (message: string) => showToast(message, 1600), + [showToast] + ) + const nativeChatTranscriptIsLocalReadable = useMobileNativeChatReadability(client, worktreeId) + const { + ready: nativeChatInputLeaseReady, + readyRef: nativeChatInputLeaseReadyRef, + lockReason: nativeChatInputLockReason, + markReady: markNativeChatInputLeaseReady, + clear: clearNativeChatInputLease + } = useMobileNativeChatInputLease({ + activeHandle, + connected: connState === 'connected' + }) + const nativeChatController = useMobileNativeChatController({ + client, + hostId, + worktreeId, + activeSessionTab, + activeSessionTabId, + activeHandleRef, + deviceTokenRef, + nativeChatTranscriptIsLocalReadable, + nativeChatInputLeaseReady, + onSendError: showNativeChatSendError + }) + const { chatTabIds, toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController const dictation = useMobileDictation({ client, enabled: canSend, onTranscript: (text) => { + // Why: dictation belongs to the visible composer. Native chat consumes it + // locally; terminal mode retains the live-input routing and flush contract. + if (showNativeChatRef.current) { + nativeChatController.setChatComposerText((current) => + appendBufferedDictation(current, text) + ) + showToast('Dictation inserted') + return + } // Live mode inserts the transcript straight into its originating PTY as // text (no Return — the user sends it themselves), matching live keystroke // semantics; buffered mode keeps appending to the command field. @@ -1290,22 +1333,27 @@ export default function SessionScreen() { return handle ? terminalRefs.current.get(handle) : undefined }, []) - const unsubscribeTerminal = useCallback((handle: string) => { - terminalUnsubsRef.current.get(handle)?.() - terminalUnsubsRef.current.delete(handle) - subscribingHandlesRef.current.delete(handle) - terminalDiagnosticsRef.current.terminalUnsubscribed(handle) - subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) - // Why: a fresh subscription will land on a new server-side state machine - // run (or the same one with a higher seq); reset the high-water mark so - // the first scrollback isn't accidentally dropped as stale. - layoutSeqRef.current.delete(handle) - }, []) + const unsubscribeTerminal = useCallback( + (handle: string) => { + terminalUnsubsRef.current.get(handle)?.() + terminalUnsubsRef.current.delete(handle) + subscribingHandlesRef.current.delete(handle) + terminalDiagnosticsRef.current.terminalUnsubscribed(handle) + subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) + // Why: a fresh subscription will land on a new server-side state machine + // run (or the same one with a higher seq); reset the high-water mark so + // the first scrollback isn't accidentally dropped as stale. + layoutSeqRef.current.delete(handle) + clearNativeChatInputLease(handle) + }, + [clearNativeChatInputLease] + ) + const unsubscribeTerminalRef = useRef(unsubscribeTerminal) + unsubscribeTerminalRef.current = unsubscribeTerminal const clearTerminalCache = useCallback(() => { - for (const unsub of terminalUnsubsRef.current.values()) { - unsub() - } + terminalUnsubsRef.current.forEach((unsub) => unsub()) + clearNativeChatInputLease() terminalUnsubsRef.current.clear() subscribingHandlesRef.current.clear() initializedHandlesRef.current.clear() @@ -1318,7 +1366,7 @@ export default function SessionScreen() { for (const term of terminalRefs.current.values()) { term.clear() } - }, []) + }, [clearNativeChatInputLease]) // Why: measures the phone viewport once from the first available TerminalWebView. // The viewport dims are passed with every subscribe call so the server can @@ -1357,13 +1405,22 @@ export default function SessionScreen() { logSkippedGate('subscribe-in-flight') return } - if (!getTerminalRef(handle)) { - logSkippedGate('no-webview-ref') - return - } - if (!webReadyHandlesRef.current.has(handle)) { - logSkippedGate('webview-not-ready') - return + const covered = nativeChatTerminalStream.isTerminalCoveredByNativeChat( + showNativeChatRef.current, + activeHandleRef.current, + handle + ) + // Why: a native-chat-covered terminal subscribes as the input-floor lease + // without a mounted xterm webview, so only gate on the webview when NOT covered. + if (!covered) { + if (!getTerminalRef(handle)) { + logSkippedGate('no-webview-ref') + return + } + if (!webReadyHandlesRef.current.has(handle)) { + logSkippedGate('webview-not-ready') + return + } } subscribingHandlesRef.current.add(handle) @@ -1375,13 +1432,13 @@ export default function SessionScreen() { // The viewport is embedded in the subscribe params so the server resizes // the PTY before serializing scrollback. This eliminates the focus→safeFit // race and the measure→resize→resubscribe pipeline. - const unsub = client.subscribe( - 'terminal.subscribe', + const unsub = subscribeMobileTerminalSafely( + client, { terminal: handle, client: { id: deviceTokenRef.current!, type: 'mobile' as const }, viewport: viewportRef.current ?? undefined, - capabilities: { terminalBinaryStream: 1 } + capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered) }, (result) => { if (subscribeSeqRef.current.get(handle) !== seq) { @@ -1389,6 +1446,25 @@ export default function SessionScreen() { } const data = result as Record diagnostics.firstStreamEvent(handle, seq, data.type) + if (data.type === 'end' || data.type === 'error') { + unsubscribeTerminalRef.current(handle) + return + } + if (data.type === 'subscribed') { + markNativeChatInputLeaseReady(handle) + return + } + // Why: retain the subscription as the mobile input-floor lease, but + // do not mutate covered xterm state; return-to-terminal resubscribes. + if ( + nativeChatTerminalStream.isTerminalCoveredByNativeChat( + showNativeChatRef.current, + activeHandleRef.current, + handle + ) + ) { + return + } // Why: stale-event filter. Server-side state machine bumps a // monotonic seq on every applyLayout. Drop `resized` events // whose seq is strictly older than what we've already observed @@ -1417,9 +1493,6 @@ export default function SessionScreen() { } else if (eventSeq != null && data.type === 'scrollback') { layoutSeqRef.current.set(handle, eventSeq) } - if (data.type === 'subscribed') { - return - } if (data.type === 'scrollback') { diagnostics.streamScrollback(handle, seq, eventSeq, data) if (initializedHandlesRef.current.has(handle)) { @@ -1572,7 +1645,8 @@ export default function SessionScreen() { } scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) } - } + }, + () => unsubscribeTerminalRef.current(handle) ) if (subscribeSeqRef.current.get(handle) === seq) { @@ -1582,9 +1656,21 @@ export default function SessionScreen() { } subscribingHandlesRef.current.delete(handle) }, - [client, getTerminalRef, scheduleDelayedAction] + [client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction] ) + const notifyTerminalWebReady = useMobileNativeChatTerminalStream({ + showNativeChat, + activeHandle, + activeTabType: activeSessionTab?.type ?? null, + subscriptionsRef: terminalUnsubsRef, + subscribingRef: subscribingHandlesRef, + webReadyRef: webReadyHandlesRef, + initializedRef: initializedHandlesRef, + subscribe: subscribeToTerminal, + unsubscribe: unsubscribeTerminal + }) + // Why: toggles between phone and desktop mode via server RPC. The server // handles the actual resize and emits a 'resized' event on the existing // subscription stream — no client-side state tracking needed. @@ -3064,6 +3150,7 @@ export default function SessionScreen() { (handle: string) => { const wasAlreadyReady = webReadyHandlesRef.current.has(handle) webReadyHandlesRef.current.add(handle) + notifyTerminalWebReady(handle, wasAlreadyReady) terminalDiagnosticsRef.current.webViewReady( handle, wasAlreadyReady, @@ -3105,7 +3192,7 @@ export default function SessionScreen() { })() } }, - [getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal] + [measureViewportOnce, notifyTerminalWebReady, subscribeToTerminal, unsubscribeTerminal] ) useEffect(() => { @@ -3156,7 +3243,7 @@ export default function SessionScreen() { } } - async function handleAccessoryKey(input: TerminalLiveAccessoryInput) { + async function handleAccessoryKey(input: ReturnType) { if (!client || !activeHandle || !canSend) { return } @@ -3643,7 +3730,7 @@ export default function SessionScreen() { } }, []) const startAccessoryRepeat = useCallback( - (input: TerminalLiveAccessoryInput) => { + (input: ReturnType) => { stopAccessoryRepeat() repeatTimeoutRef.current = setTimeout(() => { repeatIntervalRef.current = setInterval(() => { @@ -3815,19 +3902,14 @@ export default function SessionScreen() { showToast }) - const flushPendingLiveInputBeforeAttachmentSend = useCallback( - async (targetHandle: string): Promise => { - const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) - // Why: image picking/upload and IME flushing can outlive the original tab. - return ( - flushedPendingInput && - connStateRef.current === 'connected' && - targetHandle === activeHandleRef.current && - activeSessionTabTypeRef.current === 'terminal' - ) - }, - [flushPendingLiveInputBeforeExternalSend] - ) + const flushPendingLiveInputBeforeAttachmentSend = useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend, + connStateRef, + activeHandleRef, + activeSessionTabTypeRef, + nativeChatInputLeaseReadyRef, + showToast + }) const { attachImage, isAttaching } = useMobileImageAttachment({ client, @@ -4887,6 +4969,18 @@ export default function SessionScreen() { onOpenUrl={handleTerminalOpenUrl} /> ))} + void attachImage('library')} + isAttaching={isAttaching} + onMicPress={handleDictationToggle} + micActive={dictation.isRecording} + dictationMode={dictationMode} + onMicPressIn={handleDictationPressIn} + onMicPressOut={handleDictationPressOut} + inputLockReason={nativeChatInputLockReason} + keyboardInset={keyboardLift} + /> {toastMessage && ( {toastMessage} @@ -4896,8 +4990,9 @@ export default function SessionScreen() { )} {/* Why: translate instead of resizing so keyboard open/close does not - trigger a server-side PTY viewport change. */} - {!activeMarkdownTab && !activeFileTab && !activeBrowserTab && ( + trigger a server-side PTY viewport change. The dock hides in native + chat because that view supplies its own composer. */} + {!activeMarkdownTab && !activeFileTab && !activeBrowserTab && !showNativeChat && ( { - const target = actionTarget - setActionTarget(null) - if (target) { - void toggleDisplayMode(target.handle) - } - } - } - ] - : []), - { - label: 'Rename', - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - setRenameTarget(target) - } - } - }, - { - label: 'Clear Terminal', - icon: Eraser, - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - void handleClearTerminal(target) - } - } - }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - void handleCloseTerminal(target) - } - } - } - ]} + actions={getMobileTerminalActionSheetActions({ + target: actionTarget, + tabs: sessionTabs.filter((tab) => tab.type === 'terminal'), + chatTabIds, + nativeChatTranscriptIsLocalReadable, + onDismiss: () => setActionTarget(null), + onToggleChat: toggleTabChatView, + isPhoneMode, + onToggleDisplayMode: (handle) => void toggleDisplayMode(handle), + onRename: setRenameTarget, + onClear: (target) => void handleClearTerminal(target), + onClose: (target) => void handleCloseTerminal(target) + })} onClose={() => setActionTarget(null)} /> setFileActionTarget(null)} /> - { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.back') - } - } - } - ] - : []), - ...(browserActionTarget?.canGoForward - ? [ - { - label: 'Forward', - icon: ChevronRight, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.forward') - } - } - } - ] - : []), - { - label: 'Reload', - icon: RefreshCw, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.reload') - } - } - }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleCloseSessionTab(target) - } - } - } - ]} + setBrowserActionTarget(null)} + onNavigate={handleBrowserNavigationCommand} + onCloseTab={handleCloseSessionTab} /> + diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index e26893afd84..b33aac12610 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -24,7 +24,7 @@ import { UsageBar } from '../src/components/AccountUsage' import AsyncStorage from '@react-native-async-storage/async-storage' -import { loadHosts, renameHost } from '../src/transport/host-store' +import { loadHosts } from '../src/transport/host-store' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' import { pickResumeWorktree } from '../src/worktree/resume-worktree' import type { RpcClient } from '../src/transport/rpc-client' @@ -42,7 +42,6 @@ import { triggerMediumImpact } from '../src/platform/haptics' import { OrcaLogo } from '../src/components/OrcaLogo' import { MobileHostCard } from '../src/components/MobileHostCard' import { TaskProviderLogo } from '../src/components/TaskProviderLogo' -import { TextInputModal } from '../src/components/TextInputModal' import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal' import { ConfirmModal } from '../src/components/ConfirmModal' import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache' @@ -306,7 +305,6 @@ export default function HomeScreen() { const { isWideLayout, contentMaxWidth } = useResponsiveLayout() const [hosts, setHosts] = useState([]) const [actionTarget, setActionTarget] = useState(null) - const [renameTarget, setRenameTarget] = useState(null) const [confirmRemove, setConfirmRemove] = useState(null) const [hostStates, setHostStates] = useState>({}) const [hostAttempts, setHostAttempts] = useState>({}) @@ -708,19 +706,6 @@ export default function HomeScreen() { ) - async function handleRename(newName: string) { - if (!renameTarget) { - return - } - try { - await renameHost(renameTarget.id, newName) - setRenameTarget(null) - setHosts(await loadHosts()) - } catch { - setRenameTarget(null) - } - } - async function handleRemove() { if (!confirmRemove) { return @@ -1068,11 +1053,12 @@ export default function HomeScreen() { }) } items.push({ - label: 'Rename', + label: 'Edit host', icon: Edit3, closeBeforePress: true, onPress: () => { - setRenameTarget(host) + setActionTarget(null) + router.push(`/h/${host.id}/edit`) } }) items.push({ @@ -1088,16 +1074,6 @@ export default function HomeScreen() { onClose={() => setActionTarget(null)} /> - void handleRename(name)} - onCancel={() => setRenameTarget(null)} - /> - () const [status, setStatus] = useState('awaiting-confirm') @@ -104,6 +106,13 @@ export default function PairConfirmScreen() { if (!mountedRef.current || !attemptIsCurrent) { return } + // Why: re-pairing the same desktop now reuses its existing host id + // (STA-1840 dedup), so a client cached under that id from an earlier + // pairing would keep the stale endpoint/relay. Close it so the + // destination screen opens a fresh client with the newly-paired + // profile — the removeHost() path already refreshes on re-pair, and a + // brand-new host has no cached entry so this is a no-op. + closeHost(hostId) const showNotificationOptIn = await shouldPresentNotificationOptIn() if (!mountedRef.current) { return diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index aab9d86bbf5..6b980ee0be6 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -18,6 +18,7 @@ import { type PreProfilePairingAttempt } from '../src/transport/pre-profile-pairing-coordinator' import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types' +import { useCloseHost } from '../src/transport/client-context' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { TextInputModal } from '../src/components/TextInputModal' import { ConnectionLog } from '../src/components/ConnectionLog' @@ -43,6 +44,7 @@ function Step({ number, text }: { number: number; text: string }) { export default function PairScanScreen() { const router = useRouter() + const closeHost = useCloseHost() const insets = useSafeAreaInsets() const [permission, requestPermission] = useCameraPermissions() const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning') @@ -148,6 +150,13 @@ export default function PairScanScreen() { if (!mountedRef.current || !attemptIsCurrent) { return } + // Why: re-pairing the same desktop now reuses its existing host id + // (STA-1840 dedup), so a client cached under that id from an earlier + // pairing would keep the stale endpoint/relay. Close it so the + // destination screen opens a fresh client with the newly-paired + // profile — the removeHost() path already refreshes on re-pair, and a + // brand-new host has no cached entry so this is a no-op. + closeHost(hostId) const showNotificationOptIn = await shouldPresentNotificationOptIn() if (!mountedRef.current) { return diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 3200dcc5d71..8e968f604ac 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -1,12 +1,24 @@ import { Fragment, memo, useMemo, type ReactNode } from 'react' -import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' -import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { Linking, Pressable, ScrollView, Text, View } from 'react-native' import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html' +import { styles } from './mobile-markdown-styles' +import { + detectFilePathSegments, + isFilePathCodeSpan, + normalizeFilePath +} from './markdown-file-path-detection' import { parseMobileMarkdown } from './mobile-markdown-parser' type Props = { content?: string fallback?: string + /** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1; + * the chat view passes >1 so agent prose reads larger than the compact base. */ + textScale?: number + /** When provided, detected file-path tokens render as tappable and invoke this + * with the worktree-relative path. Omitted on screens with no file viewer, where + * paths render as plain text (no behavior change). */ + onOpenFile?: (relativePath: string) => void } const MAX_TABLE_ROWS = 40 @@ -19,7 +31,37 @@ function openMarkdownUrl(url: string): void { } } -function renderInline(text: string): ReactNode[] { +// Render a plain (non-token) text run, splitting out tappable file paths when +// onOpenFile is provided. Without it, paths stay plain text. +function renderTextRun( + text: string, + keyPrefix: string, + onOpenFile?: (relativePath: string) => void +): ReactNode { + if (!onOpenFile) { + return text + } + const segments = detectFilePathSegments(text) + if (segments.length === 1 && segments[0]!.type === 'text') { + return text + } + return segments.map((segment, segmentIndex) => { + if (segment.type === 'file') { + return ( + onOpenFile(segment.path)} + > + {segment.value} + + ) + } + return {segment.value} + }) +} + +function renderInline(text: string, onOpenFile?: (relativePath: string) => void): ReactNode[] { const parts: ReactNode[] = [] const pattern = /(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g @@ -28,7 +70,7 @@ function renderInline(text: string): ReactNode[] { while ((match = pattern.exec(text))) { if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)) + parts.push(renderTextRun(text.slice(lastIndex, match.index), `t${lastIndex}`, onOpenFile)) } const token = match[0] const key = `${match.index}:${token}` @@ -53,11 +95,24 @@ function renderInline(text: string): ReactNode[] { ) } else if (token.startsWith('`')) { - parts.push( - - {token.slice(1, -1)} - - ) + const code = token.slice(1, -1) + if (onOpenFile && isFilePathCodeSpan(code)) { + parts.push( + onOpenFile(normalizeFilePath(code.trim()))} + > + {code} + + ) + } else { + parts.push( + + {code} + + ) + } } else if (token.startsWith('~~')) { parts.push( @@ -81,15 +136,20 @@ function renderInline(text: string): ReactNode[] { } if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)) + parts.push(renderTextRun(text.slice(lastIndex), `t${lastIndex}`, onOpenFile)) } return parts } -function MobileMarkdownInner({ content, fallback = '' }: Props) { +function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile }: Props) { const text = content?.trim() ?? '' const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text]) const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText]) + // Scale prose sizes; inline spans inherit fontSize from the wrapping Text. + const scaled = (size: number): { fontSize: number; lineHeight: number } | null => + textScale !== 1 ? { fontSize: size * textScale, lineHeight: (size + 6) * textScale } : null + const proseScale = scaled(13) + const listScale = scaled(14) if (!text) { return fallback ? {fallback} : null } @@ -103,14 +163,14 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { key={index} style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]} > - {renderInline(block.text)} + {renderInline(block.text, onOpenFile)} ) } if (block.type === 'quote') { return ( - {renderInline(block.text)} + {renderInline(block.text, onOpenFile)} ) } @@ -147,7 +207,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { {visibleHeaders.map((header, cellIndex) => ( - {renderInline(header)} + {renderInline(header, onOpenFile)} ))} @@ -155,7 +215,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { {visibleHeaders.map((_, cellIndex) => ( - {renderInline(row[cellIndex] ?? '')} + {renderInline(row[cellIndex] ?? '', onOpenFile)} ))} @@ -185,7 +245,9 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { ? '[x]' : '[ ]'} - {renderInline(item.text)} + + {renderInline(item.text, onOpenFile)} + ))} @@ -195,11 +257,11 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { return } return ( - + {block.text.split('\n').map((line, lineIndex) => ( {lineIndex > 0 ? '\n' : null} - {renderInline(line)} + {renderInline(line, onOpenFile)} ))} @@ -210,147 +272,3 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { } export const MobileMarkdown = memo(MobileMarkdownInner) - -const styles = StyleSheet.create({ - root: { - gap: spacing.sm - }, - paragraph: { - fontSize: 13, - lineHeight: 19, - color: colors.textPrimary - }, - heading: { - fontSize: 14, - lineHeight: 20, - fontWeight: '700', - color: colors.textPrimary - }, - headingLarge: { - fontSize: 15, - lineHeight: 21 - }, - bold: { - fontWeight: '700', - color: colors.textPrimary - }, - italic: { - fontStyle: 'italic' - }, - strike: { - textDecorationLine: 'line-through' - }, - link: { - color: colors.accentBlue, - textDecorationLine: 'underline' - }, - inlineCode: { - fontFamily: typography.monoFamily, - fontSize: 12, - color: colors.textPrimary, - backgroundColor: colors.bgRaised, - borderRadius: radii.row, - paddingHorizontal: 4 - }, - quote: { - borderLeftWidth: 2, - borderLeftColor: colors.borderSubtle, - paddingLeft: spacing.sm - }, - quoteText: { - fontSize: 13, - lineHeight: 19, - color: colors.textSecondary - }, - codeBlock: { - backgroundColor: colors.bgRaised, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - padding: spacing.sm - }, - codeLanguage: { - fontSize: 10, - color: colors.textMuted, - marginBottom: spacing.xs, - textTransform: 'uppercase' - }, - codeText: { - fontFamily: typography.monoFamily, - fontSize: 12, - lineHeight: 17, - color: colors.textPrimary - }, - imageFrame: { - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - backgroundColor: colors.bgRaised, - overflow: 'hidden', - padding: spacing.sm - }, - imageCaption: { - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - fontSize: 11, - color: colors.textSecondary - }, - table: { - borderTopWidth: 1, - borderLeftWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - overflow: 'hidden', - backgroundColor: colors.bgPanel - }, - tableRow: { - flexDirection: 'row' - }, - tableCell: { - minWidth: 112, - maxWidth: 220, - borderRightWidth: 1, - borderBottomWidth: 1, - borderColor: colors.borderSubtle, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - fontSize: 12, - lineHeight: 17, - color: colors.textPrimary - }, - tableHeader: { - fontWeight: '700', - backgroundColor: colors.bgRaised - }, - tableTruncated: { - padding: spacing.sm, - fontSize: 12, - color: colors.textMuted - }, - list: { - gap: spacing.xs - }, - listItem: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: spacing.sm - }, - listMarker: { - width: 22, - fontSize: 13, - lineHeight: 19, - color: colors.textSecondary, - fontFamily: typography.monoFamily - }, - listText: { - flex: 1, - minWidth: 0, - fontSize: 13, - lineHeight: 19, - color: colors.textPrimary - }, - rule: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle - } -}) diff --git a/mobile/src/components/markdown-file-path-detection.test.ts b/mobile/src/components/markdown-file-path-detection.test.ts new file mode 100644 index 00000000000..f75da01efdc --- /dev/null +++ b/mobile/src/components/markdown-file-path-detection.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { + detectFilePathSegments, + isFilePathCodeSpan, + normalizeFilePath +} from './markdown-file-path-detection' + +describe('detectFilePathSegments', () => { + it('returns a single text segment when there is no path', () => { + expect(detectFilePathSegments('just some prose here')).toEqual([ + { type: 'text', value: 'just some prose here' } + ]) + }) + + it('detects a relative source path with surrounding prose', () => { + const segments = detectFilePathSegments('Edit src/app/Main.tsx now') + expect(segments).toEqual([ + { type: 'text', value: 'Edit ' }, + { type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' }, + { type: 'text', value: ' now' } + ]) + }) + + it('strips a leading ./ in the path but keeps the displayed value', () => { + const segments = detectFilePathSegments('see ./lib/x.ts') + expect(segments).toEqual([ + { type: 'text', value: 'see ' }, + { type: 'file', value: './lib/x.ts', path: 'lib/x.ts' } + ]) + }) + + it('keeps ../ parent-relative paths intact', () => { + const segments = detectFilePathSegments('../shared/util.ts') + expect(segments).toEqual([ + { type: 'file', value: '../shared/util.ts', path: '../shared/util.ts' } + ]) + }) + + it('detects multiple paths in one run', () => { + const segments = detectFilePathSegments('a/b.ts and c/d/e.json') + expect(segments.filter((s) => s.type === 'file')).toEqual([ + { type: 'file', value: 'a/b.ts', path: 'a/b.ts' }, + { type: 'file', value: 'c/d/e.json', path: 'c/d/e.json' } + ]) + }) + + it('detects Windows relative, drive, and UNC paths', () => { + const segments = detectFilePathSegments( + String.raw`Edit src\app\Main.tsx, C:\repo\config.json, and \\server\share\docs\readme.md` + ) + + expect(segments.filter((segment) => segment.type === 'file')).toEqual([ + { type: 'file', value: String.raw`src\app\Main.tsx`, path: String.raw`src\app\Main.tsx` }, + { + type: 'file', + value: String.raw`C:\repo\config.json`, + path: String.raw`C:\repo\config.json` + }, + { + type: 'file', + value: String.raw`\\server\share\docs\readme.md`, + path: String.raw`\\server\share\docs\readme.md` + } + ]) + }) + + it('does not match bare filenames without a slash', () => { + expect(detectFilePathSegments('open Main.tsx please')).toEqual([ + { type: 'text', value: 'open Main.tsx please' } + ]) + }) + + it('does not match URLs', () => { + expect(detectFilePathSegments('https://example.com/path/file.ts')).toEqual([ + { type: 'text', value: 'https://example.com/path/file.ts' } + ]) + }) + + it('does not match version numbers', () => { + expect(detectFilePathSegments('upgraded to 1.2.3 today')).toEqual([ + { type: 'text', value: 'upgraded to 1.2.3 today' } + ]) + }) + + it('does not match unknown extensions', () => { + expect(detectFilePathSegments('path/to/thing.whatever')).toEqual([ + { type: 'text', value: 'path/to/thing.whatever' } + ]) + }) + + it('detects scoped-package file paths with a segment-leading @', () => { + expect(detectFilePathSegments('open @types/react/index.d.ts here')).toEqual([ + { type: 'text', value: 'open ' }, + { + type: 'file', + value: '@types/react/index.d.ts', + path: '@types/react/index.d.ts' + }, + { type: 'text', value: ' here' } + ]) + expect( + detectFilePathSegments('node_modules/@scope/pkg/file.ts').filter((s) => s.type === 'file') + ).toEqual([ + { + type: 'file', + value: 'node_modules/@scope/pkg/file.ts', + path: 'node_modules/@scope/pkg/file.ts' + } + ]) + }) + + it('does not match emails or git URLs with a mid-token @', () => { + expect(detectFilePathSegments('clone git@github.com:user/repo.git')).toEqual([ + { type: 'text', value: 'clone git@github.com:user/repo.git' } + ]) + expect(detectFilePathSegments('open user@host.com/path/file.txt')).toEqual([ + { type: 'text', value: 'open user@host.com/path/file.txt' } + ]) + }) + + it('returns a single text segment when the run has no dot', () => { + const text = 'a/'.repeat(8192) + expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }]) + }) + + it('skips detection for runs over the length cap even with dots', () => { + // 'a.b/'-repeats pass the dot precheck, so this exercises the length cap that + // bounds CANDIDATE_PATTERN's worst-case backtracking. + const text = 'a.b/'.repeat(2000) + expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }]) + }) + + it('still detects a path in a long-but-under-cap run', () => { + const prefix = 'context '.repeat(200) + const segments = detectFilePathSegments(`${prefix}src/app/Main.tsx`) + expect(segments.filter((s) => s.type === 'file')).toEqual([ + { type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' } + ]) + }) +}) + +describe('isFilePathCodeSpan', () => { + it('accepts a slashed path code span', () => { + expect(isFilePathCodeSpan('src/app/Main.tsx')).toBe(true) + }) + + it('accepts Windows paths in code spans', () => { + expect(isFilePathCodeSpan(String.raw`src\app\Main.tsx`)).toBe(true) + expect(isFilePathCodeSpan(String.raw`C:\repo\Main.tsx`)).toBe(true) + expect(isFilePathCodeSpan(String.raw`\\server\share\Main.tsx`)).toBe(true) + }) + + it('accepts a bare filename code span', () => { + expect(isFilePathCodeSpan('package.json')).toBe(true) + }) + + it('rejects multi-word code spans', () => { + expect(isFilePathCodeSpan('npm run build')).toBe(false) + }) + + it('rejects non-file code spans', () => { + expect(isFilePathCodeSpan('someVariable')).toBe(false) + }) + + it('rejects urls in code spans', () => { + expect(isFilePathCodeSpan('https://x.com/a.ts')).toBe(false) + }) + + it('accepts scoped-package paths with a segment-leading @', () => { + expect(isFilePathCodeSpan('@types/react/index.d.ts')).toBe(true) + expect(isFilePathCodeSpan('node_modules/@scope/pkg/file.ts')).toBe(true) + }) + + it('rejects emails and git URLs with a mid-token @', () => { + expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false) + expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false) + }) +}) + +describe('normalizeFilePath', () => { + it('strips a leading ./', () => { + expect(normalizeFilePath('./a/b.ts')).toBe('a/b.ts') + expect(normalizeFilePath(String.raw`.\a\b.ts`)).toBe(String.raw`a\b.ts`) + }) + + it('leaves other paths unchanged', () => { + expect(normalizeFilePath('../a/b.ts')).toBe('../a/b.ts') + expect(normalizeFilePath('a/b.ts')).toBe('a/b.ts') + }) +}) diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts new file mode 100644 index 00000000000..a33c0dceae4 --- /dev/null +++ b/mobile/src/components/markdown-file-path-detection.ts @@ -0,0 +1,212 @@ +// Conservative detection of file-path-like tokens inside an inline markdown text +// run, so the chat view can render them as tappable (opening the mobile file +// viewer). We deliberately favor precision over recall: a missed path is a minor +// annoyance, but a false positive on prose or a version number is a broken tap. + +export type FilePathSegment = + | { type: 'text'; value: string } + | { type: 'file'; value: string; path: string } + +// Common source/code/config extensions we treat as openable file paths. Kept +// explicit (rather than "any extension") so prose like "etc." or "e.g." and +// domain-ish tokens like "example.com" don't get matched. +const FILE_EXTENSIONS = [ + 'ts', + 'tsx', + 'js', + 'jsx', + 'mjs', + 'cjs', + 'json', + 'jsonc', + 'css', + 'scss', + 'sass', + 'less', + 'html', + 'htm', + 'md', + 'mdx', + 'markdown', + 'txt', + 'py', + 'rb', + 'go', + 'rs', + 'java', + 'kt', + 'kts', + 'swift', + 'c', + 'h', + 'cc', + 'cpp', + 'hpp', + 'cs', + 'php', + 'sh', + 'bash', + 'zsh', + 'fish', + 'yml', + 'yaml', + 'toml', + 'ini', + 'cfg', + 'conf', + 'env', + 'lock', + 'sql', + 'graphql', + 'gql', + 'proto', + 'xml', + 'svg', + 'vue', + 'svelte', + 'astro', + 'dart', + 'ex', + 'exs', + 'erl', + 'lua', + 'pl', + 'r', + 'scala', + 'clj', + 'gradle', + 'dockerfile', + 'gitignore', + 'npmrc' +] as const + +const EXTENSION_SET = new Set(FILE_EXTENSIONS) + +// Accept the host's native separator because transcript paths originate on the +// connected runtime, which may be Windows even when the phone is not. +const CANDIDATE_PATTERN = + /(?:[A-Za-z]:[\\/]|\\\\)?(?:\.{1,2}[\\/])?(?:[\w.@~+-]+[\\/])+[\w.@+-]+\.[A-Za-z0-9]+/g + +// A path candidate in chat prose is short; a much longer run can't hold one worth +// linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we +// skip detection entirely above this cap. +const MAX_DETECTION_LENGTH = 2000 + +// A mid-token '@' (one preceded by a non-separator) marks an email or git URL such +// as git@github.com; a segment-leading '@' is a scoped package dir (@scope/…) and +// stays eligible. +function hasMidTokenAt(candidate: string): boolean { + return /[^\\/]@/.test(candidate) +} + +function isOpenablePath(candidate: string): boolean { + // Reject anything URL-ish or scheme-bearing — those are handled as web links. + if (candidate.includes('://') || hasMidTokenAt(candidate)) { + return false + } + // Must contain a separator (a bare "file.ts" is too ambiguous in prose). + if (!/[\\/]/.test(candidate)) { + return false + } + const lastSeparator = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\')) + const lastSegment = candidate.slice(lastSeparator + 1) + const dot = lastSegment.lastIndexOf('.') + // A leading-dot dotfile in the final segment (e.g. ".env") has no extension to + // anchor on; require a real name.ext shape. + if (dot <= 0) { + return false + } + const ext = lastSegment.slice(dot + 1).toLowerCase() + if (!EXTENSION_SET.has(ext)) { + return false + } + // Guard against version-number-ish tails ("1.2.3" style) where the "extension" + // is purely numeric — those aren't files. + if (/^\d+$/.test(ext)) { + return false + } + return true +} + +// Strip the leading ./ marker so callers receive a clean worktree-relative path, +// while keeping ../ (which is meaningful) intact. +export function normalizeFilePath(path: string): string { + return path.replace(/^\.[\\/]/, '') +} + +/** + * Given a plain inline text run, return ordered segments marking which substrings + * are openable file paths. Non-path text is preserved verbatim so the renderer can + * reassemble the run exactly. Returns a single text segment when nothing matches. + */ +export function detectFilePathSegments(text: string): FilePathSegment[] { + // Every real match ends in a name.ext, so a dot is mandatory; skip the regex when + // there is none, or when the run is too long to hold a chat path but long enough + // to drive CANDIDATE_PATTERN into super-linear backtracking. + if (text.length > MAX_DETECTION_LENGTH || !text.includes('.')) { + return [{ type: 'text', value: text }] + } + const segments: FilePathSegment[] = [] + let lastIndex = 0 + let match: RegExpExecArray | null + CANDIDATE_PATTERN.lastIndex = 0 + + while ((match = CANDIDATE_PATTERN.exec(text))) { + const candidate = match[0] + // Skip candidates that are part of a URL (preceded by a scheme colon or an + // alphanumeric/host char that would make this a domain tail, not a path). + const prev = match.index > 0 ? text[match.index - 1]! : '' + if (prev === ':' || prev === '/' || /[\w.@]/.test(prev)) { + continue + } + if (!isOpenablePath(candidate)) { + continue + } + if (match.index > lastIndex) { + segments.push({ type: 'text', value: text.slice(lastIndex, match.index) }) + } + segments.push({ type: 'file', value: candidate, path: normalizeFilePath(candidate) }) + lastIndex = match.index + candidate.length + } + + if (lastIndex < text.length) { + segments.push({ type: 'text', value: text.slice(lastIndex) }) + } + // Collapse to a single text segment for the common no-match case. + if (segments.length === 0) { + return [{ type: 'text', value: text }] + } + return segments +} + +/** + * True when an inline-code span's entire content is a single openable file path + * (e.g. `src/app/Main.tsx`). Code spans are a strong signal, so we also accept a + * bare `file.ts` here even without a slash. + */ +export function isFilePathCodeSpan(code: string): boolean { + const trimmed = code.trim() + if (!trimmed || /\s/.test(trimmed)) { + return false + } + if (trimmed.includes('://') || hasMidTokenAt(trimmed)) { + return false + } + if (isOpenablePath(trimmed)) { + return true + } + // Separator-less code span: accept a clean name.ext with a known extension. + if (/[\\/]/.test(trimmed)) { + return false + } + const dot = trimmed.lastIndexOf('.') + if (dot <= 0) { + return false + } + const name = trimmed.slice(0, dot) + const ext = trimmed.slice(dot + 1).toLowerCase() + if (/[^\w.@+-]/.test(name)) { + return false + } + return EXTENSION_SET.has(ext) +} diff --git a/mobile/src/components/mobile-markdown-styles.ts b/mobile/src/components/mobile-markdown-styles.ts new file mode 100644 index 00000000000..414a8daf57b --- /dev/null +++ b/mobile/src/components/mobile-markdown-styles.ts @@ -0,0 +1,150 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const styles = StyleSheet.create({ + root: { + gap: spacing.sm + }, + paragraph: { + fontSize: 13, + lineHeight: 19, + color: colors.textPrimary + }, + heading: { + fontSize: 14, + lineHeight: 20, + fontWeight: '700', + color: colors.textPrimary + }, + headingLarge: { + fontSize: 15, + lineHeight: 21 + }, + bold: { + fontWeight: '700', + color: colors.textPrimary + }, + italic: { + fontStyle: 'italic' + }, + strike: { + textDecorationLine: 'line-through' + }, + link: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + inlineCode: { + fontFamily: typography.monoFamily, + fontSize: 12, + color: colors.textPrimary, + backgroundColor: colors.bgRaised, + borderRadius: radii.row, + paddingHorizontal: 4 + }, + inlineCodeLink: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + quote: { + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + paddingLeft: spacing.sm + }, + quoteText: { + fontSize: 13, + lineHeight: 19, + color: colors.textSecondary + }, + codeBlock: { + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + padding: spacing.sm + }, + codeLanguage: { + fontSize: 10, + color: colors.textMuted, + marginBottom: spacing.xs, + textTransform: 'uppercase' + }, + codeText: { + fontFamily: typography.monoFamily, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + imageFrame: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + backgroundColor: colors.bgRaised, + overflow: 'hidden', + padding: spacing.sm + }, + imageCaption: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: 11, + color: colors.textSecondary + }, + table: { + borderTopWidth: 1, + borderLeftWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + overflow: 'hidden', + backgroundColor: colors.bgPanel + }, + tableRow: { + flexDirection: 'row' + }, + tableCell: { + minWidth: 112, + maxWidth: 220, + borderRightWidth: 1, + borderBottomWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + tableHeader: { + fontWeight: '700', + backgroundColor: colors.bgRaised + }, + tableTruncated: { + padding: spacing.sm, + fontSize: 12, + color: colors.textMuted + }, + list: { + gap: spacing.xs + }, + listItem: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm + }, + listMarker: { + width: 22, + fontSize: 13, + lineHeight: 19, + color: colors.textSecondary, + fontFamily: typography.monoFamily + }, + listText: { + flex: 1, + minWidth: 0, + fontSize: 13, + lineHeight: 19, + color: colors.textPrimary + }, + rule: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle + } +}) diff --git a/mobile/src/host-edit-route-accessibility.test.ts b/mobile/src/host-edit-route-accessibility.test.ts new file mode 100644 index 00000000000..bb5ddf4faa6 --- /dev/null +++ b/mobile/src/host-edit-route-accessibility.test.ts @@ -0,0 +1,106 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import EditHostScreen from '../app/h/[hostId]/edit' + +const dependencies = vi.hoisted(() => ({ + back: vi.fn(), + forceReconnectHost: vi.fn(), + loadHosts: vi.fn(), + primeHosts: vi.fn(), + updateHostNameAndEndpoint: vi.fn() +})) + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + KeyboardAvoidingView: 'KeyboardAvoidingView', + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' +})) + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }) +})) + +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ hostId: 'host-1' }), + useRouter: () => ({ back: dependencies.back }) +})) + +vi.mock('lucide-react-native', () => ({ + ChevronLeft: 'ChevronLeft' +})) + +vi.mock('./transport/host-store', () => ({ + loadHosts: dependencies.loadHosts, + updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint +})) + +vi.mock('./transport/client-context', () => ({ + useForceReconnect: () => dependencies.forceReconnectHost, + usePrimeHosts: () => dependencies.primeHosts +})) + +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 renderEditHostRoute(): Promise { + let renderer: ReactTestRenderer | null = null + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(EditHostScreen)) + await Promise.resolve() + }) + } finally { + restoreConsoleError() + } + if (!renderer) { + throw new Error('Edit host route did not render') + } + return renderer +} + +describe('edit host route accessibility', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + dependencies.loadHosts.mockReset().mockResolvedValue([ + { + id: 'host-1', + name: 'Desk', + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'token', + publicKeyB64: 'public-key', + lastConnected: 1 + } + ]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('exposes stable accessible names for both editable fields', async () => { + const renderer = await renderEditHostRoute() + + const inputs = renderer.root.findAllByType('TextInput') + expect(inputs).toHaveLength(2) + expect(inputs.map((input) => input.props.accessibilityLabel)).toEqual(['Name', 'Address']) + + act(() => renderer.unmount()) + }) +}) diff --git a/mobile/src/host-edit-save-flow.test.ts b/mobile/src/host-edit-save-flow.test.ts new file mode 100644 index 00000000000..1e1c252403c --- /dev/null +++ b/mobile/src/host-edit-save-flow.test.ts @@ -0,0 +1,325 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import EditHostScreen from '../app/h/[hostId]/edit' + +const dependencies = vi.hoisted(() => ({ + back: vi.fn(), + forceReconnectHost: vi.fn(), + loadHosts: vi.fn(), + primeHosts: vi.fn(), + updateHostNameAndEndpoint: vi.fn(), + hostId: 'host-1' as string | undefined +})) + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + KeyboardAvoidingView: 'KeyboardAvoidingView', + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' +})) + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }) +})) + +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ hostId: dependencies.hostId }), + useRouter: () => ({ back: dependencies.back }) +})) + +vi.mock('lucide-react-native', () => ({ + ChevronLeft: 'ChevronLeft' +})) + +vi.mock('./transport/host-store', () => ({ + loadHosts: dependencies.loadHosts, + updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint +})) + +vi.mock('./transport/client-context', () => ({ + useForceReconnect: () => dependencies.forceReconnectHost, + usePrimeHosts: () => dependencies.primeHosts +})) + +const HOST_FIXTURE = { + id: 'host-1', + name: 'Desk', + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'token', + publicKeyB64: 'public-key', + lastConnected: 1 +} + +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 renderEditHostRoute(): Promise { + let renderer: ReactTestRenderer | null = null + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(EditHostScreen)) + await Promise.resolve() + }) + } finally { + restoreConsoleError() + } + if (!renderer) { + throw new Error('Edit host route did not render') + } + return renderer +} + +function setFieldValue( + renderer: ReactTestRenderer, + accessibilityLabel: 'Name' | 'Address', + value: string +): void { + const input = renderer.root + .findAllByType('TextInput') + .find((node) => node.props.accessibilityLabel === accessibilityLabel) + if (!input) { + throw new Error(`${accessibilityLabel} input not found`) + } + act(() => { + input.props.onChangeText(value) + }) +} + +function findSaveButton(renderer: ReactTestRenderer) { + const button = renderer.root + .findAllByType('Pressable') + .find((node) => node.props.accessibilityLabel === 'Save host') + if (!button) { + throw new Error('Save button not found') + } + return button +} + +async function pressSave(renderer: ReactTestRenderer): Promise { + const button = findSaveButton(renderer) + await act(async () => { + button.props.onPress() + await Promise.resolve() + await Promise.resolve() + }) +} + +function findText(renderer: ReactTestRenderer, match: string): boolean { + return renderer.root.findAllByType('Text').some((node) => { + const children = node.props.children + if (typeof children === 'string') { + return children.includes(match) + } + if (Array.isArray(children)) { + return children.join('').includes(match) + } + return false + }) +} + +describe('edit host handleSave', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + dependencies.hostId = 'host-1' + dependencies.back.mockReset() + dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined) + dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE]) + dependencies.primeHosts.mockReset() + dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('rename-only save updates only the name and does not reconnect', async () => { + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Name', 'Home Desk') + await pressSave(renderer) + + expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', { + name: 'Home Desk' + }) + expect(dependencies.forceReconnectHost).not.toHaveBeenCalled() + expect(dependencies.back).toHaveBeenCalledTimes(1) + + act(() => renderer.unmount()) + }) + + it('endpoint-only save updates only the endpoint and reconnects', async () => { + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Address', '192.168.1.20:6768') + await pressSave(renderer) + + expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', { + endpoint: 'ws://192.168.1.20:6768' + }) + expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1') + expect(dependencies.back).toHaveBeenCalledTimes(1) + + act(() => renderer.unmount()) + }) + + it('saves name and endpoint together in one call, then reconnects', async () => { + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Name', 'Home Desk') + setFieldValue(renderer, 'Address', '192.168.1.20:6768') + await pressSave(renderer) + + expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledTimes(1) + expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', { + name: 'Home Desk', + endpoint: 'ws://192.168.1.20:6768' + }) + expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1') + expect(dependencies.back).toHaveBeenCalledTimes(1) + + act(() => renderer.unmount()) + }) + + it('navigates back without saving or reconnecting when nothing changed', async () => { + const renderer = await renderEditHostRoute() + await pressSave(renderer) + + expect(dependencies.updateHostNameAndEndpoint).not.toHaveBeenCalled() + expect(dependencies.forceReconnectHost).not.toHaveBeenCalled() + expect(dependencies.back).toHaveBeenCalledTimes(1) + + act(() => renderer.unmount()) + }) + + it('shows the error and does not navigate back or reconnect when the save rejects', async () => { + dependencies.updateHostNameAndEndpoint.mockRejectedValueOnce(new Error('Host not found')) + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Name', 'Home Desk') + await pressSave(renderer) + + expect(findText(renderer, 'Host not found')).toBe(true) + expect(dependencies.forceReconnectHost).not.toHaveBeenCalled() + expect(dependencies.back).not.toHaveBeenCalled() + + act(() => renderer.unmount()) + }) + + it('still navigates back when the post-save re-prime fails', async () => { + dependencies.loadHosts + .mockResolvedValueOnce([HOST_FIXTURE]) + .mockRejectedValueOnce(new Error('boom')) + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Name', 'Home Desk') + await pressSave(renderer) + + expect(dependencies.primeHosts).not.toHaveBeenCalled() + expect(dependencies.back).toHaveBeenCalledTimes(1) + + act(() => renderer.unmount()) + }) + + it('still navigates back and shows no error when the post-save reconnect rejects', async () => { + dependencies.forceReconnectHost.mockRejectedValueOnce(new Error('connect failed')) + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Address', '192.168.1.20:6768') + await pressSave(renderer) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1') + expect(dependencies.back).toHaveBeenCalledTimes(1) + expect(findText(renderer, 'connect failed')).toBe(false) + + act(() => renderer.unmount()) + }) + + it('ignores a second Save trigger while a save is already in flight', async () => { + let resolveSave: () => void = () => {} + dependencies.updateHostNameAndEndpoint.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSave = resolve + }) + ) + const renderer = await renderEditHostRoute() + setFieldValue(renderer, 'Name', 'Home Desk') + const button = findSaveButton(renderer) + + await act(async () => { + button.props.onPress() + button.props.onPress() + await Promise.resolve() + }) + + expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveSave() + await Promise.resolve() + await Promise.resolve() + }) + + act(() => renderer.unmount()) + }) +}) + +describe('edit host load() error states', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + dependencies.hostId = 'host-1' + dependencies.back.mockReset() + dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined) + dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE]) + dependencies.primeHosts.mockReset() + dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows "Missing host." when hostId is absent', async () => { + dependencies.hostId = undefined + const renderer = await renderEditHostRoute() + + expect(findText(renderer, 'Missing host.')).toBe(true) + expect(renderer.root.findAllByType('TextInput')).toHaveLength(0) + + act(() => renderer.unmount()) + }) + + it('shows a not-saved message when the host is not in the loaded list', async () => { + dependencies.loadHosts.mockReset().mockResolvedValue([]) + const renderer = await renderEditHostRoute() + + expect(findText(renderer, 'This host was removed from this phone.')).toBe(true) + expect(renderer.root.findAllByType('TextInput')).toHaveLength(0) + + act(() => renderer.unmount()) + }) + + it('surfaces the error message when loadHosts rejects', async () => { + dependencies.loadHosts.mockReset().mockRejectedValue(new Error('storage unreadable')) + const renderer = await renderEditHostRoute() + + expect(findText(renderer, 'storage unreadable')).toBe(true) + expect(renderer.root.findAllByType('TextInput')).toHaveLength(0) + + act(() => renderer.unmount()) + }) +}) diff --git a/mobile/src/session/MobileAgentWorkingIndicator.tsx b/mobile/src/session/MobileAgentWorkingIndicator.tsx new file mode 100644 index 00000000000..cb766b0be51 --- /dev/null +++ b/mobile/src/session/MobileAgentWorkingIndicator.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef } from 'react' +import { Animated, StyleSheet, Text, View } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +/** Animated three-dot "agent is working" row, shown while the active agent is + * still producing a reply. Pure presentation — visibility is the caller's call. */ +export function MobileAgentWorkingIndicator(): React.JSX.Element { + const dots = [ + useRef(new Animated.Value(0.3)).current, + useRef(new Animated.Value(0.3)).current, + useRef(new Animated.Value(0.3)).current + ] + + useEffect(() => { + const animations = dots.map((dot, i) => + Animated.loop( + Animated.sequence([ + Animated.delay(i * 160), + Animated.timing(dot, { toValue: 1, duration: 320, useNativeDriver: true }), + Animated.timing(dot, { toValue: 0.3, duration: 320, useNativeDriver: true }) + ]) + ) + ) + animations.forEach((a) => a.start()) + return () => animations.forEach((a) => a.stop()) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + Agent is working + + {dots.map((dot, i) => ( + + ))} + + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + }, + label: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontStyle: 'italic' + }, + dots: { + flexDirection: 'row', + gap: 4 + }, + dot: { + width: 5, + height: 5, + borderRadius: 2.5, + backgroundColor: colors.textSecondary + } +}) diff --git a/mobile/src/session/MobileBrowserTabActionSheet.tsx b/mobile/src/session/MobileBrowserTabActionSheet.tsx new file mode 100644 index 00000000000..3baa6da8c3a --- /dev/null +++ b/mobile/src/session/MobileBrowserTabActionSheet.tsx @@ -0,0 +1,79 @@ +import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native' +import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent' + +type BrowserTab = Extract +export type MobileBrowserNavigationMethod = 'browser.back' | 'browser.forward' | 'browser.reload' + +/** Keeps browser-tab navigation actions out of the session route while preserving + * the target captured at the moment each drawer action is pressed. */ +export function MobileBrowserTabActionSheet(props: { + target: BrowserTab | null + onClose: () => void + onNavigate: (target: BrowserTab, method: MobileBrowserNavigationMethod) => void + onCloseTab: (target: BrowserTab) => void +}): React.JSX.Element { + const { target, onClose, onNavigate, onCloseTab } = props + return ( + { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.back') + } + } + } + ] + : []), + ...(target?.canGoForward + ? [ + { + label: 'Forward', + icon: ChevronRight, + onPress: () => { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.forward') + } + } + } + ] + : []), + { + label: 'Reload', + icon: RefreshCw, + onPress: () => { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.reload') + } + } + }, + { + label: 'Close', + destructive: true, + onPress: () => { + const current = target + onClose() + if (current) { + onCloseTab(current) + } + } + } + ]} + onClose={onClose} + /> + ) +} diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx new file mode 100644 index 00000000000..b485818970c --- /dev/null +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -0,0 +1,378 @@ +import { useMemo, useRef, useState } from 'react' +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' + +type Props = { + prompt: AskPrompt + /** Deliver the chosen answer (per-question option indices + free text) — + * index-based so Claude's arrow-navigate selector can be driven by the + * option's stable number instead of pasted label text (STA-1860). */ + onAnswer: (selections: AskAnswerSelection[]) => Promise + onCancel?: () => Promise +} + +// Sentinel index for the free-text "Other…" row (never a real option index). +const OTHER = -1 + +/** Native renderer for an agent's AskUserQuestion prompt as a wizard: one + * question per step with tabs across the top, a Next button that advances (Send + * on the last step), and a Cancel that dismisses the prompt. Neutral styling + * with a subtle green accent on the active choice to match the rest of the app. */ +export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): React.JSX.Element { + const [index, setIndex] = useState(0) + const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) + const [otherText, setOtherText] = useState(() => prompt.questions.map(() => '')) + const [submitting, setSubmitting] = useState(false) + const submittingRef = useRef(false) + + const toggle = (qi: number, optIndex: number, multi: boolean): void => { + setSelections((prev) => { + const next = prev.map((s) => [...s]) + const cur = next[qi] ?? [] + if (multi) { + next[qi] = cur.includes(optIndex) ? cur.filter((i) => i !== optIndex) : [...cur, optIndex] + } else { + next[qi] = cur.includes(optIndex) ? [] : [optIndex] + } + return next + }) + } + + const setOther = (qi: number, value: string): void => { + setOtherText((prev) => { + const next = [...prev] + next[qi] = value + return next + }) + } + + const selectionFor = (qi: number): AskAnswerSelection => { + const picked = (selections[qi] ?? []).filter((i) => i !== OTHER) + const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : '' + return other ? { indices: picked, other } : { indices: picked } + } + + const isAnswered = (qi: number): boolean => { + const sel = selectionFor(qi) + return sel.indices.length > 0 || (sel.other ?? '').length > 0 + } + + const total = prompt.questions.length + const isLast = index === total - 1 + const currentAnswered = useMemo( + () => isAnswered(index), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selections, otherText, index] + ) + const allAnswered = useMemo( + () => prompt.questions.every((_, i) => isAnswered(i)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [otherText, prompt.questions, selections] + ) + const canAdvance = !submitting && (isLast ? allAnswered : currentAnswered) + + const submit = async (): Promise => { + if (!allAnswered || submittingRef.current) { + return + } + submittingRef.current = true + setSubmitting(true) + try { + await onAnswer(prompt.questions.map((_, i) => selectionFor(i))) + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + + const advance = async (): Promise => { + if (isLast) { + await submit() + } else { + setIndex((i) => Math.min(i + 1, total - 1)) + } + } + + const q = prompt.questions[index]! + const otherSelected = (selections[index] ?? []).includes(OTHER) + + return ( + + {total > 1 ? ( + + {prompt.questions.map((qq, i) => ( + setIndex(i)} + > + + {qq.header || `Step ${i + 1}`} + + {isAnswered(i) ? ( + + ) : null} + + ))} + + ) : null} + + + {q.question} + {q.options.map((opt, optIndex) => ( + toggle(index, optIndex, q.multiSelect)} + /> + ))} + toggle(index, OTHER, q.multiSelect)} + /> + {otherSelected ? ( + setOther(index, v)} + placeholder="Type your answer" + placeholderTextColor={colors.textMuted} + multiline + autoFocus + /> + ) : null} + + + + { + if (!submittingRef.current && onCancel) { + submittingRef.current = true + setSubmitting(true) + try { + await onCancel() + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + }} + disabled={submitting} + hitSlop={8} + > + Cancel + + {total > 1 ? ( + + {index + 1}/{total} + + ) : null} + + + {isLast ? 'Send answer' : 'Next'} + + + + + ) +} + +function OptionRow({ + label, + description, + selected, + multi, + onPress +}: { + label: string + description?: string + selected: boolean + multi?: boolean + onPress: () => void +}): React.JSX.Element { + return ( + + {/* Multi-select reads as a checkbox (square); single-select as a radio (circle). */} + + {selected ? : null} + + + {label} + {description ? ( + + {description} + + ) : null} + + + ) +} + +const styles = StyleSheet.create({ + card: { + maxHeight: 380, + backgroundColor: colors.bgPanel, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + tabs: { + flexGrow: 0, + paddingTop: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + tabsContent: { + paddingHorizontal: spacing.sm, + gap: spacing.xs, + alignItems: 'center' + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + minHeight: 36, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderBottomWidth: 2, + borderBottomColor: 'transparent' + }, + tabActive: { + borderBottomColor: colors.statusGreen + }, + tabText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '600' + }, + tabTextActive: { + color: colors.textPrimary + }, + scroll: { + paddingHorizontal: spacing.md + }, + questionText: { + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + fontWeight: '600', + marginVertical: spacing.sm + }, + option: { + flexDirection: 'row', + gap: spacing.sm, + padding: spacing.sm, + borderRadius: radii.card, + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginBottom: spacing.xs + }, + optionSelected: { + borderColor: colors.statusGreen + }, + check: { + width: 18, + height: 18, + borderWidth: 1.5, + borderColor: colors.textMuted, + alignItems: 'center', + justifyContent: 'center', + marginTop: 1 + }, + checkCircle: { + borderRadius: 9 + }, + checkSquare: { + borderRadius: 4 + }, + checkOn: { + backgroundColor: colors.statusGreen, + borderColor: colors.statusGreen + }, + optionBody: { + flex: 1, + gap: 2 + }, + optionLabel: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + optionDescription: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + input: { + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + color: colors.textPrimary, + fontSize: typography.bodySize, + padding: spacing.sm, + minHeight: 44, + marginBottom: spacing.xs + }, + footer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: spacing.md, + gap: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + cancel: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.sm + }, + cancelText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + progress: { + color: colors.textMuted, + fontSize: typography.metaSize + }, + next: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.lg, + borderRadius: radii.button, + backgroundColor: colors.textPrimary + }, + nextDisabled: { + backgroundColor: colors.bgRaised + }, + nextText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '700' + }, + nextTextDisabled: { + color: colors.textMuted + } +}) diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts new file mode 100644 index 00000000000..ef2c1012603 --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -0,0 +1,206 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatComposer } from './MobileNativeChatComposer' + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: unknown }) => + React.createElement('ScrollView', props, children), + StyleSheet: { + create: (styles: unknown) => styles, + hairlineWidth: 1 + }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' + } +}) + +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + ImagePlus: 'ImagePlus', + Mic: 'Mic', + Square: 'Square' +})) + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +describe('MobileNativeChatComposer', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function render( + onSend: (text: string) => Promise, + onChangeText: () => void, + isAttaching = false + ) { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: ' hello ', + onChangeText, + onSend, + isAttaching + }) + ) + }) + } finally { + restore() + } + } + + function sendButton(): { props: { onPress: () => Promise } } { + if (!renderer) { + throw new Error('Composer was not rendered') + } + return renderer.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Send message' + ) as { props: { onPress: () => Promise } } + } + + it('reports an accepted send without owning route-scoped draft cleanup', async () => { + const onChangeText = vi.fn() + const onSend = vi.fn().mockResolvedValue(true) + await render(onSend, onChangeText) + + await act(async () => sendButton().props.onPress()) + + expect(onSend).toHaveBeenCalledWith('hello') + expect(onChangeText).not.toHaveBeenCalled() + }) + + it('keeps the draft when the send is rejected', async () => { + const onChangeText = vi.fn() + const onSend = vi.fn().mockResolvedValue(false) + await render(onSend, onChangeText) + + await act(async () => sendButton().props.onPress()) + + expect(onSend).toHaveBeenCalledWith('hello') + expect(onChangeText).not.toHaveBeenCalled() + }) + + it('disables send while an attachment path is still being injected', async () => { + const onSend = vi.fn().mockResolvedValue(true) + await render(onSend, vi.fn(), true) + + expect(sendButton().props).toMatchObject({ disabled: true }) + await act(async () => sendButton().props.onPress()) + expect(onSend).not.toHaveBeenCalled() + }) + + it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '/c', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true) + }) + ) + }) + } finally { + restore() + } + const input = () => + renderer!.root.find((node) => node.type === 'TextInput') as { + props: { + selection?: { start: number; end: number } + onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void + } + } + // Uncontrolled selection until a suggestion is applied. + expect(input().props.selection).toBeUndefined() + // Place the caret at the end so the slash trigger is active and suggestions render. + await act(async () => + input().props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } }) + ) + const firstSuggestion = renderer!.root.findAll( + (node) => node.type === 'Pressable' && !node.props.accessibilityLabel + )[0] as { props: { onPress: () => void } } + await act(async () => firstSuggestion.props.onPress()) + // `/clear ` is 7 chars — the caret jumps just past the inserted command + space. + expect(input().props.selection).toEqual({ start: 7, end: 7 }) + // The next native selection event releases control so manual placement still works. + await act(async () => + input().props.onSelectionChange({ nativeEvent: { selection: { end: 7 } } }) + ) + expect(input().props.selection).toBeUndefined() + }) + + it('wires the mic for hold vs toggle dictation like the terminal composer', async () => { + const onMicPress = vi.fn() + const onMicPressIn = vi.fn() + const onMicPressOut = vi.fn() + const mic = () => + renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate' + ) as { props: { onPress?: unknown; onPressIn?: unknown; onPressOut?: unknown } } + + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + onMicPress, + dictationMode: 'hold', + onMicPressIn, + onMicPressOut + }) + ) + }) + // Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert. + expect(mic().props.onPress).toBeUndefined() + expect(mic().props.onPressIn).toBe(onMicPressIn) + expect(mic().props.onPressOut).toBe(onMicPressOut) + + await act(async () => { + renderer!.update( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + onMicPress, + dictationMode: 'toggle', + onMicPressIn, + onMicPressOut + }) + ) + }) + // Toggle mode: tap drives dictation, press-in/out inert. + expect(mic().props.onPress).toBe(onMicPress) + expect(mic().props.onPressIn).toBeUndefined() + expect(mic().props.onPressOut).toBeUndefined() + } finally { + restore() + } + }) +}) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx new file mode 100644 index 00000000000..ba02fe3d44b --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -0,0 +1,285 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View +} from 'react-native' +import { ArrowUp, ImagePlus, Mic, Square } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { + applyAutocomplete, + detectAutocompleteTrigger, + rankSuggestions +} from './mobile-native-chat-autocomplete' + +// Common agent slash commands offered as autocomplete; sending them is just text +// to the agent's terminal, so the set is intentionally provider-agnostic. +const SLASH_COMMANDS = [ + '/clear', + '/compact', + '/review', + '/model', + '/help', + '/init', + '/cost', + '/diff' +] + +const NO_FILE_PATHS: string[] = [] + +type Props = { + /** Controlled composer text — owned by the parent so dictation can write to it. */ + value: string + onChangeText: (text: string) => void + onSend: (text: string) => Promise + onAttachImage?: () => void + isAttaching?: boolean + onMicPress?: () => void + micActive?: boolean + /** Dictation trigger style — 'hold' uses press-in/out, 'toggle' uses tap. */ + dictationMode?: 'toggle' | 'hold' + onMicPressIn?: () => void + onMicPressOut?: () => void + disabled?: boolean + placeholder?: string + filePaths?: string[] + onNeedFiles?: (query: string) => void +} + +export function MobileNativeChatComposer({ + value, + onChangeText, + onSend, + onAttachImage, + isAttaching = false, + onMicPress, + micActive = false, + dictationMode = 'toggle', + onMicPressIn, + onMicPressOut, + disabled = false, + placeholder = 'Message, @files, /commands', + filePaths = NO_FILE_PATHS, + onNeedFiles +}: Props): React.JSX.Element { + const [cursor, setCursor] = useState(0) + // Transiently drives the native caret after a mid-text autocomplete insert, + // then released on the next selection change so manual caret placement still + // works (a permanently controlled `selection` breaks it in React Native). + const [pendingSelection, setPendingSelection] = useState<{ start: number; end: number } | null>( + null + ) + const sendingRef = useRef(false) + const [sending, setSending] = useState(false) + const trimmed = value.trim() + const canSend = trimmed.length > 0 && !disabled && !sending && !isAttaching + + const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor]) + const suggestions = useMemo(() => { + if (!trigger) { + return [] + } + if (trigger.kind === 'slash') { + return rankSuggestions(SLASH_COMMANDS, trigger.query) + } + return rankSuggestions(filePaths, trigger.query).map((p) => `@${p}`) + }, [trigger, filePaths]) + + useEffect(() => { + if (trigger?.kind === 'file') { + onNeedFiles?.(trigger.query) + } + }, [onNeedFiles, trigger?.kind, trigger?.query]) + + const handleChange = (next: string): void => { + onChangeText(next) + } + + const pickSuggestion = (suggestion: string): void => { + if (!trigger) { + return + } + const { text: nextText, cursor: nextCursor } = applyAutocomplete(value, trigger, suggestion) + onChangeText(nextText) + setCursor(nextCursor) + setPendingSelection({ start: nextCursor, end: nextCursor }) + } + + const handleSend = async (): Promise => { + if (!canSend || sendingRef.current) { + return + } + sendingRef.current = true + setSending(true) + try { + const accepted = await onSend(trimmed) + if (accepted) { + setCursor(0) + } + } finally { + sendingRef.current = false + setSending(false) + } + } + + return ( + + {suggestions.length > 0 ? ( + + + {suggestions.map((s) => ( + [styles.suggestion, pressed && styles.suggestionPressed]} + onPress={() => pickSuggestion(s)} + > + + {s} + + + ))} + + + ) : null} + + {onAttachImage ? ( + [styles.iconButton, pressed && styles.pressed]} + onPress={onAttachImage} + disabled={isAttaching || disabled} + > + {isAttaching ? ( + + ) : ( + + )} + + ) : null} + { + setCursor(e.nativeEvent.selection.end) + setPendingSelection(null) + }} + placeholder={placeholder} + placeholderTextColor={colors.textMuted} + selectionColor={colors.accentBlue} + multiline + editable={!disabled} + textAlignVertical="top" + /> + {onMicPress ? ( + [styles.iconButton, pressed && styles.pressed]} + // Hold mode is walkie-talkie (press-in/out); toggle mode taps. + onPress={dictationMode === 'hold' ? undefined : onMicPress} + onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined} + onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined} + disabled={disabled} + > + {micActive ? ( + + ) : ( + + )} + + ) : null} + [ + styles.sendButton, + !canSend && styles.sendButtonDisabled, + pressed && canSend && styles.pressed + ]} + onPress={handleSend} + disabled={!canSend} + > + + + + + ) +} + +const styles = StyleSheet.create({ + suggestions: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + suggestionScroll: { + maxHeight: 180 + }, + suggestion: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + suggestionPressed: { + backgroundColor: colors.bgRaised + }, + suggestionText: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: typography.metaSize + }, + bar: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + input: { + flex: 1, + maxHeight: 140, + minHeight: 40, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm + }, + iconButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center' + }, + sendButton: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + // White send affordance per design — dark arrow on a light circle. + backgroundColor: colors.textPrimary + }, + sendButtonDisabled: { + backgroundColor: colors.bgRaised + }, + pressed: { + opacity: 0.7 + } +}) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx new file mode 100644 index 00000000000..eadfd2043f8 --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -0,0 +1,371 @@ +import { memo, useEffect, useRef, useState } from 'react' +import { Pressable, Text, View } from 'react-native' +import * as Clipboard from 'expo-clipboard' +import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native' +import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileMarkdown } from '../components/MobileMarkdown' +import { colors } from '../theme/mobile-theme' +import { + isImageRefBlock, + isTextBlock, + pairToolBlocks, + splitNativeChatBlocks, + type ToolPair +} from './mobile-native-chat-blocks' +import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff' +import { MAX_TOOL_RESULT_CHARS, styles, TEXT_SIZE } from './mobile-native-chat-message-styles' +import { nativeChatMessageText } from './mobile-native-chat-message-text' +import { + summarizeToolInput, + summarizeToolRun, + toolFilePath +} from './mobile-native-chat-tool-summary' + +const MAX_VISIBLE_TOOL_PAIRS = 6 +const MAX_TOOL_RUN_DIFF_ROWS = 240 + +function DiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element { + return ( + + {lines.map((line, i) => ( + + {line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '} + {line.text} + + ))} + + ) +} + +/** A single inline tool line — `▸ ToolName preview` — that expands in place to + * show the call's diff/input or the result's body. Mirrors the reference design + * where tool calls read as flat lines in the conversation, not boxed blocks. */ +function ResultBody({ + output, + isError, + diff +}: { + output: string + isError?: boolean + diff: DiffLine[] | null +}): React.JSX.Element { + if (diff) { + return + } + return ( + + + {output.length > MAX_TOOL_RESULT_CHARS + ? `${output.slice(0, MAX_TOOL_RESULT_CHARS)}…` + : output} + + + ) +} + +/** One request: a tool call and its result rendered together as a single + * expandable line. `defaultExpanded` lets the group toggle open every line. */ +function ToolLine({ + pair, + defaultExpanded, + diffLineLimit, + onOpenFile +}: { + pair: ToolPair + defaultExpanded: boolean + diffLineLimit: number + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const [expanded, setExpanded] = useState(defaultExpanded) + const { call, result } = pair + const name = call ? call.name : 'Result' + const preview = call + ? summarizeToolInput(call.input) + : (result?.output.split('\n')[0]?.slice(0, 80) ?? '') + // Why: collapsed tool rows are the common path; defer bounded diff parsing + // until the user asks to reveal the detail. + const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null + const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null + const hasDetail = callDiff !== null || result !== undefined || preview.length > 40 + // A tool that targets a file (Read/Edit/Write…) renders its preview as a + // tappable link that opens the file, independent of the line's expand tap. + const filePath = call ? toolFilePath(call.input) : null + const openable = filePath !== null && onOpenFile !== undefined + return ( + + hasDetail && setExpanded((v) => !v)} + hitSlop={6} + > + {expanded ? ( + + ) : ( + + )} + {name} + {preview ? ( + onOpenFile!(filePath!) : undefined} + suppressHighlighting={!openable} + > + {preview} + + ) : null} + + {expanded ? ( + + {callDiff ? : null} + {!callDiff && call && preview ? {preview} : null} + {result ? ( + + ) : null} + + ) : null} + + ) +} + +function Prose({ + block, + invert, + fontScale, + onOpenFile +}: { + block: NativeChatBlock + invert?: boolean + fontScale: number + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element | null { + if (isTextBlock(block)) { + // Inverted (user) bubbles use a fixed dark-on-light text rather than the + // markdown renderer's light-on-dark palette. + if (invert) { + return ( + {block.text} + ) + } + return ( + + ) + } + if (isImageRefBlock(block)) { + return ( + + 🖼 {block.alt ?? block.path ?? block.url ?? 'image'} + + ) + } + return null +} + +/** A run of a message's tool calls/results, collapsed to a one-line summary that + * expands to the individual inline tool lines. `defaultExpanded` lets the global + * toolbar toggle drive every run at once while still allowing per-run override. */ +function ToolRun({ + blocks, + defaultExpanded, + trailing, + onOpenFile +}: { + blocks: NativeChatBlock[] + defaultExpanded: boolean + trailing?: React.ReactNode + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const [open, setOpen] = useState(defaultExpanded) + const pairs = pairToolBlocks(blocks, MAX_VISIBLE_TOOL_PAIRS) + const diffLineLimit = Math.max(1, Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (pairs.length * 2 || 1))) + let callCount = 0 + for (const block of blocks) { + if (block.type === 'tool-call') { + callCount++ + } + } + callCount ||= pairs.length + const summary = summarizeToolRun(blocks) + return ( + + + setOpen((v) => !v)} hitSlop={6}> + {open ? ( + + ) : ( + + )} + {callCount}× + + {summary || `${callCount} tool ${callCount === 1 ? 'call' : 'calls'}`} + + + {trailing} + + {open ? ( + + {pairs.map((pair, i) => ( + + ))} + {callCount > pairs.length ? ( + … {callCount - pairs.length} more tool calls + ) : null} + + ) : null} + + ) +} + +/** Subtle top-right controls for an agent message: copy its prose, or scroll so + * this message's top aligns to the top of the viewport. */ +function AgentControls({ + onCopy, + onScrollToTop +}: { + onCopy: () => void + onScrollToTop?: () => void +}): React.JSX.Element { + return ( + + [styles.controlButton, pressed && styles.controlPressed]} + onPress={onCopy} + hitSlop={8} + accessibilityLabel="Copy message" + > + + + {onScrollToTop ? ( + [styles.controlButton, pressed && styles.controlPressed]} + onPress={onScrollToTop} + hitSlop={8} + accessibilityLabel="Scroll this message to top" + > + + + ) : null} + + ) +} + +function MobileNativeChatMessageImpl({ + message, + queued, + toolsExpanded = false, + fontScale = 1, + messageIndex, + onScrollToMessage, + onOpenFile +}: { + message: NativeChatMessage + queued?: boolean + toolsExpanded?: boolean + /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ + fontScale?: number + /** This message's index in the list, paired with onScrollToMessage. */ + messageIndex?: number + /** Ask the list to align this message's top to the top of the viewport. */ + onScrollToMessage?: (index: number) => void + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const isUser = message.role === 'user' + const isReasoning = message.role === 'reasoning' + const isAgent = !isUser + // Briefly tint the bubble to confirm a copy landed. + const [copied, setCopied] = useState(false) + const copyTimer = useRef | null>(null) + useEffect( + () => () => { + if (copyTimer.current) { + clearTimeout(copyTimer.current) + } + }, + [] + ) + // Separate the agent's words from its tool activity: prose renders first, the + // tool calls fold into a collapsible run beneath. The user's own messages get + // an inverted (filled accent) bubble so they stand apart from agent prose. + const { prose, tools } = splitNativeChatBlocks(message.blocks) + + const handleCopy = (): void => { + const text = nativeChatMessageText(message.blocks) + if (!text) { + return + } + void Clipboard.setStringAsync(text) + setCopied(true) + if (copyTimer.current) { + clearTimeout(copyTimer.current) + } + copyTimer.current = setTimeout(() => setCopied(false), 700) + } + + // Copy + scroll-to-top, shown inline with the first tool call (or after the + // prose when there are no tools). + const controls = + isAgent && !queued ? ( + onScrollToMessage(messageIndex) + : undefined + } + /> + ) : null + + return ( + + {isUser && queued ? Queued : null} + + {prose.map((block, index) => ( + + ))} + {tools.length > 0 ? ( + + ) : controls ? ( + {controls} + ) : null} + + + ) +} + +export const MobileNativeChatMessage = memo(MobileNativeChatMessageImpl) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx new file mode 100644 index 00000000000..6acf2d305f6 --- /dev/null +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -0,0 +1,79 @@ +import { StyleSheet, View } from 'react-native' +import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView' +import type { MobileNativeChatController } from './use-mobile-native-chat-controller' + +type Props = { + controller: MobileNativeChatController + onAttachImage: () => void + isAttaching: boolean + onMicPress: () => void + micActive: boolean + dictationMode: 'toggle' | 'hold' + onMicPressIn: () => void + onMicPressOut: () => void + inputLockReason: MobileNativeChatInputLockReason | null + keyboardInset: number +} + +/** Keeps the terminal mounted underneath chat so its PTY subscription survives + * view toggles while the native surface owns the visible composer. */ +export function MobileNativeChatOverlay({ + controller, + onAttachImage, + isAttaching, + onMicPress, + micActive, + dictationMode, + onMicPressIn, + onMicPressOut, + inputLockReason, + keyboardInset +}: Props): React.JSX.Element | null { + if (!controller.showNativeChat) { + return null + } + const session = controller.nativeChatSession + return ( + + + + ) +} + +const styles = StyleSheet.create({ + overlay: StyleSheet.absoluteFillObject +}) diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts new file mode 100644 index 00000000000..8e0223f19ec --- /dev/null +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -0,0 +1,60 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatPermission } from './MobileNativeChatPermission' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion' })) + +describe('MobileNativeChatPermission', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('accepts only one response when two presses land in the same render batch', async () => { + let resolveResponse: (accepted: boolean) => void = () => {} + const response = new Promise((resolve) => (resolveResponse = resolve)) + const onRespond = vi.fn(() => response) + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatPermission, { + permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, + onRespond + }) + ) + }) + } finally { + consoleSpy.mockRestore() + } + const button = renderer.root.findByType('Pressable') + + act(() => { + button.props.onPress() + button.props.onPress() + }) + + expect(onRespond).toHaveBeenCalledOnce() + await act(async () => resolveResponse(true)) + }) +}) diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx new file mode 100644 index 00000000000..ad26d578d93 --- /dev/null +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -0,0 +1,123 @@ +import { memo, useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { ShieldQuestion } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { MobileChatPermission } from './mobile-native-chat-permission' + +// Renders a detected agent permission ask as a card with tappable options. +// The first option is treated as the primary (allow) action and gets a filled +// accent button so the affirmative choice reads as distinct from the rest. +function MobileNativeChatPermissionImpl({ + permission, + onRespond +}: { + permission: MobileChatPermission + onRespond: (send: string) => Promise +}): React.JSX.Element { + const [submitting, setSubmitting] = useState(false) + const submittingRef = useRef(false) + const respond = async (send: string): Promise => { + if (submittingRef.current) { + return + } + submittingRef.current = true + setSubmitting(true) + const accepted = await onRespond(send) + if (!accepted) { + submittingRef.current = false + setSubmitting(false) + } + } + return ( + + + + {permission.title} + + {permission.detail ? {permission.detail} : null} + + {permission.options.map((option, index) => { + const isPrimary = index === 0 + return ( + [ + styles.option, + isPrimary ? styles.optionPrimary : styles.optionSecondary, + pressed && !submitting && styles.optionPressed + ]} + hitSlop={6} + onPress={() => respond(option.send)} + disabled={submitting} + > + + {option.label} + + + ) + })} + + + ) +} + +export const MobileNativeChatPermission = memo(MobileNativeChatPermissionImpl) + +const styles = StyleSheet.create({ + card: { + marginHorizontal: spacing.lg, + marginVertical: spacing.sm, + padding: spacing.md, + gap: spacing.sm, + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + title: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + detail: { + color: colors.textSecondary, + fontSize: typography.metaSize, + lineHeight: typography.metaSize + 5 + }, + options: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm + }, + option: { + minHeight: 44, + justifyContent: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + optionPrimary: { + backgroundColor: colors.accentBlue + }, + optionSecondary: { + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + optionPressed: { + opacity: 0.7 + }, + optionText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + optionTextPrimary: { + color: colors.onAccent + } +}) diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx new file mode 100644 index 00000000000..f470214dbed --- /dev/null +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -0,0 +1,268 @@ +import { useMemo, useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' +import { ArrowUp, Check, CircleHelp } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question' + +type Props = { + question: MobileChatQuestion + onAnswer: (text: string) => Promise +} + +/** Renders an agent's choice prompt as a tappable card. Single-select answers + * on tap; multi-select toggles then Submits; an always-present text entry lets + * the user answer freely (the escape hatch) when the heuristic misreads the + * options or none apply. */ +export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.JSX.Element { + const [selected, setSelected] = useState([]) + const [freeText, setFreeText] = useState('') + const [sending, setSending] = useState(false) + const sendingRef = useRef(false) + + const hasOptions = question.options.length > 0 + const trimmedFreeText = freeText.trim() + + const toggle = (option: string): void => { + setSelected((prev) => + prev.includes(option) ? prev.filter((o) => o !== option) : [...prev, option] + ) + } + + const sendAnswer = async (text: string): Promise => { + if (sendingRef.current) { + return false + } + sendingRef.current = true + setSending(true) + try { + return await onAnswer(text) + } finally { + sendingRef.current = false + setSending(false) + } + } + + const answerSingle = async (option: string): Promise => { + await sendAnswer(formatQuestionAnswer(question, [option])) + } + + const submitMulti = async (): Promise => { + if (selected.length === 0) { + return + } + await sendAnswer(formatQuestionAnswer(question, selected)) + } + + const submitFreeText = async (): Promise => { + if (trimmedFreeText.length === 0) { + return + } + // Free text is an unknown entry; formatQuestionAnswer passes it through. + if (await sendAnswer(formatQuestionAnswer(question, [trimmedFreeText]))) { + setFreeText('') + } + } + + const canSubmitMulti = selected.length > 0 && !sending + const canSendFreeText = trimmedFreeText.length > 0 && !sending + + // Stable keys for option rows even if an agent repeats a label. + const optionRows = useMemo( + () => question.options.map((label, index) => ({ label, key: `${index}:${label}` })), + [question.options] + ) + + return ( + + + + {question.question} + + + {hasOptions ? ( + + {optionRows.map(({ label, key }) => { + const isSelected = selected.includes(label) + return ( + [ + styles.option, + isSelected && styles.optionSelected, + pressed && styles.pressed + ]} + onPress={() => (question.multiSelect ? toggle(label) : answerSingle(label))} + > + {question.multiSelect ? ( + + {isSelected ? : null} + + ) : null} + {label} + + ) + })} + + ) : null} + + {question.multiSelect && hasOptions ? ( + [ + styles.submit, + !canSubmitMulti && styles.submitDisabled, + pressed && canSubmitMulti && styles.pressed + ]} + onPress={submitMulti} + disabled={!canSubmitMulti} + > + + Submit{selected.length > 0 ? ` (${selected.length})` : ''} + + + ) : null} + + + + [ + styles.freeSend, + !canSendFreeText && styles.freeSendDisabled, + pressed && canSendFreeText && styles.pressed + ]} + onPress={submitFreeText} + disabled={!canSendFreeText} + > + + + + + ) +} + +const styles = StyleSheet.create({ + card: { + marginHorizontal: spacing.lg, + marginVertical: spacing.sm, + padding: spacing.md, + gap: spacing.sm, + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + question: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + fontWeight: '600', + lineHeight: typography.bodySize + 7 + }, + options: { + gap: spacing.xs + }, + option: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 44, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + optionSelected: { + borderColor: colors.accentBlue + }, + optionText: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize + 1 + }, + checkbox: { + width: 20, + height: 20, + borderRadius: radii.button, + borderWidth: 1.5, + borderColor: colors.textMuted, + alignItems: 'center', + justifyContent: 'center' + }, + checkboxOn: { + backgroundColor: colors.accentBlue, + borderColor: colors.accentBlue + }, + submit: { + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + backgroundColor: colors.accentBlue + }, + submitDisabled: { + backgroundColor: colors.bgRaised + }, + submitText: { + color: colors.onMergeGreen, + fontSize: typography.bodySize + 1, + fontWeight: '600' + }, + submitTextDisabled: { + color: colors.textMuted + }, + freeTextRow: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: spacing.sm + }, + freeInput: { + flex: 1, + minHeight: 40, + maxHeight: 120, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm + }, + freeSend: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.textPrimary + }, + freeSendDisabled: { + backgroundColor: colors.bgRaised + }, + pressed: { + opacity: 0.7 + } +}) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx new file mode 100644 index 00000000000..1cfe37e5d4d --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -0,0 +1,425 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + FlatList, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + Text, + View +} from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' +import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-native-chat-view-styles' +import { + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages, + mobileNativeChatEmptyState +} from './mobile-native-chat-render-data' +import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' +import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' +import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' +import { MobileNativeChatComposer } from './MobileNativeChatComposer' +import { MobileNativeChatMessage } from './MobileNativeChatMessage' +import { MobileNativeChatAsk } from './MobileNativeChatAsk' +import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' +import { MobileNativeChatPermission } from './MobileNativeChatPermission' +import type { MobileChatPermission } from './mobile-native-chat-permission' +import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' +import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question' +import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' + +/** Why the composer input is locked: the transport is disconnected, or the + * terminal subscription has not acknowledged its input lease yet. */ +export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' + +type Props = { + messages: NativeChatMessage[] + status: MobileNativeChatStatus + error?: string + /** Resolved agent for this chat; names the empty-state copy (desktop parity). */ + agent?: string | null + agentWorking?: boolean + /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ + onStop?: () => void + /** Live partial assistant text while a turn is still streaming (from the agent + * status hook). Shown as an in-progress bubble until the transcript catches up. */ + streamingText?: string + hasMore?: boolean + loadingEarlier?: boolean + onLoadEarlier?: () => void + onSend: (text: string) => Promise + /** Optimistic queued sends (owned by the route so they survive view switches). */ + pending: Array<{ id: string; text: string }> + /** Controlled composer text (owned by the route so dictation can write to it). */ + composerText: string + onComposerTextChange: (text: string) => void + onAttachImage?: () => void + isAttaching?: boolean + onMicPress?: () => void + micActive?: boolean + dictationMode?: 'toggle' | 'hold' + onMicPressIn?: () => void + onMicPressOut?: () => void + inputLockReason?: MobileNativeChatInputLockReason | null + filePaths?: string[] + onNeedFiles?: (query: string) => void + /** A pending agent question/permission detected from live status, shown as a + * native card above the composer; answering sends text to the agent. */ + /** Structured AskUserQuestion prompt parsed from the transcript (preferred over + * the heuristic question card). */ + ask?: AskPrompt | null + /** Deliver the ask answer as per-question selections; the send hook turns them + * into selector keystrokes (Claude) or pasted label text (other agents). */ + onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise + onCancelAsk?: () => Promise + question?: MobileChatQuestion | null + onAnswerQuestion?: (text: string) => Promise + permission?: MobileChatPermission | null + onRespondPermission?: (send: string) => Promise + /** Open a worktree file tapped in agent markdown. */ + onOpenFile?: (relativePath: string) => void + /** Pixels to lift the composer by when the soft keyboard is open. The route + * owns keyboard tracking (the app uses manual lift, not KeyboardAvoidingView). */ + keyboardInset?: number +} + +export function MobileNativeChatView({ + messages, + status, + error, + agent, + agentWorking, + onStop, + streamingText, + hasMore, + loadingEarlier, + onLoadEarlier, + onSend, + pending, + composerText, + onComposerTextChange, + onAttachImage, + isAttaching, + onMicPress, + micActive, + dictationMode, + onMicPressIn, + onMicPressOut, + inputLockReason, + filePaths, + onNeedFiles, + ask, + onAnswerAsk, + onCancelAsk, + question, + onAnswerQuestion, + permission, + onRespondPermission, + onOpenFile, + keyboardInset = 0 +}: Props): React.JSX.Element { + const insets = useSafeAreaInsets() + const listRef = useRef>(null) + const [toolsExpanded, setToolsExpanded] = useState(false) + // Dismiss the question card as soon as it's answered; the live status lingers + // briefly (the agent emits a post-tool event with the same prompt), so hide it + // until a genuinely different question arrives. + const { askKey, showAsk, dismissAsk } = useMobileNativeChatAskDismiss(ask) + // Lift the composer clear of the keyboard, plus the bottom safe-area so it + // never sits under the home indicator / nav bar (mirrors the terminal dock). + const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom + const [atBottom, setAtBottom] = useState(true) + const sendScrollTimerRef = useRef | null>(null) + const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture() + // Surface a rejected send inline above the composer — a bottom toast gets hidden + // behind the keyboard (the case that prompted this). Auto-dismisses after a beat. + const [sendFailed, setSendFailed] = useState(false) + useEffect(() => { + if (!sendFailed) { + return + } + const t = setTimeout(() => setSendFailed(false), 4000) + return () => clearTimeout(t) + }, [sendFailed]) + + useEffect( + () => () => { + if (sendScrollTimerRef.current) { + clearTimeout(sendScrollTimerRef.current) + } + }, + [] + ) + + const pendingIds = useMemo(() => new Set(pending.map((p) => p.id)), [pending]) + // `data` is the list source: folded transcript + synthetic streaming bubble + + // route-owned optimistic queued messages. Memoize on the same deps so the + // downstream autoscroll effects/`renderItem` keep referential stability. + const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages]) + const { data } = useMemo( + () => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }), + [foldedMessages, streamingText, pending] + ) + + // Follow the tail as the conversation grows and keep the newest message above + // the keyboard when it opens — but only when already pinned to the bottom, so + // we don't yank the user away while they read history. (Also fires on keyboard + // close, which is harmless while atBottom.) + useEffect(() => { + if (data.length === 0 || !atBottom) { + return + } + const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60) + return () => clearTimeout(t) + }, [data.length, atBottom, keyboardInset]) + + const handleSend = useCallback( + async (text: string): Promise => { + const accepted = await onSend(text) + if (!accepted) { + setSendFailed(true) + return false + } + setSendFailed(false) + // Always jump to the newest message when the user sends. + setAtBottom(true) + if (sendScrollTimerRef.current) { + clearTimeout(sendScrollTimerRef.current) + } + sendScrollTimerRef.current = setTimeout(() => { + sendScrollTimerRef.current = null + listRef.current?.scrollToEnd({ animated: true }) + }, 60) + return true + }, + [onSend] + ) + + const onScroll = useCallback( + (e: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent + const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height) + setAtBottom(distanceFromBottom < 80) + // Near the top — page in older history. + if (contentOffset.y < 60 && hasMore && !loadingEarlier) { + onLoadEarlier?.() + } + }, + [hasMore, loadingEarlier, onLoadEarlier] + ) + + // Align a single message's top to the top of the viewport. + const onScrollToMessage = useCallback((index: number) => { + listRef.current?.scrollToIndex({ index, viewPosition: 0, animated: true }) + }, []) + + const renderItem = useCallback( + ({ item, index }: { item: NativeChatMessage; index: number }) => ( + + ), + [pendingIds, toolsExpanded, fontScale, onScrollToMessage, onOpenFile] + ) + + const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) + const showLoading = status === 'loading' && messages.length === 0 + + // Composer-lock flicker guard: on a remote link, brief connState blips or lease + // hand-offs would otherwise toggle the lock placeholder on and off. Only surface + // a lock once it has held ~600ms; drop it instantly so unlocking stays snappy. + const rawLockReason = inputLockReason ?? null + const [lockHeld, setLockHeld] = useState(false) + useEffect(() => { + if (rawLockReason === null) { + setLockHeld(false) + return + } + const timer = setTimeout(() => setLockHeld(true), 600) + return () => clearTimeout(timer) + }, [rawLockReason]) + const lockReason = lockHeld ? rawLockReason : null + + return ( + + {showLoading ? ( + + + + ) : ( + + + item.id} + renderItem={renderItem} + contentContainerStyle={styles.listContent} + onScroll={onScroll} + scrollEventThrottle={32} + onContentSizeChange={() => { + if (data.length > 0 && atBottom) { + listRef.current?.scrollToEnd({ animated: false }) + } + }} + // scrollToIndex can fail before an off-screen row is measured — + // fall back to an estimated offset, then retry once it's laid out. + onScrollToIndexFailed={(info) => { + listRef.current?.scrollToOffset({ + offset: info.averageItemLength * info.index, + animated: true + }) + setTimeout(() => { + listRef.current?.scrollToIndex({ + index: info.index, + viewPosition: 0, + animated: true + }) + }, 120) + }} + ListHeaderComponent={ + hasMore ? ( + + {loadingEarlier ? ( + + ) : ( + Load earlier messages + )} + + ) : null + } + ListEmptyComponent={ + emptyState ? ( + + {emptyState.title} + {emptyState.subtitle} + + ) : null + } + /> + + {/* Jump-to-latest control. The scroll-to-top affordance now lives + per-message (the up-arrow in each agent message's controls). */} + {!atBottom ? ( + listRef.current?.scrollToEnd({ animated: true })} + > + + + ) : null} + + )} + {/* Pending agent prompt: a structured AskUserQuestion wins, then a + heuristic permission, then a heuristic question. */} + {showAsk && ask ? ( + { + const accepted = (await onAnswerAsk?.(ask, selections)) ?? false + if (accepted) { + dismissAsk() + } + return accepted + }} + onCancel={async () => { + const accepted = (await onCancelAsk?.()) ?? false + if (accepted) { + dismissAsk() + } + return accepted + }} + /> + ) : permission ? ( + (await onRespondPermission?.(send)) ?? false} + /> + ) : question ? ( + (await onAnswerQuestion?.(text)) ?? false} + /> + ) : null} + {/* Chrome row above the composer: the working indicator and the global + tool-calls expand/collapse toggle on the left, Stop in the far corner. */} + + + {agentWorking ? : null} + [styles.chromeToggle, pressed && styles.pressed]} + onPress={() => setToolsExpanded((v) => !v)} + hitSlop={8} + > + {toolsExpanded ? ( + + ) : ( + + )} + {toolsExpanded ? 'Collapse' : 'Tools'} + + + {agentWorking ? ( + [styles.stopButton, pressed && styles.pressed]} + onPress={onStop} + hitSlop={8} + accessibilityLabel="Stop the agent" + > + + Stop + + ) : null} + + {sendFailed ? ( + + + {rawLockReason === 'disconnected' + ? 'Message not sent — reconnecting…' + : 'Message not sent'} + + + ) : null} + + + ) +} diff --git a/mobile/src/session/mobile-image-attachment.test.ts b/mobile/src/session/mobile-image-attachment.test.ts index 1bf3df59e74..9d725d5fe60 100644 --- a/mobile/src/session/mobile-image-attachment.test.ts +++ b/mobile/src/session/mobile-image-attachment.test.ts @@ -8,9 +8,9 @@ function ok(id: string, result: unknown): RpcSuccess { } function clientWithResponses(responses: RpcResponse[]): Pick & { - calls: Array<{ method: string; params: unknown }> + calls: { method: string; params: unknown }[] } { - const calls: Array<{ method: string; params: unknown }> = [] + const calls: { method: string; params: unknown }[] = [] return { calls, sendRequest: vi.fn(async (method: string, params?: unknown) => { @@ -35,7 +35,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/orca-attach.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) const sent = await attachMobileImageToTerminal('library', { @@ -65,7 +65,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/x.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) await attachMobileImageToTerminal('files', { @@ -104,7 +104,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/y.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) await attachMobileImageToTerminal('library', { @@ -119,7 +119,7 @@ describe('attachMobileImageToTerminal', () => { expect(sendCall?.params).not.toHaveProperty('client') }) - it('waits for pending live input before sending the image payload', async () => { + it('drops the image payload when the final pre-send check observes an input lease gap', async () => { const client = clientWithResponses([ { id: 'start', @@ -129,6 +129,8 @@ describe('attachMobileImageToTerminal', () => { }, ok('save', '/tmp/pending.png') ]) + // Why: upload can outlive the stream subscription whose acknowledgement + // originally enabled the composer. const beforeTerminalSend = vi.fn(async () => false) const sent = await attachMobileImageToTerminal('library', { @@ -144,4 +146,27 @@ describe('attachMobileImageToTerminal', () => { expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending') expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) }) + + it('reports failure when the terminal rejects the uploaded image path', async () => { + const client = clientWithResponses([ + { + id: 'start', + ok: false, + error: { code: 'method_not_found', message: 'no' }, + _meta: { runtimeId: 'r' } + }, + ok('save', '/tmp/rejected.png'), + ok('send', { send: { accepted: false } }) + ]) + + const sent = await attachMobileImageToTerminal('library', { + client, + terminal: 'term-rejected', + deviceToken: null, + getConnectionId: async () => null, + pickImage: vi.fn().mockResolvedValue({ base64: 'EEEE' }) + }) + + expect(sent).toBe(false) + }) }) diff --git a/mobile/src/session/mobile-image-attachment.ts b/mobile/src/session/mobile-image-attachment.ts index b7d14d9a04d..567be99a9a1 100644 --- a/mobile/src/session/mobile-image-attachment.ts +++ b/mobile/src/session/mobile-image-attachment.ts @@ -4,6 +4,7 @@ import { saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image' import type { MobileImageSource, PickedMobileImage } from './mobile-image-source-picker' +import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' export type AttachMobileImageDeps = { readonly client: Pick @@ -50,11 +51,11 @@ export async function attachMobileImageToTerminal( if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) { return false } - await client.sendRequest('terminal.send', { + const response = await client.sendRequest('terminal.send', { terminal, text: payload, enter: false, ...(deviceToken ? { client: { id: deviceToken, type: 'mobile' as const } } : {}) }) - return true + return isTerminalSendRpcAccepted(response) } diff --git a/mobile/src/session/mobile-native-chat-answer-stepping.test.ts b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts new file mode 100644 index 00000000000..05e05b1f188 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mobileNativeChatQuestionOffsets, + scheduleMobileClaudeAnswer, + MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS, + MOBILE_NATIVE_CHAT_QUESTION_STEP_MS, + MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS +} from './mobile-native-chat-answer-stepping' + +describe('mobileNativeChatQuestionOffsets', () => { + it('matches the desktop cadence constants', () => { + expect(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500) + expect(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(500) + expect(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS).toBe(1000) + }) + + it('paces each question a full step apart, Enter 500ms after its body', () => { + expect(mobileNativeChatQuestionOffsets(0)).toEqual({ bodyAt: 0, enterAt: 500 }) + expect(mobileNativeChatQuestionOffsets(1)).toEqual({ bodyAt: 1000, enterAt: 1500 }) + expect(mobileNativeChatQuestionOffsets(2)).toEqual({ bodyAt: 2000, enterAt: 2500 }) + }) +}) + +describe('scheduleMobileClaudeAnswer', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('writes each question body then its Enter, paced per step, one Enter per line', () => { + const events: string[] = [] + const timers = scheduleMobileClaudeAnswer( + ['', 'b', 'c'], // blank middle answer must still get its own body + Enter + (line) => events.push(`body:${line}`), + () => events.push('enter') + ) + expect(timers).toHaveLength(6) + + vi.advanceTimersByTime(0) + expect(events).toEqual(['body:']) + vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS) + expect(events).toEqual(['body:', 'enter']) + vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS) + expect(events).toEqual(['body:', 'enter', 'body:b']) + + vi.runAllTimers() + expect(events).toEqual(['body:', 'enter', 'body:b', 'enter', 'body:c', 'enter']) + }) + + it('cancelling the returned timers stops all pending writes', () => { + const events: string[] = [] + const timers = scheduleMobileClaudeAnswer( + ['a', 'b'], + (line) => events.push(`body:${line}`), + () => events.push('enter') + ) + for (const timer of timers) { + clearTimeout(timer) + } + vi.runAllTimers() + expect(events).toEqual([]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-answer-stepping.ts b/mobile/src/session/mobile-native-chat-answer-stepping.ts new file mode 100644 index 00000000000..441f41291d7 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-answer-stepping.ts @@ -0,0 +1,13 @@ +import { + nativeChatQuestionOffsets, + scheduleNativeChatAnswer, + NATIVE_CHAT_ADVANCE_BUFFER_MS, + NATIVE_CHAT_QUESTION_STEP_MS, + NATIVE_CHAT_SUBMIT_DELAY_MS +} from '../../../src/shared/native-chat-answer-stepping' + +export const MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS = NATIVE_CHAT_ADVANCE_BUFFER_MS +export const MOBILE_NATIVE_CHAT_QUESTION_STEP_MS = NATIVE_CHAT_QUESTION_STEP_MS +export const MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS = NATIVE_CHAT_SUBMIT_DELAY_MS +export const mobileNativeChatQuestionOffsets = nativeChatQuestionOffsets +export const scheduleMobileClaudeAnswer = scheduleNativeChatAnswer diff --git a/mobile/src/session/mobile-native-chat-ask.test.ts b/mobile/src/session/mobile-native-chat-ask.test.ts new file mode 100644 index 00000000000..6654a0e8689 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-ask.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + buildAskAnswerKeys, + extractPendingAsk, + formatAskAnswer, + parseAskFromStatus +} from './mobile-native-chat-ask' + +function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage { + return { id, role: 'assistant', blocks, timestamp: 0, source: 'transcript' } +} + +const askInput = { + questions: [ + { + question: 'Pick one', + header: 'Choice', + multiSelect: false, + options: [ + { label: 'A', description: 'first' }, + { label: 'B', description: 'second' } + ] + } + ] +} + +describe('extractPendingAsk', () => { + it('returns the structured prompt from a pending AskUserQuestion call', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }]) + ]) + expect(ask?.questions[0]).toMatchObject({ + question: 'Pick one', + header: 'Choice', + multiSelect: false + }) + expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B']) + }) + + it('returns null once a tool-result follows the ask', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a'), + msg([{ type: 'tool-result', output: 'answered' }], 'r') + ]) + expect(ask).toBeNull() + }) + + it('ignores non-ask tool calls', () => { + expect( + extractPendingAsk([msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }])]) + ).toBeNull() + }) + + it('survives an unrelated earlier tool result (FIFO, not adjacency)', () => { + // A parallel Bash call precedes the ask; its result resolves the Bash call + // (oldest outstanding), so the unanswered question must remain pending. + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'), + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'ls output' }], 'r1') + ]) + expect(ask?.questions[0]!.question).toBe('Pick one') + }) + + it("clears the ask only when the ask's own result arrives", () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'), + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'ls output' }], 'r1'), + msg([{ type: 'tool-result', output: 'answered' }], 'r2') + ]) + expect(ask).toBeNull() + }) + + it('keeps the latest ask when several appear', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'x' }], 'r1'), + msg([ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { + questions: [{ question: 'Second', multiSelect: false, options: [{ label: 'Z' }] }] + } + } + ]) + ]) + expect(ask?.questions[0]!.question).toBe('Second') + }) +}) + +describe('parseAskFromStatus', () => { + it('parses the live interactivePrompt JSON into a prompt', () => { + const ask = parseAskFromStatus(JSON.stringify(askInput)) + expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B']) + }) + + it('returns null for empty or malformed input', () => { + expect(parseAskFromStatus(undefined)).toBeNull() + expect(parseAskFromStatus('')).toBeNull() + expect(parseAskFromStatus('{not json')).toBeNull() + expect(parseAskFromStatus('{"foo":1}')).toBeNull() + }) +}) + +describe('formatAskAnswer', () => { + it('joins selected labels per question', () => { + const prompt = { + questions: [ + { question: 'q1', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }] } + ] + } + expect(formatAskAnswer(prompt, [{ indices: [0, 1] }, { indices: [0] }])).toBe('A, B\nC') + }) + + it('keeps one line per question with a blank middle answer (3 questions)', () => { + const prompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'B' }] }, + { question: 'q3', multiSelect: false, options: [{ label: 'C' }] } + ] + } + const answer = formatAskAnswer(prompt, [{ indices: [0] }, { indices: [] }, { indices: [0] }]) + expect(answer).toBe('A\n\nC') + expect(answer.split('\n')).toHaveLength(3) + }) +}) + +describe('buildAskAnswerKeys', () => { + it('answers a single-select pick with its option number only', () => { + const prompt = { + questions: [ + { question: 'q', multiSelect: false, options: [{ label: 'Tabs' }, { label: 'Spaces' }] } + ] + } + expect(buildAskAnswerKeys(prompt, [{ indices: [1] }])).toEqual([{ raw: '2' }]) + }) + + it('toggles multi-select numbers then steps to Submit and confirms', () => { + const prompt = { + questions: [ + { + question: 'q', + multiSelect: true, + options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] + } + ] + } + expect(buildAskAnswerKeys(prompt, [{ indices: [0, 2] }])).toEqual([ + { raw: '1' }, + { raw: '3' }, + { raw: '\x1b[C' }, + { raw: '\r' } + ]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-ask.ts b/mobile/src/session/mobile-native-chat-ask.ts new file mode 100644 index 00000000000..50766b25ee4 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-ask.ts @@ -0,0 +1,2 @@ +// Mobile and desktop share one parser/formatter so question-tool behavior cannot drift. +export * from '../../../src/shared/native-chat-ask' diff --git a/mobile/src/session/mobile-native-chat-autocomplete.test.ts b/mobile/src/session/mobile-native-chat-autocomplete.test.ts new file mode 100644 index 00000000000..25c904c7b13 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-autocomplete.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + applyAutocomplete, + detectAutocompleteTrigger, + rankSuggestions +} from './mobile-native-chat-autocomplete' + +describe('detectAutocompleteTrigger', () => { + it('detects a slash command only at the start', () => { + expect(detectAutocompleteTrigger('/rev', 4)).toEqual({ + kind: 'slash', + query: 'rev', + start: 0, + end: 4 + }) + expect(detectAutocompleteTrigger('hi /rev', 7)).toBeNull() + }) + + it('detects an @ mention after whitespace or at start', () => { + expect(detectAutocompleteTrigger('@src', 4)).toMatchObject({ kind: 'file', query: 'src' }) + expect(detectAutocompleteTrigger('look at @comp', 13)).toMatchObject({ + kind: 'file', + query: 'comp' + }) + }) + + it('does not trigger @ mid-word (email-like)', () => { + expect(detectAutocompleteTrigger('me@host', 7)).toBeNull() + }) + + it('closes the token once a space is typed', () => { + expect(detectAutocompleteTrigger('@src ', 5)).toBeNull() + }) + + it('returns empty query right after the trigger char', () => { + expect(detectAutocompleteTrigger('@', 1)).toMatchObject({ kind: 'file', query: '' }) + }) +}) + +describe('applyAutocomplete', () => { + it('replaces the trigger span and leaves a trailing space + cursor', () => { + const trigger = detectAutocompleteTrigger('look at @comp', 13)! + const { text, cursor } = applyAutocomplete('look at @comp', trigger, '@src/App.tsx') + expect(text).toBe('look at @src/App.tsx ') + expect(cursor).toBe(text.length) + }) +}) + +describe('rankSuggestions', () => { + it('prefers prefix matches on the basename', () => { + const out = rankSuggestions(['src/app/Main.tsx', 'src/AppBar.tsx', 'lib/zapp.ts'], 'app') + expect(out[0]).toBe('src/AppBar.tsx') + expect(out).toContain('lib/zapp.ts') + }) + + it('returns the head of the list for an empty query', () => { + expect(rankSuggestions(['a', 'b', 'c'], '', 2)).toEqual(['a', 'b']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-autocomplete.ts b/mobile/src/session/mobile-native-chat-autocomplete.ts new file mode 100644 index 00000000000..de0ddc91f67 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-autocomplete.ts @@ -0,0 +1,93 @@ +// Why: the composer offers two kinds of autocomplete — `@file` mentions and +// `/slash` commands. Detection is pure (text + cursor → active trigger) so it's +// unit-testable and the composer stays a thin view over it. + +export type AutocompleteKind = 'file' | 'slash' + +export type AutocompleteTrigger = { + kind: AutocompleteKind + /** The query typed after the trigger char (may be empty). */ + query: string + /** Index of the trigger char in the text (inclusive). */ + start: number + /** Index just past the cursor / token end (exclusive) — the replace span end. */ + end: number +} + +const TOKEN_CHAR = /[^\s]/ + +/** Detect an active autocomplete trigger at the cursor. A `@` mention triggers + * anywhere it follows whitespace/start; a `/` command only at the very start of + * the input (slash commands are line-leading). Returns null when the cursor is + * not inside such a token. */ +export function detectAutocompleteTrigger( + text: string, + cursor: number +): AutocompleteTrigger | null { + const pos = Math.max(0, Math.min(cursor, text.length)) + // Walk left from the cursor over non-whitespace token chars to find the trigger. + let i = pos - 1 + while (i >= 0 && TOKEN_CHAR.test(text[i]!)) { + i-- + } + const triggerIndex = i + 1 + const triggerChar = text[triggerIndex] + if (triggerChar !== '@' && triggerChar !== '/') { + return null + } + const before = triggerIndex === 0 ? '' : text[triggerIndex - 1]! + if (triggerChar === '/' && triggerIndex !== 0) { + // Slash commands are only offered at the very start of the message. + return null + } + if (triggerChar === '@' && triggerIndex !== 0 && !/\s/.test(before)) { + return null + } + const query = text.slice(triggerIndex + 1, pos) + // A space in the query means the token already closed. + if (/\s/.test(query)) { + return null + } + return { + kind: triggerChar === '@' ? 'file' : 'slash', + query, + start: triggerIndex, + end: pos + } +} + +/** Replace the trigger span with `value`, leaving a trailing space and the + * cursor after it. Returns the new text and the new cursor position. */ +export function applyAutocomplete( + text: string, + trigger: AutocompleteTrigger, + value: string +): { text: string; cursor: number } { + const inserted = `${value} ` + const next = text.slice(0, trigger.start) + inserted + text.slice(trigger.end) + return { text: next, cursor: trigger.start + inserted.length } +} + +/** Rank suggestions for a query: case-insensitive, prefix matches first, then + * substring matches, capped. Used for both file paths and command names. */ +export function rankSuggestions(candidates: readonly string[], query: string, limit = 8): string[] { + const q = query.toLowerCase() + if (q.length === 0) { + return candidates.slice(0, limit) + } + const prefix: string[] = [] + const substring: string[] = [] + for (const candidate of candidates) { + const lower = candidate.toLowerCase() + const base = lower.split('/').pop() ?? lower + if (lower.startsWith(q) || base.startsWith(q)) { + prefix.push(candidate) + } else if (lower.includes(q)) { + substring.push(candidate) + } + if (prefix.length >= limit) { + break + } + } + return [...prefix, ...substring].slice(0, limit) +} diff --git a/mobile/src/session/mobile-native-chat-blocks.test.ts b/mobile/src/session/mobile-native-chat-blocks.test.ts new file mode 100644 index 00000000000..57351e4a20c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-blocks.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + foldToolMessages, + pairToolBlocks, + splitNativeChatBlocks +} from './mobile-native-chat-blocks' + +function msg( + role: NativeChatMessage['role'], + blocks: NativeChatMessage['blocks'], + id: string = role +): NativeChatMessage { + return { id, role, blocks, timestamp: 0, source: 'transcript' } +} + +describe('splitNativeChatBlocks', () => { + it('separates prose from tool blocks', () => { + const { prose, tools } = splitNativeChatBlocks([ + { type: 'text', text: 'hi' }, + { type: 'tool-call', name: 'Bash', input: {} }, + { type: 'tool-result', output: 'ok' } + ]) + expect(prose.map((b) => b.type)).toEqual(['text']) + expect(tools.map((b) => b.type)).toEqual(['tool-call', 'tool-result']) + }) +}) + +describe('pairToolBlocks', () => { + it('pairs each call with the following result', () => { + const pairs = pairToolBlocks([ + { type: 'tool-call', name: 'Bash', input: { command: 'ls' } }, + { type: 'tool-result', output: 'a\nb' }, + { type: 'tool-call', name: 'Edit', input: {} }, + { type: 'tool-result', output: 'ok' } + ]) + expect(pairs).toHaveLength(2) + expect(pairs[0]!.call?.name).toBe('Bash') + expect(pairs[0]!.result?.output).toBe('a\nb') + expect(pairs[1]!.call?.name).toBe('Edit') + }) + + it('keeps an orphan result on its own', () => { + const pairs = pairToolBlocks([{ type: 'tool-result', output: 'x' }]) + expect(pairs).toEqual([{ result: { type: 'tool-result', output: 'x' } }]) + }) + + it('does not attach a second result to an already-filled pair', () => { + const pairs = pairToolBlocks([ + { type: 'tool-call', name: 'Bash', input: {} }, + { type: 'tool-result', output: '1' }, + { type: 'tool-result', output: '2' } + ]) + expect(pairs).toHaveLength(2) + expect(pairs[1]!.call).toBeUndefined() + expect(pairs[1]!.result?.output).toBe('2') + }) + + it('pairs batched parallel calls by ordinal, not adjacency', () => { + const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } } + const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } } + const result1 = { type: 'tool-result' as const, output: 'out-a' } + const result2 = { type: 'tool-result' as const, output: 'out-b' } + // Parallel calls stream as [call, call, result, result]; the Nth result + // answers the Nth call, so result1 must land on call1 (not the nearer call2). + const pairs = pairToolBlocks([call1, call2, result1, result2]) + + expect(pairs).toHaveLength(2) + expect(pairs[0]).toEqual({ call: call1, result: result1 }) + expect(pairs[1]).toEqual({ call: call2, result: result2 }) + }) + + it('does not graft a dropped over-limit call result onto a kept call', () => { + const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } } + const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } } + const call3 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'c' } } + const result1 = { type: 'tool-result' as const, output: 'out-a' } + const result2 = { type: 'tool-result' as const, output: 'out-b' } + const result3 = { type: 'tool-result' as const, output: 'out-c' } + const pairs = pairToolBlocks([call1, call2, call3, result1, result2, result3], 2) + + expect(pairs).toHaveLength(2) + expect(pairs[0]).toEqual({ call: call1, result: result1 }) + // call3 was dropped past the limit, so result3 must not misgraft onto call2. + expect(pairs[1]).toEqual({ call: call2, result: result2 }) + }) + + it('retains the last result while bounding rendered pairs', () => { + const blocks = Array.from({ length: 20 }, (_unused, index) => [ + { type: 'tool-call' as const, name: 'Bash', input: { index } }, + { type: 'tool-result' as const, output: String(index) } + ]).flat() + const pairs = pairToolBlocks(blocks, 6) + + expect(pairs).toHaveLength(6) + expect(pairs[5].result?.output).toBe('5') + }) +}) + +describe('foldToolMessages', () => { + it('folds tool-call-only assistant messages and result messages into the prose turn', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'text', text: 'doing' }], 'a0'), + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'), + msg('tool', [{ type: 'tool-result', output: 'done' }], 't1'), + msg('assistant', [{ type: 'tool-call', name: 'Edit', input: {} }], 'a2'), + msg('tool', [{ type: 'tool-result', output: 'ok' }], 't2') + ]) + expect(folded).toHaveLength(1) + expect(folded[0]!.blocks.map((b) => b.type)).toEqual([ + 'text', + 'tool-call', + 'tool-result', + 'tool-call', + 'tool-result' + ]) + }) + + it('starts a new turn at the next prose assistant message', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'text', text: 't1' }], 'a0'), + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'), + msg('assistant', [{ type: 'text', text: 't2' }], 'a2') + ]) + expect(folded.map((m) => m.id)).toEqual(['a0', 'a2']) + }) + + it('keeps a tool message standalone when no assistant precedes it', () => { + const folded = foldToolMessages([msg('tool', [{ type: 'tool-result', output: 'x' }])]) + expect(folded).toHaveLength(1) + expect(folded[0]!.role).toBe('tool') + }) + + it('does not merge across a user message', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a'), + msg('user', [{ type: 'text', text: 'q' }], 'u'), + msg('tool', [{ type: 'tool-result', output: 'r' }], 't') + ]) + expect(folded.map((m) => m.role)).toEqual(['assistant', 'user', 'tool']) + }) + + it('folds a long tool run without mutating the source assistant', () => { + const assistant = msg('assistant', [{ type: 'text', text: 'working' }], 'a') + const tools = Array.from({ length: 1_000 }, (_unused, index) => + msg('tool', [{ type: 'tool-result', output: String(index) }], `t-${index}`) + ) + const folded = foldToolMessages([assistant, ...tools]) + + expect(folded[0].blocks).toHaveLength(1_001) + expect(assistant.blocks).toHaveLength(1) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-blocks.ts b/mobile/src/session/mobile-native-chat-blocks.ts new file mode 100644 index 00000000000..1cf293a79fd --- /dev/null +++ b/mobile/src/session/mobile-native-chat-blocks.ts @@ -0,0 +1,11 @@ +export { + isImageRefBlock, + isTextBlock, + NATIVE_CHAT_SOURCE_PRIORITY +} from '../../../src/shared/native-chat-types' +export { + foldToolMessages, + pairToolBlocks, + splitNativeChatBlocks, + type NativeChatToolPair as ToolPair +} from '../../../src/shared/native-chat-tool-fold' diff --git a/mobile/src/session/mobile-native-chat-diff.test.ts b/mobile/src/session/mobile-native-chat-diff.test.ts new file mode 100644 index 00000000000..3171b818050 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-diff.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { diffFromText, diffFromToolCall } from './mobile-native-chat-diff' + +describe('diffFromToolCall', () => { + it('builds del/add lines from an Edit tool call', () => { + const lines = diffFromToolCall('Edit', { + file_path: 'src/a.ts', + old_string: 'const a = 1\nconst b = 2', + new_string: 'const a = 10' + }) + expect(lines).toEqual([ + { kind: 'meta', text: 'src/a.ts' }, + { kind: 'del', text: 'const a = 1' }, + { kind: 'del', text: 'const b = 2' }, + { kind: 'add', text: 'const a = 10' } + ]) + }) + + it('returns null for non-edit tools', () => { + expect(diffFromToolCall('Bash', { command: 'ls' })).toBeNull() + }) +}) + +describe('diffFromText', () => { + it('parses unified diff lines with colours', () => { + const lines = diffFromText('@@ -1,2 +1,2 @@\n context\n-old\n+new') + expect(lines).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' context' }, + { kind: 'del', text: 'old' }, + { kind: 'add', text: 'new' } + ]) + }) + + it('does not treat ordinary prose as a diff', () => { + expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull() + }) + + it('caps large diffs before creating render rows', () => { + const text = Array.from( + { length: 1_000 }, + (_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}` + ).join('\n') + const lines = diffFromText(text) + + expect(lines).toHaveLength(120) + expect(lines?.at(-1)).toEqual({ kind: 'meta', text: '… diff truncated …' }) + }) + + it('honors a smaller caller-owned aggregate row budget', () => { + const text = Array.from( + { length: 100 }, + (_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}` + ).join('\n') + + expect(diffFromText(text, 20)).toHaveLength(20) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-diff.ts b/mobile/src/session/mobile-native-chat-diff.ts new file mode 100644 index 00000000000..4ace06d37d0 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-diff.ts @@ -0,0 +1,6 @@ +export { + diffFromText, + diffFromToolCall, + type NativeChatDiffLine as DiffLine, + type NativeChatDiffLineKind as DiffLineKind +} from '../../../src/shared/native-chat-diff' diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts new file mode 100644 index 00000000000..387736f065e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import { + canShowMobileNativeChat, + isMobileNativeChatTranscriptReadable, + resolveMobileNativeChat +} from './mobile-native-chat-eligibility' + +function status(overrides: Partial = {}): AgentStatusEntry { + return { + state: 'working', + prompt: '', + updatedAt: 0, + stateStartedAt: 0, + paneKey: 'tab:leaf', + ...overrides + } as AgentStatusEntry +} + +describe('resolveMobileNativeChat', () => { + it('prefers the authoritative supported live agent over a stale launch hint', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: { + agentType: 'codex', + providerSession: { id: 'codex-session', transcriptPath: '/tmp/codex.jsonl' } + } + } as never) + ).toMatchObject({ agent: 'codex', sessionId: 'codex-session' }) + }) + + it('rejects an unsupported live agent instead of combining it with a stale hint', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: { + agentType: 'gemini', + providerSession: { id: 'gemini-session', transcriptPath: '/tmp/gemini.jsonl' } + } + } as never) + ).toBeNull() + }) + it('resolves agent + sessionId from launchAgent and provider session', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: status({ + providerSession: { + key: 'session_id', + id: 'sess-1', + transcriptPath: '/tmp/claude-real-transcript.jsonl' + } + }) + }) + ).toEqual({ + agent: 'claude', + sessionId: 'sess-1', + transcriptPath: '/tmp/claude-real-transcript.jsonl' + }) + }) + + it('falls back to agentStatus.agentType when no launchAgent', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + agentStatus: status({ agentType: 'codex' }) + }) + ).toEqual({ agent: 'codex', sessionId: null, transcriptPath: null }) + }) + + it('admits OpenClaude with its distinct agent identity', () => { + expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'openclaude' })).toEqual({ + agent: 'openclaude', + sessionId: null, + transcriptPath: null + }) + }) + + it('returns null for unsupported agents', () => { + expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'gemini' })).toBeNull() + }) + + it('admits Grok only when its transcript is readable by the serving host', () => { + const tab = { type: 'terminal', launchAgent: 'grok' } + expect(resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable(null))).toMatchObject({ + agent: 'grok' + }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('runtime-ssh-environment')) + ).toMatchObject({ agent: 'grok' }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh')) + ).toBeNull() + }) + + it('returns null for a plain shell (no agent)', () => { + expect(resolveMobileNativeChat({ type: 'terminal' })).toBeNull() + }) + + it('returns null for non-terminal tabs', () => { + expect(resolveMobileNativeChat({ type: 'browser', launchAgent: 'claude' })).toBeNull() + }) + + it('canShowMobileNativeChat mirrors resolution', () => { + expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true) + expect(canShowMobileNativeChat(null)).toBe(false) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts new file mode 100644 index 00000000000..84e0e00d012 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -0,0 +1,66 @@ +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host' +import { isNativeChatSupportedAgent } from '../../../src/shared/native-chat-agent-support' + +// Why: native chat renders an agent's own JSONL transcript, and the host +// resolver knows these transcript layouts. Grok is additionally gated on host +// readability because Model-A SSH stores its transcript on the remote target. +export function isMobileNativeChatTranscriptReadable( + connectionId: string | null | undefined +): boolean { + return connectionId === null || isRuntimeOwnedSshTargetId(connectionId) +} + +export type MobileNativeChatResolution = { + agent: string + /** The agent's own session id, or null before it has reported one (the view + * then shows a waiting state instead of trying to read an unaddressable file). */ + sessionId: string | null + /** Hook-reported transcript path. Recent Claude sessions cannot always be + * resolved from the provider session id, so mobile forwards this to runtime. */ + transcriptPath: string | null +} + +export type MobileNativeChatTab = { + type: string + launchAgent?: string | null + agentStatus?: AgentStatusEntry | null +} + +/** Resolve a session tab to the transcript identity native chat needs, or + * null when the tab can't show native chat (not a terminal, no agent, or an + * agent whose transcript the host can't read). Agent comes from the launch + * hint or the live status; session id from the captured provider session. */ +export function resolveMobileNativeChat( + tab: MobileNativeChatTab | null, + nativeChatTranscriptIsLocalReadable = false +): MobileNativeChatResolution | null { + if (!tab || tab.type !== 'terminal') { + return null + } + const liveAgent = tab.agentStatus?.agentType ?? null + const agent = liveAgent + ? isNativeChatSupportedAgent(liveAgent) + ? liveAgent + : null + : tab.launchAgent + if (!agent || !isNativeChatSupportedAgent(agent)) { + return null + } + if (agent === 'grok' && !nativeChatTranscriptIsLocalReadable) { + return null + } + return { + agent, + sessionId: tab.agentStatus?.providerSession?.id ?? null, + transcriptPath: tab.agentStatus?.providerSession?.transcriptPath ?? null + } +} + +/** Whether the tab can toggle into native chat — gates the long-press item. */ +export function canShowMobileNativeChat( + tab: MobileNativeChatTab | null, + nativeChatTranscriptIsLocalReadable = false +): boolean { + return resolveMobileNativeChat(tab, nativeChatTranscriptIsLocalReadable) !== null +} diff --git a/mobile/src/session/mobile-native-chat-merge.test.ts b/mobile/src/session/mobile-native-chat-merge.test.ts new file mode 100644 index 00000000000..61312670512 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merge.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { mergeNativeChatMessages } from './mobile-native-chat-merge' + +function msg(id: string, overrides: Partial = {}): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript', + ...overrides + } +} + +describe('mergeNativeChatMessages', () => { + it('appends new ids in arrival order', () => { + const merged = mergeNativeChatMessages([msg('a')], [msg('b'), msg('c')]) + expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c']) + }) + + it('replaces a re-emitted id in place without reordering', () => { + const merged = mergeNativeChatMessages( + [msg('a'), msg('b'), msg('c')], + [msg('b', { blocks: [{ type: 'text', text: 'updated' }] })] + ) + expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c']) + expect(merged[1]!.blocks).toEqual([{ type: 'text', text: 'updated' }]) + }) + + it('does not let a lower-priority source overwrite a higher one', () => { + const merged = mergeNativeChatMessages( + [msg('a', { source: 'transcript', blocks: [{ type: 'text', text: 'real' }] })], + [msg('a', { source: 'scrape', blocks: [{ type: 'text', text: 'scraped' }] })] + ) + expect(merged[0]!.blocks).toEqual([{ type: 'text', text: 'real' }]) + }) + + it('returns the existing array unchanged for an empty batch', () => { + const existing = [msg('a')] + expect(mergeNativeChatMessages(existing, [])).toBe(existing) + }) + + it('does not mutate the existing array', () => { + const existing = [msg('a')] + mergeNativeChatMessages(existing, [msg('b')]) + expect(existing.map((m) => m.id)).toEqual(['a']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-merge.ts b/mobile/src/session/mobile-native-chat-merge.ts new file mode 100644 index 00000000000..6b6ffe1781e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merge.ts @@ -0,0 +1,9 @@ +// Keep the established mobile import path while executing the shared desktop/mobile merger. +export { + applyAppend, + boundNativeChatWindow, + createNativeChatMerger, + mergeNativeChatMessages, + replaceList, + type NativeChatMerger +} from '../../../src/shared/native-chat-merge' diff --git a/mobile/src/session/mobile-native-chat-merger-oracle.test.ts b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts new file mode 100644 index 00000000000..2a034fd33f3 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + applyAppend, + boundNativeChatWindow, + createNativeChatMerger, + mergeNativeChatMessages, + replaceList +} from './mobile-native-chat-merge' + +function msg(id: string, overrides: Partial = {}): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript', + ...overrides + } +} + +// ORACLE: the stateful merger (cached index, applyAppend) must deep-equal the +// pure mergeNativeChatMessages for every prefix of an adversarial subscribe +// sequence — the cache must never diverge from a full rebuild (#18). +describe('mobile stateful merger — oracle vs pure merge', () => { + const base = [msg('a'), msg('b', { source: 'hook' })] + const batches: NativeChatMessage[][] = [ + [msg('c')], // pure tail + [], // empty frame + [msg('b', { source: 'transcript', blocks: [{ type: 'text', text: 'final' }] })], // supersede + [msg('b', { source: 'scrape' })], // lower priority — ignored + [msg('a')], // re-emit, no change + [msg('d'), msg('e')] // multi tail + ] + + it('matches the pure merge for every prefix', () => { + const merger = createNativeChatMerger() + replaceList(merger, base) + let pure = mergeNativeChatMessages(base, []) + expect(merger.list).toEqual(pure) + for (const batch of batches) { + const out = applyAppend(merger, batch) + pure = mergeNativeChatMessages(pure, batch) + expect(out).toEqual(pure) + } + }) + + it('keeps prior row identity on a pure tail append', () => { + const merger = createNativeChatMerger() + replaceList(merger, base) + const before = merger.list + const tail = msg('z') + const after = applyAppend(merger, [tail]) + expect(after).not.toBe(before) + expect(after[0]).toBe(before[0]) + expect(after[after.length - 1]).toBe(tail) + }) + + it('bounds the window to the most-recent tail', () => { + const long = Array.from({ length: 10 }, (_, i) => msg(`m${i}`)) + expect(boundNativeChatWindow(long, 3).map((m) => m.id)).toEqual(['m7', 'm8', 'm9']) + expect(boundNativeChatWindow(long, 0)).toBe(long) + expect(boundNativeChatWindow(long, 20)).toBe(long) + }) + + it('bounds live appends and rebuilds the cached indexes for the retained tail', () => { + const merger = createNativeChatMerger() + replaceList(merger, [msg('a'), msg('b'), msg('c')]) + + expect(applyAppend(merger, [msg('d'), msg('e')], 3).map((m) => m.id)).toEqual(['c', 'd', 'e']) + expect(merger.indexById.has('a')).toBe(false) + expect(merger.indexById.get('c')).toBe(0) + expect( + applyAppend(merger, [msg('d', { blocks: [{ type: 'text', text: 'updated' }] })], 3)[1] + ).toMatchObject({ id: 'd', blocks: [{ type: 'text', text: 'updated' }] }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts new file mode 100644 index 00000000000..c67ffb738cc --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -0,0 +1,168 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const TEXT_SIZE = 17 +export const MONO_SIZE = 12 +export const MAX_TOOL_RESULT_CHARS = 4000 + +export const styles = StyleSheet.create({ + row: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + }, + rowUser: { + alignItems: 'flex-end' + }, + content: { + maxWidth: '100%', + gap: spacing.sm + }, + userBubble: { + maxWidth: '88%', + backgroundColor: colors.textPrimary, + borderRadius: radii.card, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + }, + userText: { + color: colors.bgBase, + fontSize: TEXT_SIZE, + lineHeight: TEXT_SIZE + 6, + fontWeight: '500' + }, + controls: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.xs, + marginBottom: 2, + opacity: 0.7 + }, + controlButton: { + padding: 3 + }, + controlPressed: { + opacity: 0.5 + }, + copied: { + backgroundColor: colors.diffAddedBg, + borderRadius: radii.card + }, + reasoning: { + opacity: 0.7 + }, + queued: { + opacity: 0.55 + }, + queuedTag: { + color: colors.textMuted, + fontSize: 11, + fontWeight: '600', + marginBottom: 2 + }, + toolRun: { + marginTop: spacing.xs + }, + toolRunHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + toolRunToggle: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: 3 + }, + controlsRow: { + flexDirection: 'row', + justifyContent: 'flex-end' + }, + toolRunCount: { + color: colors.statusGreen, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + fontWeight: '700' + }, + toolRunLabel: { + flex: 1, + color: colors.textMuted, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + }, + toolRunBody: { + paddingLeft: spacing.sm, + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + marginTop: spacing.xs + }, + toolLine: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: 3 + }, + toolName: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + 1, + fontWeight: '600' + }, + toolPreview: { + flex: 1, + color: colors.textMuted, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + }, + toolPreviewLink: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + toolDetail: { + paddingLeft: spacing.lg, + paddingBottom: spacing.xs, + gap: spacing.xs + }, + mono: { + color: colors.textSecondary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + lineHeight: MONO_SIZE + 5 + }, + toolResult: { + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + padding: spacing.md + }, + toolResultError: { + backgroundColor: colors.diffDeletedBg + }, + imageRef: { + color: colors.textSecondary, + fontSize: TEXT_SIZE + }, + diff: { + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + paddingVertical: spacing.xs, + overflow: 'hidden' + }, + diffLine: { + color: colors.textSecondary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + lineHeight: MONO_SIZE + 5, + paddingHorizontal: spacing.sm + }, + diffAdd: { + color: colors.gitDecorationAdded, + backgroundColor: colors.diffAddedBg + }, + diffDel: { + color: colors.gitDecorationDeleted, + backgroundColor: colors.diffDeletedBg + }, + diffMeta: { + color: colors.textMuted + } +}) diff --git a/mobile/src/session/mobile-native-chat-message-text.test.ts b/mobile/src/session/mobile-native-chat-message-text.test.ts new file mode 100644 index 00000000000..225904cc5d8 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-text.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock } from '../../../src/shared/native-chat-types' +import { + clampFontScale, + FONT_SCALE_MAX, + FONT_SCALE_MIN, + nativeChatMessageText +} from './mobile-native-chat-message-text' + +describe('nativeChatMessageText', () => { + it('joins text blocks and skips non-text blocks', () => { + const blocks: NativeChatBlock[] = [ + { type: 'text', text: 'Hello' }, + { type: 'tool-call', name: 'Read', input: {} }, + { type: 'text', text: 'World' } + ] + expect(nativeChatMessageText(blocks)).toBe('Hello\n\nWorld') + }) + + it('returns an empty string when there is no prose', () => { + const blocks: NativeChatBlock[] = [{ type: 'tool-call', name: 'Read', input: {} }] + expect(nativeChatMessageText(blocks)).toBe('') + }) + + it('trims surrounding whitespace', () => { + expect(nativeChatMessageText([{ type: 'text', text: ' hi ' }])).toBe('hi') + }) +}) + +describe('clampFontScale', () => { + it('clamps below the minimum', () => { + expect(clampFontScale(0.1)).toBe(FONT_SCALE_MIN) + }) + + it('clamps above the maximum', () => { + expect(clampFontScale(5)).toBe(FONT_SCALE_MAX) + }) + + it('passes through an in-range value', () => { + expect(clampFontScale(1.2)).toBe(1.2) + }) + + it('falls back to 1 for NaN', () => { + expect(clampFontScale(Number.NaN)).toBe(1) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-message-text.ts b/mobile/src/session/mobile-native-chat-message-text.ts new file mode 100644 index 00000000000..26aeea7680c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-text.ts @@ -0,0 +1,24 @@ +import type { NativeChatBlock } from '../../../src/shared/native-chat-types' +import { isTextBlock } from './mobile-native-chat-blocks' + +/** Concatenate a message's text blocks into a single copyable string. Tool + * calls/results and image refs are skipped — Copy is for the agent's prose. */ +export function nativeChatMessageText(blocks: readonly NativeChatBlock[]): string { + return blocks + .filter(isTextBlock) + .map((b) => b.text) + .join('\n\n') + .trim() +} + +/** Pinch-to-zoom font bounds. Default 1 means no visible change until pinched. */ +export const FONT_SCALE_MIN = 0.8 +export const FONT_SCALE_MAX = 1.8 + +/** Clamp a proposed font scale into the supported range. */ +export function clampFontScale(scale: number): number { + if (Number.isNaN(scale)) { + return 1 + } + return Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, scale)) +} diff --git a/mobile/src/session/mobile-native-chat-noise.test.ts b/mobile/src/session/mobile-native-chat-noise.test.ts new file mode 100644 index 00000000000..6977b764e72 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-noise.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isNoiseMessage, stripNoiseMessages } from './mobile-native-chat-noise' + +function msg( + role: NativeChatMessage['role'], + text: string, + blocks?: NativeChatMessage['blocks'] +): NativeChatMessage { + return { + id: text.slice(0, 8), + role, + blocks: blocks ?? [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + } +} + +describe('isNoiseMessage', () => { + it('flags task-notification and system-reminder user turns', () => { + expect(isNoiseMessage(msg('user', '\nx'))).toBe(true) + expect(isNoiseMessage(msg('user', '\nbe careful'))).toBe(true) + expect( + isNoiseMessage( + msg( + 'user', + 'Caveat: The messages below were generated by the user while running local commands' + ) + ) + ).toBe(true) + expect(isNoiseMessage(msg('user', '[Request interrupted by user]'))).toBe(true) + }) + + it('flags inter-agent harness turns (teammate/agent messages)', () => { + expect(isNoiseMessage(msg('user', 'ping'))).toBe(true) + expect(isNoiseMessage(msg('user', 'status update'))).toBe(true) + }) + + it('keeps real user messages', () => { + expect(isNoiseMessage(msg('user', 'make it work for codex'))).toBe(false) + // A genuine custom tag is not a known harness tag, so it stays a user turn. + expect(isNoiseMessage(msg('user', ' is my band'))).toBe(false) + }) + + it('keeps assistant and tool turns', () => { + expect(isNoiseMessage(msg('assistant', ' in prose'))).toBe(false) + }) + + it('keeps a user turn that carries tool results', () => { + expect(isNoiseMessage(msg('user', '', [{ type: 'tool-result', output: 'ok' }]))).toBe(false) + }) + + it('stripNoiseMessages removes only the noise', () => { + const out = stripNoiseMessages([ + msg('user', 'hello'), + msg('user', 'done'), + msg('assistant', 'hi') + ]) + expect(out.map((m) => m.role)).toEqual(['user', 'assistant']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-noise.ts b/mobile/src/session/mobile-native-chat-noise.ts new file mode 100644 index 00000000000..1b15a7e414a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-noise.ts @@ -0,0 +1 @@ +export { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise' diff --git a/mobile/src/session/mobile-native-chat-open-file.test.ts b/mobile/src/session/mobile-native-chat-open-file.test.ts new file mode 100644 index 00000000000..2f8ceb936a7 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-open-file.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { + openMobileNativeChatFile, + resolveMobileNativeChatWorktreePath +} from './mobile-native-chat-open-file' + +describe('resolveMobileNativeChatWorktreePath', () => { + it('resolves an absolute tool path to a worktree-relative open target', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + await expect( + resolveMobileNativeChatWorktreePath({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: '/repo/src/app.ts', + terminal: 'terminal' + }) + ).resolves.toBe('src/app.ts') + expect(sendRequest).toHaveBeenCalledWith('files.resolveTerminalPath', { + worktree: 'id:worktree', + pathText: '/repo/src/app.ts', + terminal: 'terminal' + }) + }) + + it('opens only the resolved worktree-relative target', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + .mockResolvedValueOnce({ ok: true, result: {} }) + + await openMobileNativeChatFile({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: '../repo/src/app.ts', + terminal: 'terminal' + }) + + expect(sendRequest).toHaveBeenLastCalledWith('files.open', { + worktree: 'id:worktree', + relativePath: 'src/app.ts' + }) + }) + + it('resolves null when the resolve request rejects', async () => { + const sendRequest = vi.fn().mockRejectedValue(new Error('Request timed out')) + await expect( + resolveMobileNativeChatWorktreePath({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: 'src/app.ts', + terminal: null + }) + ).resolves.toBeNull() + }) + + it('does not reject when the open request fails', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + .mockRejectedValueOnce(new Error('connection interrupted')) + + await expect( + openMobileNativeChatFile({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: 'src/app.ts', + terminal: null + }) + ).resolves.toBeUndefined() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-open-file.ts b/mobile/src/session/mobile-native-chat-open-file.ts new file mode 100644 index 00000000000..44d6d23c066 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-open-file.ts @@ -0,0 +1,51 @@ +import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' +import type { RpcClient } from '../transport/rpc-client' + +export async function resolveMobileNativeChatWorktreePath(args: { + client: RpcClient + worktreeId: string + pathText: string + terminal: string | null +}): Promise { + try { + const response = await args.client.sendRequest('files.resolveTerminalPath', { + worktree: `id:${args.worktreeId}`, + pathText: args.pathText, + ...(args.terminal ? { terminal: args.terminal } : {}) + }) + if (!response.ok) { + return null + } + const resolved = response.result as RuntimeTerminalPathResolution + if (!resolved.exists || resolved.isDirectory) { + return null + } + return resolved.openTarget?.kind === 'worktree-file' + ? resolved.openTarget.relativePath + : (resolved.relativePath ?? null) + } catch { + // Callers fire-and-forget file opens; a disconnect/timeout must not become + // an unhandled rejection. + return null + } +} + +export async function openMobileNativeChatFile(args: { + client: RpcClient + worktreeId: string + pathText: string + terminal: string | null +}): Promise { + const relativePath = await resolveMobileNativeChatWorktreePath(args) + if (relativePath) { + try { + await args.client.sendRequest('files.open', { + worktree: `id:${args.worktreeId}`, + relativePath + }) + } catch { + // Best-effort open; failures surface as a no-op rather than an + // unhandled rejection. + } + } +} diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts new file mode 100644 index 00000000000..2592038d7c9 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatPermissionResponse } from './mobile-native-chat-permission-send' + +describe('sendMobileNativeChatPermissionResponse', () => { + it('writes an approval as raw bytes without appending Return', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } } + }) + + await expect( + sendMobileNativeChatPermissionResponse({ + client: { sendRequest } as unknown as RpcClient, + terminal: 'terminal', + deviceToken: 'phone', + text: '1' + }) + ).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'terminal', + text: '1', + enter: false, + client: { id: 'phone', type: 'mobile' } + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-permission-send.ts b/mobile/src/session/mobile-native-chat-permission-send.ts new file mode 100644 index 00000000000..679be2d94ed --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission-send.ts @@ -0,0 +1,49 @@ +import { useCallback, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' + +export function sendMobileNativeChatPermissionResponse(args: { + client: RpcClient + terminal: string + deviceToken: string | null + text: string +}): Promise { + // Why: approval choices are already complete terminal control sequences; + // appending Return changes both numbered choices and Escape denial. + return sendMobileNativeChatMessage({ + client: args.client, + terminal: args.terminal, + text: args.text, + enter: false, + ...(args.deviceToken ? { mobileClient: { id: args.deviceToken, type: 'mobile' as const } } : {}) + }) +} + +export function useMobileNativeChatPermissionSend(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + onSendError: (message: string) => void +}): (text: string) => Promise { + return useCallback( + async (text: string): Promise => { + const terminal = args.handleRef.current + if (!args.client || !terminal || !args.enabled) { + args.onSendError('Response not sent (disconnected)') + return false + } + const accepted = await sendMobileNativeChatPermissionResponse({ + client: args.client, + terminal, + deviceToken: args.deviceTokenRef.current, + text + }) + if (!accepted) { + args.onSendError('Response not sent') + } + return accepted + }, + [args.client, args.deviceTokenRef, args.enabled, args.handleRef, args.onSendError] + ) +} diff --git a/mobile/src/session/mobile-native-chat-permission.test.ts b/mobile/src/session/mobile-native-chat-permission.test.ts new file mode 100644 index 00000000000..4744c394756 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' + +describe('detectAgentPermission', () => { + it('returns null for a working agent even with permission-like text', () => { + expect( + detectAgentPermission({ + state: 'working', + lastAssistantMessage: 'Do you want to proceed? (y/n)' + }) + ).toBeNull() + }) + + it('returns null when state is undefined', () => { + expect( + detectAgentPermission({ lastAssistantMessage: 'Do you want to proceed? (y/n)' }) + ).toBeNull() + }) + + it('returns null for ordinary prose while blocked', () => { + expect( + detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: 'I finished editing the file and ran the tests.' + }) + ).toBeNull() + }) + + it('returns null when the message is empty', () => { + expect(detectAgentPermission({ state: 'waiting', lastAssistantMessage: ' ' })).toBeNull() + }) + + it('detects a (y/n) prompt and offers Allow/Deny with y/n send values', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Allow running `rm -rf build`? (y/n)' + }) + expect(result).not.toBeNull() + expect(result?.title).toBe('Permission requested') + expect(result?.options).toEqual([ + { label: 'Allow', send: 'y' }, + { label: 'Deny', send: 'n' } + ]) + expect(result?.detail).toContain('Allow running') + }) + + it('detects a numbered allow/deny menu and sends the digit', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + 'Claude wants to edit App.tsx. Do you want to proceed?\n' + + '1. Yes\n' + + '2. No, and tell Claude what to do differently' + }) + expect(result).not.toBeNull() + expect(result?.options).toHaveLength(2) + expect(result?.options[0]).toEqual({ label: 'Yes', send: '1' }) + expect(result?.options[1]?.send).toBe('2') + expect(result?.options[1]?.label).toContain('No') + }) + + it('handles parenthesized numbered options "1) Yes 2) No"', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Approve this command?\n1) Yes\n2) No' + }) + expect(result?.options.map((o) => o.send)).toEqual(['1', '2']) + }) + + it('supports a three-option numbered menu (Yes / Yes always / No)', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + "Allow this tool call?\n1. Yes\n2. Yes, and don't ask again this session\n3. No" + }) + expect(result?.options).toHaveLength(3) + expect(result?.options.map((o) => o.send)).toEqual(['1', '2', '3']) + }) + + it('adds an "Allow always" option when the text offers a persistent grant', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: + 'Do you want to allow this? You can allow always for this session. (y/n)' + }) + expect(result?.options.map((o) => o.label)).toEqual(['Allow', 'Allow always', 'Deny']) + expect(result?.options.map((o) => o.send)).toEqual(['y', 'a', 'n']) + }) + + it('detects keyword-only permission asks without explicit y/n tokens', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: 'I need your permission to run this Bash command.' + }) + expect(result).not.toBeNull() + expect(result?.options.map((o) => o.send)).toEqual(['y', 'n']) + }) + + it('detects "approve" / "deny" phrasing', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Please approve or deny this write to /etc/hosts.' + }) + expect(result).not.toBeNull() + expect(result?.options).toHaveLength(2) + }) + + it('truncates a very long detail line', () => { + const long = `Do you want to proceed? ${'x'.repeat(300)}` + const result = detectAgentPermission({ state: 'waiting', lastAssistantMessage: long }) + expect(result).not.toBeNull() + expect((result?.detail ?? '').length).toBeLessThanOrEqual(160) + }) + + it('shortens long numbered option labels', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + 'Proceed?\n1. Yes\n2. No, and explain in great detail exactly why this particular approach is wrong' + }) + const second = result?.options[1] + expect(second?.send).toBe('2') + expect((second?.label ?? '').length).toBeLessThanOrEqual(40) + }) +}) + +describe('parseApprovalFromStatus', () => { + it('parses an approval envelope into an Allow/Deny card', () => { + const card = parseApprovalFromStatus( + JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } }) + ) + expect(card?.title).toBe('Allow Bash?') + expect(card?.detail).toBe('rm -rf build') + expect(card?.options.map((o) => o.label)).toEqual(['Allow', 'Deny']) + expect(card?.options[0]!.send).toBe('1') + }) + + it('returns null for non-approval / malformed input', () => { + expect(parseApprovalFromStatus(undefined)).toBeNull() + expect(parseApprovalFromStatus('{bad')).toBeNull() + expect(parseApprovalFromStatus(JSON.stringify({ questions: [] }))).toBeNull() + expect(parseApprovalFromStatus(JSON.stringify({ approval: {} }))).toBeNull() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts new file mode 100644 index 00000000000..fe3f9a0d89b --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission.ts @@ -0,0 +1,179 @@ +// Agent permission asks (e.g. Claude/Codex "Do you want to proceed?") surface +// as plain TUI text in the agent's last assistant message — there is no +// structured permission event on mobile. We detect them heuristically so the +// native chat can render tappable Allow/Deny buttons instead of forcing the +// user to type into the composer. Be conservative: only fire when the agent is +// actually paused (blocked/waiting) AND the text reads like an approval ask. + +/** A detected permission prompt, rendered as a card with tappable options. + * Each option's `send` is the literal string to write back to the agent + * (e.g. "y", "1") when the user taps it. */ +export type MobileChatPermission = { + title: string + detail?: string + options: Array<{ label: string; send: string }> +} + +const ESCAPE = String.fromCharCode(27) + +/** Parse the live `agentStatus.interactivePrompt` approval envelope + * (`{ approval: { tool, summary } }`, emitted by the host on a PermissionRequest) + * into an Allow/Deny card. This is the reliable, agent-emitted signal — unlike + * detectAgentPermission it doesn't depend on heuristic text parsing. The default + * sends (number for allow, Escape for deny) match the common TUI approval prompt; + * detectAgentPermission still takes precedence when it can read the real numbered + * options from the prompt text. */ +export function parseApprovalFromStatus( + interactivePrompt: string | undefined | null +): MobileChatPermission | null { + if (!interactivePrompt) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(interactivePrompt) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') { + return null + } + const approval = (parsed as { approval?: unknown }).approval + if (!approval || typeof approval !== 'object') { + return null + } + const tool = (approval as { tool?: unknown }).tool + if (typeof tool !== 'string' || tool.length === 0) { + return null + } + const summary = (approval as { summary?: unknown }).summary + return { + title: `Allow ${tool}?`, + detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined, + options: [ + { label: 'Allow', send: '1' }, + { label: 'Deny', send: ESCAPE } + ] + } +} + +type PermissionInput = { + state?: string + lastAssistantMessage?: string + toolName?: string + toolInput?: unknown +} + +// States where the agent is paused waiting on the human. Only these can yield a +// permission prompt — a "working" agent is mid-turn and must not be answered. +const PAUSED_STATES: ReadonlySet = new Set(['blocked', 'waiting']) + +// Phrases that read as an approval request. Kept broad but anchored to +// approval/permission language so ordinary prose doesn't trip detection. +const PERMISSION_PATTERNS: RegExp[] = [ + /\bpermission\b/i, + /\bapprove\b/i, + /\bapproval\b/i, + /\ballow\b/i, + /\bdeny\b/i, + /\bgrant\b/i, + /\bauthorize\b/i, + /\bdo you want to\b/i, + /\bwould you like to\b/i, + /\bproceed\?/i, + /\bconfirm\b/i, + /\(y\/n\)/i, + /\by\/n\b/i, + /\byes\/no\b/i, + /\bplease confirm\b/i +] + +function looksLikePermissionAsk(text: string): boolean { + return PERMISSION_PATTERNS.some((re) => re.test(text)) +} + +// A numbered-choice prompt like "1. Yes 2. No" / "2) No, and tell Claude…". +// Captures the option number and its label so we can send the literal digit. +const NUMBERED_OPTION_RE = /(?:^|\n)\s*(\d+)[.)]\s*([^\n]+)/g + +type NumberedOption = { num: string; text: string } + +function parseNumberedOptions(text: string): NumberedOption[] { + const out: NumberedOption[] = [] + for (const match of text.matchAll(NUMBERED_OPTION_RE)) { + const num = match[1] + const body = match[2]?.trim() + if (num && body) { + out.push({ num, text: body }) + } + } + return out +} + +// Whether a label reads as "allow for every future call" rather than just once. +function isAlwaysLabel(text: string): boolean { + return /\balways\b|don't ask again|do not ask again|for the rest|this session/i.test(text) +} + +function shortLabel(text: string, max = 40): string { + const trimmed = text.replace(/\s+/g, ' ').trim() + return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed +} + +function buildNumberedPermission( + options: NumberedOption[], + detail: string | undefined +): MobileChatPermission { + return { + title: 'Permission requested', + detail, + options: options.map((opt) => ({ label: shortLabel(opt.text), send: opt.num })) + } +} + +function firstLine(text: string): string { + return text.split('\n')[0]?.trim() ?? '' +} + +/** + * Heuristically detect an agent permission ask from its paused-state context. + * Returns a renderable prompt, or null when the agent is working or the text + * doesn't read like an approval request. + */ +export function detectAgentPermission(input: PermissionInput): MobileChatPermission | null { + // Only answer a genuinely paused agent. A working agent is mid-turn. + if (!input.state || !PAUSED_STATES.has(input.state)) { + return null + } + + const text = typeof input.lastAssistantMessage === 'string' ? input.lastAssistantMessage : '' + if (!text.trim()) { + return null + } + + if (!looksLikePermissionAsk(text)) { + return null + } + + const detail = shortLabel(firstLine(text), 160) || undefined + + // Prefer an explicit numbered menu ("1. Yes 2. No, and tell…") — its labels + // and send-digits come straight from the agent, so no guessing. + const numbered = parseNumberedOptions(text) + if (numbered.length >= 2) { + return buildNumberedPermission(numbered, detail) + } + + // Otherwise fall back to a y/n prompt. We surface "Allow always" only when the + // text actually offers a persistent option, to avoid sending a token the agent + // doesn't understand. + const options: MobileChatPermission['options'] = [ + { label: 'Allow', send: 'y' }, + { label: 'Deny', send: 'n' } + ] + if (isAlwaysLabel(text)) { + options.splice(1, 0, { label: 'Allow always', send: 'a' }) + } + + return { title: 'Permission requested', detail, options } +} diff --git a/mobile/src/session/mobile-native-chat-question.test.ts b/mobile/src/session/mobile-native-chat-question.test.ts new file mode 100644 index 00000000000..94fbcf055a9 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-question.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import { + formatQuestionAnswer, + mobileChatQuestionKey, + parseAgentQuestion, + type MobileChatQuestion +} from './mobile-native-chat-question' + +describe('parseAgentQuestion', () => { + it('parses a numbered list with a question line', () => { + const text = ['Which database should I use?', '1. PostgreSQL', '2. MySQL', '3. SQLite'].join( + '\n' + ) + const q = parseAgentQuestion(text) + expect(q).not.toBeNull() + expect(q?.question).toBe('Which database should I use?') + expect(q?.options).toEqual(['PostgreSQL', 'MySQL', 'SQLite']) + expect(q?.optionTokens).toEqual(['1', '2', '3']) + expect(q?.multiSelect).toBe(false) + }) + + it('parses numbered lists using ) markers', () => { + const q = parseAgentQuestion('Pick:\n1) Yes\n2) No') + expect(q?.options).toEqual(['Yes', 'No']) + expect(q?.optionTokens).toEqual(['1', '2']) + }) + + it('parses a bulleted list', () => { + const text = ['How should we proceed?', '- Rebase', '- Merge', '* Squash'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.options).toEqual(['Rebase', 'Merge', 'Squash']) + expect(q?.optionTokens).toEqual([null, null, null]) + expect(q?.question).toBe('How should we proceed?') + }) + + it('parses lettered options in brackets and parens', () => { + const q = parseAgentQuestion('Choose one:\n[a] Alpha\n[b] Beta') + expect(q?.options).toEqual(['Alpha', 'Beta']) + expect(q?.optionTokens).toEqual(['a', 'b']) + + const q2 = parseAgentQuestion('Choose one:\na) Alpha\nb) Beta') + expect(q2?.options).toEqual(['Alpha', 'Beta']) + expect(q2?.optionTokens).toEqual(['a', 'b']) + }) + + it('strips a leading pointer/highlight glyph from the selected row', () => { + const text = ['Select a target:', '❯ 1. main', ' 2. develop'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.options).toEqual(['main', 'develop']) + expect(q?.optionTokens).toEqual(['1', '2']) + }) + + it('drops a trailing colon but keeps a question mark in the title', () => { + expect(parseAgentQuestion('Options:\n1. A\n2. B')?.question).toBe('Options') + expect(parseAgentQuestion('Ready?\n1. A\n2. B')?.question).toBe('Ready?') + }) + + it('returns null for ordinary prose with no option list', () => { + expect(parseAgentQuestion('I finished the refactor and ran the tests.')).toBeNull() + expect(parseAgentQuestion('')).toBeNull() + expect(parseAgentQuestion(' ')).toBeNull() + }) + + it('returns null for a single bare option with no introducing prompt', () => { + // A lone "- item" in prose must not be treated as a question. + expect(parseAgentQuestion('Here is what I did:\nsome work\n- one stray bullet')).toBeNull() + }) + + it('accepts a single option when introduced by a prompt line', () => { + const q = parseAgentQuestion('Apply this change?\n1. Yes, apply it') + expect(q).not.toBeNull() + expect(q?.options).toEqual(['Yes, apply it']) + }) + + it('uses a fallback title when no introducing line exists', () => { + const q = parseAgentQuestion('1. Alpha\n2. Beta') + expect(q?.question).toBe('Choose an option') + expect(q?.options).toEqual(['Alpha', 'Beta']) + }) + + it('detects multi-select hints', () => { + expect(parseAgentQuestion('Select all that apply:\n1. A\n2. B')?.multiSelect).toBe(true) + expect(parseAgentQuestion('Choose multiple:\n- A\n- B')?.multiSelect).toBe(true) + expect(parseAgentQuestion('Pick one:\n1. A\n2. B')?.multiSelect).toBe(false) + }) + + it('does not flag multi-select when only one option is present', () => { + const q = parseAgentQuestion('Select all that apply?\n1. Only one') + expect(q?.multiSelect).toBe(false) + }) + + it('finds the question line nearest the options, skipping blanks', () => { + const text = ['Some preamble.', '', 'Which branch?', '', '1. main', '2. dev'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.question).toBe('Which branch?') + }) +}) + +describe('formatQuestionAnswer', () => { + const numbered: MobileChatQuestion = { + question: 'Pick', + options: ['Alpha', 'Beta', 'Gamma'], + multiSelect: false, + optionTokens: ['1', '2', '3'] + } + + it('echoes the leading token for single-select', () => { + expect(formatQuestionAnswer(numbered, ['Beta'])).toBe('2') + }) + + it('comma-joins tokens for multi-select', () => { + const multi: MobileChatQuestion = { ...numbered, multiSelect: true } + expect(formatQuestionAnswer(multi, ['Alpha', 'Gamma'])).toBe('1, 3') + }) + + it('sends the label text when the option had no token (bullet list)', () => { + const bullets: MobileChatQuestion = { + question: 'Pick', + options: ['Rebase', 'Merge'], + multiSelect: false, + optionTokens: [null, null] + } + expect(formatQuestionAnswer(bullets, ['Merge'])).toBe('Merge') + }) + + it('echoes letter tokens', () => { + const lettered: MobileChatQuestion = { + question: 'Pick', + options: ['Alpha', 'Beta'], + multiSelect: false, + optionTokens: ['a', 'b'] + } + expect(formatQuestionAnswer(lettered, ['Beta'])).toBe('b') + }) + + it('passes free-text / unknown entries through verbatim', () => { + expect(formatQuestionAnswer(numbered, ['something custom'])).toBe('something custom') + }) + + it('returns empty string when nothing is selected', () => { + expect(formatQuestionAnswer(numbered, [])).toBe('') + expect(formatQuestionAnswer(numbered, [' '])).toBe('') + }) +}) + +describe('mobileChatQuestionKey', () => { + it('changes when a replacement prompt changes any positional content', () => { + const first: MobileChatQuestion = { + question: 'Pick', + options: ['A', 'B'], + multiSelect: true, + optionTokens: ['1', '2'] + } + expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe( + mobileChatQuestionKey(first) + ) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts new file mode 100644 index 00000000000..8a1f06dfbf7 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -0,0 +1,154 @@ +// Heuristic detection of an agent's "pick an option" prompt from its status / +// assistant text. Agents (Claude et al.) render these as a TUI choice list; we +// have no structured signal, so we parse the text conservatively and only treat +// it as a question when a clear option list is present. + +export type MobileChatQuestion = { + question: string + options: string[] + multiSelect: boolean + /** Per-option leading marker ("1", "b", …) when the source line carried one, + * parallel to `options`. Null where the option was a plain bullet. Used to + * echo the exact choice the agent listed back to the terminal. */ + optionTokens: (string | null)[] +} + +export function mobileChatQuestionKey(question: MobileChatQuestion): string { + return JSON.stringify(question) +} + +type ParsedOption = { + label: string + token: string | null +} + +// A pointer/highlight glyph some TUIs prefix onto the *currently selected* row. +// Stripped first so "❯ 2. Foo" parses identically to "2. Foo". +const POINTER_PREFIX = /^(\s*)(?:❯|›|»)\s+/ + +// Ordered most-specific-first so a numbered/lettered marker wins over the +// bullet fallback. `token` is the capture index of the leading marker (0 = none), +// `label` the capture index of the choice text. +const OPTION_PATTERNS: { re: RegExp; token: number; label: number }[] = [ + // 1. Option 12) Option + { re: /^\s*(\d{1,2})[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // [a] Option [1] Option + { re: /^\s*\[([0-9a-zA-Z])\]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // a) Option a. Option (single letter, to avoid eating prose like "e.g.") + { re: /^\s*([a-zA-Z])[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // - Option * Option • Option > Option (no token) + { re: /^\s*(?:[-*•>])\s+(\S.*?)\s*$/, token: 0, label: 1 } +] + +function parseOptionLine(line: string): ParsedOption | null { + const stripped = line.replace(POINTER_PREFIX, '$1') + for (const { re, token, label } of OPTION_PATTERNS) { + const m = stripped.match(re) + if (!m) { + continue + } + const text = m[label].trim() + if (text.length === 0) { + continue + } + return { label: text, token: token > 0 ? m[token] : null } + } + return null +} + +const MULTI_SELECT_HINT = + /\b(select all|choose all|choose multiple|select multiple|pick multiple|all that apply|one or more|comma[- ]separated|multiple options)\b/i + +// Question-like introducing line: ends in ? or :. +const QUESTION_LINE = /[?:]\s*$/ + +// Drop a trailing ":" off a card title but keep a meaningful "?". +function cleanQuestionText(raw: string): string { + const trimmed = raw.trim() + return trimmed.endsWith(':') ? trimmed.slice(0, -1).trim() : trimmed +} + +/** + * Heuristically parse a question + its option list from agent text. Returns null + * when no clear option list is present (so ordinary prose is never treated as a + * question). Conservative on purpose: requires at least two option lines, or one + * option line introduced by a question-like prompt line. + */ +export function parseAgentQuestion(text: string): MobileChatQuestion | null { + if (typeof text !== 'string' || text.trim().length === 0) { + return null + } + + const lines = text.replace(/\r\n/g, '\n').split('\n') + const parsed: { index: number; option: ParsedOption }[] = [] + + lines.forEach((line, index) => { + const option = parseOptionLine(line) + if (option) { + parsed.push({ index, option }) + } + }) + + if (parsed.length === 0) { + return null + } + + const firstOptionIndex = parsed[0].index + const options = parsed.map((p) => p.option.label) + const optionTokens = parsed.map((p) => p.option.token) + + // Find the introducing question: nearest non-empty, non-option line above the + // first option. + let question = '' + let questionLooksLikePrompt = false + for (let i = firstOptionIndex - 1; i >= 0; i--) { + if (lines[i].trim().length === 0 || parseOptionLine(lines[i])) { + continue + } + question = lines[i] + questionLooksLikePrompt = QUESTION_LINE.test(lines[i]) + break + } + + // Conservative gate: a single bare option with no introducing prompt is more + // likely stray prose (a lone "- item") than a real choice list. + if (options.length < 2 && !questionLooksLikePrompt) { + return null + } + + const multiSelect = MULTI_SELECT_HINT.test(text) && options.length > 1 + + return { + question: question.length > 0 ? cleanQuestionText(question) : 'Choose an option', + options, + multiSelect, + optionTokens + } +} + +/** + * Build the text to send to the agent terminal for the selected option(s). + * Convention: echo the option's leading marker (number/letter) when the list had + * one; otherwise send the option label text (TUIs accept the literal choice). + * Multi-select answers are comma-joined; single-select is sent as-is. Unknown + * entries (free-text escape hatch) pass through verbatim. Returns '' when + * nothing is selected. + */ +export function formatQuestionAnswer(question: MobileChatQuestion, selected: string[]): string { + const labels = selected.map((s) => s.trim()).filter((s) => s.length > 0) + if (labels.length === 0) { + return '' + } + + const parts = labels.map((label) => { + const index = question.options.indexOf(label) + if (index === -1) { + // Free-text / unknown entry: pass the user's text straight through. + return label + } + const token = question.optionTokens[index] + return token != null && token.length > 0 ? token : label + }) + + return parts.join(question.multiSelect ? ', ' : ' ') +} diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts new file mode 100644 index 00000000000..e933971220d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + buildMobileNativeChatData, + mobileNativeChatEmptyState +} from './mobile-native-chat-render-data' + +function assistant(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + } +} + +function user(id: string, text: string): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 0, source: 'transcript' } +} + +describe('mobileNativeChatEmptyState', () => { + it('invites a first message naming the agent, matching desktop copy', () => { + // waiting-session (live agent, no transcript) and ready (loaded, empty) both + // resolve to the shared "empty" copy with the agent label substituted. + const waiting = mobileNativeChatEmptyState('waiting-session', 'claude') + expect(waiting).toEqual({ + title: 'Start a chat with Claude', + subtitle: 'Ask Claude to inspect code, explain output, or make a change.' + }) + expect(mobileNativeChatEmptyState('ready', 'codex')?.title).toBe('Start a chat with Codex') + }) + + it('falls back to "the agent" when the agent is unknown', () => { + expect(mobileNativeChatEmptyState('waiting-session', null)?.title).toBe( + 'Start a chat with the agent' + ) + }) + + it('prefers the provided error message over the default subtitle', () => { + expect(mobileNativeChatEmptyState('error', 'claude', 'boom')?.subtitle).toBe('boom') + expect(mobileNativeChatEmptyState('error', 'claude')?.subtitle).toBe( + 'The transcript could not be read. Toggle back to the terminal to keep working.' + ) + }) + + it('returns null for states that show no empty copy', () => { + expect(mobileNativeChatEmptyState('loading', 'claude')).toBeNull() + expect(mobileNativeChatEmptyState('idle', 'claude')).toBeNull() + }) +}) + +describe('buildMobileNativeChatData', () => { + it('appends pending optimistic messages at the tail as user turns', () => { + const { data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'hello')], + streamingText: undefined, + pending: [{ id: 'p1', text: 'queued' }] + }) + const last = data[data.length - 1] + expect(last.id).toBe('p1') + expect(last.role).toBe('user') + expect(last.blocks).toEqual([{ type: 'text', text: 'queued' }]) + }) + + it('adds a synthetic streaming bubble while the partial text leads the transcript', () => { + const { streaming, data } = buildMobileNativeChatData({ + messages: [user('u1', 'hi')], + streamingText: 'thinking out loud', + pending: [] + }) + expect(streaming).toBe('thinking out loud') + expect(data.some((m) => m.id === 'streaming')).toBe(true) + }) + + it('shows a short new streaming reply even after a longer previous turn', () => { + // The last folded turn is a long completed reply; a short new stream must not + // be suppressed just for being shorter than the prior turn. + const { streaming, data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'This is a long completed previous answer that ran on a while')], + streamingText: 'Ok', + pending: [] + }) + expect(streaming).toBe('Ok') + expect(data.some((m) => m.id === 'streaming')).toBe(true) + }) + + it('drops the streaming bubble once the real assistant turn already contains it', () => { + const { streaming, data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'done answer')], + streamingText: 'done', + pending: [] + }) + expect(streaming).toBeNull() + expect(data.some((m) => m.id === 'streaming')).toBe(false) + }) + + it('returns no streaming bubble for empty/whitespace streaming text', () => { + expect( + buildMobileNativeChatData({ messages: [], streamingText: ' ', pending: [] }).streaming + ).toBeNull() + expect(buildMobileNativeChatData({ messages: [], pending: [] }).streaming).toBeNull() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts new file mode 100644 index 00000000000..74ca01939ba --- /dev/null +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -0,0 +1,115 @@ +import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label' +import { + formatNativeChatEmptyStateCopy, + type NativeChatEmptyStateCopy +} from '../../../src/shared/native-chat-empty-state' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { foldToolMessages } from './mobile-native-chat-blocks' +import { stripNoiseMessages } from './mobile-native-chat-noise' +import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' + +/** The centered empty-state copy for a chat with no messages, mirroring the + * desktop `NativeChatEmptyState` (shared copy + agent label) so the two surfaces + * stay in lockstep. Returns null when the list should stay bare (idle, or the + * loading spinner owns the view). */ +export function mobileNativeChatEmptyState( + status: MobileNativeChatStatus, + agent: string | null, + error?: string +): NativeChatEmptyStateCopy | null { + const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent' + switch (status) { + // A live agent with no transcript yet — and a loaded-but-empty transcript — + // are both "start a chat"; invite the first message instead of implying the + // agent is still starting up. + case 'waiting-session': + case 'ready': + return formatNativeChatEmptyStateCopy('empty', agentLabel) + case 'error': { + const copy = formatNativeChatEmptyStateCopy('error', agentLabel) + return error ? { ...copy, subtitle: error } : copy + } + default: + return null + } +} + +/** Derive the list data from the raw transcript: fold tool turns into the + * assistant turn, optionally append a synthetic streaming bubble, then the + * route-owned optimistic "queued" messages at the tail. Returns the + * intermediate `folded`/`streaming` so the caller can memoize on them. */ +export function buildMobileNativeChatData({ + messages, + streamingText, + pending +}: { + messages: NativeChatMessage[] + streamingText?: string + pending: Array<{ id: string; text: string }> +}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + const folded = foldMobileNativeChatMessages(messages) + return buildMobileNativeChatTransientData({ folded, streamingText, pending }) +} + +export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { + return foldToolMessages(stripNoiseMessages(messages)) +} + +export function buildMobileNativeChatTransientData({ + folded, + streamingText, + pending +}: { + folded: NativeChatMessage[] + streamingText?: string + pending: Array<{ id: string; text: string }> +}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + // Only show the streaming bubble while its text leads the transcript — once the + // real assistant turn lands with the same text, drop the synthetic one. + const streaming = deriveStreaming(folded, streamingText) + const data: NativeChatMessage[] = [ + ...folded, + ...(streaming + ? [ + { + id: 'streaming', + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: streaming }], + timestamp: null, + source: 'hook' as const + } + ] + : []), + ...pending.map((p) => ({ + id: p.id, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: p.text }], + timestamp: null, + source: 'transcript' as const + })) + ] + return { folded, streaming, data } +} + +function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null { + const text = streamingText?.trim() + if (!text) { + return null + } + const last = folded[folded.length - 1] + const lastText = + last?.role === 'assistant' + ? last.blocks + .filter((b) => b.type === 'text') + .map((b) => (b.type === 'text' ? b.text : '')) + .join('') + .trim() + : '' + // Hide the synthetic bubble only once the real turn has landed leading with the + // streamed text. A bare length compare would suppress a short new reply behind a + // longer previous turn; a completed prior turn won't start with the new prefix. + if (lastText.startsWith(text)) { + return null + } + return text +} diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts new file mode 100644 index 00000000000..01f4d5289ba --- /dev/null +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' + +function clientWithResponse(response: unknown): RpcClient { + return { + sendRequest: vi.fn().mockResolvedValue(response) + } as unknown as RpcClient +} + +describe('sendMobileNativeChatMessage', () => { + it('returns true only when the terminal accepts the send', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'hello', + mobileClient: { id: 'device', type: 'mobile' } + }) + ).resolves.toBe(true) + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'term', + text: 'hello', + enter: true, + client: { id: 'device', type: 'mobile' } + }) + }) + + it('returns false when the terminal rejects the send', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: false } }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe(false) + }) + + it('returns false when the RPC fails', async () => { + const client = { + sendRequest: vi.fn().mockRejectedValue(new Error('disconnected')) + } as unknown as RpcClient + + await expect( + sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe(false) + }) + + it('sends a single non-submitting Escape for prompt cancellation', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: String.fromCharCode(27), + enter: false + }) + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'term', + text: String.fromCharCode(27), + enter: false + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts new file mode 100644 index 00000000000..4cf1776843e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -0,0 +1,27 @@ +import type { RpcClient } from '../transport/rpc-client' +import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' + +type MobileTerminalClient = { + id: string + type: 'mobile' +} + +export async function sendMobileNativeChatMessage(args: { + client: RpcClient + terminal: string + text: string + enter?: boolean + mobileClient?: MobileTerminalClient +}): Promise { + try { + const response = await args.client.sendRequest('terminal.send', { + terminal: args.terminal, + text: args.text, + enter: args.enter ?? true, + ...(args.mobileClient ? { client: args.mobileClient } : {}) + }) + return isTerminalSendRpcAccepted(response) + } catch { + return false + } +} diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts new file mode 100644 index 00000000000..8d7d1e7e07a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' +import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' + +function message(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript' + } +} + +describe('applyMobileNativeChatStreamFrame', () => { + it('uses the first snapshot as the ordered base and carries pagination state', () => { + const merger = createNativeChatMerger() + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { + type: 'snapshot', + messages: [message('a'), message('b')], + hasMore: true, + beforeOffset: 123 + }, + limit: 40, + replaceSnapshot: true + }) + + expect(result).toEqual({ + kind: 'messages', + messages: [message('a'), message('b')], + hasMore: true, + beforeOffset: 123 + }) + }) + + it('merges reconnect snapshots and live appends into the bounded window', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('a'), message('b')]) + + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: [message('b'), message('c'), message('d')] }, + limit: 3, + replaceSnapshot: false + }) + + expect(result).toMatchObject({ + kind: 'messages', + messages: [message('b'), message('c'), message('d')], + cursorInvalidated: true + }) + }) + + it('keeps the pagination cursor when an append does not trim history', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('a')]) + + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [message('b')] }, + limit: 3, + replaceSnapshot: false + }) + ).toEqual({ kind: 'messages', messages: [message('a'), message('b')] }) + }) + + it('replaces stale history for an explicit transcript replacement frame', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('old')]) + + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'replacement', messages: [message('new')], hasMore: false }, + limit: 40, + replaceSnapshot: false + }) + ).toEqual({ kind: 'messages', messages: [message('new')], hasMore: false }) + }) + + it('surfaces snapshot errors and ignores unrelated frames', () => { + const merger = createNativeChatMerger() + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', error: 'Transcript unavailable' }, + limit: 40, + replaceSnapshot: true + }) + ).toEqual({ kind: 'error', error: 'Transcript unavailable' }) + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'error', message: 'Socket closed' }, + limit: 40, + replaceSnapshot: false + }) + ).toEqual({ kind: 'error', error: 'Socket closed' }) + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'end' }, + limit: 40, + replaceSnapshot: true + }) + ).toEqual({ kind: 'ignored' }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts new file mode 100644 index 00000000000..690994f02f3 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -0,0 +1,63 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { applyAppend, replaceList, type NativeChatMerger } from './mobile-native-chat-merge' + +export type MobileNativeChatStreamFrame = { + type?: string + messages?: NativeChatMessage[] + hasMore?: boolean + beforeOffset?: number + error?: string + message?: string +} + +export type AppliedMobileNativeChatFrame = + | { kind: 'ignored' } + | { kind: 'error'; error: string } + | { + kind: 'messages' + messages: NativeChatMessage[] + hasMore?: boolean + beforeOffset?: number + cursorInvalidated?: boolean + } + +/** Applies runtime stream frames while preserving the initial-snapshot versus + * reconnect-replay distinction owned by the session hook. */ +export function applyMobileNativeChatStreamFrame(args: { + merger: NativeChatMerger + frame: MobileNativeChatStreamFrame + limit: number + replaceSnapshot: boolean +}): AppliedMobileNativeChatFrame { + const { merger, frame, limit, replaceSnapshot } = args + if (frame.type === 'error') { + return { kind: 'error', error: frame.message ?? frame.error ?? 'Transcript stream failed' } + } + if (frame.type !== 'snapshot' && frame.type !== 'replacement' && frame.type !== 'appended') { + return { kind: 'ignored' } + } + if (frame.error) { + return { kind: 'error', error: frame.error } + } + if (!Array.isArray(frame.messages)) { + return { kind: 'ignored' } + } + if (frame.type === 'replacement' || (frame.type === 'snapshot' && replaceSnapshot)) { + replaceList(merger, frame.messages) + return { + kind: 'messages', + messages: merger.list, + hasMore: frame.hasMore, + ...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset }) + } + } + const previousFirstId = merger.list[0]?.id + const messages = applyAppend(merger, frame.messages, limit) + return { + kind: 'messages', + messages, + // Why: once the bounded live window drops its oldest row, the snapshot's + // byte cursor no longer describes the oldest retained message. + ...(previousFirstId && messages[0]?.id !== previousFirstId ? { cursorInvalidated: true } : {}) + } +} diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts new file mode 100644 index 00000000000..cc92c6b3aca --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { + isTerminalCoveredByNativeChat, + mobileNativeChatTerminalCapabilities, + resolveMobileNativeChatTerminalStreamAction +} from './mobile-native-chat-terminal-stream' + +const base = { + showNativeChat: false, + activeHandle: 'pty-1', + activeTabType: 'terminal', + streamActive: true, + streamCovered: false, + webViewReady: true +} + +describe('mobile native-chat terminal stream lifecycle', () => { + it('pauses an active terminal stream while chat covers it', () => { + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, showNativeChat: true })).toBe( + 'pause' + ) + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false + }) + ).toBe('pause') + expect(isTerminalCoveredByNativeChat(true, 'pty-1', 'pty-1')).toBe(true) + expect(mobileNativeChatTerminalCapabilities(true)).toEqual({ + terminalBinaryStream: 1, + mobileInputLeaseOnly: 1 + }) + expect(mobileNativeChatTerminalCapabilities(false)).toEqual({ terminalBinaryStream: 1 }) + }) + + it('records a cold-start cover before WebView readiness so return refreshes', () => { + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false, + webViewReady: false + }) + ).toBe('pause') + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamActive: true, + streamCovered: true + }) + ).toBe('resume') + }) + + it('resumes only the ready active terminal after chat closes', () => { + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamActive: false })).toBe( + 'resume' + ) + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamActive: false, + webViewReady: false + }) + ).toBe('none') + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamCovered: true })).toBe( + 'resume' + ) + }) + + it('does nothing for non-terminal tabs, missing handles, or settled states', () => { + expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none') + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false, + streamCovered: true + }) + ).toBe('none') + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeTabType: 'browser' })).toBe( + 'none' + ) + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeHandle: null })).toBe( + 'none' + ) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.ts b/mobile/src/session/mobile-native-chat-terminal-stream.ts new file mode 100644 index 00000000000..387fe9c3580 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-stream.ts @@ -0,0 +1,37 @@ +export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'none' + +/** Decides whether the active mobile terminal stream should run while native chat + * covers its WebView. Resume is allowed only once the mounted WebView is ready. */ +export function resolveMobileNativeChatTerminalStreamAction(args: { + showNativeChat: boolean + activeHandle: string | null + activeTabType: string | null + streamActive: boolean + streamCovered: boolean + webViewReady: boolean +}): MobileNativeChatTerminalStreamAction { + if (!args.activeHandle || args.activeTabType !== 'terminal') { + return 'none' + } + if (args.showNativeChat) { + return !args.streamCovered ? 'pause' : 'none' + } + return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none' +} + +export function isTerminalCoveredByNativeChat( + showNativeChat: boolean, + activeHandle: string | null, + handle: string +): boolean { + return showNativeChat && activeHandle === handle +} + +export function mobileNativeChatTerminalCapabilities(covered: boolean): { + terminalBinaryStream: 1 + mobileInputLeaseOnly?: 1 +} { + return covered + ? { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } + : { terminalBinaryStream: 1 } +} diff --git a/mobile/src/session/mobile-native-chat-toggle-action.ts b/mobile/src/session/mobile-native-chat-toggle-action.ts new file mode 100644 index 00000000000..32ae700671a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-toggle-action.ts @@ -0,0 +1,37 @@ +import { MessageSquare, SquareTerminal } from 'lucide-react-native' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility' + +type ToggleTab = MobileNativeChatTab & { + id: string + terminal: string | null +} + +/** Builds the optional terminal/chat switch shown in a terminal's long-press menu. */ +export function getMobileNativeChatToggleActions(args: { + terminalHandle: string | null + tabs: readonly ToggleTab[] + chatTabIds: ReadonlySet + nativeChatTranscriptIsLocalReadable: boolean + onClose: () => void + onToggle: (tabId: string) => void +}): ActionSheetAction[] { + const { terminalHandle, tabs, chatTabIds, onClose, onToggle } = args + const tab = terminalHandle + ? tabs.find((candidate) => candidate.terminal === terminalHandle) + : null + if (!tab || !resolveMobileNativeChat(tab, args.nativeChatTranscriptIsLocalReadable)) { + return [] + } + const isChat = chatTabIds.has(tab.id) + return [ + { + label: isChat ? 'Switch to terminal view' : 'Switch to chat view', + icon: isChat ? SquareTerminal : MessageSquare, + onPress: () => { + onClose() + onToggle(tab.id) + } + } + ] +} diff --git a/mobile/src/session/mobile-native-chat-tool-summary.test.ts b/mobile/src/session/mobile-native-chat-tool-summary.test.ts new file mode 100644 index 00000000000..d08deec4b31 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-tool-summary.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { briefToolArg, summarizeToolInput, toolFilePath } from './mobile-native-chat-tool-summary' + +describe('summarizeToolInput', () => { + it('passes short strings through, collapsing whitespace', () => { + expect(summarizeToolInput('ls -la\n/tmp')).toBe('ls -la /tmp') + }) + + it('serializes objects to compact JSON', () => { + expect(summarizeToolInput({ path: 'a.ts', limit: 5 })).toBe('{"path":"a.ts","limit":5}') + }) + + it('truncates long previews with an ellipsis', () => { + const long = 'x'.repeat(200) + const out = summarizeToolInput(long) + expect(out.length).toBe(80) + expect(out.endsWith('…')).toBe(true) + }) + + it('returns empty string for null/undefined', () => { + expect(summarizeToolInput(null)).toBe('') + expect(summarizeToolInput(undefined)).toBe('') + }) + + it('handles numbers and booleans', () => { + expect(summarizeToolInput(42)).toBe('42') + expect(summarizeToolInput(true)).toBe('true') + }) + + it('does not traverse an unbounded tool-input object for a short preview', () => { + const input: Record = Object.fromEntries( + Array.from({ length: 8 }, (_unused, index) => [`key${index}`, index]) + ) + const lateGetter = vi.fn(() => 'should not be read') + Object.defineProperty(input, 'late', { enumerable: true, get: lateGetter }) + + expect(summarizeToolInput(input)).not.toBe('') + expect(lateGetter).not.toHaveBeenCalled() + }) +}) + +describe('toolFilePath', () => { + it('extracts file_path / filePath / path / notebook_path', () => { + expect(toolFilePath({ file_path: 'src/a.ts' })).toBe('src/a.ts') + expect(toolFilePath({ filePath: 'src/b.ts' })).toBe('src/b.ts') + expect(toolFilePath({ path: 'src/c.ts' })).toBe('src/c.ts') + expect(toolFilePath({ notebook_path: 'n.ipynb' })).toBe('n.ipynb') + }) + it('returns null when there is no file target', () => { + expect(toolFilePath({ command: 'ls' })).toBeNull() + expect(toolFilePath('x')).toBeNull() + expect(toolFilePath(null)).toBeNull() + }) +}) + +describe('briefToolArg', () => { + it('takes the basename of a forward-slash path', () => { + expect(briefToolArg({ path: 'src/session/app.ts' })).toBe('app.ts') + }) + + it('takes the basename of a Windows backslash path from a raw transcript', () => { + expect(briefToolArg({ file_path: 'C:\\Users\\me\\project\\app.tsx' })).toBe('app.tsx') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-tool-summary.ts b/mobile/src/session/mobile-native-chat-tool-summary.ts new file mode 100644 index 00000000000..bc3f7a2f89a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-tool-summary.ts @@ -0,0 +1,7 @@ +export { + briefToolArg, + countToolCalls, + summarizeToolInput, + summarizeToolRun, + toolFilePath +} from '../../../src/shared/native-chat-tool-summary' diff --git a/mobile/src/session/mobile-native-chat-view-styles.ts b/mobile/src/session/mobile-native-chat-view-styles.ts new file mode 100644 index 00000000000..160477cfad8 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-view-styles.ts @@ -0,0 +1,109 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: colors.bgBase + }, + chromeRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 28, + paddingHorizontal: spacing.md + }, + chromeLeft: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + stopButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + }, + stopLabel: { + color: colors.statusRed, + fontSize: typography.metaSize, + fontWeight: '700' + }, + sendError: { + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingBottom: spacing.xs + }, + sendErrorText: { + color: colors.statusRed, + fontSize: typography.metaSize, + fontWeight: '600' + }, + chromeToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 4, + paddingHorizontal: spacing.xs + }, + chromeToggleLabel: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600' + }, + pressed: { + opacity: 0.6 + }, + listWrap: { + flex: 1, + position: 'relative' + }, + listContent: { + paddingVertical: spacing.sm, + flexGrow: 1 + }, + center: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl + }, + emptyTitle: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600', + textAlign: 'center', + marginBottom: spacing.xs + }, + emptySubtitle: { + color: colors.textMuted, + fontSize: typography.metaSize, + textAlign: 'center' + }, + fab: { + position: 'absolute', + right: spacing.md, + width: 38, + height: 38, + borderRadius: 19, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + fabBottom: { + bottom: spacing.md + }, + loadEarlier: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.md, + minHeight: 36 + }, + loadEarlierText: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600' + } +}) diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.ts new file mode 100644 index 00000000000..3b29a6dfaf7 --- /dev/null +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.ts @@ -0,0 +1,69 @@ +import { Eraser, Monitor, Smartphone } from 'lucide-react-native' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import type { MobileNativeChatTab } from './mobile-native-chat-eligibility' +import { getMobileNativeChatToggleActions } from './mobile-native-chat-toggle-action' + +type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null } + +/** Builds the terminal long-press menu without adding another action block to the + * already dense session route. Native chat stays first as the view switch. */ +export function getMobileTerminalActionSheetActions(args: { + target: Target | null + tabs: readonly TerminalTab[] + chatTabIds: ReadonlySet + nativeChatTranscriptIsLocalReadable: boolean + onDismiss: () => void + onToggleChat: (tabId: string) => void + isPhoneMode: (handle: string) => boolean + onToggleDisplayMode: (handle: string) => void + onRename: (target: Target) => void + onClear: (target: Target) => void + onClose: (target: Target) => void +}): ActionSheetAction[] { + const { target } = args + if (!target) { + return [] + } + const phoneMode = args.isPhoneMode(target.handle) + return [ + ...getMobileNativeChatToggleActions({ + terminalHandle: target.handle, + tabs: args.tabs, + chatTabIds: args.chatTabIds, + nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable, + onClose: args.onDismiss, + onToggle: args.onToggleChat + }), + { + label: phoneMode ? 'Switch to Desktop' : 'Switch to Phone', + icon: phoneMode ? Monitor : Smartphone, + onPress: () => { + args.onDismiss() + args.onToggleDisplayMode(target.handle) + } + }, + { + label: 'Rename', + onPress: () => { + args.onDismiss() + args.onRename(target) + } + }, + { + label: 'Clear Terminal', + icon: Eraser, + onPress: () => { + args.onDismiss() + args.onClear(target) + } + }, + { + label: 'Close', + destructive: true, + onPress: () => { + args.onDismiss() + args.onClose(target) + } + } + ] +} diff --git a/mobile/src/session/mobile-terminal-stream-subscribe.test.ts b/mobile/src/session/mobile-terminal-stream-subscribe.test.ts new file mode 100644 index 00000000000..8d3785efdcf --- /dev/null +++ b/mobile/src/session/mobile-terminal-stream-subscribe.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { subscribeMobileTerminalSafely } from './mobile-terminal-stream-subscribe' + +describe('subscribeMobileTerminalSafely', () => { + it('reports a synchronous subscribe failure and returns a safe no-op cleanup', () => { + const onSynchronousError = vi.fn() + const client = { + subscribe: vi.fn(() => { + throw new Error('socket closed') + }) + } as unknown as Pick + + const unsubscribe = subscribeMobileTerminalSafely( + client, + { terminal: 'terminal-1' }, + vi.fn(), + onSynchronousError + ) + + expect(onSynchronousError).toHaveBeenCalledOnce() + expect(unsubscribe).not.toThrow() + }) +}) diff --git a/mobile/src/session/mobile-terminal-stream-subscribe.ts b/mobile/src/session/mobile-terminal-stream-subscribe.ts new file mode 100644 index 00000000000..189b07a74f0 --- /dev/null +++ b/mobile/src/session/mobile-terminal-stream-subscribe.ts @@ -0,0 +1,17 @@ +import type { RpcClient } from '../transport/rpc-client' + +export function subscribeMobileTerminalSafely( + client: Pick, + params: unknown, + onData: Parameters[2], + onSynchronousError: () => void +): () => void { + try { + return client.subscribe('terminal.subscribe', params, onData) + } catch { + // Why: a transport mock or closing socket can reject before returning an + // unsubscribe handle; callers must still release their subscribing marker. + onSynchronousError() + return () => {} + } +} diff --git a/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts b/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts new file mode 100644 index 00000000000..a69d66accdd --- /dev/null +++ b/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts @@ -0,0 +1,134 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileAttachmentInputLeaseGate } from './use-mobile-attachment-input-lease-gate' + +type Gate = (targetHandle: string) => Promise + +describe('useMobileAttachmentInputLeaseGate', () => { + let renderer: ReactTestRenderer | null = null + let errorSpy: ReturnType | null = null + + beforeEach(() => { + const original = console.error + errorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...(args as Parameters)) + }) + vi.useFakeTimers() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + errorSpy?.mockRestore() + }) + + function renderGate(args: { + connState: { current: string } + activeHandle: { current: string | null } + tabType: { current: string | null } + leaseReady: { current: boolean } + showToast: (message: string, durationMs?: number) => void + }): { gate: () => Gate } { + let gate: Gate = () => Promise.resolve(false) + function Probe(): null { + gate = useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend: () => Promise.resolve(true), + connStateRef: args.connState, + activeHandleRef: args.activeHandle, + activeSessionTabTypeRef: args.tabType, + nativeChatInputLeaseReadyRef: args.leaseReady, + showToast: args.showToast + }) + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + return { gate: () => gate } + } + + function baseRefs(): { + connState: { current: string } + activeHandle: { current: string | null } + tabType: { current: string | null } + leaseReady: { current: boolean } + } { + return { + connState: { current: 'connected' }, + activeHandle: { current: 'terminal-1' }, + tabType: { current: 'terminal' }, + leaseReady: { current: true } + } + } + + it('passes immediately when the lease is ready and the target is active', async () => { + const refs = baseRefs() + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + await expect(gate()('terminal-1')).resolves.toBe(true) + expect(showToast).not.toHaveBeenCalled() + }) + + it('waits out a lease-not-ready window and then sends', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(200) + refs.leaseReady.current = true + await vi.advanceTimersByTimeAsync(100) + await expect(result).resolves.toBe(true) + expect(showToast).not.toHaveBeenCalled() + }) + + it('surfaces a toast when the lease never recovers', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(3200) + await expect(result).resolves.toBe(false) + expect(showToast).toHaveBeenCalledWith('Attach failed (reconnecting)', 1500) + }) + + it('drops silently when the target changes while waiting for the lease', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + // Mid-wait the user switches tabs: the lease recovers, but for a different + // target — the attach must not proceed against the stale handle. + await vi.advanceTimersByTimeAsync(200) + refs.activeHandle.current = 'terminal-2' + refs.leaseReady.current = true + await vi.advanceTimersByTimeAsync(100) + await expect(result).resolves.toBe(false) + expect(showToast).not.toHaveBeenCalled() + }) + + it('drops silently when the connection is lost while waiting', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(200) + refs.connState.current = 'reconnecting' + await vi.advanceTimersByTimeAsync(3200) + await expect(result).resolves.toBe(false) + expect(showToast).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/use-mobile-attachment-input-lease-gate.ts b/mobile/src/session/use-mobile-attachment-input-lease-gate.ts new file mode 100644 index 00000000000..a0ae29b6c51 --- /dev/null +++ b/mobile/src/session/use-mobile-attachment-input-lease-gate.ts @@ -0,0 +1,75 @@ +import { useCallback } from 'react' + +type CurrentRef = { readonly current: T } + +type AttachmentInputLeaseGateArgs = { + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly connStateRef: CurrentRef + readonly activeHandleRef: CurrentRef + readonly activeSessionTabTypeRef: CurrentRef + readonly nativeChatInputLeaseReadyRef: CurrentRef + readonly showToast: (message: string, durationMs?: number) => void +} + +// Poll cadence + ceiling for riding out a terminal resubscribe (WS reconnect or +// return-to-terminal) during which the input lease is briefly not ready. +const LEASE_READY_POLL_MS = 100 +const LEASE_READY_TIMEOUT_MS = 3000 + +/** Gates an image attachment's terminal.send on a ready input lease. Flushes any + * pending IME/live input, confirms the send still targets the connected terminal + * tab, then waits out a short lease-not-ready window so a finished upload isn't + * dropped as if the picker were cancelled. Returns false only when the lease + * never recovers — surfacing a toast, since the caller treats a bare false as a + * silent picker-cancel (no error path). */ +export function useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend, + connStateRef, + activeHandleRef, + activeSessionTabTypeRef, + nativeChatInputLeaseReadyRef, + showToast +}: AttachmentInputLeaseGateArgs): (targetHandle: string) => Promise { + return useCallback( + async (targetHandle: string): Promise => { + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) + // Why: image picking/upload and IME flushing can outlive the original tab. + if ( + !flushedPendingInput || + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return false + } + const deadline = Date.now() + LEASE_READY_TIMEOUT_MS + while (!nativeChatInputLeaseReadyRef.current && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, LEASE_READY_POLL_MS)) + } + // Why: the wait can outlive the target too — re-check so a tab/host switch + // or disconnect mid-wait doesn't send into the wrong (or dead) terminal. + // A moved-away target drops silently like the pre-wait guard; only a lease + // that never recovered warrants the toast. + if ( + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return false + } + if (nativeChatInputLeaseReadyRef.current) { + return true + } + showToast('Attach failed (reconnecting)', 1500) + return false + }, + [ + activeHandleRef, + activeSessionTabTypeRef, + connStateRef, + flushPendingLiveInputBeforeExternalSend, + nativeChatInputLeaseReadyRef, + showToast + ] + ) +} diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts new file mode 100644 index 00000000000..79a11b6d723 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -0,0 +1,243 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentType } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' +import type { AskPrompt } from './mobile-native-chat-ask' +import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' + +type AnswerSend = ReturnType + +function acceptedResponse() { + return { + id: 'send', + ok: true as const, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + } +} + +const TABS_OR_SPACES: AskPrompt = { + questions: [ + { + question: 'Tabs or spaces?', + multiSelect: false, + options: [{ label: 'Tabs' }, { label: 'Spaces' }] + } + ] +} + +describe('useMobileNativeChatAnswerSend', () => { + let renderer: ReactTestRenderer | null = null + let answerSend: AnswerSend | null = null + let mountedClient: RpcClient | null = null + let mountedOnSendError: ((message: string) => void) | null = null + let mountedAgent: AgentType = 'claude' + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + answerSend = null + mountedClient = null + mountedOnSendError = null + mountedAgent = 'claude' + vi.useRealTimers() + }) + + function Harness({ enabled }: { enabled: boolean }): null { + answerSend = useMobileNativeChatAnswerSend({ + client: mountedClient, + enabled, + handleRef: { current: 'terminal' }, + deviceTokenRef: { current: 'device' }, + agentRef: { current: mountedAgent }, + sessionId: 'session', + streamIdentity: 'host\0worktree\0tab\0session', + onSendError: mountedOnSendError! + }) + return null + } + + async function mount( + client: RpcClient, + onSendError: (message: string) => void, + agent: AgentType = 'claude' + ): Promise { + mountedClient = client + mountedOnSendError = onSendError + mountedAgent = agent + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { enabled: true })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + async function setEnabled(enabled: boolean): Promise { + await act(async () => { + renderer?.update(createElement(Harness, { enabled })) + }) + } + + it('single-select: sends the picked option NUMBER (not the label), no trailing Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + // Spaces is option 2 — the STA-1860 case where label text committed Tabs. + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) + }) + + it('multi-select: toggles each option number, steps to Submit, confirms — paced apart', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { + question: 'Pick fruits', + multiSelect: true, + options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }] + } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [0, 2] }]) + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false }) + + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: '3', enter: false }) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({ text: '\x1b[C', enter: false }) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls[3]?.[1]).toMatchObject({ text: '\r', enter: false }) + }) + + it('multi-question: option numbers auto-advance, one final submit Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ text: '2', enter: false }), + expect.objectContaining({ text: '1', enter: false }), + expect.objectContaining({ text: '\r', enter: false }) + ]) + }) + + it('free text: opens "Type something", types the sanitized answer, then Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + let result: Promise | undefined + await act(async () => { + // A newline in raw keystrokes would submit early — must collapse to space. + result = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [], other: 'zeta\nspaces' }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ text: '3', enter: false }), + expect.objectContaining({ text: 'zeta spaces', enter: false }), + expect.objectContaining({ text: '\r', enter: false }) + ]) + }) + + it('answers OpenClaude asks with Claude selector keystrokes', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'openclaude') + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) + }) + + it('submits a non-Claude answer as pasted label text with a single Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex') + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + // Codex's question tool commits the pasted answer: label text + one Enter. + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true }) + }) + + it('stops at the first rejected write and reports failure', async () => { + const onSendError = vi.fn() + const sendRequest = vi.fn().mockResolvedValue({ + id: 'send', + ok: true, + result: { send: { accepted: false } }, + _meta: { runtimeId: 'runtime' } + }) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(onSendError).toHaveBeenCalledWith('Answer not sent') + }) + + it('rejects an empty selection without writing anything', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [] }])).resolves.toBe(false) + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('cancels delayed keystrokes when the acknowledged input lease is lost', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await setEnabled(false) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts new file mode 100644 index 00000000000..255f60de3a7 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' +import { + buildAskAnswerKeys, + formatAskAnswer, + hasAskAnswer, + type AskAnswerSelection, + type AskPrompt +} from './mobile-native-chat-ask' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { shouldStepNativeChatAskAnswer } from '../../../src/shared/native-chat-agent-support' + +/** Sends an AskUserQuestion answer to the active chat pane. Claude's selector is + * answered by option-number keystrokes; other agents get pasted label text. + * Extracted from the session route to keep that file under its line cap and to + * own the pending-timer lifecycle in one place. */ +export type MobileNativeChatAnswerSend = { + /** Answer the current question(s) from the card's per-question selections. */ + answerAsk: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise + /** Drop any in-flight per-keystroke writes (call on Stop). */ + cancelPending: () => void +} + +// A free-text answer is written as raw keystrokes into Claude's "Type something" +// input (terminal.send has no paste framing), so an embedded newline would +// submit it early — collapse line breaks to spaces. +function sanitizeAskFreeText(text: string): string { + return text.replace(/[\r\n]+/g, ' ') +} + +/** + * Owns the ask-answer send sequence for the mobile native chat. Reads the live + * pane/agent through refs (the route already keeps them current) so the returned + * callbacks stay stable. Claude answers are delivered as `buildAskAnswerKeys` + * keystroke groups written one selector-step apart over the EXISTING + * `terminal.send` passthrough (raw text, no enter) — same contract the + * permission card already uses, so old runtimes replay them verbatim (no new + * RPC; keystrokes are built client-side). The scheduled wait chain is cancelled + * on a new answer, on `cancelPending` (Stop), and on unmount / session swap — so + * a detached chain can never write PTY bytes to a stale pane. + */ +export function useMobileNativeChatAnswerSend(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + agentRef: MutableRefObject + /** Changes on chat session swap; cancels pending writes when it does. */ + sessionId: string | null + streamIdentity: string + onSendError: (message: string) => void +}): MobileNativeChatAnswerSend { + const { + client, + enabled, + handleRef, + deviceTokenRef, + agentRef, + sessionId, + streamIdentity, + onSendError + } = args + const generationRef = useRef(0) + const activeRouteRef = useRef({ client, enabled, sessionId, streamIdentity }) + activeRouteRef.current = { client, enabled, sessionId, streamIdentity } + const delaysRef = useRef< + Set<{ timer: ReturnType; resolve: (completed: boolean) => void }> + >(new Set()) + + const cancelPending = useCallback(() => { + generationRef.current += 1 + for (const delay of delaysRef.current) { + clearTimeout(delay.timer) + delay.resolve(false) + } + delaysRef.current.clear() + }, []) + + // Cancel pending writes on unmount and whenever the chat session swaps. + useEffect(() => { + if (!enabled) { + cancelPending() + } + return cancelPending + }, [client, enabled, sessionId, streamIdentity, cancelPending]) + + const answerAsk = useCallback( + async (prompt: AskPrompt, selections: AskAnswerSelection[]): Promise => { + const handle = handleRef.current + if (!client || !handle || !enabled) { + onSendError('Answer not sent (disconnected)') + return false + } + if (!hasAskAnswer(prompt, selections)) { + return false + } + // A new answer supersedes any still-pending keystroke writes. + cancelPending() + const generation = generationRef.current + const sendTerminal = (body: string, enter: boolean): Promise => { + const activeRoute = activeRouteRef.current + if ( + !activeRoute.enabled || + activeRoute.client !== client || + activeRoute.sessionId !== sessionId || + activeRoute.streamIdentity !== streamIdentity || + handleRef.current !== handle + ) { + return Promise.resolve(false) + } + return sendMobileNativeChatMessage({ + client, + terminal: handle, + text: body, + enter, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + } + const wait = (ms: number): Promise => + new Promise((resolve) => { + const delay = { + timer: setTimeout(() => { + delaysRef.current.delete(delay) + resolve(generationRef.current === generation) + }, ms), + resolve + } + delaysRef.current.add(delay) + }) + const fail = (): false => { + if (generationRef.current === generation) { + onSendError('Answer not sent') + } + return false + } + // Non-Claude question tools commit a pasted answer, so send the label text + // with one Enter. Claude's arrow-navigate selector ignores pasted labels + // (STA-1860): drive it by option-number keystrokes instead, one group per + // selector step so each renders before the next lands. + if (!shouldStepNativeChatAskAnswer(agentRef.current)) { + return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail() + } + const groups = buildAskAnswerKeys(prompt, selections) + for (let index = 0; index < groups.length; index += 1) { + if (generationRef.current !== generation) { + return false + } + const group = groups[index]! + const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text) + if (!(await sendTerminal(body, false))) { + return fail() + } + if (index < groups.length - 1 && !(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { + return false + } + } + return groups.length > 0 + }, + [ + agentRef, + enabled, + cancelPending, + client, + deviceTokenRef, + handleRef, + onSendError, + sessionId, + streamIdentity + ] + ) + + return { answerAsk, cancelPending } +} diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts new file mode 100644 index 00000000000..8c81f0255c2 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -0,0 +1,60 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AskPrompt } from './mobile-native-chat-ask' +import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' + +describe('useMobileNativeChatAskDismiss', () => { + let renderer: ReactTestRenderer | null = null + let state: ReturnType | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ prompt }: { prompt: AskPrompt }): null { + state = useMobileNativeChatAskDismiss(prompt) + return null + } + + const first: AskPrompt = { + questions: [ + { question: 'same first', multiSelect: false, options: [] }, + { question: 'old second', multiSelect: false, options: [] } + ] + } + const replacement: AskPrompt = { + questions: [ + { question: 'same first', multiSelect: false, options: [] }, + { question: 'new second', multiSelect: false, options: [] } + ] + } + + it('shows a structurally different replacement without an intervening null', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { prompt: first })) + }) + } finally { + consoleSpy.mockRestore() + } + act(() => state?.dismissAsk()) + expect(state?.showAsk).toBe(false) + + await act(async () => renderer?.update(createElement(Harness, { prompt: replacement }))) + expect(state?.showAsk).toBe(true) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts new file mode 100644 index 00000000000..8f7289f0753 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import type { AskPrompt } from './mobile-native-chat-ask' + +/** Track the answered-ask key so the lingering live status doesn't re-show the + * same card. The agent emits a post-tool event with the same prompt right after + * an answer, so the card is hidden until a genuinely different question arrives. */ +export function useMobileNativeChatAskDismiss(ask?: AskPrompt | null): { + askKey: string | null + showAsk: boolean + dismissAsk: () => void +} { + const askKey = ask ? JSON.stringify(ask.questions) : null + const [dismissedAskKey, setDismissedAskKey] = useState(null) + // Once the prompt clears (agent moved on), forget the dismissal so a later + // question — even an identical one — shows again instead of staying hidden. + const askPresent = ask != null + useEffect(() => { + if (!askPresent) { + setDismissedAskKey(null) + } + }, [askPresent]) + const showAsk = askPresent && askKey !== dismissedAskKey + const dismissAsk = (): void => setDismissedAskKey(askKey) + + return { askKey, showAsk, dismissAsk } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts new file mode 100644 index 00000000000..cca9ae3f923 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -0,0 +1,314 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type MutableRefObject, + type SetStateAction +} from 'react' +import { loadNativeChatTabIds, saveNativeChatTabIds } from '../storage/preferences' +import type { RpcClient } from '../transport/rpc-client' +import { + parseAskFromStatus, + type AskAnswerSelection, + type AskPrompt +} from './mobile-native-chat-ask' +import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' +import { detectAgentPermission } from './mobile-native-chat-permission' +import { parseAgentQuestion } from './mobile-native-chat-question' +import { openMobileNativeChatFile } from './mobile-native-chat-open-file' +import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' +import { useMobileNativeChatSession } from './use-mobile-native-chat-session' +import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' +import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' +import { useThrottledLatestValue } from './use-throttled-latest-value' + +const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 + +export type MobileNativeChatController = { + chatTabIds: Set + toggleTabChatView: (tabId: string) => void + showNativeChat: boolean + showNativeChatRef: MutableRefObject + /** Resolved agent for the active chat tab (names the empty-state copy). */ + nativeChatAgent: string | null + chatComposerText: string + setChatComposerText: Dispatch> + chatPending: Array<{ id: string; text: string }> + nativeChatSession: ReturnType + nativeChatAgentWorking: boolean + nativeChatStreamingText?: string + nativeChatPermission: ReturnType + nativeChatQuestion: ReturnType + nativeChatAsk: ReturnType + handleNativeChatOpenFile: (relativePath: string) => void + handleNativeChatAnswerAsk: ( + prompt: AskPrompt, + selections: AskAnswerSelection[] + ) => Promise + handleNativeChatCancelAsk: () => Promise + handleNativeChatRespondPermission: (text: string) => Promise + handleNativeChatStop: () => void + nativeChatFilePaths: string[] + loadNativeChatFiles: (query: string) => void + handleNativeChatSend: (text: string) => Promise +} + +/** Owns mobile native-chat state and teardown outside the already dense session + * route. The route remains responsible only for choosing and rendering the view. */ +export function useMobileNativeChatController(args: { + client: RpcClient | null + hostId: string + worktreeId: string + activeSessionTab: MobileNativeChatTab | null + activeSessionTabId: string | null + activeHandleRef: MutableRefObject + deviceTokenRef: MutableRefObject + nativeChatTranscriptIsLocalReadable: boolean + nativeChatInputLeaseReady: boolean + onSendError: (message: string) => void +}): MobileNativeChatController { + const { + client, + hostId, + worktreeId, + activeSessionTab, + activeSessionTabId, + activeHandleRef, + deviceTokenRef, + nativeChatTranscriptIsLocalReadable, + nativeChatInputLeaseReady, + onSendError + } = args + const [chatTabIds, setChatTabIds] = useState>(new Set()) + const chatTabIdsToggledRef = useRef(false) + + useEffect(() => { + let active = true + // Re-arm per host/worktree so the fresh load can seed the new selection. + chatTabIdsToggledRef.current = false + void loadNativeChatTabIds(hostId, worktreeId).then((ids) => { + // Why: a toggle before this load resolves is authoritative; the load must + // not revert the user's in-memory choice back to the persisted state. + if (active && !chatTabIdsToggledRef.current) { + // Why: route reuse must clear the prior worktree's selection even when + // the newly loaded worktree has no saved chat tabs. + setChatTabIds(new Set(ids)) + } + }) + return () => { + active = false + } + }, [hostId, worktreeId]) + + const toggleTabChatView = useCallback( + (tabId: string) => { + chatTabIdsToggledRef.current = true + setChatTabIds((previous) => { + const next = new Set(previous) + if (next.has(tabId)) { + next.delete(tabId) + } else { + next.add(tabId) + } + void saveNativeChatTabIds(hostId, worktreeId, [...next]) + return next + }) + }, + [hostId, worktreeId] + ) + + const activeChatResolution = + activeSessionTab && activeSessionTabId && chatTabIds.has(activeSessionTabId) + ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable) + : null + const showNativeChat = activeChatResolution != null + const showNativeChatRef = useRef(showNativeChat) + showNativeChatRef.current = showNativeChat + const activeChatAgentRef = useRef(activeChatResolution?.agent ?? null) + activeChatAgentRef.current = activeChatResolution?.agent ?? null + + const activeChatSessionId = activeChatResolution?.sessionId ?? null + const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + + const nativeChatSession = useMobileNativeChatSession({ + client, + agent: activeChatResolution?.agent ?? null, + sessionId: activeChatSessionId, + transcriptPath: activeChatResolution?.transcriptPath ?? null + }) + const { + composerText: chatComposerText, + setComposerText: setChatComposerText, + pending: chatPending, + captureSendOrigin, + acceptSend + } = useMobileNativeChatDrafts({ + hostId, + worktreeId, + tabId: activeSessionTabId, + sessionId: activeChatSessionId, + messages: nativeChatSession.messages + }) + + const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null + const nativeChatAgentWorking = nativeChatStatus?.state === 'working' + // Throttle the streaming bubble: OpenCode emits a status frame per streamed + // part, and each one re-renders and re-parses the whole accumulated markdown. + const nativeChatStreamingText = useThrottledLatestValue( + nativeChatAgentWorking ? nativeChatStatus?.lastAssistantMessage : undefined, + NATIVE_CHAT_STREAM_THROTTLE_MS + ) + const { + permission: nativeChatPermission, + question: nativeChatQuestion, + ask: nativeChatAsk + } = useMobileNativeChatPrompts({ + enabled: activeChatResolution != null, + status: nativeChatStatus, + messages: nativeChatSession.messages + }) + + const handleNativeChatOpenFile = useCallback( + (pathText: string) => { + if (!client) { + return + } + void openMobileNativeChatFile({ + client, + worktreeId, + pathText, + terminal: activeHandleRef.current + }) + }, + [activeHandleRef, client, worktreeId] + ) + + const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } = + useMobileNativeChatAnswerSend({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + agentRef: activeChatAgentRef, + sessionId: activeChatSessionId, + streamIdentity, + onSendError + }) + + const handleNativeChatCancelAsk = useCallback(async (): Promise => { + const handle = activeHandleRef.current + if (!client || !handle || !nativeChatInputLeaseReady) { + onSendError('Cancel not sent (disconnected)') + return false + } + cancelNativeChatAnswer() + const accepted = await sendMobileNativeChatMessage({ + client, + terminal: handle, + text: String.fromCharCode(27), + enter: false, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (!accepted) { + onSendError('Cancel not sent') + } + return accepted + }, [ + activeHandleRef, + cancelNativeChatAnswer, + client, + deviceTokenRef, + nativeChatInputLeaseReady, + onSendError + ]) + + const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + onSendError + }) + + const handleNativeChatStop = useMobileNativeChatStop({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + streamIdentity, + cancelPending: cancelNativeChatAnswer, + onSendError + }) + + const { nativeChatFilePaths, loadNativeChatFiles } = useMobileNativeChatFileSearch({ + client, + worktreeId + }) + + const handleNativeChatSend = useCallback( + async (text: string): Promise => { + const handle = activeHandleRef.current + const origin = captureSendOrigin(text) + if (!client || !handle || !origin || !nativeChatInputLeaseReady) { + onSendError('Message not sent (disconnected)') + return false + } + const accepted = await sendMobileNativeChatMessage({ + client, + terminal: handle, + text, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (!accepted) { + onSendError('Message not sent') + return false + } + acceptSend(origin, text) + return true + }, + [ + acceptSend, + activeHandleRef, + captureSendOrigin, + client, + deviceTokenRef, + nativeChatInputLeaseReady, + onSendError + ] + ) + + return { + chatTabIds, + toggleTabChatView, + showNativeChat, + showNativeChatRef, + nativeChatAgent: activeChatResolution?.agent ?? null, + chatComposerText, + setChatComposerText, + chatPending, + nativeChatSession, + nativeChatAgentWorking, + nativeChatStreamingText, + nativeChatPermission, + nativeChatQuestion, + nativeChatAsk, + handleNativeChatOpenFile, + handleNativeChatAnswerAsk, + handleNativeChatCancelAsk, + handleNativeChatRespondPermission, + handleNativeChatStop, + nativeChatFilePaths, + loadNativeChatFiles, + handleNativeChatSend + } +} diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts new file mode 100644 index 00000000000..1505c9d1dc4 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -0,0 +1,192 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type DraftState = ReturnType + +function userTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +function assistantTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +describe('useMobileNativeChatDrafts', () => { + let renderer: ReactTestRenderer | null = null + let state: DraftState | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ + tabId, + sessionId = `session-${tabId}`, + messages = [] + }: { + tabId: string + sessionId?: string | null + messages?: NativeChatMessage[] + }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId, + sessionId, + messages + }) + return null + } + + async function mount(tabId: string): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { tabId })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + async function switchTo(tabId: string): Promise { + await act(async () => renderer?.update(createElement(Harness, { tabId }))) + } + + it('keeps drafts and accepted pending messages on their originating tabs', async () => { + await mount('a') + act(() => state?.setComposerText('from a')) + const originA = state?.captureSendOrigin('from a') + expect(originA).not.toBeNull() + + await switchTo('b') + act(() => state?.setComposerText('from b')) + act(() => { + if (originA) { + state?.acceptSend(originA, 'from a') + } + }) + expect(state?.composerText).toBe('from b') + expect(state?.pending).toEqual([]) + + await switchTo('a') + expect(state?.composerText).toBe('') + expect(state?.pending.map((pending) => pending.text)).toEqual(['from a']) + }) + + it('clears one pending per landed message so duplicate sends are not all dropped', async () => { + await mount('a') + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + state?.acceptSend(origin, 'ping') + } + }) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping', 'ping']) + + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] }) + ) + ) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping']) + }) + + it('does not reconcile a repeated send against an older identical turn', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('old', 'ping')] }) + ) + ) + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('old', 'ping'), assistantTextMessage('other', 'working')] + }) + ) + ) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping']) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [ + userTextMessage('old', 'ping'), + assistantTextMessage('other', 'working'), + userTextMessage('new', 'ping') + ] + }) + ) + ) + expect(state?.pending).toEqual([]) + }) + + it('does not erase newer edits when an older send settles', async () => { + await mount('a') + act(() => state?.setComposerText('submitted')) + const origin = state?.captureSendOrigin('submitted') + act(() => state?.setComposerText('new edit')) + act(() => { + if (origin) { + state?.acceptSend(origin, 'submitted') + } + }) + + expect(state?.composerText).toBe('new edit') + }) + + it('accepts and clears the first send before a provider session id exists', async () => { + await mount('a') + await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null }))) + act(() => state?.setComposerText('start the session')) + + const origin = state?.captureSendOrigin('start the session') + expect(origin).toMatchObject({ pendingKey: null }) + act(() => { + if (origin) { + state?.acceptSend(origin, 'start the session') + } + }) + + expect(state?.composerText).toBe('') + expect(state?.pending).toEqual([]) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts new file mode 100644 index 00000000000..ddd730e227c --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +export type MobileNativeChatPendingMessage = { + id: string + text: string + expectedOccurrence: number +} +export type MobileNativeChatSendOrigin = { + draftKey: string + pendingKey: string | null + normalizedText: string + baselineOccurrences: number +} + +const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] + +function normalizedUserText(message: NativeChatMessage): string | null { + if (message.role !== 'user') { + return null + } + const text = message.blocks + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join('') + .trim() + return text || null +} + +function countUserTextOccurrences(messages: readonly NativeChatMessage[], text: string): number { + let count = 0 + for (const message of messages) { + if (normalizedUserText(message) === text) { + count++ + } + } + return count +} + +export function useMobileNativeChatDrafts(args: { + hostId: string + worktreeId: string + tabId: string | null + sessionId: string | null + messages: readonly NativeChatMessage[] +}): { + composerText: string + setComposerText: Dispatch> + pending: MobileNativeChatPendingMessage[] + captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + acceptSend: (origin: MobileNativeChatSendOrigin, text: string) => void +} { + const { hostId, worktreeId, tabId, sessionId, messages } = args + const draftKey = tabId ? `${hostId}\0${worktreeId}\0${tabId}` : null + const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null + const [drafts, setDrafts] = useState>({}) + const [pendingBySession, setPendingBySession] = useState< + Record + >({}) + const pendingCounterRef = useRef(0) + const messagesRef = useRef(messages) + messagesRef.current = messages + + const setComposerText: Dispatch> = useCallback( + (value) => { + if (!draftKey) { + return + } + setDrafts((previous) => { + const current = previous[draftKey] ?? '' + const next = typeof value === 'function' ? value(current) : value + return next === current ? previous : { ...previous, [draftKey]: next } + }) + }, + [draftKey] + ) + + const captureSendOrigin = useCallback( + (text: string) => { + if (!draftKey) { + return null + } + const normalizedText = text.trim() + return { + draftKey, + pendingKey, + normalizedText, + baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText) + } + }, + [draftKey, pendingKey] + ) + + const acceptSend = useCallback((origin: MobileNativeChatSendOrigin, text: string) => { + // Why: an RPC may settle after a tab switch; mutate only the tab that + // originated the send, without erasing edits typed after it began. + setDrafts((previous) => + (previous[origin.draftKey] ?? '').trim() === text.trim() + ? { ...previous, [origin.draftKey]: '' } + : previous + ) + // Why: the first prompt can be sent before the provider reports a session + // id; clear its draft, but wait for an id before keying an optimistic echo. + if (!origin.pendingKey) { + return + } + const pendingKey = origin.pendingKey + pendingCounterRef.current += 1 + setPendingBySession((previous) => { + const current = previous[pendingKey] ?? NO_PENDING_MESSAGES + const earlierOutstanding = current.filter( + (pending) => + pending.text.trim() === origin.normalizedText && + pending.expectedOccurrence > origin.baselineOccurrences + ).length + const pending = { + id: `pending-${pendingCounterRef.current}`, + text, + expectedOccurrence: origin.baselineOccurrences + earlierOutstanding + 1 + } + return { ...previous, [pendingKey]: [...current, pending] } + }) + }, []) + + const pending = pendingKey + ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + : NO_PENDING_MESSAGES + useEffect(() => { + if (!pendingKey || pending.length === 0) { + return + } + setPendingBySession((previous) => { + const current = previous[pendingKey] ?? [] + const landedCounts = new Map() + for (const message of messages) { + const text = normalizedUserText(message) + if (text) { + landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) + } + } + // Why: compare against the count captured before send; historical equal + // turns cannot clear a new echo, while duplicates land one occurrence each. + const next = current.filter( + (item) => (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence + ) + if (next.length === current.length) { + return previous + } + if (next.length > 0) { + return { ...previous, [pendingKey]: next } + } + const remaining = { ...previous } + delete remaining[pendingKey] + return remaining + }) + }, [messages, pending, pendingKey]) + + return { + composerText: draftKey ? (drafts[draftKey] ?? '') : '', + setComposerText, + pending, + captureSendOrigin, + acceptSend + } +} diff --git a/mobile/src/session/use-mobile-native-chat-file-search.test.ts b/mobile/src/session/use-mobile-native-chat-file-search.test.ts new file mode 100644 index 00000000000..e0500019844 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-file-search.test.ts @@ -0,0 +1,165 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' + +type SearchState = ReturnType + +function rpcSuccess(files: string[]): Awaited> { + return { + id: 'files', + ok: true, + result: { files: files.map((relativePath) => ({ relativePath })) }, + _meta: { runtimeId: 'runtime-1' } + } +} + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +describe('useMobileNativeChatFileSearch', () => { + let renderer: ReactTestRenderer | null = null + let state: SearchState | null = null + + async function mount(client: RpcClient): Promise { + function Harness(): null { + state = useMobileNativeChatFileSearch({ client, worktreeId: 'wt-1' }) + return null + } + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(Harness)) + }) + } finally { + restore() + } + } + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + state = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + }) + + it('coalesces rapid queries and retains only the bounded host result', async () => { + const sendRequest = vi.fn().mockResolvedValue(rpcSuccess(['src/app.ts', 'src/app.test.ts'])) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => { + state?.loadNativeChatFiles('a') + state?.loadNativeChatFiles('app') + }) + await act(async () => vi.advanceTimersByTimeAsync(119)) + expect(sendRequest).not.toHaveBeenCalled() + + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest).toHaveBeenCalledWith('files.searchPaths', { + worktree: 'id:wt-1', + query: 'app', + limit: 16 + }) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts', 'src/app.test.ts']) + }) + + it('loads the legacy inventory once when an older host lacks searchPaths', async () => { + const sendRequest = vi.fn(async (method: string) => { + if (method === 'files.searchPaths') { + return { + id: 'missing', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + } + return rpcSuccess(['src/apple.ts', 'docs/readme.md']) + }) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/apple.ts']) + + act(() => state?.loadNativeChatFiles('readme')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md']) + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'files.searchPaths', + 'files.list' + ]) + }) + + it('cancels an in-flight query on a cache hit so a stale result cannot clobber it', async () => { + const sendRequest = vi.fn(async (_method: string, params: { query: string }) => + rpcSuccess(params.query === 'app' ? ['src/app.ts'] : ['src/beta.ts']) + ) + await mount({ sendRequest } as unknown as RpcClient) + + // Populate the cache for 'app'. + act(() => state?.loadNativeChatFiles('app')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + + // Schedule 'beta' (debounced, unresolved), then hit the cache for 'app'. + act(() => { + state?.loadNativeChatFiles('beta') + state?.loadNativeChatFiles('app') + }) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + + // The cancelled 'beta' request must never fire and overwrite the cached result. + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + expect( + sendRequest.mock.calls.filter(([, params]) => (params as { query: string }).query === 'beta') + ).toHaveLength(0) + }) + + it('coalesces overlapping legacy inventory requests on a slow host', async () => { + let resolveList: (value: Awaited>) => void = () => {} + const listResponse = new Promise>>((resolve) => { + resolveList = resolve + }) + const sendRequest = vi.fn((method: string) => { + if (method === 'files.searchPaths') { + return Promise.resolve({ + id: 'missing', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + return listResponse + }) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + act(() => state?.loadNativeChatFiles('readme')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(sendRequest.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(1) + + await act(async () => { + resolveList(rpcSuccess(['src/apple.ts', 'docs/readme.md'])) + await Promise.resolve() + await Promise.resolve() + }) + expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md']) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-file-search.ts b/mobile/src/session/use-mobile-native-chat-file-search.ts new file mode 100644 index 00000000000..53df98e3ee0 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-file-search.ts @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { rankSuggestions } from './mobile-native-chat-autocomplete' + +const FILE_SEARCH_DEBOUNCE_MS = 120 +const FILE_SEARCH_RESULT_LIMIT = 16 +const FILE_SEARCH_QUERY_CACHE_LIMIT = 20 + +function extractPaths(result: unknown): string[] { + const files = (result as { files?: Array<{ relativePath?: string }> }).files ?? [] + return files + .map((file) => file.relativePath ?? '') + .filter((path): path is string => path.length > 0) +} + +/** Debounces current-host path searches, bounds the mobile result/cache, and + * falls back to the legacy one-time full list when paired to an older host. */ +export function useMobileNativeChatFileSearch(args: { + client: RpcClient | null + worktreeId: string +}): { nativeChatFilePaths: string[]; loadNativeChatFiles: (query: string) => void } { + const { client, worktreeId } = args + const [nativeChatFilePaths, setNativeChatFilePaths] = useState([]) + const timerRef = useRef | null>(null) + const sequenceRef = useRef(0) + const generationRef = useRef(0) + const queryCacheRef = useRef(new Map()) + const legacyPathsRef = useRef(null) + const legacyLoadRef = useRef | null>(null) + const searchSupportedRef = useRef(null) + + useEffect(() => { + sequenceRef.current++ + generationRef.current++ + queryCacheRef.current.clear() + legacyPathsRef.current = null + legacyLoadRef.current = null + searchSupportedRef.current = null + setNativeChatFilePaths([]) + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + }, [client, worktreeId]) + + const loadNativeChatFiles = useCallback( + (query: string) => { + if (!client) { + return + } + const normalizedQuery = query.trim().toLowerCase().slice(0, 256) + const cached = queryCacheRef.current.get(normalizedQuery) + if (cached) { + // Why: cancel and stale-out any in-flight debounced query so an older + // request cannot later clobber this displayed cached result. + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + sequenceRef.current++ + setNativeChatFilePaths(cached) + return + } + if (timerRef.current) { + clearTimeout(timerRef.current) + } + const sequence = ++sequenceRef.current + const generation = generationRef.current + setNativeChatFilePaths([]) + timerRef.current = setTimeout(() => { + timerRef.current = null + const applyPaths = (paths: string[]): void => { + if (sequenceRef.current !== sequence) { + return + } + queryCacheRef.current.set(normalizedQuery, paths) + while (queryCacheRef.current.size > FILE_SEARCH_QUERY_CACHE_LIMIT) { + const oldest = queryCacheRef.current.keys().next().value as string | undefined + if (!oldest) { + break + } + queryCacheRef.current.delete(oldest) + } + setNativeChatFilePaths(paths) + } + const loadLegacyPaths = async (): Promise => { + if (!legacyPathsRef.current) { + if (!legacyLoadRef.current) { + const request = client + .sendRequest('files.list', { worktree: `id:${worktreeId}` }) + .then((response) => { + if (!response.ok || generationRef.current !== generation) { + return null + } + const paths = extractPaths(response.result) + legacyPathsRef.current = paths + return paths + }) + .finally(() => { + if (legacyLoadRef.current === request && !legacyPathsRef.current) { + legacyLoadRef.current = null + } + }) + // Why: older hosts expose only the full inventory RPC; queries that + // overlap its slow local/SSH read must share one request. + legacyLoadRef.current = request + } + const paths = await legacyLoadRef.current + if (!paths) { + return + } + } + const legacyPaths = legacyPathsRef.current + if (legacyPaths) { + applyPaths(rankSuggestions(legacyPaths, normalizedQuery, FILE_SEARCH_RESULT_LIMIT)) + } + } + void (async () => { + if (searchSupportedRef.current === false) { + await loadLegacyPaths() + return + } + const response = await client.sendRequest('files.searchPaths', { + worktree: `id:${worktreeId}`, + query: normalizedQuery, + limit: FILE_SEARCH_RESULT_LIMIT + }) + if (response.ok) { + searchSupportedRef.current = true + applyPaths(extractPaths(response.result)) + return + } + if (response.error.code === 'method_not_found') { + searchSupportedRef.current = false + await loadLegacyPaths() + } + })().catch(() => {}) + }, FILE_SEARCH_DEBOUNCE_MS) + }, + [client, worktreeId] + ) + + return { nativeChatFilePaths, loadNativeChatFiles } +} diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts new file mode 100644 index 00000000000..bb90327c5b9 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts @@ -0,0 +1,57 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease' + +type Lease = ReturnType + +describe('useMobileNativeChatInputLease', () => { + let renderer: ReactTestRenderer | null = null + let lease: Lease | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + lease = null + }) + + function Harness({ connected }: { connected: boolean }): null { + lease = useMobileNativeChatInputLease({ activeHandle: 'terminal', connected }) + return null + } + + it('unlocks only after acknowledgement and clears on disconnect', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { connected: true })) + }) + expect(lease?.ready).toBe(false) + expect(lease?.lockReason).toBe('waiting') + act(() => lease?.markReady('terminal')) + expect(lease?.ready).toBe(true) + expect(lease?.lockReason).toBeNull() + + act(() => lease?.clear()) + expect(lease?.ready).toBe(false) + act(() => lease?.markReady('terminal')) + expect(lease?.ready).toBe(true) + + await act(async () => renderer?.update(createElement(Harness, { connected: false }))) + expect(lease?.ready).toBe(false) + expect(lease?.lockReason).toBe('disconnected') + } finally { + consoleSpy.mockRestore() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts new file mode 100644 index 00000000000..02dfeff84a8 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { MobileNativeChatInputLockReason } from './MobileNativeChatView' + +export function useMobileNativeChatInputLease(args: { + activeHandle: string | null + connected: boolean +}): { + ready: boolean + readyRef: { readonly current: boolean } + lockReason: MobileNativeChatInputLockReason | null + markReady: (handle: string) => void + clear: (handle?: string) => void +} { + const [readyHandles, setReadyHandles] = useState>(new Set()) + const ready = args.activeHandle != null && readyHandles.has(args.activeHandle) + // Why: absence of an acknowledgement proves only that setup is still pending; + // the protocol does not report evidence that another client owns the floor. + const lockReason: MobileNativeChatInputLockReason | null = !args.connected + ? 'disconnected' + : ready + ? null + : 'waiting' + const readyRef = useRef(ready) + readyRef.current = ready + useEffect(() => { + if (!args.connected) { + setReadyHandles(new Set()) + } + }, [args.connected]) + const markReady = useCallback((handle: string) => { + setReadyHandles((current) => new Set(current).add(handle)) + }, []) + const clear = useCallback((handle?: string) => { + setReadyHandles((current) => { + if (handle === undefined) { + return new Set() + } + if (!current.has(handle)) { + return current + } + const next = new Set(current) + next.delete(handle) + return next + }) + }, []) + return { + ready, + readyRef, + lockReason, + markReady, + clear + } +} diff --git a/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts new file mode 100644 index 00000000000..3b3cdb2e8a7 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts @@ -0,0 +1,39 @@ +import { useMemo, useRef, useState } from 'react' +import { Gesture } from 'react-native-gesture-handler' +import type { ComposedGesture } from 'react-native-gesture-handler' +import { clampFontScale } from './mobile-native-chat-message-text' + +/** Pinch-to-zoom chat font. `fontScale` is the committed size; `pinchBase` + * anchors the live gesture so successive pinches compound rather than reset. */ +export function useMobileNativeChatPinchGesture(): { + fontScale: number + pinchGesture: ComposedGesture +} { + const [fontScale, setFontScale] = useState(1) + const fontScaleRef = useRef(1) + fontScaleRef.current = fontScale + const pinchBase = useRef(1) + // Why: run the gesture callbacks on the JS thread (not a reanimated worklet) so + // they can touch React refs/state and clampFontScale directly — accessing those + // from the UI-thread worklet crashes the app. + // Compose the pinch with the list's native scroll as Simultaneous so a + // two-finger pinch is recognized even while the scroll view is active — + // otherwise the scroll grabs the gesture first and the pinch never fires. + const pinchGesture = useMemo( + () => + Gesture.Simultaneous( + Gesture.Native(), + Gesture.Pinch() + .runOnJS(true) + .onStart(() => { + pinchBase.current = fontScaleRef.current + }) + .onUpdate((e) => { + setFontScale(clampFontScale(pinchBase.current * e.scale)) + }) + ), + [] + ) + + return { fontScale, pinchGesture } +} diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts new file mode 100644 index 00000000000..11a17696bfc --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask' +import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' +import { parseAgentQuestion } from './mobile-native-chat-question' + +export type MobileNativeChatPrompts = { + permission: ReturnType + question: ReturnType + ask: ReturnType +} + +/** Derives the prompt cards shown above the composer. */ +export function useMobileNativeChatPrompts(args: { + enabled: boolean + status: AgentStatusEntry | null | undefined + messages: readonly NativeChatMessage[] +}): MobileNativeChatPrompts { + const { enabled, status, messages } = args + const blocked = status?.state === 'waiting' || status?.state === 'blocked' + const permission = + (blocked && status + ? detectAgentPermission({ + state: status.state, + lastAssistantMessage: status.lastAssistantMessage, + toolName: status.toolName, + toolInput: status.toolInput + }) + : null) ?? parseApprovalFromStatus(status?.interactivePrompt) + const question = + blocked && status && !permission ? parseAgentQuestion(status.lastAssistantMessage ?? '') : null + const askFromStatus = useMemo( + () => parseAskFromStatus(status?.interactivePrompt, status?.toolName), + [status?.interactivePrompt, status?.toolName] + ) + const askFromMessages = useMemo( + () => (askFromStatus ? null : extractPendingAsk(messages)), + [askFromStatus, messages] + ) + + return { + permission, + question, + ask: enabled ? (askFromStatus ?? askFromMessages) : null + } +} diff --git a/mobile/src/session/use-mobile-native-chat-readability.test.ts b/mobile/src/session/use-mobile-native-chat-readability.test.ts new file mode 100644 index 00000000000..46feaf79217 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-readability.test.ts @@ -0,0 +1,107 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability' + +describe('useMobileNativeChatReadability', () => { + let renderer: ReactTestRenderer | null = null + let readable = false + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + readable = false + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function mount(connectionId: string | null): Promise { + const client = { + sendRequest: vi.fn().mockResolvedValue({ + ok: true, + result: { repos: [{ id: 'repo', connectionId }] } + }) + } as unknown as RpcClient + function Harness(): null { + readable = useMobileNativeChatReadability(client, 'repo::/worktree') + return null + } + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('admits local and runtime-owned transcript hosts', async () => { + await mount(null) + expect(readable).toBe(true) + act(() => renderer?.unmount()) + renderer = null + + await mount('runtime-ssh-environment') + expect(readable).toBe(true) + }) + + it('fails closed for Model-A SSH transcript hosts', async () => { + await mount('model-a-ssh') + expect(readable).toBe(false) + }) + + it('fails closed immediately while a reused route resolves its new worktree', async () => { + let resolveNext: (response: unknown) => void = () => {} + const client = { + sendRequest: vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { repos: [{ id: 'local-repo', connectionId: null }] } + }) + .mockImplementationOnce(() => new Promise((resolve) => (resolveNext = resolve))) + } as unknown as RpcClient + function Harness({ worktreeId }: { worktreeId: string }): null { + readable = useMobileNativeChatReadability(client, worktreeId) + return null + } + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { worktreeId: 'local-repo::/one' })) + await Promise.resolve() + }) + expect(readable).toBe(true) + + act(() => renderer?.update(createElement(Harness, { worktreeId: 'ssh-repo::/two' }))) + expect(readable).toBe(false) + await act(async () => { + resolveNext({ + ok: true, + result: { repos: [{ id: 'ssh-repo', connectionId: 'model-a-ssh' }] } + }) + await Promise.resolve() + }) + expect(readable).toBe(false) + } finally { + consoleSpy.mockRestore() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-readability.ts b/mobile/src/session/use-mobile-native-chat-readability.ts new file mode 100644 index 00000000000..e952335fe23 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-readability.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility' +import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers' + +type RepoSummary = { id: string; connectionId?: string | null } +type ReadabilityState = { client: RpcClient | null; worktreeId: string; readable: boolean } + +export function useMobileNativeChatReadability( + client: RpcClient | null, + worktreeId: string +): boolean { + const [state, setState] = useState({ + client: null, + worktreeId: '', + readable: false + }) + useEffect(() => { + let active = true + if (!client) { + setState({ client, worktreeId, readable: false }) + return + } + void client + .sendRequest('repo.list') + .then((response) => { + if (!active) { + return + } + const repos = response.ok + ? ((response.result as { repos?: RepoSummary[] }).repos ?? []) + : [] + const repoId = getRepoIdFromMobileWorktreeId(worktreeId) + const repo = repos.find((candidate) => candidate.id === repoId) + setState({ + client, + worktreeId, + readable: repo ? isMobileNativeChatTranscriptReadable(repo.connectionId ?? null) : false + }) + }) + .catch(() => { + if (active) { + setState({ client, worktreeId, readable: false }) + } + }) + return () => { + active = false + } + }, [client, worktreeId]) + // Why: route reuse renders before its new effect resolves; never expose the + // previous repo's readability under a different client/worktree key. + return state.client === client && state.worktreeId === worktreeId ? state.readable : false +} diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts new file mode 100644 index 00000000000..584f34624f2 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -0,0 +1,220 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { + useMobileNativeChatSession, + type MobileNativeChatSession +} from './use-mobile-native-chat-session' + +function message(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 1, + source: 'transcript' + } +} + +describe('useMobileNativeChatSession', () => { + let renderer: ReactTestRenderer | null = null + let state: MobileNativeChatSession | null = null + let emit: (frame: unknown) => void = () => {} + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + state = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Harness({ client }: { client: RpcClient | null }): null { + state = useMobileNativeChatSession({ + client, + agent: 'claude', + sessionId: 'session', + transcriptPath: null + }) + return null + } + + async function mount(client: RpcClient): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { client })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('drops an older-page response captured before transcript replacement', async () => { + let resolveEarlier: (response: unknown) => void = () => {} + const sendRequest = vi.fn( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) as unknown as RpcClient['sendRequest'] + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + + await act(async () => { + emit({ + type: 'replacement', + messages: [message('replacement')], + hasMore: false, + beforeOffset: 0 + }) + }) + await act(async () => { + resolveEarlier({ + ok: true, + result: { messages: [message('stale-page')], hasMore: false, beforeOffset: 0 } + }) + await Promise.resolve() + }) + + expect(state?.messages.map((entry) => entry.id)).toEqual(['replacement']) + expect(state?.loadingEarlier).toBe(false) + }) + + it('drops an older-page response after the client source disappears', async () => { + let resolveEarlier: (response: unknown) => void = () => {} + const sendRequest = vi.fn( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) as unknown as RpcClient['sendRequest'] + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + await act(async () => renderer?.update(createElement(Harness, { client: null }))) + await act(async () => { + resolveEarlier({ ok: true, result: { messages: [message('stale-page')] } }) + await Promise.resolve() + }) + + expect(state?.messages).toEqual([]) + expect(state?.status).toBe('idle') + expect(state?.loadingEarlier).toBe(false) + }) + + it.each(['replacement', 'snapshot'] as const)( + 'can page again after an authoritative %s resets a maxed-out read window', + async (frameType) => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { messages: [message('older')], hasMore: true, beforeOffset: 50 } + }) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + for (let page = 0; page < 33; page += 1) { + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + } + const requestsAtCap = sendRequest.mock.calls.length + + await act(async () => + emit({ + type: frameType, + messages: [message('authoritative')], + hasMore: true, + beforeOffset: 500 + }) + ) + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenCalledTimes(requestsAtCap + 1) + expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', { + agent: 'claude', + sessionId: 'session', + limit: 60, + beforeOffset: 500 + }) + } + ) + + it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => { + let resolveCursorPage: (response: unknown) => void = () => {} + const sendRequest = vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => (resolveCursorPage = resolve))) + .mockResolvedValueOnce({ + ok: true, + result: { messages: [message('fresh-growing-tail')], hasMore: false } + }) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + await act(async () => emit({ type: 'appended', messages: [message('live-trim')] })) + await act(async () => { + resolveCursorPage({ + ok: true, + result: { messages: [message('stale-cursor-page')], hasMore: true, beforeOffset: 50 } + }) + await Promise.resolve() + }) + expect(state?.messages.map((entry) => entry.id)).not.toContain('stale-cursor-page') + + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', { + agent: 'claude', + sessionId: 'session', + limit: 100 + }) + expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail']) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts new file mode 100644 index 00000000000..2d381fdd602 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' +import type { RpcClient } from '../transport/rpc-client' +import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' +import { + applyMobileNativeChatStreamFrame, + type MobileNativeChatStreamFrame +} from './mobile-native-chat-stream-frame' + +export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'ready' | 'error' + +export type MobileNativeChatSession = { + messages: NativeChatMessage[] + status: MobileNativeChatStatus + error?: string + /** True when an older page may exist (the last read filled the window). */ + hasMore: boolean + /** Whether an older-history page is currently loading. */ + loadingEarlier: boolean + /** Grow the window to page in older history. */ + loadEarlier: () => void +} + +// Small first page for a fast first paint; grows by a page as the user scrolls. +const INITIAL_LIMIT = 40 +const PAGE = 60 +const MAX_MESSAGES = 2000 + +type ReadSessionResult = + | { messages: NativeChatMessage[]; hasMore?: boolean; beforeOffset?: number } + | { error: string } +/** Subscribe to an agent's native-chat transcript over the paired connection. + * Reads a small recent window for a fast first paint, tails it for live turns, + * and pages in older history on demand. Read results replace the list (they are + * an ordered tail); live appends merge by id so order stays stable. */ +export function useMobileNativeChatSession(args: { + client: RpcClient | null + agent: string | null + sessionId: string | null + transcriptPath: string | null +}): MobileNativeChatSession { + const { client, agent, sessionId, transcriptPath } = args + const [messages, setMessages] = useState([]) + const [status, setStatus] = useState('idle') + const [error, setError] = useState(undefined) + const [hasMore, setHasMore] = useState(false) + const [loadingEarlier, setLoadingEarlier] = useState(false) + const loadingEarlierRef = useRef(false) + const beforeOffsetRef = useRef(null) + // Stateful id-dedup merger: caches the id→index map so each live append frame + // costs O(incoming), not O(existing+incoming) (#18). `replaceList` resets the + // base (read / loadEarlier ordered tails); `applyAppend` folds live frames in. + const mergerRef = useRef(createNativeChatMerger()) + const limitRef = useRef(INITIAL_LIMIT) + // Tracks the live session so a late loadEarlier resolve can detect a swap. + const sessionIdRef = useRef(sessionId) + sessionIdRef.current = sessionId + const streamGenerationRef = useRef(0) + + // Replace the base list (read results are an ordered tail). Resets the merger + // cache so the index is rebuilt once over the new base. + const setList = useCallback((next: readonly NativeChatMessage[]) => { + replaceList(mergerRef.current, next) + setMessages(mergerRef.current.list) + }, []) + + useEffect(() => { + let cancelled = false + // Why: disconnect/agent/session loss must invalidate a page request before + // the early idle/waiting return can clear the visible source. + streamGenerationRef.current += 1 + limitRef.current = INITIAL_LIMIT + loadingEarlierRef.current = false + setLoadingEarlier(false) + setList([]) + setError(undefined) + setHasMore(false) + beforeOffsetRef.current = null + if (!client || !agent) { + setStatus('idle') + return + } + if (!sessionId) { + setStatus('waiting-session') + return + } + + setStatus('loading') + + const unsubscribe = client.subscribe( + 'nativeChat.subscribe', + { + agent, + sessionId, + limit: limitRef.current, + subscriptionId: buildNativeChatSubscriptionId(agent, sessionId), + ...(transcriptPath ? { transcriptPath } : {}) + }, + (raw) => { + if (cancelled) { + return + } + const frame = raw as MobileNativeChatStreamFrame + if (frame.type === 'replacement' || frame.type === 'snapshot') { + // Why: replacement and reconnect snapshots are authoritative windows; + // stale page limits/results must not constrain the fresh generation. + streamGenerationRef.current += 1 + limitRef.current = INITIAL_LIMIT + loadingEarlierRef.current = false + setLoadingEarlier(false) + } + const replaceSnapshot = frame.type === 'snapshot' + const applied = applyMobileNativeChatStreamFrame({ + merger: mergerRef.current, + frame, + limit: limitRef.current, + replaceSnapshot + }) + if (applied.kind === 'ignored') { + return + } + if (applied.kind === 'error') { + setStatus('error') + setError(applied.error) + return + } + setMessages(applied.messages) + if (applied.hasMore != null) { + setHasMore(applied.hasMore) + } + if (applied.beforeOffset != null) { + beforeOffsetRef.current = applied.beforeOffset + } + if (applied.cursorInvalidated) { + // Fall back to a growing-tail read so history trimmed by live appends + // cannot leave a gap between the retained window and the old cursor. + streamGenerationRef.current += 1 + loadingEarlierRef.current = false + setLoadingEarlier(false) + beforeOffsetRef.current = null + } + setStatus('ready') + } + ) + + return () => { + cancelled = true + unsubscribe() + } + }, [client, agent, sessionId, transcriptPath, setList]) + + const loadEarlier = useCallback(() => { + if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) { + return + } + // Capture the session this page belongs to; a swap underneath us must not + // apply this read's result onto the new session (mirrors desktop's guard). + const requestSessionId = sessionId + const requestGeneration = streamGenerationRef.current + const nextLimit = Math.min(limitRef.current + PAGE, MAX_MESSAGES) + const pageLimit = nextLimit - limitRef.current + if (pageLimit <= 0) { + setHasMore(false) + return + } + const beforeOffset = beforeOffsetRef.current + loadingEarlierRef.current = true + setLoadingEarlier(true) + void (async () => { + try { + const response = await client.sendRequest('nativeChat.readSession', { + agent, + sessionId, + limit: beforeOffset === null ? nextLimit : pageLimit, + ...(beforeOffset === null ? {} : { beforeOffset }), + ...(transcriptPath ? { transcriptPath } : {}) + }) + if (!response.ok) { + return + } + const result = response.result as ReadSessionResult + if ('error' in result) { + return + } + // Drop a stale resolve from a session that swapped underneath us. + if ( + sessionIdRef.current !== requestSessionId || + streamGenerationRef.current !== requestGeneration + ) { + return + } + limitRef.current = nextLimit + if (beforeOffset !== null && result.beforeOffset != null) { + beforeOffsetRef.current = result.beforeOffset + setList([...result.messages, ...mergerRef.current.list]) + setHasMore( + nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit) + ) + } else { + // Older runtimes ignore the cursor and return the growing tail. + setList(result.messages) + setHasMore(result.messages.length >= nextLimit) + } + } finally { + // A late page from a prior tab must not unlock the current tab's request. + if ( + sessionIdRef.current === requestSessionId && + streamGenerationRef.current === requestGeneration + ) { + loadingEarlierRef.current = false + setLoadingEarlier(false) + } + } + })() + }, [client, agent, sessionId, transcriptPath, hasMore, setList]) + + return { messages, status, error, hasMore, loadingEarlier, loadEarlier } +} diff --git a/mobile/src/session/use-mobile-native-chat-stop.test.ts b/mobile/src/session/use-mobile-native-chat-stop.test.ts new file mode 100644 index 00000000000..27385f8d33e --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-stop.test.ts @@ -0,0 +1,85 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' + +describe('useMobileNativeChatStop', () => { + let renderer: ReactTestRenderer | null = null + let stop: (() => void) | null = null + const sendRequest = vi.fn() + const onSendError = vi.fn() + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + sendRequest.mockReset().mockResolvedValue({ ok: true }) + onSendError.mockReset() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + stop = null + vi.useRealTimers() + }) + + function Harness({ + enabled, + streamIdentity + }: { + enabled: boolean + streamIdentity: string + }): null { + stop = useMobileNativeChatStop({ + client: { sendRequest } as unknown as RpcClient, + enabled, + handleRef: { current: 'terminal-1' }, + deviceTokenRef: { current: 'mobile-1' }, + streamIdentity, + cancelPending: vi.fn(), + onSendError + }) + return null + } + + async function render(enabled: boolean, streamIdentity: string): Promise { + await act(async () => { + const element = createElement(Harness, { enabled, streamIdentity }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + } + + it.each([ + ['the acknowledged input lease is lost', false, 'stream-1'], + ['the active stream changes', true, 'stream-2'] + ])('cancels the delayed second Escape when %s', async (_case, enabled, streamIdentity) => { + await render(true, 'stream-1') + + act(() => stop?.()) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await render(enabled as boolean, streamIdentity as string) + await act(async () => vi.runAllTimersAsync()) + + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('handles a rejected Escape without leaking an unhandled rejection', async () => { + sendRequest.mockRejectedValue(new Error('disconnected')) + await render(true, 'stream-1') + + act(() => stop?.()) + await act(async () => { + await Promise.resolve() + await vi.runAllTimersAsync() + }) + + expect(onSendError).toHaveBeenCalledOnce() + expect(onSendError).toHaveBeenCalledWith('Stop not sent') + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-stop.ts b/mobile/src/session/use-mobile-native-chat-stop.ts new file mode 100644 index 00000000000..c6d36ea3436 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-stop.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' + +export function useMobileNativeChatStop(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + streamIdentity: string + cancelPending: () => void + onSendError: (message: string) => void +}): () => void { + const { client, enabled, handleRef, deviceTokenRef, streamIdentity, cancelPending, onSendError } = + args + const timerRef = useRef | null>(null) + const activeRouteRef = useRef({ client, enabled, streamIdentity }) + activeRouteRef.current = { client, enabled, streamIdentity } + useEffect(() => { + if (!enabled && timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + }, [client, enabled, streamIdentity]) + return useCallback(() => { + const handle = handleRef.current + if (!client || !handle || !enabled) { + onSendError('Stop not sent (terminal not ready)') + return + } + cancelPending() + if (timerRef.current) { + clearTimeout(timerRef.current) + } + const stopStreamIdentity = streamIdentity + let failureReported = false + const sendEscape = (): void => { + const activeRoute = activeRouteRef.current + if ( + !activeRoute.enabled || + activeRoute.client !== client || + activeRoute.streamIdentity !== stopStreamIdentity || + handleRef.current !== handle + ) { + return + } + void client + .sendRequest('terminal.send', { + terminal: handle, + text: String.fromCharCode(27), + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + .catch(() => { + // Why: disconnect can race either fire-and-forget Escape; surface one + // failure instead of leaking an unhandled RPC rejection. + if (!failureReported) { + failureReported = true + onSendError('Stop not sent') + } + }) + } + sendEscape() + // Why: two paced Escape bytes reliably stop TUIs without remote coalescing. + timerRef.current = setTimeout(() => { + timerRef.current = null + sendEscape() + }, 80) + }, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError, streamIdentity]) +} diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts new file mode 100644 index 00000000000..92320b9ff86 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts @@ -0,0 +1,115 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatTerminalStream } from './use-mobile-native-chat-terminal-stream' + +describe('useMobileNativeChatTerminalStream', () => { + let renderer: ReactTestRenderer | null = null + let harnessRenderCount = 0 + const subscriptionsRef = { current: new Map void>() } + const subscribingRef = { current: new Set() } + const webReadyRef = { current: new Set(['terminal-1']) } + const initializedRef = { current: new Set(['terminal-1']) } + const subscribe = vi.fn((handle: string) => subscriptionsRef.current.set(handle, () => {})) + const unsubscribe = vi.fn((handle: string) => subscriptionsRef.current.delete(handle)) + const notifyWebReadyRef = { current: (_handle: string, _wasAlreadyReady: boolean): void => {} } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + subscriptionsRef.current = new Map([['terminal-1', () => {}]]) + subscribingRef.current = new Set() + webReadyRef.current = new Set(['terminal-1']) + initializedRef.current = new Set(['terminal-1']) + harnessRenderCount = 0 + subscribe.mockClear() + unsubscribe.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Harness({ showNativeChat }: { showNativeChat: boolean }): null { + harnessRenderCount += 1 + notifyWebReadyRef.current = useMobileNativeChatTerminalStream({ + showNativeChat, + activeHandle: 'terminal-1', + activeTabType: 'terminal', + subscriptionsRef, + subscribingRef, + webReadyRef, + initializedRef, + subscribe, + unsubscribe + }) + return null + } + + it('replaces output with a lease-only stream while covered, then restores output', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: false })) + }) + await act(async () => { + notifyWebReadyRef.current('terminal-1', false) + }) + expect(harnessRenderCount).toBe(1) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: true })) + }) + + expect(unsubscribe).toHaveBeenNthCalledWith(1, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(1, 'terminal-1') + expect(initializedRef.current.has('terminal-1')).toBe(false) + + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: false })) + }) + + expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + } finally { + consoleSpy.mockRestore() + } + }) + + it('resumes a cold-start lease-only stream when WebView readiness arrives late', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + webReadyRef.current.clear() + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: false })) + }) + + expect(unsubscribe).toHaveBeenCalledOnce() + expect(subscribe).toHaveBeenCalledOnce() + + webReadyRef.current.add('terminal-1') + await act(async () => { + notifyWebReadyRef.current('terminal-1', false) + }) + + expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + } finally { + consoleSpy.mockRestore() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts new file mode 100644 index 00000000000..c45980ae6e3 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' +import { resolveMobileNativeChatTerminalStreamAction } from './mobile-native-chat-terminal-stream' + +/** Pauses the active terminal stream while native chat covers its mounted WebView, + * then resumes from a fresh scrollback snapshot when terminal view returns. */ +export function useMobileNativeChatTerminalStream(args: { + showNativeChat: boolean + activeHandle: string | null + activeTabType: string | null + subscriptionsRef: MutableRefObject void>> + subscribingRef: MutableRefObject> + webReadyRef: MutableRefObject> + initializedRef: MutableRefObject> + subscribe: (handle: string) => void + unsubscribe: (handle: string) => void +}): (handle: string, wasAlreadyReady: boolean) => void { + const coveredHandleRef = useRef(null) + const [webReadyRevision, setWebReadyRevision] = useState(0) + const notifyWebReady = useCallback((handle: string, wasAlreadyReady: boolean) => { + // Why: ordinary WebView startups must not rerender the large session route; + // only readiness that can release a native-chat lease needs reconciliation. + if (!wasAlreadyReady && coveredHandleRef.current === handle) { + setWebReadyRevision((revision) => revision + 1) + } + }, []) + useEffect(() => { + const handle = args.activeHandle + if (coveredHandleRef.current && coveredHandleRef.current !== handle) { + coveredHandleRef.current = null + } + const streamActive = + handle != null && + (args.subscriptionsRef.current.has(handle) || args.subscribingRef.current.has(handle)) + const action = resolveMobileNativeChatTerminalStreamAction({ + showNativeChat: args.showNativeChat, + activeHandle: handle, + activeTabType: args.activeTabType, + streamActive, + streamCovered: coveredHandleRef.current === handle, + webViewReady: handle != null && args.webReadyRef.current.has(handle) + }) + if (!handle || action === 'none') { + return + } + if (action === 'pause') { + coveredHandleRef.current = handle + // Why: returning to terminal must accept the fresh scrollback snapshot; + // the stream was paused while chat covered output that xterm never saw. + args.initializedRef.current.delete(handle) + // Why: covered chat needs the input-floor lease without paying to stream + // duplicate PTY output. Replace any view stream with a lease-only stream. + if (streamActive) { + args.unsubscribe(handle) + } + args.subscribe(handle) + return + } + if (coveredHandleRef.current === handle) { + args.unsubscribe(handle) + coveredHandleRef.current = null + } + args.subscribe(handle) + }, [ + args.activeHandle, + args.activeTabType, + args.initializedRef, + args.showNativeChat, + args.subscribe, + args.subscribingRef, + args.subscriptionsRef, + args.unsubscribe, + args.webReadyRef, + webReadyRevision + ]) + return notifyWebReady +} diff --git a/mobile/src/session/use-throttled-latest-value.test.ts b/mobile/src/session/use-throttled-latest-value.test.ts new file mode 100644 index 00000000000..bdea29c7459 --- /dev/null +++ b/mobile/src/session/use-throttled-latest-value.test.ts @@ -0,0 +1,69 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { useThrottledLatestValue } from './use-throttled-latest-value' + +describe('useThrottledLatestValue', () => { + let renderer: ReactTestRenderer | null = null + let latest: string | undefined + let consoleSpy: MockInstance + + function Harness({ value }: { value: string | undefined }): null { + latest = useThrottledLatestValue(value, 50) + return null + } + + function render(value: string | undefined): void { + act(() => { + renderer = create(createElement(Harness, { value })) + }) + } + + function update(value: string | undefined): void { + act(() => renderer?.update(createElement(Harness, { value }))) + } + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + latest = undefined + const original = console.error + consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + consoleSpy.mockRestore() + }) + + it('emits the first frame immediately', () => { + render('a') + expect(latest).toBe('a') + }) + + it('holds rapid updates but eventually emits the latest value', () => { + render('a') + update('ab') + update('abc') + expect(latest).toBe('a') + act(() => vi.advanceTimersByTime(50)) + expect(latest).toBe('abc') + }) + + it('clears immediately and drops the trailing emit when the value goes undefined', () => { + render('a') + update('ab') + expect(latest).toBe('a') + update(undefined) + expect(latest).toBeUndefined() + act(() => vi.advanceTimersByTime(50)) + expect(latest).toBeUndefined() + }) +}) diff --git a/mobile/src/session/use-throttled-latest-value.ts b/mobile/src/session/use-throttled-latest-value.ts new file mode 100644 index 00000000000..652b1e79119 --- /dev/null +++ b/mobile/src/session/use-throttled-latest-value.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from 'react' + +/** Rate-limits a rapidly-changing value to at most one emit per `intervalMs` + * while always surfacing the latest value. OpenCode publishes a streaming + * assistant frame per part; unthrottled, each re-parses the whole bubble. */ +export function useThrottledLatestValue(value: T, intervalMs: number): T { + const [throttled, setThrottled] = useState(value) + const valueRef = useRef(value) + valueRef.current = value + const lastEmitRef = useRef(0) + const timerRef = useRef | null>(null) + + useEffect(() => { + if (value == null) { + // Turn ended: drop any trailing emit and reset so the next stream's first + // frame shows at once instead of the stale bubble lingering. + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + lastEmitRef.current = 0 + setThrottled(value) + return + } + const elapsed = Date.now() - lastEmitRef.current + if (elapsed >= intervalMs) { + lastEmitRef.current = Date.now() + setThrottled(value) + return + } + if (timerRef.current) { + return + } + timerRef.current = setTimeout(() => { + timerRef.current = null + lastEmitRef.current = Date.now() + setThrottled(valueRef.current) + }, intervalMs - elapsed) + }, [value, intervalMs]) + + useEffect( + () => () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, + [] + ) + + return throttled +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index f8e07fb4d98..881391db0bd 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -8,6 +8,7 @@ import { HOST_SIDEBAR_MIN_WIDTH, clampHostDockWidth, clampHostSidebarWidth, + loadNativeChatTabIds, loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, loadPushNotificationsEnabled, @@ -17,6 +18,7 @@ import { readDisabledTerminalLiveInputHandlesPreference, saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, + saveNativeChatTabIds, savePushNotificationsEnabled, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode @@ -29,6 +31,31 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ } })) +describe('native chat tab preference', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + it('loads and saves tab ids under a host-and-worktree scoped key', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2'])) + + await expect(loadNativeChatTabIds('host/one', 'folder:C:\\repo')).resolves.toEqual([ + 'tab-1', + 'tab-2' + ]) + expect(AsyncStorage.getItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo' + ) + + await saveNativeChatTabIds('host/one', 'folder:C:\\repo', ['tab-2']) + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo', + JSON.stringify(['tab-2']) + ) + }) +}) + describe('push notification preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5173ac5bc8a..a5af9bf350f 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -2,6 +2,35 @@ import AsyncStorage from '@react-native-async-storage/async-storage' const PINS_PREFIX = 'orca:pins:' const NOTIF_KEY = 'orca:pushNotificationsEnabled' +const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:' + +function nativeChatTabIdsKey(hostId: string, worktreeId: string): string { + return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}` +} + +/** Tab ids currently showing native chat, scoped to the paired host and worktree + * so colliding remote ids cannot activate a transcript watcher on another host. */ +export async function loadNativeChatTabIds(hostId: string, worktreeId: string): Promise { + try { + const raw = await AsyncStorage.getItem(nativeChatTabIdsKey(hostId, worktreeId)) + const parsed = raw ? (JSON.parse(raw) as unknown) : null + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] + } catch { + return [] + } +} + +export async function saveNativeChatTabIds( + hostId: string, + worktreeId: string, + ids: string[] +): Promise { + try { + await AsyncStorage.setItem(nativeChatTabIdsKey(hostId, worktreeId), JSON.stringify(ids)) + } catch { + // Best-effort persistence; a failed write just forgets the view choice. + } +} export type PushNotificationsPreference = { readonly value: boolean | null diff --git a/mobile/src/theme/mobile-theme.ts b/mobile/src/theme/mobile-theme.ts index 76b14415a32..dbf09b745b9 100644 --- a/mobile/src/theme/mobile-theme.ts +++ b/mobile/src/theme/mobile-theme.ts @@ -18,6 +18,9 @@ export const colors = { surfaceBright: '#f5f5f5', accentBlue: '#3b82f6', + // Text/icon color on a filled accent (accentBlue) button, where the muted + // textPrimary would lack contrast against the saturated fill. + onAccent: '#ffffff', statusGreen: '#22c55e', statusAmber: '#f59e0b', diff --git a/mobile/src/transport/host-endpoint.test.ts b/mobile/src/transport/host-endpoint.test.ts new file mode 100644 index 00000000000..ee904278e98 --- /dev/null +++ b/mobile/src/transport/host-endpoint.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest' +import { + displayHostEndpoint, + endpointPort, + endpointScheme, + normalizeHostEndpoint +} from './host-endpoint' + +describe('displayHostEndpoint', () => { + it('shows host:port for websocket URLs', () => { + expect(displayHostEndpoint('ws://192.168.1.10:6768')).toBe('192.168.1.10:6768') + // Why: URL omits default scheme ports (443 for wss); use a non-default port. + expect(displayHostEndpoint('wss://desk.example:8443')).toBe('desk.example:8443') + }) + + it('returns the raw string when not a URL', () => { + expect(displayHostEndpoint('not-a-url')).toBe('not-a-url') + }) + + it('brackets IPv6 hostnames for round-trip safety', () => { + expect(displayHostEndpoint('ws://[fd7a:115c:a1e0::1]:6768')).toBe('[fd7a:115c:a1e0::1]:6768') + }) +}) + +describe('endpointPort', () => { + it('reads the port when present', () => { + expect(endpointPort('ws://192.168.1.10:6768')).toBe('6768') + }) + + it('returns undefined when the port is omitted', () => { + expect(endpointPort('ws://192.168.1.10')).toBeUndefined() + }) + + it('preserves scheme-default ports that URL.port hides', () => { + expect(endpointPort('ws://192.168.1.10:80')).toBe('80') + expect(endpointPort('wss://desk.example:443')).toBe('443') + }) +}) + +describe('endpointScheme', () => { + it('returns wss for a wss:// endpoint', () => { + expect(endpointScheme('wss://desk.example:8443')).toBe('wss') + }) + + it('returns ws for a ws:// endpoint', () => { + expect(endpointScheme('ws://192.168.1.10:6768')).toBe('ws') + }) + + it('falls back to ws for a non-URL endpoint', () => { + expect(endpointScheme('not-a-url')).toBe('ws') + }) +}) + +describe('normalizeHostEndpoint', () => { + it('accepts a full ws URL', () => { + expect(normalizeHostEndpoint('ws://100.64.0.5:6768')).toEqual({ + ok: true, + endpoint: 'ws://100.64.0.5:6768' + }) + }) + + it('accepts wss and preserves scheme', () => { + expect(normalizeHostEndpoint('wss://desk.example:8443')).toEqual({ + ok: true, + endpoint: 'wss://desk.example:8443' + }) + }) + + it('accepts bare host:port', () => { + expect(normalizeHostEndpoint('192.168.1.10:6768')).toEqual({ + ok: true, + endpoint: 'ws://192.168.1.10:6768' + }) + }) + + it('defaults missing port to 6768', () => { + expect(normalizeHostEndpoint('192.168.1.10')).toEqual({ + ok: true, + endpoint: 'ws://192.168.1.10:6768' + }) + }) + + it('uses fallbackPort when the user omits the port', () => { + expect(normalizeHostEndpoint('mac-mini.local', { fallbackPort: '7777' })).toEqual({ + ok: true, + endpoint: 'ws://mac-mini.local:7777' + }) + }) + + it('fills missing port on scheme URLs from fallbackPort', () => { + expect(normalizeHostEndpoint('ws://192.168.1.10', { fallbackPort: '9000' })).toEqual({ + ok: true, + endpoint: 'ws://192.168.1.10:9000' + }) + }) + + it('preserves explicit ws :80 and wss :443 instead of rewriting to fallback', () => { + expect(normalizeHostEndpoint('ws://192.168.1.10:80', { fallbackPort: '6768' })).toEqual({ + ok: true, + endpoint: 'ws://192.168.1.10:80' + }) + expect(normalizeHostEndpoint('wss://desk.example:443', { fallbackPort: '6768' })).toEqual({ + ok: true, + endpoint: 'wss://desk.example:443' + }) + expect(displayHostEndpoint('ws://192.168.1.10:80')).toBe('192.168.1.10:80') + expect(displayHostEndpoint('wss://desk.example:443')).toBe('desk.example:443') + }) + + it('trims whitespace', () => { + expect(normalizeHostEndpoint(' 10.0.0.2:6768 ')).toEqual({ + ok: true, + endpoint: 'ws://10.0.0.2:6768' + }) + }) + + it('rejects empty input', () => { + expect(normalizeHostEndpoint(' ')).toEqual({ + ok: false, + error: 'Enter a host address.' + }) + }) + + it('rejects non-websocket schemes', () => { + expect(normalizeHostEndpoint('http://192.168.1.10:6768')).toEqual({ + ok: false, + error: 'Use ws:// or wss:// (or host:port).' + }) + }) + + it('rejects explicit invalid ports without falling back', () => { + for (const input of [ + '192.168.1.10:0', + '192.168.1.10:99999', + 'ws://192.168.1.10:0', + 'ws://192.168.1.10:99999' + ]) { + expect(normalizeHostEndpoint(input, { fallbackPort: '6768' })).toEqual({ + ok: false, + error: 'Port must be 1–65535.' + }) + } + }) + + it('rejects bare hosts with path, query, or spaces', () => { + expect(normalizeHostEndpoint('desk/path')).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + expect(normalizeHostEndpoint('desk?route')).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + expect(normalizeHostEndpoint('desk name')).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + }) + + it('rejects scheme URLs with path or query', () => { + expect(normalizeHostEndpoint('ws://desk.example/path')).toEqual({ + ok: false, + error: 'Host must not include a path or query.' + }) + expect(normalizeHostEndpoint('ws://desk.example?route=1')).toEqual({ + ok: false, + error: 'Host must not include a path or query.' + }) + }) + + it('accepts bracketed IPv6 with port', () => { + expect(normalizeHostEndpoint('[fd7a:115c:a1e0::1]:6768')).toEqual({ + ok: true, + endpoint: 'ws://[fd7a:115c:a1e0::1]:6768' + }) + }) + + it('rejects malformed IPv6 that a WebSocket URL cannot parse', () => { + expect(normalizeHostEndpoint('[1::2::3]:6768')).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + }) + + it('rejects non-canonical numeric IPv4 forms in bare addresses', () => { + for (const host of ['999.999.999.999', '010.0.0.1', '127.1', '0x7f000001']) { + expect(normalizeHostEndpoint(`${host}:6768`)).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + } + }) + + it('rejects non-canonical numeric IPv4 forms before URL normalization', () => { + for (const host of ['999.999.999.999', '010.0.0.1', '127.1', '0x7f000001']) { + expect(normalizeHostEndpoint(`ws://${host}:6768`)).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + } + }) + + it('rejects encoded and trailing-dot numeric IPv4 aliases', () => { + for (const input of ['ws://127.1.:6768', 'wss://127.1.:6768', 'ws://%31%32%37.1:6768']) { + expect(normalizeHostEndpoint(input)).toEqual({ + ok: false, + error: 'Not a valid hostname.' + }) + } + }) + + it('rejects empty userinfo before URL normalization', () => { + expect(normalizeHostEndpoint('ws://@127.1:6768')).toEqual({ + ok: false, + error: 'Not a valid address.' + }) + }) + + it('reports a path or query before validating a numeric hostname alias', () => { + for (const input of ['ws://127.1/path', 'ws://127.1?route=1']) { + expect(normalizeHostEndpoint(input)).toEqual({ + ok: false, + error: 'Host must not include a path or query.' + }) + } + }) + + it('keeps ordinary DNS names that contain numeric labels', () => { + expect(normalizeHostEndpoint('desk123.local:6768')).toEqual({ + ok: true, + endpoint: 'ws://desk123.local:6768' + }) + expect(normalizeHostEndpoint('ws://123.example:6768')).toEqual({ + ok: true, + endpoint: 'ws://123.example:6768' + }) + }) + + it('preserves wss when re-normalizing bare host:port via fallbackScheme', () => { + expect( + normalizeHostEndpoint('desk.example:8443', { fallbackScheme: 'wss', fallbackPort: '8443' }) + ).toEqual({ + ok: true, + endpoint: 'wss://desk.example:8443' + }) + }) + + it('round-trips displayed IPv6 endpoints without port corruption', () => { + const original = 'ws://[fd7a:115c:a1e0::1]:6768' + const displayed = displayHostEndpoint(original) + expect(displayed).toBe('[fd7a:115c:a1e0::1]:6768') + expect(normalizeHostEndpoint(displayed, { fallbackPort: '6768' })).toEqual({ + ok: true, + endpoint: original + }) + }) +}) diff --git a/mobile/src/transport/host-endpoint.ts b/mobile/src/transport/host-endpoint.ts new file mode 100644 index 00000000000..68d77aa4b1a --- /dev/null +++ b/mobile/src/transport/host-endpoint.ts @@ -0,0 +1,365 @@ +// Why: mobile host profiles store a single websocket endpoint fixed at pair +// time. Edit-host lets the user rewrite host/port without re-pairing; this +// helper accepts phone-friendly input (bare IP, host:port, or full ws URL) +// and normalizes to the ws(s):// form RpcClient expects. + +export type NormalizeHostEndpointResult = + | { ok: true; endpoint: string } + | { ok: false; error: string } + +type WebsocketUrlPortResolution = + | { kind: 'missing' } + | { kind: 'valid'; port: string } + | { kind: 'invalid' } + +type RawSchemeAuthority = { + hostname: string | null + hasUserInfo: boolean + hasPathOrQuery: boolean +} + +const DEFAULT_PORT = '6768' +const NUMERIC_IPV4_CANDIDATE = /^(?:0[xX][0-9a-fA-F]+|\d+)(?:\.(?:0[xX][0-9a-fA-F]+|\d+))*$/ + +export function displayHostEndpoint(endpoint: string): string { + try { + const url = new URL(endpoint) + // Why: some URL parsers leave IPv6 brackets on hostname, others strip them. + // Normalize once so round-trip through normalizeHostEndpoint stays stable. + const host = formatHostForUrl(unwrapHostname(url.hostname)) + const port = resolveWebsocketUrlPort(endpoint, url) + if (port.kind === 'invalid') { + return endpoint + } + return port.kind === 'valid' ? `${host}:${port.port}` : host + } catch { + return endpoint + } +} + +function unwrapHostname(hostname: string): string { + return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname +} + +/** + * Recover an explicitly written port from a ws(s) URL authority. + * Why: `new URL('ws://host:80').port` and `wss://host:443` are empty — the + * URL parser hides scheme-default ports, so callers that need the user's + * literal :80/:443 must re-parse the original string. + */ +function extractExplicitPortFromWebsocketUrl(input: string): string | null { + const withoutScheme = input.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '') + if (withoutScheme.startsWith('[')) { + const close = withoutScheme.indexOf(']') + if (close <= 1) { + return null + } + const rest = withoutScheme.slice(close + 1) + const match = /^:(\d+)(?=[/?#]|$)/.exec(rest) + return match?.[1] ?? null + } + const end = withoutScheme.search(/[/?#]/) + const authority = end === -1 ? withoutScheme : withoutScheme.slice(0, end) + const at = authority.lastIndexOf('@') + const hostPort = at === -1 ? authority : authority.slice(at + 1) + const match = /:(\d+)$/.exec(hostPort) + return match?.[1] ?? null +} + +function resolveWebsocketUrlPort(input: string, url?: URL): WebsocketUrlPortResolution { + const explicit = extractExplicitPortFromWebsocketUrl(input) + // Why: missing and invalid are different states. Treating both as null lets + // an explicit :0/:99999 silently inherit fallbackPort on permissive parsers. + if (explicit !== null && !isValidPort(explicit)) { + return { kind: 'invalid' } + } + if (url?.port) { + return isValidPort(url.port) ? { kind: 'valid', port: url.port } : { kind: 'invalid' } + } + if (explicit !== null) { + return { kind: 'valid', port: explicit } + } + return { kind: 'missing' } +} + +export function endpointPort(endpoint: string): string | undefined { + try { + const url = new URL(endpoint) + const port = resolveWebsocketUrlPort(endpoint, url) + return port.kind === 'valid' ? port.port : undefined + } catch { + return undefined + } +} + +export function endpointScheme(endpoint: string): 'ws' | 'wss' { + try { + const protocol = new URL(endpoint).protocol.replace(':', '') + return protocol === 'wss' ? 'wss' : 'ws' + } catch { + return 'ws' + } +} + +export function normalizeHostEndpoint( + input: string, + options?: { fallbackPort?: string | number; fallbackScheme?: 'ws' | 'wss' } +): NormalizeHostEndpointResult { + const trimmed = input.trim() + if (!trimmed) { + return { ok: false, error: 'Enter a host address.' } + } + + const fallbackPort = resolveFallbackPort(options?.fallbackPort) + const fallbackScheme = options?.fallbackScheme === 'wss' ? 'wss' : 'ws' + + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) { + return normalizeSchemeUrl(trimmed, fallbackPort) + } + + return normalizeHostPort(trimmed, fallbackPort, fallbackScheme) +} + +function resolveFallbackPort(value: string | number | undefined): string { + if (value == null) { + return DEFAULT_PORT + } + const asString = String(value).trim() + if (!asString || !isValidPort(asString)) { + return DEFAULT_PORT + } + return asString +} + +function normalizeSchemeUrl(input: string, fallbackPort: string): NormalizeHostEndpointResult { + const explicitPort = resolveWebsocketUrlPort(input) + if (explicitPort.kind === 'invalid') { + return { ok: false, error: 'Port must be 1–65535.' } + } + const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.exec(input)?.[1]?.toLowerCase() + if (scheme !== 'ws' && scheme !== 'wss') { + return { ok: false, error: 'Use ws:// or wss:// (or host:port).' } + } + + const rawAuthority = parseRawSchemeAuthority(input) + if (rawAuthority.hasUserInfo) { + return { ok: false, error: 'Not a valid address.' } + } + if (rawAuthority.hasPathOrQuery) { + return { ok: false, error: 'Host must not include a path or query.' } + } + if ( + rawAuthority.hostname && + validateNumericIpv4Candidate(normalizeRawNumericIpv4Candidate(rawAuthority.hostname)) + ) { + return { ok: false, error: 'Not a valid hostname.' } + } + + let url: URL + try { + url = new URL(input) + } catch { + return { ok: false, error: 'Not a valid address.' } + } + + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + return { ok: false, error: 'Use ws:// or wss:// (or host:port).' } + } + if (!url.hostname) { + return { ok: false, error: 'Missing hostname.' } + } + + // Why: edit-host persists a bare host:port WebSocket endpoint. Path/query/ + // userinfo are not part of the pairing contract — reject rather than strip + // so typos like desk/path or desk?route cannot be saved silently. + if (url.username || url.password) { + return { ok: false, error: 'Not a valid address.' } + } + if ((url.pathname && url.pathname !== '/') || url.search || url.hash) { + return { ok: false, error: 'Host must not include a path or query.' } + } + + const hostname = unwrapHostname(url.hostname) + // Why: WHATWG URL accepts legacy aliases and rewrites them to a different + // IPv4 address. Only an already-canonical raw dotted quad may become IPv4. + if (rawAuthority.hostname && isCanonicalIpv4(hostname) && rawAuthority.hostname !== hostname) { + return { ok: false, error: 'Not a valid hostname.' } + } + const hostError = validateHostname(hostname) + if (hostError) { + return { ok: false, error: hostError } + } + + // Why: keep explicit :80/:443 (URL.port is empty for scheme defaults) instead + // of rewriting them to fallbackPort (usually 6768). + const resolvedPort = resolveWebsocketUrlPort(input, url) + if (resolvedPort.kind === 'invalid') { + return { ok: false, error: 'Port must be 1–65535.' } + } + const port = resolvedPort.kind === 'valid' ? resolvedPort.port : fallbackPort + + // Why: rebuild so accidental whitespace never reaches the WebSocket constructor. + return { ok: true, endpoint: `${url.protocol}//${formatHostForUrl(hostname)}:${port}` } +} + +function parseRawSchemeAuthority(input: string): RawSchemeAuthority { + const schemeEnd = input.indexOf('://') + const remainder = input.slice(schemeEnd + 3) + const authorityEnd = remainder.search(/[/?#]/) + const authority = authorityEnd === -1 ? remainder : remainder.slice(0, authorityEnd) + const suffix = authorityEnd === -1 ? '' : remainder.slice(authorityEnd) + const hasUserInfo = authority.includes('@') + const hostPort = hasUserInfo ? authority.slice(authority.lastIndexOf('@') + 1) : authority + if (!hostPort) { + return { hostname: null, hasUserInfo, hasPathOrQuery: suffix !== '' && suffix !== '/' } + } + if (hostPort.startsWith('[')) { + const close = hostPort.indexOf(']') + return { + hostname: close > 1 ? hostPort.slice(1, close) : null, + hasUserInfo, + hasPathOrQuery: suffix !== '' && suffix !== '/' + } + } + const lastColon = hostPort.lastIndexOf(':') + return { + hostname: lastColon === -1 ? hostPort : hostPort.slice(0, lastColon), + hasUserInfo, + hasPathOrQuery: suffix !== '' && suffix !== '/' + } +} + +function normalizeHostPort( + input: string, + fallbackPort: string, + fallbackScheme: 'ws' | 'wss' +): NormalizeHostEndpointResult { + let host: string + let port: string | undefined + + if (input.startsWith('[')) { + const close = input.indexOf(']') + if (close <= 1) { + return { ok: false, error: 'Not a valid address.' } + } + host = input.slice(1, close) + const rest = input.slice(close + 1) + if (rest.startsWith(':')) { + port = rest.slice(1) + } else if (rest.length > 0) { + return { ok: false, error: 'Not a valid address.' } + } + } else { + const firstColon = input.indexOf(':') + const lastColon = input.lastIndexOf(':') + if (firstColon !== -1 && firstColon === lastColon) { + host = input.slice(0, firstColon) + port = input.slice(firstColon + 1) + } else { + // No port, or bare IPv6 (multiple colons, no brackets). + host = input + } + } + + host = host.trim() + if (!host) { + return { ok: false, error: 'Missing hostname.' } + } + + // Why: bare input is not a URL, so characters that only make sense in a URL + // (path, query, fragment, whitespace) must not be treated as hostname bytes. + const hostError = validateHostname(host) + if (hostError) { + return { ok: false, error: hostError } + } + + if (port !== undefined) { + port = port.trim() + if (!isValidPort(port)) { + return { ok: false, error: 'Port must be 1–65535.' } + } + } + + const finalPort = port ?? fallbackPort + return { ok: true, endpoint: `${fallbackScheme}://${formatHostForUrl(host)}:${finalPort}` } +} + +function formatHostForUrl(host: string): string { + return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host +} + +/** + * Reject hostnames that would be illegal or ambiguous in a websocket URL. + * Allows DNS labels, `.local` mDNS, IPv4, and IPv6 hex forms. + */ +function validateHostname(host: string): string | null { + if (!host) { + return 'Missing hostname.' + } + // Spaces, path/query/fragment separators, userinfo separators, brackets. + if (/[\s/?#@[\]]/.test(host)) { + return 'Not a valid hostname.' + } + const numericIpv4Error = validateNumericIpv4Candidate(host) + if (numericIpv4Error) { + return numericIpv4Error + } + if (host.includes(':')) { + // Why: a hex/colon regex accepts malformed forms such as two `::` runs. + // Reuse the URL parser that WebSocket will ultimately use. + if (!/^[0-9a-fA-F:]+$/.test(host)) { + return 'Not a valid hostname.' + } + try { + new URL(`ws://[${host}]:${DEFAULT_PORT}`) + } catch { + return 'Not a valid hostname.' + } + return null + } + // DNS / IPv4 / mDNS: labels of alnum and hyphen, dots between, no empty labels. + if ( + !/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test( + host + ) + ) { + return 'Not a valid hostname.' + } + return null +} + +function validateNumericIpv4Candidate(host: string): string | null { + if (!NUMERIC_IPV4_CANDIDATE.test(host)) { + return null + } + if (!isCanonicalIpv4(host)) { + return 'Not a valid hostname.' + } + return null +} + +function normalizeRawNumericIpv4Candidate(host: string): string { + let decoded = host + try { + decoded = decodeURIComponent(host) + } catch { + // The URL parser will reject malformed escapes; keep them untouched here. + } + return decoded.endsWith('.') ? decoded.slice(0, -1) : decoded +} + +function isCanonicalIpv4(host: string): boolean { + const octets = host.split('.') + return ( + octets.length === 4 && + octets.every((octet) => /^(?:0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255) + ) +} + +function isValidPort(port: string): boolean { + if (!/^\d+$/.test(port)) { + return false + } + const n = Number(port) + return n >= 1 && n <= 65535 +} diff --git a/mobile/src/transport/host-store-endpoint.test.ts b/mobile/src/transport/host-store-endpoint.test.ts new file mode 100644 index 00000000000..95d9c364b88 --- /dev/null +++ b/mobile/src/transport/host-store-endpoint.test.ts @@ -0,0 +1,95 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { updateHostNameAndEndpoint } from './host-store' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() + } +})) + +vi.mock('expo-secure-store', () => ({ + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), + deleteItemAsync: vi.fn(), + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY' +})) + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' } +})) + +describe('updateHostNameAndEndpoint', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + const stored = [ + { + id: 'host-1', + name: 'Desk', + endpoint: 'ws://100.64.0.5:6768', + publicKeyB64: 'pk', + lastConnected: 1 + }, + { + id: 'host-2', + name: 'Laptop', + endpoint: 'wss://laptop.example:8443', + publicKeyB64: 'pk-2', + lastConnected: 2 + } + ] + + it('commits name and endpoint together in a single write', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored)) + + await updateHostNameAndEndpoint('host-1', { + name: 'Home Desk', + endpoint: 'ws://192.168.1.10:6768' + }) + + expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1) + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:hosts', + JSON.stringify([ + { ...stored[0], name: 'Home Desk', endpoint: 'ws://192.168.1.10:6768' }, + stored[1] + ]) + ) + }) + + it('updates only the provided field', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored)) + + await updateHostNameAndEndpoint('host-1', { name: 'Home Desk' }) + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:hosts', + JSON.stringify([{ ...stored[0], name: 'Home Desk' }, stored[1]]) + ) + }) + + it('rewrites only the endpoint when name is omitted', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored)) + + await updateHostNameAndEndpoint('host-1', { endpoint: 'ws://192.168.1.10:6768' }) + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:hosts', + JSON.stringify([{ ...stored[0], endpoint: 'ws://192.168.1.10:6768' }, stored[1]]) + ) + }) + + it('throws and writes nothing when the host is missing', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue('[]') + + await expect(updateHostNameAndEndpoint('missing', { name: 'Renamed' })).rejects.toThrow( + 'Host not found' + ) + expect(AsyncStorage.setItem).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index a1aa3d677a1..918e6e92f93 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -37,9 +37,11 @@ import { loadHosts, MobileRelayUpgradeHostRemovedError, removeHost, - renameHost, + resolvePairingHostIdentity, resetHostStoreForTests, + saveHost, saveExistingHostRelayUpgrade, + updateHostNameAndEndpoint, updateLastConnected } from './host-store' import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' @@ -85,6 +87,8 @@ describe('host-store list mutations', () => { asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { if (key === HOSTS_STORAGE_KEY) { storedHostsRaw = raw + } else if (key === OVERLAY_STORAGE_KEY) { + storedOverlayRaw = raw } }) secureStoreMock.getItemAsync.mockImplementation(async (key: string) => @@ -92,6 +96,89 @@ describe('host-store list mutations', () => { ) }) + it('resolves an existing host by pinned key with one durable read', async () => { + await expect(resolvePairingHostIdentity(HOST_TWO.publicKeyB64, 'host-new')).resolves.toEqual({ + id: HOST_TWO.id, + name: HOST_TWO.name + }) + expect(asyncStorageMock.getItem).toHaveBeenCalledOnce() + }) + + it('names a new host from the same durable read used for identity lookup', async () => { + await expect(resolvePairingHostIdentity('unpaired-key', 'host-new')).resolves.toEqual({ + id: 'host-new', + name: 'Host 3' + }) + expect(asyncStorageMock.getItem).toHaveBeenCalledOnce() + }) + + it('fails closed when durable host identity storage is unreadable', async () => { + asyncStorageMock.getItem.mockRejectedValueOnce(new Error('storage unavailable')) + + await expect(resolvePairingHostIdentity('key-new', 'host-new')).rejects.toThrow(/unreadable/) + expect(asyncStorageMock.setItem).not.toHaveBeenCalled() + }) + + it('collapses already-persisted duplicates when the desktop is re-paired', async () => { + storedHostsRaw = JSON.stringify([ + HOST_ONE, + { ...HOST_TWO, id: 'host-duplicate', publicKeyB64: HOST_ONE.publicKeyB64 } + ]) + + await saveHost({ + ...HOST_ONE, + endpoint: 'ws://127.0.0.1:3', + deviceToken: 'replacement-token' + }) + + expect(JSON.parse(storedHostsRaw)).toEqual([{ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3' }]) + expect(scheduleCleanupMock).toHaveBeenCalledWith('host-duplicate', expect.any(Function)) + }) + + it('clears stale relay state when an existing host is re-paired direct-only', async () => { + const overlay: MobileRelayHostOverlay = { + v: 2, + hostId: HOST_ONE.id, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: HOST_ONE.endpoint }, + { + id: 'relay-primary', + kind: 'relay', + url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9' + } + ], + relayHostId: 'AbCdEf0123_-xyZ9', + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 + } + } + storedOverlayRaw = JSON.stringify([overlay]) + + await saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' }) + + expect(JSON.parse(storedOverlayRaw!)).toEqual([]) + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('does not touch relay storage when saving a new direct-only host', async () => { + await saveHost({ + id: 'host-new', + name: 'New Host', + endpoint: 'ws://127.0.0.1:3', + publicKeyB64: 'key-new', + deviceToken: 'new-token', + lastConnected: 0 + }) + + expect(asyncStorageMock.getItem).not.toHaveBeenCalledWith(OVERLAY_STORAGE_KEY) + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + it('commits the removal when credential cleanup scheduling rejects', async () => { scheduleCleanupMock.mockRejectedValue(new Error('intent storage unavailable')) @@ -186,7 +273,7 @@ describe('host-store list mutations', () => { return storedHostsRaw }) - const rename = renameHost(HOST_ONE.id, 'Renamed Host') + const rename = updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Renamed Host' }) const remove = removeHost(HOST_TWO.id) // Both writers have started their RMW and are blocked on the shared read // gate; without a mutation queue the second would clobber the first. @@ -207,9 +294,9 @@ describe('host-store list mutations', () => { it('preserves a rename when lastConnected updates race it', async () => { const before = Date.now() await Promise.all([ - renameHost(HOST_ONE.id, 'Alpha'), + updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Alpha' }), updateLastConnected(HOST_ONE.id), - renameHost(HOST_TWO.id, 'Beta') + updateHostNameAndEndpoint(HOST_TWO.id, { name: 'Beta' }) ]) const stored = JSON.parse(storedHostsRaw) as Array @@ -225,7 +312,9 @@ describe('host-store list mutations', () => { it('does not wipe the host list when storage is unreadable during mutation', async () => { storedHostsRaw = '{' - await expect(renameHost(HOST_ONE.id, 'Nope')).rejects.toThrow(/unreadable/) + await expect(updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Nope' })).rejects.toThrow( + /unreadable/ + ) expect(asyncStorageMock.setItem).not.toHaveBeenCalled() expect(storedHostsRaw).toBe('{') }) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index a17e6f57a1b..a404379e557 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -15,6 +15,7 @@ import { import { loadMobileRelayHostOverlayState, removeMobileRelayHostOverlay, + removeMobileRelayHostOverlays, saveMobileRelayHostOverlay } from './mobile-relay-host-overlay-store' import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' @@ -182,12 +183,18 @@ async function doLoadHosts(): Promise { return out } -async function loadStoredHosts(): Promise { - try { - return parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) ?? [] - } catch { - return [] - } +export async function resolvePairingHostIdentity( + publicKeyB64: string, + newHostId: string +): Promise<{ id: string; name: string }> { + // Why: one durable read both preserves an existing identity and names a new host, + // avoiding duplicate cards and a second serial storage read before connecting. + await hostListMutation + const hosts = await readStoredHostsForMutation() + const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64) + return match + ? { id: match.id, name: match.name } + : { id: newHostId, name: getNextHostNameFromHosts(hosts) } } async function readStoredHostsForMutation(): Promise { @@ -242,18 +249,28 @@ export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise { const validated = HostProfileSchema.parse(host) const stored = toStored(validated) + const duplicateHostIds = new Set() + let updatedExistingHost = false await mutateStoredHosts((hosts) => { const index = hosts.findIndex((h) => h.id === stored.id) + for (const candidate of hosts) { + if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) { + duplicateHostIds.add(candidate.id) + } + } if (index >= 0) { - const next = hosts.slice() - next[index] = stored - return next + updatedExistingHost = true + // Why: affected installs may already contain duplicate rows; an authoritative + // save is the safe point to collapse them to the preserved host id. + return hosts + .filter(({ id }) => !duplicateHostIds.has(id)) + .map((candidate) => (candidate.id === stored.id ? stored : candidate)) } if (requireExisting) { // Why: an in-flight relay upgrade must not resurrect a host the user removed. throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') } - return [...hosts, stored] + return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] }) // Why: write metadata BEFORE the keychain token so a crash between the two // leaves orphaned metadata (which loadHosts skips and removeHost can clean @@ -271,6 +288,23 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise relay: validated.relay }) } + const overlayRemovalIds = [...duplicateHostIds] + if (!validated.endpoints && updatedExistingHost) { + overlayRemovalIds.push(stored.id) + } + if (overlayRemovalIds.length > 0) { + // Why: reusing an id for direct-only re-pairing must not retain routing + // metadata from the host's previous transport state. + await removeMobileRelayHostOverlays(overlayRemovalIds) + } + for (const duplicateHostId of duplicateHostIds) { + tokenCache.delete(duplicateHostId) + try { + await scheduleHostCredentialCleanup(duplicateHostId, deleteHostCredentials) + } catch { + // Metadata is already deduplicated; orphan-token recovery is best-effort. + } + } } export async function removeHost(hostId: string): Promise { @@ -299,24 +333,29 @@ export async function retryPendingHostCredentialCleanup(): Promise<{ return retryPendingHostCredentialCleanups(deleteHostCredentials) } -export async function renameHost(hostId: string, newName: string): Promise { +// Why: Edit host can change name and endpoint together; a single +// mutateStoredHosts pass keeps both fields committed atomically so a +// mid-save failure can never persist one change without the other, and a +// host removed mid-edit throws consistently instead of silently no-oping. +export async function updateHostNameAndEndpoint( + hostId: string, + updates: { name?: string; endpoint?: string } +): Promise { await mutateStoredHosts((hosts) => { - const index = hosts.findIndex((h) => h.id === hostId) + const index = hosts.findIndex((host) => host.id === hostId) if (index < 0) { - return hosts + throw new Error('Host not found') } const next = hosts.slice() - next[index] = { ...next[index]!, name: newName } + next[index] = { + ...next[index]!, + ...(updates.name !== undefined ? { name: updates.name } : {}), + ...(updates.endpoint !== undefined ? { endpoint: updates.endpoint } : {}) + } return next }) } -export async function getNextHostName(): Promise { - await hostListMutation - const hosts = await loadStoredHosts() - return getNextHostNameFromHosts(hosts) -} - export async function updateLastConnected(hostId: string): Promise { try { await mutateStoredHosts((hosts) => { diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts index 8286fe67f5b..812d61ebb1d 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts @@ -9,6 +9,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStor import { loadMobileRelayHostOverlays, + removeMobileRelayHostOverlays, resetMobileRelayHostOverlayStoreForTests, saveMobileRelayHostOverlay } from './mobile-relay-host-overlay-store' @@ -77,4 +78,24 @@ describe('mobile relay host overlay store', () => { await expect(saveMobileRelayHostOverlay(OVERLAY)).rejects.toThrow(/unreadable/) expect(asyncStorage.setItem).not.toHaveBeenCalled() }) + + it('removes requested overlays in one storage write', async () => { + const second = { ...OVERLAY, hostId: 'host-2' } + stored = JSON.stringify([OVERLAY, second]) + + await expect(removeMobileRelayHostOverlays(['host-1', 'host-missing'])).resolves.toBeUndefined() + + expect(JSON.parse(stored!)).toEqual([second]) + expect(asyncStorage.getItem).toHaveBeenCalledOnce() + expect(asyncStorage.setItem).toHaveBeenCalledOnce() + }) + + it('skips the storage write when no requested overlay exists', async () => { + stored = JSON.stringify([OVERLAY]) + + await expect(removeMobileRelayHostOverlays(['host-missing'])).resolves.toBeUndefined() + + expect(asyncStorage.getItem).toHaveBeenCalledOnce() + expect(asyncStorage.setItem).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.ts b/mobile/src/transport/mobile-relay-host-overlay-store.ts index 4dbb50d9f46..7f1519eb5c9 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.ts @@ -40,7 +40,12 @@ async function mutateOverlays( ): Promise { const mutation = overlayMutation.then(async () => { const current = await readOverlaysForMutation() - await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(update(current))) + const next = update(current) + // Why: direct-only saves commonly have no overlay to remove; avoid a full + // AsyncStorage write when cleanup leaves the durable list unchanged. + if (next !== current) { + await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(next)) + } }) overlayMutation = mutation.catch(() => {}) return mutation @@ -85,7 +90,22 @@ export async function saveMobileRelayHostOverlay(overlay: MobileRelayHostOverlay } export function removeMobileRelayHostOverlay(hostId: string): Promise { - return mutateOverlays((overlays) => overlays.filter((overlay) => overlay.hostId !== hostId)) + return removeMobileRelayHostOverlays([hostId]) +} + +export function removeMobileRelayHostOverlays(hostIds: readonly string[]): Promise { + const targets = new Set(hostIds) + let removed = false + return mutateOverlays((overlays) => { + const next = overlays.filter((overlay) => { + if (!targets.has(overlay.hostId)) { + return true + } + removed = true + return false + }) + return removed ? next : overlays + }) } /** Test-only: drain the module mutation chain between cases. */ diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts index 16c833bf4f2..782c02aed50 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts @@ -64,7 +64,10 @@ function dependencies(client: RpcClient, events: string[]) { resolveInviteDirector: vi.fn(async () => { throw new Error('director unavailable') }), - getNextHostName: vi.fn(async () => 'Blue Whale'), + resolveHostIdentity: vi.fn(async (_publicKeyB64: string, hostId: string) => ({ + id: hostId, + name: 'Blue Whale' + })), saveHost: vi.fn(async (_host: HostProfile) => { events.push('save-host') }), @@ -136,6 +139,35 @@ describe('pre-profile pairing coordinator', () => { expect(events).toEqual(['connect', 'save-host']) }) + it('reuses the existing host id and name when re-pairing the same desktop key (no duplicate)', async () => { + // STA-1840: re-pairing a desktop already stored under a different id must + // merge into that card, not mint a new host-${now} and duplicate the row. + const events: string[] = [] + const client = fakeClient([success({ version: '1.0.0' })]) + const deps = dependencies(client, events) + deps.resolveHostIdentity = vi.fn(async (publicKeyB64: string, newHostId: string) => { + expect(publicKeyB64).toBe(directOffer.publicKeyB64) + expect(newHostId).toBe(`host-${now}`) + return { id: 'host-existing', name: 'Studio Mac' } + }) + + const attempt = startPreProfilePairing({ + offer: directOffer, + timeoutMs: 5_000, + dependencies: deps + }) + + await expect(attempt.result).resolves.toEqual({ hostId: 'host-existing' }) + expect(deps.saveHost).toHaveBeenCalledWith({ + id: 'host-existing', + name: 'Studio Mac', + endpoint: directOffer.endpoint, + deviceToken: directOffer.deviceToken, + publicKeyB64: directOffer.publicKeyB64, + lastConnected: now + }) + }) + it('journals before connecting and publishes only after authoritative direct install', async () => { const events: string[] = [] let journal: MobileRelayPairingJournal | null = null diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.ts b/mobile/src/transport/pre-profile-pairing-coordinator.ts index 9d2a3b48ddb..82bd989be03 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.ts @@ -6,7 +6,7 @@ import { type MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' import { connect, type ConnectOptions } from './rpc-client' -import { getNextHostName, saveHost } from './host-store' +import { resolvePairingHostIdentity, saveHost } from './host-store' import type { HostProfile, PairingOffer, RpcResponse } from './types' import { createMobileRelayPairingJournal, @@ -39,7 +39,7 @@ type Dependencies = { connectDirect: typeof connect connectRelay: typeof connectMobileRelayForPairing resolveInviteDirector: typeof resolvePairingInviteThroughDirector - getNextHostName: typeof getNextHostName + resolveHostIdentity: typeof resolvePairingHostIdentity saveHost: typeof saveHost saveJournal: typeof saveMobileRelayPairingJournal updateJournal: typeof updateMobileRelayPairingJournal @@ -53,7 +53,7 @@ const defaultDependencies: Dependencies = { connectDirect: connect, connectRelay: connectMobileRelayForPairing, resolveInviteDirector: resolvePairingInviteThroughDirector, - getNextHostName, + resolveHostIdentity: resolvePairingHostIdentity, saveHost, saveJournal: saveMobileRelayPairingJournal, updateJournal: updateMobileRelayPairingJournal, @@ -130,8 +130,12 @@ async function runPairing( isDisposed: () => boolean ): Promise<{ hostId: string }> { const now = dependencies.now() - const hostId = `host-${now}` - const hostName = await dependencies.getNextHostName() + // Why: every pairing artifact must share the preserved host id so re-pairing + // updates one card instead of publishing a second identity (STA-1840). + const { id: hostId, name: hostName } = await dependencies.resolveHostIdentity( + offer.publicKeyB64, + `host-${now}` + ) assertActive(isDisposed) let journal: MobileRelayPairingJournal | null = null if (offer.relay && dependencies.platform !== 'web') { diff --git a/mobile/src/transport/rpc-client-terminal-subscription.ts b/mobile/src/transport/rpc-client-terminal-subscription.ts index 9afc4a1fd0c..471bc3c4a4e 100644 --- a/mobile/src/transport/rpc-client-terminal-subscription.ts +++ b/mobile/src/transport/rpc-client-terminal-subscription.ts @@ -1,3 +1,5 @@ +import { buildNativeChatUnsubscribe } from '../../../src/shared/native-chat-stream-unsubscribe' + type TerminalStreamParams = { terminal?: unknown } @@ -31,6 +33,37 @@ export function updateTerminalSubscriptionViewport( } } +/** Build the unsubscribe RPC for a streaming method that needs the host told to + * tear down (session tabs, native chat), or null when none is required. Keeps + * the per-method echo logic out of the rpc-client teardown closure. */ +export function buildStreamUnsubscribe( + method: string | undefined, + params: unknown +): { method: string; params: Record } | null { + if (!params || typeof params !== 'object') { + return null + } + if (method === 'session.tabs.subscribe') { + const worktree = (params as { worktree?: unknown }).worktree + return typeof worktree === 'string' + ? { method: 'session.tabs.unsubscribe', params: { worktree } } + : null + } + if (method === 'nativeChat.subscribe') { + const subscriptionId = (params as { subscriptionId?: unknown }).subscriptionId + if (typeof subscriptionId === 'string') { + return { method: 'nativeChat.unsubscribe', params: { subscriptionId } } + } + // Backward compatibility for callers that predate explicit cleanup tokens. + const agent = (params as { agent?: unknown }).agent + const sessionId = (params as { sessionId?: unknown }).sessionId + return typeof agent === 'string' && typeof sessionId === 'string' + ? buildNativeChatUnsubscribe(agent, sessionId) + : null + } + return null +} + export function buildTerminalUnsubscribeParams( params: unknown ): { subscriptionId: string; client?: { id: string } } | null { diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 014bea955cd..5420ba679a6 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -23,6 +23,7 @@ import { type BrowserScreencastFrame } from './browser-screencast-protocol' import { + buildStreamUnsubscribe, buildTerminalUnsubscribeParams, updateTerminalSubscriptionViewport as updateCachedTerminalSubscriptionViewport } from './rpc-client-terminal-subscription' @@ -1171,18 +1172,11 @@ export function connect( params: unsubscribeParams }) } - } else if ( - stream?.method === 'session.tabs.subscribe' && - stream.params && - typeof stream.params === 'object' && - typeof (stream.params as { worktree?: unknown }).worktree === 'string' - ) { - sendEncrypted({ - id: nextId(), - deviceToken, - method: 'session.tabs.unsubscribe', - params: { worktree: (stream.params as { worktree: string }).worktree } - }) + } else { + const unsub = buildStreamUnsubscribe(stream?.method, stream?.params) + if (unsub) { + sendEncrypted({ id: nextId(), deviceToken, method: unsub.method, params: unsub.params }) + } } removeStreamListener(id) } diff --git a/notes/skill-freshness-design.md b/notes/skill-freshness-design.md index 5da3b242817..8d12f8915a7 100644 --- a/notes/skill-freshness-design.md +++ b/notes/skill-freshness-design.md @@ -126,6 +126,7 @@ Rules: ### C. Read-only detection (kept from Phase 1, slimmed) Kept as-is: + - Bundled `skills/` packages + current manifest + released-snapshot registry + release mapping, with the generation script and merge-queue monotonicity gate (static data + CI, not runtime machinery). @@ -135,9 +136,12 @@ Kept as-is: plugin caches and repo scopes excluded), and the launch / focus / post-install triggers. - The skills-CLI round-trip CI on macOS/Linux/Windows — extended from current-install tests to historical-fat-install → targeted global update → stub migration. The matrix covers - copy/symlink shapes, LF/CRLF, supported lock migrations, and post-update identity. + LF/CRLF and provider aliases as a positive convergence contract, plus independent-copy + observation that accepts only unchanged historical or exact-current bytes. Post-update + bytes, not exit status, decide. Slimmed: + - Statuses collapse to: `current`, `outdated` (exact match of an older released snapshot), `newer-known`, `unrecognized`, and `inaccessible`. Without a ledger, Orca cannot honestly distinguish a locally modified official copy from unrelated same-named content; @@ -151,32 +155,51 @@ Slimmed: ### D. Surfacing -- **Settings rows** (read-only): name, status badge, one-line explanation. `newer-known`, - `unrecognized`, `inaccessible`, and unsupported-topology rows are informational. -- **Name-scoped update eligibility:** the skills CLI reinstalls every placement of a selected - skill name, so eligibility is computed across all discovered placements of that name, not - per row. Offer a name only when at least one placement is `outdated` and every placement is - an exact `current` or `outdated` official snapshot in a supported global topology. One - `newer-known`, unrecognized, external, read-only, inaccessible, or otherwise unsupported - provider copy poisons the update offer for that name entirely. +- **Surfaces (venue decision 2026-07-14):** a lingering toast, an update modal, and the + existing Settings setup rails for CLI, Orchestration, Computer Use, and Per-Workspace + Environments. Their installed pills carry safe freshness status, while their existing + Update and Re-check actions remain the per-skill path. The Skills page was + de-linked by #4535 (2026-06-02) — its only entry, the sidebar toolbox menu, was removed — + so it is no longer a venue; the freshness surface moved off it entirely. The behavior + contracts below (name-scoped eligibility, no auto-run, dismissal keys, re-inventory + triggers) are unchanged; only the venue moved. +- **Per-placement rows** (read-only): name, status badge, one-line explanation. `newer-known`, + `unrecognized`, `inaccessible`, and unsupported-topology rows are informational. They live in + the modal's collapsed **Details** section (auto-expanded when a placement is blocked). +- **Name-scoped update eligibility:** eligibility is computed across all discovered placements + of a name, not per row. Offer a name only when at least one placement is `outdated` and every + placement is an exact `current` or `outdated` official snapshot in a topology the validated + rail actually converges. With skills CLI 1.5.17 that means the canonical global copy and + provider aliases to it. Independent provider copies are informational and poison the offer: + empirical copy-mode testing produced both stale and converged provider copies in otherwise + equivalent 1.5.17 environments, so that topology is not deterministic enough to offer. One + `newer-known`, unrecognized, external, read-only, + inaccessible, repo/plugin, independent-copy, or otherwise unsupported placement poisons the + update offer for that name entirely. - The action combines only eligible outdated Orca names into - `npx skills update --global`, opens the existing run-command terminal with that - command pre-filled, and leaves execution to the user. Never use an unscoped bulk update and - never auto-submit the command. Re-inventory after terminal exit or focus; only observed - bytes, not the skills CLI exit status, determine success. -- **One non-repeating nudge**: count only eligible outdated skill names and offer the same - targeted run-the-command action. An outdated name poisoned by another placement remains - visible in settings but never produces an unsafe nudge action. Dismissal is recorded per - (install, bundled revision), so a newly outdated official placement or genuinely newer - stub revision may prompt once more. No toggle — nothing automatic happens that would need - one. + `npx skills update --global` and opens the update modal's editable terminal with + that command pre-filled, leaving execution to the user. Never use an unscoped bulk update and + never auto-submit the command. Re-inventory after terminal exit, modal close, or focus; only + observed bytes, not the skills CLI exit status, determine success. When the eligible set + empties and every placement is `current`, the modal shows an up-to-date state; if placements + remain outdated-but-blocked or unrecognized, it says so honestly instead. +- **One lingering, non-repeating nudge**: count only eligible outdated skill names and offer the + same targeted action, which opens the update modal. The toast lingers (no auto-close) until the + user opens the modal or explicitly dismisses it; ignoring it (app quit) records nothing, so a + still-outdated skill may prompt once more next launch. A later inventory that resolves or blocks + the offered tuple retracts the stale toast without recording a dismissal. An outdated name + poisoned by another placement remains visible in the modal's Details but never produces an + unsafe nudge action. Dismissal is recorded per (physical identity, name, bundled revision) only + on explicit dismissal, so a newly outdated official placement or genuinely newer stub revision + may prompt once more. No + toggle — nothing automatic happens that would need one. ### E. Migration (fat → stub) 1. **Implemented, pending release:** from a fresh main-based PR, add authoritative guide sources, generated embedded data, `orca skills list/get`, aliases, generated-output checks, and local/SSH/WSL/dev tests. Keep distributed skills fat and ship this release first. -2. From a separate PR, land slim read-only detection and settings/nudge UI, including the +2. From a separate PR, land slim read-only detection and Skills-page/nudge UI, including the name-scoped targeted update action and the real migration-rail CI. Keep distributed skills fat. 3. Run the pointer-compliance spike against the released guide-serving binary, not a checkout @@ -211,15 +234,17 @@ not thin, until the relevant variant passes. global Windows failures, missing global lock tracking, lossy lock migration, and copy-mode topology changes. The historical-fat → targeted-global-update → stub CI is a release gate, not an early-warning job. Detection always re-checks bytes after the user updates, so a - failed or no-op update re-surfaces `outdated` instead of lying. Choose and document a - validated CLI-version policy before rollout; monitor and contribute upstream fixes. + failed or no-op update re-surfaces `outdated` instead of lying. Minimum validated version is + 1.5.17: 1.5.16 failed the provider-alias convergence contract, while 1.5.17 copy convergence + still varies by environment. CI pins 1.5.17 and probes latest; monitor and contribute upstream + fixes before broadening eligibility. - **Trigger-copy iteration slows.** Improvements to stub descriptions reach existing installs only when users run the npx command. Acceptable at stub-change cadence; the compiled guides (the content that matters) are exempt by construction. - **Multi-file skills.** Current shipped packages are single-file. If a future skill needs scripts/assets, either the binary serves them (`--full` / `--script`) or that skill accepts the fat-file decay model. Decide when it happens. -- **Remote hosts.** Detection ships local-host-only. Stubs make remote *content* a non-issue: +- **Remote hosts.** Detection ships local-host-only. Stubs make remote _content_ a non-issue: SSH/WSL launchers forward to the host's bundled CLI, so the guide matches the command surface that will handle subsequent requests. Remote stub installs can lag on trigger copy, which is the accepted residual. The WSL/SSH reconciler phases of the old design are diff --git a/package.json b/package.json index efcbf53ac08..292873687f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.142-rc.6", + "version": "1.4.144-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", @@ -23,6 +23,8 @@ "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", "verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check", + "generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write", + "verify:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs", "verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs", "vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs", "tc:node": "pnpm run typecheck:node", @@ -131,6 +133,7 @@ "@electron/rebuild": "^4.2.0", "@monaco-editor/react": "^4.7.0", "@playwright/test": "^1.59.1", + "@sanity/diff-match-patch": "^3.2.0", "@stablyai/playwright-test": "^2.1.14", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-virtual": "^3.13.24", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7af13f9c378..b8d00d79dc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: '@playwright/test': specifier: ^1.59.1 version: 1.59.1 + '@sanity/diff-match-patch': + specifier: ^3.2.0 + version: 3.2.0 '@stablyai/playwright-test': specifier: ^2.1.14 version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) @@ -2604,6 +2607,10 @@ packages: cpu: [x64] os: [win32] + '@sanity/diff-match-patch@3.2.0': + resolution: {integrity: sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww==} + engines: {node: '>=18.18'} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -8799,6 +8806,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.3': optional: true + '@sanity/diff-match-patch@3.2.0': {} + '@sec-ant/readable-stream@0.4.1': {} '@sindresorhus/is@4.6.0': {} diff --git a/pty-exhaustion-agent-session-leak.md b/pty-exhaustion-agent-session-leak.md deleted file mode 100644 index 64cbb3d93ce..00000000000 --- a/pty-exhaustion-agent-session-leak.md +++ /dev/null @@ -1,178 +0,0 @@ -# Design Doc: PTY Exhaustion from Leaked Agent Sessions - -**Date:** 2026-07-13 -**Status:** Root cause identified; fixes proposed -**Severity:** P0 — machine-wide outage. Once the pty cap is hit, _nothing_ on the host can open a terminal (Orca, Ghostty, IDEs, `ssh`, agent spawns all fail with "cannot allocate pty"). -**Source refs:** file:line references below are against the main checkout at `~/orca/orca` as of 2026-07-13. - ---- - -## 1. Summary - -An Orca desktop instance exhausted the macOS pseudo-terminal limit (`kern.tty.ptmx_max = 511`). Investigation on the live machine found **526 allocated ptys**, of which **~476 belonged to three `Orca Helper` processes** — hundreds of idle `login → zsh → codex resume ` chains, some alive for almost 4 days, all at 0% CPU. Sessions the user had explicitly closed in the UI were still running, and closed sessions kept _coming back_ in bulk waves after restarts. - -The root cause is **two independent defects that compound into a self-perpetuating leak**: - -- **Bug A (teardown):** Closing a session tab never kills the underlying pty. Tab close is pure renderer-state cleanup; the actual `pty.kill` only happens as a side effect of a mounted `TerminalPane` unmounting. For "cold-parked" tabs in background worktrees — whose panes are unmounted by design — closing the tab silently orphans the live process chain. -- **Bug B (resume):** Closing a tab never removes the session's record from the persisted resume list (`sleepingAgentSessionsByPaneKey`), and the record staleness check is a no-op (it compares `capturedAt - updatedAt`, which is always ≈ 0, instead of `now - updatedAt`). Every workspace open / app restart therefore bulk-launches `codex resume` for the entire orphaned backlog — and a periodic capture timer re-persists all of them, so the backlog only grows. - -Every restart makes it worse: Bug B resurrects the dead-but-remembered sessions (each burning a fresh pty), Bug A guarantees closing them doesn't actually free anything, and the capture loop re-persists the ever-larger set. - ---- - -## 2. Observed incident (live-machine evidence) - -Measurements taken 2026-07-13 on the affected desktop: - -| Metric | Value | -| ------------------------------------------------- | ----------------------------------------------------------------------- | -| Allocated ptys (`/dev/ttys*`) | 526 (cap: `kern.tty.ptmx_max = 511`) | -| `login` + `zsh` session pairs | 506 | -| Sessions parented to installed `Orca.app` helpers | 476 (helper pids 53960: **377**, 31662: 58, 1510: 41) | -| Sessions from dev-build Orca instances | ~25 | -| `codex` agent processes | ~280 (~270 **unique** session IDs; a handful resumed 2–3× concurrently) | -| `claude` agent processes | ~38 | -| Processes using >1% CPU | 7 of 763 — everything else fully idle | - -Key observations: - -- **`Orca Helper` pid 53960 alone held 376 open ptmx master fds.** Every leaked chain was still fully parented: `Orca Helper → /usr/bin/login → zsh → node → codex`. Nothing was orphaned to launchd — the app-side master fd was simply never closed. -- **Working directories were overwhelmingly finished PR-review worktrees** (`review-p1-pr-7511-issue-7026` alone had 79 codex processes; `review-p0-pr-8370-issue-8212` had 76; `review-p0-pr-8279-issue-8260` had 67). -- **Start times cluster in same-second waves** — batches on Fri Jul 10 (~15:41–18:14) and large bursts on Sun Jul 12 (14:03, 14:27, 14:36–14:40, 15:09). These are bulk resumes on workspace open / app restart, not user-opened sessions. -- **The user had closed these tabs.** The tabs were gone from the UI; the processes survived (Bug A) and were then re-resumed in later waves (Bug B). -- Nearly all resumed with `codex --dangerously-bypass-approvals-and-sandbox resume ` — i.e., they idle at an interactive prompt and never exit on their own. - ---- - -## 3. Background: pty ownership architecture - -- Terminal sessions are spawned in an Electron utility/helper process which holds the **pty master** (hence the ptmx fds on `Orca Helper`). -- A **daemon** hosts sessions so they survive app restarts for warm reattach: it is forked `detached: true` + `unref()` and deliberately outlives the app (`daemon/daemon-init.ts:306-319`; comment at `:741` — "sessions stay alive for warm reattach"). -- Background (non-active) worktrees are **"cold-parked"**: their `TerminalPane` React components unmount, the transport calls `detach()` (`pty-transport.ts:929` — "keep the PTY exit observer alive"), and pane-less byte watchers observe the still-running pty. -- Agent sessions are additionally tracked in a persisted map, `sleepingAgentSessionsByPaneKey` (`src/renderer/src/store/slices/agent-status.ts:121`), so they can be resumed (`codex resume `) after sleep/quit. - -Keep-alive-on-detach is **intentional**. The defects are in what is supposed to end a session's life. - ---- - -## 4. Bug A: closing a tab never kills the pty - -### The close path contains no kill - -`closeTerminalTab()` (`terminal-tab-actions.ts:152`) → `closeLocalTerminalTabState` → `state.closeTab()` (`store/slices/terminals.ts:1192-1381`). `closeTab` scrubs ~20 per-tab maps — including `delete ptyIdsByTabId[tabId]` (`terminals.ts:1217-1218`) — but issues **no `pty.kill`, no shutdown, no IPC**. It just _forgets the pty id_. - -### The only kill is a React-unmount side effect - -The pty dies only when a _mounted_ `TerminalPane` unmounts with the tab gone: `shouldDetachPaneTransportOnUnmount()` (`use-terminal-pane-lifecycle.ts:456`) returns false → unmount cleanup (`use-terminal-pane-lifecycle.ts:1725-1826`, kill branch at `:1807`) → `transport.destroy()` (`pty-transport.ts:1031`) → `window.api.pty.kill(id)` → main `pty:kill` (`ipc/pty.ts:4953`) → `provider.shutdown(id, {immediate: true})`. - -This works for the **active** worktree. But for a **cold-parked** tab there is no mounted pane; closing it runs `syncParkedTerminalTabWatchers` → `disposeParkedTabWatchers` (`terminal-parked-watcher-registry.ts:53-63`), which stops the watchers and **never kills the pty**. The `login → zsh → codex` chain is orphaned with nothing pointing at it. **This is the zombie source.** - -### Nothing else ever reaps it - -Every existing reaper is scoped to an event _other than_ tab close: - -| Mechanism | Why it doesn't help | -| --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Daemon `detach` handler | Logging no-op — "full implementation would track tokens" (`daemon/daemon-server.ts:508-512`). No idle timeout exists anywhere. | -| `reapSession` (`daemon/terminal-host.ts:215`) | Fires only on the child's natural exit or explicit kill; an idle interactive `codex` never exits. | -| App-quit `killAllPty()` (`ipc/pty.ts:5191`), reload `killOrphanedPtys` (`ipc/pty.ts:2851`) | Both guarded by `if (localProvider instanceof LocalPtyProvider)` — false for the daemon adapter, so daemon-hosted sessions are excluded _by design_ (warm reattach). | -| Worktree removal (`runtime/worktree-teardown.ts:39`, `killAllProcessesForWorktree`) | Works, but only on full worktree delete. | -| Sleep flow (`sidebar/sleep-worktree-flow.ts:148` → `shutdownWorktreeTerminals`, `terminals.ts:2331`, kill at `:2665`) | Works, but only when the user explicitly sleeps the worktree. | - -### The codebase already knows close ≠ kill - -`kill-all-terminal-surfaces.ts` performs daemon `killAll()`, then `closeTerminalTab({force: true})` per tab, **then an explicit `window.api.pty.kill(ptyId)` loop over `ptyIdsByTabId`** (`:140-147`, `:188-190`) — direct evidence that tab close alone is known not to kill. There is even a manual mop-up UI: **Manage Sessions → "Kill orphan terminals"** for sessions "that have no tab in this Orca instance" (`status-bar/ResourceUsageStatusSegment.tsx:~1114-1155`) — a hand-operated workaround for exactly this leak. - ---- - -## 5. Bug B: closed sessions are resurrected in bulk forever - -### The resume list never learns about tab close - -Agent sessions are recorded in `sleepingAgentSessionsByPaneKey` (`agent-status.ts:121`; record shape in `src/shared/agent-session-resume.ts:38-59`, with `state`, `capturedAt`, `updatedAt`, and `origin: 'worktree-sleep' | 'quit' | 'live'`). Records are written: - -- **Live:** on every non-done agent-status update, an `origin: 'live'` record is captured (`agent-status.ts:1492-1544`). -- **Quit + periodic:** `captureAllSleepingAgentSessions` writes an `origin: 'quit'` record for _every currently-live pane_ (`agent-status.ts:2312-2350`), called on quit (`App.tsx:1367`) **and on a periodic interval** (`App.tsx:1394`). - -`closeTab` (`terminals.ts:1192-1381`) calls `dropAgentStatusByTabPrefix` (`terminals.ts:1894-2010`), which removes the agent-_status_ entry but **never touches `sleepingAgentSessionsByPaneKey`**. There is no concept of "user closed this tab" in the resume store. The orphaned record persists to disk with the workspace session state (`workspace-session-sleeping-agents.ts` → `workspace-session.ts:393`). - -### The staleness gate is a no-op - -On workspace/worktree open, `resumeSleepingAgentSessionsForWorktree` (`resume-sleeping-agent-session.ts:143-218`) launches `codex resume ` (argv built in `agent-session-resume.ts:195-220`; full command in `tui-agent-startup.ts:189-238`; launched via `sleeping-agent-session-launch.ts:65-128`) for every record unless a filter rejects it: - -- `isPassiveCompletedHibernationEvidence` (`sleeping-agent-pane-ownership.ts:16-18`): only skips records that are `done` **and** not `live`/`quit` origin. Orphaned records are `live`/`quit`, so they pass. -- `isInvalidWorktreeActivationRecord` (`resume-sleeping-agent-session.ts:131-141`): rejects records where `state !== 'done' && (capturedAt - updatedAt) > 30 min` (`AGENT_STATUS_STALE_AFTER_MS`, `shared/agent-status-types.ts:256`). **But live records are captured with `capturedAt === updatedAt`** (`agent-status.ts:1501`) and quit records stamp `capturedAt = Date.now()` immediately after the last update — so the difference is always ≈ 0. The check should compare against **`now`**; as written, a record from three days ago is perpetually "fresh." -- `recordPaneIsOwnedByPreservedPane`: skips only if the pane still exists in the restored layout — but the user closed the tab, so it doesn't. - -Result: orphaned records are neither passive, nor stale, nor pane-owned → **all of them get `codex resume`'d**, dozens in the same second. Triggers: folder-workspace open (`worktree-activation.ts:229`), worktree open (`:363`), startup hydration of the active worktree (`Terminal.tsx:1062-1075`), and mobile wake (`wake-sleeping-agents-in-background.ts:165-212`, wired at `useIpcEvents.ts:836`). - -### The feedback loop - -1. Workspace opens → every orphaned record is resumed → N fresh ptys, N idle `codex` processes. -2. Resumed sessions idle at a prompt and never reach `done`, so done-cleanup (`agent-status.ts:1541-1544`) never runs. -3. The periodic/quit capture (`agent-status.ts:2312-2350`) re-persists a record for every one of them. -4. User closes the tabs → Bug A leaves the processes alive; Bug B leaves the records in place. -5. Next open/restart → goto 1, with a strictly larger backlog. - -Distinct historical session IDs produce distinct claim keys, so per-claim dedup doesn't collapse them — which is exactly why one worktree accumulated 79 live codex processes across ~270 unique session IDs machine-wide. - ---- - -## 6. Proposed fixes - -### Fix 1 (primary, Bug A): make tab close actually kill the pty - -In `closeTab` (`terminals.ts:1192`) / `closeTerminalTab` (`terminal-tab-actions.ts:152`), issue `window.api.pty.kill()` for the tab's pty ids **before** `delete ptyIdsByTabId[tabId]`, mirroring the explicit kill loop in `kill-all-terminal-surfaces.ts:140-147`. This closes the parked-pane gap directly: the kill no longer depends on a mounted `TerminalPane` unmounting. Cover the split-pane close paths (`use-terminal-pane-lifecycle.ts`, `TerminalPaneOverlayLayer.tsx`) as well. - -Design decision needed: if warm-reattach semantics should be preserved for _some_ tab closes, distinguish "close tab" (kill) from "sleep/detach" (keep alive) explicitly in the UI action — today the distinction exists in the code (Sleep uses `keepIdentifiers: true`, `sleep-worktree-flow.ts:148`) but plain tab close falls into an unintended third state: forgotten but alive. - -### Fix 2 (primary, Bug B): remove resume records on tab close - -Have `closeTab` (and the split-pane close paths) delete the closed panes' entries from `sleepingAgentSessionsByPaneKey`. A user closing a tab is expressing "I'm done with this session" — it must not remain resumable-by-default. - -### Fix 3 (secondary, Bug B): origin-aware wall-clock expiry - -Do not globally expire resume records by wall time: intentional `worktree-sleep` -checkpoints may remain valid for days. A defensive expiry can apply only to -unowned `live` or `quit` records after their origin and ownership are validated -at every wake entry point. Explicit tab close must delete its records -immediately and remains the primary fix. - -### Fix 4 (secondary, Bug A): daemon-side reaper - -Implement the daemon `detach` handler (`daemon-server.ts:508-512`) and an idle reaper for sessions that are detached with **no owning tab**. The daemon deliberately outlives the app and is excluded from every `instanceof LocalPtyProvider` sweep, so it needs its own lifecycle policy — otherwise any future client-side bookkeeping bug leaks forever again. - -### Defense in depth - -- Surface a pty-pressure warning (the data already exists for the "Kill orphan terminals" feature) when app-owned ptys exceed a threshold (e.g. 300), since exhaustion is a machine-wide outage. -- The `--dangerously-bypass-approvals-and-sandbox` resume flag amplifies blast radius: hundreds of unattended, sandbox-less agents idling for days. Consider whether bulk background resume should ever use it. - ---- - -## 7. Verification plan - -1. **Repro (pre-fix):** in a background worktree, open an agent session, switch to another worktree (parking the pane), close the tab → `ps` shows the `login/zsh/codex` chain still alive and the helper still holds its ptmx fd. Restart Orca → the session is resumed again. -2. **Post-fix:** same steps → chain dies on tab close (Fix 1); record removed so restart resumes nothing (Fix 2). Origin-aware defensive expiry is verified separately when implemented (Fix 3). -3. **Regression:** worktree Sleep → wake still resumes sessions; quit → relaunch still warm-reattaches sessions whose tabs were _not_ closed; daemon warm-reattach unaffected. -4. **Soak:** count `Orca Helper` ptmx fds (`lsof -p | grep -c ptmx`) across a day of open/close/restart cycles — should stay flat. - -## 8. Immediate mitigation (ops, no code) - -- Use **Manage Sessions → "Kill orphan terminals"** or exact-session termination - to release the leaked ptys. A normal Orca.app restart is insufficient because - the persistent terminal daemon deliberately survives for warm reattach. -- `sudo sysctl kern.tty.ptmx_max=999` raises the cap to its macOS hard max — buys headroom only, hours at current leak rate. -- Restart the terminal daemon only when exact-session cleanup is unavailable; - this terminates all daemon-backed sessions, including legitimate in-flight work. - ---- - -## Appendix: diagnostic commands used - -```sh -ls /dev/ttys* | wc -l && sysctl kern.tty.ptmx_max # allocation vs cap -ps -ax -o pid,tty,etime,comm | grep -E 'ttys[0-9]+' # who holds ptys -lsof -a -d cwd -c codex | awk '{print $NF}' | sort | uniq -c | sort -rn # per-worktree counts -lsof -p | grep -c ptmx # master fds per Orca Helper -ps -ax -o args | grep -oE '[0-9a-f-]{36}' | sort | uniq -c # duplicate session resumes -``` diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json new file mode 100644 index 00000000000..fca1162fa5e --- /dev/null +++ b/resources/skills/current-manifest.json @@ -0,0 +1,158 @@ +{ + "schemaVersion": 1, + "appVersion": "1.4.144-rc.1", + "skills": [ + { + "name": "computer-use", + "sourcePath": "skills/computer-use", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 5, + "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", + "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", + "files": [ + { + "path": "SKILL.md", + "size": 11241, + "executable": false, + "classification": "text", + "exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39" + } + ] + }, + { + "name": "linear-tickets", + "sourcePath": "skills/linear-tickets", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 4, + "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", + "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", + "files": [ + { + "path": "SKILL.md", + "size": 10596, + "executable": false, + "classification": "text", + "exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5" + } + ] + }, + { + "name": "orca-cli", + "sourcePath": "skills/orca-cli", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 32, + "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", + "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", + "files": [ + { + "path": "SKILL.md", + "size": 21557, + "executable": false, + "classification": "text", + "exactSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "textNormalizedSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "identitySha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be" + } + ] + }, + { + "name": "orca-emulator", + "sourcePath": "skills/orca-emulator", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 4, + "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", + "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", + "files": [ + { + "path": "SKILL.md", + "size": 11527, + "executable": false, + "classification": "text", + "exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429" + } + ] + }, + { + "name": "orca-emulator-android", + "sourcePath": "skills/orca-emulator-android", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", + "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", + "files": [ + { + "path": "SKILL.md", + "size": 8886, + "executable": false, + "classification": "text", + "exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332" + } + ] + }, + { + "name": "orca-linear", + "sourcePath": "skills/orca-linear", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", + "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", + "files": [ + { + "path": "SKILL.md", + "size": 10320, + "executable": false, + "classification": "text", + "exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5" + } + ] + }, + { + "name": "orca-per-workspace-env", + "sourcePath": "skills/orca-per-workspace-env", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", + "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", + "files": [ + { + "path": "SKILL.md", + "size": 43769, + "executable": false, + "classification": "text", + "exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7" + } + ] + }, + { + "name": "orchestration", + "sourcePath": "skills/orchestration", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 24, + "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", + "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", + "files": [ + { + "path": "SKILL.md", + "size": 22861, + "executable": false, + "classification": "text", + "exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" + } + ] + } + ] +} diff --git a/resources/skills/release-mapping.json b/resources/skills/release-mapping.json new file mode 100644 index 00000000000..12baa4b6e7f --- /dev/null +++ b/resources/skills/release-mapping.json @@ -0,0 +1,501 @@ +{ + "schemaVersion": 1, + "releases": [ + { + "appVersion": "1.0.81", + "skills": { + "orca-cli": 1 + } + }, + { + "appVersion": "1.0.86", + "skills": { + "orca-cli": 2 + } + }, + { + "appVersion": "1.0.94-rc.0", + "skills": { + "orca-cli": 3 + } + }, + { + "appVersion": "1.3.8-rc.1", + "skills": { + "orca-cli": 4 + } + }, + { + "appVersion": "1.3.12-rc.4", + "skills": { + "orca-cli": 5 + } + }, + { + "appVersion": "1.3.24-rc.2", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.33-rc.3", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "0.0.1", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.35-rc.0", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.38-rc.3", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.38", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.39", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.46-rc.3", + "skills": { + "orca-cli": 7, + "orchestration": 2 + } + }, + { + "appVersion": "1.3.47-rc.0", + "skills": { + "orca-cli": 8, + "orchestration": 3 + } + }, + { + "appVersion": "1.3.49-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 8, + "orchestration": 3 + } + }, + { + "appVersion": "1.4.2-rc.10", + "skills": { + "computer-use": 1, + "orca-cli": 9, + "orchestration": 4 + } + }, + { + "appVersion": "1.4.2", + "skills": { + "computer-use": 1, + "orca-cli": 10, + "orchestration": 5 + } + }, + { + "appVersion": "1.4.3-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 11, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.9-rc.1", + "skills": { + "computer-use": 1, + "orca-cli": 12, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.10-rc.3", + "skills": { + "computer-use": 1, + "orca-cli": 13, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.11-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 14, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.18-rc.2", + "skills": { + "computer-use": 1, + "orca-cli": 15, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.18-rc.3", + "skills": { + "computer-use": 1, + "orca-cli": 16, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.22", + "skills": { + "computer-use": 1, + "orca-cli": 16, + "orchestration": 7 + } + }, + { + "appVersion": "1.4.36-rc.2", + "skills": { + "computer-use": 1, + "orca-cli": 17, + "orchestration": 8 + } + }, + { + "appVersion": "1.4.36-rc.6", + "skills": { + "computer-use": 1, + "orca-cli": 18, + "orchestration": 9 + } + }, + { + "appVersion": "1.4.42-rc.8", + "skills": { + "computer-use": 2, + "orca-cli": 19, + "orchestration": 10 + } + }, + { + "appVersion": "1.4.46-rc.0", + "skills": { + "computer-use": 2, + "orca-cli": 19, + "orchestration": 11 + } + }, + { + "appVersion": "1.4.51-rc.8", + "skills": { + "computer-use": 3, + "orca-cli": 19, + "orchestration": 11 + } + }, + { + "appVersion": "1.4.51-rc.9", + "skills": { + "computer-use": 3, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.65-rc.3", + "skills": { + "computer-use": 3, + "linear-tickets": 1, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.65", + "skills": { + "computer-use": 3, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.66-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 1, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.67-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.68", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 13 + } + }, + { + "appVersion": "1.4.74-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 21, + "orca-emulator": 1, + "orchestration": 14 + } + }, + { + "appVersion": "1.4.78-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 22, + "orca-emulator": 1, + "orchestration": 15 + } + }, + { + "appVersion": "1.4.78-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 23, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.81-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 24, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.81-rc.3", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 25, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.88", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 26, + "orca-emulator": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.90", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 26, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.92-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 27, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.96-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 28, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.105-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.111-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 1, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.124-rc.6", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 2, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.124-rc.9", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 2, + "orchestration": 19 + } + }, + { + "appVersion": "1.4.124", + "skills": { + "computer-use": 3, + "linear-tickets": 4, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 20 + } + }, + { + "appVersion": "1.4.137-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 4, + "orca-cli": 30, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 21 + } + }, + { + "appVersion": "1.4.138-rc.0", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 22 + } + }, + { + "appVersion": "1.4.138-rc.3", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 23 + } + }, + { + "appVersion": "1.4.138-rc.4", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 24 + } + }, + { + "appVersion": "1.4.141", + "skills": { + "computer-use": 5, + "linear-tickets": 4, + "orca-cli": 32, + "orca-emulator": 4, + "orca-emulator-android": 2, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 24 + } + } + ] +} diff --git a/resources/skills/snapshot-registry.json b/resources/skills/snapshot-registry.json new file mode 100644 index 00000000000..58d6c7f6c0e --- /dev/null +++ b/resources/skills/snapshot-registry.json @@ -0,0 +1,1239 @@ +{ + "schemaVersion": 1, + "skills": { + "orca-cli": [ + { + "releaseRevision": 1, + "packageDigest": "a3831a8a5561b24f085cb3c185c23ccc368970898119d90723aa3406778aecb6", + "gitTreeSha": "aed90e508a1acac62d3f22ceba3afa5489cffb84", + "files": [ + { + "path": "SKILL.md", + "size": 5547, + "executable": false, + "classification": "text", + "exactSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1", + "textNormalizedSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1", + "identitySha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "e0ed74622f5f77a6339f6f2632dc6edb0b724c7f69a4d2ffecaa45bd0995865a", + "gitTreeSha": "d9d879b3040843ae434462db46939b1eac410304", + "files": [ + { + "path": "SKILL.md", + "size": 5850, + "executable": false, + "classification": "text", + "exactSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb", + "textNormalizedSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb", + "identitySha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "0032941edd38a56de97a56f50c7688e38bcb566735310a9321bb8a7959a06878", + "gitTreeSha": "57ab93e99a12dc947d4329ef7d1227d24bf3dd26", + "files": [ + { + "path": "SKILL.md", + "size": 8871, + "executable": false, + "classification": "text", + "exactSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681", + "textNormalizedSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681", + "identitySha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "d30e6b53ff35008ffe467e2557326326575ac5d37fab88019b1d8c2d6fb8eeba", + "gitTreeSha": "f43280abdf0e484966ce840600a6cae4963f9d64", + "files": [ + { + "path": "SKILL.md", + "size": 24622, + "executable": false, + "classification": "text", + "exactSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56", + "textNormalizedSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56", + "identitySha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "1f2ae2e3922a93e020feebfa799b993557fead2e8c83ce03b5646767ea354f19", + "gitTreeSha": "f8a840864ad01f1c3892a1bf05fb699c5f4543e5", + "files": [ + { + "path": "SKILL.md", + "size": 26576, + "executable": false, + "classification": "text", + "exactSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc", + "textNormalizedSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc", + "identitySha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc" + } + ] + }, + { + "releaseRevision": 6, + "packageDigest": "6e77196af8e6b9123861d7f09c0727e3f982722940eaff2ca53521d76134362f", + "gitTreeSha": "35f50f11a68d2f8f532df28d677ba1b0bc71b740", + "files": [ + { + "path": "SKILL.md", + "size": 26970, + "executable": false, + "classification": "text", + "exactSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a", + "textNormalizedSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a", + "identitySha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a" + } + ] + }, + { + "releaseRevision": 7, + "packageDigest": "d15b79aee7d15f1cc089467284f2454a8ad4f351c08e1e3758e8c58fdcf07b02", + "gitTreeSha": "d7788dc3cd1f1ed705981299c7b8ff9b26cdd5e6", + "files": [ + { + "path": "SKILL.md", + "size": 27011, + "executable": false, + "classification": "text", + "exactSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1", + "textNormalizedSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1", + "identitySha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1" + } + ] + }, + { + "releaseRevision": 8, + "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8", + "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774", + "files": [ + { + "path": "SKILL.md", + "size": 27038, + "executable": false, + "classification": "text", + "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347" + } + ] + }, + { + "releaseRevision": 9, + "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233", + "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde", + "files": [ + { + "path": "SKILL.md", + "size": 27197, + "executable": false, + "classification": "text", + "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e" + } + ] + }, + { + "releaseRevision": 10, + "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8", + "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774", + "files": [ + { + "path": "SKILL.md", + "size": 27038, + "executable": false, + "classification": "text", + "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347" + } + ] + }, + { + "releaseRevision": 11, + "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233", + "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde", + "files": [ + { + "path": "SKILL.md", + "size": 27197, + "executable": false, + "classification": "text", + "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e" + } + ] + }, + { + "releaseRevision": 12, + "packageDigest": "962904e301eef6917af25eccbfe74ab8a119503749030e44c67fd34415d85002", + "gitTreeSha": "4fde9a02a5a77e7ff1a47c04819b764378557a28", + "files": [ + { + "path": "SKILL.md", + "size": 28908, + "executable": false, + "classification": "text", + "exactSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137", + "textNormalizedSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137", + "identitySha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137" + } + ] + }, + { + "releaseRevision": 13, + "packageDigest": "d73f45feb6ccf88947aba3095e9ae36c2c02e9fb4dfeaba67f3f8e3af1cb6f74", + "gitTreeSha": "b5c98b55e0b10e4525d63f5a8df3bc95b62158e6", + "files": [ + { + "path": "SKILL.md", + "size": 30358, + "executable": false, + "classification": "text", + "exactSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07", + "textNormalizedSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07", + "identitySha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07" + } + ] + }, + { + "releaseRevision": 14, + "packageDigest": "d1adae602b254aa2de6d65d09066ca1dcd9e89685bc1ba7c72e9964beb3f6539", + "gitTreeSha": "b545ca22a27b8cfff80fd2d33c77db8b03efd7ed", + "files": [ + { + "path": "SKILL.md", + "size": 30787, + "executable": false, + "classification": "text", + "exactSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1", + "textNormalizedSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1", + "identitySha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1" + } + ] + }, + { + "releaseRevision": 15, + "packageDigest": "23a6bb7a6069a8c4d6506aaa688a44c8421699e3575bb8c73185561579c1f623", + "gitTreeSha": "8b7caa9655971745a8ea782f6d05d76ab0f3812c", + "files": [ + { + "path": "SKILL.md", + "size": 31985, + "executable": false, + "classification": "text", + "exactSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d", + "textNormalizedSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d", + "identitySha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d" + } + ] + }, + { + "releaseRevision": 16, + "packageDigest": "c882bcd1293ef6f601e7f48199f6e755692f668419f8c2098e00757d9349d557", + "gitTreeSha": "22de521546df142d7fd540dd3a329462c548e86b", + "files": [ + { + "path": "SKILL.md", + "size": 32276, + "executable": false, + "classification": "text", + "exactSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c", + "textNormalizedSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c", + "identitySha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c" + } + ] + }, + { + "releaseRevision": 17, + "packageDigest": "a1bfdab91a68b3441b548de158e2c6a56452a499600625c6a9a3523d325837ec", + "gitTreeSha": "c694b183230bface19ef8ec9218726f82e391f69", + "files": [ + { + "path": "SKILL.md", + "size": 32853, + "executable": false, + "classification": "text", + "exactSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672", + "textNormalizedSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672", + "identitySha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672" + } + ] + }, + { + "releaseRevision": 18, + "packageDigest": "afb5026bf156e8d411898ecc84c48348010129558499a18240194b0d5533028c", + "gitTreeSha": "3b9164e0fba50923c9ce3a4a298a9c8b81677291", + "files": [ + { + "path": "SKILL.md", + "size": 33310, + "executable": false, + "classification": "text", + "exactSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f", + "textNormalizedSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f", + "identitySha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f" + } + ] + }, + { + "releaseRevision": 19, + "packageDigest": "2c07a8155f0a0f3fdc6a038c25e0758486dc2ce19d8e0f5bb19d4722f6acbae7", + "gitTreeSha": "dc8ef30fef07f81167e948a9bfc90d14670ea10c", + "files": [ + { + "path": "SKILL.md", + "size": 11055, + "executable": false, + "classification": "text", + "exactSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b", + "textNormalizedSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b", + "identitySha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b" + } + ] + }, + { + "releaseRevision": 20, + "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1", + "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4", + "files": [ + { + "path": "SKILL.md", + "size": 13423, + "executable": false, + "classification": "text", + "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea" + } + ] + }, + { + "releaseRevision": 21, + "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9", + "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4", + "files": [ + { + "path": "SKILL.md", + "size": 13764, + "executable": false, + "classification": "text", + "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c" + } + ] + }, + { + "releaseRevision": 22, + "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1", + "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4", + "files": [ + { + "path": "SKILL.md", + "size": 13423, + "executable": false, + "classification": "text", + "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea" + } + ] + }, + { + "releaseRevision": 23, + "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9", + "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4", + "files": [ + { + "path": "SKILL.md", + "size": 13764, + "executable": false, + "classification": "text", + "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c" + } + ] + }, + { + "releaseRevision": 24, + "packageDigest": "30b067d09f9f9ee9c048f0b3d809c25e269fd46cc948486cdb66690205ae8c4b", + "gitTreeSha": "833e5ce46df5bf55d29fbbd3bd710aa2dfe6b5c4", + "files": [ + { + "path": "SKILL.md", + "size": 14298, + "executable": false, + "classification": "text", + "exactSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f", + "textNormalizedSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f", + "identitySha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f" + } + ] + }, + { + "releaseRevision": 25, + "packageDigest": "2ae59c0bea7f2a12ef0c78e3cba65895ff67b311b40b97b46223460b4d1a7acd", + "gitTreeSha": "ba4e183d53b70fd01b763a9cc19f757550a18e2e", + "files": [ + { + "path": "SKILL.md", + "size": 14320, + "executable": false, + "classification": "text", + "exactSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4", + "textNormalizedSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4", + "identitySha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4" + } + ] + }, + { + "releaseRevision": 26, + "packageDigest": "5d7bdc664990cd84771eceea9393b0a566aee4ad9cdbf0883910cd8d4d46d5ac", + "gitTreeSha": "6b459e105bb77cbfa67ba0086f3afcbe32e12093", + "files": [ + { + "path": "SKILL.md", + "size": 14636, + "executable": false, + "classification": "text", + "exactSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce", + "textNormalizedSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce", + "identitySha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce" + } + ] + }, + { + "releaseRevision": 27, + "packageDigest": "94f535ede189381ae1cf1a7cf6effee03335b2e98e9bdf57fed72dcba39036e3", + "gitTreeSha": "1b02d6a7c06465e3ba6661bef201c6db6552c9e6", + "files": [ + { + "path": "SKILL.md", + "size": 14829, + "executable": false, + "classification": "text", + "exactSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835", + "textNormalizedSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835", + "identitySha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835" + } + ] + }, + { + "releaseRevision": 28, + "packageDigest": "d70d2e70a9ac4ae8cf8ec5cb958524521842d558e40ea24dd882b90a846a2cdc", + "gitTreeSha": "3d734f7f385d66ab408c56e8814b9c115d53556c", + "files": [ + { + "path": "SKILL.md", + "size": 15055, + "executable": false, + "classification": "text", + "exactSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995", + "textNormalizedSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995", + "identitySha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995" + } + ] + }, + { + "releaseRevision": 29, + "packageDigest": "ca9d1d5e2ad2dc6814be80af642c5fb485644713e9bd7ef19acd36d4228b94c5", + "gitTreeSha": "d1175a5a5807a6783a3d896f720645b39f736dbb", + "files": [ + { + "path": "SKILL.md", + "size": 17257, + "executable": false, + "classification": "text", + "exactSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e", + "textNormalizedSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e", + "identitySha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e" + } + ] + }, + { + "releaseRevision": 30, + "packageDigest": "4994ac99e6563e53f85c404d52470ab05945679ecb3e0f6a1ac4dbba83d70b35", + "gitTreeSha": "91c0234c3e64dfcf45ac1515ecc77c973a7af185", + "files": [ + { + "path": "SKILL.md", + "size": 19660, + "executable": false, + "classification": "text", + "exactSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368", + "textNormalizedSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368", + "identitySha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368" + } + ] + }, + { + "releaseRevision": 31, + "packageDigest": "c52a999a35f8e96ec72c97a537ce5906ef098879495382d85f50c70a888ba901", + "gitTreeSha": "77be03ea57ddbc4c4067bca22ab58c3af5ae3d2c", + "files": [ + { + "path": "SKILL.md", + "size": 20862, + "executable": false, + "classification": "text", + "exactSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14", + "textNormalizedSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14", + "identitySha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14" + } + ] + }, + { + "releaseRevision": 32, + "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", + "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", + "files": [ + { + "path": "SKILL.md", + "size": 21557, + "executable": false, + "classification": "text", + "exactSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "textNormalizedSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "identitySha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be" + } + ] + } + ], + "orchestration": [ + { + "releaseRevision": 1, + "packageDigest": "427a9ae737ae258ccf2c1f7659549da1b6b00a65c1fad8ac9c6baf4f42af8c4e", + "gitTreeSha": "13840d1ad238a462bdc34f7fca11cb4d224b514c", + "files": [ + { + "path": "SKILL.md", + "size": 12589, + "executable": false, + "classification": "text", + "exactSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75", + "textNormalizedSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75", + "identitySha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "122aa952ff591cddbda47767a7de738b05dbeb0c5d26065010bcb6b788ef329d", + "gitTreeSha": "2c0e80be2d5ff6760f461711179d9fd94a7454b2", + "files": [ + { + "path": "SKILL.md", + "size": 12972, + "executable": false, + "classification": "text", + "exactSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca", + "textNormalizedSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca", + "identitySha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab", + "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff", + "files": [ + { + "path": "SKILL.md", + "size": 13003, + "executable": false, + "classification": "text", + "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049", + "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0", + "files": [ + { + "path": "SKILL.md", + "size": 13167, + "executable": false, + "classification": "text", + "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab", + "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff", + "files": [ + { + "path": "SKILL.md", + "size": 13003, + "executable": false, + "classification": "text", + "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee" + } + ] + }, + { + "releaseRevision": 6, + "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049", + "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0", + "files": [ + { + "path": "SKILL.md", + "size": 13167, + "executable": false, + "classification": "text", + "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059" + } + ] + }, + { + "releaseRevision": 7, + "packageDigest": "0c9848cc3d1e135f6d0105c49ec6f16a126e675125408744d9e6c940645fd427", + "gitTreeSha": "d12449bc4ba94699a9c9ec636a15ad3b6f095bfd", + "files": [ + { + "path": "SKILL.md", + "size": 12749, + "executable": false, + "classification": "text", + "exactSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223", + "textNormalizedSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223", + "identitySha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223" + } + ] + }, + { + "releaseRevision": 8, + "packageDigest": "fbb016bd4ec67677fea3ffa16aacc2e95d13968b33520409385bef5d1a025c16", + "gitTreeSha": "413ae24e6011f86372aa44c8611d8fe76d936f8a", + "files": [ + { + "path": "SKILL.md", + "size": 12795, + "executable": false, + "classification": "text", + "exactSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1", + "textNormalizedSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1", + "identitySha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1" + } + ] + }, + { + "releaseRevision": 9, + "packageDigest": "cb991258e829f2a1256bcaf4f90018046d2bc91b5fba301bd12b046e9e2e6e02", + "gitTreeSha": "820faebf9aa70e7c1792909ceb9fbf35db93795a", + "files": [ + { + "path": "SKILL.md", + "size": 15044, + "executable": false, + "classification": "text", + "exactSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6", + "textNormalizedSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6", + "identitySha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6" + } + ] + }, + { + "releaseRevision": 10, + "packageDigest": "0d8470040c3c15570fe2745fc159b5ff57b5a229ec4e27ea600a8ced594c0455", + "gitTreeSha": "5113d720ff1a96ed158befcb8752f3a26aadd161", + "files": [ + { + "path": "SKILL.md", + "size": 10338, + "executable": false, + "classification": "text", + "exactSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce", + "textNormalizedSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce", + "identitySha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce" + } + ] + }, + { + "releaseRevision": 11, + "packageDigest": "9008c30bdf8a20857642968753013bba4961a6297b02d31afe65105606b0bcfd", + "gitTreeSha": "993c9e1861ad97047e34ab82e5dbd045e888a4a5", + "files": [ + { + "path": "SKILL.md", + "size": 10348, + "executable": false, + "classification": "text", + "exactSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7", + "textNormalizedSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7", + "identitySha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7" + } + ] + }, + { + "releaseRevision": 12, + "packageDigest": "c2a45891a381db4376a61f2e0f170cd512883e0f817a5c28266b744e04d00006", + "gitTreeSha": "2f7404841129a80c8b9add53c761f0ff11174dae", + "files": [ + { + "path": "SKILL.md", + "size": 10973, + "executable": false, + "classification": "text", + "exactSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4", + "textNormalizedSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4", + "identitySha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4" + } + ] + }, + { + "releaseRevision": 13, + "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8", + "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c", + "files": [ + { + "path": "SKILL.md", + "size": 11536, + "executable": false, + "classification": "text", + "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab" + } + ] + }, + { + "releaseRevision": 14, + "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1", + "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f", + "files": [ + { + "path": "SKILL.md", + "size": 12177, + "executable": false, + "classification": "text", + "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a" + } + ] + }, + { + "releaseRevision": 15, + "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8", + "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c", + "files": [ + { + "path": "SKILL.md", + "size": 11536, + "executable": false, + "classification": "text", + "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab" + } + ] + }, + { + "releaseRevision": 16, + "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1", + "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f", + "files": [ + { + "path": "SKILL.md", + "size": 12177, + "executable": false, + "classification": "text", + "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a" + } + ] + }, + { + "releaseRevision": 17, + "packageDigest": "f51066a029f17a1d87f7ac587df9850440cde08f5bdda80cb810a100ea45a169", + "gitTreeSha": "df7b970dda7e03e87bd9fd1dfe62c9aaf8769adc", + "files": [ + { + "path": "SKILL.md", + "size": 13415, + "executable": false, + "classification": "text", + "exactSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb", + "textNormalizedSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb", + "identitySha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb" + } + ] + }, + { + "releaseRevision": 18, + "packageDigest": "77663355eef8ed82ff6918d9745bfecc4a50550ea86edc0f2a151f989c205a23", + "gitTreeSha": "7ff543c1701d55ffb5dffb2cd8dc5dd3fc47c700", + "files": [ + { + "path": "SKILL.md", + "size": 17357, + "executable": false, + "classification": "text", + "exactSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a", + "textNormalizedSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a", + "identitySha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a" + } + ] + }, + { + "releaseRevision": 19, + "packageDigest": "3318ddeba58457774a0436b6d2d1e722b8d81e2ab3c67cd8409bfbd8efe62a5f", + "gitTreeSha": "90a57c3d0135992369caa36493ca04278e02e55a", + "files": [ + { + "path": "SKILL.md", + "size": 17600, + "executable": false, + "classification": "text", + "exactSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628", + "textNormalizedSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628", + "identitySha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628" + } + ] + }, + { + "releaseRevision": 20, + "packageDigest": "15ec10fdb480836bedc166c54ea15c10fb1e66e7c480f19300ad1227abe2b34d", + "gitTreeSha": "189912ad39f48b0a71f1cf0ee87abfcd55f90716", + "files": [ + { + "path": "SKILL.md", + "size": 19701, + "executable": false, + "classification": "text", + "exactSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a", + "textNormalizedSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a", + "identitySha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a" + } + ] + }, + { + "releaseRevision": 21, + "packageDigest": "211f80c301de8afa2623dac3fde79f9ada8ec99a817f54a56dbb3cb43e4325d7", + "gitTreeSha": "a96dcd29ab8618eccc54c5c433f0b2378e50f698", + "files": [ + { + "path": "SKILL.md", + "size": 21546, + "executable": false, + "classification": "text", + "exactSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67", + "textNormalizedSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67", + "identitySha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67" + } + ] + }, + { + "releaseRevision": 22, + "packageDigest": "c8eddb2814dda4c4b000048e628f147ef730088f8c9628afb04529a4863a410b", + "gitTreeSha": "ad1024ebab1f464a743d1c91a7005211beed0917", + "files": [ + { + "path": "SKILL.md", + "size": 21701, + "executable": false, + "classification": "text", + "exactSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10", + "textNormalizedSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10", + "identitySha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10" + } + ] + }, + { + "releaseRevision": 23, + "packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b", + "gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2", + "files": [ + { + "path": "SKILL.md", + "size": 22850, + "executable": false, + "classification": "text", + "exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9", + "textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9", + "identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9" + } + ] + }, + { + "releaseRevision": 24, + "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", + "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", + "files": [ + { + "path": "SKILL.md", + "size": 22861, + "executable": false, + "classification": "text", + "exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" + } + ] + } + ], + "mobile-fit-debug": [ + { + "releaseRevision": 1, + "packageDigest": "97ff3f85018bdea5e4518325423b159708eabe16cde3fbf7297525fa084d6a46", + "gitTreeSha": "9ff10aaeea448fa956cd4708b7f3b8242d178c0a", + "files": [ + { + "path": "SKILL.md", + "size": 9414, + "executable": false, + "classification": "text", + "exactSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab", + "textNormalizedSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab", + "identitySha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab" + } + ] + } + ], + "computer-use": [ + { + "releaseRevision": 1, + "packageDigest": "7c844c42b8e8c1a413cb8755b11a3b89fb3dc6c5a349d7aa63f269df975c1ccb", + "gitTreeSha": "ac1f6e3de0cbd9b488c5cfb1d4c5c076e17afea0", + "files": [ + { + "path": "SKILL.md", + "size": 8514, + "executable": false, + "classification": "text", + "exactSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc", + "textNormalizedSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc", + "identitySha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "6c882a35e50a00c49d2f87647c54d401fe2af60a95dc3d17a14bf3cf879e5547", + "gitTreeSha": "d83929742fd30aa3d4ab82ed1be4000acbb9cce0", + "files": [ + { + "path": "SKILL.md", + "size": 6368, + "executable": false, + "classification": "text", + "exactSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c", + "textNormalizedSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c", + "identitySha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "747b9bbe7d08d0c6fc69a47c02064de73fa09ab755244b40a6253aa810d015f7", + "gitTreeSha": "b3fb19187d9775774df2a93d59e9e0003093e4a2", + "files": [ + { + "path": "SKILL.md", + "size": 10454, + "executable": false, + "classification": "text", + "exactSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601", + "textNormalizedSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601", + "identitySha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "35b6d91d1d3d38f3828c60e5b856a91324bdd09273db33664f508392c5c48dfc", + "gitTreeSha": "b4e2d7e7af8f8f29c6d0df6e92f23c1b2c439e3b", + "files": [ + { + "path": "SKILL.md", + "size": 10625, + "executable": false, + "classification": "text", + "exactSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b", + "textNormalizedSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b", + "identitySha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", + "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", + "files": [ + { + "path": "SKILL.md", + "size": 11241, + "executable": false, + "classification": "text", + "exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39" + } + ] + } + ], + "orca-emulator": [ + { + "releaseRevision": 1, + "packageDigest": "fdecfa3957c5502ec5f4933504f7112ab98c2c3b7b8902aad34db6cb1b7dc28a", + "gitTreeSha": "6b6b92ea67eec1661e45a8b37bf8014542f05417", + "files": [ + { + "path": "SKILL.md", + "size": 10715, + "executable": false, + "classification": "text", + "exactSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045", + "textNormalizedSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045", + "identitySha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "5a25152a06a88be0361f47cc37325e06ab840e81df0092cfea82f5e20e50978b", + "gitTreeSha": "5575f765c3dc31f8bc57032ca3716df38826eabd", + "files": [ + { + "path": "SKILL.md", + "size": 10717, + "executable": false, + "classification": "text", + "exactSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712", + "textNormalizedSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712", + "identitySha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "1807d287d2c08b1c535ba4e14ca2f29afe39274c4bde5737995a1792e44d5412", + "gitTreeSha": "dbb8e0daba768527885715ee5efebeaa1ee38f0f", + "files": [ + { + "path": "SKILL.md", + "size": 10853, + "executable": false, + "classification": "text", + "exactSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834", + "textNormalizedSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834", + "identitySha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", + "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", + "files": [ + { + "path": "SKILL.md", + "size": 11527, + "executable": false, + "classification": "text", + "exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429" + } + ] + } + ], + "linear-tickets": [ + { + "releaseRevision": 1, + "packageDigest": "d70fb0e7640182b1b193969c96d2ff0d70821f59f71945915709ae95ddc4e350", + "gitTreeSha": "bb8c28593807f05b5007f89ccc86f9c054728882", + "files": [ + { + "path": "SKILL.md", + "size": 6271, + "executable": false, + "classification": "text", + "exactSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1", + "textNormalizedSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1", + "identitySha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "cc5901e9ce67829d909ab97910eb22825ca58547d215c42b665b8ff7401d5a05", + "gitTreeSha": "954d226df97ba0034002887bc9d23221667d45f0", + "files": [ + { + "path": "SKILL.md", + "size": 8985, + "executable": false, + "classification": "text", + "exactSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2", + "textNormalizedSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2", + "identitySha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "948919e8f2b37157c40009f865fa43fc925e56d0c30b544d0508cec823c26b15", + "gitTreeSha": "e00b39a2c9b37916e40b539e08341274bd8db2f7", + "files": [ + { + "path": "SKILL.md", + "size": 9670, + "executable": false, + "classification": "text", + "exactSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b", + "textNormalizedSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b", + "identitySha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", + "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", + "files": [ + { + "path": "SKILL.md", + "size": 10596, + "executable": false, + "classification": "text", + "exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5" + } + ] + } + ], + "orca-linear": [ + { + "releaseRevision": 1, + "packageDigest": "9015e708218fe86620eee39041d3e4f9ddd4b67ff2c6d049beca9ffe3783e03d", + "gitTreeSha": "bc547faebffa3f5645b7d2aad32560d25d836b7f", + "files": [ + { + "path": "SKILL.md", + "size": 9394, + "executable": false, + "classification": "text", + "exactSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f", + "textNormalizedSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f", + "identitySha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", + "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", + "files": [ + { + "path": "SKILL.md", + "size": 10320, + "executable": false, + "classification": "text", + "exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5" + } + ] + } + ], + "orca-emulator-android": [ + { + "releaseRevision": 1, + "packageDigest": "cf86dd085b981febd8e4e11b7796c90d69a60bd9c1dfaab5a16b99f77841aaa4", + "gitTreeSha": "6299122f69f0218ec01214e2a14c479b1811cdf6", + "files": [ + { + "path": "SKILL.md", + "size": 8189, + "executable": false, + "classification": "text", + "exactSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844", + "textNormalizedSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844", + "identitySha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", + "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", + "files": [ + { + "path": "SKILL.md", + "size": 8886, + "executable": false, + "classification": "text", + "exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332" + } + ] + } + ], + "orca-per-workspace-env": [ + { + "releaseRevision": 1, + "packageDigest": "dc7f471327f55b27a40cd9575ca476fea7a79c2aba74881af00ce4e468cbf714", + "gitTreeSha": "8efb9872787bf874c558393b9a651df91604650c", + "files": [ + { + "path": "SKILL.md", + "size": 37692, + "executable": false, + "classification": "text", + "exactSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc", + "textNormalizedSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc", + "identitySha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", + "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", + "files": [ + { + "path": "SKILL.md", + "size": 43769, + "executable": false, + "classification": "text", + "exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7" + } + ] + } + ] + } +} diff --git a/resources/tray/orca-menu-barTemplate.png b/resources/tray/orca-menu-barTemplate.png new file mode 100644 index 00000000000..b98b90f2fda Binary files /dev/null and b/resources/tray/orca-menu-barTemplate.png differ diff --git a/resources/tray/orca-menu-barTemplate@2x.png b/resources/tray/orca-menu-barTemplate@2x.png new file mode 100644 index 00000000000..fffb571fe76 Binary files /dev/null and b/resources/tray/orca-menu-barTemplate@2x.png differ diff --git a/src/cli/args.ts b/src/cli/args.ts index a3dc0df5349..4392d64314c 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -58,6 +58,7 @@ export const BOOLEAN_FLAGS = new Set([ 'run-hooks', 'show-profile', 'staged', + 'tab', 'tasks', 'text-stdin', 'unread', diff --git a/src/cli/handlers/terminal.test.ts b/src/cli/handlers/terminal.test.ts new file mode 100644 index 00000000000..7dadf41c2d3 --- /dev/null +++ b/src/cli/handlers/terminal.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeClient } from '../runtime-client' +import { parseArgs } from '../args' +import { printHelp } from '../help' +import { COMMAND_SPECS } from '../specs' +import { TERMINAL_HANDLERS } from './terminal' + +describe('terminal close CLI', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps the default close RPC unchanged', async () => { + const call = vi.fn().mockResolvedValue({ + result: { close: { handle: 'term-1', tabId: 'tab-1', ptyKilled: true } } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal close']({ + flags: new Map([['terminal', 'term-1']]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + + expect(call).toHaveBeenCalledWith('terminal.close', { terminal: 'term-1' }) + }) + + it('routes --tab to the durable whole-tab RPC', async () => { + const parsed = parseArgs(['terminal', 'close', '--terminal', 'term-1', '--tab']) + const call = vi.fn().mockResolvedValue({ + result: { + close: { + handle: 'term-1', + tabId: 'tab-1', + closeMode: 'tab', + ptyKilled: false + } + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal close']({ + flags: parsed.flags, + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + + expect(parsed.flags.get('tab')).toBe(true) + expect(call).toHaveBeenCalledWith('terminal.closeTab', { terminal: 'term-1' }) + }) + + it('documents that --tab waits for durable persistence', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + printHelp(COMMAND_SPECS, ['terminal', 'close']) + + const help = String(log.mock.calls[0]?.[0]) + expect(help).toContain('orca terminal close [--terminal ] [--tab] [--json]') + expect(help).toContain('durable persistence') + }) +}) diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index fb2eefc3748..1359f5cce13 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -150,7 +150,8 @@ export const TERMINAL_HANDLERS: Record = { // `focus` resolves to this canonical path via CommandSpec.aliases before dispatch. 'terminal switch': terminalFocusHandler, 'terminal close': async ({ flags, client, cwd, json }) => { - const result = await client.call<{ close: RuntimeTerminalClose }>('terminal.close', { + const method = flags.get('tab') === true ? 'terminal.closeTab' : 'terminal.close' + const result = await client.call<{ close: RuntimeTerminalClose }>(method, { terminal: await getTerminalHandle(flags, cwd, client) }) printResult(result, json, formatTerminalClose) diff --git a/src/cli/help.ts b/src/cli/help.ts index 379bfbce1e0..11d9fd2dc20 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -82,7 +82,7 @@ Terminals: terminal split Split an existing terminal pane terminal switch Bring a terminal tab to the foreground terminal focus Alias for terminal switch - terminal close Close a terminal pane (or tab if last pane) + terminal close Close a terminal pane/session, or its whole tab with --tab Orchestration: orchestration send Send an inter-agent message @@ -224,7 +224,7 @@ Common Commands: orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json] orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] orca terminal switch [--terminal ] [--json] - orca terminal close [--terminal ] [--json] + orca terminal close [--terminal ] [--tab] [--json] orca project list [--json] orca project setups [--project ] [--host ] [--json] orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json] @@ -401,6 +401,9 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string { function formatCommandFlagHelp(flag: string, commandPath: string[]): string { const command = commandPath.join(' ') + if (command === 'terminal close' && flag === 'tab') { + return '--tab Close the whole tab and wait for durable persistence' + } if (command === 'linear issue' && flag === 'id') { return '--id Linear issue key, id, or URL' } diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 3c89c5ff3a5..41d001daf58 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -254,10 +254,16 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['terminal', 'close'], - summary: 'Close a terminal tab (kills PTY if running)', - usage: 'orca terminal close [--terminal ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal'], - examples: ['orca terminal close --terminal term_abc123'] + summary: 'Close a terminal pane/session, or its whole tab with --tab', + usage: 'orca terminal close [--terminal ] [--tab] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'tab'], + notes: [ + 'Without --tab, preserves the existing pane/session close behavior. With --tab, waits until the whole tab is durably removed.' + ], + examples: [ + 'orca terminal close --terminal term_abc123', + 'orca terminal close --terminal term_abc123 --tab --json' + ] }, { path: ['terminal', 'rename'], diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index a0791d6e038..a6fbd6a9514 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -168,6 +168,9 @@ export function formatTerminalFocus(result: { focus: RuntimeTerminalFocus }): st } export function formatTerminalClose(result: { close: RuntimeTerminalClose }): string { + if (result.close.closeMode === 'tab') { + return `Closed terminal tab ${result.close.tabId} (${result.close.handle}).` + } const ptyNote = result.close.ptyKilled ? ' PTY killed.' : '' return `Closed terminal ${result.close.handle}.${ptyNote}` } diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 7423be836dd..1239e6dd9eb 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -6046,6 +6046,62 @@ describe('Last-status persistence', () => { } }) + it('drops persisted idle Claude child rows from hydration replay', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const receivedAt = recentTs() + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [PANE]: { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt: recentTs(-1000), + payload: { + state: 'done', + prompt: 'finished orchestration', + agentType: 'claude', + subagents: [ + { id: 'aweb-research-8a76b7d7', state: 'idle', startedAt: receivedAt - 5000 }, + { id: 'apr-history-9b87c6e6', state: 'idle', startedAt: receivedAt - 4000 } + ] + } + } + } + }), + 'utf8' + ) + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const listener = vi.fn() + server.setListener(listener) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ subagents: undefined }) + }) + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'finished orchestration', + subagents: undefined + }) + ]) + // Why: make the migration one-time; otherwise every launch reparses and + // re-prunes the same persisted idle rows. + const persisted = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(persisted.entries[PANE].payload.subagents).toBeUndefined() + } finally { + server.stop() + } + }) + it('treats a corrupt file as empty hydration without throwing', async () => { mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) writeFileSync(lastStatusPath(), 'not-json{{', 'utf8') diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 513735bd679..4011fdabda6 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -174,6 +174,24 @@ export function isValidPaneKey(value: unknown): value is string { ) } +function dropHydratedIdleClaudeSubagents( + payload: ParsedAgentStatusPayload +): ParsedAgentStatusPayload { + if ( + payload.agentType !== 'claude' || + !payload.subagents?.some((subagent) => subagent.state === 'idle') + ) { + return payload + } + const workingSubagents = payload.subagents.filter((subagent) => subagent.state === 'working') + // Why: older builds persisted finished Claude children as idle rows. Prune + // them from the replay payload itself so restart cannot resurrect the pile. + return { + ...payload, + subagents: workingSubagents.length > 0 ? workingSubagents : undefined + } +} + function sanitizeHydratedEntry( paneKey: string, rawEntry: unknown @@ -1758,6 +1776,7 @@ export class AgentHookServer { } let hydrated = 0 let dropped = 0 + let prunedLegacyClaudeSubagents = 0 // Why: bound disk growth — drop anything older than HYDRATE_MAX_AGE_MS so // entries from worktrees archived weeks ago do not pile up forever. Use // Date.now() once to keep the cutoff consistent across all entries this @@ -1771,11 +1790,15 @@ export class AgentHookServer { : { ...(rawEntry as Record), paneKey: resolvedPaneKey } const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) if (entry && entry.receivedAt >= ttlCutoff) { + const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload) + if (hydratedPayload !== entry.payload) { + prunedLegacyClaudeSubagents += + (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) + entry.payload = hydratedPayload + } this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) - // Why: the in-memory subagent roster died with the previous process. - // Reseed it from the persisted snapshot so the next teammate-bearing - // Stop (whose task ids never match lifecycle ids) doesn't silently - // drop the replayed child rows. + // Why: preserve only working children across restart. Live activity + // confirms them; a later complete inventory may reap stale seeds. if (entry.payload.subagents) { seedClaudeSubagentRosterFromSnapshots( this.state, @@ -1793,18 +1816,16 @@ export class AgentHookServer { `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})` ) } - if (hydrated > 0 && dropped === 0) { + if (dropped > 0 || prunedLegacyClaudeSubagents > 0) { + // Why: persist load-time pruning once so legacy idle rows do not consume + // parse/filter work again on every launch. + this.runStatusPersist() + } else if (hydrated > 0) { // Why: prime from the raw on-disk bytes (not a re-serialization) so the // dedup is robust against future shape drift in serializeStatusFile. // Only prime when hydration was lossless — if entries were dropped // during sanitize, the in-memory map diverges from the on-disk bytes. this.lastWrittenJson = raw - } else if (dropped > 0) { - // Why: clean the stale on-disk file now so a user who has not run an - // agent in 8+ days does not re-drop the same entries on every cold - // boot. Synchronous variant is safe at start time and avoids - // unref'd-timer-during-quit edge cases. - this.runStatusPersist() } } diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts new file mode 100644 index 00000000000..1791baa82cd --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -0,0 +1,168 @@ +import { extname } from 'node:path' +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileStat, IFilesystemProvider } from '../providers/types' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' +import type { FileWithMtime } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import type { + RemoteScannerContext, + RemoteSessionCandidate, + RemoteSessionSource +} from './remote-session-scanner-types' + +const REMOTE_DISCOVERY_CONCURRENCY = 8 + +export async function discoverRemoteSourceCandidates(args: { + source: RemoteSessionSource + context: RemoteScannerContext + issues: AiVaultScanIssue[] +}): Promise { + const walked = args.source.fixedChildFileSegments + ? await listRemoteFixedChildFiles(args.source, args.context, args.issues) + : await walkRemoteSessionFiles(args.source, args.context, args.issues) + const partition = args.source.collectSubagentSiblingCounts + ? partitionSubagentTranscriptPaths(walked) + : null + const paths = partition ? partition.sessionFilePaths : walked + const files = await mapDiscoveryConcurrently(paths, (path) => + statRemoteFile( + args.context.provider, + path, + args.source.agent, + args.context.executionHostId, + args.issues, + Boolean(args.source.fixedChildFileSegments) + ) + ) + return files + .filter((file): file is FileWithMtime => Boolean(file)) + .map((file) => ({ + source: args.source, + file, + subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0 + })) +} + +async function listRemoteFixedChildFiles( + source: RemoteSessionSource, + context: RemoteScannerContext, + issues: AiVaultScanIssue[] +): Promise { + let entries + try { + entries = await context.provider.readDir(source.rootDir) + } catch (err) { + recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err) + return [] + } + const segments = source.fixedChildFileSegments ?? [] + // Why: Antigravity's transcript path is fixed. Constructing it avoids three + // serialized SSH readDir round trips for every conversation directory. + return entries + .filter((entry) => entry.isDirectory && !entry.isSymlink) + .map((entry) => joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...segments)) + .filter((path) => source.filePredicate?.(path) ?? true) +} + +async function walkRemoteSessionFiles( + source: RemoteSessionSource, + context: RemoteScannerContext, + issues: AiVaultScanIssue[], + dirPath = source.rootDir, + depth = 0 +): Promise { + let entries + try { + entries = await context.provider.readDir(dirPath) + } catch (err) { + recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err) + return [] + } + + const extensions = new Set(source.extensions) + const files: string[] = [] + for (const entry of entries) { + const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name) + if ( + entry.isDirectory && + !entry.isSymlink && + (source.directoryPredicate?.(entry.name, depth) ?? true) + ) { + files.push(...(await walkRemoteSessionFiles(source, context, issues, fullPath, depth + 1))) + continue + } + if ( + !entry.isSymlink && + extensions.has(extname(entry.name).toLowerCase()) && + (source.filePredicate?.(fullPath) ?? true) + ) { + files.push(fullPath) + } + } + return files +} + +async function statRemoteFile( + provider: IFilesystemProvider, + path: string, + agent: AiVaultAgent, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[], + missingIsExpected: boolean +): Promise { + try { + const stat = await provider.stat(path) + const mtimeMs = remoteStatMtimeMs(stat) + return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } + } catch (err) { + if (!missingIsExpected || !isMissingRemotePathError(err)) { + issues.push({ executionHostId, agent, path, message: errorMessage(err) }) + } + return null + } +} + +function recordRemoteDirectoryIssue( + source: RemoteSessionSource, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[], + path: string, + err: unknown +): void { + if (!isMissingRemotePathError(err)) { + issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) }) + } +} + +function isMissingRemotePathError(err: unknown): boolean { + const code = + err && typeof err === 'object' && 'code' in err && typeof err.code === 'string' + ? err.code + : null + if (code === 'ENOENT' || code === 'ENOTDIR') { + return true + } + // Relay/provider boundaries can preserve only the underlying Node error text. + return /(?:^|[\s:])(ENOENT|ENOTDIR)(?=[\s:]|$)/.test(errorMessage(err)) +} + +function remoteStatMtimeMs(stat: FileStat): number { + if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { + return stat.mtimeMs + } + return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 +} + +async function mapDiscoveryConcurrently( + items: readonly T[], + mapper: (item: T) => Promise +): Promise { + const results: U[] = [] + for (let index = 0; index < items.length; index += REMOTE_DISCOVERY_CONCURRENCY) { + const batch = items.slice(index, index + REMOTE_DISCOVERY_CONCURRENCY) + results.push(...(await Promise.all(batch.map(mapper)))) + } + return results +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index c05261de31d..c33a0ae7701 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -1,6 +1,8 @@ import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser' +import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths' import { parseCodexSessionContent } from './session-scanner-codex-parser' import { parseDevinSessionContent } from './session-scanner-devin-parser' import { parseDroidSessionContent } from './session-scanner-droid-parser' @@ -49,6 +51,7 @@ export function remoteSessionSources( // top-level sessions carrying the parent's sessionId. collectSubagentSiblingCounts: true }, + remoteAntigravitySource(remoteHome, hostPlatform), source( 'gemini', remoteHome, @@ -108,6 +111,30 @@ export function remoteSessionSources( ] } +function remoteAntigravitySource( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource { + const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', 'antigravity-cli') + const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl') + return { + agent: 'antigravity', + rootDir: joinRemotePath(hostPlatform, cliRoot, 'brain'), + extensions: ['.jsonl'], + filePredicate: isAntigravityTranscriptPath, + fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'], + parse: async (file, content, context) => { + const session = await parseAntigravitySessionContent( + file, + content, + context.hostPlatform.os, + parserOptions(context) + ) + return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null + } + } +} + function source( agent: AiVaultAgent, remoteHome: string, @@ -115,13 +142,15 @@ function source( segments: readonly string[], extensions: readonly string[], parseContent: RemoteContentParser, - filePredicate?: (path: string) => boolean + filePredicate?: (path: string) => boolean, + directoryPredicate?: (name: string, depth: number) => boolean ): RemoteSessionSource { return { agent, rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments), extensions, filePredicate, + directoryPredicate, parse: (file, content, context) => Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context))) } diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index c65ef99aded..4da4bd3f4a6 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -3,12 +3,14 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import type { FileWithMtime } from './session-scanner-types' +import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' export type RemoteScannerContext = { provider: IFilesystemProvider executionHostId: ExecutionHostId hostPlatform: RemoteHostPlatform titleCaches: Map>> + antigravityWorkspaceResolver: AntigravityWorkspaceResolver } export type RemoteParserOptions = { @@ -21,6 +23,10 @@ export type RemoteSessionSource = { rootDir: string extensions: readonly string[] filePredicate?: (path: string) => boolean + // Depth 0 denotes a direct child of rootDir. + directoryPredicate?: (name: string, depth: number) => boolean + // A canonical file directly beneath every top-level session directory. + fixedChildFileSegments?: readonly string[] // Claude layout: count `/subagents/*.jsonl` siblings from the walked // listing and drop them from candidates instead of indexing them as sessions. collectSubagentSiblingCounts?: boolean diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index a2280b29165..4f9cbd73bb0 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -6,13 +6,29 @@ import { scanRemoteAiVaultSessions } from './remote-session-scanner' class MemoryRemoteProvider implements IFilesystemProvider { private readonly files = new Map() + private readonly readDirErrors = new Map() + private readonly statErrors = new Map() + readonly readDirPaths: string[] = [] addFile(path: string, content: string, mtimeMs: number): void { this.files.set(normalize(path), { content, mtimeMs }) } + failStat(path: string, error: Error): void { + this.statErrors.set(normalize(path), error) + } + + failReadDir(path: string, error: Error): void { + this.readDirErrors.set(normalize(path), error) + } + async readDir(dirPath: string): Promise { const dir = normalize(dirPath) + this.readDirPaths.push(dir) + const readDirError = this.readDirErrors.get(dir) + if (readDirError) { + throw readDirError + } const prefix = dir.endsWith('/') ? dir : `${dir}/` const entries = new Map() for (const path of this.files.keys()) { @@ -45,6 +61,10 @@ class MemoryRemoteProvider implements IFilesystemProvider { } async stat(filePath: string): Promise { + const statError = this.statErrors.get(normalize(filePath)) + if (statError) { + throw statError + } const file = this.files.get(normalize(filePath)) if (!file) { throw new Error(`ENOENT: ${filePath}`) @@ -201,6 +221,184 @@ describe('scanRemoteAiVaultSessions', () => { }) }) + it('parses only canonical Antigravity transcripts on SSH hosts', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const logsDir = `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs` + provider.addFile( + `${logsDir}/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10Z', + content: 'Fix remote Antigravity history' + }, + { + source: 'MODEL', + type: 'PLANNER_RESPONSE', + created_at: '2026-07-15T11:39:12Z', + content: 'Done' + } + ]), + 50 + ) + provider.addFile(`${logsDir}/transcript_full.jsonl`, 'duplicate', 51) + provider.addFile( + `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/artifacts/task.jsonl`, + 'not a transcript', + 52 + ) + provider.addFile( + '/home/ada/.gemini/antigravity-cli/history.jsonl', + jsonLines([ + { + display: 'Fix remote Antigravity history', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/home/ada/project' + } + ]), + 53 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scopePaths: ['/home/ada/project'] + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + agent: 'antigravity', + sessionId, + title: 'Fix remote Antigravity history', + cwd: '/home/ada/project', + messageCount: 2, + resumeCommand: `agy --conversation '${sessionId}'`, + filePath: `${logsDir}/transcript.jsonl` + }) + expect( + provider.readDirPaths.filter((path) => + path.startsWith('/home/ada/.gemini/antigravity-cli/brain') + ) + ).toEqual(['/home/ada/.gemini/antigravity-cli/brain']) + }) + + it('keeps Antigravity SSH discovery to one listing as the session store grows', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + for (let index = 0; index < 40; index++) { + provider.addFile( + `${brainDir}/session-${index}/.system_generated/logs/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: `2026-07-15T11:39:${String(index).padStart(2, '0')}Z`, + content: `Remote session ${index}` + } + ]), + 100 + index + ) + } + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(40) + expect(provider.readDirPaths.filter((path) => path.startsWith(brainDir))).toEqual([brainDir]) + }) + + it('ignores missing canonical Antigravity transcripts but reports other stat failures', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + provider.addFile(`${brainDir}/missing-session/artifacts/task.jsonl`, 'artifact', 1) + const deniedTranscript = `${brainDir}/denied-session/.system_generated/logs/transcript.jsonl` + provider.addFile(deniedTranscript, 'unreadable', 2) + provider.failStat( + deniedTranscript, + new Error(`EACCES: permission denied, stat '${deniedTranscript}'`) + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([ + expect.objectContaining({ + agent: 'antigravity', + path: deniedTranscript, + message: expect.stringContaining('EACCES') + }) + ]) + }) + + it('reports non-missing fixed and recursive remote directory failures', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + const claudeProjectDir = '/home/ada/.claude/projects/repo' + provider.addFile(`${claudeProjectDir}/session.jsonl`, 'unreadable', 1) + provider.failReadDir(brainDir, new Error(`EACCES: permission denied, scandir '${brainDir}'`)) + provider.failReadDir( + claudeProjectDir, + new Error(`ECONNRESET: connection lost while reading '${claudeProjectDir}'`) + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agent: 'antigravity', + path: brainDir, + message: expect.stringContaining('EACCES') + }), + expect.objectContaining({ + agent: 'claude', + path: claudeProjectDir, + message: expect.stringContaining('ECONNRESET') + }) + ]) + ) + expect(result.issues).toHaveLength(2) + }) + + it('keeps missing optional remote directories silent', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + provider.failReadDir(brainDir, new Error(`ENOENT: no such directory, scandir '${brainDir}'`)) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([]) + }) + it('excludes Claude subagent transcripts from remote scans', async () => { const provider = new MemoryRemoteProvider() provider.addFile( @@ -357,6 +555,49 @@ describe('scanRemoteAiVaultSessions', () => { ) }) + it('loads Antigravity workspace history with Windows remote paths', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = 'dddddddd-eeee-4fff-8aaa-bbbbbbbbbbbb' + provider.addFile( + `C:/Users/Ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'Windows Antigravity title' + } + ]), + 30 + ) + provider.addFile( + 'C:/Users/Ada/.gemini/antigravity-cli/history.jsonl', + jsonLines([ + { + display: 'Windows Antigravity title', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: 'C:/repo/app' + } + ]), + 31 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:win-box', + remoteHome: 'C:/Users/Ada', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions[0]).toMatchObject({ + agent: 'antigravity', + sessionId, + cwd: 'C:/repo/app', + executionHostPlatform: 'win32' + }) + }) + it('continues past skipped candidates to fill the remote scan limit', async () => { const provider = new MemoryRemoteProvider() provider.addFile( diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index cc5ae37dad3..c8e07929b61 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -1,25 +1,18 @@ -import { extname } from 'node:path' import type { - AiVaultAgent, AiVaultListResult, AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' import { sessionSortTime } from './session-scanner-accumulator' -import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' -import type { FileWithMtime } from './session-scanner-types' +import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { errorMessage } from './session-scanner-values' +import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' import { remoteSessionSources } from './remote-session-scanner-sources' -import type { - RemoteScannerContext, - RemoteSessionCandidate, - RemoteSessionSource -} from './remote-session-scanner-types' +import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types' const DEFAULT_REMOTE_SCAN_LIMIT = 1000 const REMOTE_SCAN_CONCURRENCY = 8 @@ -39,7 +32,15 @@ export async function scanRemoteAiVaultSessions(args: { provider: args.provider, executionHostId: args.executionHostId, hostPlatform: args.hostPlatform, - titleCaches: new Map() + titleCaches: new Map(), + antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => { + try { + const read = await args.provider.readFile(historyPath) + return read.isBinary ? null : read.content + } catch { + return null + } + }) } const candidates = ( await mapRemoteScanConcurrently( @@ -73,70 +74,6 @@ export async function scanRemoteAiVaultSessions(args: { } } -async function discoverRemoteSourceCandidates(args: { - source: RemoteSessionSource - context: RemoteScannerContext - issues: AiVaultScanIssue[] -}): Promise { - const walked = await walkRemoteSessionFiles( - args.source, - args.context.provider, - args.context.hostPlatform - ) - const partition = args.source.collectSubagentSiblingCounts - ? partitionSubagentTranscriptPaths(walked) - : null - const paths = partition ? partition.sessionFilePaths : walked - const files = await mapRemoteScanConcurrently(paths, (path) => - statRemoteFile( - args.context.provider, - path, - args.source.agent, - args.context.executionHostId, - args.issues - ) - ) - return files - .filter((file): file is FileWithMtime => Boolean(file)) - .map((file) => ({ - source: args.source, - file, - subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0 - })) -} - -async function walkRemoteSessionFiles( - source: RemoteSessionSource, - provider: IFilesystemProvider, - hostPlatform: RemoteHostPlatform, - dirPath = source.rootDir -): Promise { - let entries - try { - entries = await provider.readDir(dirPath) - } catch { - return [] - } - - const extensions = new Set(source.extensions) - const files: string[] = [] - for (const entry of entries) { - const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name) - if (entry.isDirectory && !entry.isSymlink) { - files.push(...(await walkRemoteSessionFiles(source, provider, hostPlatform, fullPath))) - continue - } - if ( - !entry.isSymlink && - extensions.has(extname(entry.name).toLowerCase()) && - (source.filePredicate?.(fullPath) ?? true) - ) { - files.push(fullPath) - } - } - return files -} - async function parseRemoteSessionCandidates(args: { candidates: readonly RemoteSessionCandidate[] context: RemoteScannerContext @@ -254,30 +191,6 @@ function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) } -async function statRemoteFile( - provider: IFilesystemProvider, - path: string, - agent: AiVaultAgent, - executionHostId: ExecutionHostId, - issues: AiVaultScanIssue[] -): Promise { - try { - const stat = await provider.stat(path) - const mtimeMs = remoteStatMtimeMs(stat) - return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } - } catch (err) { - issues.push({ executionHostId, agent, path, message: errorMessage(err) }) - return null - } -} - -function remoteStatMtimeMs(stat: FileStat): number { - if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { - return stat.mtimeMs - } - return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 -} - function canStopParsingRemoteSessions( sessions: AiVaultSession[], limit: number, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 22c6cd46abc..029227d8745 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -1,5 +1,6 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseDevinSessionFile } from './session-scanner-devin-parser' +import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser' import { parseDroidSessionFile } from './session-scanner-droid-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' @@ -37,6 +38,8 @@ export async function parseAgentSessionFile( return parseCodexSessionFile(candidate.file, platform, candidate.codexHome) case 'gemini': return parseGeminiSessionFile(candidate.file, platform) + case 'antigravity': + return parseAntigravitySessionFile(candidate.file, platform) case 'copilot': return parseCopilotSessionFile(candidate.file, platform) case 'cursor': diff --git a/src/main/ai-vault/session-scanner-antigravity-fixtures.ts b/src/main/ai-vault/session-scanner-antigravity-fixtures.ts new file mode 100644 index 00000000000..cbcab494933 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-fixtures.ts @@ -0,0 +1,35 @@ +import type { IncrementalAgentFixture } from './session-scanner-incremental-fixtures' + +export function antigravityFixture(): IncrementalAgentFixture { + const record = (source: string, type: string, content: string, createdAt: string) => + JSON.stringify({ source, type, content, created_at: createdAt }) + return { + agent: 'antigravity', + fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/.system_generated/logs/transcript.jsonl', + seedLines: [ + record( + 'USER_EXPLICIT', + 'USER_INPUT', + 'antigravity seed question', + '2026-05-01T10:00:00.000Z' + ), + record('MODEL', 'PLANNER_RESPONSE', 'antigravity seed answer', '2026-05-01T10:00:30.000Z') + ], + appendLines: [ + record( + 'USER', + 'REQUEST', + 'antigravity follow-up', + '2026-05-01T10:01:00.000Z' + ) + ], + truncatedLines: [ + record( + 'USER_EXPLICIT', + 'USER_INPUT', + 'antigravity rewritten', + '2026-05-01T10:00:00.000Z' + ) + ] + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-history.ts b/src/main/ai-vault/session-scanner-antigravity-history.ts new file mode 100644 index 00000000000..989ac7b4179 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-history.ts @@ -0,0 +1,77 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { normalizeTitleText, parseJsonObject, timestampMs } from './session-scanner-values' + +const HISTORY_MATCH_WINDOW_MS = 2_000 + +type AntigravityHistoryEntry = { + timestampMs: number + workspace: string +} + +type AntigravityHistoryIndex = Map + +export type AntigravityWorkspaceResolver = { + enrich(session: AiVaultSession, historyPath: string): Promise +} + +export function createAntigravityWorkspaceResolver( + readHistory: (historyPath: string) => Promise +): AntigravityWorkspaceResolver { + const indexes = new Map>() + + return { + async enrich(session, historyPath) { + if (session.agent !== 'antigravity' || session.cwd) { + return session + } + let index = indexes.get(historyPath) + if (!index) { + index = readHistory(historyPath).then(indexAntigravityHistory) + indexes.set(historyPath, index) + } + const workspace = findAntigravityWorkspace(session, await index) + return workspace ? { ...session, cwd: workspace } : session + } + } +} + +function indexAntigravityHistory(content: string | null): AntigravityHistoryIndex { + const index: AntigravityHistoryIndex = new Map() + for (const line of content?.split(/\r?\n/) ?? []) { + const record = parseJsonObject(line) + const display = typeof record?.display === 'string' ? normalizeTitleText(record.display) : null + const workspace = typeof record?.workspace === 'string' ? record.workspace.trim() : '' + const entryTimestampMs = timestampMs(record?.timestamp) + if (!display || !workspace || !Number.isFinite(entryTimestampMs)) { + continue + } + const entries = index.get(display) ?? [] + entries.push({ timestampMs: entryTimestampMs, workspace }) + index.set(display, entries) + } + return index +} + +function findAntigravityWorkspace( + session: AiVaultSession, + index: AntigravityHistoryIndex +): string | null { + // Why: truncated titles are not prompt identities; long worker prompts often + // share the same 96-character prefix across unrelated workspaces. + if (session.title.endsWith('...')) { + return null + } + const firstTitledUserTimestamp = session.previewMessages.find( + (message) => message.role === 'user' && normalizeTitleText(message.text) === session.title + )?.timestamp + const promptTimestampMs = timestampMs(firstTitledUserTimestamp ?? session.createdAt) + if (!Number.isFinite(promptTimestampMs)) { + return null + } + const matches = (index.get(session.title) ?? []).filter( + (entry) => Math.abs(entry.timestampMs - promptTimestampMs) <= HISTORY_MATCH_WINDOW_MS + ) + // Why: history rows have no conversation id. A unique prompt/time match is + // evidence for cwd; ambiguity must stay unknown instead of crossing projects. + return matches.length === 1 ? (matches[0]?.workspace ?? null) : null +} diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.test.ts b/src/main/ai-vault/session-scanner-antigravity-parser.test.ts new file mode 100644 index 00000000000..c8d037098ca --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-parser.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser' +import type { FileWithMtime } from './session-scanner-types' +import { jsonLines } from './session-scanner-test-fixtures' + +const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + +describe('Antigravity AI Vault parser', () => { + it('parses the established transcript contract without leaking metadata wrappers', async () => { + const session = await parseAntigravitySessionContent( + file( + `/home/ada/.gemini/antigravity-cli/brain/${SESSION_ID}/.system_generated/logs/transcript.jsonl` + ), + jsonLines([ + { + step_index: 0, + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + status: 'DONE', + created_at: '2026-07-15T11:39:10Z', + content: + '\nFix the vault adapter\n\nignored' + }, + { + step_index: 1, + source: 'SYSTEM', + type: 'CONVERSATION_HISTORY', + created_at: '2026-07-15T11:39:11Z' + }, + { + step_index: 2, + source: 'MODEL', + type: 'PLANNER_RESPONSE', + status: 'DONE', + created_at: '2026-07-15T11:39:12Z', + content: 'The adapter is ready.' + }, + { + step_index: 3, + source: 'SYSTEM', + type: 'CHECKPOINT', + created_at: '2026-07-15T11:39:14Z', + content: 'internal checkpoint' + } + ]), + 'linux' + ) + + expect(session).toMatchObject({ + agent: 'antigravity', + sessionId: SESSION_ID, + title: 'Fix the vault adapter', + cwd: null, + model: null, + createdAt: '2026-07-15T11:39:10.000Z', + updatedAt: '2026-07-15T11:39:14.000Z', + messageCount: 2, + resumeCommand: `agy --conversation '${SESSION_ID}'` + }) + expect(session?.previewMessages).toEqual([ + { + role: 'user', + text: 'Fix the vault adapter', + timestamp: '2026-07-15T11:39:10.000Z' + }, + { + role: 'assistant', + text: 'The adapter is ready.', + timestamp: '2026-07-15T11:39:12.000Z' + } + ]) + }) + + it('derives the conversation id from Windows paths and quotes the resume command', async () => { + const session = await parseAntigravitySessionContent( + file( + `C:\\Users\\Ada\\.gemini\\antigravity-cli\\brain\\${SESSION_ID}\\.system_generated\\logs\\transcript.jsonl` + ), + jsonLines([ + { + source: 'USER', + type: 'REQUEST', + created_at: '2026-07-15T11:39:10Z', + content: 'Windows prompt' + } + ]), + 'win32' + ) + + expect(session).toMatchObject({ + sessionId: SESSION_ID, + title: 'Windows prompt', + resumeCommand: `agy --conversation "${SESSION_ID}"` + }) + }) + + it('rejects files outside the targeted Antigravity transcript layout', async () => { + await expect( + parseAntigravitySessionContent( + file('/home/ada/.gemini/antigravity-cli/brain/transcript.jsonl'), + jsonLines([{ source: 'USER', type: 'REQUEST', content: 'Not a conversation transcript' }]) + ) + ).resolves.toBeNull() + }) +}) + +function file(path: string): FileWithMtime { + return { + path, + mtimeMs: Date.parse('2026-07-15T11:39:14Z'), + modifiedAt: '2026-07-15T11:39:14.000Z', + sizeBytes: 1 + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts new file mode 100644 index 00000000000..2e3a3a08b4a --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -0,0 +1,120 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { + accumulatorFoldResumeState, + addPreviewMessage, + createAccumulator, + updateTimeline +} from './session-scanner-accumulator' +import { antigravityConversationIdFromTranscriptPath } from './session-scanner-antigravity-paths' +import type { + FileWithMtime, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' +import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export async function parseAntigravitySessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + return parseAntigravitySessionLines({ file, lines, platform }) +} + +export async function parseAntigravitySessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseAntigravitySessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +export function createAntigravitySessionResumeState( + file: FileWithMtime +): ResumableSessionParseState { + const sessionId = antigravityConversationIdFromTranscriptPath(file.path) ?? '' + // Why: the transcript has no cwd/model fields. Workspace enrichment is a + // separate, conservative history join; protobuf/SQLite blobs are unstable. + return accumulatorFoldResumeState( + createAccumulator({ agent: 'antigravity', file, sessionId }), + consumeAntigravityRecordLine + ) +} + +async function parseAntigravitySessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const state = createAntigravitySessionResumeState(args.file) + for await (const line of args.lines) { + state.consumeLine(line) + } + return state.finalize(args.platform, args.options) +} + +function consumeAntigravityRecordLine(accumulator: SessionAccumulator, line: string): void { + const record = parseJsonObject(line) + if (!record) { + return + } + + updateTimeline(accumulator, record.created_at) + const source = extractString(record.source) + const type = extractString(record.type) + const content = extractString(record.content) + + if ( + (source === 'USER_EXPLICIT' || source === 'USER') && + (type === 'USER_INPUT' || type === 'REQUEST') + ) { + const request = extractAntigravityUserRequest(content ?? '') + if (!request) { + return + } + accumulator.messageCount++ + accumulator.title ??= normalizeTitleText(request) + addPreviewMessage(accumulator, { role: 'user', text: request, timestamp: record.created_at }) + return + } + + if (source === 'MODEL' && type === 'PLANNER_RESPONSE' && content) { + accumulator.messageCount++ + addPreviewMessage(accumulator, { + role: 'assistant', + text: content, + timestamp: record.created_at + }) + } +} + +function extractAntigravityUserRequest(content: string): string | null { + const opener = '' + const startIndex = content.indexOf(opener) + if (startIndex === -1) { + return extractString(content) + } + const bodyStart = startIndex + opener.length + const endIndex = content.indexOf('', bodyStart) + return extractString( + endIndex === -1 ? content.slice(bodyStart) : content.slice(bodyStart, endIndex) + ) +} diff --git a/src/main/ai-vault/session-scanner-antigravity-paths.ts b/src/main/ai-vault/session-scanner-antigravity-paths.ts new file mode 100644 index 00000000000..c505cb7e5ee --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-paths.ts @@ -0,0 +1,42 @@ +import { dirname, join } from 'node:path' + +const ANTIGRAVITY_TRANSCRIPT_FILE = 'transcript.jsonl' +const ANTIGRAVITY_SYSTEM_DIR = '.system_generated' +const ANTIGRAVITY_LOGS_DIR = 'logs' + +export function antigravityConversationIdFromTranscriptPath(filePath: string): string | null { + const segments = pathSegments(filePath) + const transcriptIndex = segments.length - 1 + if ( + segments[transcriptIndex] !== ANTIGRAVITY_TRANSCRIPT_FILE || + segments[transcriptIndex - 1] !== ANTIGRAVITY_LOGS_DIR || + segments[transcriptIndex - 2] !== ANTIGRAVITY_SYSTEM_DIR + ) { + return null + } + return segments[transcriptIndex - 3] ?? null +} + +export function isAntigravityTranscriptPath(filePath: string): boolean { + return antigravityConversationIdFromTranscriptPath(filePath) !== null +} + +export function shouldDescendAntigravityBrainDirectory(name: string, depth: number): boolean { + // Why: brain entries contain large artifact trees; only the fixed transcript + // chain is part of the resumable conversation contract. + if (depth === 0) { + return true + } + if (depth === 1) { + return name === ANTIGRAVITY_SYSTEM_DIR + } + return depth === 2 && name === ANTIGRAVITY_LOGS_DIR +} + +export function antigravityHistoryPathForBrainDir(brainDir: string): string { + return join(dirname(brainDir), 'history.jsonl') +} + +function pathSegments(filePath: string): string[] { + return filePath.split(/[\\/]+/).filter(Boolean) +} diff --git a/src/main/ai-vault/session-scanner-antigravity-source.test.ts b/src/main/ai-vault/session-scanner-antigravity-source.test.ts new file mode 100644 index 00000000000..454b3cf92f6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-source.test.ts @@ -0,0 +1,167 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { filterAiVaultSessions } from '../../shared/ai-vault-session-filters' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' +import { scanAiVaultSessions } from './session-scanner' +import { + isolatedScanRoots, + writeAntigravityHistory, + writeAntigravityTranscript +} from './session-scanner-test-fixtures' + +const tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('Antigravity AI Vault discovery', () => { + it('discovers canonical transcripts from WSL homes without indexing sibling artifacts', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-wsl-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const wslHome = join(root, 'wsl-home') + const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const transcriptPath = await writeAntigravityTranscript( + join(wslHome, '.gemini', 'antigravity-cli', 'brain'), + sessionId, + [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10Z', + content: 'Index the WSL conversation' + } + ] + ) + await writeFile(join(dirname(transcriptPath), 'transcript_full.jsonl'), 'duplicate') + await writeAntigravityHistory(join(wslHome, '.gemini', 'antigravity-cli', 'brain'), [ + { + display: 'Index the WSL conversation', + timestamp: Date.parse('2026-07-15T11:39:10.100Z') / 1000, + workspace: '/home/ada/project' + } + ]) + + const result = await scanAiVaultSessions({ + ...roots, + wslHomeDirs: [wslHome], + platform: 'linux' + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'antigravity', + sessionId, + title: 'Index the WSL conversation', + cwd: '/home/ada/project', + resumeCommand: `agy --conversation '${sessionId}'`, + filePath: transcriptPath + }) + }) + + it('uses a unique history match in the default workspace view', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-workspace-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' + const workspace = join(root, 'active-workspace') + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'ORCA_8742_REPRO' + } + ]) + + const beforeHistory = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(beforeHistory.sessions[0]?.cwd).toBeNull() + + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: 'ORCA_8742_REPRO', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace + } + ]) + // The transcript is unchanged, so this also covers history refresh after a + // cached incremental parse has already been stored. + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.sessions[0]?.cwd).toBe(workspace) + expect( + filterAiVaultSessions(result.sessions, { + query: '', + agents: AI_VAULT_AGENTS, + scope: 'workspace', + sort: 'updated', + activeWorktreePaths: [workspace], + hideEmptySessions: true + }).map((session) => session.sessionId) + ).toEqual([sessionId]) + }) + + it('keeps workspace unknown when matching history rows are ambiguous', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-ambiguous-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'cccccccc-dddd-4eee-8fff-aaaaaaaaaaaa' + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'Repeated prompt' + } + ]) + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: 'Repeated prompt', + timestamp: Date.parse('2026-07-15T11:39:09.900Z'), + workspace: '/repo/one' + }, + { + display: 'Repeated prompt', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/repo/two' + } + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux' }) + + expect(result.sessions[0]?.cwd).toBeNull() + }) + + it('does not join workspace from a colliding truncated prompt title', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-long-title-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'eeeeeeee-ffff-4aaa-8bbb-cccccccccccc' + const commonPrefix = 'shared worker preamble '.repeat(7) + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: `${commonPrefix}workspace A` + } + ]) + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: `${commonPrefix}workspace B`, + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/repo/wrong' + } + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux' }) + + expect(result.sessions[0]?.title).toHaveLength(96) + expect(result.sessions[0]?.title).toMatch(/\.\.\.$/) + expect(result.sessions[0]?.cwd).toBeNull() + }) +}) diff --git a/src/main/ai-vault/session-scanner-antigravity-sources.ts b/src/main/ai-vault/session-scanner-antigravity-sources.ts new file mode 100644 index 00000000000..a79edc380e6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-sources.ts @@ -0,0 +1,34 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { + isAntigravityTranscriptPath, + shouldDescendAntigravityBrainDirectory +} from './session-scanner-antigravity-paths' +import { discoverFiles } from './session-scanner-discovery' +import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' + +const ANTIGRAVITY_BRAIN_DIR = join(homedir(), '.gemini', 'antigravity-cli', 'brain') + +export function antigravityDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + const rootDirs = [ + options.antigravityBrainDir ?? ANTIGRAVITY_BRAIN_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.gemini', 'antigravity-cli', 'brain')) + ] + return rootDirs.map((rootDir) => + discoverFiles({ + rootDir, + limit, + agent: 'antigravity', + issues, + extensions: ['.jsonl'], + filePredicate: isAntigravityTranscriptPath, + directoryPredicate: shouldDescendAntigravityBrainDirectory + }) + ) +} diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 8a6fb183f62..d8a8e2850ac 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -106,6 +106,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir, geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index c176fb3c6e5..576438a8c49 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -11,7 +11,7 @@ export async function discoverFiles(args: { issues: AiVaultScanIssue[] extensions: string[] filePredicate?: (path: string) => boolean - directoryPredicate?: (name: string) => boolean + directoryPredicate?: (name: string, depth: number) => boolean }): Promise { const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { extensions: new Set(args.extensions), @@ -70,10 +70,11 @@ export async function walkSessionFiles( options: { extensions: Set filePredicate?: (path: string) => boolean - // Return false to skip descending into a directory (matched by its name), - // so pruned subtrees are never stat'd or parsed. - directoryPredicate?: (name: string) => boolean - } + // Return false to skip descending into a directory; depth 0 is a child of + // rootDir, so pruned subtrees are never stat'd or parsed. + directoryPredicate?: (name: string, depth: number) => boolean + }, + depth = 0 ): Promise { let entries try { @@ -88,8 +89,8 @@ export async function walkSessionFiles( if (entry.isDirectory()) { // Skip whole subtrees an agent never wants (e.g. subagent transcripts), // avoiding the readdir cost of descending into them. - if (options.directoryPredicate?.(entry.name) ?? true) { - files.push(...(await walkSessionFiles(fullPath, agent, issues, options))) + if (options.directoryPredicate?.(entry.name, depth) ?? true) { + files.push(...(await walkSessionFiles(fullPath, agent, issues, options, depth + 1))) } continue } diff --git a/src/main/ai-vault/session-scanner-incremental-fixtures.ts b/src/main/ai-vault/session-scanner-incremental-fixtures.ts index 1945193e6c7..54f0171e6c6 100644 --- a/src/main/ai-vault/session-scanner-incremental-fixtures.ts +++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts @@ -1,5 +1,6 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { antigravityFixture } from './session-scanner-antigravity-fixtures' import { codexFixture } from './session-scanner-codex-fixtures' // Line builders for the incremental-parse differential tests: each agent gets @@ -286,6 +287,7 @@ export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] { openclawFixture(), piFixture(), ompFixture(), - geminiJsonlFixture() + geminiJsonlFixture(), + antigravityFixture() ] } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index 2fb3df21613..1819afe81f2 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -23,6 +23,7 @@ function isolatedScanRoots(root: string) { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), diff --git a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts index 61348ad7cec..cd4977679d2 100644 --- a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts +++ b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts @@ -1,6 +1,6 @@ import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { @@ -55,6 +55,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('reuses unchanged files, resumes appends, and matches cold parses exactly', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) await writeFile(path, `${fixture.seedLines.join('\n')}\n`) const stats = createSessionParseStats() @@ -87,6 +88,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('includes a trailing unterminated line without double-counting it later', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) const lastSeedLine = fixture.seedLines.at(-1) const headLines = fixture.seedLines.slice(0, -1) await writeFile(path, `${[...headLines, ''].join('\n')}${lastSeedLine}`) @@ -106,6 +108,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('tolerates a mid-write truncated trailing line and never double-counts it', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) // A writer caught mid-record: the trailing line is invalid JSON. await writeFile(path, `${fixture.seedLines.join('\n')}\n{"type":"user","mess`) diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 70d0d311d6b..d42da123437 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -1,6 +1,7 @@ import { createReadStream } from 'node:fs' import { open } from 'node:fs/promises' import type { AiVaultSession } from '../../shared/ai-vault-types' +import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { createCodexSessionResumeState } from './session-scanner-codex-parser' import { createDroidSessionResumeState } from './session-scanner-droid-parser' @@ -67,6 +68,8 @@ function resumableStateFactoryFor( return candidate.file.path.endsWith('.jsonl') ? () => createGeminiJsonlSessionResumeState(candidate.file) : null + case 'antigravity': + return () => createAntigravitySessionResumeState(candidate.file) case 'devin': case 'grok': case 'hermes': diff --git a/src/main/ai-vault/session-scanner-scope.test.ts b/src/main/ai-vault/session-scanner-scope.test.ts index 77931d68715..487748de97a 100644 --- a/src/main/ai-vault/session-scanner-scope.test.ts +++ b/src/main/ai-vault/session-scanner-scope.test.ts @@ -19,6 +19,7 @@ function scopedScanOptions(claudeProjectsDir: string, extra: Partial[] { return [ + ...antigravityDiscoveries(options, wslHomeDirs, limit, issues), ...sessionRootDirs(options.geminiSessionsDir ?? GEMINI_SESSIONS_DIR, wslHomeDirs, [ '.gemini', 'tmp' diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 0a3b6c7c1a8..f69bf2939c7 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -6,6 +6,7 @@ export function isolatedScanRoots(root: string) { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), @@ -34,3 +35,37 @@ export async function writeJsonlFile(filePath: string, records: unknown[]): Prom await mkdir(dirname(filePath), { recursive: true }) await writeFile(filePath, jsonLines(records)) } + +export async function writeAntigravityTranscript( + brainDir: string, + sessionId: string, + records: unknown[] +): Promise { + const transcriptPath = join(brainDir, sessionId, '.system_generated', 'logs', 'transcript.jsonl') + await writeJsonlFile(transcriptPath, records) + return transcriptPath +} + +export function writeAntigravityHistory(brainDir: string, records: unknown[]): Promise { + return writeJsonlFile(join(dirname(brainDir), 'history.jsonl'), records) +} + +export function writeAntigravityScannerFixture( + brainDir: string, + sessionId: string +): Promise { + return writeAntigravityTranscript(brainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-05-01T10:02:30.000Z', + content: 'Antigravity title' + }, + { + source: 'MODEL', + type: 'PLANNER_RESPONSE', + created_at: '2026-05-01T10:02:31.000Z', + content: 'Done' + } + ]) +} diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 803554316c1..efc6660ab1a 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -12,6 +12,7 @@ export type AiVaultScanOptions = { additionalCodexSessionsDirs?: readonly string[] wslHomeDirs?: readonly string[] geminiSessionsDir?: string + antigravityBrainDir?: string copilotSessionsDir?: string cursorProjectsDir?: string opencodeStorageDir?: string @@ -52,6 +53,7 @@ export type SessionFileCandidate = { agent: AiVaultAgent file: FileWithMtime codexHome: string | null + antigravityHistoryPath?: string } export type SessionFileDiscovery = { diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index d4404341fc1..7ba80556051 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -4,7 +4,11 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' -import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' +import { + isolatedScanRoots, + jsonLines, + writeAntigravityScannerFixture +} from './session-scanner-test-fixtures' let tempRoots: string[] = [] @@ -423,6 +427,9 @@ describe('scanAiVaultSessions', () => { }) ) + const antigravitySessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + await writeAntigravityScannerFixture(roots.antigravityBrainDir, antigravitySessionId) + await mkdir(roots.copilotSessionsDir, { recursive: true }) await writeFile( join(roots.copilotSessionsDir, 'copilot-session.jsonl'), @@ -718,11 +725,7 @@ describe('scanAiVaultSessions', () => { ]) ) - const result = await scanAiVaultSessions({ - ...roots, - platform: 'darwin', - limit: 20 - }) + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) expect(result.issues).toEqual([]) expect(new Set(result.sessions.map((session) => session.agent))).toEqual( @@ -739,6 +742,7 @@ describe('scanAiVaultSessions', () => { `cd '/tmp/codex' && CODEX_HOME='${root}' codex resume 'codex-session'` ) expect(commandByAgent.get('gemini')).toBe("gemini --resume 'gemini-session'") + expect(commandByAgent.get('antigravity')).toBe(`agy --conversation '${antigravitySessionId}'`) expect(commandByAgent.get('copilot')).toBe( "cd '/tmp/copilot' && copilot --resume='copilot-session'" ) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 5ba2a95d503..ddb9ba7a861 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import type { AiVaultListResult, AiVaultScanIssue, @@ -6,6 +7,11 @@ import type { import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' +import { + createAntigravityWorkspaceResolver, + type AntigravityWorkspaceResolver +} from './session-scanner-antigravity-history' +import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' import { createSessionParseStats, @@ -54,6 +60,7 @@ export async function scanAiVaultSessions( const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID const issues: AiVaultScanIssue[] = [] const parseStats = createSessionParseStats() + const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile) const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) const candidates = discoveries @@ -65,7 +72,11 @@ export async function scanAiVaultSessions( codexHome: discovery.agent === 'codex' ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR) - : null + : null, + antigravityHistoryPath: + discovery.agent === 'antigravity' + ? antigravityHistoryPathForBrainDir(discovery.rootDir) + : undefined }) ) ) @@ -77,7 +88,8 @@ export async function scanAiVaultSessions( platform, executionHostId, issues, - parseStats + parseStats, + antigravityWorkspaceResolver }) const cappedSessions = parsedSessions @@ -175,6 +187,7 @@ async function parseSessionCandidates(args: { executionHostId: ExecutionHostId issues: AiVaultScanIssue[] parseStats: SessionParseStats + antigravityWorkspaceResolver?: AntigravityWorkspaceResolver }): Promise { const sessions: AiVaultSession[] = [] let index = 0 @@ -190,7 +203,13 @@ async function parseSessionCandidates(args: { const batch = args.candidates.slice(index, index + batchSize) const results = await Promise.all( batch.map((candidate) => - parseSessionCandidate(candidate, args.platform, args.executionHostId, args.parseStats) + parseSessionCandidate( + candidate, + args.platform, + args.executionHostId, + args.parseStats, + args.antigravityWorkspaceResolver + ) ) ) @@ -213,10 +232,14 @@ async function parseSessionCandidate( candidate: SessionFileCandidate, platform: NodeJS.Platform, executionHostId: ExecutionHostId, - parseStats: SessionParseStats + parseStats: SessionParseStats, + antigravityWorkspaceResolver?: AntigravityWorkspaceResolver ): Promise { try { - const session = await parseAgentSessionFileCached(candidate, platform, parseStats) + let session = await parseAgentSessionFileCached(candidate, platform, parseStats) + if (session && candidate.antigravityHistoryPath && antigravityWorkspaceResolver) { + session = await antigravityWorkspaceResolver.enrich(session, candidate.antigravityHistoryPath) + } return { session: session ? withSessionExecutionHost(session, executionHostId) : null, issue: null @@ -234,6 +257,14 @@ async function parseSessionCandidate( } } +async function readOptionalTextFile(path: string): Promise { + try { + return await readFile(path, 'utf-8') + } catch { + return null + } +} + function withSessionExecutionHost( session: AiVaultSession, executionHostId: ExecutionHostId diff --git a/src/main/browser/browser-clicked-link-routing.test.ts b/src/main/browser/browser-clicked-link-routing.test.ts new file mode 100644 index 00000000000..eee5eea35f1 --- /dev/null +++ b/src/main/browser/browser-clicked-link-routing.test.ts @@ -0,0 +1,272 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + buildBrowserClickedLinkRoutingScript, + buildBrowserIframeClickedLinkRoutingScript, + installBrowserIframeClickedLinkRouting, + installBrowserClickedLinkRouting +} from './browser-clicked-link-routing' + +const FOREGROUND_FRAME_NAME = '__orca_clicked_link_foreground_test' + +type RoutingGlobal = typeof globalThis & { + __orcaBrowserClickedLinkRouting?: { + listener: (event: MouseEvent) => void + } +} + +let cleanupIframeRouting: (() => void) | null = null + +function resetRouting(): void { + const routingGlobal = globalThis as RoutingGlobal + const state = routingGlobal.__orcaBrowserClickedLinkRouting + if (state) { + window.removeEventListener('click', state.listener) + window.removeEventListener('auxclick', state.listener) + } + delete routingGlobal.__orcaBrowserClickedLinkRouting + cleanupIframeRouting?.() + cleanupIframeRouting = null +} + +function installRouting(isMac = true): void { + installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, isMac, true) +} + +function clickLink( + link: Element, + init: MouseEventInit & { type?: 'click' | 'auxclick' } = {} +): { event: MouseEvent; open: ReturnType } { + const open = vi.fn() + vi.spyOn(window, 'open').mockImplementation(open) + const { type = 'click', ...eventInit } = init + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + button: type === 'auxclick' ? 1 : 0, + ...eventInit + }) + link.dispatchEvent(event) + return { event, open } +} + +describe('browser clicked-link routing', () => { + beforeEach(() => { + resetRouting() + document.head.innerHTML = '' + document.body.innerHTML = '' + }) + + afterEach(() => { + resetRouting() + vi.restoreAllMocks() + }) + + it('routes plain target=_blank links back into the current Orca tab', () => { + const link = document.createElement('a') + link.href = 'https://docs.example.com/guide' + link.target = '_blank' + document.body.append(link) + installRouting() + + const { event, open } = clickLink(link) + + expect(event.defaultPrevented).toBe(false) + expect(open).not.toHaveBeenCalled() + expect(link.getAttribute('target')).toBe('_self') + }) + + it('routes the host-platform modifier without trusting an emulated guest user agent', () => { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' + }) + const link = document.createElement('a') + link.href = 'https://example.com/reference' + document.body.append(link) + installRouting(false) + + expect(clickLink(link, { metaKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(link, { ctrlKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + FOREGROUND_FRAME_NAME + ) + }) + + it('routes middle clicks and primary-modifier plus Shift clicks as new-tab intents', () => { + const link = document.createElement('a') + link.href = 'https://example.com/reference' + document.body.append(link) + installRouting() + + expect(clickLink(link, { type: 'auxclick' }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + FOREGROUND_FRAME_NAME + ) + expect(clickLink(link, { metaKey: true, shiftKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + FOREGROUND_FRAME_NAME + ) + }) + + it('honors page cancellation and observes link rewrites before routing', () => { + const cancelled = document.createElement('a') + cancelled.href = 'https://example.com/original' + cancelled.target = '_blank' + cancelled.addEventListener('click', (event) => event.preventDefault()) + const rewritten = document.createElement('a') + rewritten.href = 'https://example.com/original' + rewritten.target = '_blank' + rewritten.addEventListener('click', () => { + rewritten.href = 'https://example.com/rewritten' + }) + document.body.append(cancelled, rewritten) + installRouting() + + expect(clickLink(cancelled).open).not.toHaveBeenCalled() + expect(cancelled.getAttribute('target')).toBe('_blank') + expect(clickLink(rewritten).open).not.toHaveBeenCalled() + expect(rewritten.href).toBe('https://example.com/rewritten') + expect(rewritten.getAttribute('target')).toBe('_self') + }) + + it('routes SVG links but leaves download links and links without href alone', () => { + const svgLink = document.createElementNS('http://www.w3.org/2000/svg', 'a') + svgLink.setAttribute('href', 'https://example.com/svg') + svgLink.setAttribute('target', '_blank') + const areaDownload = document.createElement('area') + areaDownload.href = 'https://example.com/archive.zip' + areaDownload.target = '_blank' + areaDownload.download = 'archive.zip' + const noHref = document.createElement('a') + noHref.target = '_blank' + document.body.append(svgLink, areaDownload, noHref) + installRouting() + + expect(clickLink(svgLink).open).not.toHaveBeenCalled() + expect(svgLink.getAttribute('target')).toBe('_self') + expect(clickLink(areaDownload).open).not.toHaveBeenCalled() + expect(clickLink(noHref).open).not.toHaveBeenCalled() + }) + + it('leaves ordinary same-tab, Shift-window, Alt, and unsafe links to Chromium', () => { + const sameTab = document.createElement('a') + sameTab.href = 'https://example.com/current' + const shifted = document.createElement('a') + shifted.href = 'https://example.com/window' + shifted.target = '_blank' + const altClicked = document.createElement('a') + altClicked.href = 'https://example.com/download' + altClicked.target = '_blank' + const unsafe = document.createElement('a') + unsafe.href = 'javascript:void(0)' + unsafe.target = '_blank' + document.body.append(sameTab, shifted, altClicked, unsafe) + installRouting() + + expect(clickLink(sameTab).open).not.toHaveBeenCalled() + expect(clickLink(shifted, { shiftKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(altClicked, { altKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(unsafe).open).not.toHaveBeenCalled() + }) + + it('updates an existing isolated-world installation without adding another listener', () => { + const addEventListener = vi.spyOn(window, 'addEventListener') + const link = document.createElement('a') + link.href = 'https://example.com/reference' + document.body.append(link) + installBrowserClickedLinkRouting('__orca_clicked_link_old_fg', true, true) + installBrowserClickedLinkRouting('__orca_clicked_link_new_fg', false, true) + + expect(addEventListener.mock.calls.filter(([event]) => event === 'click')).toHaveLength(1) + expect(clickLink(link, { ctrlKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + '__orca_clicked_link_new_fg' + ) + }) + + it('builds a self-contained isolated-world script', () => { + const script = buildBrowserClickedLinkRoutingScript(FOREGROUND_FRAME_NAME, false) + + expect(script).toContain('installBrowserClickedLinkRouting') + expect(script).toContain(`"${FOREGROUND_FRAME_NAME}",false`) + expect(script).not.toContain('BrowserClickedLinkRoutingState') + }) + + it('routes plain iframe target=_blank links into the top-level guest', () => { + const link = document.createElement('a') + link.href = 'https://example.com/from-frame' + link.target = '_blank' + document.body.append(link) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + + const { event, open } = clickLink(link) + + expect(event.defaultPrevented).toBe(false) + expect(open).not.toHaveBeenCalled() + expect(link.getAttribute('target')).toBe('_top') + }) + + it('routes explicit iframe new-tab gestures through one-use frame names', () => { + const blank = document.createElement('a') + blank.href = 'https://example.com/new-context' + blank.target = '_blank' + document.body.append(blank) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + + expect(clickLink(blank, { metaKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/new-context', + FOREGROUND_FRAME_NAME + ) + + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + expect(clickLink(blank, { type: 'auxclick' }).open).toHaveBeenCalledWith( + 'https://example.com/new-context', + FOREGROUND_FRAME_NAME + ) + + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + expect(clickLink(blank, { metaKey: true, shiftKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/new-context', + FOREGROUND_FRAME_NAME + ) + }) + + it('leaves other-platform, Shift-window, and ordinary iframe clicks to Chromium', () => { + const blank = document.createElement('a') + blank.href = 'https://example.com/new-context' + blank.target = '_blank' + const ordinary = document.createElement('a') + ordinary.href = 'https://example.com/current' + document.body.append(blank, ordinary) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + + expect(clickLink(blank, { ctrlKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(blank, { shiftKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(ordinary).open).not.toHaveBeenCalled() + }) + + it('ignores synthetic main-frame and iframe clicks in production mode', () => { + const mainLink = document.createElement('a') + mainLink.href = 'https://example.com/main-synthetic' + const frameLink = document.createElement('a') + frameLink.href = 'https://example.com/frame-synthetic' + frameLink.target = '_blank' + document.body.append(mainLink, frameLink) + installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, true) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true) + + expect(clickLink(mainLink, { metaKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(frameLink).open).not.toHaveBeenCalled() + expect(frameLink.getAttribute('target')).toBe('_blank') + }) + + it('builds a self-contained iframe script with per-frame routing tokens', () => { + const script = buildBrowserIframeClickedLinkRoutingScript(FOREGROUND_FRAME_NAME, false) + + expect(script).toContain('installBrowserIframeClickedLinkRouting') + expect(script).toContain(`"${FOREGROUND_FRAME_NAME}",false`) + }) +}) diff --git a/src/main/browser/browser-clicked-link-routing.ts b/src/main/browser/browser-clicked-link-routing.ts new file mode 100644 index 00000000000..42a8bbf8232 --- /dev/null +++ b/src/main/browser/browser-clicked-link-routing.ts @@ -0,0 +1,217 @@ +export const BROWSER_CLICKED_LINK_ROUTING_WORLD_ID = 1208 + +type BrowserClickedLinkRoutingState = { + frameName: string + isMac: boolean + allowUntrustedEvents: boolean + listener: (event: MouseEvent) => void +} + +type BrowserClickedLinkRoutingGlobal = typeof globalThis & { + __orcaBrowserClickedLinkRouting?: BrowserClickedLinkRoutingState +} + +/** + * Re-expresses explicit new-tab link gestures with private frame names so main + * can distinguish them from opener-dependent window.open calls. + */ +export function installBrowserClickedLinkRouting( + frameName: string, + isMac: boolean, + allowUntrustedEvents = false +): void { + const routingGlobal = globalThis as BrowserClickedLinkRoutingGlobal + const existing = routingGlobal.__orcaBrowserClickedLinkRouting + if (existing) { + existing.frameName = frameName + existing.isMac = isMac + existing.allowUntrustedEvents = allowUntrustedEvents + return + } + + const state: BrowserClickedLinkRoutingState = { + frameName, + isMac, + allowUntrustedEvents, + listener: () => {} + } + state.listener = (event) => { + const primaryClick = event.type === 'click' && event.button === 0 + const middleClick = event.type === 'auxclick' && event.button === 1 + if ( + !(event instanceof MouseEvent) || + (!event.isTrusted && !state.allowUntrustedEvents) || + (!primaryClick && !middleClick) || + event.defaultPrevented || + event.altKey + ) { + return + } + + const link = event + .composedPath() + .find( + (target): target is Element => + target instanceof Element && + ((target.namespaceURI === 'http://www.w3.org/1999/xhtml' && + (target.localName === 'a' || target.localName === 'area')) || + (target.namespaceURI === 'http://www.w3.org/2000/svg' && target.localName === 'a')) + ) + if (!link || link.hasAttribute('download')) { + return + } + + const modifierClick = state.isMac ? event.metaKey : event.ctrlKey + const otherPlatformModifier = state.isMac ? event.ctrlKey : event.metaKey + if (otherPlatformModifier) { + return + } + // Shift alone is browser-native new-window intent; keep OAuth and other + // opener-dependent window flows in Orca's guarded popup window. + if (event.shiftKey && !modifierClick) { + return + } + + const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? '' + const ownTarget = link.getAttribute('target') + const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase() + const opensNewContext = middleClick || modifierClick + if (!opensNewContext && effectiveTarget !== '_blank') { + return + } + + const rawHref = + link.getAttribute('href') ?? link.getAttributeNS('http://www.w3.org/1999/xlink', 'href') + if (rawHref === null) { + return + } + + let targetUrl: URL + try { + targetUrl = new URL(rawHref, document.baseURI) + } catch { + return + } + if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') { + return + } + + if (!opensNewContext) { + // Why: changing only the browsing context keeps Chromium's native anchor + // navigation, including referrer policy, attribution, and history. + link.setAttribute('target', '_self') + return + } + + // Why: Electron reports direct link clicks and featureless window.open() + // with the same disposition. The private frame name preserves that one + // distinction without weakening OAuth popups that need window.opener. + event.preventDefault() + window.open(targetUrl.toString(), state.frameName) + } + routingGlobal.__orcaBrowserClickedLinkRouting = state + + // Why: page click handlers must get the first chance to cancel or rewrite a + // link; capture-phase interception breaks SPA routing and analytics handlers. + window.addEventListener('click', state.listener, false) + window.addEventListener('auxclick', state.listener, false) +} + +/** + * Keeps plain target=_blank clicks inside the top-level guest when Electron's + * isolated-world API cannot target a child frame. + */ +export function installBrowserIframeClickedLinkRouting( + frameName: string, + isMac: boolean, + allowUntrustedEvents = false +): () => void { + const listener = (event: MouseEvent): void => { + const primaryClick = event.type === 'click' && event.button === 0 + const middleClick = event.type === 'auxclick' && event.button === 1 + if ( + !(event instanceof MouseEvent) || + (!event.isTrusted && !allowUntrustedEvents) || + (!primaryClick && !middleClick) || + event.defaultPrevented || + event.altKey + ) { + return + } + + const link = event + .composedPath() + .find( + (target): target is Element => + target instanceof Element && + ((target.namespaceURI === 'http://www.w3.org/1999/xhtml' && + (target.localName === 'a' || target.localName === 'area')) || + (target.namespaceURI === 'http://www.w3.org/2000/svg' && target.localName === 'a')) + ) + if (!link || link.hasAttribute('download')) { + return + } + + const modifierClick = isMac ? event.metaKey : event.ctrlKey + const otherPlatformModifier = isMac ? event.ctrlKey : event.metaKey + if (otherPlatformModifier || (event.shiftKey && !modifierClick)) { + return + } + + const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? '' + const ownTarget = link.getAttribute('target') + const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase() + const opensNewContext = middleClick || modifierClick + if (!opensNewContext && effectiveTarget !== '_blank') { + return + } + + const rawHref = + link.getAttribute('href') ?? link.getAttributeNS('http://www.w3.org/1999/xlink', 'href') + if (rawHref === null) { + return + } + + let targetUrl: URL + try { + targetUrl = new URL(rawHref, document.baseURI) + } catch { + return + } + if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') { + return + } + + if (!opensNewContext) { + // Why: WebContents isolated worlds only cover the main frame. Rewriting + // to `_top` preserves native anchor semantics without opening a popup. + link.setAttribute('target', '_top') + return + } + + // Why: child-frame code runs in the page world, so each token is one-use. + // A page that observes a real click cannot replay it to create more tabs. + event.preventDefault() + cleanup() + window.open(targetUrl.toString(), frameName) + } + + const cleanup = (): void => { + window.removeEventListener('click', listener, false) + window.removeEventListener('auxclick', listener, false) + } + window.addEventListener('click', listener, false) + window.addEventListener('auxclick', listener, false) + return cleanup +} + +export function buildBrowserClickedLinkRoutingScript(frameName: string, isMac: boolean): string { + return `(${installBrowserClickedLinkRouting.toString()})(${JSON.stringify(frameName)},${JSON.stringify(isMac)});` +} + +export function buildBrowserIframeClickedLinkRoutingScript( + frameName: string, + isMac: boolean +): string { + return `void (${installBrowserIframeClickedLinkRouting.toString()})(${JSON.stringify(frameName)},${JSON.stringify(isMac)});` +} diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 29d2c8f8edd..912923fc8f5 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -250,6 +250,165 @@ describe('browserManager', () => { expect(shellOpenExternalMock).not.toHaveBeenCalled() }) + it('keeps plain links current and routes explicit new-tab gestures to Orca tabs', async () => { + const rendererSendMock = vi.fn() + const executeJavaScriptInIsolatedWorldMock = vi.fn().mockResolvedValue(undefined) + const guest = { + id: 141, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + executeJavaScriptInIsolatedWorld: executeJavaScriptInIsolatedWorldMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const domReadyHandler = guestOnMock.mock.calls.find(([event]) => event === 'dom-ready')?.[1] as + | (() => void) + | undefined + domReadyHandler?.() + await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorldMock).toHaveBeenCalledTimes(1)) + + const managerState = browserManager as unknown as { + clickedLinkFrameNameByGuestId: Map + } + const clickedLinkFrameName = managerState.clickedLinkFrameNameByGuestId.get(guest.id) + if (!clickedLinkFrameName) { + throw new Error('Expected a private clicked-link frame name') + } + expect(clickedLinkFrameName).toMatch(/^__orca_clicked_link_foreground_/) + expect(executeJavaScriptInIsolatedWorldMock).toHaveBeenCalledWith( + expect.any(Number), + [ + expect.objectContaining({ + code: expect.stringContaining( + `${JSON.stringify(clickedLinkFrameName)},${process.platform === 'darwin'})` + ) + }) + ], + false + ) + + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + frameName: string + }) => { action: 'allow' | 'deny' } + expect( + handler({ + url: 'https://docs.example.com/guide', + frameName: clickedLinkFrameName + }) + ).toEqual({ action: 'deny' }) + + expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', { + browserPageId: 'browser-1', + url: 'https://docs.example.com/guide' + }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { + browserPageId: 'browser-1', + origin: 'https://docs.example.com', + action: 'opened-in-orca' + }) + expect(openPopupWithOriginBarMock).not.toHaveBeenCalled() + expect(shellOpenExternalMock).not.toHaveBeenCalled() + }) + + it('routes child-frame gestures with one-use tokens', async () => { + const rendererSendMock = vi.fn() + const executeJavaScriptMock = vi.fn().mockResolvedValue(undefined) + const frameOnceMock = vi.fn() + const frame = { + parent: {}, + isDestroyed: vi.fn(() => false), + executeJavaScript: executeJavaScriptMock, + once: frameOnceMock, + off: vi.fn() + } + const guest = { + id: 142, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + executeJavaScriptInIsolatedWorld: vi.fn().mockResolvedValue(undefined) + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-frame', + webContentsId: guest.id, + rendererWebContentsId + }) + const frameCreatedHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'frame-created' + )?.[1] as ((event: Electron.Event, details: Electron.FrameCreatedDetails) => void) | undefined + frameCreatedHandler?.({} as Electron.Event, { frame } as never) + const frameDomReadyHandler = frameOnceMock.mock.calls.find( + ([event]) => event === 'dom-ready' + )?.[1] as (() => void) | undefined + frameDomReadyHandler?.() + + await vi.waitFor(() => expect(executeJavaScriptMock).toHaveBeenCalledTimes(1)) + const firstScript = executeJavaScriptMock.mock.calls[0][0] as string + const foregroundFrameName = firstScript.match( + /__orca_clicked_link_iframe_foreground_[0-9a-f-]+/ + )?.[0] + if (!foregroundFrameName) { + throw new Error('Expected a private child-frame routing token') + } + expect(firstScript).toContain('installBrowserIframeClickedLinkRouting') + expect(executeJavaScriptMock).toHaveBeenCalledWith(firstScript, false) + + const popupHandler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + frameName: string + }) => { action: string } + expect( + popupHandler({ + url: 'https://docs.example.com/from-frame', + frameName: foregroundFrameName + }) + ).toEqual({ action: 'deny' }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', { + browserPageId: 'browser-frame', + url: 'https://docs.example.com/from-frame' + }) + + await vi.waitFor(() => expect(executeJavaScriptMock).toHaveBeenCalledTimes(2)) + const secondScript = executeJavaScriptMock.mock.calls[1][0] as string + expect(secondScript).not.toContain(foregroundFrameName) + }) + it('hosts allowed popups in an origin-bar window with inherited guest policies', () => { const rendererSendMock = vi.fn() const guestOnceMock = vi.fn() @@ -320,6 +479,7 @@ describe('browserManager', () => { // attached directly here. expect(popupContents.setWindowOpenHandler).toHaveBeenCalledTimes(1) expect(popupContents.setBackgroundThrottling).toHaveBeenCalledWith(false) + expect(popupContents.on.mock.calls.some(([event]) => event === 'dom-ready')).toBe(false) // The renderer notice carries only the sanitized origin, never the URL. expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { browserPageId: 'browser-1', @@ -1000,12 +1160,19 @@ describe('browserManager', () => { webContentsId: 102, rendererWebContentsId }) + // Why: attach-before-registration teardown skips unregisterGuest, so global + // cleanup must independently release the private click-routing token. + browserManager.attachGuestPolicies({ ...guest, id: 103 } as never) browserManager.unregisterAll() expect(browserManager.getGuestWebContentsId('browser-1')).toBeNull() expect(browserManager.getGuestWebContentsId('browser-2')).toBeNull() expect(guestOffMock).toHaveBeenCalled() + const managerState = browserManager as unknown as { + clickedLinkFrameNameByGuestId: Map + } + expect(managerState.clickedLinkFrameNameByGuestId.size).toBe(0) }) it('rejects non-webview guest types to prevent privilege escalation', () => { diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index acd404f91ee..b9232bcd8d6 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -39,6 +39,11 @@ import { } from './browser-guest-ui' import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' +import { + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + buildBrowserClickedLinkRoutingScript, + buildBrowserIframeClickedLinkRoutingScript +} from './browser-clicked-link-routing' import { cleanElectronUserAgent } from './browser-session-ua' import type { BrowserViewportOverride } from '../../shared/types' import { @@ -146,7 +151,6 @@ type PopupOwnerContext = { browserTabId: string rootGuestWebContentsId: number } - const SAFE_POPUP_WINDOW_OPTIONS = { alwaysOnTop: false, closable: true, @@ -235,6 +239,7 @@ export class BrowserManager { private readonly worktreeIdByTabId = new Map() private readonly policyAttachedGuestIds = new Set() private readonly policyCleanupByGuestId = new Map void>() + private readonly clickedLinkFrameNameByGuestId = new Map() private shouldForwardDictationShortcut: (() => boolean) | null = null private readonly pendingLoadFailuresByGuestId = new Map< number, @@ -620,6 +625,15 @@ export class BrowserManager { if (inheritedOwnerContext) { this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) } + // Why: OAuth child windows must retain normal link/window relationships; + // only the primary embedded browser converts new-tab clicks to Orca tabs. + const clickedLinkFrameName = inheritedOwnerContext + ? null + : `__orca_clicked_link_foreground_${randomUUID()}` + if (clickedLinkFrameName) { + this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName) + } + let clickedLinkRoutingActive = Boolean(clickedLinkFrameName) // Why: Cloudflare Turnstile and similar bot detectors probe browser APIs // (navigator.webdriver, plugins, window.chrome) that differ in Electron @@ -632,16 +646,118 @@ export class BrowserManager { // Orca window is not the focused foreground app. With throttling enabled, // the compositor stops producing frames and capturePage() returns empty. guest.setBackgroundThrottling(false) + const installClickedLinkRouting = (): void => { + if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) { + return + } + // Why: an isolated-world click listener can label real anchor clicks + // without exposing the private frame name to untrusted page scripts. + void guest + .executeJavaScriptInIsolatedWorld( + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + [ + { + // Why: mobile emulation spoofs the guest UA as iOS, so modifier + // routing must use the actual desktop host platform from main. + code: buildBrowserClickedLinkRoutingScript( + clickedLinkFrameName, + process.platform === 'darwin' + ) + } + ], + false + ) + .catch(() => {}) + } + if (clickedLinkFrameName) { + guest.on('dom-ready', installClickedLinkRouting) + } + const pendingIframeRoutingInstalls = new Map void>() + const iframeFrameNameByFrame = new Map() + const iframeFrameByFrameName = new Map() + const clearIframeFrameName = (frame: Electron.WebFrameMain): void => { + const name = iframeFrameNameByFrame.get(frame) + if (!name) { + return + } + iframeFrameNameByFrame.delete(frame) + iframeFrameByFrameName.delete(name) + } + const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => { + clearIframeFrameName(frame) + if (!clickedLinkRoutingActive || frame.isDestroyed()) { + return + } + const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}` + iframeFrameNameByFrame.set(frame, name) + iframeFrameByFrameName.set(name, frame) + // Why: child-frame tokens live in the page world, so they are consumed + // after one trusted click and replaced before another can be routed. + void frame + .executeJavaScript( + buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'), + false + ) + .catch(() => { + if (iframeFrameNameByFrame.get(frame) === name) { + clearIframeFrameName(frame) + } + }) + } + const handleFrameCreated = ( + _event: Electron.Event, + { frame }: Electron.FrameCreatedDetails + ): void => { + if (!clickedLinkFrameName || !frame || frame.parent === null) { + return + } + for (const knownFrame of iframeFrameNameByFrame.keys()) { + if (knownFrame.isDestroyed()) { + clearIframeFrameName(knownFrame) + } + } + const installAfterDomReady = (): void => { + pendingIframeRoutingInstalls.delete(frame) + installIframeClickedLinkRouting(frame) + } + pendingIframeRoutingInstalls.set(frame, installAfterDomReady) + frame.once('dom-ready', installAfterDomReady) + } + if (clickedLinkFrameName) { + guest.on('frame-created', handleFrameCreated) + } const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { // Why: popup descendants inherit the opener's owner context for routing, // but must not replace its primary guest registration. this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) } guest.on('did-create-window', handleDidCreateWindow) - guest.setWindowOpenHandler(({ url }) => { + guest.setWindowOpenHandler(({ url, frameName }) => { const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) const browserUrl = normalizeBrowserNavigationUrl(url) const externalUrl = normalizeExternalBrowserUrl(url) + const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) + const iframeFrame = frameName ? iframeFrameByFrameName.get(frameName) : undefined + let isClickedLink = Boolean( + expectedClickedLinkFrameName && frameName === expectedClickedLinkFrameName + ) + if (!isClickedLink && iframeFrame) { + isClickedLink = true + clearIframeFrameName(iframeFrame) + queueMicrotask(() => installIframeClickedLinkRouting(iframeFrame)) + } + + if (isClickedLink) { + if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(browserUrl), + action: 'opened-in-orca' + }) + } + // Why: a recognized user gesture must never fall through to a native + // popup merely because its renderer disappeared during the click. + return { action: 'deny' } + } // Why: file URLs are valid for user-opened in-pane previews, but remote // content must not create native child windows targeting local paths. @@ -739,6 +855,19 @@ export class BrowserManager { try { guest.off('destroyed', handleDestroyed) guest.off('did-create-window', handleDidCreateWindow) + if (clickedLinkFrameName) { + clickedLinkRoutingActive = false + guest.off('dom-ready', installClickedLinkRouting) + guest.off('frame-created', handleFrameCreated) + for (const [frame, install] of pendingIframeRoutingInstalls) { + if (!frame.isDestroyed()) { + frame.off('dom-ready', install) + } + } + pendingIframeRoutingInstalls.clear() + iframeFrameNameByFrame.clear() + iframeFrameByFrameName.clear() + } } catch { // guest may already be destroyed } @@ -795,6 +924,7 @@ export class BrowserManager { this.policyCleanupByGuestId.delete(guestWebContentsId) } this.policyAttachedGuestIds.delete(guestWebContentsId) + this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization as soon as its primary // owner is retired, even if Chromium has not destroyed the child yet. @@ -989,6 +1119,7 @@ export class BrowserManager { cleanup() } this.policyCleanupByGuestId.clear() + this.clickedLinkFrameNameByGuestId.clear() this.tabIdByWebContentsId.clear() this.popupOwnerContextByGuestId.clear() this.worktreeIdByTabId.clear() @@ -1881,6 +2012,24 @@ export class BrowserManager { } }) } + + private openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return false + } + const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl) + if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) { + return false + } + // Why: only the renderer owns Orca's worktree/tab model. Main forwards a + // validated URL instead of letting arbitrary guest content mutate it. + renderer.send('browser:open-link-in-orca-tab', { + browserPageId: browserTabId, + url: normalizedUrl + }) + return true + } } export const browserManager = new BrowserManager() diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 0c7012b383a..8392d07f1b9 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -34,8 +34,8 @@ export const CLAUDE_EVENTS = [ { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, // Why: subagent/teammate lifecycle feeds the sidebar's child rows and keeps // a pane 'working' while background children outlive the lead's turn. - // TeammateIdle is required because idle-but-alive teammates still report - // status "running" in Stop's background_tasks. + // TeammateIdle retires the working-only row when SubagentStop is lost; + // idle teammates still report status "running" in Stop's background_tasks. // Older Claude builds ignore unregistered event names (StopFailure precedent). { eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } }, diff --git a/src/main/crash-reporting/crash-breadcrumb-store.test.ts b/src/main/crash-reporting/crash-breadcrumb-store.test.ts index 2965007c9dd..944109a195c 100644 --- a/src/main/crash-reporting/crash-breadcrumb-store.test.ts +++ b/src/main/crash-reporting/crash-breadcrumb-store.test.ts @@ -55,27 +55,30 @@ describe('crash breadcrumb store', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-05-20T12:00:00.000Z')) - recordCoalescedCrashBreadcrumb({ + const first = recordCoalescedCrashBreadcrumb({ name: 'agent_state_changed', data: { agentType: 'claude', state: 'working' }, coalesceKey: 'agent:claude:working', minIntervalMs: 30_000 }) vi.advanceTimersByTime(1_000) - recordCoalescedCrashBreadcrumb({ + const suppressed = recordCoalescedCrashBreadcrumb({ name: 'agent_state_changed', data: { agentType: 'claude', state: 'working' }, coalesceKey: 'agent:claude:working', minIntervalMs: 30_000 }) vi.advanceTimersByTime(30_000) - recordCoalescedCrashBreadcrumb({ + const resumed = recordCoalescedCrashBreadcrumb({ name: 'agent_state_changed', data: { agentType: 'claude', state: 'working' }, coalesceKey: 'agent:claude:working', minIntervalMs: 30_000 }) + expect(first).toEqual({ suppressedSinceLast: 0 }) + expect(suppressed).toBeUndefined() + expect(resumed).toEqual({ suppressedSinceLast: 1 }) expect(getCrashBreadcrumbSnapshot().map((entry) => entry.data)).toEqual([ { agentType: 'claude', state: 'working' }, { agentType: 'claude', state: 'working', suppressedSinceLast: 1 } diff --git a/src/main/crash-reporting/crash-breadcrumb-store.ts b/src/main/crash-reporting/crash-breadcrumb-store.ts index c76daca67d6..76cf875619f 100644 --- a/src/main/crash-reporting/crash-breadcrumb-store.ts +++ b/src/main/crash-reporting/crash-breadcrumb-store.ts @@ -41,12 +41,12 @@ export function recordCoalescedCrashBreadcrumb({ data?: CrashReportBreadcrumbData coalesceKey: string minIntervalMs: number -}): void { +}): { suppressedSinceLast: number } | undefined { const now = Date.now() const previous = coalescedBreadcrumbs.get(coalesceKey) if (previous && now - previous.recordedAt < minIntervalMs) { previous.suppressed += 1 - return + return undefined } // Drop entries past their suppression window (they can no longer coalesce @@ -66,10 +66,9 @@ export function recordCoalescedCrashBreadcrumb({ } coalescedBreadcrumbs.delete(oldest.value) } - recordCrashBreadcrumb( - name, - previous?.suppressed ? { ...data, suppressedSinceLast: previous.suppressed } : data - ) + const suppressedSinceLast = previous?.suppressed ?? 0 + recordCrashBreadcrumb(name, suppressedSinceLast > 0 ? { ...data, suppressedSinceLast } : data) + return { suppressedSinceLast } } export function getCrashBreadcrumbSnapshot(): CrashReportBreadcrumb[] { diff --git a/src/main/daemon/daemon-main.test.ts b/src/main/daemon/daemon-main.test.ts index 3fa0bb1f394..3a38e656a8c 100644 --- a/src/main/daemon/daemon-main.test.ts +++ b/src/main/daemon/daemon-main.test.ts @@ -98,7 +98,7 @@ function createMockSubprocess() { write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), + forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)), signal: vi.fn(), onData(_cb: (data: string) => void) {}, onExit(cb: (code: number) => void) { diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index ce66fdfe5d0..0dac05d41c3 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -50,7 +50,7 @@ function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & { pause: vi.fn<() => void>(), resume: vi.fn<() => void>(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), + forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)), signal: vi.fn(), onData(cb) { onDataCb = cb @@ -604,7 +604,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { describe('listProcesses', () => { it('returns active sessions', async () => { - await adapter.spawn({ cols: 80, rows: 24 }) + await adapter.spawn({ cols: 80, rows: 24, cwd: '/repo/owned-before-osc7' }) await adapter.spawn({ cols: 80, rows: 24 }) const procs = await adapter.listProcesses() @@ -612,6 +612,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(procs[0]).toHaveProperty('id') expect(procs[0]).toHaveProperty('cwd') expect(procs[0]).toHaveProperty('title') + expect(procs[0].cwd).toBe('/repo/owned-before-osc7') }) }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index ce60f6f4a17..3bfbd659dd2 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -527,6 +527,9 @@ export class DaemonPtyAdapter implements IPtyProvider { if (coldRestore) { this.coldRestoreCache.set(id, coldRestore) this.sleepRestoreSessionIds.add(id) + // Why: physical exit must not mark intentional sleep as a clean end; + // the final checkpoint remains the wake-time recovery authority. + this.historyManager?.suspendSession(id) } } await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false }) @@ -779,7 +782,9 @@ export class DaemonPtyAdapter implements IPtyProvider { .filter((s) => s.isAlive) .map((s) => ({ id: s.sessionId, - cwd: s.cwd ?? '', + // Why: OSC 7 may not arrive before destructive cleanup. Spawn cwd is + // still authoritative ownership until the daemon reports a live cwd. + cwd: s.cwd ?? this.initialCwds.get(s.sessionId) ?? '', title: 'shell', ...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}) })) diff --git a/src/main/daemon/daemon-pty-provider.test.ts b/src/main/daemon/daemon-pty-provider.test.ts index 2a405326d1b..f4d4b431ad6 100644 --- a/src/main/daemon/daemon-pty-provider.test.ts +++ b/src/main/daemon/daemon-pty-provider.test.ts @@ -23,7 +23,7 @@ function createMockSubprocess(): SubprocessHandle & { write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), + forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)), signal: vi.fn(), onData(cb) { onDataCb = cb diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 3760ee89de2..b014058742f 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect, type Server, type Socket } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, rmSync, readFileSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, readFileSync } from 'node:fs' import { DaemonServer } from './daemon-server' import { DaemonClient } from './client' import { encodeNdjson } from './ndjson' @@ -29,7 +29,7 @@ function createMockSubprocess(): SubprocessHandle & { write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), + forceKill: vi.fn(() => onExitCb?.(137)), signal: vi.fn(), onData(cb) { onDataCb = cb @@ -49,6 +49,9 @@ function createMockSubprocess(): SubprocessHandle & { type DaemonServerPrivate = { server: Server | null + host: { + kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise + } clients: Map< string, { @@ -289,6 +292,36 @@ describe('DaemonServer', () => { expect(result).toBeDefined() }) + it('does not acknowledge kill until asynchronous teardown completes', async () => { + await startServer() + const daemon = server as unknown as DaemonServerPrivate + let finishKill!: () => void + const teardown = new Promise((resolve) => { + finishKill = resolve + }) + const kill = vi.spyOn(daemon.host, 'kill').mockReturnValue(teardown) + + let acknowledged = false + const routed = daemon + .routeRequest('client-1', { + id: 'kill-1', + type: 'kill', + payload: { sessionId: 'agent-session', immediate: true } + }) + .then((result) => { + acknowledged = true + return result + }) + + await Promise.resolve() + expect(kill).toHaveBeenCalledWith('agent-session', { immediate: true }) + expect(acknowledged).toBe(false) + + finishKill() + await expect(routed).resolves.toEqual({}) + expect(acknowledged).toBe(true) + }) + it('handles getCwd', async () => { await startServer() const c = await connectClient() @@ -626,5 +659,27 @@ describe('DaemonServer', () => { const c = new DaemonClient({ socketPath, tokenPath }) await expect(c.ensureConnected()).rejects.toThrow() }) + + it('still terminates via the shutdown RPC when disposal cannot prove physical exit', async () => { + await startServer() + const daemon = server as unknown as DaemonServerPrivate & { + host: { dispose: () => Promise } + } + // Why: an unreapable child rejects dispose after its exit deadline; the + // daemon must exit anyway or its replacement flow strands it as an orphan. + daemon.host.dispose = vi.fn(() => + Promise.reject(new Error('Timed out waiting for physical PTY exit')) + ) + + const c = await connectClient() + // The daemon may self-terminate before the reply flushes; callers treat + // that as success, so only the observable teardown below is asserted. + await c.request('shutdown', { killSessions: true }).catch(() => {}) + + await waitFor(() => daemon.server === null) + await waitFor(() => !existsSync(socketPath)) + const late = new DaemonClient({ socketPath, tokenPath }) + await expect(late.ensureConnected()).rejects.toThrow() + }) }) }) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index fe87022e5c5..3396493e0fd 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -149,7 +149,15 @@ export class DaemonServer { async shutdown(): Promise { this.stopStreamBacklogProbe() this.transientFactRelay.dispose() - this.host.dispose() + try { + await this.host.dispose() + } catch (err) { + // Why: an unreapable child must not block daemon exit — after exit it + // reparents to init, while a blocked daemon would orphan alongside it. + this.log.log('shutdown-dispose-failed', { + error: err instanceof Error ? err.message : String(err) + }) + } this.streamDataBatcher.clear() for (const [, client] of this.clients) { @@ -498,7 +506,7 @@ export class DaemonServer { sessionId: request.payload.sessionId, immediate: request.payload.immediate === true }) - this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate }) + await this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate }) return {} case 'signal': @@ -580,7 +588,16 @@ export class DaemonServer { killSessions: request.payload.killSessions === true }) if (request.payload.killSessions) { - this.host.dispose() + try { + await this.host.dispose() + } catch (err) { + // Why: the shutdown RPC contract is that the daemon always + // self-terminates; dispose keeps failed owners retryable, and the + // follow-up shutdown() below retries them once more before exit. + this.log.log('shutdown-dispose-failed', { + error: err instanceof Error ? err.message : String(err) + }) + } } process.nextTick(() => this.shutdown()) return {} diff --git a/src/main/daemon/daemon-spawner.test.ts b/src/main/daemon/daemon-spawner.test.ts index 429a864f841..f0367319ed5 100644 --- a/src/main/daemon/daemon-spawner.test.ts +++ b/src/main/daemon/daemon-spawner.test.ts @@ -25,7 +25,7 @@ function createMockSubprocess(): SubprocessHandle { write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), + forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)), signal: vi.fn(), onData(_cb: (data: string) => void) {}, onExit(cb: (code: number) => void) { diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 472c59a619f..b4e5fc8e26c 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1382,6 +1382,46 @@ describe('createPtySubprocess', () => { expect(proc.kill).toHaveBeenCalled() }) + it('propagates rejected graceful kills without marking the wrapper dead', () => { + const proc = mockPtyProcess() + proc.kill.mockImplementationOnce(() => { + throw new Error('native kill rejected') + }) + spawnMock.mockReturnValue(proc) + + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + expect(() => handle.kill()).toThrow('native kill rejected') + handle.write('still owned') + expect(() => handle.kill()).not.toThrow() + + expect(proc.write).toHaveBeenCalledWith('still owned') + expect(proc.kill).toHaveBeenCalledTimes(2) + }) + + it('propagates rejected force kills so the owner can retry', () => { + const proc = mockPtyProcess(77) + proc.kill.mockImplementation(() => { + throw new Error('native fallback rejected') + }) + spawnMock.mockReturnValue(proc) + const killSpy = vi + .spyOn(process, 'kill') + .mockImplementationOnce(() => { + throw new Error('SIGKILL rejected') + }) + .mockImplementationOnce(() => true) + + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + expect(() => handle.forceKill()).toThrow('SIGKILL rejected') + expect(() => handle.forceKill()).not.toThrow() + expect(killSpy).toHaveBeenCalledTimes(2) + expect(proc.kill).toHaveBeenCalledOnce() + } finally { + killSpy.mockRestore() + } + }) + it('forceKill sends SIGKILL to the child pid', () => { const proc = mockPtyProcess(77) spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index b0e69655a08..8e1f7024891 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -64,6 +64,7 @@ import { getAgentForegroundContextPaths } from '../providers/agent-foreground-co import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' import type { TuiAgent } from '../../shared/types' +import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -1199,11 +1200,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl if (dead) { return } + nodePtyKillIssued = true try { - nodePtyKillIssued = true proc.kill() - } catch { - dead = true + } catch (error) { + // Why: a rejected native kill is not proof of exit. Keep the wrapper + // live and let Session retain/retry the physical owner. + nodePtyKillIssued = false + throw error } }, forceKill: () => { @@ -1217,13 +1221,17 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return } try { - process.kill(proc.pid, 'SIGKILL') - } catch { + forceKillPosixPtyProcessGroups(proc.pid, () => { + process.kill(proc.pid, 'SIGKILL') + }) + } catch (signalError) { try { - nodePtyKillIssued = true proc.kill() + nodePtyKillIssued = true } catch { - // Process may already be dead + nodePtyKillIssued = false + // Keep the original OS failure so callers can retry the same owner. + throw signalError } } }, diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index ade8b18fe15..c022604ceb2 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -1,6 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { PRODUCER_PAUSE_FAILSAFE_MS, Session } from './session' +import { PRODUCER_PAUSE_FAILSAFE_MS, SESSION_FORCE_KILL_RETRY_MS, Session } from './session' import type { SessionState, ShellReadyState } from './types' +import type { TuiAgent } from '../../shared/types' + +const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) +vi.mock('../pty-descendant-termination', () => ({ + killWithDescendantSweep: killWithDescendantSweepMock +})) // Stub the subprocess — Session talks to it via an interface, not child_process directly. function createMockSubprocess() { @@ -86,6 +92,7 @@ describe('Session', () => { beforeEach(() => { vi.useFakeTimers() subprocess = createMockSubprocess() + killWithDescendantSweepMock.mockReset() }) afterEach(() => { @@ -98,11 +105,13 @@ describe('Session', () => { shellReadyTimeoutMs?: number cols?: number rows?: number + launchAgent?: TuiAgent }): Session { session = new Session({ sessionId: 'test-session', cols: opts?.cols ?? 80, rows: opts?.rows ?? 24, + ...(opts?.launchAgent ? { launchAgent: opts.launchAgent } : {}), subprocess, shellReadySupported: opts?.shellReadySupported ?? false, ...(opts?.shellReadyTimeoutMs !== undefined @@ -353,13 +362,15 @@ describe('Session', () => { expect(taken?.snapshot).toBeTruthy() }) - it('cancels the post-ready flush gate when force-disposing the subprocess', () => { + it('cancels the post-ready flush gate when force-disposing the subprocess', async () => { createSession({ shellReadySupported: true }) session.write('codex\n') subprocess.simulateData('\x1b]777;orca-shell-ready\x07') expect(session.shellState).toBe('ready' satisfies ShellReadyState) - session.forceKillAndDisposeSubprocess() + const dispose = session.forceKillAndDisposeSubprocess() + subprocess.simulateExit(137) + await dispose vi.advanceTimersByTime(500) expect(subprocess.written).toEqual([]) @@ -406,6 +417,55 @@ describe('Session', () => { expect(session.isTerminating).toBe(true) }) + it('allows a graceful retry when the first kill is rejected', () => { + let attempts = 0 + subprocess.kill = () => { + attempts++ + if (attempts === 1) { + throw new Error('graceful kill rejected') + } + } + createSession() + + expect(() => session.kill()).toThrow('graceful kill rejected') + expect(session.isTerminating).toBe(false) + expect(() => session.kill()).not.toThrow() + + expect(attempts).toBe(2) + expect(session.isTerminating).toBe(true) + }) + + it('non-agent kill stays synchronous and never routes through the descendant sweep', () => { + createSession() + session.kill() + expect(subprocess.killed).toBe(true) + expect(killWithDescendantSweepMock).not.toHaveBeenCalled() + }) + + it('agent kill routes through the descendant sweep with the subprocess as root', () => { + createSession({ launchAgent: 'claude' }) + session.kill() + expect(killWithDescendantSweepMock).toHaveBeenCalledWith( + subprocess.pid, + expect.any(Function), + expect.objectContaining({ ownsRoot: expect.any(Function) }) + ) + // The root kill is deferred to the sweep's snapshot-first sequencing. + expect(subprocess.killed).toBe(false) + const killRoot = killWithDescendantSweepMock.mock.calls[0][1] as () => void + killRoot() + expect(subprocess.killed).toBe(true) + }) + + it('agent kill root callback is a no-op after the session already exited', () => { + createSession({ launchAgent: 'claude' }) + session.kill() + const killRoot = killWithDescendantSweepMock.mock.calls[0][1] as () => void + subprocess.simulateExit(0) + killRoot() + expect(subprocess.killed).toBe(false) + }) + it('notifies attached clients on exit after kill', async () => { vi.useRealTimers() createSession() @@ -422,7 +482,7 @@ describe('Session', () => { expect(exitCodes).toEqual([0]) }) - it('force-disposes after 5s if subprocess does not exit', () => { + it('force-kills after 5s but retains ownership until subprocess exit', () => { createSession() // Override kill to NOT trigger exit subprocess.kill = () => {} @@ -432,11 +492,58 @@ describe('Session', () => { expect(session.state).not.toBe('exited') vi.advanceTimersByTime(5_000) - expect(session.state).toBe('exited') expect(forceKillSpy).toHaveBeenCalled() + expect(session.state).toBe('running') + expect(session.isTerminating).toBe(true) + + subprocess.simulateExit(137) + expect(session.state).toBe('exited') }) - it('ignores late data and exit after force-dispose', () => { + it('retries a rejected destructive force kill while retaining ownership', async () => { + let forceKillAttempts = 0 + subprocess.forceKill = () => { + forceKillAttempts++ + if (forceKillAttempts === 1) { + throw new Error('force kill rejected') + } + } + createSession() + + const shutdown = session.forceKillAndWaitForExit() + expect(forceKillAttempts).toBe(1) + await vi.advanceTimersByTimeAsync(SESSION_FORCE_KILL_RETRY_MS) + expect(forceKillAttempts).toBe(2) + subprocess.simulateExit(137) + await shutdown + + expect(session.state).toBe('exited') + }) + + it('retries a rejected graceful-deadline force kill', async () => { + subprocess.kill = () => {} + let forceKillAttempts = 0 + subprocess.forceKill = () => { + forceKillAttempts++ + if (forceKillAttempts === 1) { + throw new Error('transient graceful fallback failure') + } + } + createSession() + + session.kill() + await vi.advanceTimersByTimeAsync(5_000) + expect(forceKillAttempts).toBe(1) + expect(session.isTerminating).toBe(true) + + await vi.advanceTimersByTimeAsync(SESSION_FORCE_KILL_RETRY_MS) + expect(forceKillAttempts).toBe(2) + subprocess.simulateExit(137) + expect(session.state).toBe('exited') + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps late data and the real exit code until physical exit', () => { createSession() subprocess.kill = () => {} const onData = vi.fn() @@ -449,10 +556,10 @@ describe('Session', () => { subprocess.simulateData('late output') subprocess.simulateExit(23) - expect(onData).not.toHaveBeenCalled() + expect(onData).toHaveBeenCalledWith('late output') expect(onExit).toHaveBeenCalledTimes(1) - expect(onExit).toHaveBeenCalledWith(-1) - expect(session.exitCode).toBe(-1) + expect(onExit).toHaveBeenCalledWith(23) + expect(session.exitCode).toBe(23) }) }) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index b9c82a48174..a40cce8480a 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -9,7 +9,9 @@ import { type ShellReadyScanState } from '../shell-ready-marker-scanner' import { isPowerShellProcess } from '../../shared/shell-process-detection' +import { killWithDescendantSweep } from '../pty-descendant-termination' import type { TuiAgent } from '../../shared/types' +import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import type { PendingOutputRecord, SessionState, @@ -23,6 +25,9 @@ const SHELL_READY_TIMEOUT_MS = 15_000 // older daemon/local paths that still report shell-ready support for Codex. export const CODEX_SHELL_READY_TIMEOUT_MS = 300 const KILL_TIMEOUT_MS = 5_000 +export const IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS = 8_000 +export const SESSION_FORCE_KILL_RETRY_MS = 250 +const SESSION_FORCE_KILL_MAX_ATTEMPTS = 2 // Why: pending records exist so the 5s checkpoint can persist increments // instead of re-serializing the whole buffer. If no client drains them (main // process gone, history disabled), memory must stay bounded — past the cap we @@ -125,6 +130,9 @@ export class Session { private producerPaused = false private producerPauseFailsafeTimer: ReturnType | null = null private readonly _historySeeded: boolean | undefined + private forceKillSent = false + private subprocessDisposed = false + private readonly physicalExit = new PhysicalExitTracker() constructor(opts: SessionOptions) { this.sessionId = opts.sessionId @@ -186,6 +194,19 @@ export class Session { return this._isTerminating } + /** Claims termination synchronously so attach/re-entry cannot race async + * teardown preparation. Returns false when another owner already claimed it. */ + beginTermination(): boolean { + if (this._state === 'exited' || this._isTerminating) { + return false + } + this._isTerminating = true + // Why: a paused child can be blocked inside write(); resume before any + // async snapshot so it can handle termination promptly. + this.releaseProducerPause({ resume: true }) + return true + } + get pid(): number { return this.subprocess.pid } @@ -259,21 +280,102 @@ export class Session { } kill(): void { - if (this._state === 'exited' || this._isTerminating) { + if (!this.beginTermination()) { return } - this._isTerminating = true + if (!this.launchAgent) { + this.signalTerminationRoot() + } else { + // Why: agent tool children live in detached process groups a dying + // shell's SIGHUP never reaches. The bounded snapshot briefly defers the + // signal; the kill timer below starts now, so force-dispose timing is + // unaffected. + void Promise.resolve( + killWithDescendantSweep( + this.subprocess.pid, + () => { + this.signalTerminationRoot() + }, + { + // Why: if the root exits during ps, its numeric PID can be recycled. + // Never apply that stale snapshot to a different process tree. + ownsRoot: () => this.isAlive + } + ) + ).catch((error) => { + if (this.isAlive) { + this.resetTerminationAfterSignalFailure() + } + console.warn('[Session] descendant-aware graceful kill failed:', error) + }) + } + this.scheduleForceDisposeFallback() + } - // Why: a paused child can be blocked inside write(); resume before - // signalling so it can run signal handlers and actually exit. - this.releaseProducerPause({ resume: true }) - this.subprocess.kill() + /** Signals a root whose descendant snapshot has completed. */ + signalTerminationRoot(): void { + if (this._state === 'exited') { + return + } + try { + this.subprocess.kill() + } catch (error) { + // Why: rejected signalling is not termination. Reopen the session so a + // later graceful or destructive retry can still target the live child. + this.resetTerminationAfterSignalFailure() + throw error + } + } + /** Starts the existing graceful-kill deadline when a coordinator owns the + * snapshot-first portion of teardown. */ + scheduleForceDisposeFallback(): void { + if (this.killTimer) { + return + } + this.armForceKillFallback(KILL_TIMEOUT_MS, SESSION_FORCE_KILL_MAX_ATTEMPTS) + } + + private resetTerminationAfterSignalFailure(): void { + this._isTerminating = false + if (this.killTimer) { + clearTimeout(this.killTimer) + this.killTimer = null + } + } + + private armForceKillFallback(delayMs: number, attemptsRemaining: number): void { this.killTimer = setTimeout(() => { + this.killTimer = null if (this._state !== 'exited') { - this.forceDispose() + try { + this.requestForceKill() + } catch (error) { + console.warn('[Session] failed to force-kill terminating subprocess:', error) + // Why: a transient SIGKILL rejection must not consume the only + // fallback owner after graceful shutdown has already returned. + if (attemptsRemaining > 1) { + this.armForceKillFallback(SESSION_FORCE_KILL_RETRY_MS, attemptsRemaining - 1) + } + } } - }, KILL_TIMEOUT_MS) + }, delayMs) + } + + async forceKillAndWaitForExit( + timeoutMs = IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS + ): Promise { + if (this._state === 'exited') { + return + } + if (!this._isTerminating) { + this._isTerminating = true + this.releaseProducerPause({ resume: true }) + } + // Why: destructive cleanup joins a graceful termination but escalates it + // now; waiting for the 5s timer would spend most of the physical-exit budget. + await this.requestForceKillWithRetry() + await this.waitForPhysicalExit(timeoutMs) } signal(sig: string): void { @@ -475,26 +577,14 @@ export class Session { * out onExit to attached clients — renderer reconnects cold after daemon * exit. Callers MUST check isAlive first; see disposeSubprocess() for the * already-exited case. */ - forceKillAndDisposeSubprocess(): void { - // Why: forceKill before #teardownSubprocess. The helper's subprocess.dispose() - // neutralizes node-pty's proc.kill on POSIX (to kill the SIGHUP-to-recycled-pid - // hazard). subprocess.forceKill uses process.kill(pid, 'SIGKILL') directly - // (pty-subprocess.ts) — unaffected by the neutralization, because it does not - // go through proc.kill. SIGKILL is not ignorable; any child that would have - // survived the 5s timer is reaped immediately. - try { - this.subprocess.forceKill() - } catch { - /* swallow — child may already be gone */ - } - this.#teardownSubprocess() - this._state = 'exited' - // Why: free the headless emulator's scrollback here too (this path skips - // dispose()). Matches forceDispose(); reaping just drops the map entry. - this.emulator.dispose() + async forceKillAndDisposeSubprocess(): Promise { + // Why: daemon exit cannot neutralize the native handle until a bounded + // retry is accepted and onExit proves the child was physically reaped. + await this.forceKillAndWaitForExit() + this.dispose() } - /** Private: shared teardown helper called by dispose(), forceDispose(), and + /** Private: shared teardown helper called by dispose() and * forceKillAndDisposeSubprocess(). Flips `_disposed`, clears pending timers, * and forwards to subprocess.dispose() exactly once. Does NOT set `_state` — * the caller owns the state transition AFTER capturing any invariants that @@ -518,6 +608,14 @@ export class Session { this.shellReadyScanState = null this.preReadyStdinQueue = [] this.postReadyFlushGate.clear() + this.disposeSubprocessHandle() + } + + private disposeSubprocessHandle(): void { + if (this.subprocessDisposed) { + return + } + this.subprocessDisposed = true try { this.subprocess.dispose() } catch (err) { @@ -588,12 +686,14 @@ export class Session { } private handleSubprocessExit(code: number): void { + this.physicalExit.markExited() if (this._disposed) { return } this._exitCode = code this._state = 'exited' + this._isTerminating = false // Why resume:false — the child is reaped, so there is nothing to unblock; // only the failsafe timer must not outlive the session. this.releaseProducerPause({ resume: false }) @@ -615,12 +715,7 @@ export class Session { // that helper flips `_disposed = true`, which would short-circuit the later // Session.dispose() call from TerminalHost.reapSession (wired via onExit // below) — skipping attachedClients/emulator/postReadyFlushGate cleanup. - // Call subprocess.dispose() directly inside try/catch. - try { - this.subprocess.dispose() - } catch { - /* swallow — must not prevent exit-code fanout below */ - } + this.disposeSubprocessHandle() for (const client of this.attachedClients) { client.onExit(code) @@ -676,40 +771,49 @@ export class Session { } } - private forceDispose(): void { - if (this._state === 'exited') { + private requestForceKill(): void { + if (this._state === 'exited' || this.forceKillSent) { return } - // Why: forceKill BEFORE #teardownSubprocess. Order is load-bearing — the - // helper's subprocess.dispose() neutralizes proc.kill on POSIX (to defuse - // the SIGHUP-to-recycled-pid hazard inside node-pty). forceKill uses - // process.kill(pid, 'SIGKILL') directly and is unaffected by that - // neutralization. Must NOT flip `_disposed` here before #teardownSubprocess - // runs, or the helper would early-return and skip subprocess.dispose() — - // the ptmx fd would leak on every kill-timeout (this whole doc's target). + this.forceKillSent = true try { this.subprocess.forceKill() - } catch { - /* already dead */ + } catch (error) { + this.forceKillSent = false + throw error } - this._exitCode = -1 - this._isTerminating = false + } - this.#teardownSubprocess() - this._state = 'exited' - - const clients = this.attachedClients - this.attachedClients = [] - this.preReadyStdinQueue = [] - this.postReadyFlushGate.clear() - this.emulator.dispose() - - for (const client of clients) { - client.onExit(-1) + private async requestForceKillWithRetry(): Promise { + let lastError: unknown + for (let attempt = 0; attempt < SESSION_FORCE_KILL_MAX_ATTEMPTS; attempt++) { + try { + this.requestForceKill() + return + } catch (error) { + lastError = error + } + if (attempt + 1 < SESSION_FORCE_KILL_MAX_ATTEMPTS) { + try { + await this.physicalExit.waitForExit( + SESSION_FORCE_KILL_RETRY_MS, + () => new Error(`Retrying force-kill for PTY ${this.sessionId}`) + ) + return + } catch { + // The bounded waiter detached; retry the still-owned subprocess. + } + } } + throw lastError + } - // Why: reap from the host map on the kill-timeout path too (emulator already - // disposed above; reapSession's dispose() call is a no-op and just drops it). - this.onSessionExit?.(-1) + private waitForPhysicalExit(timeoutMs: number): Promise { + // Why: timed-out destructive retries must detach from an unkillable child; + // otherwise every retry stays retained until the process eventually exits. + return this.physicalExit.waitForExit( + timeoutMs, + () => new Error(`Timed out waiting for PTY process exit: ${this.sessionId}`) + ) } } diff --git a/src/main/daemon/slow-daemon-session-verification.test.ts b/src/main/daemon/slow-daemon-session-verification.test.ts index 15634530dcd..fc65e275e2b 100644 --- a/src/main/daemon/slow-daemon-session-verification.test.ts +++ b/src/main/daemon/slow-daemon-session-verification.test.ts @@ -21,16 +21,19 @@ import type { SubprocessHandle } from './session' const RESPONSE_DELAY_MS = 3_500 function createMockSubprocess(): SubprocessHandle { + let onExitCb: ((code: number) => void) | null = null return { pid: 55555, getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), - kill: vi.fn(), - forceKill: vi.fn(), + kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), + forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)), signal: vi.fn(), onData: vi.fn(), - onExit: vi.fn(), + onExit: vi.fn((callback: (code: number) => void) => { + onExitCb = callback + }), dispose: vi.fn() } } diff --git a/src/main/daemon/terminal-host-session-reaping-leak.test.ts b/src/main/daemon/terminal-host-session-reaping-leak.test.ts index 30d1736e42d..221a5581fe7 100644 --- a/src/main/daemon/terminal-host-session-reaping-leak.test.ts +++ b/src/main/daemon/terminal-host-session-reaping-leak.test.ts @@ -28,7 +28,7 @@ function createMockSubprocess(): SubprocessHandle & { kill: vi.fn(() => { setTimeout(() => onExitCb?.(0), 5) }), - forceKill: vi.fn(), + forceKill: vi.fn(() => onExitCb?.(137)), signal: vi.fn(), onData(cb) { onDataCb = cb @@ -60,8 +60,8 @@ describe('TerminalHost dead-session reaping (leak regression)', () => { host = new TerminalHost({ spawnSubprocess: spawnFn }) }) - afterEach(() => { - host.dispose() + afterEach(async () => { + await host.dispose() emulatorDispose.mockRestore() }) @@ -113,23 +113,32 @@ describe('TerminalHost dead-session reaping (leak regression)', () => { rows: 24, streamClient: streamClient() }) + lastSubprocess.forceKill = vi.fn() - host.kill('session-1', { immediate: true }) + const killed = host.kill('session-1', { immediate: true }) + + expect(emulatorDispose).not.toHaveBeenCalled() + expect(host.listSessions()).toHaveLength(1) + lastSubprocess._onExitCb?.(137) + await killed // Emulator freed and session dropped from the map (no lingering dead entry). expect(emulatorDispose).toHaveBeenCalledTimes(1) expect(host.listSessions()).toHaveLength(0) }) - it('reaps a session whose graceful kill times out (forceDispose path)', async () => { + it('retains a graceful-timeout session until the forced child physically exits', async () => { vi.useFakeTimers() try { + let stubbornSubprocess: ReturnType | undefined const stubbornHost = new TerminalHost({ spawnSubprocess: () => { const sub = createMockSubprocess() // Stubborn child: ignores graceful kill, so the KILL_TIMEOUT_MS timer // must force-dispose it. sub.kill = vi.fn() + sub.forceKill = vi.fn() + stubbornSubprocess = sub return sub } }) @@ -144,13 +153,16 @@ describe('TerminalHost dead-session reaping (leak regression)', () => { stubbornHost.kill('stubborn') expect(emulatorDispose).not.toHaveBeenCalled() - // The 5s KILL_TIMEOUT_MS fallback fires forceDispose, which disposes the - // emulator and reaps the session via the onExit hook. + // The 5s fallback sends SIGKILL but cannot claim physical cleanup yet. vi.advanceTimersByTime(5000) + expect(emulatorDispose).not.toHaveBeenCalled() + expect(stubbornHost.listSessions()).toHaveLength(1) + + stubbornSubprocess?._onExitCb?.(137) expect(emulatorDispose).toHaveBeenCalledTimes(1) expect(stubbornHost.listSessions()).toHaveLength(0) - stubbornHost.dispose() + await stubbornHost.dispose() } finally { vi.useRealTimers() } diff --git a/src/main/daemon/terminal-host-session-shutdown.ts b/src/main/daemon/terminal-host-session-shutdown.ts new file mode 100644 index 00000000000..41080ff1249 --- /dev/null +++ b/src/main/daemon/terminal-host-session-shutdown.ts @@ -0,0 +1,63 @@ +import type { Session } from './session' +import type { TakePendingOutputResult, TerminalSnapshot } from './types' + +function checkpointTerminalHostSessions( + sessions: ReadonlyMap, + onFinalCheckpoint?: ( + sessionId: string, + snapshot: TerminalSnapshot, + records: TakePendingOutputResult['records'] + ) => void +): void { + if (!onFinalCheckpoint) { + return + } + for (const [sessionId, session] of sessions) { + if (!session.isAlive) { + continue + } + const take = session.takePendingOutput(true, { teardownSnapshot: true }) + if (!take?.snapshot) { + continue + } + try { + onFinalCheckpoint(sessionId, take.snapshot, take.records) + } catch { + // Final checkpoints are best-effort and must not block native teardown. + } + } +} + +async function disposeTerminalHostSessions(sessions: Iterable): Promise { + const results = await Promise.allSettled( + [...sessions].map(async (session) => { + session.detachAllClients() + // Why: live children retain native ownership until physical exit, while + // exited children must release handles without signalling a recycled pid. + if (session.isAlive) { + await session.forceKillAndDisposeSubprocess() + } else { + session.disposeSubprocess() + } + }) + ) + const rejected = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + if (rejected) { + throw rejected.reason + } +} + +export async function shutdownTerminalHostSessions( + sessions: Map, + onFinalCheckpoint?: ( + sessionId: string, + snapshot: TerminalSnapshot, + records: TakePendingOutputResult['records'] + ) => void +): Promise { + checkpointTerminalHostSessions(sessions, onFinalCheckpoint) + await disposeTerminalHostSessions(sessions.values()) + sessions.clear() +} diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 2a12b5bf4ab..cee72eee1cf 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -1,8 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Session, type SubprocessHandle } from './session' +import { + IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS, + SESSION_FORCE_KILL_RETRY_MS, + Session, + type SubprocessHandle +} from './session' import { TerminalHost } from './terminal-host' import type { TuiAgent } from '../../shared/types' +const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) +vi.mock('../pty-descendant-termination', () => ({ + killWithDescendantSweep: killWithDescendantSweepMock +})) + function createMockSubprocess( options: { startupCommandDeliveredInShellArgs?: boolean; shellPath?: string } = {} ): SubprocessHandle { @@ -20,7 +30,7 @@ function createMockSubprocess( kill: vi.fn(() => { setTimeout(() => onExitCb?.(0), 5) }), - forceKill: vi.fn(), + forceKill: vi.fn(() => onExitCb?.(137)), signal: vi.fn(), onData(cb) { onDataCb = cb @@ -58,6 +68,7 @@ describe('TerminalHost', () => { } beforeEach(() => { + killWithDescendantSweepMock.mockReset() spawnFn = vi.fn(() => { const sub = createMockSubprocess() as ReturnType & { _onDataCb: ((data: string) => void) | null @@ -69,8 +80,8 @@ describe('TerminalHost', () => { host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) }) - afterEach(() => { - host.dispose() + afterEach(async () => { + await host.dispose() }) describe('createOrAttach', () => { @@ -209,7 +220,7 @@ describe('TerminalHost', () => { lastSubprocess = sub return sub }) - host.dispose() + await host.dispose() host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) await host.createOrAttach({ @@ -237,7 +248,7 @@ describe('TerminalHost', () => { lastSubprocess = sub return sub }) - host.dispose() + await host.dispose() host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) await host.createOrAttach({ @@ -265,7 +276,7 @@ describe('TerminalHost', () => { lastSubprocess = sub return sub }) - host.dispose() + await host.dispose() host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) await host.createOrAttach({ @@ -291,7 +302,7 @@ describe('TerminalHost', () => { lastSubprocess = sub return sub }) - host.dispose() + await host.dispose() host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) await host.createOrAttach({ @@ -378,6 +389,29 @@ describe('TerminalHost', () => { expect(host.isKilled('session-1')).toBe(true) }) + it('does not tombstone a session when graceful kill admission fails', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + lastSubprocess.kill = vi.fn(() => { + throw new Error('signal rejected') + }) + + expect(() => host.kill('session-1')).toThrow('signal rejected') + expect(host.isKilled('session-1')).toBe(false) + await expect( + host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + ).resolves.toMatchObject({ isNew: false }) + }) + it('force-kills immediately when requested', async () => { await host.createOrAttach({ sessionId: 'session-1', @@ -385,18 +419,287 @@ describe('TerminalHost', () => { rows: 24, streamClient: { onData: vi.fn(), onExit: vi.fn() } }) + lastSubprocess.forceKill = vi.fn() - host.kill('session-1', { immediate: true }) + const killed = host.kill('session-1', { immediate: true }) expect(lastSubprocess.kill).not.toHaveBeenCalled() expect(lastSubprocess.forceKill).toHaveBeenCalled() + expect(killWithDescendantSweepMock).not.toHaveBeenCalled() + expect(lastSubprocess.dispose).not.toHaveBeenCalled() + expect(host.listSessions()).toHaveLength(1) + + lastSubprocess._onExitCb?.(137) + await killed + expect(lastSubprocess.dispose).toHaveBeenCalled() + expect(host.listSessions()).toHaveLength(0) expect(host.isKilled('session-1')).toBe(true) }) + it('escalates an already-graceful termination and joins its physical exit', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + lastSubprocess.forceKill = vi.fn() + + await host.kill('session-1') + const immediate = host.kill('session-1', { immediate: true }) + let settled = false + void immediate.then(() => { + settled = true + }) + await Promise.resolve() + + expect(lastSubprocess.kill).toHaveBeenCalledTimes(1) + expect(lastSubprocess.forceKill).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + expect(host.listSessions()).toHaveLength(1) + + lastSubprocess._onExitCb?.(137) + await immediate + expect(host.listSessions()).toHaveLength(0) + }) + + it('retains an immediate-kill session when physical exit times out', async () => { + vi.useFakeTimers() + try { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + lastSubprocess.forceKill = vi.fn() + + const killed = host.kill('session-1', { immediate: true }) + const rejected = expect(killed).rejects.toThrow('Timed out waiting for PTY process exit') + await vi.advanceTimersByTimeAsync(IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS) + await rejected + + expect(lastSubprocess.forceKill).toHaveBeenCalledTimes(1) + expect(lastSubprocess.dispose).not.toHaveBeenCalled() + expect(host.listSessions()).toHaveLength(1) + await expect( + host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + ).rejects.toThrow('Session not found') + + lastSubprocess._onExitCb?.(137) + expect(host.listSessions()).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) + it('throws for non-existent session', () => { expect(() => host.kill('missing')).toThrow('Session not found') }) + + it('agent immediate kill routes through the descendant sweep and defers the force-kill to it', async () => { + await host.createOrAttach({ + sessionId: 'agent-1', + cols: 80, + rows: 24, + launchAgent: 'claude', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + lastSubprocess.forceKill = vi.fn() + + const killing = host.kill('agent-1', { immediate: true }) + + // Why order matters: force-killing first would let orphans reparent to + // pid 1 and escape the sweep's ppid walk entirely. + expect(killWithDescendantSweepMock).toHaveBeenCalledWith( + 99999, + expect.any(Function), + expect.objectContaining({ ownsRoot: expect.any(Function) }) + ) + expect(lastSubprocess.forceKill).not.toHaveBeenCalled() + expect(host.isKilled('agent-1')).toBe(true) + + const finish = killWithDescendantSweepMock.mock.calls[0][1] as () => void + finish() + expect(lastSubprocess.forceKill).toHaveBeenCalled() + expect(lastSubprocess.dispose).not.toHaveBeenCalled() + + lastSubprocess._onExitCb?.(137) + await killing + expect(lastSubprocess.dispose).toHaveBeenCalled() + }) + + it('rejects reattach while an agent immediate-kill snapshot is pending', async () => { + let finishSweep!: () => void + killWithDescendantSweepMock.mockImplementation( + (_pid: number, finish: () => void) => + new Promise((resolve) => { + finishSweep = () => { + finish() + resolve() + } + }) + ) + await host.createOrAttach({ + sessionId: 'agent-reattach', + cols: 80, + rows: 24, + launchAgent: 'claude', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const killing = host.kill('agent-reattach', { immediate: true }) + await expect( + host.createOrAttach({ + sessionId: 'agent-reattach', + cols: 80, + rows: 24, + launchAgent: 'claude', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + ).rejects.toThrow('Session not found') + expect(lastSubprocess.forceKill).not.toHaveBeenCalled() + + finishSweep() + lastSubprocess._onExitCb?.(137) + await killing + expect(lastSubprocess.forceKill).toHaveBeenCalledOnce() + }) + + it('coalesces duplicate immediate kill while descendant capture is pending', async () => { + const sweep = new Promise(() => {}) + killWithDescendantSweepMock.mockReturnValue(sweep) + await host.createOrAttach({ + sessionId: 'agent-duplicate-kill', + cols: 80, + rows: 24, + launchAgent: 'claude', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const first = host.kill('agent-duplicate-kill', { immediate: true }) + // The root can exit while the descendant scan is pending. Duplicate RPCs + // still own the original completion even after the session was reaped. + lastSubprocess._onExitCb?.(0) + const second = host.kill('agent-duplicate-kill', { immediate: true }) + + expect(killWithDescendantSweepMock).toHaveBeenCalledOnce() + expect(second).toBe(first) + expect(lastSubprocess.forceKill).not.toHaveBeenCalled() + }) + + it('keeps a naturally-exited id reserved until teardown finishes without re-killing its pid', async () => { + let completeSweep!: () => void + killWithDescendantSweepMock.mockImplementation( + (_pid: number, finish: () => void) => + new Promise((resolve) => { + completeSweep = () => { + finish() + resolve() + } + }) + ) + await host.createOrAttach({ + sessionId: 'agent-natural-exit', + cols: 80, + rows: 24, + launchAgent: 'claude', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + const retiredSubprocess = lastSubprocess + + const killing = host.kill('agent-natural-exit', { immediate: true }) + retiredSubprocess._onExitCb?.(0) + await expect( + host.createOrAttach({ + sessionId: 'agent-natural-exit', + cols: 80, + rows: 24, + launchAgent: 'claude', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + ).rejects.toThrow('Session not found') + + completeSweep() + await killing + expect(retiredSubprocess.forceKill).not.toHaveBeenCalled() + + await expect( + host.createOrAttach({ + sessionId: 'agent-natural-exit', + cols: 80, + rows: 24, + launchAgent: 'claude', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + ).resolves.toEqual(expect.objectContaining({ isNew: true })) + expect(spawnFn).toHaveBeenCalledTimes(2) + }) + + it('upgrades a pending graceful agent teardown when immediate kill arrives', async () => { + let completeSweep!: () => void + killWithDescendantSweepMock.mockImplementation( + (_pid: number, finish: () => void) => + new Promise((resolve) => { + completeSweep = () => { + finish() + resolve() + } + }) + ) + await host.createOrAttach({ + sessionId: 'agent-upgrade-kill', + cols: 80, + rows: 24, + launchAgent: 'claude', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const graceful = host.kill('agent-upgrade-kill') + const immediate = host.kill('agent-upgrade-kill', { immediate: true }) + expect(immediate).toBe(graceful) + expect(lastSubprocess.kill).not.toHaveBeenCalled() + expect(lastSubprocess.forceKill).not.toHaveBeenCalled() + + completeSweep() + lastSubprocess._onExitCb?.(137) + await Promise.all([graceful, immediate]) + expect(lastSubprocess.kill).not.toHaveBeenCalled() + expect(lastSubprocess.forceKill).toHaveBeenCalledOnce() + expect(lastSubprocess.dispose).toHaveBeenCalledOnce() + }) + + it('force-kills when immediate teardown follows a completed graceful snapshot', async () => { + killWithDescendantSweepMock.mockImplementation(async (_pid: number, finish: () => void) => + finish() + ) + await host.createOrAttach({ + sessionId: 'agent-post-snapshot-upgrade', + cols: 80, + rows: 24, + launchAgent: 'claude', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + await host.kill('agent-post-snapshot-upgrade') + expect(lastSubprocess.kill).toHaveBeenCalledOnce() + expect(lastSubprocess.forceKill).not.toHaveBeenCalled() + lastSubprocess.forceKill = vi.fn() + + const immediate = host.kill('agent-post-snapshot-upgrade', { immediate: true }) + expect(lastSubprocess.forceKill).toHaveBeenCalledOnce() + expect(lastSubprocess.dispose).not.toHaveBeenCalled() + + lastSubprocess._onExitCb?.(137) + await immediate + expect(lastSubprocess.dispose).toHaveBeenCalledOnce() + }) }) describe('signal', () => { @@ -478,7 +781,7 @@ describe('TerminalHost', () => { describe('tombstones', () => { it('caps tombstones at limit', async () => { - host.dispose() + await host.dispose() host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn, maxTombstones: 3 }) for (let i = 0; i < 5; i++) { @@ -506,10 +809,9 @@ describe('TerminalHost', () => { streamClient: { onData: vi.fn(), onExit: vi.fn() } }) - host.dispose() - // Why: for LIVE sessions, dispose() calls session.forceKillAndDisposeSubprocess() - // which sends SIGKILL (forceKill) and releases the ptmx fd (subprocess.dispose) - // synchronously — no longer relies on the 5s KILL_TIMEOUT_MS fallback. + await host.dispose() + // Why: live sessions retain the native owner until force-kill is accepted + // and physical exit proves the child can no longer hold the ptmx fd. // Exited sessions take the disposeSubprocess() path instead (see the test // below). See docs/fix-pty-fd-leak.md. expect(lastSubprocess.forceKill).toHaveBeenCalled() @@ -517,7 +819,7 @@ describe('TerminalHost', () => { }) it('releases held shell-ready marker prefixes before final checkpoint', async () => { - host.dispose() + await host.dispose() const onFinalCheckpoint = vi.fn() host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn, @@ -532,13 +834,54 @@ describe('TerminalHost', () => { }) lastSubprocess._onDataCb?.('\x1b]777;orca-shell-ready') - host.dispose() + await host.dispose() expect(onFinalCheckpoint).toHaveBeenCalledWith('session-1', expect.any(Object), [ { kind: 'output', data: '\x1b]777;orca-shell-ready' } ]) }) + it('fences creation and retries a rejected force kill before dropping ownership', async () => { + vi.useFakeTimers() + try { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + let attempts = 0 + const forceKill = vi.fn(() => { + attempts++ + if (attempts === 1) { + throw new Error('transient daemon dispose kill failure') + } + lastSubprocess._onExitCb?.(137) + }) + lastSubprocess.forceKill = forceKill + + const dispose = host.dispose() + expect(forceKill).toHaveBeenCalledTimes(1) + expect(host.dispose()).toBe(dispose) + await expect( + host.createOrAttach({ + sessionId: 'late-session', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + ).rejects.toThrow('Terminal host is shutting down') + + await vi.advanceTimersByTimeAsync(SESSION_FORCE_KILL_RETRY_MS) + await dispose + expect(forceKill).toHaveBeenCalledTimes(2) + expect(lastSubprocess.dispose).toHaveBeenCalled() + expect(host.listSessions()).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + it('does not list exited sessions', async () => { await host.createOrAttach({ sessionId: 'session-1', @@ -581,7 +924,7 @@ describe('TerminalHost', () => { }) const liveSub = lastSubprocess - host.dispose() + await host.dispose() expect(exitedSub.forceKill).not.toHaveBeenCalled() expect(exitedSub.dispose).toHaveBeenCalled() diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index e9726742bc8..7bf06f57012 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -4,9 +4,15 @@ import { shellPathSupportsPtyStartupBarrier } from './shell-ready' import { resolveProcessCwd } from '../providers/process-cwd' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' -import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types' -import { SessionNotFoundError } from './types' +import { + SessionNotFoundError, + type SessionInfo, + type TakePendingOutputResult, + type TerminalSnapshot +} from './types' import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' +import { shutdownTerminalHostSessions } from './terminal-host-session-shutdown' +import { TerminalSessionTeardown } from './terminal-session-teardown' export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' @@ -41,10 +47,13 @@ export type TerminalHostOptions = { export class TerminalHost { private sessions = new Map() + private sessionTeardown = new TerminalSessionTeardown(this.sessions) private killedTombstones = new Map() private spawnSubprocess: TerminalHostOptions['spawnSubprocess'] private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint'] private maxTombstones: number + private creationFenced = false + private disposePromise: Promise | null = null constructor(opts: TerminalHostOptions) { this.spawnSubprocess = opts.spawnSubprocess @@ -59,13 +68,18 @@ export class TerminalHost { * already deliver them through shell launch arguments. */ async createOrAttach(opts: CreateOrAttachOptions): Promise { + if (this.creationFenced) { + throw new Error('Terminal host is shutting down') + } const existing = this.sessions.get(opts.sessionId) - // Why: a session that has been asked to terminate (kill() called but the - // subprocess hasn't exited yet) must not be reattached. Reattaching would - // hand the caller a handle that races with the in-flight exit, and any - // subsequent operation (write/kill/resize) would fail once the subprocess - // finally exits. Treat terminating sessions the same as fully-exited ones. + // Why: async descendant capture must finish before anyone can attach or + // dispose/recreate this id. Disposing here would kill the root before the + // snapshot and reattaching would hand out a doomed session. + if (this.sessionTeardown.get(opts.sessionId) || existing?.isTerminating) { + throw new SessionNotFoundError(opts.sessionId) + } + if (existing && existing.isAlive && !existing.isTerminating) { const snapshot = existing.getSnapshot() existing.detachAllClients() @@ -81,6 +95,12 @@ export class TerminalHost { } } + if (existing?.isAlive && existing.isTerminating) { + // Why: replacing a SIGKILLed-but-unreaped child would lose ownership of + // its native handles and let the same session id hide two generations. + throw new Error(`Session "${opts.sessionId}" is terminating`) + } + // Clean up dead session if present if (existing) { existing.dispose() @@ -194,25 +214,23 @@ export class TerminalHost { this.sessions.get(sessionId)?.resumeProducer() } - kill(sessionId: string, opts: { immediate?: boolean } = {}): void { - const session = this.getAliveSession(sessionId) - this.recordTombstone(sessionId) - if (opts.immediate) { - session.forceKillAndDisposeSubprocess() - // Why: the immediate path tears down synchronously without firing the - // session's onExit hook, so reap it here. The graceful path below funnels - // through Session.handleSubprocessExit -> onExit -> reapSession. - this.reapSession(sessionId) - return + kill(sessionId: string, opts: { immediate?: boolean } = {}): Promise { + const pending = this.sessionTeardown.get(sessionId) + if (pending) { + return Promise.resolve( + opts.immediate ? this.sessionTeardown.requestImmediate(sessionId) : pending + ) } - session.kill() + const session = this.getAliveSession(sessionId) + const killed = this.sessionTeardown.killSession(sessionId, session, opts.immediate === true) + this.recordTombstone(sessionId) + return Promise.resolve(killed) } // Why: dispose a dead session's headless emulator and drop it from the map so // exited terminals don't pin ~5000 rows of scrollback for the daemon's life. // No-ops on live sessions (a live session must never be disposed here) and on - // already-reaped/unknown ids. Wired as the Session onExit hook and also called - // on the immediate-kill path. + // already-reaped/unknown ids. Wired as the Session onExit hook. private reapSession(sessionId: string): void { const session = this.sessions.get(sessionId) if (!session || session.isAlive) { @@ -341,42 +359,24 @@ export class TerminalHost { return result } - dispose(): void { - // Why: write final checkpoints before killing sessions so graceful shutdown - // has zero data loss. The checkpoint callback writes synchronously to disk. - if (this.onFinalCheckpoint) { - for (const [sessionId, session] of this.sessions) { - if (!session.isAlive) { - continue - } - const take = session.takePendingOutput(true, { teardownSnapshot: true }) - if (take?.snapshot) { - try { - this.onFinalCheckpoint(sessionId, take.snapshot, take.records) - } catch { - // Best-effort — don't block shutdown - } - } - } + dispose(): Promise { + this.creationFenced = true + if (this.disposePromise) { + return this.disposePromise } + const disposePromise = this.disposeSessions() + this.disposePromise = disposePromise + void disposePromise.catch(() => { + // Why: keep failed native owners retryable on a later shutdown request. + if (this.disposePromise === disposePromise) { + this.disposePromise = null + } + }) + return disposePromise + } - for (const [, session] of this.sessions) { - session.detachAllClients() - // Why: live-vs-exited is load-bearing. For LIVE sessions we use - // forceKillAndDisposeSubprocess (SIGKILL + destroy) to reap stubborn - // children AND release the ptmx fd on the same tick, bypassing the 5s - // KILL_TIMEOUT_MS fallback that would otherwise outlive the daemon - // process. For sessions that have already exited but are still in the - // map, SIGKILL would target a reaped pid — on POSIX that pid can be - // recycled to an unrelated process, so we MUST only release the fd via - // disposeSubprocess() (destroy without kill). See docs/fix-pty-fd-leak.md. - if (session.isAlive) { - session.forceKillAndDisposeSubprocess() - } else { - session.disposeSubprocess() - } - } - this.sessions.clear() + private async disposeSessions(): Promise { + await shutdownTerminalHostSessions(this.sessions, this.onFinalCheckpoint) this.killedTombstones.clear() } diff --git a/src/main/daemon/terminal-session-teardown.ts b/src/main/daemon/terminal-session-teardown.ts new file mode 100644 index 00000000000..29fca4f952c --- /dev/null +++ b/src/main/daemon/terminal-session-teardown.ts @@ -0,0 +1,115 @@ +import { killWithDescendantSweep } from '../pty-descendant-termination' +import type { Session } from './session' + +type AgentTeardownOperation = { + promise: Promise + immediate: boolean + rootSignalled: boolean + rootCompletion: Promise + session: Session +} + +/** Owns agent teardown by session id until descendant capture and root + * signalling finish, even when the root exits and its Session is reaped. */ +export class TerminalSessionTeardown { + private operations = new Map() + + constructor(private sessions: ReadonlyMap) {} + + get(sessionId: string): Promise | undefined { + return this.operations.get(sessionId)?.promise + } + + requestImmediate(sessionId: string): Promise | undefined { + const pending = this.operations.get(sessionId) + if (pending) { + pending.immediate = true + if (pending.rootSignalled && pending.session.isAlive) { + // Why: the snapshot callback may have already sent the graceful root + // signal in this turn; an immediate join must still escalate and wait. + pending.rootCompletion = pending.session.forceKillAndWaitForExit() + } + } + return pending?.promise + } + + killSession(sessionId: string, session: Session, immediate: boolean): void | Promise { + if (session.launchAgent) { + return this.killAgentSession(sessionId, session, immediate) + } + if (immediate) { + return session.forceKillAndWaitForExit() + } else { + session.kill() + } + } + + private killAgentSession( + sessionId: string, + session: Session, + immediate: boolean + ): void | Promise { + const pending = this.operations.get(sessionId) + if (pending) { + // Why: an immediate caller is a stronger teardown request and must not + // acknowledge a still-graceful root kill while capture is pending. + pending.immediate ||= immediate + return pending.promise + } + + if (!session.beginTermination()) { + // A completed graceful sweep can leave the root alive during its grace + // window. Immediate teardown may safely escalate once no scan is pending. + if (immediate && session.isAlive && session.isTerminating) { + return session.forceKillAndWaitForExit() + } + return + } + if (!immediate) { + session.scheduleForceDisposeFallback() + } + + const entry: AgentTeardownOperation = { + promise: Promise.resolve(), + immediate, + rootSignalled: false, + rootCompletion: Promise.resolve(), + session + } + const sweep = Promise.resolve( + killWithDescendantSweep( + session.pid, + () => { + // Why: natural exit reaps the PID while ps is running. Never signal that + // stale numeric PID after the Session no longer represents a live root. + if (!session.isAlive) { + return + } + entry.rootSignalled = true + if (entry.immediate) { + entry.rootCompletion = session.forceKillAndWaitForExit() + } else { + session.signalTerminationRoot() + } + }, + { + // Why: the descendant rows are only authoritative while this exact + // Session still owns the root PID captured by ps. + ownsRoot: () => this.sessions.get(sessionId) === session && session.isAlive + } + ) + ) + // Why: descendant capture completion only proves signals were requested; + // destructive callers must retain the native owner until OS-confirmed exit. + const operation = sweep.then(() => entry.rootCompletion) + entry.promise = operation + this.operations.set(sessionId, entry) + const clearOperation = (): void => { + if (this.operations.get(sessionId) === entry) { + this.operations.delete(sessionId) + } + } + void operation.then(clearOperation, clearOperation) + return operation + } +} diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts deleted file mode 100644 index e528ea324bb..00000000000 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS, - ServeSimStateWatcher, - type ServeSimStateDetectedEvent -} from './serve-sim-state-watcher' - -const TEST_UDID = '11111111-2222-3333-4444-555555555555' - -let isolatedStateDirSeq = 0 -// Why: the default state dir is the real $TMPDIR/serve-sim — a leftover state -// file from an actual simulator session would leak extra events into tests -// that only exercise PTY ingestion. Point at a dir that never exists. -function createIsolatedWatcher(): ServeSimStateWatcher { - isolatedStateDirSeq += 1 - return new ServeSimStateWatcher({ - stateDir: join(tmpdir(), `orca-serve-sim-test-none-${process.pid}-${isolatedStateDirSeq}`) - }) -} - -async function waitForEvent( - events: ServeSimStateDetectedEvent[], - predicate: (event: ServeSimStateDetectedEvent) => boolean -): Promise { - const deadline = Date.now() + 1500 - while (Date.now() < deadline) { - const match = events.find(predicate) - if (match) { - return match - } - await new Promise((resolve) => setTimeout(resolve, 25)) - } - throw new Error(`Timed out waiting for serve-sim watcher event. Received ${events.length}`) -} - -describe('ServeSimStateWatcher', () => { - const cleanupPaths: string[] = [] - - afterEach(async () => { - await Promise.all( - cleanupPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) - ) - }) - - it('attaches to the serve-sim state directory when it appears after startup', async () => { - const parentDir = await mkdtemp(join(tmpdir(), 'orca-serve-sim-watch-')) - cleanupPaths.push(parentDir) - const stateDir = join(parentDir, 'serve-sim') - // Force darwin (the poll is macOS-only) and a fast interval so this - // cross-platform test exercises the existence-poll -> attach path quickly. - const watcher = new ServeSimStateWatcher({ stateDir, platform: 'darwin', existencePollMs: 50 }) - const events: ServeSimStateDetectedEvent[] = [] - - watcher.bindPty('pty-1', 'worktree-1') - watcher.onDetected((event) => events.push(event)) - watcher.start() - - await mkdir(stateDir) - await writeFile( - join(stateDir, `server-${TEST_UDID}.json`), - JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws', - pid: 12345 - }) - ) - - const event = await waitForEvent(events, (candidate) => candidate.info.deviceUdid === TEST_UDID) - expect(event).toMatchObject({ - worktreeId: 'worktree-1', - source: 'state-file', - info: { - deviceUdid: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws', - helperPid: 12345 - } - }) - - watcher.stop() - }) - - it('does not arm the existence poll on non-macOS platforms', () => { - // serve-sim state never appears off macOS, so start() must not leave a - // recurring timer waking the daemon for a directory that can never exist. - for (const platform of ['win32', 'linux'] as const) { - const watcher = new ServeSimStateWatcher({ - stateDir: join(tmpdir(), `orca-serve-sim-nonmac-${process.pid}-${platform}`), - platform - }) - watcher.start() - const poll = (watcher as unknown as { stateDirPoll: unknown }).stateDirPoll - expect(poll).toBeNull() - watcher.stop() - } - }) - - it('defaults the existence poll to a coarse (battery-friendly) interval', () => { - expect(SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS).toBe(2_000) - const watcher = new ServeSimStateWatcher() - expect((watcher as unknown as { existencePollMs: number }).existencePollMs).toBe( - SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS - ) - }) - - it('does not buffer repeated brace-free PTY output while waiting for metadata', () => { - const watcher = createIsolatedWatcher() - const buffers = (watcher as unknown as { ptyBuffers: Map }).ptyBuffers - const originalSet = buffers.set - let setCalls = 0 - buffers.set = function (key, value): Map { - setCalls += 1 - return originalSet.call(this, key, value) - } - watcher.bindPty('pty-1', 'worktree-1') - - try { - for (let index = 0; index < 4096; index += 1) { - watcher.ingestPtyOutput('pty-1', 'ordinary compiler progress without JSON metadata\n') - } - } finally { - buffers.set = originalSet - } - - expect(setCalls).toBe(0) - expect(buffers.has('pty-1')).toBe(false) - watcher.stop() - }) - - it('detects serve-sim metadata split across PTY chunks and releases the partial object', () => { - const watcher = createIsolatedWatcher() - const events: ServeSimStateDetectedEvent[] = [] - const buffers = (watcher as unknown as { ptyBuffers: Map }).ptyBuffers - const payload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws' - }) - const splitIndex = payload.indexOf('streamUrl') + 4 - watcher.bindPty('pty-1', 'worktree-1') - watcher.onDetected((event) => events.push(event)) - - watcher.ingestPtyOutput('pty-1', payload.slice(0, splitIndex)) - expect(events).toHaveLength(0) - expect(buffers.has('pty-1')).toBe(true) - - watcher.ingestPtyOutput('pty-1', payload.slice(splitIndex)) - expect(events).toHaveLength(1) - expect(events[0]?.info.deviceUdid).toBe(TEST_UDID) - expect(buffers.has('pty-1')).toBe(false) - watcher.stop() - }) - - it('suppresses Orca-managed sessions only while they are marked managed', async () => { - const watcher = createIsolatedWatcher() - const events: ServeSimStateDetectedEvent[] = [] - const payload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws' - }) - - watcher.bindPty('pty-1', 'worktree-1') - watcher.onDetected((event) => events.push(event)) - watcher.markOrcaManaged({ - deviceUdid: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws' - }) - watcher.ingestPtyOutput('pty-1', payload) - expect(events).toHaveLength(0) - - watcher.unmarkOrcaManaged(TEST_UDID) - watcher.ingestPtyOutput('pty-1', payload) - expect(events).toHaveLength(1) - expect(events[0]?.info.deviceUdid).toBe(TEST_UDID) - - watcher.stop() - }) - - it('prunes worktree-scoped dedupe keys on forget so a re-bound worktree re-emits', () => { - const watcher = createIsolatedWatcher() - const events: ServeSimStateDetectedEvent[] = [] - const payload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws', - pid: 12345 - }) - - watcher.onDetected((event) => events.push(event)) - - watcher.bindPty('pty-1', 'worktree-1') - watcher.ingestPtyOutput('pty-1', payload) - watcher.ingestPtyOutput('pty-1', payload) // deduped within the same worktree - expect(events).toHaveLength(1) - - // Forgetting the worktree must drop its dedupe keys; a re-bind is a fresh - // context and should re-emit (otherwise the Set leaks for the session). - watcher.forgetWorktree('worktree-1') - watcher.bindPty('pty-2', 'worktree-1') - watcher.ingestPtyOutput('pty-2', payload) - expect(events).toHaveLength(2) - - watcher.stop() - }) - - it('dedupes one helper without hiding a later helper for the same simulator', () => { - const watcher = createIsolatedWatcher() - const events: ServeSimStateDetectedEvent[] = [] - const firstPayload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws', - pid: 12345 - }) - const secondPayload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3101/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3101/ws', - pid: 23456 - }) - - watcher.bindPty('pty-1', 'worktree-1') - watcher.onDetected((event) => events.push(event)) - - watcher.ingestPtyOutput('pty-1', firstPayload) - watcher.ingestPtyOutput('pty-1', firstPayload) - watcher.ingestPtyOutput('pty-1', secondPayload) - - expect(events.map((event) => event.info.helperPid)).toEqual([12345, 23456]) - - watcher.stop() - }) -}) diff --git a/src/main/emulator/serve-sim-state-watcher.ts b/src/main/emulator/serve-sim-state-watcher.ts deleted file mode 100644 index dae8e57bc39..00000000000 --- a/src/main/emulator/serve-sim-state-watcher.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { existsSync, readFileSync, readdirSync, watch } from 'node:fs' -import { basename, join } from 'node:path' -import { tmpdir } from 'node:os' -import type { FSWatcher } from 'node:fs' - -// Why: terminal-started `serve-sim --detach` writes state under $TMPDIR/serve-sim/ and may -// print JSON to the PTY. Orca-managed attach (CLI/pane) already registers via EmulatorBridge; -// this watcher only reacts to *external* helper starts so the UI can open a simulator tab -// without focus-steal (mirror advertised-url-watcher PTY binding model). - -export type ServeSimHelperInfo = { - deviceUdid: string - wsUrl: string - streamUrl: string - axUrl?: string - helperPid?: number -} - -export type ServeSimStateDetectedEvent = { - worktreeId: string - info: ServeSimHelperInfo - source: 'pty' | 'state-file' -} - -const DEFAULT_STATE_DIR = join(tmpdir(), 'serve-sim') -// Why: this only waits for a rarely-created dir to appear; sub-second detection -// of a detached emulator is not user-perceptible, so a coarse interval avoids -// waking the daemon 4x/sec for the whole session on machines that never use it. -export const SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS = 2_000 -const STATE_FILE_RE = /^server-([0-9A-F-]{36})\.json$/i -const PTY_JSON_RE = /\{[^{}]*"streamUrl"\s*:\s*"[^"]+"[^{}]*"wsUrl"\s*:\s*"[^"]+"[^{}]*\}/g - -function parseHelperInfo(raw: unknown, fallbackUdid?: string): ServeSimHelperInfo | null { - if (!raw || typeof raw !== 'object') { - return null - } - const obj = raw as Record - const deviceUdid = - (typeof obj.device === 'string' && obj.device) || - (typeof obj.deviceUdid === 'string' && obj.deviceUdid) || - fallbackUdid - const streamUrl = - (typeof obj.streamUrl === 'string' && obj.streamUrl) || (typeof obj.url === 'string' && obj.url) - const wsUrl = typeof obj.wsUrl === 'string' ? obj.wsUrl : undefined - if (!deviceUdid || !streamUrl || !wsUrl) { - return null - } - return { - deviceUdid, - wsUrl, - streamUrl, - axUrl: typeof obj.axUrl === 'string' ? obj.axUrl : undefined, - helperPid: typeof obj.pid === 'number' ? obj.pid : undefined - } -} - -function readStateFilePath(filePath: string): ServeSimHelperInfo | null { - try { - const name = basename(filePath) - const match = STATE_FILE_RE.exec(name) - const fallbackUdid = match?.[1] - const raw = JSON.parse(readFileSync(filePath, 'utf8')) - return parseHelperInfo(raw, fallbackUdid) - } catch { - return null - } -} - -function helperInstanceKey(info: ServeSimHelperInfo): string { - return `${info.deviceUdid}::${info.helperPid ?? 'pidless'}::${info.wsUrl}::${info.streamUrl}` -} - -function trailingIncompletePtyJsonObject(data: string): string { - const lastObjectStart = data.lastIndexOf('{') - return lastObjectStart > data.lastIndexOf('}') ? data.slice(lastObjectStart) : '' -} - -export class ServeSimStateWatcher { - private readonly stateDir: string - private readonly platform: NodeJS.Platform - private readonly existencePollMs: number - private readonly ptyToWorktree = new Map() - private readonly ptyBuffers = new Map() - private readonly seenExternalKeys = new Set() - private readonly orcaManagedHelperKeys = new Set() - private readonly listeners = new Set<(event: ServeSimStateDetectedEvent) => void>() - private stateWatcher: FSWatcher | null = null - private stateDirPoll: ReturnType | null = null - - constructor( - options: { stateDir?: string; platform?: NodeJS.Platform; existencePollMs?: number } = {} - ) { - this.stateDir = options.stateDir ?? DEFAULT_STATE_DIR - this.platform = options.platform ?? process.platform - this.existencePollMs = options.existencePollMs ?? SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS - } - - onDetected(listener: (event: ServeSimStateDetectedEvent) => void): () => void { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - } - - // Why: bridge calls this when Orca CLI/pane attaches so we do not duplicate-tab on our own session. - markOrcaManaged(info: ServeSimHelperInfo): void { - this.orcaManagedHelperKeys.add(helperInstanceKey(info)) - } - - unmarkOrcaManaged(deviceUdid: string): void { - for (const key of this.orcaManagedHelperKeys) { - if (key.startsWith(`${deviceUdid}::`)) { - this.orcaManagedHelperKeys.delete(key) - } - } - } - - bindPty(ptyId: string, worktreeId: string): void { - this.ptyToWorktree.set(ptyId, worktreeId) - this.scanExistingStateFiles() - } - - unbindPty(ptyId: string): void { - this.ptyToWorktree.delete(ptyId) - this.ptyBuffers.delete(ptyId) - } - - forgetWorktree(worktreeId: string): void { - for (const [ptyId, wt] of this.ptyToWorktree.entries()) { - if (wt === worktreeId) { - this.ptyToWorktree.delete(ptyId) - this.ptyBuffers.delete(ptyId) - } - } - // Why: dedupe keys are worktree-scoped (`${worktreeId}::...`); prune them on - // forget so the Set does not grow for the session across worktree switches. - // A later re-bind of the same worktree is a fresh context and should re-emit. - const prefix = `${worktreeId}::` - for (const key of this.seenExternalKeys) { - if (key.startsWith(prefix)) { - this.seenExternalKeys.delete(key) - } - } - } - - ingestPtyOutput(ptyId: string, data: string): void { - const worktreeId = this.ptyToWorktree.get(ptyId) - if (!worktreeId) { - return - } - - const previous = this.ptyBuffers.get(ptyId) - if (!previous && !data.includes('{')) { - // Why: serve-sim metadata is a JSON object, while ordinary PTY output is - // the hot path. Stay idle instead of rebuilding and regex-scanning 16 KiB. - return - } - const combined = `${previous ?? ''}${data}`.slice(-16_384) - const trailingObject = trailingIncompletePtyJsonObject(combined) - if (trailingObject) { - this.ptyBuffers.set(ptyId, trailingObject) - } else { - this.ptyBuffers.delete(ptyId) - } - - const matches = combined.match(PTY_JSON_RE) - if (!matches) { - return - } - for (const fragment of matches) { - try { - const info = parseHelperInfo(JSON.parse(fragment)) - if (info) { - this.emitIfExternal(worktreeId, info, 'pty') - } - } catch { - /* ignore partial JSON */ - } - } - } - - start(): void { - if (this.stateDirPoll || this.stateWatcher) { - return - } - // Why: serve-sim (the iOS Simulator bridge) only ever writes state on macOS, - // so $TMPDIR/serve-sim never appears on Windows/Linux. Skip arming the - // existence poll there instead of waking the daemon every interval for a - // directory that can never exist. - if (this.platform !== 'darwin') { - return - } - try { - // Why: $TMPDIR/serve-sim/ may not exist until the first terminal `serve-sim --detach`. - // Poll for it instead of fs.watch on the parent tmpdir: watching $TMPDIR - // registers a permanent FSEvents client on the system's highest-churn - // directory, while an existence poll costs the daemon nothing. - this.attachStateDirWatch() - if (this.stateWatcher) { - this.scanExistingStateFiles() - return - } - - // attachStateDirWatch() clears this poll once the dir appears and the - // native watcher takes over, so it only ticks while waiting for a - // rarely-created dir — a coarse interval keeps that wait off the idle floor. - this.stateDirPoll = setInterval(() => { - this.attachStateDirWatch() - this.scanExistingStateFiles() - }, this.existencePollMs) - this.stateDirPoll.unref?.() - } catch { - // Permission issues: watcher is best-effort. - } - } - - stop(): void { - this.stateWatcher?.close() - if (this.stateDirPoll) { - clearInterval(this.stateDirPoll) - } - this.stateWatcher = null - this.stateDirPoll = null - this.ptyToWorktree.clear() - this.ptyBuffers.clear() - this.seenExternalKeys.clear() - this.orcaManagedHelperKeys.clear() - this.listeners.clear() - } - - private attachStateDirWatch(): void { - if (this.stateWatcher || !existsSync(this.stateDir)) { - return - } - this.stateWatcher = watch(this.stateDir, (_event, filename) => { - if (!filename) { - return - } - const name = String(filename) - if (!STATE_FILE_RE.test(name)) { - return - } - const info = readStateFilePath(join(this.stateDir, name)) - if (!info) { - return - } - const worktreeId = this.latestBoundWorktree() - if (worktreeId) { - this.emitIfExternal(worktreeId, info, 'state-file') - } - }) - if (this.stateDirPoll) { - clearInterval(this.stateDirPoll) - this.stateDirPoll = null - } - } - - private latestBoundWorktree(): string | undefined { - let latest: string | undefined - for (const wt of this.ptyToWorktree.values()) { - latest = wt - } - return latest - } - - private scanExistingStateFiles(): void { - const worktreeId = this.latestBoundWorktree() - if (!worktreeId || !existsSync(this.stateDir)) { - return - } - try { - for (const name of readdirSync(this.stateDir)) { - if (!STATE_FILE_RE.test(name)) { - continue - } - const info = readStateFilePath(join(this.stateDir, name)) - if (info) { - this.emitIfExternal(worktreeId, info, 'state-file') - } - } - } catch { - /* ignore */ - } - } - - private emitIfExternal( - worktreeId: string, - info: ServeSimHelperInfo, - source: ServeSimStateDetectedEvent['source'] - ): void { - const instanceKey = helperInstanceKey(info) - if (this.orcaManagedHelperKeys.has(instanceKey)) { - return - } - const dedupeKey = `${worktreeId}::${instanceKey}` - if (this.seenExternalKeys.has(dedupeKey)) { - return - } - this.seenExternalKeys.add(dedupeKey) - const event: ServeSimStateDetectedEvent = { worktreeId, info, source } - for (const listener of this.listeners) { - try { - listener(event) - } catch { - /* listener errors must not break watcher */ - } - } - } -} - -export const serveSimStateWatcher = new ServeSimStateWatcher() diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 4330a3be9e5..121a8208754 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -40,7 +40,8 @@ import { assertWorktreeCleanForRemoval, forceDeleteLocalBranch, listWorktrees, - removeWorktree + removeWorktree, + WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS } from './worktree' beforeEach(() => { @@ -748,7 +749,7 @@ describe('assertWorktreeCleanForRemoval', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['status', '--porcelain', '--untracked-files=all'], - { cwd: '/repo-feature' } + { cwd: '/repo-feature', timeout: WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS } ) }) @@ -761,6 +762,47 @@ describe('assertWorktreeCleanForRemoval', () => { }) }) + it('ignores configured linked paths without mutating them before preflight', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '?? node_modules\0', stderr: '' }) + + await expect( + assertWorktreeCleanForRemoval('/repo-feature', false, { + ignoredUntrackedPaths: ['node_modules'] + }) + ).resolves.toBeUndefined() + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['status', '--porcelain', '-z', '--untracked-files=all'], + { cwd: '/repo-feature', timeout: WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS } + ) + }) + + it('allows a shorter caller deadline for destructive preflight', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('git timed out.')) + + await expect( + assertWorktreeCleanForRemoval('/repo-feature', false, { timeout: 25 }) + ).rejects.toThrow('git timed out.') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['status', '--porcelain', '--untracked-files=all'], + { cwd: '/repo-feature', timeout: 25 } + ) + }) + + it('still rejects real changes when configured linked paths are ignored', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: '?? node_modules\0 M src/index.ts\0', + stderr: '' + }) + + await expect( + assertWorktreeCleanForRemoval('/repo-feature', false, { + ignoredUntrackedPaths: ['node_modules'] + }) + ).rejects.toMatchObject({ message: 'Worktree has uncommitted or untracked changes.' }) + }) + it('rethrows preflight subprocess failures as-is', async () => { const error = Object.assign(new Error('fatal: not a git repository'), { stderr: 'fatal: not a git repository (or any of the parent directories): .git\n' @@ -1026,6 +1068,14 @@ describe('listWorktrees', () => { 'worktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n' } }) + // Why: the fallback probes each linked worktree path for existence; keep + // the paths "present" so this test stays about parser selection. + statMock.mockImplementation(async (targetPath: string) => { + if (String(targetPath).endsWith('sparse-checkout')) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return {} + }) await expect(listWorktrees('/repo')).resolves.toEqual([ { @@ -1048,6 +1098,36 @@ describe('listWorktrees', () => { 'git worktree list --porcelain' ]) }) + + it('annotates missing linked worktrees as prunable via the line-block fallback', async () => { + // Why: Git <2.36 lacks the `prunable` porcelain field (issue #8389), so + // the fallback must probe each linked worktree path instead of treating a + // stale registration as a live workspace. + mockGitCommands({ + 'git worktree list --porcelain -z': { + error: Object.assign(new Error("unknown switch `z'"), { + stderr: "error: unknown switch `z'" + }) + }, + 'git worktree list --porcelain': { + stdout: + 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\n' + + 'worktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n\n' + + 'worktree /repo-locked\nHEAD aaa789\nbranch refs/heads/agent\nlocked agent session\n' + } + }) + // statMock default (beforeEach): every path is missing (ENOENT). + + const worktrees = await listWorktrees('/repo') + + expect(worktrees.find((worktree) => worktree.path === '/repo-feature')).toMatchObject({ + prunable: true + }) + // Locked registrations are shielded, mirroring git's own prunable rules; + // the main worktree is covered by the repo-level missing-path handling. + expect(worktrees.find((worktree) => worktree.path === '/repo-locked')?.prunable).toBeUndefined() + expect(worktrees.find((worktree) => worktree.path === '/repo')?.prunable).toBeUndefined() + }) }) describe('addSparseWorktree', () => { diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 3f7cbfd9f3a..8af536a31a6 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -1414,6 +1414,116 @@ describe('getStatus', () => { ]) }) + it('reuses unchanged line stats only when the safety hint is present', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: + '# branch.oid head-1\n' + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + await getStatus('/repo') + const reused = await getStatus('/repo', { reuseLineStats: true }) + await getStatus('/repo') + + expect(reused.entries).toEqual([ + { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 } + ]) + expect( + gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) + ).toHaveLength(2) + }) + + it('recomputes after a scan whose numstat failed instead of pinning missing counts', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let failNumstat = true + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: + '# branch.oid head-1\n' + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + if (failNumstat) { + failNumstat = false + return Promise.reject(new Error('transient index.lock')) + } + return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + const failed = await getStatus('/repo') + const reused = await getStatus('/repo', { reuseLineStats: true }) + + expect(failed.entries[0]?.added).toBeUndefined() + expect(reused.entries).toEqual([ + { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 } + ]) + }) + + it('invalidates safety reuse for a new head and for known mutations', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let head = 'head-1' + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: + `# branch.oid ${head}\n` + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + await getStatus('/repo') + head = 'head-2' + await getStatus('/repo', { reuseLineStats: true }) + await stageFile('/repo', 'src/unstaged.ts') + await getStatus('/repo', { reuseLineStats: true }) + + expect( + gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) + ).toHaveLength(3) + }) + + it('isolates line-stat reuse between WSL distributions', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + await getStatus('/repo', { wslDistro: 'ubuntu' }) + await getStatus('/repo', { wslDistro: 'debian', reuseLineStats: true }) + await getStatus('/repo', { wslDistro: 'ubuntu', reuseLineStats: true }) + + expect( + gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) + ).toHaveLength(2) + }) + it('attaches numstat counts for literal paths containing rename markers', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index ddbdd1b2ca4..a0c103b744c 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -51,6 +51,12 @@ import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' +import { + beginGitStatusLineStatsCacheWrite, + clearGitStatusLineStatsCache, + clearGitStatusLineStatsCacheKey, + reuseOrRecomputeGitStatusLineStats +} from '../../shared/git-status-line-stats-cache' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES @@ -97,6 +103,7 @@ const statusReadsInFlight = new Map>() export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() statusReadsInFlight.clear() + clearGitStatusLineStatsCache() clearSubmodulePathsCache() resolvedUpstreamNameCache.clear() } @@ -195,6 +202,7 @@ export function getEffectiveUpstreamStatusGenerationCountForTests(): number { export type GetStatusOptions = GitRuntimeOptions & { includeIgnored?: boolean + reuseLineStats?: boolean /** * Max changed-file entries before git is stopped and the result is marked * `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap. @@ -243,6 +251,7 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri worktreePath, options.wslDistro ?? '', options.includeIgnored === true, + options.reuseLineStats === true, options.bypassEffectiveUpstreamNegativeCache === true, limit ].join('\0') @@ -252,6 +261,8 @@ async function runGetStatus( worktreePath: string, options: GetStatusOptions = {} ): Promise { + const lineStatsCacheKey = getStatusLineStatsCacheKey(worktreePath, options) + const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) let effectiveUpstreamStatus: GitUpstreamStatus | undefined let statusSucceeded = false // Why: a negative/fractional/NaN limit would trigger spurious early-stop or @@ -303,7 +314,12 @@ async function runGetStatus( } didHitLimit = stoppedEarly statusSucceeded = true - } catch { + } catch (error) { + // Why: an aborted scan must reject, not resolve — swallowing here would let + // a cancelled request be mistaken for a completed (empty) status result. + if (options.signal?.aborted) { + throw error + } // Not a git repo or git not available } @@ -334,11 +350,17 @@ async function runGetStatus( options ) try { + // Why: the probe promise and its name/negative caches are shared by + // concurrent status reads, so one caller's abort must not reject the + // shared probe or evict warm cache state for the others. The probe is + // small and its cached result stays useful, so run it unbound from + // this request's signal. + const { signal: _requestSignal, ...sharedProbeOptions } = options effectiveUpstreamStatus = await readOrProbeEffectiveUpstreamStatus( cacheKey, worktreePath, branchName, - options, + sharedProbeOptions, options.bypassEffectiveUpstreamNegativeCache === true ) } catch { @@ -355,7 +377,25 @@ async function runGetStatus( // running numstat over a huge change set would reintroduce the cost the limit // exists to avoid, matching how a "huge" repo disables extra git features. if (!didHitLimit) { - await attachLineStats(worktreePath, entries, options) + await reuseOrRecomputeGitStatusLineStats({ + cacheKey: lineStatsCacheKey, + head, + entries, + writeToken: lineStatsWriteToken, + reuse: options.reuseLineStats === true, + isAborted: () => options.signal?.aborted === true, + recompute: () => attachLineStats(worktreePath, entries, options) + }) + } else { + clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) + } + + // Why: abort after the stream (e.g. during unmerged/upstream/line-stats work) + // must still reject — never resolve a cancelled scan as a completed result. + if (options.signal?.aborted) { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + throw error } return { @@ -382,6 +422,12 @@ async function runGetStatus( } } +function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string { + // Why: identical path strings can address different Linux filesystems in + // different WSL distros, so derived stats must follow Git's execution host. + return `${options.wslDistro ?? 'native'}\0${worktreePath}` +} + /** * Resolve a submodule's own worktree path from a parent worktree + relative * submodule path, rejecting anything that escapes the parent. Shared by the @@ -505,7 +551,7 @@ async function runNumstat( worktreePath: string, cached: boolean, options: GitRuntimeOptions = {} -): Promise> { +): Promise | null> { try { const { stdout } = await gitExecFileAsync( [ @@ -520,20 +566,28 @@ async function runNumstat( { ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() } ) return parseNumstat(stdout) - } catch { + } catch (error) { + // Why: an aborted pass must reject so a cancelled scan is never treated as + // a completed one; only a genuine (non-abort) numstat failure degrades to + // uncounted rows below. + if (options.signal?.aborted) { + throw error + } // Why: a numstat failure (e.g. transient lock) should leave rows without - // counts rather than break the whole status refresh. - return new Map() + // counts rather than break the whole status refresh. Null (vs an empty + // map) tells the caller the pass is incomplete and must not be cached. + return null } } +/** Returns false when a numstat pass failed, so callers skip caching it. */ async function attachLineStats( worktreePath: string, entries: GitStatusEntry[], options: GitRuntimeOptions = {} -): Promise { +): Promise { if (entries.length === 0) { - return + return true } const hasStaged = entries.some((entry) => entry.area === 'staged') const hasUnstaged = entries.some((entry) => entry.area === 'unstaged') @@ -544,18 +598,19 @@ async function attachLineStats( const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ hasStaged ? runNumstat(worktreePath, true, options) : Promise.resolve(emptyStats), hasUnstaged ? runNumstat(worktreePath, false, options) : Promise.resolve(emptyStats), - collectUntrackedAdditions(worktreePath, untrackedPaths) + collectUntrackedAdditions(worktreePath, untrackedPaths, options.signal) ]) for (const entry of entries) { applyLineStats( entry, entry.area === 'staged' - ? stagedStats.get(entry.path) + ? (stagedStats ?? emptyStats).get(entry.path) : entry.area === 'unstaged' - ? unstagedStats.get(entry.path) + ? (unstagedStats ?? emptyStats).get(entry.path) : untrackedStats.get(entry.path) ) } + return stagedStats !== null && unstagedStats !== null } function getShortBranchName(branch: string | undefined): string | null { @@ -753,7 +808,12 @@ async function probeOrRevalidateEffectiveUpstreamStatus( cached.upstreamName ) return { status, probedSameNameOriginRef: false } - } catch { + } catch (error) { + // Why: an aborted probe says nothing about the ref; evicting the warm + // name cache here would force a pointless full re-resolve next scan. + if (options.signal?.aborted) { + throw error + } // Ref deleted or repo state changed — fall through to a full re-resolve. resolvedUpstreamNameCache.delete(cacheKey) } diff --git a/src/main/git/worktree-list-paths.test.ts b/src/main/git/worktree-list-paths.test.ts index df83417e7cb..a248ddb7518 100644 --- a/src/main/git/worktree-list-paths.test.ts +++ b/src/main/git/worktree-list-paths.test.ts @@ -66,6 +66,31 @@ async function createRepoWithLockedDeletedWorktree(): Promise<{ } } +async function createRepoWithPrunableWorktree(): Promise<{ + repoPath: string + worktreePath: string +}> { + const root = await mkdtemp(path.join(tmpdir(), 'orca-worktree-prunable-')) + tempRoots.push(root) + const repoPath = path.join(root, 'repo') + const requestedWorktreePath = path.join(root, 'stale-worktree') + + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'test@example.com']) + git(repoPath, ['config', 'user.name', 'Test User']) + git(repoPath, ['commit', '--allow-empty', '--quiet', '-m', 'initial']) + git(repoPath, ['worktree', 'add', '--quiet', '-b', 'feature/stale', requestedWorktreePath]) + + const worktreePath = await realpath(requestedWorktreePath) + await rm(worktreePath, { recursive: true, force: true }) + + return { + repoPath: await realpath(repoPath), + worktreePath + } +} + function branchExists(repoPath: string, branchName: string): boolean { try { git(repoPath, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]) @@ -102,6 +127,19 @@ describe('git worktree paths', () => { } ) + it('annotates a worktree whose directory was deleted as prunable', async () => { + // Why: an un-pruned registration with a deleted directory must not surface + // as a live workspace (issue #8389). + const { repoPath, worktreePath } = await createRepoWithPrunableWorktree() + + const worktrees = await listWorktrees(repoPath) + const stale = worktrees.find( + (worktree) => worktree.path.replaceAll('\\', '/') === worktreePath.replaceAll('\\', '/') + ) + + expect(stale).toMatchObject({ prunable: true }) + }) + it('preserves a locked worktree whose directory was deleted manually', async () => { const { repoPath, worktreePath } = await createRepoWithLockedDeletedWorktree() diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 035a7f77405..258ecfa36df 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -273,6 +273,38 @@ describe('parseWorktreeList', () => { }) }) + it('preserves a prunable marker and its reason', () => { + expect( + parseWorktreeList( + 'worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /stale\nHEAD def\nbranch refs/heads/feature\nprunable gitdir file points to non-existent location\n' + )[1] + ).toMatchObject({ + path: '/stale', + prunable: true, + prunableReason: 'gitdir file points to non-existent location' + }) + }) + + it('preserves a NUL-delimited prunable marker', () => { + const output = [ + 'worktree /repo', + 'HEAD abc', + 'branch refs/heads/main', + '', + 'worktree /stale', + 'HEAD def', + 'branch refs/heads/feature', + 'prunable gitdir file points to non-existent location', + '' + ].join('\0') + + expect(parseWorktreeList(output, { nulDelimited: true })[1]).toMatchObject({ + path: '/stale', + prunable: true, + prunableReason: 'gitdir file points to non-existent location' + }) + }) + it('parses regular and bare worktree blocks from porcelain output', () => { const output = ` worktree /repo @@ -555,7 +587,10 @@ branch refs/heads/main-2 head: 'def456', branch: 'refs/heads/main-2', isBare: false, - isMainWorktree: false + isMainWorktree: false, + // The line-block fallback also probes linked worktree paths for + // existence (issue #8389), and this mocked path is absent on disk. + prunable: true } ]) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index c9175b3dcb4..8823c683b60 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -39,6 +39,11 @@ type SparseWorktreeCreateError = Error & { export type GitWorktreeExecOptions = { wslDistro?: string signal?: AbortSignal + timeout?: number +} + +type WorktreeRemovalPreflightOptions = GitWorktreeExecOptions & { + ignoredUntrackedPaths?: readonly string[] } export type AddWorktreeOptions = GitWorktreeExecOptions & { @@ -76,20 +81,24 @@ type LocalBaseRefRefreshability = const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8 +const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8 + // Why: bound `git worktree add` so a OneDrive cloud-placeholder base path can't // stall its checkout writes for minutes (STA-1292) — a stuck create then fails // fast instead of spinning forever. Generous enough not to kill a legit large // checkout (mirrors the SYNC runner's cloud-placeholder floor, #7225). export const WORKTREE_ADD_TIMEOUT_MS = 180_000 +export const WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS = 30_000 function gitExecOptions( cwd: string, options: GitWorktreeExecOptions = {} -): { cwd: string; wslDistro?: string; signal?: AbortSignal } { +): { cwd: string; wslDistro?: string; signal?: AbortSignal; timeout?: number } { return { cwd, ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), - ...(options.signal ? { signal: options.signal } : {}) + ...(options.signal ? { signal: options.signal } : {}), + ...(options.timeout ? { timeout: options.timeout } : {}) } } @@ -498,6 +507,8 @@ export function parseWorktreeList( let isSparse = false let locked = false let lockReason = '' + let prunable = false + let prunableReason = '' for (const line of lines) { if (line.startsWith('worktree ')) { @@ -514,6 +525,12 @@ export function parseWorktreeList( locked = true const rawReason = line.slice('locked'.length).trim() lockReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason) + } else if (line === 'prunable' || line.startsWith('prunable ')) { + // Why: Git ≥ 2.36 flags registrations whose directory is gone; ignoring + // it surfaces the stale worktree as a live workspace (issue #8389). + prunable = true + const rawReason = line.slice('prunable'.length).trim() + prunableReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason) } } @@ -527,6 +544,8 @@ export function parseWorktreeList( ...(isSparse ? { isSparse } : {}), ...(locked ? { locked: true } : {}), ...(lockReason ? { lockReason } : {}), + ...(prunable ? { prunable: true } : {}), + ...(prunableReason ? { prunableReason } : {}), isMainWorktree: worktrees.length === 0 }) } @@ -596,12 +615,64 @@ async function readWorktreeList( cwd: repoPath, ...options }) - return normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout), options) + const normalized = await normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout), + options + ) + // Why: this `-z`-unsupported fallback (Git <2.36) also serves Git <2.31, + // which emits no `prunable` annotation; probe each linked worktree path + // for existence instead of treating stale registrations as live. On Git + // 2.31–2.35 `parseWorktreeList` already set `prunable`, so the probe is a + // harmless backstop that skips those entries (issue #8389). + return annotatePrunableByExistence(normalized, repoPath, options) }, isUnsupportedWorktreeListZError ) } +async function annotatePrunableByExistence( + worktrees: GitWorktreeInfo[], + repoPath: string, + options: GitWorktreeExecOptions = {} +): Promise { + const annotated = [...worktrees] + let nextIndex = 0 + + async function probeNext(): Promise { + while (nextIndex < worktrees.length) { + const index = nextIndex + nextIndex += 1 + const worktree = worktrees[index] + // Git only marks linked worktrees prunable, and never locked ones (a + // lock shields the registration even when the directory is missing). The + // `locked` annotation is only parsed on Git >=2.31, so on older Git a + // locked+missing worktree cannot be shielded here. A missing main + // worktree is handled by the repo-level ENOENT paths. + if ( + !worktree || + worktree.isMainWorktree || + worktree.isBare || + worktree.locked || + worktree.prunable + ) { + continue + } + try { + await stat(translateWorktreePath(worktree.path, repoPath, options)) + } catch (err) { + if (getErrorCode(err) === 'ENOENT') { + annotated[index] = { ...worktree, prunable: true } + } + } + } + } + + const workerCount = Math.min(PRUNABLE_EXISTENCE_PROBE_CONCURRENCY, worktrees.length) + await Promise.all(Array.from({ length: workerCount }, () => probeNext())) + return annotated +} + async function readTranslatedWorktreeGraph( repoPath: string, options: GitWorktreeExecOptions = {} @@ -1348,16 +1419,26 @@ async function isLocalBranchCheckedOut( export async function assertWorktreeCleanForRemoval( worktreePath: string, force = false, - options: GitWorktreeExecOptions = {} + options: WorktreeRemovalPreflightOptions = {} ): Promise { if (force) { return } - const { stdout } = await gitExecFileAsync(['status', '--porcelain', '--untracked-files=all'], { - ...gitExecOptions(worktreePath, options) - }) - if (!stdout.trim()) { + const { ignoredUntrackedPaths = [], ...gitOptions } = options + const useNullTerminatedStatus = ignoredUntrackedPaths.length > 0 + const { stdout } = await gitExecFileAsync( + ['status', '--porcelain', ...(useNullTerminatedStatus ? ['-z'] : []), '--untracked-files=all'], + { + ...gitExecOptions(worktreePath, gitOptions), + timeout: gitOptions.timeout ?? WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS + } + ) + if ( + useNullTerminatedStatus + ? hasOnlyIgnoredUntrackedStatus(stdout, ignoredUntrackedPaths) + : !stdout.trim() + ) { return } @@ -1366,6 +1447,26 @@ export async function assertWorktreeCleanForRemoval( throw error } +function hasOnlyIgnoredUntrackedStatus( + status: string, + ignoredUntrackedPaths: readonly string[] +): boolean { + const ignored = new Set( + ignoredUntrackedPaths + .map((entry) => + entry + .trim() + .replace(/^[\\/]+/, '') + .replace(/\\/g, '/') + ) + .filter((entry) => entry && !entry.split('/').includes('..')) + ) + return status + .split('\0') + .filter(Boolean) + .every((entry) => entry.startsWith('?? ') && ignored.has(entry.slice(3).replace(/\\/g, '/'))) +} + function translateWorktreePath( worktreePath: string, repoPath: string, diff --git a/src/main/gitea/client.test.ts b/src/main/gitea/client.test.ts index 620ab33225f..bf1fe13ca72 100644 --- a/src/main/gitea/client.test.ts +++ b/src/main/gitea/client.test.ts @@ -14,6 +14,11 @@ import { normalizeGiteaApiBaseUrl } from './client' import { _resetGiteaRepoRefCache } from './repository-ref' +import { + _getGiteaPullRequestScanCacheSize, + _resetGiteaPullRequestScanCache, + scanGiteaPullRequests +} from './pull-request-scan-cache' const OLD_ENV = process.env @@ -44,6 +49,7 @@ describe('Gitea client', () => { stderr: '' }) _resetGiteaRepoRefCache() + _resetGiteaPullRequestScanCache() vi.unstubAllGlobals() }) @@ -92,6 +98,145 @@ describe('Gitea client', () => { expect(listUrl.searchParams.get('limit')).toBe('50') }) + it('shares one /pulls scan across concurrent branch lookups (#8807)', async () => { + let listCalls = 0 + const fetchMock = vi.fn(async (url: string) => { + const parsed = new URL(url) + if (parsed.pathname.endsWith('/status')) { + return Response.json({ state: 'success' }) + } + listCalls++ + return Response.json([giteaPr(7, 'feature/a'), giteaPr(8, 'feature/b')]) + }) + vi.stubGlobal('fetch', fetchMock) + + const [a, b, missing] = await Promise.all([ + getGiteaPullRequestForBranch('/repo', 'feature/a'), + getGiteaPullRequestForBranch('/repo', 'feature/b'), + getGiteaPullRequestForBranch('/repo', 'feature/none') + ]) + + expect(a?.number).toBe(7) + expect(b?.number).toBe(8) + expect(missing).toBeNull() + expect(listCalls).toBe(1) + }) + + it('reuses the cached /pulls scan for lookups inside the TTL', async () => { + let listCalls = 0 + const fetchMock = vi.fn(async (url: string) => { + const parsed = new URL(url) + if (parsed.pathname.endsWith('/status')) { + return Response.json({ state: 'success' }) + } + listCalls++ + return Response.json([giteaPr()]) + }) + vi.stubGlobal('fetch', fetchMock) + + await getGiteaPullRequestForBranch('/repo', 'feature/gitea') + await getGiteaPullRequestForBranch('/repo', 'feature/gitea') + await getGiteaPullRequestForBranch('/repo', 'no-pr-branch') + + expect(listCalls).toBe(1) + }) + + it('retries a failed /pulls scan after only the short failure cooldown', async () => { + vi.useFakeTimers() + try { + let listCalls = 0 + const fetchMock = vi.fn(async (url: string) => { + const parsed = new URL(url) + if (parsed.pathname.endsWith('/status')) { + return Response.json({ state: 'success' }) + } + listCalls++ + return listCalls === 1 + ? Response.json({ message: 'temporary failure' }, { status: 503 }) + : Response.json([giteaPr()]) + }) + vi.stubGlobal('fetch', fetchMock) + + await expect(getGiteaPullRequestForBranch('/repo', 'feature/gitea')).resolves.toBeNull() + await expect(getGiteaPullRequestForBranch('/repo', 'feature/gitea')).resolves.toBeNull() + expect(listCalls).toBe(1) + + await vi.advanceTimersByTimeAsync(3_001) + await expect(getGiteaPullRequestForBranch('/repo', 'feature/gitea')).resolves.toMatchObject({ + number: 7 + }) + expect(listCalls).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('expires successful scans and bounds retained repository listings', async () => { + vi.useFakeTimers() + try { + let listCalls = 0 + const fetchMock = vi.fn(async (url: string) => { + const parsed = new URL(url) + if (parsed.pathname.endsWith('/status')) { + return Response.json({ state: 'success' }) + } + listCalls++ + return Response.json([giteaPr()]) + }) + vi.stubGlobal('fetch', fetchMock) + + await getGiteaPullRequestForBranch('/repo', 'feature/gitea') + expect(_getGiteaPullRequestScanCacheSize()).toBe(1) + await vi.advanceTimersByTimeAsync(30_001) + expect(_getGiteaPullRequestScanCacheSize()).toBe(0) + await getGiteaPullRequestForBranch('/repo', 'feature/gitea') + expect(listCalls).toBe(2) + + await Promise.all( + Array.from({ length: 40 }, (_, index) => + scanGiteaPullRequests(`repo-${index}`, async () => [], 50, 5) + ) + ) + expect(_getGiteaPullRequestScanCacheSize()).toBe(32) + } finally { + vi.useRealTimers() + } + }) + + it('does not let an in-flight scan re-cache results from before an invalidation', async () => { + let releaseFirstScan!: () => void + const firstScanGate = new Promise((resolve) => { + releaseFirstScan = resolve + }) + let listCalls = 0 + const fetchMock = vi.fn(async (url: string) => { + const parsed = new URL(url) + if (parsed.pathname.endsWith('/status')) { + return Response.json({ state: 'success' }) + } + listCalls++ + if (listCalls === 1) { + // First scan is in flight (pre-create listing) when the invalidation lands. + await firstScanGate + return Response.json([giteaPr(7, 'feature/old')]) + } + return Response.json([giteaPr(7, 'feature/old'), giteaPr(8, 'feature/new')]) + }) + vi.stubGlobal('fetch', fetchMock) + + const staleScanRead = getGiteaPullRequestForBranch('/repo', 'feature/old') + const { invalidateGiteaPullRequestScanForRepo, getGiteaRepoSlug } = await import('./client') + const repo = await getGiteaRepoSlug('/repo') + invalidateGiteaPullRequestScanForRepo(repo!) + releaseFirstScan() + await staleScanRead + + await expect(getGiteaPullRequestForBranch('/repo', 'feature/new')).resolves.toMatchObject({ + number: 8 + }) + expect(listCalls).toBe(2) + }) + it('uses an API base URL override for subpath or non-standard deployments', async () => { process.env.ORCA_GITEA_API_BASE_URL = 'https://git.example.com/code' const fetchMock = vi.fn(async (url: string | URL) => { diff --git a/src/main/gitea/client.ts b/src/main/gitea/client.ts index 300ecda6e04..30545f83ad7 100644 --- a/src/main/gitea/client.ts +++ b/src/main/gitea/client.ts @@ -6,12 +6,17 @@ import { type RawGiteaPullRequest } from './pull-request-mappers' import { getGiteaRepoRef, type GiteaRepoRef } from './repository-ref' +import { invalidateGiteaPullRequestScan, scanGiteaPullRequests } from './pull-request-scan-cache' import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' const REQUEST_TIMEOUT_MS = 5000 +// Why: self-hosted Forgejo can take ~5s to serve one /pulls page (it loads +// reviewer data per PR). The default 5s cap aborted responses right as they +// completed, so the work was discarded and retried on the next refresh (#8807). +const PULL_REQUEST_LIST_TIMEOUT_MS = 15_000 const PULL_REQUEST_PAGE_LIMIT = 50 const MAX_PULL_REQUEST_PAGES = 5 @@ -104,6 +109,16 @@ function encodedRepoPath(repo: GiteaRepoRef): string { return `${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}` } +function giteaPullRequestScanKey(repo: GiteaRepoRef): string { + return `${configuredApiBaseUrl(repo)}/${encodedRepoPath(repo)}` +} + +/** Invalidate the shared /pulls scan after Orca itself creates a PR so the + * next worktree-card refresh sees it instead of a cached miss. */ +export function invalidateGiteaPullRequestScanForRepo(repo: GiteaRepoRef): void { + invalidateGiteaPullRequestScan(giteaPullRequestScanKey(repo)) +} + async function getCommitStatus( repo: GiteaRepoRef, headSha: string | undefined @@ -227,26 +242,24 @@ export async function getGiteaPullRequestForBranch( } if (branchName) { - for (let page = 1; page <= MAX_PULL_REQUEST_PAGES; page++) { - const list = await requestJson( - repo, - `/repos/${encodedRepoPath(repo)}/pulls`, - { + const pullRequests = await scanGiteaPullRequests( + giteaPullRequestScanKey(repo), + (page) => + requestJson(repo, `/repos/${encodedRepoPath(repo)}/pulls`, { searchParams: { state: 'all', sort: 'recentupdate', page, limit: PULL_REQUEST_PAGE_LIMIT - } - } - ) - const raw = list?.find((item) => matchesBranch(item, branchName)) - if (raw) { - return normalizePullRequest(repo, raw) - } - if (!list || list.length < PULL_REQUEST_PAGE_LIMIT) { - break - } + }, + timeoutMs: PULL_REQUEST_LIST_TIMEOUT_MS + }), + PULL_REQUEST_PAGE_LIMIT, + MAX_PULL_REQUEST_PAGES + ) + const raw = pullRequests.find((item) => matchesBranch(item, branchName)) + if (raw) { + return normalizePullRequest(repo, raw) } } diff --git a/src/main/gitea/pull-request-creation.ts b/src/main/gitea/pull-request-creation.ts index b113b1783ce..3fdef7daec1 100644 --- a/src/main/gitea/pull-request-creation.ts +++ b/src/main/gitea/pull-request-creation.ts @@ -8,7 +8,7 @@ import { requestHostedReviewJson } from '../source-control/hosted-review-api-request' import { readHostedPullRequestTemplate } from '../source-control/pull-request-template' -import { getGiteaPullRequestForBranch } from './client' +import { getGiteaPullRequestForBranch, invalidateGiteaPullRequestScanForRepo } from './client' import { mapGiteaPullRequest, type RawGiteaPullRequest } from './pull-request-mappers' import { getGiteaRepoRef, type GiteaRepoRef } from './repository-ref' @@ -105,6 +105,12 @@ async function findExistingPullRequest( head: string, connectionId?: string | null ): Promise<{ number: number; url: string } | null> { + // Why: only called after a create attempt, which may have just mutated the + // remote — a cached /pulls scan from before the POST would miss the new PR. + const repo = await getGiteaRepoRef(repoPath, connectionId) + if (repo) { + invalidateGiteaPullRequestScanForRepo(repo) + } const existing = await getGiteaPullRequestForBranch(repoPath, head, null, connectionId) return existing ? { number: existing.number, url: existing.url } : null } @@ -177,6 +183,7 @@ export async function createGiteaPullRequest( ) const created = mapGiteaPullRequest(raw, 'neutral') if (created) { + invalidateGiteaPullRequestScanForRepo(repo) return { ok: true, number: created.number, url: created.url } } const found = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null) diff --git a/src/main/gitea/pull-request-scan-cache.ts b/src/main/gitea/pull-request-scan-cache.ts new file mode 100644 index 00000000000..8864f087d6d --- /dev/null +++ b/src/main/gitea/pull-request-scan-cache.ts @@ -0,0 +1,161 @@ +import type { RawGiteaPullRequest } from './pull-request-mappers' + +export type GiteaPullRequestPageFetcher = (page: number) => Promise + +type GiteaPullRequestScanEntry = { + expiresAt: number + expirationTimer: ReturnType + pullRequests: RawGiteaPullRequest[] +} + +// Why: long enough to absorb a push-event burst that refreshes every worktree +// card at once, short enough that a PR opened outside Orca shows up promptly. +const SCAN_TTL_MS = 30_000 +// Why: a short failure cooldown still coalesces rapid card retries without +// turning a transient outage into a 30-second authoritative "no PR" result. +const FAILED_SCAN_RETRY_MS = 3_000 +// Why: each entry can retain hundreds of full PR payloads, so TTL alone is not +// enough protection when many repositories are opened during one app session. +const MAX_SCAN_CACHE_ENTRIES = 32 + +const scanCache = new Map() +const inFlightScans = new Map>() +// Why: an invalidation (PR just created) must also defeat a scan already in +// flight — otherwise that scan finishes afterwards and re-caches a listing +// from before the mutation, hiding the new PR for a full TTL. +const scanGenerations = new Map() +const activeScanCounts = new Map() + +function removeScanCacheEntry(repoKey: string, expected?: GiteaPullRequestScanEntry): void { + const entry = scanCache.get(repoKey) + if (!entry || (expected && entry !== expected)) { + return + } + clearTimeout(entry.expirationTimer) + scanCache.delete(repoKey) +} + +function rememberScanCacheEntry( + repoKey: string, + pullRequests: RawGiteaPullRequest[], + ttlMs: number +): void { + removeScanCacheEntry(repoKey) + let entry!: GiteaPullRequestScanEntry + const expirationTimer = setTimeout(() => removeScanCacheEntry(repoKey, entry), ttlMs) + expirationTimer.unref() + entry = { + expiresAt: Date.now() + ttlMs, + expirationTimer, + pullRequests + } + scanCache.set(repoKey, entry) + while (scanCache.size > MAX_SCAN_CACHE_ENTRIES) { + const oldestKey = scanCache.keys().next().value + if (oldestKey === undefined) { + break + } + removeScanCacheEntry(oldestKey) + } +} + +function reusableScanCacheEntry(repoKey: string): GiteaPullRequestScanEntry | null { + const entry = scanCache.get(repoKey) + if (!entry) { + return null + } + if (Date.now() >= entry.expiresAt) { + removeScanCacheEntry(repoKey, entry) + return null + } + // Keep the cap useful for users actively switching among several repositories. + scanCache.delete(repoKey) + scanCache.set(repoKey, entry) + return entry +} + +/** + * Why: every worktree card resolves its branch by paginating the same + * /repos/{repo}/pulls listing — Gitea/Forgejo have no head-branch filter. + * Self-hosted forges serve that endpoint slowly, and a push event refreshes + * all cards at once, so per-card scans multiplied one page walk into hundreds + * of requests and OOM-killed a small Forgejo pod (#8807). All concurrent + * callers share one in-flight scan per repo, and the result is cached briefly + * so a burst costs a single page walk. + */ +export async function scanGiteaPullRequests( + repoKey: string, + fetchPage: GiteaPullRequestPageFetcher, + pageLimit: number, + maxPages: number +): Promise { + const cached = reusableScanCacheEntry(repoKey) + if (cached) { + return cached.pullRequests + } + const running = inFlightScans.get(repoKey) + if (running) { + return running + } + const generation = scanGenerations.get(repoKey) ?? 0 + activeScanCounts.set(repoKey, (activeScanCounts.get(repoKey) ?? 0) + 1) + const scan = (async () => { + const pullRequests: RawGiteaPullRequest[] = [] + let completed = true + for (let page = 1; page <= maxPages; page++) { + const list = await fetchPage(page) + if (!list) { + completed = false + break + } + pullRequests.push(...list) + if (list.length < pageLimit) { + break + } + } + if ((scanGenerations.get(repoKey) ?? 0) === generation) { + rememberScanCacheEntry(repoKey, pullRequests, completed ? SCAN_TTL_MS : FAILED_SCAN_RETRY_MS) + } + return pullRequests + })() + inFlightScans.set(repoKey, scan) + try { + return await scan + } finally { + if (inFlightScans.get(repoKey) === scan) { + inFlightScans.delete(repoKey) + } + const activeScans = (activeScanCounts.get(repoKey) ?? 1) - 1 + if (activeScans > 0) { + activeScanCounts.set(repoKey, activeScans) + } else { + activeScanCounts.delete(repoKey) + scanGenerations.delete(repoKey) + } + } +} + +/** Drop the cached scan after a mutation Orca itself performed (PR create), + * so the next card refresh sees the new PR instead of a stale miss. */ +export function invalidateGiteaPullRequestScan(repoKey: string): void { + removeScanCacheEntry(repoKey) + inFlightScans.delete(repoKey) + if ((activeScanCounts.get(repoKey) ?? 0) > 0) { + scanGenerations.set(repoKey, (scanGenerations.get(repoKey) ?? 0) + 1) + } else { + scanGenerations.delete(repoKey) + } +} + +export function _resetGiteaPullRequestScanCache(): void { + for (const repoKey of scanCache.keys()) { + removeScanCacheEntry(repoKey) + } + inFlightScans.clear() + scanGenerations.clear() + activeScanCounts.clear() +} + +export function _getGiteaPullRequestScanCacheSize(): number { + return scanCache.size +} diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index 5312343224a..df658007fe5 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -49,6 +49,47 @@ vi.mock('./rate-limit', () => ({ import { countWorkItems, getWorkItem, listWorkItems, _resetOwnerRepoCache } from './client' +const PR_LIST_FIELDS = + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' + +function issueSearchArgs( + ownerRepo: string, + options: { noCache?: boolean; query?: string } = {} +): string[] { + const query = options.query ?? 'is:issue is:open' + return [ + 'api', + ...(options.noCache ? [] : ['--cache', '120s']), + `search/issues?q=${encodeURIComponent(`repo:${ownerRepo} ${query}`)}&sort=created&order=desc&per_page=10&page=1`, + '--jq', + '.items' + ] +} + +function prListArgs(ownerRepo: string, query = 'is:pr is:open'): string[] { + return [ + 'pr', + 'list', + '--limit', + '10', + '--state', + 'all', + '--json', + PR_LIST_FIELDS, + '--repo', + ownerRepo, + '--search', + `${query} sort:created-desc` + ] +} + +function decodedIssueSearchPath(callIndex: number): string { + const args = ghExecFileAsyncMock.mock.calls[callIndex]?.[0] as string[] | undefined + const apiPath = args?.find((arg) => arg.startsWith('search/issues?')) + expect(apiPath).toBeDefined() + return decodeURIComponent(apiPath ?? '') +} + describe('GitHub issue source split', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -115,26 +156,12 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'api', - '--cache', - '120s', - 'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), { + cwd: '/repo-root' + }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), { + cwd: '/repo-root' + }) }) it('omits gh api cache args for no-cache recent work-item requests', async () => { @@ -148,14 +175,12 @@ describe('GitHub issue source split', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, - ['api', 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'], - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - ['api', 'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc'], + issueSearchArgs('stablyai/orca', { noCache: true }), { cwd: '/repo-root' } ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), { + cwd: '/repo-root' + }) }) it('lists SSH repo work items with explicit owner/repo and no local cwd', async () => { @@ -177,26 +202,8 @@ describe('GitHub issue source split', () => { {} ) expect(getOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2', {}) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ], - {} - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'api', - '--cache', - '120s', - 'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc' - ], - {} - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), {}) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), {}) }) it('uses upstream for issue-only queries and origin for PR-only queries', async () => { @@ -206,10 +213,7 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, 'is:issue') - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--repo', 'stablyai/orca']), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:stablyai/orca is:issue') ghExecFileAsyncMock.mockClear() getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) @@ -237,16 +241,9 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'api', - '--cache', - '120s', - 'repos/stablyai/orca/pulls?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('stablyai/orca'), { + cwd: '/repo-root' + }) }) it("uses upstream for queried PRs when preference='upstream'", async () => { @@ -515,16 +512,9 @@ describe('GitHub issue source split', () => { const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'auto') expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'auto', undefined, {}) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), { + cwd: '/repo-root' + }) expect(result.issueSourceFellBack).toBeUndefined() }) @@ -540,16 +530,9 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, undefined, undefined, 'auto') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - 'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('solo/orca'), { + cwd: '/repo-root' + }) }) it("preference='upstream' + upstream exists → queries upstream", async () => { @@ -564,13 +547,7 @@ describe('GitHub issue source split', () => { const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ]), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:stablyai/orca is:issue is:open') expect(result.issueSourceFellBack).toBeUndefined() }) @@ -586,13 +563,7 @@ describe('GitHub issue source split', () => { const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - 'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ]), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:solo/orca is:issue is:open') expect(result.issueSourceFellBack).toBe(true) }) @@ -608,13 +579,7 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, undefined, undefined, 'origin') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - 'repos/fork/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ]), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:fork/orca is:issue is:open') }) it("preference='origin' + no upstream → queries origin", async () => { @@ -629,13 +594,7 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, undefined, undefined, 'origin') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - 'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ]), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:solo/orca is:issue is:open') }) it('surfaces upstreamCandidate in sources regardless of effective preference', async () => { diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index d0e93eb306c..a30db9f19e0 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -180,18 +180,12 @@ describe('listWorkItems', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, [ - 'issue', - 'list', - '--limit', - '10', - '--json', - 'number,title,state,url,labels,updatedAt,author,assignees', - '--repo', - 'acme/widgets', - '--assignee', - '@me', - '--search', - 'sort:updated-desc' + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue assignee:@me')}&sort=created&order=desc&per_page=10&page=1`, + '--jq', + '.items' ], { cwd: '/repo-root' } ) @@ -202,14 +196,14 @@ describe('listWorkItems', () => { 'list', '--limit', '10', + '--state', + 'all', '--json', 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', - '--assignee', - '@me', '--search', - 'sort:updated-desc' + 'is:pr assignee:@me sort:created-desc' ], { cwd: '/repo-root' } ) @@ -218,24 +212,6 @@ describe('listWorkItems', () => { expect(prListFields).toContain('reviewRequests') expect(prListFields).not.toContain('mergeStateStatus') expect(items).toEqual([ - { - id: 'issue:12', - type: 'issue', - number: 12, - title: 'Fix bug', - state: 'open', - url: 'https://github.com/acme/widgets/issues/12', - labels: [], - updatedAt: '2026-03-29T00:00:00Z', - author: 'octocat', - assignees: [ - { - login: 'test-assignee', - name: 'Test Assignee', - avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' - } - ] - }, { id: 'pr:42', type: 'pr', @@ -257,6 +233,24 @@ describe('listWorkItems', () => { avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' } ] + }, + { + id: 'issue:12', + type: 'issue', + number: 12, + title: 'Fix bug', + state: 'open', + url: 'https://github.com/acme/widgets/issues/12', + labels: [], + updatedAt: '2026-03-29T00:00:00Z', + author: 'octocat', + assignees: [ + { + login: 'test-assignee', + name: 'Test Assignee', + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' + } + ] } ]) }) @@ -404,15 +398,14 @@ describe('listWorkItems', () => { 'list', '--limit', '10', + '--state', + 'all', '--json', 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', - '--state', - 'open', - '--draft', '--search', - 'sort:updated-desc' + 'is:pr is:open draft:true sort:created-desc' ], { cwd: '/repo-root' } ) @@ -465,14 +458,14 @@ describe('listWorkItems', () => { 'list', '--limit', '10', + '--state', + 'all', '--json', 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', - '--state', - 'merged', '--search', - 'sort:updated-desc' + 'is:pr is:merged sort:created-desc' ], { cwd: '/repo-root' } ) @@ -536,7 +529,12 @@ describe('listWorkItems', () => { const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:closed') expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--state', 'closed', '--search', '-is:merged sort:updated-desc']), + expect.arrayContaining([ + '--state', + 'all', + '--search', + 'is:pr is:closed -is:merged sort:created-desc' + ]), { cwd: '/repo-root' } ) expect(items).toMatchObject([{ id: 'pr:9', type: 'pr', state: 'closed' }]) @@ -605,7 +603,7 @@ describe('listWorkItems', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'review-requested:@me sort:updated-desc']), + expect.arrayContaining(['--search', 'is:pr is:open review-requested:@me sort:created-desc']), { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( @@ -614,32 +612,86 @@ describe('listWorkItems', () => { ) }) - it('pins list ordering to updated-desc so the updatedAt cursor pages consistently', async () => { + it('uses the requested numbered Search API page for issues', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - await listWorkItems('/repo-root', 10, 'is:issue is:open') + await listWorkItems('/repo-root', 10, 'is:issue is:open', 2) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'sort:updated-desc']), + [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`, + '--jq', + '.items' + ], { cwd: '/repo-root' } ) }) - it('combines the inclusive updatedAt cursor with updated-desc ordering on later pages', async () => { + it('fetches and slices stable PR results for the requested numbered page', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify( + [1, 2, 3, 4].map((number) => ({ + number, + title: `PR ${number}`, + state: 'OPEN', + url: `https://github.com/acme/widgets/pull/${number}`, + labels: [], + updatedAt: `2026-07-0${number}T00:00:00Z`, + author: { login: 'octocat' }, + isDraft: false, + headRefName: `feature/${number}`, + headRefOid: `head-${number}`, + baseRefName: 'main' + })) + ) + }) - await listWorkItems('/repo-root', 10, 'is:issue is:open', '2026-07-01T00:00:00Z') + const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2) - // Why: the bound is inclusive (`<=`) so boundary items sharing the cursor's - // exact updatedAt aren't skipped; the renderer dedupes the re-fetched rows. expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'updated:<=2026-07-01T00:00:00Z sort:updated-desc']), + expect.arrayContaining(['--limit', '4', '--search', 'is:pr is:open sort:created-desc']), { cwd: '/repo-root' } ) + expect(items.map((item) => item.number)).toEqual([4, 3]) + }) + + it('filters pull request rows out of issue Search API results', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 2, + title: 'PR-shaped search row', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/2', + labels: [], + updated_at: '2026-07-02T00:00:00Z', + user: { login: 'octocat' }, + pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' } + }, + { + number: 1, + title: 'Issue row', + state: 'open', + html_url: 'https://github.com/acme/widgets/issues/1', + labels: [], + updated_at: '2026-07-01T00:00:00Z', + user: { login: 'octocat' } + } + ]) + }) + + const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open') + + expect(items.map((item) => item.id)).toEqual(['issue:1']) }) it('returns open issues and PRs for the all-open preset query', async () => { @@ -679,18 +731,12 @@ describe('listWorkItems', () => { const { items } = await listWorkItems('/repo-root', 10, 'is:open') expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ - 'issue', - 'list', - '--limit', - '10', - '--json', - 'number,title,state,url,labels,updatedAt,author,assignees', - '--repo', - 'acme/widgets', - '--state', - 'open', - '--search', - 'sort:updated-desc' + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=1`, + '--jq', + '.items' ], { cwd: '/repo-root' } ) @@ -700,29 +746,18 @@ describe('listWorkItems', () => { 'list', '--limit', '10', + '--state', + 'all', '--json', 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', - '--state', - 'open', '--search', - 'sort:updated-desc' + 'is:pr is:open sort:created-desc' ], { cwd: '/repo-root' } ) expect(items).toEqual([ - { - id: 'issue:1', - type: 'issue', - number: 1, - title: 'Open issue', - state: 'open', - url: 'https://github.com/acme/widgets/issues/1', - labels: [], - updatedAt: '2026-03-31T00:00:00Z', - author: 'octocat' - }, { id: 'pr:2', type: 'pr', @@ -737,6 +772,17 @@ describe('listWorkItems', () => { baseRefName: 'main', headSha: 'head-2', prRepo: { owner: 'acme', repo: 'widgets' } + }, + { + id: 'issue:1', + type: 'issue', + number: 1, + title: 'Open issue', + state: 'open', + url: 'https://github.com/acme/widgets/issues/1', + labels: [], + updatedAt: '2026-03-31T00:00:00Z', + author: 'octocat' } ]) }) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index c0a3c58be37..9e3de8a15c9 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -926,7 +926,11 @@ describe('getPRForBranch', () => { expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(outcome).toMatchObject({ kind: 'found', - pr: { number: 6012, confirmedContainedHeadOid: 'cccc3333cccc3333' } + pr: { + number: 6012, + headRefName: 'fix-hibernation-wake', + confirmedContainedHeadOid: 'cccc3333cccc3333' + } }) }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 5330de5f701..54572bd406f 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -32,7 +32,7 @@ import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github-work-items-q import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query' import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE, - sortWorkItemsByUpdatedAt + sortWorkItemsByNumber } from '../../shared/work-items' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' @@ -449,17 +449,13 @@ export async function getAuthenticatedViewer(): Promise { } // Why: main-process maps omit repoId because the IPC handler never receives -// a repo identifier beyond path. The renderer stamps repoId after IPC so -// single-repo and cross-repo items are uniform downstream. -type MainWorkItem = Omit +// a repo identifier beyond path. Exported because runtime consumers receive +// listWorkItems results before the renderer stamps repoId. +export type MainWorkItem = Omit -const WORK_ITEM_ISSUE_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,assignees' - -// Why: the Tasks pager slices pages on updatedAt, so every list/search fetch -// must return rows newest-updated-first for the cursor to advance correctly. -// This is the search-qualifier spelling of the same contract the REST list -// paths express as `sort=updated&direction=desc`; keep the two in sync (#8649). -const WORK_ITEM_LIST_SORT_QUALIFIER = 'sort:updated-desc' +// Why: issue numbers follow creation order within a repository. Pinning this +// sort keeps gh's rich PR rows aligned with numbered Search API issue pages. +const WORK_ITEM_NUMBER_SORT_QUALIFIER = 'sort:created-desc' const WORK_ITEM_PR_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' @@ -956,78 +952,98 @@ async function fetchPullRequestWorkItem( return mapPullRequestWorkItem(JSON.parse(stdout) as Record) } -function buildWorkItemListArgs(args: { +type WorkItemListRequest = { + args: string[] + offset: number +} + +function normalizeWorkItemPage(page: number | undefined): number { + return typeof page === 'number' && Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1 +} + +function buildWorkItemListRequest(args: { kind: 'issue' | 'pr' ownerRepo: OwnerRepo | null limit: number query: ParsedTaskQuery - before?: string -}): string[] { - const { kind, ownerRepo, limit, query, before } = args - const fields = kind === 'issue' ? WORK_ITEM_ISSUE_LIST_JSON_FIELDS : WORK_ITEM_PR_LIST_JSON_FIELDS - const command = kind === 'issue' ? ['issue', 'list'] : ['pr', 'list'] - const out = [...command, '--limit', String(limit), '--json', fields] + page: number +}): WorkItemListRequest { + const { kind, ownerRepo, limit, query, page } = args + const searchParts: string[] = [] - if (ownerRepo) { - out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + if (kind === 'issue' && ownerRepo) { + searchParts.push(`repo:${ownerRepo.owner}/${ownerRepo.repo}`) + } + searchParts.push(kind === 'issue' ? 'is:issue' : 'is:pr') + + if (query.state === 'open') { + searchParts.push('is:open') + } else if (query.state === 'closed') { + searchParts.push('is:closed') + if (kind === 'pr') { + searchParts.push('-is:merged') + } + } else if (query.state === 'merged') { + searchParts.push('is:merged') } - if (query.state) { - out.push('--state', query.state) + if (kind === 'pr' && query.draft) { + searchParts.push('draft:true') } - // Why: GitHub considers merged PRs as "closed". When the user filters for - // closed-only, exclude merged PRs via the search predicate so the displayed - // and counted results match the user's intent. - const excludeMergedFromClosed = kind === 'pr' && query.state === 'closed' if (query.assignee) { - out.push('--assignee', query.assignee) + searchParts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`) } if (query.author) { - out.push('--author', query.author) + searchParts.push(`author:${quoteGitHubSearchValue(query.author)}`) } if (query.labels.length > 0) { for (const label of query.labels) { - out.push('--label', label) + searchParts.push(`label:${quoteGitHubSearchValue(label)}`) } } - // Why: only add --draft when the user explicitly typed `is:draft`. Previously - // this fired for any PR-scoped open query, which made `is:pr is:open` (the - // "PRs" preset) silently filter to drafts-only. - if (kind === 'pr' && query.draft) { - out.push('--draft') - } - - const searchParts: string[] = [] - if (excludeMergedFromClosed) { - searchParts.push('-is:merged') - } - // Why: cursor-based pagination. GitHub search supports updated:<=DATE to - // fetch items at or older than the cursor. We use the oldest item's updatedAt - // from the previous page as the cursor. The bound is inclusive (`<=`) so items - // sharing the boundary row's exact updatedAt aren't skipped between pages; the - // renderer dedupes the re-fetched boundary rows by id (#8649). - if (before) { - searchParts.push(`updated:<=${before}`) - } if (kind === 'pr' && query.reviewRequested) { - searchParts.push(`review-requested:${query.reviewRequested}`) + searchParts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`) } if (kind === 'pr' && query.reviewedBy) { - searchParts.push(`reviewed-by:${query.reviewedBy}`) + searchParts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`) } if (query.freeText) { searchParts.push(query.freeText) } - // Why: pagination cursors slice on updatedAt, but `gh issue list` defaults - // to created-desc and `--search` defaults to best-match. `gh issue/pr list` - // has no --sort flag, so the only lever is the search query — which forces us - // to always emit --search. Without pinning the sort, recently-updated old - // items never appear on any page, so the pager advertises pages the fetch - // chain can never reach (#8649). - searchParts.push(WORK_ITEM_LIST_SORT_QUALIFIER) + + if (kind === 'issue') { + return { + args: [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent(searchParts.join(' '))}&sort=created&order=desc&per_page=${limit}&page=${page}`, + '--jq', + '.items' + ], + offset: 0 + } + } + + // Why: search/issues omits the PR fields used by the Tasks management + // columns. Fetch through gh's rich PR list and slice its stable created sort. + searchParts.push(WORK_ITEM_NUMBER_SORT_QUALIFIER) + const out = [ + 'pr', + 'list', + '--limit', + String(Math.min(page * limit, 1000)), + '--state', + 'all', + '--json', + WORK_ITEM_PR_LIST_JSON_FIELDS + ] + if (ownerRepo) { + out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } out.push('--search', searchParts.join(' ')) - return out + return { args: out, offset: (page - 1) * limit } } // Why: internal shape shared by listRecentWorkItems / listQueriedWorkItems so @@ -1105,6 +1121,7 @@ async function listRecentWorkItems( issueOwnerRepo: OwnerRepo | null, prOwnerRepo: OwnerRepo | null, limit: number, + page: number, connectionId?: string | null, noCache?: boolean, localGitOptions: LocalGitExecOptions = {} @@ -1112,72 +1129,49 @@ async function listRecentWorkItems( const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(repoContext) const requiresExplicitRepo = Boolean(connectionId) - const restCacheArgs = noCache ? [] : ['--cache', '120s'] assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) + const recentQuery = parseTaskQuery('is:open') + const issueRequest = buildWorkItemListRequest({ + kind: 'issue', + ownerRepo: issueOwnerRepo, + limit, + query: recentQuery, + page + }) + const prRequest = buildWorkItemListRequest({ + kind: 'pr', + ownerRepo: prOwnerRepo, + limit, + query: recentQuery, + page + }) + if (noCache) { + issueRequest.args.splice(1, 2) + } if (issueOwnerRepo || prOwnerRepo || requiresExplicitRepo) { // Why: allSettled so a 403 on upstream issues doesn't zero out the origin // PR half — the UI renders partial results plus a banner for the failing // side, matching the parent design doc's partial-failure rule (§2). const [issuesSettled, prsSettled] = await Promise.allSettled([ issueOwnerRepo - ? ghExecFileAsync( - [ - 'api', - ...restCacheArgs, - `repos/${issueOwnerRepo.owner}/${issueOwnerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc` - ], - ghOptions - ) + ? ghExecFileAsync(issueRequest.args, ghOptions) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) - : ghCwdResolvedExec( - repoContext, - [ - 'issue', - 'list', - '--limit', - String(limit), - '--state', - 'open', - '--json', - WORK_ITEM_ISSUE_LIST_JSON_FIELDS - ], - ghOptions - ), + : ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions), prOwnerRepo - ? ghExecFileAsync( - [ - 'api', - ...restCacheArgs, - `repos/${prOwnerRepo.owner}/${prOwnerRepo.repo}/pulls?per_page=${limit}&state=open&sort=updated&direction=desc` - ], - ghOptions - ) + ? ghExecFileAsync(prRequest.args, ghOptions) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) - : ghCwdResolvedExec( - repoContext, - [ - 'pr', - 'list', - '--limit', - String(limit), - '--state', - 'open', - '--json', - WORK_ITEM_PR_LIST_JSON_FIELDS - ], - ghOptions - ) + : ghCwdResolvedExec(repoContext, prRequest.args, ghOptions) ]) let issues: MainWorkItem[] = [] let issuesError: ClassifiedError | undefined if (issuesSettled.status === 'fulfilled') { issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) - // Why: the GitHub issues REST endpoint also returns pull requests with a - // `pull_request` marker. The new-workspace task picker needs distinct - // issue vs PR buckets, so drop PR-shaped issue rows here before merging. + // Why: the GitHub search/issues endpoint may still return PRs with a + // pull_request marker even when queried with is:issue. Filter them out + // here to keep the issue and PR buckets clean. .filter((item) => !('pull_request' in item)) .map(mapIssueWorkItem) } else { @@ -1190,9 +1184,9 @@ async function listRecentWorkItems( let prs: MainWorkItem[] = [] if (prsSettled.status === 'fulfilled') { - prs = (JSON.parse(prsSettled.value.stdout) as Record[]).map((item) => - mapPullRequestWorkItem(item, prOwnerRepo) - ) + prs = (JSON.parse(prsSettled.value.stdout) as Record[]) + .slice(prRequest.offset, prRequest.offset + limit) + .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, ghOptions) } else { // Why: PR-side failures must preserve the pre-diff behavior of @@ -1215,7 +1209,7 @@ async function listRecentWorkItems( } return { - items: sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit), + items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit), issuesError } } @@ -1228,45 +1222,19 @@ async function listRecentWorkItems( // effectively unusable for the feature — reject-all matches reality. If // non-GitHub remotes ever grow source metadata, revisit this symmetry. const [issuesResult, prsResult] = await Promise.all([ - ghCwdResolvedExec( - repoContext, - [ - 'issue', - 'list', - '--limit', - String(limit), - '--state', - 'open', - '--json', - WORK_ITEM_ISSUE_LIST_JSON_FIELDS - ], - ghOptions - ), - ghCwdResolvedExec( - repoContext, - [ - 'pr', - 'list', - '--limit', - String(limit), - '--state', - 'open', - '--json', - WORK_ITEM_PR_LIST_JSON_FIELDS - ], - ghOptions - ) + ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions), + ghCwdResolvedExec(repoContext, prRequest.args, ghOptions) ]) - const issues = (JSON.parse(issuesResult.stdout) as Record[]).map( - mapIssueWorkItem - ) - const prs = (JSON.parse(prsResult.stdout) as Record[]).map((item) => - mapPullRequestWorkItem(item, null) - ) + const issues = (JSON.parse(issuesResult.stdout) as Record[]) + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) + const prs = (JSON.parse(prsResult.stdout) as Record[]) + .slice(prRequest.offset, prRequest.offset + limit) + .map((item) => mapPullRequestWorkItem(item, null)) return { - items: sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit) + items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit) } } @@ -1276,7 +1244,7 @@ async function listQueriedWorkItems( prOwnerRepo: OwnerRepo | null, query: ParsedTaskQuery, limit: number, - before?: string, + page?: number, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { @@ -1302,19 +1270,21 @@ async function listQueriedWorkItems( if (requiresExplicitRepo && !issueOwnerRepo) { return { items: [] } } - const args = buildWorkItemListArgs({ + const request = buildWorkItemListRequest({ kind: 'issue', ownerRepo: issueOwnerRepo, limit, query, - before + page: page ?? 1 }) try { const { stdout } = issueOwnerRepo - ? await ghExecFileAsync(args, ghOptions) - : await ghCwdResolvedExec(repoContext, args, ghOptions) + ? await ghExecFileAsync(request.args, ghOptions) + : await ghCwdResolvedExec(repoContext, request.args, ghOptions) return { - items: (JSON.parse(stdout) as Record[]).map(mapIssueWorkItem) + items: (JSON.parse(stdout) as Record[]) + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) @@ -1329,20 +1299,20 @@ async function listQueriedWorkItems( if (requiresExplicitRepo && !prOwnerRepo) { return [] } - const args = buildWorkItemListArgs({ + const request = buildWorkItemListRequest({ kind: 'pr', ownerRepo: prOwnerRepo, limit, query, - before + page: page ?? 1 }) try { const { stdout } = prOwnerRepo - ? await ghExecFileAsync(args, ghOptions) - : await ghCwdResolvedExec(repoContext, args, ghOptions) - const mapped = (JSON.parse(stdout) as Record[]).map((item) => - mapPullRequestWorkItem(item, prOwnerRepo) - ) + ? await ghExecFileAsync(request.args, ghOptions) + : await ghCwdResolvedExec(repoContext, request.args, ghOptions) + const mapped = (JSON.parse(stdout) as Record[]) + .slice(request.offset, request.offset + limit) + .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, ghOptions) if (query.state === 'closed') { return hydrated.filter((item) => item.state !== 'merged') @@ -1356,7 +1326,7 @@ async function listQueriedWorkItems( const [issueResult, prItems] = await Promise.all([issueFetch, prFetch]) return { - items: sortWorkItemsByUpdatedAt([...issueResult.items, ...prItems]).slice(0, limit), + items: sortWorkItemsByNumber([...issueResult.items, ...prItems]).slice(0, limit), issuesError: issueResult.issuesError } } @@ -1365,13 +1335,14 @@ export async function listWorkItems( repoPath: string, limit = 24, query?: string, - before?: string, + page?: number, preference?: IssueSourcePreference, connectionId?: string | null, noCache?: boolean, localGitOptions: LocalGitExecOptions = {} ): Promise> { const trimmedQuery = query?.trim() ?? '' + const requestedPage = normalizeWorkItemPage(page) if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) { return { items: [], @@ -1401,6 +1372,7 @@ export async function listWorkItems( issueOwnerRepo, prOwnerRepo, limit, + requestedPage, connectionId, noCache, localGitOptions @@ -1411,7 +1383,7 @@ export async function listWorkItems( prOwnerRepo, parseTaskQuery(trimmedQuery), limit, - before, + requestedPage, connectionId, localGitOptions ) @@ -1480,7 +1452,7 @@ function buildSearchQueryString( } function quoteGitHubSearchValue(value: string): string { - return /\s/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value + return /[\s"]/.test(value) ? `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : value } async function countWorkItemsForQuery( @@ -3214,6 +3186,7 @@ export async function getPRForBranchOutcome( ...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}), ...(headDivergedFromMergedPRAtOid ? { headDivergedFromMergedPRAtOid } : {}), ...(data.baseRefName ? { baseRefName: data.baseRefName } : {}), + ...(data.headRefName ? { headRefName: data.headRefName } : {}), prRepo: dataRepo ?? undefined, headRepo: dataHeadRepo ?? undefined, conflictSummary diff --git a/src/main/index.ts b/src/main/index.ts index 8bda7328eda..efdd7ee9a6e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,7 @@ import { existsSync, statSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import os from 'node:os' -import { app, BrowserWindow, dialog, ipcMain, nativeTheme } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, nativeTheme, type Tray } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' import * as QRCode from 'qrcode' import { @@ -54,6 +54,7 @@ import { rebuildAppMenu } from './menu/register-app-menu' import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater' +import type { UpdateCheckOptions } from '../shared/types' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { configureElectronNetworkCompatibility, @@ -118,7 +119,13 @@ import { ensureAutoUpdaterConfigured } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' -import { createSystemTray, destroySystemTray, setTrayAttention } from './tray/system-tray' +import { + createSystemTray, + destroySystemTray, + setMacMenuBarIconVisible, + setTrayAttention, + type SystemTrayOptions +} from './tray/system-tray' import { focusExistingMainWindow } from './window/focus-existing-window' import { notifyMainWindowBecameVisible } from './window/main-window-visibility' import { CodexAccountService } from './codex-accounts/service' @@ -151,11 +158,11 @@ import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, getLocalPtyProvider, + getSshPtyProvider, registerHeadlessPtyRuntime } from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { EmulatorBridge } from './emulator/emulator-bridge' -import { serveSimStateWatcher } from './emulator/serve-sim-state-watcher' import { browserManager } from './browser/browser-manager' import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend' import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' @@ -239,6 +246,9 @@ let keybindings: KeybindingService | null = null // sweep spare live sessions across that one reload (#5787). const expectedRendererReload = createWebContentsTimedFlag() const recoveryReloadInFlight = createWebContentsTimedFlag() +// Why: a tray/menu-bar "Settings…" click can precede the renderer attaching +// its ui:openSettings listener; the renderer pulls this one-shot on mount. +const pendingOpenSettings = createWebContentsTimedFlag() let firstWindowStartupServicesReady: Promise = Promise.resolve() let managedWslCliReconciliationReady: Promise = Promise.resolve() let managedWslCliStartupBarrierReady: Promise = Promise.resolve() @@ -666,6 +676,12 @@ ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) }) +// Why: the renderer pulls this once its ui:openSettings listener is attached so +// a tray/menu-bar Settings request queued before mount is not lost to a race. +ipcMain.handle('ui:consumePendingOpenSettings', (event) => + pendingOpenSettings.matches(event.sender.id, { consume: true }) +) + ipcMain.handle( 'app:startupDiagnostic', (_event, event: string, details?: Record) => { @@ -788,6 +804,69 @@ function showMainWindowFromTray(): void { } } +function openSettingsFromSystemMenu(): void { + showMainWindowFromTray() + const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null + if (!targetWindow) { + return + } + recordCrashBreadcrumb('settings_opened') + + // Why: no main-side signal proves the renderer listener is attached, so push + // and leave a one-shot intent — a mounted renderer acts on the push, an + // unmounted one pulls the intent at mount; only one fires per renderer life. + targetWindow.webContents.send('ui:openSettings') + // Why: leave an untimed intent — any TTL can be outrun by a slow cold start and + // would silently drop the Settings click. webContents-id scoping plus consume-on- + // read still prevents the intent from leaking to a later, unrelated renderer. + pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) +} + +function quitFromSystemTray(): void { + if (mainWindow && !mainWindow.isDestroyed()) { + // Why: a real quit can still surface renderer save/discard prompts; the + // window must be visible if a hidden session vetoes shutdown. + showMainWindowFromTray() + } + // Why: set the quit latch before app.quit() so the window 'close' handler + // proceeds to teardown instead of re-hiding to the Windows tray. + isQuitting = true + app.quit() +} + +// Why: a manual check must run against a configured updater; the menu and +// tray are both clickable before anything else configures it. +function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { + ensureAutoUpdaterConfigured() + checkForUpdatesFromMenu(options) +} + +function getSystemTrayOptions(): SystemTrayOptions | null { + if (!store) { + return null + } + return { + appIcon: store.getSettings().appIcon, + onOpen: showMainWindowFromTray, + onOpenSettings: openSettingsFromSystemMenu, + onCheckForUpdates: () => { + // Why: updater status renders in the main window; with every window + // closed a bare check would complete invisibly. + showMainWindowFromTray() + runUserInitiatedUpdateCheck() + }, + onQuit: quitFromSystemTray + } +} + +function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { + if (process.platform !== 'darwin' || isServeMode) { + return null + } + const options = getSystemTrayOptions() + return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null +} + function openMainWindow(): BrowserWindow { logStartupMilestone('open-main-window-start') if (!store) { @@ -899,38 +978,26 @@ function openMainWindow(): BrowserWindow { }) recordCrashBreadcrumb('main_window_created') logStartupMilestone('window-created') - // Why: new Tray() is a synchronous Shell_NotifyIcon call that can block for - // seconds while explorer.exe's notification area is busy (part of issue - // #7225's pre-paint stall), so create it after first paint. The timer - // fallback covers windows revealed without ready-to-show ever firing - // (createMainWindow's 10s reveal fallback) — those can still be - // hidden to the tray on close, so the icon must exist by then. + // Why: Windows Tray construction can synchronously block on Shell_NotifyIcon, + // so both desktop status-item platforms defer creation to after first paint. let trayCreated = false const createSystemTrayDeferred = (): void => { if (trayCreated || window.isDestroyed() || isQuitting || !store) { return } trayCreated = true - // Why: Windows-only system tray. createSystemTray is idempotent and a - // no-op off win32, so calling it on each window open keeps exactly one - // live icon. - createSystemTray({ - appIcon: store.getSettings().appIcon, - onOpen: showMainWindowFromTray, - onQuit: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - // Why: a real quit can still surface renderer save/discard prompts; - // the window must be visible if a hidden-to-tray session vetoes - // shutdown. - showMainWindowFromTray() - } - // Why: set the quit latch before app.quit() so the window 'close' - // handler proceeds to teardown instead of re-hiding to the tray. - isQuitting = true - app.quit() + if (process.platform === 'darwin') { + // Why: route through syncMacMenuBarIcon so startup and the live toggle + // share one serve-mode/visibility policy. + if (syncMacMenuBarIcon(store.getSettings().showMenuBarIcon !== false)) { + logStartupMilestone('tray-created') } - }) - logStartupMilestone('tray-created') + return + } + const options = getSystemTrayOptions() + if (options && createSystemTray(options)) { + logStartupMilestone('tray-created') + } } window.once('ready-to-show', () => { logStartupMilestone('ready-to-show') @@ -1680,6 +1747,13 @@ app.whenReady().then(async () => { const activeOrcaProfile = ensureActiveOrcaProfile() store = new Store({ dataFile: activeOrcaProfile.dataFile }) logStartupMilestone('store-loaded') + store.onSettingsChanged((updates, settings) => { + if ('showMenuBarIcon' in updates) { + // Why: Store is the mutation authority for renderer, RPC, and future + // settings writes, so every macOS toggle updates the native item live. + syncMacMenuBarIcon(settings.showMenuBarIcon !== false) + } + }) // Why: must run before ClaudeRuntimeAuthService's constructor sync — a Claude // CLI that survived the restart inside the daemon still holds the current // single-use refresh token, and an unguarded early refresh would rotate it @@ -1818,6 +1892,9 @@ app.whenReady().then(async () => { // provider reference eagerly here would freeze the pre-daemon LocalPtyProvider // and defeat the teardown helper's prefix sweep (design §4.3 wire-up). getLocalProvider: () => getLocalPtyProvider(), + // Why: SSH relay providers are registered after runtime construction and + // may reconnect; destructive cleanup must resolve the current generation. + getSshProvider: (connectionId) => getSshPtyProvider(connectionId), onPtyStopped: clearProviderPtyState, onTerminalAgentStatus: (event) => { agentHookServer.ingestTerminalStatus(event) @@ -1954,16 +2031,10 @@ app.whenReady().then(async () => { ) // Emulator bridge (serve-sim). macOS-only feature (gated in CLI/runtime); always ship like agent-browser. + // Why: only Orca-managed or explicitly attached helpers belong to a workspace; + // externally started serve-sim processes must remain independent from Orca. const emulatorBridge = new EmulatorBridge() runtimeService.setEmulatorBridge(emulatorBridge) - serveSimStateWatcher.start() - serveSimStateWatcher.onDetected(({ worktreeId, info }) => { - runtimeService.getEmulatorBridge()?.registerActiveEmulator(worktreeId, info, { - managed: false - }) - serveSimStateWatcher.markOrcaManaged(info) - runtimeService.notifyEmulatorAutoAttachFromWatcher(worktreeId, info) - }) nativeTheme.themeSource = store.getSettings().theme ?? 'system' if (shouldInstallManagedHooks(is.dev)) { // Why: the persisted off switch must run before any auto-install path so @@ -1998,22 +2069,14 @@ app.whenReady().then(async () => { logStartupMilestone('i18n-ready') registerAppMenu({ - onCheckForUpdates: (options) => { - // Why: the menu is clickable before first paint; a manual check must - // run against a configured updater (see attach-main-window-services). - ensureAutoUpdaterConfigured() - return checkForUpdatesFromMenu(options) - }, + onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options), onBeforeReload: ({ ignoreCache, webContentsId }) => { if (mainWindow?.webContents.id === webContentsId) { markExpectedRendererReload(webContentsId) } recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) }, - onOpenSettings: () => { - recordCrashBreadcrumb('settings_opened') - mainWindow?.webContents.send('ui:openSettings') - }, + onOpenSettings: openSettingsFromSystemMenu, onOpenSetupGuide: (targetWindow) => { recordCrashBreadcrumb('setup_guide_opened') const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null @@ -2331,7 +2394,6 @@ app.on('will-quit', (e) => { // down explicitly on quit alongside the other browser/session shutdowns. runtime?.getOffscreenBrowserBackend()?.destroyAll?.() const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() - serveSimStateWatcher.stop() killAllPty() const watcherShutdown = shutdownWatchersOnce() store?.flush() diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts new file mode 100644 index 00000000000..57b0076b08e --- /dev/null +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + listeners, + recordCoalescedCrashBreadcrumbMock, + recordCrashBreadcrumbMock, + spanEndMock, + startSpanMock +} = vi.hoisted(() => { + const spanEndMock = vi.fn() + return { + listeners: new Map void>(), + recordCoalescedCrashBreadcrumbMock: vi.fn(), + recordCrashBreadcrumbMock: vi.fn(), + spanEndMock, + startSpanMock: vi.fn(() => ({ + traceId: 'trace-id', + spanId: 'span-id', + setAttribute: vi.fn(), + addEvent: vi.fn(), + fail: vi.fn(), + interrupt: vi.fn(), + end: spanEndMock + })) + } +}) + +vi.mock('electron', () => ({ + app: { getVersion: () => '1.2.3-test' }, + clipboard: { writeText: vi.fn() }, + ipcMain: { + removeHandler: vi.fn(), + handle: vi.fn(), + removeAllListeners: vi.fn((channel: string) => listeners.delete(channel)), + on: vi.fn((channel: string, listener: (_event: unknown, args?: unknown) => void) => { + listeners.set(channel, listener) + }) + } +})) + +vi.mock('./feedback', () => ({ + submitFeedback: vi.fn() +})) + +vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({ + getCrashBreadcrumbSnapshot: vi.fn(() => []), + recordCoalescedCrashBreadcrumb: (...args: unknown[]) => + recordCoalescedCrashBreadcrumbMock(...args), + recordCrashBreadcrumb: (...args: unknown[]) => recordCrashBreadcrumbMock(...args) +})) + +vi.mock('../observability', () => ({ + collectDiagnosticBundle: vi.fn(), + getDiagnosticsStatus: vi.fn() +})) + +vi.mock('../observability/diagnostic-upload-endpoint', () => ({ + resolveDiagnosticOrcaChannel: vi.fn() +})) + +vi.mock('../observability/tracer', () => ({ + startSpan: startSpanMock +})) + +import { registerCrashReportingHandlers } from './crash-reporting' + +function registerHandlersWithStubStore(): void { + registerCrashReportingHandlers({ + getLatestPending: vi.fn(), + getById: vi.fn(), + dismiss: vi.fn(), + markSent: vi.fn(), + listRecent: vi.fn(), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) +} + +function emitRendererBreadcrumb(args: unknown): void { + listeners.get('crashReports:recordBreadcrumb')?.(null, args) +} + +describe('renderer breadcrumb IPC routing', () => { + beforeEach(() => { + listeners.clear() + recordCoalescedCrashBreadcrumbMock.mockReset() + recordCoalescedCrashBreadcrumbMock.mockReturnValue({ suppressedSinceLast: 0 }) + recordCrashBreadcrumbMock.mockReset() + startSpanMock.mockClear() + spanEndMock.mockClear() + registerHandlersWithStubStore() + }) + + it('sanitizes and coalesces renderer error breadcrumbs', () => { + emitRendererBreadcrumb({ + name: 'renderer_error', + data: { + message: 'boom', + count: 2, + ok: true, + empty: null, + badNumber: Number.POSITIVE_INFINITY, + object: { ignored: true } + } + }) + + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith({ + name: 'renderer_error', + data: { message: 'boom', count: 2, ok: true, empty: null }, + coalesceKey: expect.stringContaining('boom'), + minIntervalMs: 30_000 + }) + expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled() + expect(startSpanMock).toHaveBeenCalledWith('renderer.breadcrumb', { + attributes: { + kind: 'crash-breadcrumb', + 'breadcrumb.name': 'renderer_error', + 'breadcrumb.data': { message: 'boom', count: 2, ok: true, empty: null } + } + }) + expect(spanEndMock).toHaveBeenCalledTimes(1) + }) + + it('coalesces renderer rejection breadcrumbs by reason message', () => { + emitRendererBreadcrumb({ + name: 'renderer_unhandled_rejection', + data: { reasonType: 'string', reasonMessage: 'Remote connection dropped/reconnecting' } + }) + + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith({ + name: 'renderer_unhandled_rejection', + data: { reasonType: 'string', reasonMessage: 'Remote connection dropped/reconnecting' }, + coalesceKey: expect.stringContaining('Remote connection dropped/reconnecting'), + minIntervalMs: 30_000 + }) + expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled() + }) + + it('keeps the full sanitized message in the coalesce key', () => { + const message = `${'same-prefix-'.repeat(12)}distinct-tail` + + emitRendererBreadcrumb({ name: 'renderer_error', data: { message } }) + + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith({ + name: 'renderer_error', + data: { message }, + coalesceKey: expect.stringContaining(message), + minIntervalMs: 30_000 + }) + }) + + it('does not coalesce same-message errors from different source sites', () => { + emitRendererBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom', filename: 'file:///first.js', lineno: 10, colno: 2 } + }) + emitRendererBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom', filename: 'file:///second.js', lineno: 20, colno: 4 } + }) + + const [first, second] = recordCoalescedCrashBreadcrumbMock.mock.calls.map( + ([args]) => args.coalesceKey + ) + expect(first).not.toBe(second) + }) + + it('does not coalesce same-message rejections from different stacks', () => { + emitRendererBreadcrumb({ + name: 'renderer_unhandled_rejection', + data: { reasonMessage: 'boom', reasonStack: 'Error: boom\n at first' } + }) + emitRendererBreadcrumb({ + name: 'renderer_unhandled_rejection', + data: { reasonMessage: 'boom', reasonStack: 'Error: boom\n at second' } + }) + + const [first, second] = recordCoalescedCrashBreadcrumbMock.mock.calls.map( + ([args]) => args.coalesceKey + ) + expect(first).not.toBe(second) + }) + + it('records message-less errors without coalescing unrelated failures', () => { + emitRendererBreadcrumb({ + name: 'renderer_unhandled_rejection', + data: { reasonType: 'Object' } + }) + + expect(recordCrashBreadcrumbMock).toHaveBeenCalledWith('renderer_unhandled_rejection', { + reasonType: 'Object' + }) + expect(recordCoalescedCrashBreadcrumbMock).not.toHaveBeenCalled() + expect(startSpanMock).toHaveBeenCalledTimes(1) + }) + + it('uses the Error object message when an error event has no message', () => { + emitRendererBreadcrumb({ + name: 'renderer_error', + data: { message: '', errorMessage: 'fallback failure' } + }) + + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith({ + name: 'renderer_error', + data: { message: '', errorMessage: 'fallback failure' }, + coalesceKey: expect.stringContaining('fallback failure'), + minIntervalMs: 30_000 + }) + }) + + it('does not emit durable trace spans for errors suppressed by coalescing', () => { + recordCoalescedCrashBreadcrumbMock + .mockReturnValueOnce({ suppressedSinceLast: 0 }) + .mockReturnValue(undefined) + + for (let index = 0; index < 1_000; index += 1) { + emitRendererBreadcrumb({ name: 'renderer_error', data: { message: 'storm' } }) + } + + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledTimes(1_000) + expect(startSpanMock).toHaveBeenCalledTimes(1) + expect(spanEndMock).toHaveBeenCalledTimes(1) + }) + + it('includes the suppressed count when durable tracing resumes', () => { + recordCoalescedCrashBreadcrumbMock.mockReturnValueOnce({ suppressedSinceLast: 999 }) + + emitRendererBreadcrumb({ name: 'renderer_error', data: { message: 'storm' } }) + + expect(startSpanMock).toHaveBeenCalledWith('renderer.breadcrumb', { + attributes: { + kind: 'crash-breadcrumb', + 'breadcrumb.name': 'renderer_error', + 'breadcrumb.data': { message: 'storm', suppressedSinceLast: 999 } + } + }) + }) + + it('records non-error renderer breadcrumbs without coalescing', () => { + emitRendererBreadcrumb({ name: 'renderer_bootstrap_started', data: { dev: true } }) + + expect(recordCrashBreadcrumbMock).toHaveBeenCalledWith('renderer_bootstrap_started', { + dev: true + }) + expect(recordCoalescedCrashBreadcrumbMock).not.toHaveBeenCalled() + }) + + it('ignores renderer breadcrumbs without a string name', () => { + emitRendererBreadcrumb({ name: 123, data: { message: 'boom' } }) + + expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled() + expect(recordCoalescedCrashBreadcrumbMock).not.toHaveBeenCalled() + expect(startSpanMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts index 2c28cfd6c77..86867659302 100644 --- a/src/main/ipc/crash-reporting.test.ts +++ b/src/main/ipc/crash-reporting.test.ts @@ -57,6 +57,8 @@ vi.mock('./feedback', () => ({ vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({ getCrashBreadcrumbSnapshot: vi.fn(() => []), + // Renderer breadcrumb routing is covered in crash-reporting-renderer-breadcrumbs.test.ts. + recordCoalescedCrashBreadcrumb: vi.fn(), recordCrashBreadcrumb: (...args: unknown[]) => recordCrashBreadcrumbMock(...args) })) @@ -782,68 +784,4 @@ describe('registerCrashReportingHandlers', () => { expect(recordMock).toHaveBeenCalledTimes(261) }) - - it('records sanitized renderer breadcrumbs', () => { - registerCrashReportingHandlers({ - getLatestPending: vi.fn(), - getById: vi.fn(), - dismiss: vi.fn(), - markSent: vi.fn(), - listRecent: vi.fn(), - record: vi.fn(), - formatDiagnosticText: vi.fn() - } as never) - - listeners.get('crashReports:recordBreadcrumb')?.(null, { - name: 'renderer_error', - data: { - message: 'boom', - count: 2, - ok: true, - empty: null, - badNumber: Number.POSITIVE_INFINITY, - object: { ignored: true } - } - }) - - expect(recordCrashBreadcrumbMock).toHaveBeenCalledWith('renderer_error', { - message: 'boom', - count: 2, - ok: true, - empty: null - }) - expect(startSpanMock).toHaveBeenCalledWith('renderer.breadcrumb', { - attributes: { - kind: 'crash-breadcrumb', - 'breadcrumb.name': 'renderer_error', - 'breadcrumb.data': { - message: 'boom', - count: 2, - ok: true, - empty: null - } - } - }) - expect(spanEndMock).toHaveBeenCalledTimes(1) - }) - - it('ignores renderer breadcrumbs without a string name', () => { - registerCrashReportingHandlers({ - getLatestPending: vi.fn(), - getById: vi.fn(), - dismiss: vi.fn(), - markSent: vi.fn(), - listRecent: vi.fn(), - record: vi.fn(), - formatDiagnosticText: vi.fn() - } as never) - - listeners.get('crashReports:recordBreadcrumb')?.(null, { - name: 123, - data: { message: 'boom' } - }) - - expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled() - expect(startSpanMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index ac424d834a8..89b88537eb9 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -19,6 +19,7 @@ import { submitFeedback } from './feedback' import type { CrashReportStore } from '../crash-reporting/crash-report-store' import { getCrashBreadcrumbSnapshot, + recordCoalescedCrashBreadcrumb, recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { startSpan } from '../observability/tracer' @@ -319,6 +320,52 @@ function buildUncapturedCrashReportText( ) } +// Why: a repeating renderer error (e.g. a ResizeObserver or SSH-rejection +// storm, #8260) can flush the whole fixed-size breadcrumb ring in seconds, +// erasing the pre-crash trail. Coalesce repeats into one entry that carries a +// suppressed count instead. +const COALESCED_RENDERER_ERROR_BREADCRUMB_NAMES = new Set([ + 'renderer_error', + 'renderer_unhandled_rejection' +]) +const RENDERER_ERROR_BREADCRUMB_COALESCE_MS = 30_000 + +function rendererErrorBreadcrumbCoalesceKey( + name: string, + data: CrashReportBreadcrumbData | undefined +): string | undefined { + const primaryMessage = name === 'renderer_error' ? data?.message : data?.reasonMessage + const fallbackMessage = name === 'renderer_error' ? data?.errorMessage : undefined + const message = + typeof primaryMessage === 'string' && primaryMessage.length > 0 + ? primaryMessage + : typeof fallbackMessage === 'string' && fallbackMessage.length > 0 + ? fallbackMessage + : undefined + // Why: message-less failures have no stable identity, so grouping them could + // erase unrelated crash evidence. Sanitization already caps messages at 240 chars. + if (!message) { + return undefined + } + + // Why: common messages such as "Script error" or "Cannot read properties" + // can come from unrelated sites. Include sanitized source evidence so one + // failure cannot suppress the breadcrumb for another. + const sourceIdentity = + name === 'renderer_error' + ? [ + data?.errorStack, + data?.filename, + data?.lineno, + data?.colno, + data?.errorType, + data?.errorName, + data?.errorMessage + ] + : [data?.reasonStack, data?.reasonType, data?.reasonName] + return JSON.stringify([name, message, ...sourceIdentity]) +} + export function registerCrashReportingHandlers(store: CrashReportStore): void { ipcMain.removeHandler('crashReports:getLatestPending') ipcMain.handle('crashReports:getLatestPending', () => getLatestPendingReport(store)) @@ -346,8 +393,33 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { return } const data = sanitizeRendererBreadcrumbData(args.data) - recordCrashBreadcrumb(args.name, data) - recordRendererBreadcrumbTrace(args.name, data) + if (COALESCED_RENDERER_ERROR_BREADCRUMB_NAMES.has(args.name)) { + const coalesceKey = rendererErrorBreadcrumbCoalesceKey(args.name, data) + if (!coalesceKey) { + recordCrashBreadcrumb(args.name, data) + recordRendererBreadcrumbTrace(args.name, data) + return + } + const coalesceResult = recordCoalescedCrashBreadcrumb({ + name: args.name, + data, + coalesceKey, + minIntervalMs: RENDERER_ERROR_BREADCRUMB_COALESCE_MS + }) + // Why: tracing every suppressed duplicate would preserve the same + // serialization and disk churn that breadcrumb coalescing removes. + if (coalesceResult) { + recordRendererBreadcrumbTrace( + args.name, + coalesceResult.suppressedSinceLast > 0 + ? { ...data, suppressedSinceLast: coalesceResult.suppressedSinceLast } + : data + ) + } + } else { + recordCrashBreadcrumb(args.name, data) + recordRendererBreadcrumbTrace(args.name, data) + } } ) diff --git a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts index 95315f6b00d..0cf672fa30f 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts @@ -126,6 +126,18 @@ describe('filesystem-list-files real git fallback', () => { ]) }) + it('bounds a non-git readdir fallback without treating the limit as an error', async () => { + checkRgAvailableMock.mockResolvedValue(false) + tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bounded-non-git-')) + await writeRel(tempDir, 'a.ts') + await writeRel(tempDir, 'b.ts') + + const files = await listQuickOpenFiles(tempDir, makeStore(tempDir), undefined, undefined, 1) + + expect(files).toHaveLength(1) + expect(['a.ts', 'b.ts']).toContain(files[0]) + }) + it('rejects abnormal git ls-files failures instead of resolving an empty list', async () => { checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bad-index-')) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts index 616a9a10c93..ff390d13caf 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -1,10 +1,15 @@ import type { ChildProcess } from 'node:child_process' import { gitSpawn } from '../git/runner' -import { buildGitLsFilesArgsForQuickOpen } from '../../shared/quick-open-filter' +import { + buildGitLsFilesArgsForQuickOpen, + shouldExcludeQuickOpenRelPath, + shouldIncludeQuickOpenPath +} from '../../shared/quick-open-filter' import { createQuickOpenReaddirBudget, expandQuickOpenGitFileListing, - listQuickOpenFilesWithReaddir + listQuickOpenFilesWithReaddir, + parseQuickOpenGitLsFilesEntry } from '../../shared/quick-open-readdir-walk' import { fileListingCancellationError } from '../../shared/file-listing-cancellation' @@ -73,7 +78,8 @@ export async function listFilesWithGit( rootPath: string, excludePathPrefixes: readonly string[], localGitOptions: { wslDistro?: string }, - signal?: AbortSignal + signal?: AbortSignal, + maxResults?: number ): Promise { const isGitWorkTree = await isInsideGitWorkTree(rootPath, localGitOptions, signal) if (signal?.aborted) { @@ -83,17 +89,20 @@ export async function listFilesWithGit( return listQuickOpenFilesWithReaddir(rootPath, { excludePathPrefixes, budget: createQuickOpenReaddirBudget(), + maxResults, signal }) } const gitPaths = new Set() const directoryPaths = new Set() + const directFileCandidates = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ChildProcess isDone: () => boolean reject: (error: Error) => void + resolve: () => void }[] = [] const runGitLsFiles = (args: string[]): Promise => { @@ -101,15 +110,32 @@ export async function listFilesWithGit( let buf = '' let done = false - const processPath = (path: string): void => { + const processPath = (path: string): boolean => { if (!path) { - return + return false } if (path.endsWith('/')) { directoryPaths.add(path) } else { gitPaths.add(path) + if (maxResults !== undefined) { + // Why: this duplicate classification exists only to stop bounded + // scans; unbounded scans must not retain a second repo-sized set. + const parsed = parseQuickOpenGitLsFilesEntry(path) + const relPath = parsed.path.replace(/\/+$/, '') + if ( + !parsed.isGitlink && + !parsed.isUntrackedDir && + shouldIncludeQuickOpenPath(relPath) && + !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) + ) { + directFileCandidates.add(relPath) + } + } } + // Why: collapsed directories and gitlinks may be discarded during the + // later filesystem classification, so they cannot consume the stop cap. + return maxResults !== undefined && directFileCandidates.size >= maxResults } // Why: git ls-files outputs paths relative to cwd, so we set cwd to @@ -149,14 +175,19 @@ export async function listFilesWithGit( children.push({ child, isDone: () => done, - reject: rejectPass + reject: rejectPass, + resolve: resolvePass }) const handleStdoutData = (chunk: string): void => { buf += chunk let start = 0 let nulIdx = buf.indexOf('\0', start) while (nulIdx !== -1) { - processPath(buf.substring(start, nulIdx)) + if (processPath(buf.substring(start, nulIdx))) { + buf = '' + finishAtLimit() + return + } start = nulIdx + 1 nulIdx = buf.indexOf('\0', start) } @@ -176,8 +207,10 @@ export async function listFilesWithGit( rejectPass(new Error(`git ls-files killed by ${signal}`)) return } - if (buf) { - processPath(buf) + if (buf && processPath(buf)) { + buf = '' + finishAtLimit() + return } if (code === 0) { resolvePass() @@ -213,22 +246,39 @@ export async function listFilesWithGit( } } + function finishAtLimit(): void { + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.resolve() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + } + } + const onAbort = (): void => killSurvivors('git ls-files cancelled') signal?.addEventListener('abort', onAbort, { once: true }) try { - await Promise.all([ - runGitLsFiles(primary), + const runIgnoredPass = () => // Why: ignored files are supplementary — a failed or timed-out ignored - // pass must not discard the primary listing the user actually needs - // (#7719 root cause: the all-or-nothing failure showed zero files). - // Entries streamed before the failure are kept; a cancelled scan still - // rejects via the primary pass or the expansion's cancellation check. + // pass must not discard the primary listing the user actually needs. runGitLsFiles(ignoredPass).catch((err: Error) => { if (!signal?.aborted) { console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err) } }) - ]) + if (maxResults === undefined) { + await Promise.all([runGitLsFiles(primary), runIgnoredPass()]) + } else { + // Why: give ordinary source files first claim on a bounded autocomplete + // inventory; a large ignored tree must not win a parallel-output race. + await runGitLsFiles(primary) + if (directFileCandidates.size < maxResults) { + await runIgnoredPass() + } + } } catch (err) { killSurvivors() if (signal?.aborted) { @@ -244,9 +294,10 @@ export async function listFilesWithGit( gitPaths, directoryPaths, excludePathPrefixes, - signal + signal, + maxResults }) // Why: directory placeholders are expanded after Git exits; restore Git's // path order so empty queries and fuzzy-score ties remain stable. - return files.sort() + return files.sort().slice(0, maxResults) } diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index ba535a25868..946f8653dd6 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -71,6 +71,30 @@ describe('filesystem-list-files', () => { getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) }) + it('stops after the primary rg pass fills the result budget', async () => { + const p1 = createMockProcess() + const p2 = createMockProcess() + spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + undefined, + 2 + ) + + setTimeout(() => { + ;(p1.stdout as unknown as EventEmitter).emit('data', 'one.ts\ntwo.ts') + p1.emit('close', 0, null) + }, 0) + const result = await promise + + expect(result).toEqual(['one.ts', 'two.ts']) + expect(p1.kill).toHaveBeenCalled() + expect(p2.kill).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) + }) + it('merges normal files and ignored files and filters correctly', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -377,6 +401,73 @@ describe('filesystem-list-files', () => { expect(result).not.toContain('node_modules/dep/index.js') }) + it('stops after primary Git files fill the result budget', async () => { + checkRgAvailableMock.mockResolvedValue(false) + const revParseProc = createMockProcess() + const gitP1 = createMockProcess() + const gitP2 = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args.includes('rev-parse')) { + return revParseProc + } + if (cmd === 'git' && args.includes('ls-files')) { + callIndex += 1 + return callIndex === 1 ? gitP1 : gitP2 + } + return createMockProcess() + }) + + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + undefined, + 2 + ) + setTimeout(() => revParseProc.emit('close', 0, null), 0) + setTimeout(() => { + ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'one.ts\0two.ts') + gitP1.emit('close', 0, null) + }, 10) + + await expect(promise).resolves.toEqual(['one.ts', 'two.ts']) + expect(gitP1.kill).toHaveBeenCalled() + expect(gitP2.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + + it('does not let a discarded Git directory placeholder consume the result budget', async () => { + checkRgAvailableMock.mockResolvedValue(false) + const revParseProc = createMockProcess() + const primary = createMockProcess() + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args.includes('rev-parse')) { + return revParseProc + } + return primary + }) + + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + undefined, + 1 + ) + setTimeout(() => revParseProc.emit('close', 0, null), 0) + setTimeout(() => { + ;(primary.stdout as unknown as EventEmitter).emit( + 'data', + `discarded/\0${staged('100644', 'src/kept.ts')}\0` + ) + }, 10) + + await expect(promise).resolves.toEqual(['src/kept.ts']) + expect(primary.kill).toHaveBeenCalled() + }) + it('git fallback applies hidden dir blocklist', async () => { checkRgAvailableMock.mockResolvedValue(false) diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 9dcd2c45038..58b1c0c0cad 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -20,7 +20,8 @@ export async function listQuickOpenFiles( rootPath: string, store: Store, excludePaths?: string[], - signal?: AbortSignal + signal?: AbortSignal, + maxResults?: number ): Promise { const authorizedRootPath = await resolveAuthorizedPath(rootPath, store) const localGitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -41,11 +42,21 @@ export async function listQuickOpenFiles( // can run. const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) if (!rgAvailable) { - return listFilesWithGit(authorizedRootPath, excludePathPrefixes, localGitOptions, signal) + return listFilesWithGit( + authorizedRootPath, + excludePathPrefixes, + localGitOptions, + signal, + maxResults + ) } const files = new Set() - const children: ChildProcess[] = [] + const children: { + child: ChildProcess + isDone: () => boolean + finish: () => void + }[] = [] // Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry // their distro in the path; Windows-path repos carry it in project runtime. const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro @@ -67,7 +78,7 @@ export async function listQuickOpenFiles( let done = false let parseablePathCount = 0 - const processLine = (rawLine: string): void => { + const processLine = (rawLine: string): boolean => { const translated = wslDistroForOutput && rawLine.startsWith('/') ? toWindowsWslPath(rawLine, wslDistroForOutput) @@ -77,16 +88,20 @@ export async function listQuickOpenFiles( getQuickOpenRgOutputMode(rawLine, translated, authorizedRootPath) ) if (relPath === null) { - return + return false } parseablePathCount++ if (!shouldIncludeQuickOpenPath(relPath)) { - return + return false } if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { - return + return false + } + if (maxResults !== undefined && files.size >= maxResults) { + return true } files.add(relPath) + return maxResults !== undefined && files.size >= maxResults } const child = wslAwareSpawn('rg', args, { @@ -94,14 +109,17 @@ export async function listQuickOpenFiles( ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), stdio: ['ignore', 'pipe', 'pipe'] }) - children.push(child) let timer: ReturnType const handleStdoutData = (chunk: string): void => { buf += chunk let start = 0 let newlineIdx = buf.indexOf('\n', start) while (newlineIdx !== -1) { - processLine(buf.substring(start, newlineIdx)) + if (processLine(buf.substring(start, newlineIdx))) { + buf = '' + finishAtLimit() + return + } start = newlineIdx + 1 newlineIdx = buf.indexOf('\n', start) } @@ -125,8 +143,10 @@ export async function listQuickOpenFiles( finish(new Error(`rg killed by ${signal}`)) return } - if (buf) { - processLine(buf) + if (buf && processLine(buf)) { + buf = '' + finishAtLimit() + return } if (code === 0 || code === 1) { finish() @@ -157,6 +177,8 @@ export async function listQuickOpenFiles( } } + children.push({ child, isDone: () => done, finish }) + child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) @@ -176,20 +198,45 @@ export async function listQuickOpenFiles( // Why: if one rg pass fails, Promise.all rejects immediately while the // sibling scan can keep walking a huge tree until timeout. Stop it so // repeated Quick Open attempts do not accumulate local rg processes. - for (const child of children) { - if (child.exitCode === null && child.signalCode === null) { - child.kill() + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.finish() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + } + } + + function finishAtLimit(): void { + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.finish() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() } } } try { - await Promise.all([runRg(primary), runRg(ignoredPass)]) + if (maxResults === undefined) { + await Promise.all([runRg(primary), runRg(ignoredPass)]) + } else { + // Why: ignored-file output can be much larger and faster than the primary + // pass; let source files claim the bounded autocomplete budget first. + await runRg(primary) + if (files.size < maxResults) { + await runRg(ignoredPass) + } + } } catch (err) { killSurvivors() throw err } - return Array.from(files) + return Array.from(files).slice(0, maxResults) } function getQuickOpenRgOutputMode( diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts index a566bf178c6..9a5c5831492 100644 --- a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts +++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ParcelWatcherProcess from './parcel-watcher-process' const { handleMock } = vi.hoisted(() => ({ handleMock: vi.fn() @@ -22,6 +23,14 @@ vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) +vi.mock('./parcel-watcher-process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + subscribeViaWatcherProcess: vi.fn(actual.subscribeViaWatcherProcess) + } +}) + vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn() })) @@ -29,10 +38,13 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { closeAllWatchers, closeLocalWatcherForWorktreePath, - registerFilesystemWatcherHandlers + registerFilesystemWatcherHandlers, + restoreLocalWatcherAfterFailedRemoval } from './filesystem-watcher' import { stat } from 'node:fs/promises' import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' +import { subscribeViaWatcherProcess } from './parcel-watcher-process' +import { WatcherProcessFailure } from './parcel-watcher-process-failure' type HandlerMap = Record Promise | unknown> @@ -43,6 +55,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { handleMock.mockReset() vi.mocked(stat).mockReset() vi.mocked(subscribeParcelWatcher).mockReset() + vi.mocked(subscribeViaWatcherProcess).mockClear() for (const key of Object.keys(handlers)) { delete handlers[key] } @@ -273,6 +286,214 @@ describe('local filesystem watcher unsubscribe cleanup', () => { expect(unsubscribeMock).toHaveBeenCalledTimes(1) }) + it.each([ + ['C:\\Repo', 'c:/repo'], + ['\\\\Server\\Share\\Repo', '//server/share/repo'] + ])('matches Windows watcher cleanup across path casing for %s', async (watchPath, closePath) => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + const unsubscribeMock = vi.fn() + vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + id: 1 + } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath: watchPath }) + await closeLocalWatcherForWorktreePath(closePath) + + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['C:\\Repo', 'c:/repo'], + ['\\\\Server\\Share\\Repo', '//server/share/repo'] + ])('keeps the physical Windows root in event payloads for %s', async (watchPath, closePath) => { + vi.useFakeTimers() + try { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let watcherCallback: ( + error: Error | null, + events: { type: 'update'; path: string }[] + ) => void = () => undefined + const unsubscribeMock = vi.fn() + vi.mocked(subscribeParcelWatcher).mockImplementation(async (_path, callback) => { + watcherCallback = callback as typeof watcherCallback + return { unsubscribe: unsubscribeMock } as never + }) + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + id: 1 + } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath: watchPath }) + watcherCallback(null, [{ type: 'update', path: `${watchPath}\\file.txt` }]) + await vi.advanceTimersByTimeAsync(150) + + expect(sender.send).toHaveBeenCalledWith( + 'fs:changed', + expect.objectContaining({ worktreePath: watchPath }) + ) + await closeLocalWatcherForWorktreePath(closePath) + } finally { + vi.useRealTimers() + } + }) + + it('propagates watcher termination failure to worktree deletion', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + const terminationError = new Error('watcher child did not exit') + const unsubscribeMock = vi.fn().mockRejectedValue(terminationError) + vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + + await expect(closeLocalWatcherForWorktreePath('/tmp/repo')).rejects.toBe(terminationError) + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + }) + + it('awaits and propagates sender cleanup already tearing down the same root', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + const terminationError = new Error('watcher child did not exit') + let rejectUnsubscribe: (error: Error) => void = () => {} + const unsubscribeMock = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectUnsubscribe = reject + }) + ) + vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) + const destroyedCallbacks: (() => void)[] = [] + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }), + id: 1 + } + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + destroyedCallbacks[0]() + await vi.waitFor(() => expect(unsubscribeMock).toHaveBeenCalledTimes(1)) + + let settled = false + const closeFailure = expect( + closeLocalWatcherForWorktreePath('/tmp/repo').finally(() => { + settled = true + }) + ).rejects.toBe(terminationError) + await Promise.resolve() + expect(settled).toBe(false) + rejectUnsubscribe(terminationError) + await closeFailure + }) + + it('retains teardown failure only until the unkillable child physically exits', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let signalPhysicalExit: () => void = () => {} + const physicalExit = new Promise((resolve) => { + signalPhysicalExit = resolve + }) + const terminationError = new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable', + physicalExit + ) + const unsubscribeMock = vi.fn().mockRejectedValue(terminationError) + vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) + const destroyedCallbacks: (() => void)[] = [] + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }), + id: 1 + } + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + destroyedCallbacks[0]() + await vi.waitFor(() => expect(unsubscribeMock).toHaveBeenCalledTimes(1)) + + await expect(closeLocalWatcherForWorktreePath('/tmp/repo')).rejects.toBe(terminationError) + signalPhysicalExit() + await Promise.resolve() + await expect(closeLocalWatcherForWorktreePath('/tmp/repo')).resolves.toBeUndefined() + }) + + it('retains callback terminal failure when the cleared subscription later unsubscribes cleanly', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let watcherCallback: (error: Error | null, events: []) => void = () => {} + let signalPhysicalExit: () => void = () => {} + const physicalExit = new Promise((resolve) => { + signalPhysicalExit = resolve + }) + const terminationError = new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable', + physicalExit + ) + const unsubscribe = vi.fn().mockResolvedValue(undefined) + vi.mocked(subscribeViaWatcherProcess).mockImplementationOnce(async (_dir, callback) => { + watcherCallback = callback as typeof watcherCallback + return { unsubscribe } + }) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + + watcherCallback(terminationError, []) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1)) + + await expect(closeLocalWatcherForWorktreePath('/tmp/repo')).rejects.toBe(terminationError) + signalPhysicalExit() + await physicalExit + await expect(closeLocalWatcherForWorktreePath('/tmp/repo')).resolves.toBeUndefined() + }) + + it('propagates terminal child failure while deletion cancels an active crawl', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let signalPhysicalExit: () => void = () => undefined + const physicalExit = new Promise((resolve) => { + signalPhysicalExit = resolve + }) + const terminationError = new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable', + physicalExit + ) + vi.mocked(subscribeViaWatcherProcess).mockImplementationOnce( + (_dir, _callback, _opts, hooks) => + new Promise((_resolve, reject) => { + hooks?.signal?.addEventListener('abort', () => reject(terminationError), { once: true }) + }) + ) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const watchPromise = handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/tmp/repo' } + ) as Promise + const watchFailure = expect(watchPromise).rejects.toBe(terminationError) + await vi.waitFor(() => expect(subscribeViaWatcherProcess).toHaveBeenCalledTimes(1)) + + await expect(closeLocalWatcherForWorktreePath('/tmp/repo')).rejects.toBe(terminationError) + await watchFailure + await expect(closeLocalWatcherForWorktreePath('/tmp/repo')).rejects.toBe(terminationError) + + signalPhysicalExit() + await physicalExit + await expect(closeLocalWatcherForWorktreePath('/tmp/repo')).resolves.toBeUndefined() + }) + it('closes a pending grace-teardown watcher immediately for worktree deletion', async () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) const unsubscribeMock = vi.fn() @@ -342,6 +563,107 @@ describe('local filesystem watcher unsubscribe cleanup', () => { expect(unsubscribeMock).toHaveBeenCalledTimes(1) }) + it('retries on a fresh generation when a listener arrives after install abort', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let replacementCallback: Parameters[1] = () => undefined + const replacementUnsubscribe = vi.fn() + vi.mocked(subscribeViaWatcherProcess) + .mockImplementationOnce( + (_dir, _callback, _options, hooks) => + new Promise((_resolve, reject) => { + hooks?.signal?.addEventListener('abort', () => reject(new Error('subscribe aborted')), { + once: true + }) + }) + ) + .mockImplementationOnce(async (_dir, callback) => { + replacementCallback = callback + return { unsubscribe: replacementUnsubscribe } + }) + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const replacementSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + + const firstWatch = handlers['fs:watchWorktree']( + { sender: firstSender }, + { worktreePath: '/tmp/repo' } + ) as Promise + await vi.waitFor(() => expect(subscribeViaWatcherProcess).toHaveBeenCalledTimes(1)) + + handlers['fs:unwatchWorktree']({ sender: firstSender }, { worktreePath: '/tmp/repo' }) + const replacementWatch = handlers['fs:watchWorktree']( + { sender: replacementSender }, + { worktreePath: '/tmp/repo' } + ) as Promise + + await Promise.all([firstWatch, replacementWatch]) + expect(subscribeViaWatcherProcess).toHaveBeenCalledTimes(2) + replacementCallback(null, [{ type: 'update', path: '/tmp/repo/retry.txt' }] as never) + await vi.waitFor(() => + expect(replacementSender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/tmp/repo', + events: [{ kind: 'update', absolutePath: '/tmp/repo/retry.txt', isDirectory: true }] + }) + ) + }) + + it('refuses an aborted pre-shutdown joiner after a different root reopens watching', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + const installs = new Map< + string, + { + signal: AbortSignal | undefined + resolve: (subscription: { unsubscribe: () => void }) => void + } + >() + const install = ( + rootPath: string, + _callback: unknown, + _options: unknown, + hooks?: { signal?: AbortSignal } + ) => + new Promise((resolve) => { + installs.set(rootPath, { signal: hooks?.signal, resolve }) + }) + vi.mocked(subscribeViaWatcherProcess) + .mockImplementationOnce(install as never) + .mockImplementationOnce(install as never) + const lateUnsubscribe = vi.fn() + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const joinerSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const reopenSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 3 } + const first = handlers['fs:watchWorktree']( + { sender: firstSender }, + { worktreePath: '/tmp/repo' } + ) as Promise + + await vi.waitFor(() => expect(subscribeViaWatcherProcess).toHaveBeenCalledTimes(1)) + handlers['fs:unwatchWorktree']({ sender: firstSender }, { worktreePath: '/tmp/repo' }) + expect(installs.get('/tmp/repo')?.signal?.aborted).toBe(true) + + const joiner = handlers['fs:watchWorktree']( + { sender: joinerSender }, + { worktreePath: '/tmp/repo' } + ) as Promise + await closeAllWatchers() + const reopen = handlers['fs:watchWorktree']( + { sender: reopenSender }, + { worktreePath: '/tmp/other' } + ) as Promise + await vi.waitFor(() => expect(subscribeViaWatcherProcess).toHaveBeenCalledTimes(2)) + + installs.get('/tmp/repo')?.resolve({ unsubscribe: lateUnsubscribe }) + installs.get('/tmp/other')?.resolve({ unsubscribe: vi.fn() }) + await Promise.all([first, joiner, reopen]) + + expect(subscribeViaWatcherProcess).toHaveBeenCalledTimes(2) + expect( + vi + .mocked(subscribeViaWatcherProcess) + .mock.calls.filter(([rootPath]) => rootPath.endsWith('/tmp/repo')) + ).toHaveLength(1) + await vi.waitFor(() => expect(lateUnsubscribe).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 = () => {} @@ -378,6 +700,74 @@ describe('local filesystem watcher unsubscribe cleanup', () => { expect(unsubscribeMock).toHaveBeenCalledTimes(1) }) + it('re-arms active local listeners after worktree deletion fails', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + const firstUnsubscribe = vi.fn() + const replacementUnsubscribe = vi.fn() + vi.mocked(subscribeParcelWatcher) + .mockResolvedValueOnce({ unsubscribe: firstUnsubscribe } as never) + .mockResolvedValueOnce({ unsubscribe: replacementUnsubscribe } as never) + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + id: 1 + } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + await closeLocalWatcherForWorktreePath('/tmp/repo') + await restoreLocalWatcherAfterFailedRemoval('/tmp/repo') + + expect(firstUnsubscribe).toHaveBeenCalledTimes(1) + expect(subscribeParcelWatcher).toHaveBeenCalledTimes(2) + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/tmp/repo', + events: [{ kind: 'overflow', absolutePath: '/tmp/repo' }] + }) + }) + + it('does not restore a local listener stopped while deletion is pending', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + const firstUnsubscribe = vi.fn() + vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: firstUnsubscribe } as never) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + await closeLocalWatcherForWorktreePath('/tmp/repo') + handlers['fs:unwatchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + await restoreLocalWatcherAfterFailedRemoval('/tmp/repo') + + expect(firstUnsubscribe).toHaveBeenCalledTimes(1) + expect(subscribeParcelWatcher).toHaveBeenCalledTimes(1) + expect(sender.send).not.toHaveBeenCalled() + }) + + it('does not restore a local listener destroyed while deletion is pending', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + const firstUnsubscribe = vi.fn() + vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: firstUnsubscribe } as never) + const destroyedCallbacks: (() => void)[] = [] + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }), + id: 1 + } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + await closeLocalWatcherForWorktreePath('/tmp/repo') + destroyedCallbacks[0]?.() + await restoreLocalWatcherAfterFailedRemoval('/tmp/repo') + + expect(firstUnsubscribe).toHaveBeenCalledTimes(1) + expect(subscribeParcelWatcher).toHaveBeenCalledTimes(1) + expect(sender.send).not.toHaveBeenCalled() + }) + it('cancels an opening local watcher during app shutdown', async () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) let resolveSubscribe: (subscription: { unsubscribe: () => void }) => void = () => {} diff --git a/src/main/ipc/filesystem-watcher-native-capacity.test.ts b/src/main/ipc/filesystem-watcher-native-capacity.test.ts new file mode 100644 index 00000000000..658793e2f5e --- /dev/null +++ b/src/main/ipc/filesystem-watcher-native-capacity.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, statMock, subscribeViaWatcherProcessMock, disposeWatcherProcessMock } = + vi.hoisted(() => ({ + handleMock: vi.fn(), + statMock: vi.fn(), + subscribeViaWatcherProcessMock: vi.fn(), + disposeWatcherProcessMock: vi.fn() + })) + +vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) +vi.mock('node:fs/promises', () => ({ stat: statMock })) +vi.mock('./parcel-watcher-process', () => ({ + subscribeViaWatcherProcess: subscribeViaWatcherProcessMock, + disposeWatcherProcess: disposeWatcherProcessMock +})) +vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: vi.fn() +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' +import { + MAX_PHYSICAL_WATCHER_CHILDREN, + reserveWatcherChild, + resetWatcherChildRegistryForTest, + WatcherChildCapacityError +} from './parcel-watcher-child-registry' + +type HandlerMap = Record Promise | unknown> + +function fillWatcherChildCapacity(): (() => void)[] { + return Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => { + const release = reserveWatcherChild() + if (!release) { + throw new Error('expected watcher child reservation') + } + return release + }) +} + +describe('native filesystem watcher capacity recovery', () => { + const handlers: HandlerMap = {} + + beforeEach(async () => { + handleMock.mockReset() + statMock.mockReset() + subscribeViaWatcherProcessMock.mockReset() + disposeWatcherProcessMock.mockReset() + resetWatcherChildRegistryForTest() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + statMock.mockResolvedValue({ isDirectory: () => true }) + registerFilesystemWatcherHandlers() + await closeAllWatchers() + }) + + afterEach(async () => { + await closeAllWatchers() + resetWatcherChildRegistryForTest() + }) + + it('automatically retries a native root when a child slot is released', async () => { + const releases = fillWatcherChildCapacity() + const unsubscribe = vi.fn().mockResolvedValue(undefined) + subscribeViaWatcherProcessMock + .mockRejectedValueOnce(new WatcherChildCapacityError()) + .mockResolvedValueOnce({ unsubscribe }) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const args = { worktreePath: '/tmp/native-capacity-root' } + + await handlers['fs:watchWorktree']({ sender }, args) + expect(subscribeViaWatcherProcessMock).toHaveBeenCalledOnce() + + releases.pop()?.() + await vi.waitFor(() => expect(subscribeViaWatcherProcessMock).toHaveBeenCalledTimes(2)) + + releases.forEach((release) => release()) + }) + + it('cancels the native capacity wait when its renderer unwatches', async () => { + const releases = fillWatcherChildCapacity() + subscribeViaWatcherProcessMock.mockRejectedValue(new WatcherChildCapacityError()) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const args = { worktreePath: '/tmp/native-capacity-root' } + + await handlers['fs:watchWorktree']({ sender }, args) + handlers['fs:unwatchWorktree']({ sender: { id: sender.id } }, args) + releases.pop()?.() + await Promise.resolve() + await Promise.resolve() + + expect(subscribeViaWatcherProcessMock).toHaveBeenCalledOnce() + releases.forEach((release) => release()) + }) +}) diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 4258c0e675e..4c8333d508e 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -11,6 +11,12 @@ vi.mock('child_process', () => ({ })) import { createWslWatcher } from './filesystem-watcher-wsl' +import { WSL_WATCHER_PHYSICAL_EXIT_TIMEOUT_MS } from './wsl-watcher-process-exit' +import { + MAX_PHYSICAL_WATCHER_CHILDREN, + reserveWatcherChild, + resetWatcherChildRegistryForTest +} from './parcel-watcher-child-registry' import type { WatchedRoot, WslWatcherDeps } from './filesystem-watcher-wsl' const SNAPSHOT_START = '\x1e' @@ -71,6 +77,7 @@ async function resolveInitialSnapshot( describe('createWslWatcher', () => { beforeEach(() => { spawnMock.mockReset() + resetWatcherChildRegistryForTest() }) afterEach(() => { @@ -93,6 +100,54 @@ describe('createWslWatcher', () => { ) }) + it('counts WSL watcher processes against the global physical child cap', async () => { + const releases = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => + reserveWatcherChild() + ) + + await expect(createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps())).rejects.toThrow( + 'Physical file watcher process limit reached' + ) + expect(spawnMock).not.toHaveBeenCalled() + releases.forEach((release) => release?.()) + }) + + it('releases the WSL physical child reservation only after close', async () => { + const siblingReleases = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN - 1 }, () => + reserveWatcherChild() + ) + const { child, promise } = startWatcher() + await resolveInitialSnapshot(child, promise) + + expect(reserveWatcherChild()).toBeNull() + child.emit('close', null, 'SIGTERM') + const afterClose = reserveWatcherChild() + expect(afterClose).not.toBeNull() + + afterClose?.() + siblingReleases.forEach((release) => release?.()) + }) + + it('releases the WSL child reservation after a synchronous script write failure', async () => { + const siblingReleases = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN - 1 }, () => + reserveWatcherChild() + ) + const child = new FakeChildProcess() + vi.spyOn(child.stdin, 'end').mockImplementation(() => { + throw new Error('script write failed') + }) + spawnMock.mockReturnValueOnce(child) + + await expect(createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps())).rejects.toThrow( + 'script write failed' + ) + const afterFailure = reserveWatcherChild() + expect(afterFailure).not.toBeNull() + + afterFailure?.() + siblingReleases.forEach((release) => release?.()) + }) + it('diffs WSL snapshots into create, update, and delete events', async () => { const scheduleBatchFlush = vi.fn() const { child, promise } = startWatcher(makeDeps(scheduleBatchFlush)) @@ -145,6 +200,69 @@ describe('createWslWatcher', () => { expect(scheduleBatchFlush).not.toHaveBeenCalled() }) + it('keeps unsubscribe pending until the WSL child physically closes', async () => { + const child = new FakeChildProcess() + child.kill = vi.fn(() => true) + spawnMock.mockReturnValueOnce(child) + const promise = createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps()) + const root = await resolveInitialSnapshot(child, promise) + + let settled = false + const unsubscribe = root.subscription.unsubscribe().finally(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + child.emit('close', null, 'SIGTERM') + await unsubscribe + expect(settled).toBe(true) + }) + + it('retains a timed-out WSL watcher owner until a later physical close', async () => { + vi.useFakeTimers() + try { + const child = new FakeChildProcess() + child.kill = vi.fn(() => true) + spawnMock.mockReturnValueOnce(child) + const promise = createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps()) + const root = await resolveInitialSnapshot(child, promise) + + const unsubscribe = root.subscription.unsubscribe() + const rejected = expect(unsubscribe).rejects.toMatchObject({ + code: 'process_unavailable', + physicalExit: expect.any(Promise) + }) + await vi.advanceTimersByTimeAsync(WSL_WATCHER_PHYSICAL_EXIT_TIMEOUT_MS) + await rejected + + const retry = root.subscription.unsubscribe() + child.emit('close', null, 'SIGTERM') + await expect(retry).resolves.toBeUndefined() + expect(child.kill).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('retains the WSL owner when the termination signal is rejected', async () => { + const child = new FakeChildProcess() + child.kill = vi.fn().mockReturnValueOnce(false).mockReturnValue(true) + spawnMock.mockReturnValueOnce(child) + const promise = createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps()) + const root = await resolveInitialSnapshot(child, promise) + + await expect(root.subscription.unsubscribe()).rejects.toMatchObject({ + code: 'process_unavailable', + physicalExit: expect.any(Promise) + }) + + const retry = root.subscription.unsubscribe() + child.emit('close', null, 'SIGTERM') + await expect(retry).resolves.toBeUndefined() + expect(child.kill).toHaveBeenCalledTimes(2) + }) + it('rejects when the WSL process exits before the first snapshot', async () => { const { child, promise } = startWatcher() @@ -185,6 +303,30 @@ describe('createWslWatcher', () => { expect(child.kill).toHaveBeenCalledOnce() }) + it('does not acknowledge startup abort before the WSL child closes', async () => { + const child = new FakeChildProcess() + child.kill = vi.fn(() => true) + spawnMock.mockReturnValueOnce(child) + const controller = new AbortController() + const promise = createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps(), controller.signal) + + controller.abort() + let settled = false + void promise.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await Promise.resolve() + expect(settled).toBe(false) + + child.emit('close', null, 'SIGTERM') + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + }) + it('rejects immediately when the install abort signal is already aborted', async () => { const controller = new AbortController() controller.abort() diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index 54e58415894..b73f58d5122 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -11,6 +11,8 @@ import type { WebContents } from 'electron' import type { Event as WatcherEvent } from '@parcel/watcher' import { queueWatcherEvents } from './filesystem-watcher-event-batch' import { parseWslUncPath } from '../../shared/wsl-paths' +import { createWslWatcherProcessExit, createWslWatcherStartup } from './wsl-watcher-process-exit' +import { reserveWatcherChild, WatcherChildCapacityError } from './parcel-watcher-child-registry' export type WatcherSubscription = { unsubscribe(): Promise @@ -27,6 +29,7 @@ export type WatchedRoot = { subscription: WatcherSubscription listeners: Map batch: DebouncedBatch + rootPath?: string } export type WslWatcherDeps = { @@ -35,11 +38,9 @@ export type WslWatcherDeps = { watchedRoots: Map } -const POLL_INTERVAL_MS = 2000 -const POLL_INTERVAL_SECONDS = Math.max(1, Math.ceil(POLL_INTERVAL_MS / 1000)) +const POLL_INTERVAL_SECONDS = 2 const STARTUP_TIMEOUT_MS = 10_000 -const SNAPSHOT_START = '\x1e' -const SNAPSHOT_END = '\x1f' +const [SNAPSHOT_START, SNAPSHOT_END] = ['\x1e', '\x1f'] const MAX_STREAM_BUFFER_CHARS = 10 * 1024 * 1024 type WslSnapshotEntry = { @@ -153,8 +154,7 @@ export async function createWslWatcher( 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. + // Why: honor local-install cancellation before spawning a WSL snapshot process. if (signal?.aborted) { throw new DOMException('WSL watcher subscription aborted', 'AbortError') } @@ -169,7 +169,8 @@ export async function createWslWatcher( const root: WatchedRoot = { subscription: null!, listeners: new Map(), - batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 } + batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 }, + rootPath: worktreePath } let disposed = false @@ -180,25 +181,7 @@ export async function createWslWatcher( const stderrDecoder = new StringDecoder('utf8') let stderrTail = '' - let resolveInitial!: () => void - let rejectInitial!: (error: Error) => void - let initialSettled = false - const initialSnapshotReady = new Promise((resolve, reject) => { - resolveInitial = resolve - rejectInitial = reject - }) - - function settleInitial(error?: Error): void { - if (initialSettled) { - return - } - initialSettled = true - if (error) { - rejectInitial(error) - } else { - resolveInitial() - } - } + const startup = createWslWatcherStartup() function signalWatcherStopped(): void { if (stopped) { @@ -217,7 +200,7 @@ export async function createWslWatcher( const nextSnapshot = parseSnapshotFrame(frame, distro) if (!prevSnapshot) { prevSnapshot = nextSnapshot - settleInitial() + startup.settle() return } const events = diffSnapshots(prevSnapshot, nextSnapshot) @@ -255,31 +238,40 @@ export async function createWslWatcher( } let child: ChildProcessWithoutNullStreams + const releaseChildReservation = reserveWatcherChild() + if (!releaseChildReservation) { + throw new WatcherChildCapacityError() + } try { child = spawn('wsl.exe', ['-d', distro, '--', 'sh', '-s', '--', linuxPath], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) } catch (error) { + releaseChildReservation() throw error instanceof Error ? error : new Error(String(error)) } + // Why: the physical-exit owner releases the shared process reservation on + // exactly the same close/error proof that settles destructive cleanup. + const processExit = createWslWatcherProcessExit(child, worktreePath, releaseChildReservation) + const onAbort = (): void => { - settleInitial(new DOMException('WSL watcher subscription aborted', 'AbortError')) - child.kill() + startup.settle(new DOMException('WSL watcher subscription aborted', 'AbortError')) + processExit.requestStopBestEffort() } signal?.addEventListener('abort', onAbort, { once: true }) const startupTimer = setTimeout(() => { - settleInitial(new Error(`Timed out starting WSL watcher for ${worktreePath}`)) - child.kill() + startup.settle(new Error(`Timed out starting WSL watcher for ${worktreePath}`)) + processExit.requestStopBestEffort() }, STARTUP_TIMEOUT_MS) child.stdin.on('error', (error) => { // Why: WSL can exit before reading the script; handle EPIPE here so the // startup failure rejects the watcher instead of crashing on a stream error. - if (!initialSettled) { - settleInitial(error) + if (!startup.settled) { + startup.settle(error) } }) @@ -296,8 +288,8 @@ export async function createWslWatcher( }) child.stdout.on('error', (error) => { - if (!initialSettled) { - settleInitial(error) + if (!startup.settled) { + startup.settle(error) return } if (!disposed) { @@ -310,8 +302,13 @@ export async function createWslWatcher( }) child.once('error', (error) => { - if (!initialSettled) { - settleInitial(error) + // Why: a child that never acquired a pid has no OS owner to await. Kill + // errors for an already-spawned child still require the close event. + if (child.pid === undefined) { + processExit.markPhysicalExit() + } + if (!startup.settled) { + startup.settle(error) return } if (!disposed) { @@ -320,9 +317,10 @@ export async function createWslWatcher( }) child.once('close', (code, signal) => { - if (!initialSettled) { + processExit.markPhysicalExit() + if (!startup.settled) { const suffix = stderrTail.trim() ? `: ${stderrTail.trim()}` : '' - settleInitial( + startup.settle( new Error(`WSL watcher exited before first snapshot (${code ?? signal})${suffix}`) ) return @@ -332,10 +330,16 @@ export async function createWslWatcher( } }) - child.stdin.end(buildSnapshotScript(deps.ignoreDirs)) - try { - await initialSnapshotReady + child.stdin.end(buildSnapshotScript(deps.ignoreDirs)) + await startup.ready + } catch (error) { + // Why: cancellation is part of destructive removal. Do not let the failed + // install disappear from ownership until WSL confirms the child exited. + startup.settle(error instanceof Error ? error : new Error(String(error))) + await startup.ready.catch(() => undefined) + await processExit.stopAndWait() + throw error } finally { clearTimeout(startupTimer) signal?.removeEventListener('abort', onAbort) @@ -344,7 +348,7 @@ export async function createWslWatcher( root.subscription = { unsubscribe: async () => { disposed = true - child.kill() + await processExit.stopAndWait() } } diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index f776e0d494d..8d658b25f12 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -27,9 +27,22 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: getSshFilesystemProviderMock })) -import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' +import { + closeAllWatchers, + closeRemoteWatcherForWorktreePath, + registerFilesystemWatcherHandlers, + restoreRemoteWatcherAfterFailedRemoval +} from './filesystem-watcher' import { stat } from 'node:fs/promises' import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' +import { createWslWatcher } from './filesystem-watcher-wsl' +import { + MAX_PHYSICAL_WATCHER_CHILDREN, + reserveWatcherChild, + resetWatcherChildRegistryForTest, + WatcherChildCapacityError +} from './parcel-watcher-child-registry' +import { acquireWatcherRemovalGate } from './watcher-removal-gate' type HandlerMap = Record Promise | unknown> @@ -43,6 +56,8 @@ describe('registerFilesystemWatcherHandlers', () => { getSshFilesystemProviderMock.mockReset() vi.mocked(stat).mockReset() vi.mocked(subscribeParcelWatcher).mockReset() + vi.mocked(createWslWatcher).mockReset() + resetWatcherChildRegistryForTest() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -79,6 +94,80 @@ describe('registerFilesystemWatcherHandlers', () => { await closeAllWatchers() }) + it('automatically retries a WSL watcher when child capacity becomes available', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + const heldReservations = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => + reserveWatcherChild() + ) + vi.mocked(createWslWatcher).mockImplementation(async () => { + const release = reserveWatcherChild() + if (!release) { + throw new WatcherChildCapacityError() + } + return { + subscription: { unsubscribe: vi.fn(async () => release()) }, + listeners: new Map(), + batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 } + } + }) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const args = { worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' } + + await expect(handlers['fs:watchWorktree']({ sender }, args)).resolves.toBeUndefined() + expect(createWslWatcher).toHaveBeenCalledOnce() + + heldReservations.pop()?.() + await vi.waitFor(() => expect(createWslWatcher).toHaveBeenCalledTimes(2)) + await closeAllWatchers() + heldReservations.forEach((release) => release?.()) + }) + + it('cancels a pending WSL capacity retry when the renderer unwatches', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + const heldReservations = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => + reserveWatcherChild() + ) + vi.mocked(createWslWatcher).mockRejectedValue(new WatcherChildCapacityError()) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const args = { worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' } + + await handlers['fs:watchWorktree']({ sender }, args) + handlers['fs:unwatchWorktree']({ sender: { id: sender.id } }, args) + heldReservations.pop()?.() + await Promise.resolve() + await Promise.resolve() + + expect(createWslWatcher).toHaveBeenCalledOnce() + heldReservations.forEach((release) => release?.()) + }) + + it('rejects installs during destructive removal and allows a retry afterward', async () => { + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: vi.fn() } as never) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const removal = acquireWatcherRemovalGate('/repo') + await removal.ready + + await expect( + handlers['fs:watchWorktree']({ sender }, { worktreePath: '/repo' }) + ).rejects.toMatchObject({ code: 'watcher_removal_in_progress' }) + expect(subscribeParcelWatcher).not.toHaveBeenCalled() + + removal.release() + await expect( + handlers['fs:watchWorktree']({ sender }, { worktreePath: '/repo' }) + ).resolves.toBeUndefined() + expect(subscribeParcelWatcher).toHaveBeenCalledTimes(1) + }) + it('quietly skips SSH worktree watches while the filesystem provider is unavailable', async () => { vi.useFakeTimers() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -125,7 +214,8 @@ describe('registerFilesystemWatcherHandlers', () => { await vi.advanceTimersByTimeAsync(1_000) expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), { - signal: expect.any(AbortSignal) + signal: expect.any(AbortSignal), + onTerminalError: expect.any(Function) }) const onEvents = watchMock.mock.calls[0][1] onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) @@ -164,7 +254,8 @@ describe('registerFilesystemWatcherHandlers', () => { await vi.advanceTimersByTimeAsync(1_000) expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), { - signal: expect.any(AbortSignal) + signal: expect.any(AbortSignal), + onTerminalError: expect.any(Function) }) handlers['fs:unwatchWorktree']( { sender: { id: 1 } }, @@ -195,6 +286,47 @@ describe('registerFilesystemWatcherHandlers', () => { vi.useRealTimers() }) + it('retries all live renderer owners after a terminal relay watch failure', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const watchMock = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + await handlers['fs:watchWorktree']( + { sender: senderOne }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + await handlers['fs:watchWorktree']( + { sender: senderTwo }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + const staleTerminal = watchMock.mock.calls[0][2].onTerminalError as (error: Error) => void + + staleTerminal(new Error('relay watcher recovery failed')) + handlers['fs:unwatchWorktree']( + { sender: senderOne }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + await vi.advanceTimersByTimeAsync(1_000) + + expect(watchMock).toHaveBeenCalledTimes(2) + const recoveredEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void + recoveredEvents([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) + expect(senderOne.send).not.toHaveBeenCalled() + expect(senderTwo.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/home/me/repo', + events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }] + }) + + staleTerminal(new Error('late stale failure')) + await vi.advanceTimersByTimeAsync(1_000) + expect(watchMock).toHaveBeenCalledTimes(2) + warnSpy.mockRestore() + vi.useRealTimers() + }) + it('shares SSH worktree watchers across renderer senders until the last unwatch', async () => { const sendOne = vi.fn() const sendTwo = vi.fn() @@ -232,6 +364,148 @@ describe('registerFilesystemWatcherHandlers', () => { expect(unwatchMock).toHaveBeenCalledTimes(1) }) + it('awaits acknowledged SSH watcher teardown before remote deletion', async () => { + let resolveClose: () => void = () => {} + const closeWatch = vi.fn( + () => + new Promise((resolve) => { + resolveClose = resolve + }) + ) + const provider = { watch: vi.fn().mockResolvedValue(vi.fn()), closeWatch } + getSshFilesystemProviderMock.mockReturnValue(provider) + await handlers['fs:watchWorktree']( + { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + let closed = false + const close = closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo').then(() => { + closed = true + }) + await Promise.resolve() + expect(closeWatch).toHaveBeenCalledWith('/home/me/repo') + expect(closed).toBe(false) + + resolveClose() + await close + }) + + it('re-arms SSH listeners after remote worktree deletion fails', async () => { + const firstUnwatch = vi.fn() + const replacementUnwatch = vi.fn() + const watchMock = vi + .fn() + .mockResolvedValueOnce(firstUnwatch) + .mockResolvedValueOnce(replacementUnwatch) + const closeWatch = vi.fn().mockResolvedValue(undefined) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch }) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + + await handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + await closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo') + await restoreRemoteWatcherAfterFailedRemoval('conn-1', '/home/me/repo') + + expect(closeWatch).toHaveBeenCalledWith('/home/me/repo') + expect(watchMock).toHaveBeenCalledTimes(2) + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/home/me/repo', + events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] + }) + const replacementEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void + replacementEvents([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) + expect(sender.send).toHaveBeenLastCalledWith('fs:changed', { + worktreePath: '/home/me/repo', + events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }] + }) + }) + + it('does not restore an SSH listener stopped while deletion is pending', async () => { + const firstUnwatch = vi.fn() + const watchMock = vi.fn().mockResolvedValue(firstUnwatch) + const closeWatch = vi.fn().mockResolvedValue(undefined) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch }) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + + await handlers['fs:watchWorktree']({ sender }, args) + await closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo') + handlers['fs:unwatchWorktree']({ sender }, args) + await restoreRemoteWatcherAfterFailedRemoval('conn-1', '/home/me/repo') + + expect(closeWatch).toHaveBeenCalledWith('/home/me/repo') + expect(watchMock).toHaveBeenCalledTimes(1) + expect(sender.send).not.toHaveBeenCalled() + }) + + it('does not restore an SSH listener destroyed while deletion is pending', async () => { + const watchMock = vi.fn().mockResolvedValue(vi.fn()) + const closeWatch = vi.fn().mockResolvedValue(undefined) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch }) + const destroyedCallbacks: (() => void)[] = [] + const sender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }), + id: 1 + } + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + + await handlers['fs:watchWorktree']({ sender }, args) + await closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo') + destroyedCallbacks[0]?.() + await restoreRemoteWatcherAfterFailedRemoval('conn-1', '/home/me/repo') + + expect(closeWatch).toHaveBeenCalledWith('/home/me/repo') + expect(watchMock).toHaveBeenCalledTimes(1) + expect(sender.send).not.toHaveBeenCalled() + }) + + it('preserves remote event routing when acknowledged teardown rejects', async () => { + const sendOne = vi.fn() + const sendTwo = vi.fn() + const senderOne = { isDestroyed: () => false, send: sendOne, once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: sendTwo, once: vi.fn(), id: 2 } + const watchMock = vi.fn().mockResolvedValue(vi.fn()) + const closeWatch = vi + .fn() + .mockRejectedValueOnce(new Error('Remote path is still watched by another client')) + .mockResolvedValueOnce(undefined) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch }) + await handlers['fs:watchWorktree']( + { sender: senderOne }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + await expect(closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo')).rejects.toThrow( + 'still watched by another client' + ) + const onEvents = watchMock.mock.calls[0][1] + onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + expect(sendOne).toHaveBeenCalledTimes(1) + + await handlers['fs:watchWorktree']( + { sender: senderTwo }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + expect(watchMock).toHaveBeenCalledTimes(1) + onEvents([{ path: '/home/me/repo/file-2.txt', type: 'update' }]) + expect(sendOne).toHaveBeenCalledTimes(2) + expect(sendTwo).toHaveBeenCalledTimes(1) + + await expect( + closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo') + ).resolves.toBeUndefined() + expect(closeWatch).toHaveBeenCalledTimes(2) + }) + it('dedupes concurrent pending SSH worktree watcher installs', async () => { const sendOne = vi.fn() const sendTwo = vi.fn() diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 665b543779f..6e5876e9add 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -8,12 +8,22 @@ import * as path from 'node:path' import { stat } from 'node:fs/promises' import type { Event as WatcherEvent } from '@parcel/watcher' import type { FsChangeEvent, FsChangedPayload } from '../../shared/types' +import { + isWindowsAbsolutePathLike, + normalizeRuntimePathForComparison +} from '../../shared/cross-platform-path' import { isWslPath } from '../wsl' import { createWslWatcher } from './filesystem-watcher-wsl' import type { WatchedRoot } from './filesystem-watcher-wsl' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch' import { disposeWatcherProcess, subscribeViaWatcherProcess } from './parcel-watcher-process' +import { isWatcherProcessFailure } from './parcel-watcher-process-failure' +import { + onWatcherChildCapacityAvailable, + WatcherChildCapacityError +} from './parcel-watcher-child-registry' +import { beginWatcherInstall, isWatcherRemovalInProgressError } from './watcher-removal-gate' // Why: high-churn directories are suppressed at the native watcher level so // events never leave the OS/daemon. This list is separate from the File // Explorer display filter (which only hides rows). Directories like `dist` @@ -69,22 +79,37 @@ const pendingTeardowns = new Map>() // Why: @parcel/watcher unsubscribe completes native async work. Sender-destroy // cleanup can start it before app shutdown, so will-quit must still await it. const pendingLocalUnsubscribes = new Set>() +const pendingLocalUnsubscribesByRoot = new Map>>() +const suspendedLocalWatcherListeners = new Map< + string, + { worktreePath: string; listeners: Map } +>() +// Why: an install cancelled by shutdown cannot be revived by a waiter that +// resumes after a later handler call reopens the watcher subsystem. +let localWatchersClosed = false +let localWatcherLifecycleGeneration = 0 +const failedLocalUnsubscribes = new Map() type LocalWatcherInstallToken = { cancelled: boolean listeners: Map abortController: AbortController } -type LocalWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled' +type LocalWatcherInstallResult = 'installed' | 'unavailable' | 'capacity' | 'cancelled' +type LocalWatcherCapacityRetry = { + listeners: Map + cancelWait: () => void +} // Why: native watcher creation is async. Concurrent local watch requests for // the same root must share one install or later resolves can orphan listeners. const inFlightLocalInstalls = new Map() const pendingLocalInstallPromises = new Map>() +const pendingLocalCapacityRetries = new Map() function addInFlightLocalInstallListener( token: LocalWatcherInstallToken, sender: WebContents ): void { - if (sender.isDestroyed()) { + if (sender.isDestroyed() || token.abortController.signal.aborted) { return } token.listeners.set(sender.id, sender) @@ -102,12 +127,84 @@ function cleanupInFlightLocalInstallsForSender(senderId: number): void { token.abortController.abort() } } + for (const [rootKey, retry] of pendingLocalCapacityRetries) { + retry.listeners.delete(senderId) + if (retry.listeners.size === 0) { + retry.cancelWait() + pendingLocalCapacityRetries.delete(rootKey) + } + } +} + +function takeLocalCapacityRetryListeners(rootKey: string): WebContents[] { + const retry = pendingLocalCapacityRetries.get(rootKey) + if (!retry) { + return [] + } + retry.cancelWait() + pendingLocalCapacityRetries.delete(rootKey) + return [...retry.listeners.values()].filter((listener) => !listener.isDestroyed()) +} + +function clearLocalCapacityRetry(rootKey: string): void { + const retry = pendingLocalCapacityRetries.get(rootKey) + retry?.cancelWait() + pendingLocalCapacityRetries.delete(rootKey) +} + +function scheduleLocalCapacityRetry( + rootKey: string, + worktreePath: string, + listeners: Map +): void { + const existing = pendingLocalCapacityRetries.get(rootKey) + if (existing) { + for (const listener of listeners.values()) { + if (!listener.isDestroyed()) { + existing.listeners.set(listener.id, listener) + } + } + return + } + + let retry!: LocalWatcherCapacityRetry + const cancelWait = onWatcherChildCapacityAvailable(async () => { + if (pendingLocalCapacityRetries.get(rootKey) !== retry) { + return + } + pendingLocalCapacityRetries.delete(rootKey) + await Promise.all( + [...retry.listeners.values()].map(async (listener) => { + if (listener.isDestroyed()) { + return + } + await subscribe(worktreePath, listener).catch((error: unknown) => { + if (!isWatcherRemovalInProgressError(error)) { + console.error(`[filesystem-watcher] capacity retry failed for ${rootKey}:`, error) + } + }) + }) + ) + }) + retry = { listeners: new Map(), cancelWait } + pendingLocalCapacityRetries.set(rootKey, retry) + for (const listener of listeners.values()) { + if (!listener.isDestroyed()) { + retry.listeners.set(listener.id, listener) + registerSenderCleanup(listener) + } + } + if (retry.listeners.size === 0) { + clearLocalCapacityRetry(rootKey) + } } // ── Path normalization ─────────────────────────────────────────────── function normalizeRootPath(rootPath: string): string { - let resolved = path.resolve(rootPath) + let resolved = isWindowsAbsolutePathLike(rootPath) + ? path.win32.resolve(rootPath) + : path.resolve(rootPath) // Why: on Windows, watcher events may report lowercase drive letters while // stored worktree paths use uppercase. Normalizing here ensures the renderer's // POSIX normalization produces casing-consistent results (see design §4.4). @@ -117,6 +214,16 @@ function normalizeRootPath(rootPath: string): string { return resolved } +function localWatcherRoot(rootPath: string): { key: string; path: string } { + const normalizedPath = normalizeRootPath(rootPath) + return { + // Why: Windows drive and UNC paths are case-insensitive; destructive + // cleanup must find the owner even when Git returns a different spelling. + key: normalizeRuntimePathForComparison(normalizedPath), + path: normalizedPath + } +} + function normalizeEventPath(eventPath: string): string { let resolved = path.resolve(eventPath) if (/^[a-zA-Z]:/.test(resolved)) { @@ -198,9 +305,10 @@ async function tryStatIsDirectory(filePath: string): Promise { ) const payload: FsChangedPayload = { - worktreePath: rootKey, + worktreePath: root.rootPath ?? rootKey, events } @@ -290,7 +398,8 @@ async function createWatcher( const root: WatchedRoot = { subscription: null!, listeners: new Map(), - batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 } + batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 }, + rootPath } try { @@ -338,6 +447,7 @@ async function createWatcher( // is deleted or inaccessible at startup). Guard against null so // the cleanup path doesn't crash the main process. if (root.subscription) { + retainLocalWatcherPhysicalFailure(rootKey, err) void trackLocalUnsubscribe(rootKey, root) } errorCleanedUp = true @@ -380,6 +490,12 @@ async function createWatcher( // ── Subscribe / Unsubscribe ────────────────────────────────────────── function cleanupLocalWatchersForSender(senderId: number): void { + for (const [rootKey, suspended] of suspendedLocalWatcherListeners) { + suspended.listeners.delete(senderId) + if (suspended.listeners.size === 0) { + suspendedLocalWatcherListeners.delete(rootKey) + } + } cleanupInFlightLocalInstallsForSender(senderId) for (const [key, watchedRoot] of watchedRoots) { if (watchedRoot.listeners.has(senderId)) { @@ -402,18 +518,40 @@ function cleanupLocalWatchersForSender(senderId: number): void { } function trackLocalUnsubscribe(rootKey: string, root: WatchedRoot): Promise { + const rootUnsubscribes = pendingLocalUnsubscribesByRoot.get(rootKey) ?? new Set>() + pendingLocalUnsubscribesByRoot.set(rootKey, rootUnsubscribes) const unsubscribePromise = Promise.resolve() .then(() => root.subscription.unsubscribe()) - .catch((err: unknown) => { - console.error(`[filesystem-watcher] unsubscribe error for ${rootKey}:`, err) - }) .finally(() => { pendingLocalUnsubscribes.delete(unsubscribePromise) + rootUnsubscribes.delete(unsubscribePromise) + if (rootUnsubscribes.size === 0) { + pendingLocalUnsubscribesByRoot.delete(rootKey) + } }) pendingLocalUnsubscribes.add(unsubscribePromise) + rootUnsubscribes.add(unsubscribePromise) + // Why: background cleanup must not create unhandled rejections, but the + // original promise stays rejected so later destructive cleanup can fail closed. + void unsubscribePromise.catch((error: unknown) => { + retainLocalWatcherPhysicalFailure(rootKey, error) + console.error(`[filesystem-watcher] unsubscribe error for ${rootKey}:`, error) + }) return unsubscribePromise } +function retainLocalWatcherPhysicalFailure(rootKey: string, error: unknown): void { + if (!isWatcherProcessFailure(error) || !error.physicalExit) { + return + } + failedLocalUnsubscribes.set(rootKey, error) + void error.physicalExit.then(() => { + if (failedLocalUnsubscribes.get(rootKey) === error) { + failedLocalUnsubscribes.delete(rootKey) + } + }) +} + function registerSenderCleanup(sender: WebContents): void { if (senderCleanupRegistered.has(sender.id)) { return @@ -435,8 +573,31 @@ function addLocalWatchListener(rootKey: string, sender: WebContents): void { registerSenderCleanup(sender) } -async function subscribe(worktreePath: string, sender: WebContents): Promise { - const rootKey = normalizeRootPath(worktreePath) +async function subscribe( + worktreePath: string, + sender: WebContents, + generation = localWatcherLifecycleGeneration +): Promise { + if (localWatchersClosed || generation !== localWatcherLifecycleGeneration) { + return + } + const finishInstall = beginWatcherInstall(worktreePath) + try { + await subscribeWhileRemovalAllowed(worktreePath, sender, generation) + } finally { + finishInstall() + } +} + +async function subscribeWhileRemovalAllowed( + worktreePath: string, + sender: WebContents, + generation: number +): Promise { + if (localWatchersClosed || generation !== localWatcherLifecycleGeneration) { + return + } + const { key: rootKey, path: rootPath } = localWatcherRoot(worktreePath) if (sender.isDestroyed()) { return } @@ -455,8 +616,12 @@ async function subscribe(worktreePath: string, sender: WebContents): Promise [listener.id, listener]) + ) + retryListeners.set(sender.id, sender) + for (const listener of retryListeners.values()) { + if (!listener.isDestroyed()) { + await subscribeWhileRemovalAllowed(worktreePath, listener, generation) + } + } + return + } + if (!inFlight) { + if (result === 'installed') { + for (const listener of capacityRetryListeners) { + addLocalWatchListener(rootKey, listener) + } + } else if (result === 'capacity') { + const retryListeners = new Map( + capacityRetryListeners.map((listener) => [listener.id, listener]) + ) + retryListeners.set(sender.id, sender) + scheduleLocalCapacityRetry(rootKey, worktreePath, retryListeners) + } + } if ( result === 'installed' && watchedRoots.has(rootKey) && @@ -487,8 +691,11 @@ async function subscribe(worktreePath: string, sender: WebContents): Promise { let root: WatchedRoot try { - const s = await stat(rootKey) + const s = await stat(rootPath) if (!s.isDirectory()) { console.warn(`[filesystem-watcher] not a directory: ${rootKey}`) rememberUnwatchableRoot(rootKey) @@ -532,13 +740,23 @@ async function doInstallLocalWatcher( }, cancelToken.abortController.signal ) - : await createWatcher(rootKey, rootKey, cancelToken.abortController.signal) - } catch { - // Why: createWatcher / createWslWatcher already logged the error. Swallow - // it here so the renderer's watchWorktree call resolves without crashing. + : await createWatcher(rootKey, rootPath, cancelToken.abortController.signal) + } catch (error) { + // Why: setup may fail after its child misses the exit deadline; retain that + // owner even when the ordinary renderer-facing setup error is swallowed. + retainLocalWatcherPhysicalFailure(rootKey, error) if (cancelToken.cancelled) { + if (isWatcherProcessFailure(error) && error.code === 'process_unavailable') { + throw error + } return 'cancelled' } + // Why: capacity is transient; once another physical child exits this root + // must be allowed to retry instead of entering the permanent-failure cache. + if (error instanceof WatcherChildCapacityError) { + scheduleLocalCapacityRetry(rootKey, worktreePath, cancelToken.listeners) + return 'capacity' + } rememberUnwatchableRoot(rootKey) return 'unavailable' } finally { @@ -567,7 +785,19 @@ async function doInstallLocalWatcher( } function unsubscribe(worktreePath: string, senderId: number): void { - const rootKey = normalizeRootPath(worktreePath) + const { key: rootKey } = localWatcherRoot(worktreePath) + const suspended = suspendedLocalWatcherListeners.get(rootKey) + suspended?.listeners.delete(senderId) + if (suspended?.listeners.size === 0) { + suspendedLocalWatcherListeners.delete(rootKey) + } + const capacityRetry = pendingLocalCapacityRetries.get(rootKey) + if (capacityRetry) { + capacityRetry.listeners.delete(senderId) + if (capacityRetry.listeners.size === 0) { + clearLocalCapacityRetry(rootKey) + } + } const inFlight = inFlightLocalInstalls.get(rootKey) if (inFlight) { inFlight.listeners.delete(senderId) @@ -615,7 +845,26 @@ function unsubscribe(worktreePath: string, senderId: number): void { } export async function closeLocalWatcherForWorktreePath(worktreePath: string): Promise { - const rootKey = normalizeRootPath(worktreePath) + const { key: rootKey } = localWatcherRoot(worktreePath) + const suspended = suspendedLocalWatcherListeners.get(rootKey) ?? { + worktreePath, + listeners: new Map() + } + for (const source of [ + pendingLocalCapacityRetries.get(rootKey)?.listeners, + inFlightLocalInstalls.get(rootKey)?.listeners, + watchedRoots.get(rootKey)?.listeners + ]) { + for (const [senderId, sender] of source ?? []) { + if (!sender.isDestroyed()) { + suspended.listeners.set(senderId, sender) + } + } + } + if (suspended.listeners.size > 0) { + suspendedLocalWatcherListeners.set(rootKey, suspended) + } + clearLocalCapacityRetry(rootKey) const pendingTeardown = pendingTeardowns.get(rootKey) if (pendingTeardown) { clearTimeout(pendingTeardown) @@ -630,7 +879,14 @@ export async function closeLocalWatcherForWorktreePath(worktreePath: string): Pr inFlight.cancelled = true inFlight.abortController.abort() } - await pendingLocalInstallPromises.get(rootKey)?.catch(() => undefined) + await pendingLocalInstallPromises.get(rootKey) + const pendingUnsubscribes = pendingLocalUnsubscribesByRoot.get(rootKey) + if (pendingUnsubscribes) { + await Promise.all(Array.from(pendingUnsubscribes)) + } + if (failedLocalUnsubscribes.has(rootKey)) { + throw failedLocalUnsubscribes.get(rootKey) + } const root = watchedRoots.get(rootKey) if (!root) { @@ -643,10 +899,48 @@ export async function closeLocalWatcherForWorktreePath(worktreePath: string): Pr await trackLocalUnsubscribe(rootKey, root) } +export async function restoreLocalWatcherAfterFailedRemoval(worktreePath: string): Promise { + const { key: rootKey } = localWatcherRoot(worktreePath) + const suspended = suspendedLocalWatcherListeners.get(rootKey) + if (!suspended) { + return + } + suspendedLocalWatcherListeners.delete(rootKey) + const failures: unknown[] = [] + const failedListeners = new Map() + for (const sender of suspended.listeners.values()) { + if (sender.isDestroyed()) { + continue + } + try { + await subscribe(suspended.worktreePath, sender) + sender.send('fs:changed', { + worktreePath: suspended.worktreePath, + events: [{ kind: 'overflow', absolutePath: suspended.worktreePath }] + } satisfies FsChangedPayload) + } catch (error) { + failures.push(error) + failedListeners.set(sender.id, sender) + } + } + if (failures.length > 0) { + suspendedLocalWatcherListeners.set(rootKey, { + worktreePath: suspended.worktreePath, + listeners: failedListeners + }) + throw failures[0] + } +} + +export function forgetLocalWatcherRemovalSnapshot(worktreePath: string): void { + suspendedLocalWatcherListeners.delete(localWatcherRoot(worktreePath).key) +} + // Remote watcher state type RemoteWatcherState = { unwatch: () => void listeners: Map + installToken: RemoteWatcherInstallToken } type RemoteWatcherInstallToken = { @@ -654,14 +948,23 @@ type RemoteWatcherInstallToken = { listeners: Map abortController: AbortController abortScheduled: boolean + terminalError?: Error } // Key: `${connectionId}:${worktreePath}`, Value: shared remote watch state. const remoteWatchers = new Map() +const suspendedRemoteWatcherListeners = new Map< + string, + { connectionId: string; worktreePath: string; listeners: Map } +>() const loggedUnavailableRemoteWatchers = new Set() const pendingRemoteWatcherRetries = new Map>() -// Why: track in-flight `provider.watch()` calls so last-listener cleanup can -// abort relay setup, while late success is still unwatched instead of leaked. +const pendingRemoteWatcherRetryListeners = new Map< + string, + { listeners: Map; startedAt: number } +>() +// Why: last-listener cleanup aborts relay setup, while late success is still +// unwatched instead of being installed after the renderer stopped watching. const inFlightRemoteInstalls = new Map() // Why: dedupe concurrent installRemoteWatcher calls for the same key so // overlapping fs:watchWorktree IPCs share one native watcher and one listener @@ -680,6 +983,82 @@ let remoteWatcherLifecycleGeneration = 0 const REMOTE_WATCH_RETRY_MS = 1_000 const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000 +export async function closeRemoteWatcherForWorktreePath( + connectionId: string, + worktreePath: string +): Promise { + const key = remoteWatcherKey(connectionId, worktreePath) + const suspended = suspendedRemoteWatcherListeners.get(key) ?? { + connectionId, + worktreePath, + listeners: new Map() + } + for (const source of [ + pendingRemoteWatcherRetryListeners.get(key)?.listeners, + inFlightRemoteInstalls.get(key)?.listeners, + remoteWatchers.get(key)?.listeners + ]) { + for (const [senderId, sender] of source ?? []) { + if (!sender.isDestroyed()) { + suspended.listeners.set(senderId, sender) + } + } + } + if (suspended.listeners.size > 0) { + suspendedRemoteWatcherListeners.set(key, suspended) + } + const retryTimer = pendingRemoteWatcherRetries.get(key) + if (retryTimer) { + clearTimeout(retryTimer) + pendingRemoteWatcherRetries.delete(key) + pendingRemoteWatcherRetryListeners.delete(key) + } + const inFlight = inFlightRemoteInstalls.get(key) + if (inFlight) { + inFlight.listeners.clear() + inFlight.cancelled = true + } + const state = remoteWatchers.get(key) + const provider = getSshFilesystemProvider(connectionId) + await (provider?.closeWatch + ? provider.closeWatch(worktreePath) + : Promise.resolve(state?.unwatch())) + remoteWatchers.delete(key) + loggedUnavailableRemoteWatchers.delete(key) +} + +export async function restoreRemoteWatcherAfterFailedRemoval( + connectionId: string, + worktreePath: string +): Promise { + const key = remoteWatcherKey(connectionId, worktreePath) + const suspended = suspendedRemoteWatcherListeners.get(key) + if (!suspended) { + return + } + suspendedRemoteWatcherListeners.delete(key) + for (const sender of suspended.listeners.values()) { + if (sender.isDestroyed()) { + continue + } + const result = await installRemoteWatcher(sender, connectionId, worktreePath) + if (result === 'unavailable') { + scheduleRemoteWatcherRetry(sender, connectionId, worktreePath) + } + sender.send('fs:changed', { + worktreePath, + events: [{ kind: 'overflow', absolutePath: worktreePath }] + } satisfies FsChangedPayload) + } +} + +export function forgetRemoteWatcherRemovalSnapshot( + connectionId: string, + worktreePath: string +): void { + suspendedRemoteWatcherListeners.delete(remoteWatcherKey(connectionId, worktreePath)) +} + function addInFlightRemoteInstallListener( token: RemoteWatcherInstallToken, sender: WebContents @@ -713,6 +1092,17 @@ function cleanupInFlightRemoteInstallsForSender(senderId: number): void { token.listeners.delete(senderId) cancelInFlightRemoteInstallIfUnowned(token) } + for (const [key, retry] of pendingRemoteWatcherRetryListeners) { + retry.listeners.delete(senderId) + if (retry.listeners.size === 0) { + const timer = pendingRemoteWatcherRetries.get(key) + if (timer) { + clearTimeout(timer) + pendingRemoteWatcherRetries.delete(key) + } + pendingRemoteWatcherRetryListeners.delete(key) + } + } } function addRemoteWatchListener(key: string, sender: WebContents): void { @@ -738,6 +1128,12 @@ function releaseRemoteWatchListener(key: string, senderId: number): void { } function cleanupRemoteWatchersForSender(senderId: number): void { + for (const [key, suspended] of suspendedRemoteWatcherListeners) { + suspended.listeners.delete(senderId) + if (suspended.listeners.size === 0) { + suspendedRemoteWatcherListeners.delete(key) + } + } cleanupInFlightRemoteInstallsForSender(senderId) for (const key of Array.from(remoteWatchers.keys())) { releaseRemoteWatchListener(key, senderId) @@ -759,12 +1155,31 @@ async function installRemoteWatcher( if (remoteWatchersClosed || generation !== remoteWatcherLifecycleGeneration) { return 'cancelled' } + const finishInstall = beginWatcherInstall(worktreePath, connectionId) + try { + return await installRemoteWatcherWhileRemovalAllowed( + sender, + connectionId, + worktreePath, + generation + ) + } finally { + finishInstall() + } +} + +async function installRemoteWatcherWhileRemovalAllowed( + sender: WebContents, + connectionId: string, + worktreePath: string, + generation: number +): Promise { const provider = getSshFilesystemProvider(connectionId) if (!provider || sender.isDestroyed()) { return 'unavailable' } - const key = `${connectionId}:${worktreePath}` + const key = remoteWatcherKey(connectionId, worktreePath) const existing = remoteWatchers.get(key) if (existing) { addRemoteWatchListener(key, sender) @@ -817,7 +1232,13 @@ async function installRemoteWatcher( } inFlightRemoteInstalls.set(key, cancelToken) addInFlightRemoteInstallListener(cancelToken, sender) - const installPromise = doInstallRemoteWatcher(provider, key, worktreePath, cancelToken) + const installPromise = doInstallRemoteWatcher( + provider, + key, + connectionId, + worktreePath, + cancelToken + ) pendingRemoteInstallPromises.set(key, installPromise) try { return await installPromise @@ -831,6 +1252,7 @@ async function installRemoteWatcher( async function doInstallRemoteWatcher( provider: NonNullable>, key: string, + connectionId: string, worktreePath: string, cancelToken: RemoteWatcherInstallToken ): Promise { @@ -853,7 +1275,11 @@ async function doInstallRemoteWatcher( } satisfies FsChangedPayload) } }, - { signal: cancelToken.abortController.signal } + { + signal: cancelToken.abortController.signal, + onTerminalError: (error) => + handleRemoteWatcherTerminalError(key, connectionId, worktreePath, cancelToken, error) + } ) } catch (err) { if (cancelToken.cancelled || cancelToken.abortController.signal.aborted) { @@ -877,7 +1303,10 @@ async function doInstallRemoteWatcher( } return 'cancelled' } - remoteWatchers.set(key, { unwatch, listeners: liveListeners }) + if (cancelToken.terminalError) { + return 'unavailable' + } + remoteWatchers.set(key, { unwatch, listeners: liveListeners, installToken: cancelToken }) for (const listener of liveListeners.values()) { registerSenderCleanup(listener) } @@ -885,29 +1314,62 @@ async function doInstallRemoteWatcher( return 'installed' } +function handleRemoteWatcherTerminalError( + key: string, + connectionId: string, + worktreePath: string, + installToken: RemoteWatcherInstallToken, + error: Error +): void { + installToken.terminalError = error + const state = remoteWatchers.get(key) + if (!state || state.installToken !== installToken) { + return + } + remoteWatchers.delete(key) + console.warn(`[filesystem-watcher] SSH watcher terminated for ${key}:`, error) + for (const listener of state.listeners.values()) { + scheduleRemoteWatcherRetry(listener, connectionId, worktreePath) + } +} + function scheduleRemoteWatcherRetry( sender: WebContents, connectionId: string, worktreePath: string, startedAt = Date.now() ): void { - const key = `${connectionId}:${worktreePath}` - if (pendingRemoteWatcherRetries.has(key)) { + const key = remoteWatcherKey(connectionId, worktreePath) + const existingRetry = pendingRemoteWatcherRetryListeners.get(key) + if (existingRetry) { + if (!sender.isDestroyed()) { + existingRetry.listeners.set(sender.id, sender) + } return } + const retry = { + listeners: new Map(sender.isDestroyed() ? [] : [[sender.id, sender]]), + startedAt + } + pendingRemoteWatcherRetryListeners.set(key, retry) + if (Date.now() - startedAt >= REMOTE_WATCH_RETRY_TIMEOUT_MS || sender.isDestroyed()) { pendingRemoteWatcherRetries.delete(key) + pendingRemoteWatcherRetryListeners.delete(key) loggedUnavailableRemoteWatchers.delete(key) // Why: the original `fs:watchWorktree` handler resolved successfully // when the retry was first scheduled, so the renderer believes the // watch is live. After giving up, emit a one-shot overflow so the // renderer falls back to a manual refresh instead of waiting forever. - if (!sender.isDestroyed()) { + for (const listener of retry.listeners.values()) { + if (listener.isDestroyed()) { + continue + } console.warn( `[filesystem-watcher] giving up SSH watch retry for ${worktreePath} on connection ${connectionId} after ${REMOTE_WATCH_RETRY_TIMEOUT_MS}ms` ) - sender.send('fs:changed', { + listener.send('fs:changed', { worktreePath, events: [{ kind: 'overflow', absolutePath: worktreePath }] } satisfies FsChangedPayload) @@ -917,18 +1379,31 @@ function scheduleRemoteWatcherRetry( const retryTimer = setTimeout(() => { pendingRemoteWatcherRetries.delete(key) - void installRemoteWatcher(sender, connectionId, worktreePath) - .then((result) => { + pendingRemoteWatcherRetryListeners.delete(key) + const listeners = Array.from(retry.listeners.values()).filter( + (listener) => !listener.isDestroyed() + ) + void Promise.all( + listeners.map((listener) => installRemoteWatcher(listener, connectionId, worktreePath)) + ) + .then((results) => { // Why: 'cancelled' means an unwatch/shutdown raced with this install // attempt. Re-arming the retry would reschedule for a worktree the // renderer explicitly stopped watching, eventually firing a stale // overflow when the 60s window expires. - if (result === 'unavailable') { - scheduleRemoteWatcherRetry(sender, connectionId, worktreePath, startedAt) + if (results.some((result) => result === 'unavailable')) { + for (const listener of listeners) { + scheduleRemoteWatcherRetry(listener, connectionId, worktreePath, retry.startedAt) + } } }) - .catch(() => { - scheduleRemoteWatcherRetry(sender, connectionId, worktreePath, startedAt) + .catch((error: unknown) => { + if (isWatcherRemovalInProgressError(error)) { + return + } + for (const listener of listeners) { + scheduleRemoteWatcherRetry(listener, connectionId, worktreePath, retry.startedAt) + } }) }, REMOTE_WATCH_RETRY_MS) pendingRemoteWatcherRetries.set(key, retryTimer) @@ -944,7 +1419,7 @@ export function registerFilesystemWatcherHandlers(): void { // Why: a real new watch reopens the subsystem after closeAllWatchers // latched it shut (also how tests reset between cases). remoteWatchersClosed = false - const key = `${args.connectionId}:${args.worktreePath}` + const key = remoteWatcherKey(args.connectionId, args.worktreePath) const result = await installRemoteWatcher( event.sender, args.connectionId, @@ -962,6 +1437,9 @@ export function registerFilesystemWatcherHandlers(): void { } return } + // Why: tests and post-shutdown renderer reattachment reopen the local + // subsystem, while stale callers retain the prior generation. + localWatchersClosed = false await subscribe(args.worktreePath, event.sender) } ) @@ -970,11 +1448,19 @@ export function registerFilesystemWatcherHandlers(): void { 'fs:unwatchWorktree', (_event, args: { worktreePath: string; connectionId?: string }): void => { if (args.connectionId) { - const key = `${args.connectionId}:${args.worktreePath}` + const key = remoteWatcherKey(args.connectionId, args.worktreePath) + const suspended = suspendedRemoteWatcherListeners.get(key) + suspended?.listeners.delete(_event.sender.id) + if (suspended?.listeners.size === 0) { + suspendedRemoteWatcherListeners.delete(key) + } + const retry = pendingRemoteWatcherRetryListeners.get(key) + retry?.listeners.delete(_event.sender.id) const retryTimer = pendingRemoteWatcherRetries.get(key) - if (retryTimer) { + if (retryTimer && retry?.listeners.size === 0) { clearTimeout(retryTimer) pendingRemoteWatcherRetries.delete(key) + pendingRemoteWatcherRetryListeners.delete(key) } // Why: a `provider.watch()` call may still be in flight from a // retry tick. Mark it cancelled so installRemoteWatcher discards @@ -995,10 +1481,20 @@ export function registerFilesystemWatcherHandlers(): void { ) } +function remoteWatcherKey(connectionId: string, worktreePath: string): string { + return JSON.stringify([connectionId, normalizeRuntimePathForComparison(worktreePath)]) +} + /** Tear down all watchers on app shutdown. */ export async function closeAllWatchers(): Promise { senderCleanupRegistered.clear() unwatchableRoots.clear() + suspendedLocalWatcherListeners.clear() + suspendedRemoteWatcherListeners.clear() + for (const retry of pendingLocalCapacityRetries.values()) { + retry.cancelWait() + } + pendingLocalCapacityRetries.clear() // Cancel any pending grace-period teardowns — we're tearing down everything. for (const timer of pendingTeardowns.values()) { @@ -1010,13 +1506,14 @@ export async function closeAllWatchers(): Promise { clearTimeout(timer) } pendingRemoteWatcherRetries.clear() + pendingRemoteWatcherRetryListeners.clear() loggedUnavailableRemoteWatchers.clear() - // Why: latch the subsystem shut and drop the dedup map so a late install that - // begins after teardown is refused instead of registering post-shutdown. Bump - // the generation so a waiter that resumes after a later reopen still recurses - // on a stale lifecycle and is refused. + // Why: latch both watcher subsystems shut so late installs cannot register + // post-shutdown. Generation bumps also reject waiters from an older lifecycle. remoteWatchersClosed = true remoteWatcherLifecycleGeneration += 1 + localWatchersClosed = true + localWatcherLifecycleGeneration += 1 pendingRemoteInstallPromises.clear() // Why: cancel any in-flight provider.watch() calls so their resolved // unwatch handles are discarded instead of being installed after shutdown. @@ -1035,10 +1532,11 @@ export async function closeAllWatchers(): Promise { if (root.batch.timer) { clearTimeout(root.batch.timer) } - await trackLocalUnsubscribe(rootKey, root) + await trackLocalUnsubscribe(rootKey, root).catch(() => undefined) } watchedRoots.clear() await Promise.allSettled(Array.from(pendingLocalUnsubscribes)) + failedLocalUnsubscribes.clear() // Why: with every local subscription released, drop the forked watcher // process outright — process death frees any remaining native handles // without running watcher.node's crash-prone async teardown in this process. diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 2e9d1843988..c1c319c55f1 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -1148,6 +1148,72 @@ describe('registerFilesystemHandlers', () => { }) }) + it('forwards line-stat reuse through local and SSH git status IPC', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const sshProvider = { + getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + } + getSshGitProviderMock.mockReturnValue(sshProvider) + registerFilesystemHandlers(store as never) + + await handlers.get('git:status')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + reuseLineStats: true + }) + await handlers.get('git:status')!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1', + reuseLineStats: true + }) + + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + includeIgnored: false, + reuseLineStats: true + }) + expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { + includeIgnored: false, + reuseLineStats: true + }) + }) + + it('aborts tokenized local status without crossing renderer boundaries', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + const statusSignals: AbortSignal[] = [] + getStatusMock.mockImplementation( + (_worktreePath: string, options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + if (options.signal) { + statusSignals.push(options.signal) + options.signal.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + } + }) + ) + registerFilesystemHandlers(store as never) + + const firstEvent = { sender: { id: 7 } } + const secondEvent = { sender: { id: 8 } } + const firstRequest = handlers.get('git:status')!(firstEvent, { + worktreePath: WORKTREE_FEATURE_PATH, + requestToken: 'status-1' + }) as Promise + const secondRequest = handlers.get('git:status')!(secondEvent, { + worktreePath: WORKTREE_FEATURE_PATH, + requestToken: 'status-1' + }) as Promise + await vi.waitFor(() => expect(statusSignals).toHaveLength(2)) + await handlers.get('git:cancelStatus')!(firstEvent, { requestToken: 'status-1' }) + + expect(statusSignals[0]?.aborted).toBe(true) + expect(statusSignals[1]?.aborted).toBe(false) + await expect(firstRequest).rejects.toThrow('aborted') + + await handlers.get('git:cancelStatus')!(secondEvent, { requestToken: 'status-1' }) + await expect(secondRequest).rejects.toThrow('aborted') + }) + it('checks ignored paths through local and SSH git providers', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js']) @@ -2364,20 +2430,23 @@ describe('registerFilesystemHandlers', () => { registerFilesystemHandlers(store as never) - const pending = handlers.get('fs:listFiles')!(null, { + // Why: cancellation keys are scoped to the issuing webContents, so the + // cancel must come from the same sender as the listing request. + const senderEvent = { sender: { id: 7 } } + const pending = handlers.get('fs:listFiles')!(senderEvent, { rootPath: '/home/user/repo', connectionId: 'conn-1', requestToken: 'token-1' }) as Promise expect(capturedSignal?.aborted).toBe(false) - await handlers.get('fs:cancelListFiles')!(null, { requestToken: 'token-1' }) + await handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'token-1' }) expect(capturedSignal?.aborted).toBe(true) await expect(pending).rejects.toThrow('listing cancelled') // Unknown or already-settled tokens are a no-op, not an error. expect(() => - handlers.get('fs:cancelListFiles')!(null, { requestToken: 'unknown' }) + handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'unknown' }) ).not.toThrow() }) }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 3918f8d3ea4..931c28c401f 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -122,6 +122,7 @@ import { getRuntimePathBasename } from '../../shared/cross-platform-path' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { registerLocalLogTailHandlers } from './local-log-tail' import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader' +import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' // Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes // ordinary JSON/log files inaccessible before the editor can degrade features. @@ -1072,11 +1073,11 @@ export function registerFilesystemHandlers( // Why #7721: keyed by renderer-generated token so a workspace switch can // abort the previous workspace's full-tree scan (SSH relays otherwise stack // scans that starve interactive fs.readDir/fs.stat past their 30s timeout). - const listFilesCancellations = new Map() + const listFilesCancellations = createSenderScopedRequestCancellations() ipcMain.handle( 'fs:listFiles', async ( - _event, + event, args: { rootPath: string connectionId?: string @@ -1084,10 +1085,7 @@ export function registerFilesystemHandlers( requestToken?: string } ): Promise => { - const controller = args.requestToken ? new AbortController() : null - if (controller && args.requestToken) { - listFilesCancellations.set(args.requestToken, controller) - } + const controller = listFilesCancellations.begin(event, args.requestToken) try { if (args.connectionId) { const provider = getSshFilesystemProvider(args.connectionId) @@ -1108,53 +1106,66 @@ export function registerFilesystemHandlers( } return await listQuickOpenFiles(args.rootPath, store, args.excludePaths, controller?.signal) } finally { - if (args.requestToken) { - listFilesCancellations.delete(args.requestToken) - } + listFilesCancellations.finish(event, args.requestToken, controller) } } ) - ipcMain.handle('fs:cancelListFiles', (_event, args: { requestToken: string }): void => { - // Why: best-effort — the entry is gone once the listing settles. - listFilesCancellations.get(args.requestToken)?.abort() + ipcMain.handle('fs:cancelListFiles', (event, args: { requestToken: string }): void => { + listFilesCancellations.cancel(event, args.requestToken) }) // ─── Git operations ───────────────────────────────────── + const gitStatusCancellations = createSenderScopedRequestCancellations() ipcMain.handle( 'git:status', async ( - _event, + event, args: { worktreePath: string connectionId?: string includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + requestToken?: string } ): Promise => { + const controller = gitStatusCancellations.begin(event, args.requestToken) const options = { includeIgnored: args.includeIgnored ?? false, + ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), ...(args.bypassEffectiveUpstreamNegativeCache === true ? { bypassEffectiveUpstreamNegativeCache: true } - : {}) + : {}), + ...(controller ? { signal: controller.signal } : {}) } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + try { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + // Why: awaiting here keeps the cancellation token registered until + // the remote request settles instead of running finally immediately. + return await provider.getStatus(args.worktreePath, options) } - return provider.getStatus(args.worktreePath, options) + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return await getStatus(worktreePath, { ...options, ...gitOptions }) + } finally { + gitStatusCancellations.finish(event, args.requestToken, controller) } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getStatus(worktreePath, { ...options, ...gitOptions }) } ) + ipcMain.handle('git:cancelStatus', (event, args: { requestToken: string }): void => { + gitStatusCancellations.cancel(event, args.requestToken) + }) + // Why: the parent status only reports one gitlink row per submodule. When the // user expands a dirty submodule, this fetches the inner per-file changes by // running a plain status inside the submodule's own worktree (read-only). diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index fef0cf7aace..71f5d286624 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -668,7 +668,7 @@ describe('registerGitHubHandlers', () => { repoPath: '/workspace/repo', limit: 10, query: 'is:open', - before: 'cursor-1', + page: 2, noCache: true }) @@ -676,7 +676,7 @@ describe('registerGitHubHandlers', () => { '/workspace/repo', 10, 'is:open', - 'cursor-1', + 2, 'origin', null, true @@ -732,7 +732,7 @@ describe('registerGitHubHandlers', () => { repoPath: '/workspace/repo', limit: 10, query: 'is:open', - before: 'cursor-1', + page: 2, noCache: true }) await handlers['gh:countWorkItems'](null, { @@ -789,7 +789,7 @@ describe('registerGitHubHandlers', () => { '/workspace/repo', 10, 'is:open', - 'cursor-1', + 2, undefined, null, true, diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index a95fb887e9c..84afec20c3b 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -433,7 +433,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoId?: string limit?: number query?: string - before?: string + page?: number noCache?: boolean } ) => { @@ -442,7 +442,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repo.path, args.limit, args.query, - args.before, + args.page, repo.issueSourcePreference, repoConnectionId(repo), args.noCache, diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index e4a0e7ff895..44335aae86a 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -91,7 +91,8 @@ export function registerJiraHandlers(): void { const result = await connect({ siteUrl: args.siteUrl, email: args.email, - apiToken: args.apiToken + apiToken: args.apiToken, + authType: args.authType === 'server' ? 'server' : 'cloud' }) if (result.ok) { _resetPreflightCache() diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 53588814848..516f839fca9 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -30,6 +30,7 @@ import { type TestSubscription = { unsubscribe: ReturnType + watching: boolean } type DeferredSubscription = { @@ -69,7 +70,7 @@ function deferredSubscription(): DeferredSubscription { return { promise, reject: rejectPromise, - resolve: () => resolvePromise({ unsubscribe }), + resolve: () => resolvePromise({ unsubscribe, watching: true }), unsubscribe } } @@ -106,6 +107,23 @@ function subscribe(sender: SenderHarness['sender'], subscriptionId: string): voi listener({ sender }, { subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}` }) } +type InitialSnapshotCallback = ( + messages: unknown[], + hasMore: boolean, + beforeOffset: number, + error?: string +) => void + +// The onInitialSnapshot callback the handler passed into the Nth subscribeTranscript +// call; transcript-watch fires it during setup, so tests invoke it directly. +function initialSnapshot(callIndex: number): InitialSnapshotCallback { + const call = subscribeTranscript.mock.calls[callIndex] + if (!call) { + throw new Error('subscribeTranscript was not called') + } + return (call[0] as { onInitialSnapshot: InitialSnapshotCallback }).onInitialSnapshot +} + function unsubscribe(sender: SenderHarness['sender'], subscriptionId: string): void { const listener = listeners.get('nativeChat:unsubscribe') if (!listener) { @@ -225,4 +243,39 @@ describe('nativeChat subscribe lifecycle', () => { expect(replacementPending.unsubscribe).toHaveBeenCalledOnce() expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) }) + + it('forwards an initial-drain error onto the snapshot frame', () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(5) + + subscribe(renderer.sender, 'drain-error') + // transcript-watch delivers the drain error synchronously via onInitialSnapshot; + // invoke the captured callback to exercise the handler's forwarding closure. + initialSnapshot(0)([], false, 0, 'Transcript unavailable') + + expect(renderer.sender.send).toHaveBeenCalledWith('nativeChat:appended', { + subscriptionId: 'drain-error', + frame: { + type: 'snapshot', + messages: [], + hasMore: false, + error: 'Transcript unavailable' + } + }) + }) + + it('omits error from the snapshot frame on a clean initial drain', () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(6) + + subscribe(renderer.sender, 'drain-clean') + initialSnapshot(0)([], false, 0) + + expect(renderer.sender.send).toHaveBeenCalledWith('nativeChat:appended', { + subscriptionId: 'drain-clean', + frame: { type: 'snapshot', messages: [], hasMore: false } + }) + }) }) diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index d16a39b1f13..4d0e1765a44 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -58,6 +58,7 @@ async function invokeReadSession(args: { agent: string sessionId: string limit?: number + transcriptPath?: string }): Promise { registerNativeChatHandlers() const handler = handlers.get('nativeChat:readSession') @@ -68,6 +69,17 @@ async function invokeReadSession(args: { } describe('nativeChat:readSession handler', () => { + it('preserves notFound so a just-created session stays in retry/loading', async () => { + const result = (await invokeReadSession({ + agent: 'claude', + sessionId: 'missing-session', + transcriptPath: join(tmpdir(), 'orca-native-chat-ipc-does-not-exist.jsonl') + })) as { error?: string; notFound?: true } + + expect(result.error).toBeDefined() + expect(result.notFound).toBe(true) + }) + it('resolves a Claude transcript and returns the full conversation', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-')) tempRoots.push(root) @@ -152,7 +164,7 @@ describe('nativeChat:readSession handler', () => { } }) - it('emits appended messages over nativeChat:appended and tears down on destroy', async () => { + it('emits snapshot and appended frames and tears down on destroy', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-sub-')) tempRoots.push(root) const projectsDir = join(root, '.claude', 'projects') @@ -214,13 +226,14 @@ describe('nativeChat:readSession handler', () => { })}\n` ) - // Seed-at-0 means the first appended event carries the whole-file re-read; - // the new turn 'a-1' arrives across one of the appended events. Collect ids - // from every appended event and assert the new turn shows up. + // The first frame is a bounded snapshot and later frames are appends. + // Collect ids across both and assert the new turn shows up. const appendedIds = (): string[] => sent .filter((s) => s.channel === 'nativeChat:appended') - .flatMap((s) => (s.payload as { messages: { id: string }[] }).messages.map((m) => m.id)) + .flatMap((s) => + (s.payload as { frame: { messages: { id: string }[] } }).frame.messages.map((m) => m.id) + ) await waitFor(() => appendedIds().includes('a-1')) const appendedEvent = sent.find((s) => s.channel === 'nativeChat:appended')! const payload = appendedEvent.payload as { subscriptionId: string } diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 3ed4e9b74a6..227918a68f3 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -1,12 +1,10 @@ import { ipcMain, type IpcMainEvent, type WebContents } from 'electron' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' -import { - clearNativeChatTranscriptCache, - readNativeChatTranscriptCached -} from '../native-chat/transcript-read-cache' +import { clearNativeChatTranscriptCache } from '../native-chat/transcript-read-cache' import type { ReadTranscriptResult } from '../native-chat/transcript-reader' import { subscribeNativeChatTranscript, + readNativeChatTranscriptTail, type NativeChatTranscriptSubscription } from '../native-chat/transcript-watch' @@ -25,27 +23,20 @@ export type NativeChatReadSessionArgs = { transcriptPath?: string } -// Why: render only the most recent turns so switching to chat view on a long -// session (thousands of messages) doesn't stall on building that many message -// components. The full transcript is still cached; only the returned slice is -// capped. The renderer raises `limit` to page in older history; live appends -// extend it from there. +// Why: render and parse only the recent window so long transcripts do not stall +// either the main process or the message list. Pagination raises this limit. const DESKTOP_READ_WINDOW = 300 -function windowTranscript(result: ReadTranscriptResult, limit: number): ReadTranscriptResult { - if (!('messages' in result) || result.messages.length <= limit) { - return result - } - return { ...result, messages: result.messages.slice(-limit) } -} - async function readSession(args: NativeChatReadSessionArgs): Promise { const { agent, sessionId } = args // Clamp to a positive window; default to the desktop window for the first page. const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW - // Desktop is full-class: window by count only, no char truncation. - const result = await readNativeChatTranscriptCached(agent, sessionId, args.transcriptPath) - return windowTranscript(result, limit) + return readNativeChatTranscriptTail({ + agent, + sessionId, + transcriptPath: args.transcriptPath, + limit + }) } export type NativeChatSubscribeArgs = { @@ -56,11 +47,15 @@ export type NativeChatSubscribeArgs = { sessionId: string /** Authoritative transcript path from the agent hook (providerSession). */ transcriptPath?: string + limit?: number } export type NativeChatAppendedPayload = { subscriptionId: string - messages: NativeChatMessage[] + frame: + | { type: 'snapshot'; messages: NativeChatMessage[]; hasMore: boolean; error?: string } + | { type: 'replacement'; messages: NativeChatMessage[]; hasMore: boolean } + | { type: 'appended'; messages: NativeChatMessage[] } } type LiveSubscription = { @@ -144,6 +139,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg return } const { subscriptionId, agent, sessionId, transcriptPath } = args + const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW // Replace any prior subscription under the same id (session change/resubscribe). const pendingToken = beginPendingSubscription(sender.id, subscriptionId) registerSenderCleanup(sender) @@ -154,11 +150,36 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg agent, sessionId, transcriptPath, + initialLimit: limit, + onInitialSnapshot: (messages, hasMore, _beforeOffset, error) => { + if (sender.isDestroyed()) { + return + } + // Forward an initial-drain error so a watching client's first frame carries it + // instead of stranding the view at 'loading' when the read keeps throwing. + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { type: 'snapshot', messages, hasMore, ...(error ? { error } : {}) } + } + sender.send('nativeChat:appended', payload) + }, + onReplace: (messages, hasMore) => { + if (sender.isDestroyed()) { + return + } + sender.send('nativeChat:appended', { + subscriptionId, + frame: { type: 'replacement', messages, hasMore } + } satisfies NativeChatAppendedPayload) + }, onAppend: (messages) => { if (sender.isDestroyed()) { return } - const payload: NativeChatAppendedPayload = { subscriptionId, messages } + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { type: 'appended', messages } + } sender.send('nativeChat:appended', payload) } }) @@ -182,6 +203,18 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg } bySubId.set(subscriptionId, { subscription }) liveSubscriptions.set(sender.id, bySubId) + if (!subscription.watching && !sender.isDestroyed()) { + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { + type: 'snapshot', + messages: [], + hasMore: false, + error: 'Transcript unavailable' + } + } + sender.send('nativeChat:appended', payload) + } } /** Test-only: drop all live and pending transcript subscriptions between runs. */ diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 4eb8938f2b4..9d6c825498d 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -436,8 +436,8 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime // hold back the dot. It rides the notification dispatch, so it follows the // renderer's per-source decision to notify: bells always reach here, while // an agent completion is suppressed upstream when its notification is - // disabled. Tray exists only on Windows, so setTrayAttention no-ops - // elsewhere. + // disabled. The status item exists on Windows and macOS, so + // setTrayAttention lights its attention dot there and no-ops on Linux. if (args.source === 'agent-task-complete' || args.source === 'terminal-bell') { const activeWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null if (!isMainWindowVisible(activeWindow)) { diff --git a/src/main/ipc/parcel-watcher-cancellation-restart.ts b/src/main/ipc/parcel-watcher-cancellation-restart.ts new file mode 100644 index 00000000000..21f59d07f1d --- /dev/null +++ b/src/main/ipc/parcel-watcher-cancellation-restart.ts @@ -0,0 +1,86 @@ +import type { ChildProcess } from 'node:child_process' +import { + failAllWatcherSubscriptions, + type PendingWatcherUnsubscribe, + resolvePendingWatcherUnsubscribes +} from './parcel-watcher-host-subscriptions' +import type { WatcherCancellationTracker } from './parcel-watcher-cancellation-tracker' +import { + createWatcherChildTerminationFailure, + terminateWatcherChild +} from './parcel-watcher-child-termination' +import { resetPendingSubscribeAttempt } from './parcel-watcher-pending-subscribe' +import { WatcherProcessFailure } from './parcel-watcher-process-failure' +import type { WatcherProcessSubscriptionRecord } from './parcel-watcher-process-subscription' + +export function restoreWatcherRecordsAfterCancellation( + records: Map, + ensureWatcherProcess: () => ChildProcess | null, + sendSubscribe: (child: ChildProcess, record: WatcherProcessSubscriptionRecord) => void, + onUnavailable: () => void +): void { + if (records.size === 0) { + return + } + const replacement = ensureWatcherProcess() + if (!replacement) { + failAllWatcherSubscriptions( + records, + new WatcherProcessFailure( + 'file watcher process unavailable after subscription cancellation', + 'supervisor', + 'process_unavailable' + ) + ) + onUnavailable() + return + } + for (const record of records.values()) { + record.interrupted = true + resetPendingSubscribeAttempt(record) + sendSubscribe(replacement, record) + } +} + +export function failWatcherRecordsAfterTerminationDeadline( + records: Map, + error = new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable' + ) +): void { + failAllWatcherSubscriptions(records, error) +} + +export async function restartCancelledWatcherChild( + child: ChildProcess, + records: Map, + pendingUnsubscribes: Map, + cancelledSubscribes: WatcherCancellationTracker, + onTerminationFinished: (exited: boolean) => boolean, + ensureWatcherProcess: () => ChildProcess | null, + sendSubscribe: (child: ChildProcess, record: WatcherProcessSubscriptionRecord) => void, + onUnavailable: () => void +): Promise { + const exited = await terminateWatcherChild(child) + const shouldRestore = onTerminationFinished(exited) + if (!exited) { + const error = createWatcherChildTerminationFailure(child) + cancelledSubscribes.finishRestart(child, error) + resolvePendingWatcherUnsubscribes(pendingUnsubscribes, error) + failWatcherRecordsAfterTerminationDeadline(records, error) + throw error + } + cancelledSubscribes.finishRestart(child) + resolvePendingWatcherUnsubscribes(pendingUnsubscribes) + if (!shouldRestore) { + return + } + restoreWatcherRecordsAfterCancellation( + records, + ensureWatcherProcess, + sendSubscribe, + onUnavailable + ) +} diff --git a/src/main/ipc/parcel-watcher-cancellation-tracker.ts b/src/main/ipc/parcel-watcher-cancellation-tracker.ts new file mode 100644 index 00000000000..78f39e3bbee --- /dev/null +++ b/src/main/ipc/parcel-watcher-cancellation-tracker.ts @@ -0,0 +1,77 @@ +import type { ChildProcess } from 'node:child_process' +import { RUNTIME_FILE_WATCH_CANCEL_TIMEOUT_MS } from '../../shared/runtime-file-watch-limits' + +type CancelledSubscribe = { + child: ChildProcess + error: Error + reject: (error: Error) => void + timer: ReturnType +} + +// Why: cancellation may queue behind a legitimate crawl, but must not let a +// wedged native unsubscribe block destructive worktree cleanup forever. +export const WATCHER_PROCESS_CANCEL_TIMEOUT_MS = RUNTIME_FILE_WATCH_CANCEL_TIMEOUT_MS + +export class WatcherCancellationTracker { + private readonly entries = new Map() + private readonly restartingChildren = new Set() + + begin( + id: number, + child: ChildProcess, + error: Error, + reject: (error: Error) => void, + onTimeout: () => void + ): void { + const timer = setTimeout(onTimeout, WATCHER_PROCESS_CANCEL_TIMEOUT_MS) + timer.unref?.() + this.entries.set(id, { child, error, reject, timer }) + } + + has(id: number): boolean { + return this.entries.has(id) + } + + beginRestart(child: ChildProcess): boolean { + if (this.restartingChildren.has(child)) { + return false + } + this.restartingChildren.add(child) + for (const cancelled of this.entries.values()) { + if (cancelled.child === child) { + clearTimeout(cancelled.timer) + } + } + return true + } + + finishRestart(child: ChildProcess, error?: Error): void { + this.completeForChild(child, error) + this.restartingChildren.delete(child) + } + + complete(id: number, error?: Error): boolean { + const cancelled = this.entries.get(id) + if (!cancelled) { + return false + } + this.entries.delete(id) + clearTimeout(cancelled.timer) + cancelled.reject(error ?? cancelled.error) + return true + } + + completeAll(): void { + for (const id of this.entries.keys()) { + this.complete(id) + } + } + + completeForChild(child: ChildProcess, error?: Error): void { + for (const [id, cancelled] of this.entries) { + if (cancelled.child === child) { + this.complete(id, error) + } + } + } +} diff --git a/src/main/ipc/parcel-watcher-child-launch.test.ts b/src/main/ipc/parcel-watcher-child-launch.test.ts new file mode 100644 index 00000000000..bc2e8635d72 --- /dev/null +++ b/src/main/ipc/parcel-watcher-child-launch.test.ts @@ -0,0 +1,76 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WatcherChildRegistry from './parcel-watcher-child-registry' + +const { forkMock, releaseReservationMock, signalPhysicalExitMock, writeFileSyncMock } = vi.hoisted( + () => ({ + forkMock: vi.fn(), + releaseReservationMock: vi.fn(), + signalPhysicalExitMock: vi.fn(), + writeFileSyncMock: vi.fn() + }) +) + +vi.mock('node:child_process', () => ({ fork: forkMock })) +vi.mock('node:fs', () => ({ writeFileSync: writeFileSyncMock })) +vi.mock('./parcel-watcher-canary-directory', () => ({ + createWatcherCanaryDirectory: vi.fn(() => null), + removeWatcherCanaryDirectory: vi.fn() +})) +vi.mock('./parcel-watcher-child-registry', async (importOriginal) => ({ + ...(await importOriginal()), + reserveWatcherChild: vi.fn(() => releaseReservationMock) +})) +vi.mock('./parcel-watcher-child-termination', () => ({ + registerWatcherChildPhysicalExit: vi.fn(() => signalPhysicalExitMock) +})) + +import { launchWatcherChild } from './parcel-watcher-child-launch' +import { reserveWatcherChild, WatcherChildCapacityError } from './parcel-watcher-child-registry' + +class LaunchChild extends EventEmitter { + pid = 1234 + stderr = new EventEmitter() +} + +describe('launchWatcherChild', () => { + beforeEach(() => { + vi.stubEnv('ORCA_WATCHER_CHILD_PID_FILE', '/tmp/orca-watcher.pid') + forkMock.mockReturnValue(new LaunchChild()) + releaseReservationMock.mockReset() + signalPhysicalExitMock.mockReset() + writeFileSyncMock.mockReset() + vi.mocked(reserveWatcherChild).mockReturnValue(releaseReservationMock) + }) + + it('preserves typed transient capacity exhaustion without forking', () => { + vi.mocked(reserveWatcherChild).mockReturnValueOnce(null) + + expect(() => launchWatcherChild('/watcher.js', null, vi.fn(), vi.fn())).toThrow( + WatcherChildCapacityError + ) + expect(forkMock).not.toHaveBeenCalled() + }) + + it('creates the fault-harness pid file without clobbering an existing path', () => { + expect(launchWatcherChild('/watcher.js', null, vi.fn(), vi.fn())).not.toBeNull() + expect(writeFileSyncMock).toHaveBeenCalledWith('/tmp/orca-watcher.pid', '1234', { + flag: 'wx' + }) + }) + + it('releases physical capacity when an async spawn failure closes without exit', () => { + const child = new LaunchChild() + forkMock.mockReturnValue(child) + const onGone = vi.fn() + + expect(launchWatcherChild('/watcher.js', null, vi.fn(), onGone)).not.toBeNull() + child.emit('error', Object.assign(new Error('spawn failed'), { code: 'ENOENT' })) + child.emit('close', -2, null) + child.emit('exit', -2, null) + + expect(onGone).toHaveBeenCalledTimes(2) + expect(signalPhysicalExitMock).toHaveBeenCalledTimes(1) + expect(releaseReservationMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/parcel-watcher-child-launch.ts b/src/main/ipc/parcel-watcher-child-launch.ts index 6a72bb98145..325bbed2d10 100644 --- a/src/main/ipc/parcel-watcher-child-launch.ts +++ b/src/main/ipc/parcel-watcher-child-launch.ts @@ -4,6 +4,8 @@ import { createWatcherCanaryDirectory, removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory' +import { reserveWatcherChild, WatcherChildCapacityError } from './parcel-watcher-child-registry' +import { registerWatcherChildPhysicalExit } from './parcel-watcher-child-termination' import type { WatcherToHostMessage } from './parcel-watcher-process-protocol' export type LaunchedWatcherChild = { @@ -17,6 +19,13 @@ export function launchWatcherChild( onMessage: (child: ChildProcess, message: WatcherToHostMessage) => void, onGone: (child: ChildProcess, code?: number | null, signal?: NodeJS.Signals | null) => void ): LaunchedWatcherChild | null { + const releaseReservation = reserveWatcherChild() + if (!releaseReservation) { + console.error('[parcel-watcher-process] physical watcher child limit reached') + // Why: capacity is transient and has an event-driven retry owner. Preserve + // its type so the desktop root is not cached permanently unavailable. + throw new WatcherChildCapacityError() + } const canaryDir = currentCanaryDir ?? createWatcherCanaryDirectory() let child: ChildProcess try { @@ -30,6 +39,7 @@ export function launchWatcherChild( ...(process.platform === 'win32' ? { windowsHide: true } : {}) }) } catch (error) { + releaseReservation() removeWatcherCanaryDirectory(canaryDir) console.error('[parcel-watcher-process] failed to fork watcher process:', error) return null @@ -48,10 +58,26 @@ export function launchWatcherChild( console.error('[parcel-watcher-process]', String(chunk).trimEnd()) }) child.on('message', (message) => onMessage(child, message as WatcherToHostMessage)) + const signalPhysicalExit = registerWatcherChildPhysicalExit(child) + let finalized = false + const finalizePhysicalExit = (): void => { + if (finalized) { + return + } + finalized = true + signalPhysicalExit() + releaseReservation() + } // 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)) + // Why: asynchronous spawn failures emit error then close without exit; close + // must release the physical-process budget even though no child was created. + child.once('close', finalizePhysicalExit) + child.on('exit', (code, signal) => { + finalizePhysicalExit() + onGone(child, code, signal) + }) return { child, canaryDir } } diff --git a/src/main/ipc/parcel-watcher-child-messaging.ts b/src/main/ipc/parcel-watcher-child-messaging.ts new file mode 100644 index 00000000000..73e429abed5 --- /dev/null +++ b/src/main/ipc/parcel-watcher-child-messaging.ts @@ -0,0 +1,10 @@ +import type { ChildProcess } from 'node:child_process' +import type { HostToWatcherMessage } from './parcel-watcher-process-protocol' + +export function sendToWatcherChild(proc: ChildProcess, message: HostToWatcherMessage): void { + try { + proc.send(message) + } catch { + // The child's exit handler owns recovery after its IPC channel closes. + } +} diff --git a/src/main/ipc/parcel-watcher-child-recovery.ts b/src/main/ipc/parcel-watcher-child-recovery.ts new file mode 100644 index 00000000000..b0329474735 --- /dev/null +++ b/src/main/ipc/parcel-watcher-child-recovery.ts @@ -0,0 +1,94 @@ +import type { ChildProcess } from 'node:child_process' +import { + failAllWatcherSubscriptions, + type PendingWatcherUnsubscribe, + resolvePendingWatcherUnsubscribes +} from './parcel-watcher-host-subscriptions' +import type { WatcherCancellationTracker } from './parcel-watcher-cancellation-tracker' +import { + createWatcherChildTerminationFailure, + terminateWatcherChild +} from './parcel-watcher-child-termination' +import { resetPendingSubscribeAttempt } from './parcel-watcher-pending-subscribe' +import type { WatcherProcessCrashFuse } from './parcel-watcher-crash-fuse' +import { WatcherProcessFailure } from './parcel-watcher-process-failure' +import type { WatcherProcessSubscriptionRecord } from './parcel-watcher-process-subscription' + +export function recoverWatcherRecordsAfterChildGone( + records: Map, + crashFuse: WatcherProcessCrashFuse, + shutdownRequested: boolean, + ensureWatcherProcess: () => ChildProcess | null, + sendSubscribe: (child: ChildProcess, record: WatcherProcessSubscriptionRecord) => void, + removeCanary: () => void, + code?: number | null, + signal?: NodeJS.Signals | null +): void { + if (code !== undefined && (code !== 0 || signal)) { + console.error( + `[parcel-watcher-process] watcher process exited (code=${code}, signal=${signal})` + ) + } + if (shutdownRequested || records.size === 0) { + return + } + crashFuse.recordCrash() + for (const record of records.values()) { + record.interrupted = true + resetPendingSubscribeAttempt(record) + } + const replacement = ensureWatcherProcess() + if (!replacement) { + console.error( + '[parcel-watcher-process] watcher process crashed repeatedly; disabling file watching' + ) + failAllWatcherSubscriptions( + records, + new WatcherProcessFailure( + 'file watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ) + ) + removeCanary() + return + } + console.error( + `[parcel-watcher-process] watcher process crashed; resubscribing ${records.size} root(s)` + ) + for (const record of records.values()) { + sendSubscribe(replacement, record) + } +} + +export async function terminateDisconnectedWatcherChild( + child: ChildProcess, + records: Map, + pendingUnsubscribes: Map, + cancelledSubscribes: WatcherCancellationTracker, + crashFuse: WatcherProcessCrashFuse, + onTerminationFinished: (exited: boolean) => boolean, + ensureWatcherProcess: () => ChildProcess | null, + sendSubscribe: (child: ChildProcess, record: WatcherProcessSubscriptionRecord) => void, + removeCanary: () => void +): Promise { + const exited = await terminateWatcherChild(child) + const error = exited ? undefined : createWatcherChildTerminationFailure(child) + cancelledSubscribes.completeForChild(child, error) + const shouldRestore = onTerminationFinished(exited) + resolvePendingWatcherUnsubscribes(pendingUnsubscribes, error) + if (error) { + failAllWatcherSubscriptions(records, error) + throw error + } + if (shouldRestore) { + recoverWatcherRecordsAfterChildGone( + records, + crashFuse, + false, + ensureWatcherProcess, + sendSubscribe, + removeCanary + ) + } +} diff --git a/src/main/ipc/parcel-watcher-child-registry.test.ts b/src/main/ipc/parcel-watcher-child-registry.test.ts new file mode 100644 index 00000000000..b85ef4a6820 --- /dev/null +++ b/src/main/ipc/parcel-watcher-child-registry.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + MAX_PHYSICAL_WATCHER_CHILDREN, + onWatcherChildCapacityAvailable, + reserveWatcherChild, + resetWatcherChildRegistryForTest +} from './parcel-watcher-child-registry' + +function fillWatcherChildCapacity(): (() => void)[] { + return Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => { + const release = reserveWatcherChild() + if (!release) { + throw new Error('expected watcher child reservation') + } + return release + }) +} + +describe('parcel watcher child registry capacity notifications', () => { + afterEach(() => resetWatcherChildRegistryForTest()) + + it('notifies after capacity was released before listener registration', async () => { + const releases = fillWatcherChildCapacity() + expect(reserveWatcherChild()).toBeNull() + releases.pop()?.() + const listener = vi.fn() + + onWatcherChildCapacityAvailable(listener) + await Promise.resolve() + + expect(listener).toHaveBeenCalledOnce() + releases.forEach((release) => release()) + }) + + it('cancels a pending capacity notification without leaking the listener', () => { + const releases = fillWatcherChildCapacity() + const listener = vi.fn() + const cancel = onWatcherChildCapacityAvailable(listener) + + cancel() + releases.pop()?.() + + expect(listener).not.toHaveBeenCalled() + releases.forEach((release) => release()) + }) + + it('delivers each capacity notification at most once', () => { + const releases = fillWatcherChildCapacity() + const listener = vi.fn() + onWatcherChildCapacityAvailable(listener) + + releases.pop()?.() + releases.pop()?.() + + expect(listener).toHaveBeenCalledOnce() + releases.forEach((release) => release()) + }) + + it('hands one released slot to one reserving waiter at a time', async () => { + const releases = fillWatcherChildCapacity() + let firstReservation: (() => void) | null = null + let secondReservation: (() => void) | null = null + const first = vi.fn(() => { + firstReservation = reserveWatcherChild() + }) + const second = vi.fn(() => { + secondReservation = reserveWatcherChild() + }) + onWatcherChildCapacityAvailable(first) + onWatcherChildCapacityAvailable(second) + + releases.pop()?.() + await vi.waitFor(() => expect(first).toHaveBeenCalledOnce()) + expect(firstReservation).toBeTypeOf('function') + expect(second).not.toHaveBeenCalled() + + ;(firstReservation as (() => void) | null)?.() + await vi.waitFor(() => expect(second).toHaveBeenCalledOnce()) + expect(secondReservation).toBeTypeOf('function') + + ;(secondReservation as (() => void) | null)?.() + releases.forEach((release) => release()) + }) +}) diff --git a/src/main/ipc/parcel-watcher-child-registry.ts b/src/main/ipc/parcel-watcher-child-registry.ts new file mode 100644 index 00000000000..82d8011ba5a --- /dev/null +++ b/src/main/ipc/parcel-watcher-child-registry.ts @@ -0,0 +1,85 @@ +// Why: stuck retiring children still consume RSS even after their logical pool +// slot is retired, so they must count against one process-wide hard cap. +export const MAX_PHYSICAL_WATCHER_CHILDREN = 8 + +export class WatcherChildCapacityError extends Error { + readonly code = 'watcher_child_capacity' + + constructor() { + super('Physical file watcher process limit reached') + this.name = 'WatcherChildCapacityError' + } +} + +let reservedChildren = 0 +const capacityListeners = new Set<() => Promise>() +let capacityNotificationsInProgress = 0 +let capacityNotificationGeneration = 0 + +export function reserveWatcherChild(): (() => void) | null { + if (reservedChildren >= MAX_PHYSICAL_WATCHER_CHILDREN) { + return null + } + reservedChildren++ + let released = false + return () => { + if (!released) { + released = true + reservedChildren-- + notifyCapacityListeners() + } + } +} + +export function onWatcherChildCapacityAvailable(listener: () => void | Promise): () => void { + let active = true + const notify = async (): Promise => { + if (!active) { + return + } + active = false + capacityListeners.delete(notify) + await listener() + } + capacityListeners.add(notify) + // Why: a child may exit between a failed reservation and listener setup. + // Recheck asynchronously so that release cannot become a lost wake-up. + if (reservedChildren < MAX_PHYSICAL_WATCHER_CHILDREN) { + queueMicrotask(notifyCapacityListeners) + } + return () => { + active = false + capacityListeners.delete(notify) + } +} + +function notifyCapacityListeners(): void { + // Why: an installing callback claims one free slot until it either reserves + // the child or finishes, so one release cannot wake every waiting root. + while (capacityNotificationsInProgress < MAX_PHYSICAL_WATCHER_CHILDREN - reservedChildren) { + const listener = capacityListeners.values().next().value + if (!listener) { + return + } + const generation = capacityNotificationGeneration + capacityNotificationsInProgress++ + void listener() + .catch((error: unknown) => { + console.error('[parcel-watcher-child-registry] capacity listener failed:', error) + }) + .finally(() => { + if (generation !== capacityNotificationGeneration) { + return + } + capacityNotificationsInProgress-- + notifyCapacityListeners() + }) + } +} + +export function resetWatcherChildRegistryForTest(): void { + reservedChildren = 0 + capacityListeners.clear() + capacityNotificationsInProgress = 0 + capacityNotificationGeneration++ +} diff --git a/src/main/ipc/parcel-watcher-child-termination.ts b/src/main/ipc/parcel-watcher-child-termination.ts new file mode 100644 index 00000000000..fc498410438 --- /dev/null +++ b/src/main/ipc/parcel-watcher-child-termination.ts @@ -0,0 +1,142 @@ +import type { ChildProcess } from 'node:child_process' +import { + type PendingWatcherUnsubscribe, + resolvePendingWatcherUnsubscribes +} from './parcel-watcher-host-subscriptions' +import { WatcherProcessFailure } from './parcel-watcher-process-failure' +import { RUNTIME_FILE_WATCH_EXIT_DEADLINE_MS } from '../../shared/runtime-file-watch-limits' + +export const WATCHER_PROCESS_HARD_KILL_DELAY_MS = 5_000 +export const WATCHER_PROCESS_EXIT_DEADLINE_MS = RUNTIME_FILE_WATCH_EXIT_DEADLINE_MS + +const physicalExitPromises = new WeakMap>() + +export function registerWatcherChildPhysicalExit(child: ChildProcess): () => void { + let resolveExit: () => void = () => undefined + const promise = new Promise((resolve) => { + resolveExit = resolve + }) + physicalExitPromises.set(child, promise) + return resolveExit +} + +export function ignoreWatcherTermination(promise: Promise): void { + void promise.catch(() => undefined) +} + +export class WatcherTerminationQueue { + private current: Promise | null = null + + waitFor(operation: () => Promise): Promise | null { + // Why: an idle child may miss its exit deadline without poisoning the + // reusable supervisor; queued work must re-check state after either result. + return this.current?.then(operation, operation) ?? null + } + + getCurrent(): Promise | null { + return this.current + } + + track(promise: Promise): Promise { + const tracked = promise.finally(() => { + if (this.current === tracked) { + this.current = null + } + }) + this.current = tracked + ignoreWatcherTermination(tracked) + return tracked + } + + resetForTest(): void { + this.current = null + } +} + +export function terminateWatcherChild(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + let settled = false + const finish = (exited: boolean): void => { + if (settled) { + return + } + settled = true + clearTimeout(hardKillTimer) + clearTimeout(exitDeadlineTimer) + child.removeListener('exit', onExit) + child.removeListener('close', onClose) + resolve(exited) + } + const onExit = (): void => finish(true) + // Why: an asynchronous spawn failure can close without emitting exit; the + // close event is still definitive proof that the child owns no OS handles. + const onClose = (): void => finish(true) + child.once('exit', onExit) + child.once('close', onClose) + const hardKillTimer = setTimeout(() => { + try { + child.kill('SIGKILL') + } catch { + // The finite deadline below reports termination failure. + } + }, WATCHER_PROCESS_HARD_KILL_DELAY_MS) + hardKillTimer.unref?.() + const exitDeadlineTimer = setTimeout(() => finish(false), WATCHER_PROCESS_EXIT_DEADLINE_MS) + exitDeadlineTimer.unref?.() + try { + child.kill() + } catch { + finish(false) + } + }) +} + +export function createWatcherChildTerminationFailure(child: ChildProcess): WatcherProcessFailure { + const physicalExit = + child.exitCode !== null || child.signalCode !== null + ? Promise.resolve() + : (physicalExitPromises.get(child) ?? + new Promise((resolve) => { + const finish = (): void => { + child.removeListener('exit', finish) + child.removeListener('close', finish) + resolve() + } + child.once('exit', finish) + child.once('close', finish) + })) + return new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable', + physicalExit + ) +} + +export async function requireWatcherChildTermination(child: ChildProcess): Promise { + if (!(await terminateWatcherChild(child))) { + throw createWatcherChildTerminationFailure(child) + } +} + +export async function terminateIdleWatcherChild( + child: ChildProcess, + pendingUnsubscribes: Map, + onFinished: (exited: boolean) => void +): Promise { + try { + await requireWatcherChildTermination(child) + onFinished(true) + } catch (error) { + onFinished(false) + resolvePendingWatcherUnsubscribes( + pendingUnsubscribes, + error instanceof Error ? error : new Error(String(error)) + ) + throw error + } + resolvePendingWatcherUnsubscribes(pendingUnsubscribes) +} diff --git a/src/main/ipc/parcel-watcher-disconnect-termination.test.ts b/src/main/ipc/parcel-watcher-disconnect-termination.test.ts new file mode 100644 index 00000000000..b478775d1b7 --- /dev/null +++ b/src/main/ipc/parcel-watcher-disconnect-termination.test.ts @@ -0,0 +1,413 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WATCHER_PROCESS_CANCEL_TIMEOUT_MS } from './parcel-watcher-cancellation-tracker' +import { WATCHER_PROCESS_EXIT_DEADLINE_MS } from './parcel-watcher-child-termination' + +const { existsSyncMock, forkMock, mkdtempSyncMock, rmSyncMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + forkMock: vi.fn(), + mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-disconnect-test'), + rmSyncMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ fork: forkMock })) +vi.mock('node:fs', () => ({ + existsSync: existsSyncMock, + mkdtempSync: mkdtempSyncMock, + rmSync: rmSyncMock +})) + +import { + createWatcherProcessSupervisor, + disposeWatcherProcess, + resetWatcherProcessForTest, + subscribeViaWatcherProcess +} from './parcel-watcher-process' +import type { WatcherProcessSubscription } from './parcel-watcher-process-subscription' + +type SentMessage = { op: string; id: number } + +class FakeChild extends EventEmitter { + connected = true + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + sent: SentMessage[] = [] + stderr = new EventEmitter() + kill = vi.fn(() => { + this.connected = false + }) + send = vi.fn((message: SentMessage) => { + this.sent.push(message) + return true + }) +} + +function currentChild(): FakeChild { + return forkMock.mock.results.at(-1)?.value as FakeChild +} + +function ackSubscribe(child: FakeChild): void { + const message = child.sent.findLast((candidate) => candidate.op === 'subscribe') + child.emit('message', { op: 'subscribed', id: message?.id }) +} + +async function subscribePair(): Promise<{ child: FakeChild; unsubscribe: () => Promise }> { + const firstPromise = subscribeViaWatcherProcess('/first', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + const first = await firstPromise + const siblingPromise = subscribeViaWatcherProcess('/sibling', vi.fn(), {}) + ackSubscribe(child) + await siblingPromise + return { child, unsubscribe: first.unsubscribe } +} + +describe('watcher disconnect termination', () => { + beforeEach(() => { + resetWatcherProcessForTest() + vi.stubEnv('VITEST', '') + existsSyncMock.mockReturnValue(true) + forkMock.mockImplementation(() => new FakeChild()) + }) + + afterEach(() => { + disposeWatcherProcess() + vi.unstubAllEnvs() + vi.clearAllMocks() + }) + + it('keeps unsubscribe pending until physical exit', async () => { + const { child, unsubscribe } = await subscribePair() + let settled = false + const pending = unsubscribe().then(() => { + settled = true + }) + child.connected = false + child.emit('disconnect') + await Promise.resolve() + + expect(settled).toBe(false) + expect(forkMock).toHaveBeenCalledTimes(1) + child.emit('exit', 0, null) + await pending + expect(settled).toBe(true) + }) + + it('forces disconnected ready-subscription teardown even with a live sibling', async () => { + const { child, unsubscribe } = await subscribePair() + child.connected = false + let settled = false + const pending = unsubscribe().then(() => { + settled = true + }) + + await Promise.resolve() + expect(child.kill).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + + child.emit('exit', 0, null) + await pending + expect(forkMock).toHaveBeenCalledTimes(2) + }) + + it('forces disconnected pending-crawl teardown even with a live sibling', async () => { + const siblingPromise = subscribeViaWatcherProcess('/sibling', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + await siblingPromise + const controller = new AbortController() + const subscribing = subscribeViaWatcherProcess( + '/pending', + vi.fn(), + {}, + { + signal: controller.signal + } + ) + child.connected = false + controller.abort() + let settled = false + void subscribing.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await Promise.resolve() + expect(child.kill).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + + child.emit('exit', 0, null) + await expect(subscribing).rejects.toMatchObject({ code: 'subscribe_aborted' }) + expect(forkMock).toHaveBeenCalledTimes(2) + }) + + it('joins a cancellation deadline to active disconnect termination', async () => { + vi.useFakeTimers() + try { + const siblingPromise = subscribeViaWatcherProcess('/sibling', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + await siblingPromise + const controller = new AbortController() + const pending = subscribeViaWatcherProcess( + '/pending', + vi.fn(), + {}, + { + signal: controller.signal + } + ) + const pendingId = child.sent.at(-1)?.id + child.emit('message', { op: 'subscribe-started', id: pendingId }) + controller.abort() + const rejection = expect(pending).rejects.toMatchObject({ code: 'subscribe_aborted' }) + + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_CANCEL_TIMEOUT_MS - 1) + child.connected = false + child.emit('disconnect') + expect(child.kill).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + expect(child.kill).toHaveBeenCalledTimes(1) + child.emit('exit', 0, null) + await rejection + + const replacement = currentChild() + expect(forkMock).toHaveBeenCalledTimes(2) + expect(replacement.sent.filter((message) => message.op === 'subscribe')).toEqual([ + expect.objectContaining({ dir: '/sibling' }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('reports physical-exit failure when a timed-out recovery child cannot terminate', async () => { + vi.useFakeTimers() + try { + const supervisor = createWatcherProcessSupervisor() + let subscription: WatcherProcessSubscription + let terminalUnsubscribeResult: Promise | undefined + const onTerminalError = vi.fn(() => { + terminalUnsubscribeResult = subscription.unsubscribe().catch((error: unknown) => error) + }) + const initial = supervisor.subscribe( + '/stuck-recovery', + vi.fn(), + {}, + { + onTerminalError, + subscribeTimeoutMs: 100 + } + ) + const first = currentChild() + ackSubscribe(first) + subscription = 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 + WATCHER_PROCESS_EXIT_DEADLINE_MS) + + await vi.waitFor(() => { + expect(onTerminalError).toHaveBeenCalledTimes(1) + expect(onTerminalError).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'process_unavailable', + message: 'file watcher process did not exit after termination deadline', + physicalExit: expect.any(Promise) + }) + ) + }) + await expect(terminalUnsubscribeResult).resolves.toMatchObject({ + code: 'process_unavailable', + physicalExit: expect.any(Promise) + }) + expect(forkMock).toHaveBeenCalledTimes(2) + replacement.emit('exit', 0, null) + await Promise.resolve() + await expect(subscription.unsubscribe()).resolves.toBeUndefined() + supervisor.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('reports a recovery timeout only after its replacement child physically exits', 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) + const subscription = 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).not.toHaveBeenCalled() + expect(replacement.kill).toHaveBeenCalledTimes(1) + expect(forkMock).toHaveBeenCalledTimes(2) + let unsubscribeSettled = false + const unsubscribe = subscription.unsubscribe().then(() => { + unsubscribeSettled = true + }) + await Promise.resolve() + expect(unsubscribeSettled).toBe(false) + + replacement.emit('exit', 0, null) + await unsubscribe + await vi.waitFor(() => + expect(onTerminalError).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'subscribe_timeout', + message: 'file watcher resubscription timed out after 100ms' + }) + ) + ) + supervisor.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('keeps pending-crawl cancellation joined to disconnected child exit', async () => { + const controller = new AbortController() + const subscribing = subscribeViaWatcherProcess( + '/pending', + vi.fn(), + {}, + { + signal: controller.signal + } + ) + const child = currentChild() + child.connected = false + child.emit('disconnect') + controller.abort() + let settled = false + void subscribing.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await Promise.resolve() + expect(settled).toBe(false) + + child.emit('exit', 0, null) + await expect(subscribing).rejects.toMatchObject({ code: 'subscribe_aborted' }) + }) + + it('reports physical-exit failure instead of early pending-crawl cancellation', async () => { + vi.useFakeTimers() + try { + const controller = new AbortController() + const subscribing = subscribeViaWatcherProcess( + '/pending', + vi.fn(), + {}, + { + signal: controller.signal + } + ) + const child = currentChild() + child.connected = false + child.emit('disconnect') + controller.abort() + const failure = expect(subscribing).rejects.toMatchObject({ + code: 'process_unavailable', + physicalExit: expect.any(Promise) + }) + + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) + + await failure + } finally { + vi.useRealTimers() + } + }) + + it('rejects unsubscribe at the physical-exit deadline', async () => { + vi.useFakeTimers() + try { + const { child, unsubscribe } = await subscribePair() + const failure = expect(unsubscribe()).rejects.toThrow( + 'file watcher process did not exit after termination deadline' + ) + child.connected = false + child.emit('disconnect') + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) + + await failure + expect(forkMock).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('retires the child before settling a rejected native unsubscribe', async () => { + const firstPromise = subscribeViaWatcherProcess('/first', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + const first = await firstPromise + const siblingInterruption = vi.fn() + const siblingPromise = subscribeViaWatcherProcess( + '/sibling', + vi.fn(), + {}, + { + onInterruption: siblingInterruption + } + ) + ackSubscribe(child) + await siblingPromise + + let settled = false + const unsubscribe = first.unsubscribe().then(() => { + settled = true + }) + child.emit('message', { op: 'unsubscribe-failed', id: 1, message: 'native handle active' }) + await Promise.resolve() + + expect(settled).toBe(false) + expect(child.kill).toHaveBeenCalledTimes(1) + expect(forkMock).toHaveBeenCalledTimes(1) + child.emit('exit', 0, null) + await unsubscribe + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(2)) + const replacement = currentChild() + expect(replacement.sent).toContainEqual(expect.objectContaining({ dir: '/sibling' })) + ackSubscribe(replacement) + expect(siblingInterruption).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/parcel-watcher-entry-path.test.ts b/src/main/ipc/parcel-watcher-entry-path.test.ts index f8b59becc78..2b29c6c9016 100644 --- a/src/main/ipc/parcel-watcher-entry-path.test.ts +++ b/src/main/ipc/parcel-watcher-entry-path.test.ts @@ -1,6 +1,9 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' -import { resolveWatcherProcessEntryPath } from './parcel-watcher-entry-path' +import { + resolveWatcherProcessEntryPath, + resolveWatcherProcessEntryPathWithoutApp +} from './parcel-watcher-entry-path' describe('resolveWatcherProcessEntryPath', () => { it('uses an adjacent entry when electron-vite appPath is already out/main', () => { @@ -37,4 +40,35 @@ describe('resolveWatcherProcessEntryPath', () => { ) ) }) + + it('uses resourcesPath for packaged Electron-as-Node serve processes', () => { + const resourcesPath = path.join('Applications', 'Orca.app', 'Contents', 'Resources') + const packagedEntry = path.join( + resourcesPath, + 'app.asar.unpacked', + 'out', + 'main', + 'parcel-watcher-process-entry.js' + ) + + expect( + resolveWatcherProcessEntryPathWithoutApp( + path.join(resourcesPath, 'unrelated-cwd'), + resourcesPath, + (candidate) => candidate === packagedEntry + ) + ).toBe(packagedEntry) + }) + + it('keeps the cwd build fallback when resourcesPath has no packaged entry', () => { + const appRoot = path.join('workspace', 'orca') + + expect( + resolveWatcherProcessEntryPathWithoutApp( + appRoot, + path.join('node_modules', 'electron', 'Resources'), + () => false + ) + ).toBe(path.join(appRoot, 'out', 'main', 'parcel-watcher-process-entry.js')) + }) }) diff --git a/src/main/ipc/parcel-watcher-entry-path.ts b/src/main/ipc/parcel-watcher-entry-path.ts index 90133146715..7eba4c205da 100644 --- a/src/main/ipc/parcel-watcher-entry-path.ts +++ b/src/main/ipc/parcel-watcher-entry-path.ts @@ -28,10 +28,32 @@ export function resolveWatcherProcessEntryPath( return join(basePath, 'out', 'main', 'parcel-watcher-process-entry.js') } +export function resolveWatcherProcessEntryPathWithoutApp( + cwd: string, + resourcesPath: string | undefined, + pathExists: (candidate: string) => boolean = existsSync +): string { + if (resourcesPath) { + const packagedEntry = join( + resourcesPath, + 'app.asar.unpacked', + 'out', + 'main', + 'parcel-watcher-process-entry.js' + ) + // Why: ELECTRON_RUN_AS_NODE exposes resourcesPath but not electron.app. + // Prefer the unpacked packaged entry without breaking dev Node fallbacks. + if (pathExists(packagedEntry)) { + return packagedEntry + } + } + return resolveWatcherProcessEntryPath(cwd, false, pathExists) +} + export function getWatcherProcessEntryPath(): string { const app = loadElectronApp() - return resolveWatcherProcessEntryPath( - app?.getAppPath() ?? process.cwd(), - app?.isPackaged === true - ) + if (app) { + return resolveWatcherProcessEntryPath(app.getAppPath(), app.isPackaged) + } + return resolveWatcherProcessEntryPathWithoutApp(process.cwd(), process.resourcesPath) } diff --git a/src/main/ipc/parcel-watcher-host-subscriptions.ts b/src/main/ipc/parcel-watcher-host-subscriptions.ts index 71a3d82868f..24f1803ee68 100644 --- a/src/main/ipc/parcel-watcher-host-subscriptions.ts +++ b/src/main/ipc/parcel-watcher-host-subscriptions.ts @@ -3,12 +3,19 @@ import { resetPendingSubscribeAttempt, takePendingSubscribe } from './parcel-watcher-pending-subscribe' -import { WatcherProcessFailure } from './parcel-watcher-process-failure' +import { isWatcherProcessFailure, WatcherProcessFailure } from './parcel-watcher-process-failure' import type { HostToWatcherMessage, WatcherToHostMessage } from './parcel-watcher-process-protocol' import type { WatcherProcessSubscription, WatcherProcessSubscriptionRecord } from './parcel-watcher-process-subscription' +import { RUNTIME_FILE_WATCH_CANCEL_TIMEOUT_MS } from '../../shared/runtime-file-watch-limits' + +export type PendingWatcherUnsubscribe = (error?: Error) => void + +export const WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS = RUNTIME_FILE_WATCH_CANCEL_TIMEOUT_MS + +const terminalTeardownFailures = new WeakMap() export function handleWatcherHostMessage( message: Exclude< @@ -16,14 +23,14 @@ export function handleWatcherHostMessage( { op: 'subscribe-started' } | { op: 'cancel-requires-restart' } >, records: Map, - pendingUnsubscribes: Map void>, + pendingUnsubscribes: Map, reportTerminalError: (record: WatcherProcessSubscriptionRecord, error: Error) => void, killWatcherChildIfIdle: () => void ): void { if (message.op === 'unsubscribed') { - const resolve = pendingUnsubscribes.get(message.id) + const settle = pendingUnsubscribes.get(message.id) pendingUnsubscribes.delete(message.id) - resolve?.() + settle?.() return } const record = records.get(message.id) @@ -67,6 +74,15 @@ export function reportWatcherTerminalError( record: WatcherProcessSubscriptionRecord, error: Error ): void { + if (isWatcherProcessFailure(error) && error.physicalExit) { + terminalTeardownFailures.set(record, error) + const clearFailure = (): void => { + if (terminalTeardownFailures.get(record) === error) { + terminalTeardownFailures.delete(record) + } + } + void error.physicalExit.then(clearFailure, clearFailure) + } if (record.hooks.onTerminalError) { record.hooks.onTerminalError(error) return @@ -92,10 +108,11 @@ export function failAllWatcherSubscriptions( } export function resolvePendingWatcherUnsubscribes( - pendingUnsubscribes: Map void> + pendingUnsubscribes: Map, + error?: Error ): void { - for (const resolve of pendingUnsubscribes.values()) { - resolve() + for (const settle of pendingUnsubscribes.values()) { + settle(error) } pendingUnsubscribes.clear() } @@ -118,9 +135,11 @@ export function disposeWatcherSupervisorSubscriptions( export type CreateHostWatcherSubscriptionOptions = { record: WatcherProcessSubscriptionRecord records: Map - pendingUnsubscribes: Map void> + pendingUnsubscribes: Map getChild: () => ChildProcess | null - killWatcherChildIfIdle: () => void + getTerminationPromise: () => Promise | null + killWatcherChildIfIdle: () => Promise + terminateUnavailableChild: (child: ChildProcess | null) => Promise sendToChild: (child: ChildProcess, message: HostToWatcherMessage) => void } @@ -129,25 +148,52 @@ export function createHostWatcherSubscription({ records, pendingUnsubscribes, getChild, + getTerminationPromise, killWatcherChildIfIdle, + terminateUnavailableChild, sendToChild }: CreateHostWatcherSubscriptionOptions): WatcherProcessSubscription { return { unsubscribe: (): Promise => { + const terminalFailure = terminalTeardownFailures.get(record) + if (terminalFailure) { + return Promise.reject(terminalFailure) + } + const termination = getTerminationPromise() if (!records.delete(record.id)) { - return Promise.resolve() + return termination ?? Promise.resolve() } resetPendingSubscribeAttempt(record) - const child = getChild() - if (!child?.connected) { - return Promise.resolve() + if (termination) { + return termination } if (records.size === 0) { - killWatcherChildIfIdle() - return Promise.resolve() + return killWatcherChildIfIdle() } - return new Promise((resolve) => { - pendingUnsubscribes.set(record.id, resolve) + const child = getChild() + if (!child?.connected) { + return terminateUnavailableChild(child) + } + return new Promise((resolve, reject) => { + const onTimeout = (): void => { + if (pendingUnsubscribes.get(record.id) !== settle) { + return + } + // Why: canary setup itself can fail, so native teardown needs an + // independent host deadline that exits the child and releases handles. + void terminateUnavailableChild(child).catch(() => undefined) + } + const timer = setTimeout(onTimeout, WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS) + timer.unref?.() + const settle: PendingWatcherUnsubscribe = (error) => { + clearTimeout(timer) + if (error) { + reject(error) + } else { + resolve() + } + } + pendingUnsubscribes.set(record.id, settle) sendToChild(child, { op: 'unsubscribe', id: record.id }) }) } diff --git a/src/main/ipc/parcel-watcher-interrupted-cancellation.ts b/src/main/ipc/parcel-watcher-interrupted-cancellation.ts new file mode 100644 index 00000000000..af23240cae1 --- /dev/null +++ b/src/main/ipc/parcel-watcher-interrupted-cancellation.ts @@ -0,0 +1,27 @@ +import { resetPendingSubscribeAttempt } from './parcel-watcher-pending-subscribe' +import type { WatcherProcessFailure } from './parcel-watcher-process-failure' +import type { WatcherProcessSubscriptionRecord } from './parcel-watcher-process-subscription' + +export function cancelInterruptedWatcherSubscribe(args: { + record: WatcherProcessSubscriptionRecord + error: WatcherProcessFailure + records: Map + reportTerminalError: (record: WatcherProcessSubscriptionRecord, error: Error) => void + restartChild: () => Promise +}): void { + const { record } = args + if (!record.interrupted || record.pendingSubscribe || !args.records.delete(record.id)) { + return + } + resetPendingSubscribeAttempt(record) + // Why: recovery may replace this root synchronously from the terminal hook; + // publish failure only after its predecessor releases native handles. + void args.restartChild().then( + () => args.reportTerminalError(record, args.error), + (terminationError: unknown) => + args.reportTerminalError( + record, + terminationError instanceof Error ? terminationError : new Error(String(terminationError)) + ) + ) +} diff --git a/src/main/ipc/parcel-watcher-pending-cancellation.ts b/src/main/ipc/parcel-watcher-pending-cancellation.ts new file mode 100644 index 00000000000..9fd37d19a8d --- /dev/null +++ b/src/main/ipc/parcel-watcher-pending-cancellation.ts @@ -0,0 +1,51 @@ +import type { ChildProcess } from 'node:child_process' +import type { WatcherCancellationTracker } from './parcel-watcher-cancellation-tracker' +import { takePendingSubscribe } from './parcel-watcher-pending-subscribe' +import type { WatcherProcessFailure } from './parcel-watcher-process-failure' +import type { WatcherProcessSubscriptionRecord } from './parcel-watcher-process-subscription' + +type CancelPendingWatcherSubscribeOptions = { + record: WatcherProcessSubscriptionRecord + error: WatcherProcessFailure + records: Map + child: ChildProcess | null + cancelledSubscribes: WatcherCancellationTracker + onChildUnavailable: (child: ChildProcess | null) => Promise + restartChild: (child: ChildProcess) => Promise + sendCancel: (child: ChildProcess, id: number) => void +} + +export function cancelPendingWatcherSubscribe({ + record, + error, + records, + child, + cancelledSubscribes, + onChildUnavailable, + restartChild, + sendCancel +}: CancelPendingWatcherSubscribeOptions): void { + if (!record.pendingSubscribe || !records.delete(record.id)) { + return + } + const pending = takePendingSubscribe(record) + if (!pending) { + return + } + if (!child?.connected) { + // Why: disconnect starts physical termination before cancellation sees the + // child; destructive callers must join that exact termination generation. + void onChildUnavailable(child).then( + () => pending.reject(error), + (terminationError: unknown) => + pending.reject( + terminationError instanceof Error ? terminationError : new Error(String(terminationError)) + ) + ) + return + } + // Why: only the child knows whether native setup is queued, active, or + // resolved, so its physical teardown must precede caller settlement. + cancelledSubscribes.begin(record.id, child, error, pending.reject, () => restartChild(child)) + sendCancel(child, record.id) +} diff --git a/src/main/ipc/parcel-watcher-pending-subscribe.ts b/src/main/ipc/parcel-watcher-pending-subscribe.ts index 67a020b9d5e..f37c7b3dbf6 100644 --- a/src/main/ipc/parcel-watcher-pending-subscribe.ts +++ b/src/main/ipc/parcel-watcher-pending-subscribe.ts @@ -53,15 +53,13 @@ export function startInterruptedSubscribeTimeout( record: WatcherProcessSubscriptionRecord, cancel: (error: WatcherProcessFailure) => void ): void { - if ( - !record.interrupted || - record.pendingSubscribe || - record.resubscribeTimer || - record.hooks.subscribeTimeoutMs === undefined - ) { + if (!record.interrupted || record.pendingSubscribe) { return } record.crawlStarted = true + if (record.resubscribeTimer || record.hooks.subscribeTimeoutMs === undefined) { + return + } record.resubscribeTimer = setTimeout(() => { cancel( new WatcherProcessFailure( diff --git a/src/main/ipc/parcel-watcher-process-entry.test.ts b/src/main/ipc/parcel-watcher-process-entry.test.ts index 19740f17fff..62b264f47b3 100644 --- a/src/main/ipc/parcel-watcher-process-entry.test.ts +++ b/src/main/ipc/parcel-watcher-process-entry.test.ts @@ -259,6 +259,29 @@ describe('parcel watcher process canary', () => { expect(sendMock).not.toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 }) }) + it('reports native unsubscribe rejection without acknowledging handle release', async () => { + const unsubscribeError = new Error('native handle still active') + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockResolvedValueOnce({ unsubscribe: vi.fn().mockRejectedValue(unsubscribeError) }) + 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) + process.emit('message', { op: 'unsubscribe', id: 1 }) + await vi.advanceTimersByTimeAsync(0) + + expect(sendMock).toHaveBeenCalledWith({ + op: 'unsubscribe-failed', + id: 1, + message: 'native handle still active' + }) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'unsubscribed', 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( diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts index 1e043739a4a..4bfaefdc930 100644 --- a/src/main/ipc/parcel-watcher-process-entry.ts +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -249,9 +249,10 @@ function main(): void { await subscription?.unsubscribe() }) } catch (err) { - process.stderr.write( - `[parcel-watcher-process] unsubscribe ${id} failed: ${errorMessage(err)}\n` - ) + const message = errorMessage(err) + process.stderr.write(`[parcel-watcher-process] unsubscribe ${id} failed: ${message}\n`) + send({ op: 'unsubscribe-failed', id, message }) + return } finally { subscriptionActivityRevision++ } diff --git a/src/main/ipc/parcel-watcher-process-failure.ts b/src/main/ipc/parcel-watcher-process-failure.ts index 80eb79ddd33..336cc69caa4 100644 --- a/src/main/ipc/parcel-watcher-process-failure.ts +++ b/src/main/ipc/parcel-watcher-process-failure.ts @@ -13,7 +13,8 @@ export class WatcherProcessFailure extends Error { constructor( message: string, readonly scope: WatcherProcessFailureScope, - readonly code: WatcherProcessFailureCode + readonly code: WatcherProcessFailureCode, + readonly physicalExit?: Promise ) { super(message) this.name = 'WatcherProcessFailure' diff --git a/src/main/ipc/parcel-watcher-process-protocol.ts b/src/main/ipc/parcel-watcher-process-protocol.ts index 17dbcc36a88..05fb6247b0f 100644 --- a/src/main/ipc/parcel-watcher-process-protocol.ts +++ b/src/main/ipc/parcel-watcher-process-protocol.ts @@ -34,4 +34,5 @@ export type WatcherToHostMessage = | { op: 'overflow'; id: number } | { op: 'watch-error'; id: number; message: string } | { op: 'cancel-requires-restart'; id: number } + | { op: 'unsubscribe-failed'; id: number; message: string } | { op: 'unsubscribed'; id: number } diff --git a/src/main/ipc/parcel-watcher-process-supervisor.ts b/src/main/ipc/parcel-watcher-process-supervisor.ts index 9be9b232b08..3f0a534c198 100644 --- a/src/main/ipc/parcel-watcher-process-supervisor.ts +++ b/src/main/ipc/parcel-watcher-process-supervisor.ts @@ -1,26 +1,28 @@ import type { ChildProcess } from 'node:child_process' import { existsSync } from 'node:fs' +import { restartCancelledWatcherChild } from './parcel-watcher-cancellation-restart' +import { WatcherCancellationTracker } from './parcel-watcher-cancellation-tracker' import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path' import { removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory' +import * as termination from './parcel-watcher-child-termination' import { launchWatcherChild } from './parcel-watcher-child-launch' -import { WatcherProcessCrashFuse } from './parcel-watcher-crash-fuse' +import { sendToWatcherChild } from './parcel-watcher-child-messaging' import { - disposeWatcherSupervisorSubscriptions, - failAllWatcherSubscriptions, - handleWatcherHostMessage, + recoverWatcherRecordsAfterChildGone, + terminateDisconnectedWatcherChild +} from './parcel-watcher-child-recovery' +import { resetWatcherChildRegistryForTest } from './parcel-watcher-child-registry' +import { WatcherSupervisorCapacityWait } from './parcel-watcher-supervisor-capacity-wait' +import { WatcherProcessCrashFuse } from './parcel-watcher-crash-fuse' +import { cancelInterruptedWatcherSubscribe } from './parcel-watcher-interrupted-cancellation' +import { + type PendingWatcherUnsubscribe, 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 { cancelPendingWatcherSubscribe } from './parcel-watcher-pending-cancellation' import type { WatcherProcessFailure } from './parcel-watcher-process-failure' import type { - HostToWatcherMessage, WatcherProcessSubscribeOptions, WatcherToHostMessage } from './parcel-watcher-process-protocol' @@ -31,14 +33,12 @@ import type { 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' +import { + sendWatcherSubscribe, + subscribeThroughWatcherSupervisor +} from './parcel-watcher-supervisor-subscribe' +import { disposeWatcherSupervisor } from './parcel-watcher-supervisor-disposal' +import { handleWatcherSupervisorMessage } from './parcel-watcher-supervisor-message' export class WatcherProcessSupervisor { private child: ChildProcess | null = null @@ -46,9 +46,12 @@ export class WatcherProcessSupervisor { private readonly crashFuse = new WatcherProcessCrashFuse() private shutdownRequested = false private canaryDir: string | null = null + private terminatingChild: ChildProcess | null = null + private readonly terminationQueue = new termination.WatcherTerminationQueue() private readonly records = new Map() - private readonly pendingUnsubscribes = new Map void>() - private readonly cancelledSubscribesAwaitingChild = new Set() + private readonly pendingUnsubscribes = new Map() + private readonly cancelledSubscribes = new WatcherCancellationTracker() + private readonly capacityWait = new WatcherSupervisorCapacityWait() constructor(private readonly options: WatcherProcessSupervisorOptions = {}) {} @@ -58,51 +61,63 @@ export class WatcherProcessSupervisor { 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) - }) + const queued = this.terminationQueue.waitFor(() => this.subscribe(dir, callback, opts, hooks)) + if (queued) { + return queued + } + return this.capacityWait.run( + 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, + getTerminationPromise: () => this.terminationQueue.getCurrent(), + killWatcherChildIfIdle: () => this.killWatcherChildIfIdle(), + terminateUnavailableChild: (child) => this.terminateUnavailableChild(child), + sendSubscribe: sendWatcherSubscribe, + sendToChild: sendToWatcherChild, + cancelPendingSubscribe: (record, error) => this.cancelPendingSubscribe(record, error) + }), + () => this.subscribe(dir, callback, opts, hooks), + hooks.signal + ) } dispose(): void { this.shutdownRequested = true + this.capacityWait.dispose() const proc = this.child this.child = null - const error = watcherHostFailure('file watcher supervisor disposed', 'supervisor_disposed') - disposeWatcherSupervisorSubscriptions( + this.canaryDir = disposeWatcherSupervisor( + proc, this.records, this.pendingUnsubscribes, - this.cancelledSubscribesAwaitingChild, - error + this.cancelledSubscribes, + this.canaryDir ) - proc?.kill() - this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) } resetForTest(): void { this.dispose() this.shutdownRequested = false + this.terminatingChild = null + this.terminationQueue.resetForTest() this.crashFuse.reset() + resetWatcherChildRegistryForTest() } private ensureWatcherProcess( entryPath = this.options.entryPath ?? getWatcherProcessEntryPath() ): ChildProcess | null { - if (this.shutdownRequested) { + if (this.shutdownRequested || this.terminatingChild) { return null } if (this.child?.connected) { @@ -135,43 +150,30 @@ export class WatcherProcessSupervisor { } 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() - ) + const child = this.child + handleWatcherSupervisorMessage(message, { + records: this.records, + pendingUnsubscribes: this.pendingUnsubscribes, + cancelledSubscribes: this.cancelledSubscribes, + child, + cancelPendingSubscribe: (record, error) => this.cancelPendingSubscribe(record, error), + cancelInterruptedSubscribe: (record, error) => + cancelInterruptedWatcherSubscribe({ + record, + error, + records: this.records, + reportTerminalError: reportWatcherTerminalError, + restartChild: () => this.restartAfterCancelledSubscribe(child) + }), + restartAfterCancelledSubscribe: (child) => this.restartAfterCancelledSubscribe(child), + terminateUnavailableChild: (child) => { + if (child) { + this.terminateUnavailableChild(child) + } + }, + killWatcherChildIfIdle: () => + termination.ignoreWatcherTermination(this.killWatcherChildIfIdle()) + }) } private handleChildGone( @@ -182,135 +184,131 @@ export class WatcherProcessSupervisor { if (this.child !== proc) { return } - if (code !== undefined && (code !== 0 || signal)) { - console.error( - `[parcel-watcher-process] watcher process exited (code=${code}, signal=${signal})` - ) + if (code === undefined) { + this.terminateUnavailableChild(proc) + return } this.child = null - this.cancelledSubscribesAwaitingChild.clear() + this.cancelledSubscribes.completeForChild(proc) 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)` + recoverWatcherRecordsAfterChildGone( + this.records, + this.crashFuse, + this.shutdownRequested, + () => this.ensureWatcherProcess(), + sendWatcherSubscribe, + () => { + this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) + }, + code, + signal ) - 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 + private terminateUnavailableChild(requestedChild: ChildProcess | null): Promise { + const currentTermination = this.terminationQueue.getCurrent() + if (currentTermination) { + return currentTermination + } + const proc = requestedChild ?? this.terminatingChild + if (!proc) { + return Promise.resolve() } this.child = null - resolvePendingWatcherUnsubscribes(this.pendingUnsubscribes) - proc.kill() + this.terminatingChild = proc this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) + return this.terminationQueue.track( + terminateDisconnectedWatcherChild( + proc, + this.records, + this.pendingUnsubscribes, + this.cancelledSubscribes, + this.crashFuse, + (exited) => { + this.terminatingChild = null + if (!exited) { + this.shutdownRequested = true + } + return !this.shutdownRequested + }, + () => this.ensureWatcherProcess(), + sendWatcherSubscribe, + () => { + this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) + } + ) + ) + } + + private killWatcherChildIfIdle(): Promise { + const terminationPromise = this.terminationQueue.getCurrent() + if (terminationPromise) { + return terminationPromise + } + const proc = this.child + if (!proc || this.records.size > 0) { + return Promise.resolve() + } + this.child = null + this.terminatingChild = proc + // Why: destructive Windows cleanup must await exit to release directory handles. + this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) + return this.terminationQueue.track( + termination.terminateIdleWatcherChild(proc, this.pendingUnsubscribes, () => { + // Why: an idle child owns zero records, so a missed exit deadline has no + // double-watch hazard; the child keeps its capacity reservation until + // physical exit, and poisoning this supervisor would permanently end + // local watching — the shared singleton has no retire-and-replace path. + this.terminatingChild = null + }) + ) } 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 + cancelPendingWatcherSubscribe({ + record, + error, + records: this.records, + child: this.child, + cancelledSubscribes: this.cancelledSubscribes, + onChildUnavailable: (child) => this.terminateUnavailableChild(child), + restartChild: (child) => this.restartAfterCancelledSubscribe(child), + sendCancel: (child, id) => sendToWatcherChild(child, { op: 'cancel-subscribe', id }) }) } - private sendToChild(proc: ChildProcess, message: HostToWatcherMessage): void { - try { - proc.send(message) - } catch { - // Channel already closed — the child's exit handler recovers. + private restartAfterCancelledSubscribe(proc: ChildProcess | null): Promise { + const activeTermination = this.terminationQueue.getCurrent() + if (activeTermination || !proc || !this.cancelledSubscribes.beginRestart(proc)) { + return activeTermination ?? Promise.resolve() } + if (this.child === proc) { + this.child = null + } + this.terminatingChild = proc + this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) + return this.terminationQueue.track( + restartCancelledWatcherChild( + proc, + this.records, + this.pendingUnsubscribes, + this.cancelledSubscribes, + (exited) => { + this.terminatingChild = null + if (!exited) { + this.shutdownRequested = true + } + return !this.shutdownRequested + }, + () => this.ensureWatcherProcess(), + sendWatcherSubscribe, + () => { + this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) + } + ) + ) } } diff --git a/src/main/ipc/parcel-watcher-process-test-child.ts b/src/main/ipc/parcel-watcher-process-test-child.ts new file mode 100644 index 00000000000..d4b8af4333f --- /dev/null +++ b/src/main/ipc/parcel-watcher-process-test-child.ts @@ -0,0 +1,46 @@ +import { EventEmitter } from 'node:events' +import { vi } from 'vitest' + +type SentMessage = { op: string; id: number; dir?: string } + +export class FakeWatcherChild extends EventEmitter { + connected = true + pid = 1234 + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + sent: SentMessage[] = [] + stderr = new EventEmitter() + kill = vi.fn(() => { + this.connected = false + }) + send = vi.fn((message: SentMessage) => { + this.sent.push(message) + return true + }) +} + +export function currentWatcherChild(forkMock: ReturnType): FakeWatcherChild { + const result = forkMock.mock.results.at(-1) + if (!result) { + throw new Error('fork was not called') + } + return result.value as FakeWatcherChild +} + +export function acknowledgeWatcherSubscribe(child: FakeWatcherChild, index = -1): number { + const message = child.sent.filter((candidate) => candidate.op === 'subscribe').at(index) + if (!message) { + throw new Error('no subscribe message sent') + } + child.emit('message', { op: 'subscribed', id: message.id }) + return message.id +} + +export function trackPromiseSettlement(promise: Promise): () => boolean { + let settled = false + void promise.then( + () => (settled = true), + () => (settled = true) + ) + return () => settled +} diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts index 1809ad484a7..805281bbea6 100644 --- a/src/main/ipc/parcel-watcher-process.test.ts +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -1,31 +1,35 @@ // Regression tests for issue #7547: local @parcel/watcher subscriptions run // in a forked watcher process, and a native watcher crash must be contained — // respawn, resubscribe, notify interruption — instead of killing the app. -import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WATCHER_PROCESS_CANCEL_TIMEOUT_MS } from './parcel-watcher-cancellation-tracker' +import { + terminateWatcherChild, + WATCHER_PROCESS_EXIT_DEADLINE_MS, + WATCHER_PROCESS_HARD_KILL_DELAY_MS +} from './parcel-watcher-child-termination' +import { + acknowledgeWatcherSubscribe as ackSubscribe, + currentWatcherChild, + FakeWatcherChild as FakeChild, + trackPromiseSettlement +} from './parcel-watcher-process-test-child' -const { - forkMock, - existsSyncMock, - mkdtempSyncMock, - parcelSubscribeMock, - rmSyncMock, - writeFileSyncMock -} = vi.hoisted(() => ({ - forkMock: vi.fn(), - existsSyncMock: vi.fn(), - mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-canary-supervisor-test'), - parcelSubscribeMock: vi.fn(), - rmSyncMock: vi.fn(), - writeFileSyncMock: vi.fn() -})) +const { forkMock, existsSyncMock, mkdtempSyncMock, parcelSubscribeMock, rmSyncMock } = vi.hoisted( + () => ({ + forkMock: vi.fn(), + existsSyncMock: vi.fn(), + mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-canary-supervisor-test'), + parcelSubscribeMock: vi.fn(), + rmSyncMock: vi.fn() + }) +) vi.mock('node:child_process', () => ({ fork: forkMock })) vi.mock('node:fs', () => ({ existsSync: existsSyncMock, mkdtempSync: mkdtempSyncMock, - rmSync: rmSyncMock, - writeFileSync: writeFileSyncMock + rmSync: rmSyncMock })) vi.mock('@parcel/watcher', () => ({ subscribe: parcelSubscribeMock })) @@ -38,38 +42,7 @@ import { subscribeViaWatcherProcess } from './parcel-watcher-process' -type SentMessage = { op: string; id: number; dir?: string } - -class FakeChild extends EventEmitter { - connected = true - pid = 1234 - sent: SentMessage[] = [] - stderr = new EventEmitter() - kill = vi.fn(() => { - this.connected = false - }) - send = vi.fn((message: SentMessage) => { - this.sent.push(message) - return true - }) -} - -function currentChild(): FakeChild { - const result = forkMock.mock.results.at(-1) - if (!result) { - throw new Error('fork was not called') - } - return result.value as FakeChild -} - -function ackSubscribe(child: FakeChild, index = -1): number { - const message = child.sent.filter((m) => m.op === 'subscribe').at(index) - if (!message) { - throw new Error('no subscribe message sent') - } - child.emit('message', { op: 'subscribed', id: message.id }) - return message.id -} +const currentChild = (): FakeChild => currentWatcherChild(forkMock) describe('subscribeViaWatcherProcess', () => { beforeEach(() => { @@ -89,6 +62,21 @@ describe('subscribeViaWatcherProcess', () => { vi.clearAllMocks() }) + it('accepts close without exit as physical child termination', async () => { + vi.useFakeTimers() + try { + const child = new FakeChild() + + const termination = terminateWatcherChild(child as never) + child.emit('close', -2, null) + + await expect(termination).resolves.toBe(true) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('subscribes through the watcher process and forwards events', async () => { const callback = vi.fn() const promise = subscribeViaWatcherProcess('/repo', callback, { ignore: ['**/.git'] }) @@ -102,19 +90,6 @@ 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, {}) @@ -126,6 +101,31 @@ describe('subscribeViaWatcherProcess', () => { expect(callback).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' }), []) }) + it('rejects and physically cancels a watcher error before subscription readiness', async () => { + const callback = vi.fn() + const promise = subscribeViaWatcherProcess('/repo', callback, {}) + const child = currentChild() + const id = child.sent[0].id + child.emit('message', { op: 'subscribe-started', id }) + + child.emit('message', { op: 'watch-error', id, message: 'root disappeared during crawl' }) + + let settled = false + void promise.catch(() => { + settled = true + }) + expect(child.sent.at(-1)).toEqual({ op: 'cancel-subscribe', id }) + expect(settled).toBe(false) + child.emit('message', { op: 'cancel-requires-restart', id }) + expect(settled).toBe(false) + expect(callback).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledTimes(1) + child.emit('exit', 0, null) + await expect(promise).rejects.toThrow('root disappeared during crawl') + child.emit('message', { op: 'subscribed', id }) + expect(forkMock).toHaveBeenCalledTimes(1) + }) + it('reports a terminal resubscribe failure separately from recoverable watch errors', async () => { const callback = vi.fn() const onTerminalError = vi.fn() @@ -227,6 +227,36 @@ describe('subscribeViaWatcherProcess', () => { expect(callback).toHaveBeenCalledWith(null, events) }) + it('rejects a replacement watch error before crash recovery is ready', async () => { + const onTerminalError = vi.fn() + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}, { onTerminalError }) + const first = currentChild() + const id = ackSubscribe(first) + await promise + + first.connected = false + first.emit('exit', null, 'SIGSEGV') + const replacement = currentChild() + replacement.emit('message', { op: 'subscribe-started', id }) + replacement.emit('message', { + op: 'watch-error', + id, + message: 'root disappeared during replacement crawl' + }) + + expect(replacement.kill).toHaveBeenCalledTimes(1) + replacement.emit('message', { op: 'subscribed', id }) + expect(onTerminalError).not.toHaveBeenCalled() + replacement.emit('exit', 0, null) + + await vi.waitFor(() => + expect(onTerminalError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'root disappeared during replacement crawl' }) + ) + ) + expect(forkMock).toHaveBeenCalledTimes(2) + }) + it('recovers existing records when IPC disconnects before child exit', async () => { const firstPromise = subscribeViaWatcherProcess('/existing', vi.fn(), {}) const first = currentChild() @@ -235,9 +265,12 @@ describe('subscribeViaWatcherProcess', () => { first.connected = false first.emit('disconnect') - const replacement = currentChild() const secondPromise = subscribeViaWatcherProcess('/new', vi.fn(), {}) + expect(forkMock).toHaveBeenCalledTimes(1) + first.emit('exit', 0, null) + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(2)) + const replacement = currentChild() expect(replacement).not.toBe(first) expect(replacement.sent.filter((message) => message.op === 'subscribe')).toEqual([ expect.objectContaining({ dir: '/existing' }), @@ -248,7 +281,7 @@ describe('subscribeViaWatcherProcess', () => { await secondPromise }) - it('spreads runtime roots across healthy children and contains a shard crash', async () => { + it('shares healthy runtime roots and recovers the shard after a crash', async () => { const firstInterruption = vi.fn() const secondInterruption = vi.fn() const firstPromise = subscribeViaRuntimeWatcherProcess( @@ -272,21 +305,21 @@ describe('subscribeViaWatcherProcess', () => { } ) const secondChild = currentChild() - expect(secondChild).not.toBe(firstChild) + expect(secondChild).toBe(firstChild) ackSubscribe(secondChild) await secondPromise - expect(forkMock).toHaveBeenCalledTimes(2) + expect(forkMock).toHaveBeenCalledTimes(1) 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(replacement.sent.filter((message) => message.op === 'subscribe')).toHaveLength(2) + ackSubscribe(replacement, 0) + ackSubscribe(replacement, 1) expect(firstInterruption).toHaveBeenCalledTimes(1) - expect(secondInterruption).not.toHaveBeenCalled() - expect(secondChild.connected).toBe(true) + expect(secondInterruption).toHaveBeenCalledTimes(1) }) it('completes an in-flight subscribe across a crash-respawn', async () => { @@ -330,12 +363,63 @@ describe('subscribeViaWatcherProcess', () => { const queuedId = child.sent.at(-1)?.id controller.abort() - await expect(pending).rejects.toThrow('aborted') + const isSettled = trackPromiseSettlement(pending) expect(child.kill).not.toHaveBeenCalled() expect(child.sent.at(-1)).toEqual({ op: 'cancel-subscribe', id: queuedId }) + expect(isSettled()).toBe(false) + child.emit('message', { op: 'unsubscribed', id: queuedId }) + await expect(pending).rejects.toThrow('aborted') expect(healthyInterruption).not.toHaveBeenCalled() }) + it('kills a shard when resolved-child cancellation teardown never acknowledges', async () => { + vi.useFakeTimers() + try { + 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( + '/resolved-but-unacked', + vi.fn(), + {}, + { + signal: controller.signal + } + ) + const pendingId = first.sent.at(-1)?.id + controller.abort() + + const isSettled = trackPromiseSettlement(pending) + expect(first.sent.at(-1)).toEqual({ op: 'cancel-subscribe', id: pendingId }) + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_CANCEL_TIMEOUT_MS) + + expect(first.kill).toHaveBeenCalledTimes(1) + expect(isSettled()).toBe(false) + expect(forkMock).toHaveBeenCalledTimes(1) + + first.emit('exit', 0, null) + await expect(pending).rejects.toThrow('aborted') + 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) + } finally { + vi.useRealTimers() + } + }) + it('cancels an active crawl and restores healthy roots when its owner aborts', async () => { const healthyInterruption = vi.fn() const healthyPromise = subscribeViaWatcherProcess( @@ -352,11 +436,18 @@ describe('subscribeViaWatcherProcess', () => { 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 }) + const pendingId = first.sent.at(-1)?.id + first.emit('message', { op: 'subscribe-started', id: pendingId }) controller.abort() - await expect(pending).rejects.toThrow('aborted') + const isSettled = trackPromiseSettlement(pending) + expect(first.sent.at(-1)).toEqual({ op: 'cancel-subscribe', id: pendingId }) + first.emit('message', { op: 'cancel-requires-restart', id: pendingId }) expect(first.kill).toHaveBeenCalledTimes(1) + expect(isSettled()).toBe(false) + expect(forkMock).toHaveBeenCalledTimes(1) + first.emit('exit', 0, null) + await expect(pending).rejects.toThrow('aborted') const replacement = currentChild() expect(replacement).not.toBe(first) expect(replacement.sent.filter((message) => message.op === 'subscribe')).toEqual([ @@ -366,6 +457,94 @@ describe('subscribeViaWatcherProcess', () => { expect(healthyInterruption).toHaveBeenCalledTimes(1) }) + it('keeps process count bounded when a killed child never exits', async () => { + vi.useFakeTimers() + try { + const healthyPromise = subscribeViaWatcherProcess('/healthy', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + await healthyPromise + + const controller = new AbortController() + const pending = subscribeViaWatcherProcess( + '/slow', + vi.fn(), + {}, + { + signal: controller.signal + } + ) + const pendingId = child.sent.at(-1)?.id + child.emit('message', { op: 'subscribe-started', id: pendingId }) + controller.abort() + const rejected = expect(pending).rejects.toThrow( + 'file watcher process did not exit after termination deadline' + ) + child.emit('message', { op: 'cancel-requires-restart', id: pendingId }) + + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_HARD_KILL_DELAY_MS) + expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + expect(forkMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync( + WATCHER_PROCESS_EXIT_DEADLINE_MS - WATCHER_PROCESS_HARD_KILL_DELAY_MS + ) + + await rejected + expect(forkMock).toHaveBeenCalledTimes(1) + expect(child.listenerCount('exit')).toBe(1) + expect(vi.getTimerCount()).toBe(0) + await expect(subscribeViaWatcherProcess('/later', vi.fn(), {})).rejects.toThrow('disposed') + } finally { + vi.useRealTimers() + } + }) + + it('queues subscribe and unsubscribe behind the retiring child', async () => { + const healthyPromise = subscribeViaWatcherProcess('/healthy', vi.fn(), {}) + const first = currentChild() + ackSubscribe(first) + const healthy = await healthyPromise + + const controller = new AbortController() + const cancelled = subscribeViaWatcherProcess( + '/slow', + vi.fn(), + {}, + { + signal: controller.signal + } + ) + const cancelledId = first.sent.at(-1)?.id + first.emit('message', { op: 'subscribe-started', id: cancelledId }) + controller.abort() + void cancelled.catch(() => undefined) + first.emit('message', { op: 'cancel-requires-restart', id: cancelledId }) + + let unsubscribeSettled = false + const unsubscribe = healthy.unsubscribe().then(() => { + unsubscribeSettled = true + }) + let queuedSettled = false + const queued = subscribeViaWatcherProcess('/queued-during-termination', vi.fn(), {}).then( + (subscription) => { + queuedSettled = true + return subscription + } + ) + await Promise.resolve() + expect(unsubscribeSettled).toBe(false) + expect(queuedSettled).toBe(false) + expect(forkMock).toHaveBeenCalledTimes(1) + + first.emit('exit', 0, null) + await expect(cancelled).rejects.toThrow('aborted') + await unsubscribe + const replacement = currentChild() + expect(replacement).not.toBe(first) + ackSubscribe(replacement) + await expect(queued).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + }) + it('bounds a pending crawl and restores healthy roots after setup timeout', async () => { vi.useFakeTimers() try { @@ -384,9 +563,13 @@ describe('subscribeViaWatcherProcess', () => { const pending = subscribeViaWatcherProcess('/slow', vi.fn(), {}, { subscribeTimeoutMs: 100 }) first.emit('message', { op: 'subscribe-started', id: first.sent.at(-1)?.id }) + const pendingId = first.sent.at(-1)?.id const timedOut = expect(pending).rejects.toThrow('timed out') await vi.advanceTimersByTimeAsync(100) + expect(first.sent.at(-1)).toEqual({ op: 'cancel-subscribe', id: pendingId }) + first.emit('message', { op: 'cancel-requires-restart', id: pendingId }) + first.emit('exit', 0, null) await timedOut const replacement = currentChild() expect(replacement).not.toBe(first) @@ -397,48 +580,6 @@ describe('subscribeViaWatcherProcess', () => { } }) - 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 { @@ -465,9 +606,12 @@ describe('subscribeViaWatcherProcess', () => { expect(settled).toBe(false) expect(child.kill).not.toHaveBeenCalled() - child.emit('message', { op: 'subscribe-started', id: child.sent.at(-1)?.id }) + const pendingId = child.sent.at(-1)?.id + child.emit('message', { op: 'subscribe-started', id: pendingId }) const timedOut = expect(pending).rejects.toThrow('timed out') await vi.advanceTimersByTimeAsync(100) + child.emit('message', { op: 'cancel-requires-restart', id: pendingId }) + child.emit('exit', 0, null) await timedOut } finally { vi.useRealTimers() @@ -518,8 +662,10 @@ describe('subscribeViaWatcherProcess', () => { ackSubscribe(first) const subscription = await promise - await expect(subscription.unsubscribe()).resolves.toBeUndefined() + const unsubscribe = subscription.unsubscribe() + const isSettled = trackPromiseSettlement(unsubscribe) expect(first.kill).toHaveBeenCalledTimes(1) + expect(isSettled()).toBe(false) expect(rmSyncMock).toHaveBeenCalledWith('/tmp/orca-watcher-canary-supervisor-test', { recursive: true, force: true @@ -529,6 +675,7 @@ describe('subscribeViaWatcherProcess', () => { // and no crash-fuse advance when the killed child's exit event lands. first.connected = false first.emit('exit', null, 'SIGTERM') + await expect(unsubscribe).resolves.toBeUndefined() expect(forkMock).toHaveBeenCalledTimes(1) const respawnPromise = subscribeViaWatcherProcess('/repo2', vi.fn(), {}) @@ -550,6 +697,8 @@ describe('subscribeViaWatcherProcess', () => { }) await expect(promise).rejects.toThrow('Error opening directory') expect(first.kill).toHaveBeenCalledTimes(1) + first.emit('exit', 0, null) + await new Promise((resolve) => setImmediate(resolve)) const respawnPromise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) expect(forkMock).toHaveBeenCalledTimes(2) @@ -582,21 +731,38 @@ describe('subscribeViaWatcherProcess', () => { expect(child.kill).not.toHaveBeenCalled() expect(child.sent.at(-1)).toMatchObject({ op: 'unsubscribe' }) - // The last unsubscribe kills the idle child, which also completes the - // still-pending unsubscribe ack from the first record. + // The last unsubscribe kills the idle child; both callers wait for its + // physical exit because either may gate destructive worktree cleanup. const secondUnsub = second.unsubscribe() expect(child.kill).toHaveBeenCalledTimes(1) + child.emit('exit', 0, null) await expect(firstUnsub).resolves.toBeUndefined() await expect(secondUnsub).resolves.toBeUndefined() }) - it('uses the in-process watcher under vitest', async () => { + it('disposes production runtime watcher children during app shutdown', async () => { + const pending = subscribeViaRuntimeWatcherProcess('/runtime-root', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + await pending + + disposeWatcherProcess() + + expect(child.kill).toHaveBeenCalledTimes(1) + await expect( + subscribeViaRuntimeWatcherProcess('/runtime-root', vi.fn(), {}) + ).rejects.toMatchObject({ code: 'supervisor_disposed' }) + expect(forkMock).toHaveBeenCalledTimes(1) + }) + + it('keeps the runtime pool reusable with the in-process watcher under vitest', async () => { vi.stubEnv('VITEST', 'true') + disposeWatcherProcess() const unsubscribe = vi.fn().mockResolvedValue(undefined) parcelSubscribeMock.mockResolvedValue({ unsubscribe }) const callback = vi.fn() - const subscription = await subscribeViaWatcherProcess('/repo', callback, { + const subscription = await subscribeViaRuntimeWatcherProcess('/repo', callback, { ignore: ['**/.git'] }) expect(forkMock).not.toHaveBeenCalled() @@ -638,6 +804,26 @@ describe('subscribeViaWatcherProcess', () => { await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1)) }) + it('contains rejection while releasing a late in-process subscription', async () => { + vi.stubEnv('VITEST', 'true') + const unsubscribe = vi.fn().mockRejectedValue(new Error('late unsubscribe failed')) + 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) diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts index 2d6b45ee97f..978edb21c6b 100644 --- a/src/main/ipc/parcel-watcher-process.ts +++ b/src/main/ipc/parcel-watcher-process.ts @@ -1,19 +1,19 @@ // 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 { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor' import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol' +import type { + WatcherProcessCallback, + WatcherProcessHooks, + WatcherProcessSubscription +} from './parcel-watcher-process-subscription' import { RuntimeWatcherProcessPool } from './runtime-watcher-process-pool' export type { WatcherProcessCallback, WatcherProcessHooks, WatcherProcessSubscription -} from './parcel-watcher-process-supervisor' +} from './parcel-watcher-process-subscription' export type { WatcherProcessDeliveryOptions, WatcherProcessEvent, @@ -21,8 +21,11 @@ export type { } from './parcel-watcher-process-protocol' 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. +// Why: the canary pauses during legitimate crawls, so production callers need +// a separate liveness fuse once a child reports that it owns native setup. +export const WATCHER_PROCESS_SUBSCRIBE_TIMEOUT_MS = 60_000 +// Why: healthy roots share one child; only fault quarantine scales to four, +// containing a failed shard without paying that RSS cost during normal use. const runtimeWatcherProcessPool = new RuntimeWatcherProcessPool() export function createWatcherProcessSupervisor(): WatcherProcessSupervisor { diff --git a/src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts b/src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts new file mode 100644 index 00000000000..4f0845ebd26 --- /dev/null +++ b/src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts @@ -0,0 +1,35 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + MAX_PHYSICAL_WATCHER_CHILDREN, + reserveWatcherChild, + resetWatcherChildRegistryForTest +} from './parcel-watcher-child-registry' +import { WatcherSupervisorCapacityWait } from './parcel-watcher-supervisor-capacity-wait' + +describe('WatcherSupervisorCapacityWait', () => { + beforeEach(() => resetWatcherChildRegistryForTest()) + afterEach(() => resetWatcherChildRegistryForTest()) + + it('removes ten thousand cancelled capacity callers while one anchor remains', async () => { + const releases = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => + reserveWatcherChild() + ) + expect(releases.every(Boolean)).toBe(true) + const capacity = new WatcherSupervisorCapacityWait() + const anchor = capacity.wait() + const controllers = Array.from({ length: 10_000 }, () => new AbortController()) + const cancelled = controllers.map((controller) => + capacity.wait(controller.signal).catch((error) => error) + ) + + for (const controller of controllers) { + controller.abort() + } + await Promise.all(cancelled) + + expect(capacity.waiterCount).toBe(1) + releases[0]?.() + await expect(anchor).resolves.toBeUndefined() + expect(capacity.waiterCount).toBe(0) + }) +}) diff --git a/src/main/ipc/parcel-watcher-supervisor-capacity-wait.ts b/src/main/ipc/parcel-watcher-supervisor-capacity-wait.ts new file mode 100644 index 00000000000..bda018da6b0 --- /dev/null +++ b/src/main/ipc/parcel-watcher-supervisor-capacity-wait.ts @@ -0,0 +1,111 @@ +import { + onWatcherChildCapacityAvailable, + WatcherChildCapacityError +} from './parcel-watcher-child-registry' +import { watcherHostFailure, WatcherProcessFailure } from './parcel-watcher-process-failure' +import { PromiseSettlementWaiters } from '../../shared/promise-settlement-waiters' + +type CapacityWaitState = { + waiters: PromiseSettlementWaiters + resolve: () => void + reject: (error: Error) => void + cancel: () => void +} + +export class WatcherSupervisorCapacityWait { + private state: CapacityWaitState | null = null + private lifecycleGeneration = 0 + + get waiterCount(): number { + return this.state?.waiters.waiterCount ?? 0 + } + + run(operation: Promise, retry: () => Promise, signal?: AbortSignal): Promise { + const lifecycleGeneration = this.lifecycleGeneration + return operation.catch(async (error: unknown) => { + if (!(error instanceof WatcherChildCapacityError)) { + throw error + } + if (lifecycleGeneration !== this.lifecycleGeneration) { + throw watcherHostFailure('file watcher supervisor disposed', 'supervisor_disposed') + } + await this.wait(signal) + if (lifecycleGeneration !== this.lifecycleGeneration) { + throw watcherHostFailure('file watcher supervisor disposed', 'supervisor_disposed') + } + // Why: subscribe() wraps each retry in a fresh capacity wait, so a slot + // reclaimed by crash recovery remains pending without polling or failure. + return retry() + }) + } + + wait(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.reject(createCapacityAbortError()) + } + const state = this.state ?? this.createState() + return state.waiters.wait({ + signal, + createAbortError: createCapacityAbortError, + onAbandon: () => { + if (state.waiters.waiterCount === 0 && this.state === state) { + // Why: one cancelled root must not strand a shared listener after all + // same-supervisor capacity waiters have left. + this.state = null + state.cancel() + state.reject(createCapacityAbortError()) + } + } + }) + } + + dispose(): void { + this.lifecycleGeneration++ + const state = this.state + if (!state) { + return + } + this.state = null + state.cancel() + state.reject( + new WatcherProcessFailure( + 'file watcher supervisor disposed', + 'supervisor', + 'supervisor_disposed' + ) + ) + } + + private createState(): CapacityWaitState { + let resolve = (): void => undefined + let reject = (_error: Error): void => undefined + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve + reject = nextReject + }) + const state: CapacityWaitState = { + waiters: new PromiseSettlementWaiters(promise), + resolve, + reject, + cancel: () => undefined + } + this.state = state + // Why: one physical slot launches the shared supervisor child; every root + // awaiting this barrier can then subscribe through that same child. + state.cancel = onWatcherChildCapacityAvailable(() => { + if (this.state === state) { + this.state = null + } + state.resolve() + }) + return state + } +} + +function createCapacityAbortError(): WatcherProcessFailure { + return new WatcherProcessFailure( + 'file watcher subscription aborted', + 'subscription', + 'subscribe_aborted' + ) +} diff --git a/src/main/ipc/parcel-watcher-supervisor-capacity.test.ts b/src/main/ipc/parcel-watcher-supervisor-capacity.test.ts new file mode 100644 index 00000000000..e1a26c03b2a --- /dev/null +++ b/src/main/ipc/parcel-watcher-supervisor-capacity.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WATCHER_PROCESS_EXIT_DEADLINE_MS } from './parcel-watcher-child-termination' +import { + MAX_PHYSICAL_WATCHER_CHILDREN, + resetWatcherChildRegistryForTest +} from './parcel-watcher-child-registry' +import { + acknowledgeWatcherSubscribe, + currentWatcherChild, + FakeWatcherChild, + trackPromiseSettlement +} from './parcel-watcher-process-test-child' +import type { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor' + +const { existsSyncMock, forkMock, mkdtempSyncMock, rmSyncMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + forkMock: vi.fn(), + mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-capacity-supervisor-test'), + rmSyncMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ fork: forkMock })) +vi.mock('node:fs', () => ({ + existsSync: existsSyncMock, + mkdtempSync: mkdtempSyncMock, + rmSync: rmSyncMock +})) + +import { createWatcherProcessSupervisor } from './parcel-watcher-process' + +const supervisors: WatcherProcessSupervisor[] = [] + +function createSupervisor(): WatcherProcessSupervisor { + const supervisor = createWatcherProcessSupervisor() + supervisors.push(supervisor) + return supervisor +} + +function currentChild(): FakeWatcherChild { + return currentWatcherChild(forkMock) +} + +describe('WatcherProcessSupervisor capacity wait', () => { + beforeEach(() => { + resetWatcherChildRegistryForTest() + vi.stubEnv('VITEST', '') + existsSyncMock.mockReturnValue(true) + forkMock.mockImplementation(() => new FakeWatcherChild()) + }) + + afterEach(() => { + for (const supervisor of supervisors.splice(0)) { + supervisor.dispose() + } + resetWatcherChildRegistryForTest() + vi.unstubAllEnvs() + vi.clearAllMocks() + vi.useRealTimers() + }) + + it('shares one released slot across same-supervisor capacity waiters', async () => { + vi.useFakeTimers() + const pending: Promise[] = [] + const children: FakeWatcherChild[] = [] + for (let index = 0; index < MAX_PHYSICAL_WATCHER_CHILDREN; index++) { + const supervisor = createSupervisor() + const controller = new AbortController() + const subscription = supervisor.subscribe( + `/stuck-${index}`, + vi.fn(), + {}, + { + signal: controller.signal + } + ) + pending.push(subscription.catch((error) => error)) + const child = currentChild() + children.push(child) + const id = child.sent.at(-1)?.id + child.emit('message', { op: 'subscribe-started', id }) + controller.abort() + child.emit('message', { op: 'cancel-requires-restart', id }) + } + expect(forkMock).toHaveBeenCalledTimes(MAX_PHYSICAL_WATCHER_CHILDREN) + + const extraSupervisor = createSupervisor() + const firstExtra = extraSupervisor.subscribe('/over-cap-a', vi.fn(), {}) + const secondExtra = extraSupervisor.subscribe('/over-cap-b', vi.fn(), {}) + const firstSettled = trackPromiseSettlement(firstExtra) + const secondSettled = trackPromiseSettlement(secondExtra) + await Promise.resolve() + expect(firstSettled()).toBe(false) + expect(secondSettled()).toBe(false) + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) + await Promise.all(pending) + + expect(forkMock).toHaveBeenCalledTimes(MAX_PHYSICAL_WATCHER_CHILDREN) + expect(children.every((child) => child.listenerCount('exit') === 1)).toBe(true) + expect(vi.getTimerCount()).toBe(0) + + children[0].emit('exit', 0, null) + await vi.waitFor(() => + expect(forkMock).toHaveBeenCalledTimes(MAX_PHYSICAL_WATCHER_CHILDREN + 1) + ) + const replacement = currentChild() + const subscribeMessages = replacement.sent.filter(({ op }) => op === 'subscribe') + expect(subscribeMessages.map(({ dir }) => dir)).toEqual(['/over-cap-a', '/over-cap-b']) + acknowledgeWatcherSubscribe(replacement, 0) + acknowledgeWatcherSubscribe(replacement, 1) + await expect(Promise.all([firstExtra, secondExtra])).resolves.toHaveLength(2) + + const blockedSupervisor = createSupervisor() + const firstController = new AbortController() + const secondController = new AbortController() + const firstBlocked = blockedSupervisor.subscribe( + '/cancelled-over-cap-a', + vi.fn(), + {}, + { + signal: firstController.signal + } + ) + const secondBlocked = blockedSupervisor.subscribe( + '/cancelled-over-cap-b', + vi.fn(), + {}, + { + signal: secondController.signal + } + ) + await Promise.resolve() + expect(forkMock).toHaveBeenCalledTimes(MAX_PHYSICAL_WATCHER_CHILDREN + 1) + firstController.abort() + await expect(firstBlocked).rejects.toMatchObject({ code: 'subscribe_aborted' }) + expect(trackPromiseSettlement(secondBlocked)()).toBe(false) + secondController.abort() + await expect(secondBlocked).rejects.toMatchObject({ code: 'subscribe_aborted' }) + + const racedDisposeSupervisor = createSupervisor() + const racedDisposeWait = racedDisposeSupervisor.subscribe('/raced-dispose', vi.fn(), {}) + racedDisposeSupervisor.dispose() + await expect(racedDisposeWait).rejects.toMatchObject({ code: 'supervisor_disposed' }) + + const activeDisposeSupervisor = createSupervisor() + const activeDisposeWait = activeDisposeSupervisor.subscribe('/active-dispose', vi.fn(), {}) + await Promise.resolve() + activeDisposeSupervisor.dispose() + await expect(activeDisposeWait).rejects.toMatchObject({ code: 'supervisor_disposed' }) + + children[1].emit('exit', 0, null) + await Promise.resolve() + await Promise.resolve() + expect(forkMock).toHaveBeenCalledTimes(MAX_PHYSICAL_WATCHER_CHILDREN + 1) + }) + + it('keeps waiting when crash recovery reclaims the announced slot', async () => { + const children: FakeWatcherChild[] = [] + for (let index = 0; index < MAX_PHYSICAL_WATCHER_CHILDREN; index++) { + const supervisor = createSupervisor() + const subscription = supervisor.subscribe(`/active-${index}`, vi.fn(), {}) + const child = currentChild() + children.push(child) + acknowledgeWatcherSubscribe(child) + await subscription + } + + const waitingSupervisor = createSupervisor() + const waiting = waitingSupervisor.subscribe('/waiting-root', vi.fn(), {}) + const waitingSettled = trackPromiseSettlement(waiting) + await Promise.resolve() + expect(waitingSettled()).toBe(false) + + children[0].connected = false + children[0].emit('exit', null, 'SIGSEGV') + await vi.waitFor(() => + expect(forkMock).toHaveBeenCalledTimes(MAX_PHYSICAL_WATCHER_CHILDREN + 1) + ) + await Promise.resolve() + await Promise.resolve() + expect(waitingSettled()).toBe(false) + + supervisors[1].dispose() + children[1].emit('exit', 0, null) + await vi.waitFor(() => + expect(forkMock).toHaveBeenCalledTimes(MAX_PHYSICAL_WATCHER_CHILDREN + 2) + ) + const waitingChild = currentChild() + expect(waitingChild.sent).toContainEqual(expect.objectContaining({ dir: '/waiting-root' })) + acknowledgeWatcherSubscribe(waitingChild) + await expect(waiting).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + }) +}) diff --git a/src/main/ipc/parcel-watcher-supervisor-disposal.ts b/src/main/ipc/parcel-watcher-supervisor-disposal.ts new file mode 100644 index 00000000000..42dc918e05b --- /dev/null +++ b/src/main/ipc/parcel-watcher-supervisor-disposal.ts @@ -0,0 +1,32 @@ +import type { ChildProcess } from 'node:child_process' +import { removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory' +import type { WatcherCancellationTracker } from './parcel-watcher-cancellation-tracker' +import { + type PendingWatcherUnsubscribe, + resolvePendingWatcherUnsubscribes +} from './parcel-watcher-host-subscriptions' +import { + resetPendingSubscribeAttempt, + takePendingSubscribe +} from './parcel-watcher-pending-subscribe' +import { watcherHostFailure } from './parcel-watcher-process-failure' +import type { WatcherProcessSubscriptionRecord } from './parcel-watcher-process-subscription' + +export function disposeWatcherSupervisor( + child: ChildProcess | null, + records: Map, + pendingUnsubscribes: Map, + cancelledSubscribes: WatcherCancellationTracker, + canaryDir: string | null +): null { + const error = watcherHostFailure('file watcher supervisor disposed', 'supervisor_disposed') + for (const record of records.values()) { + resetPendingSubscribeAttempt(record) + takePendingSubscribe(record)?.reject(error) + } + resolvePendingWatcherUnsubscribes(pendingUnsubscribes) + cancelledSubscribes.completeAll() + records.clear() + child?.kill() + return removeWatcherCanaryDirectory(canaryDir) +} diff --git a/src/main/ipc/parcel-watcher-supervisor-message.ts b/src/main/ipc/parcel-watcher-supervisor-message.ts new file mode 100644 index 00000000000..e5fbce4342f --- /dev/null +++ b/src/main/ipc/parcel-watcher-supervisor-message.ts @@ -0,0 +1,104 @@ +import type { ChildProcess } from 'node:child_process' +import type { WatcherCancellationTracker } from './parcel-watcher-cancellation-tracker' +import { + handleWatcherHostMessage, + type PendingWatcherUnsubscribe, + reportWatcherTerminalError +} from './parcel-watcher-host-subscriptions' +import { + startInterruptedSubscribeTimeout, + startPendingSubscribeTimeout +} from './parcel-watcher-pending-subscribe' +import { WatcherProcessFailure } from './parcel-watcher-process-failure' +import type { WatcherToHostMessage } from './parcel-watcher-process-protocol' +import type { WatcherProcessSubscriptionRecord } from './parcel-watcher-process-subscription' + +type WatcherSupervisorMessageContext = { + records: Map + pendingUnsubscribes: Map + cancelledSubscribes: WatcherCancellationTracker + child: ChildProcess | null + cancelPendingSubscribe: ( + record: WatcherProcessSubscriptionRecord, + error: WatcherProcessFailure + ) => void + cancelInterruptedSubscribe: ( + record: WatcherProcessSubscriptionRecord, + error: WatcherProcessFailure + ) => void + restartAfterCancelledSubscribe: (child: ChildProcess | null) => void + terminateUnavailableChild: (child: ChildProcess | null) => void + killWatcherChildIfIdle: () => void +} + +export function handleWatcherSupervisorMessage( + message: WatcherToHostMessage, + context: WatcherSupervisorMessageContext +): void { + const record = context.records.get(message.id) + if (message.op === 'subscribe-started') { + if (record) { + startPendingSubscribeTimeout(record, (error) => context.cancelPendingSubscribe(record, error)) + startInterruptedSubscribeTimeout(record, (error) => + context.cancelInterruptedSubscribe(record, error) + ) + } + return + } + if (message.op === 'watch-error' && record) { + const error = new WatcherProcessFailure(message.message, 'subscription', 'subscribe_failed') + if (record.pendingSubscribe) { + // Why: an error before readiness means native setup failed. Cancel the + // physical crawl so a later subscribed ack cannot expose a dead root. + context.cancelPendingSubscribe(record, error) + return + } + if (record.interrupted && record.crawlStarted) { + // Why: crash recovery has no pending caller promise, but its replacement + // crawl is equally unready and must not accept a later subscribed ack. + context.cancelInterruptedSubscribe(record, error) + return + } + } + if (message.op === 'watch-error') { + handleWatcherHostMessage( + message, + context.records, + context.pendingUnsubscribes, + reportWatcherTerminalError, + context.killWatcherChildIfIdle + ) + return + } + if (message.op === 'cancel-requires-restart') { + if (context.cancelledSubscribes.has(message.id)) { + context.restartAfterCancelledSubscribe(context.child) + } + return + } + if (message.op === 'unsubscribe-failed') { + context.terminateUnavailableChild(context.child) + return + } + if (message.op === 'unsubscribed') { + const completedCancellation = context.cancelledSubscribes.complete(message.id) + handleWatcherHostMessage( + message, + context.records, + context.pendingUnsubscribes, + reportWatcherTerminalError, + context.killWatcherChildIfIdle + ) + if (completedCancellation) { + context.killWatcherChildIfIdle() + } + return + } + handleWatcherHostMessage( + message, + context.records, + context.pendingUnsubscribes, + reportWatcherTerminalError, + context.killWatcherChildIfIdle + ) +} diff --git a/src/main/ipc/parcel-watcher-supervisor-subscribe.ts b/src/main/ipc/parcel-watcher-supervisor-subscribe.ts index abfa9ffe540..f508f9b117b 100644 --- a/src/main/ipc/parcel-watcher-supervisor-subscribe.ts +++ b/src/main/ipc/parcel-watcher-supervisor-subscribe.ts @@ -1,8 +1,13 @@ import type { ChildProcess } from 'node:child_process' import { existsSync } from 'node:fs' +import { sendToWatcherChild } from './parcel-watcher-child-messaging' import { createHostWatcherSubscription } from './parcel-watcher-host-subscriptions' +import type { PendingWatcherUnsubscribe } from './parcel-watcher-host-subscriptions' import { subscribeWithInProcessWatcher } from './parcel-watcher-in-process-fallback' -import { installPendingSubscribeControls } from './parcel-watcher-pending-subscribe' +import { + installPendingSubscribeControls, + resetPendingSubscribeAttempt +} from './parcel-watcher-pending-subscribe' import { WatcherProcessFailure } from './parcel-watcher-process-failure' import type { HostToWatcherMessage, @@ -25,10 +30,12 @@ type WatcherSupervisorSubscribeOptions = { useInProcessVitestFallback: boolean allocateId: () => number records: Map - pendingUnsubscribes: Map void> + pendingUnsubscribes: Map ensureWatcherProcess: (entryPath: string) => ChildProcess | null getChild: () => ChildProcess | null - killWatcherChildIfIdle: () => void + getTerminationPromise: () => Promise | null + killWatcherChildIfIdle: () => Promise + terminateUnavailableChild: (child: ChildProcess | null) => Promise sendSubscribe: (child: ChildProcess, record: WatcherProcessSubscriptionRecord) => void sendToChild: (child: ChildProcess, message: HostToWatcherMessage) => void cancelPendingSubscribe: ( @@ -37,6 +44,20 @@ type WatcherSupervisorSubscribeOptions = { ) => void } +export function sendWatcherSubscribe( + child: ChildProcess, + record: WatcherProcessSubscriptionRecord +): void { + resetPendingSubscribeAttempt(record) + sendToWatcherChild(child, { + op: 'subscribe', + id: record.id, + dir: record.dir, + opts: record.opts, + delivery: record.hooks.delivery + }) +} + export function subscribeThroughWatcherSupervisor({ dir, callback, @@ -50,7 +71,9 @@ export function subscribeThroughWatcherSupervisor({ pendingUnsubscribes, ensureWatcherProcess, getChild, + getTerminationPromise, killWatcherChildIfIdle, + terminateUnavailableChild, sendSubscribe, sendToChild, cancelPendingSubscribe @@ -117,7 +140,9 @@ export function subscribeThroughWatcherSupervisor({ records, pendingUnsubscribes, getChild, + getTerminationPromise, killWatcherChildIfIdle, + terminateUnavailableChild, sendToChild }) ), diff --git a/src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts b/src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts new file mode 100644 index 00000000000..2aad68d54b7 --- /dev/null +++ b/src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WATCHER_PROCESS_EXIT_DEADLINE_MS } from './parcel-watcher-child-termination' +import { WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS } from './parcel-watcher-host-subscriptions' +import { + acknowledgeWatcherSubscribe as ackSubscribe, + currentWatcherChild, + FakeWatcherChild, + trackPromiseSettlement +} from './parcel-watcher-process-test-child' + +const { existsSyncMock, forkMock, mkdtempSyncMock, rmSyncMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + forkMock: vi.fn(), + mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-unsubscribe-timeout-test'), + rmSyncMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ fork: forkMock })) +vi.mock('node:fs', () => ({ + existsSync: existsSyncMock, + mkdtempSync: mkdtempSyncMock, + rmSync: rmSyncMock +})) + +import { + disposeWatcherProcess, + resetWatcherProcessForTest, + subscribeViaWatcherProcess +} from './parcel-watcher-process' + +const currentChild = (): FakeWatcherChild => currentWatcherChild(forkMock) + +describe('watcher native unsubscribe timeout', () => { + beforeEach(() => { + resetWatcherProcessForTest() + vi.stubEnv('VITEST', '') + existsSyncMock.mockReturnValue(true) + forkMock.mockImplementation(() => new FakeWatcherChild()) + }) + + afterEach(() => { + disposeWatcherProcess() + vi.unstubAllEnvs() + vi.clearAllMocks() + }) + + it('restarts the shard and restores a healthy sibling', async () => { + vi.useFakeTimers() + try { + const firstPromise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const first = currentChild() + ackSubscribe(first, 0) + const firstSubscription = await firstPromise + const siblingInterruption = vi.fn() + const siblingPromise = subscribeViaWatcherProcess( + '/sibling', + vi.fn(), + {}, + { onInterruption: siblingInterruption } + ) + ackSubscribe(first) + await siblingPromise + + const unsubscribe = firstSubscription.unsubscribe() + const isSettled = trackPromiseSettlement(unsubscribe) + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS) + + expect(first.kill).toHaveBeenCalledTimes(1) + expect(isSettled()).toBe(false) + first.emit('exit', 0, null) + await expect(unsubscribe).resolves.toBeUndefined() + + const replacement = currentChild() + expect(replacement).not.toBe(first) + expect(replacement.sent.filter((message) => message.op === 'subscribe')).toEqual([ + expect.objectContaining({ dir: '/sibling' }) + ]) + ackSubscribe(replacement) + expect(siblingInterruption).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('spawns a fresh child after the idle kill misses the exit deadline', async () => { + vi.useFakeTimers() + try { + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const first = currentChild() + ackSubscribe(first, 0) + const subscription = await promise + + const unsubscribe = subscription.unsubscribe().catch((error: unknown) => error) + expect(first.kill).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) + expect(first.kill).toHaveBeenLastCalledWith('SIGKILL') + // Why: removal callers still fail closed on the unproven exit. + const error = (await unsubscribe) as Error & { physicalExit?: Promise } + expect(error).toMatchObject({ + message: 'file watcher process did not exit after termination deadline', + physicalExit: expect.any(Promise) + }) + + // Why: the idle child owned zero records, so its zombie must not poison + // the shared supervisor — the next subscribe gets a fresh child. + const respawnPromise = subscribeViaWatcherProcess('/repo2', vi.fn(), {}) + expect(forkMock).toHaveBeenCalledTimes(2) + const second = currentChild() + expect(second).not.toBe(first) + expect(second.sent[0]).toMatchObject({ op: 'subscribe', dir: '/repo2' }) + ackSubscribe(second) + await expect(respawnPromise).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + + first.emit('exit', null, 'SIGKILL') + await error.physicalExit + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('spawns a fresh child for a subscribe queued before an idle exit deadline miss', async () => { + vi.useFakeTimers() + try { + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const first = currentChild() + ackSubscribe(first, 0) + const subscription = await promise + + const unsubscribe = subscription.unsubscribe().catch((error: unknown) => error) + const queued = subscribeViaWatcherProcess('/repo2', vi.fn(), {}) + + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) + const error = (await unsubscribe) as Error & { physicalExit?: Promise } + expect(error).toMatchObject({ + message: 'file watcher process did not exit after termination deadline', + physicalExit: expect.any(Promise) + }) + + expect(forkMock).toHaveBeenCalledTimes(2) + const second = currentChild() + expect(second.sent[0]).toMatchObject({ op: 'subscribe', dir: '/repo2' }) + ackSubscribe(second) + await expect(queued).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + + first.emit('exit', null, 'SIGKILL') + await error.physicalExit + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('rejects only after an unkillable child reaches its exit deadline', async () => { + vi.useFakeTimers() + try { + const firstPromise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child, 0) + const firstSubscription = await firstPromise + const siblingPromise = subscribeViaWatcherProcess('/sibling', vi.fn(), {}) + ackSubscribe(child) + await siblingPromise + + const result = firstSubscription.unsubscribe().catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS) + const isSettled = trackPromiseSettlement(result) + expect(isSettled()).toBe(false) + + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) + const error = (await result) as Error & { physicalExit?: Promise } + expect(error).toMatchObject({ + message: 'file watcher process did not exit after termination deadline', + physicalExit: expect.any(Promise) + }) + expect(vi.getTimerCount()).toBe(0) + + child.emit('exit', 0, null) + await error.physicalExit + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 4acea214fb8..4f08581282b 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -205,6 +205,7 @@ import { unregisterSshPtyProvider, getLocalPtyProvider } from './pty' +import { _resetLocalPtyProviderStateForTest } from '../providers/local-pty-provider' import { _resetHiddenRendererPtyDeliveryGateForTest, isHiddenRendererPty @@ -221,6 +222,7 @@ import { SSH_SESSION_EXPIRED_ERROR } from '../providers/ssh-pty-provider' import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl' +import { acquireWatcherRemovalGate } from './watcher-removal-gate' const POWERSHELL_OSC133_ARGS = [ '-NoLogo', @@ -423,6 +425,7 @@ describe('registerPtyHandlers', () => { }) afterEach(() => { + _resetLocalPtyProviderStateForTest() _resetWslCachesForTests() vi.useRealTimers() // Why: sshProviders is module-level state; any id left registered leaks @@ -826,6 +829,19 @@ describe('registerPtyHandlers', () => { describe('spawn environment', () => { it('marks local Claude launches live until the PTY is killed', async () => { + let exitCb: ((info: { exitCode: number }) => void) | undefined + spawnMock.mockReturnValue({ + onData: vi.fn(() => makeDisposable()), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCb = cb + return makeDisposable() + }), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => exitCb?.({ exitCode: -1 })), + process: 'zsh', + pid: 12345 + }) const prepareClaudeAuth = vi.fn(async () => ({ configDir: '/tmp/claude', envPatch: {}, @@ -6085,6 +6101,7 @@ describe('registerPtyHandlers', () => { env?: Record }): Promise<{ id: string }> hasRendererSerializer?(ptyId: string): boolean + getRendererSerializerGeneration?(ptyId: string): number } let controller: RuntimeSpawnController | null = null const runtime = { @@ -6112,10 +6129,113 @@ describe('registerPtyHandlers', () => { worktreeId: 'wt-1', env: { ORCA_PANE_KEY: ` ${paneKey} ` } }) + const replacementGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, { + paneKey + })) as number expect(spawnController.hasRendererSerializer?.(result.id)).toBe(false) await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen }) + expect(spawnController.hasRendererSerializer?.(result.id)).toBe(false) + expect(spawnController.getRendererSerializerGeneration?.(result.id)).toBe(0) + await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: replacementGen }) expect(spawnController.hasRendererSerializer?.(result.id)).toBe(true) + expect(spawnController.getRendererSerializerGeneration?.(result.id)).toBe(1) + }) + + it('does not let old teardown cancel serializer settlement for a reused PTY id', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + getRendererSerializerGeneration?(ptyId: string): number + hasRendererSerializer?(ptyId: string): boolean + waitForRendererSerializer?( + ptyId: string, + afterGeneration: number, + timeoutMs?: number + ): Promise + } + const reusedPtyId = 'pty-reused' + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: reusedPtyId })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + preAllocateHandleForPty: vi.fn(() => null), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + const paneKey = makePaneKey('tab-reused', '33333333-3333-4333-8333-333333333333') + const spawnController = controller as unknown as RuntimeSpawnController + const spawn = async (): Promise => { + await spawnController.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + env: { ORCA_PANE_KEY: paneKey } + }) + } + + const firstGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, { + paneKey + })) as number + await spawn() + await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: firstGen }) + const priorGeneration = spawnController.getRendererSerializerGeneration?.(reusedPtyId) ?? 0 + + const secondGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, { + paneKey + })) as number + await spawn() + const ready = spawnController.waitForRendererSerializer?.(reusedPtyId, priorGeneration, 1_000) + clearProviderPtyState(reusedPtyId) + clearProviderPtyState(reusedPtyId) + await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: secondGen }) + + await expect(ready).resolves.toBe(true) + expect(spawnController.hasRendererSerializer?.(reusedPtyId)).toBe(true) + }) + + it('tracks exact remote-runtime serializer readiness without a local spawn mapping', async () => { + type RuntimeSpawnController = { + hasRendererSerializer?(ptyId: string): boolean + getRendererSerializerGeneration?(ptyId: string): number + } + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }) + } + + registerPtyHandlers(mainWindow as never, runtime as never) + const ptyId = 'remote:env-1@@terminal-1' + const spawnController = controller as unknown as RuntimeSpawnController + + expect(spawnController.hasRendererSerializer?.(ptyId)).toBe(false) + await handlers.get('pty:reportRendererSerializerReady')!(null, { ptyId: 'local-pty' }) + expect(spawnController.hasRendererSerializer?.('local-pty')).toBe(false) + await handlers.get('pty:reportRendererSerializerReady')!(null, { ptyId }) + expect(spawnController.hasRendererSerializer?.(ptyId)).toBe(true) + expect(spawnController.getRendererSerializerGeneration?.(ptyId)).toBe(1) }) it('clears pending pane serializer declarations when their renderer is destroyed', async () => { @@ -6690,6 +6810,85 @@ describe('registerPtyHandlers', () => { expect(options.cwd).toBe('/tmp/floating-notes') }) + it('rejects a renderer spawn while destructive worktree removal holds the gate', async () => { + const removal = acquireWatcherRemovalGate('/repo/app') + await removal.ready + registerPtyHandlers(mainWindow as never) + + try { + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + worktreeId: 'repo-1::/repo/app' + }) + ).rejects.toMatchObject({ code: 'terminal_removal_in_progress' }) + expect(spawnMock).not.toHaveBeenCalled() + } finally { + removal.release() + } + }) + + it('rejects a sibling-worktree terminal cwd inside a worktree being removed', async () => { + const removal = acquireWatcherRemovalGate('/repo/app') + await removal.ready + registerPtyHandlers(mainWindow as never) + + try { + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/repo/app/nested', + worktreeId: 'repo-1::/repo/sibling' + }) + ).rejects.toMatchObject({ code: 'terminal_removal_in_progress' }) + expect(spawnMock).not.toHaveBeenCalled() + } finally { + removal.release() + } + const siblingRemoval = acquireWatcherRemovalGate('/repo/sibling') + await siblingRemoval.ready + siblingRemoval.release() + }) + + it('rejects a runtime sibling-worktree cwd inside a removing worktree', async () => { + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + spawn(args: { + cols: number + rows: number + cwd?: string + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + } + const removal = acquireWatcherRemovalGate('/repo/app') + await removal.ready + + try { + await expect( + controller.spawn({ + cols: 80, + rows: 24, + cwd: '/repo/app/nested', + worktreeId: 'repo-1::/repo/sibling', + env: {} + }) + ).rejects.toMatchObject({ code: 'terminal_removal_in_progress' }) + expect(spawnMock).not.toHaveBeenCalled() + } finally { + removal.release() + } + }) + it('falls back to the worktree root when a saved local cwd no longer exists', async () => { registerPtyHandlers(mainWindow as never) // Why: issue #7239 reproduced in a Japanese-named worktree; the fallback @@ -10804,9 +11003,10 @@ describe('registerPtyHandlers', () => { expect(piClearPtyMock).toHaveBeenCalledWith(spawnResult.id) }) - it('disposes PTY listeners before manual kill IPC', async () => { + it('retains PTY listeners until physical exit after manual kill IPC', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() + let exitCb: ((info: { exitCode: number }) => void) | undefined // Why: hold a stable reference to the kill spy. On POSIX, destroyPtyProcess // in local-pty-provider reassigns proc.kill to a no-op to defuse the // SIGHUP-to-recycled-pid hazard (see docs/fix-pty-fd-leak.md). Reading @@ -10814,7 +11014,10 @@ describe('registerPtyHandlers', () => { const killSpy = vi.fn() const proc = { onData: vi.fn(() => onDataDisposable), - onExit: vi.fn(() => onExitDisposable), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCb = cb + return onExitDisposable + }), write: vi.fn(), resize: vi.fn(), kill: killSpy, @@ -10829,23 +11032,30 @@ describe('registerPtyHandlers', () => { rows: 24 })) as { id: string } - await handlers.get('pty:kill')!(null, { id: spawnResult.id }) + const killPromise = handlers.get('pty:kill')!(null, { id: spawnResult.id }) as Promise - expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killSpy.mock.invocationCallOrder[0] - ) - expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killSpy.mock.invocationCallOrder[0] - ) + expect(killSpy).toHaveBeenCalledTimes(1) + expect(onDataDisposable.dispose).not.toHaveBeenCalled() + expect(onExitDisposable.dispose).not.toHaveBeenCalled() + + exitCb?.({ exitCode: -1 }) + await killPromise + + expect(onDataDisposable.dispose).toHaveBeenCalledTimes(1) + expect(onExitDisposable.dispose).toHaveBeenCalledTimes(1) }) - it('disposes PTY listeners before runtime controller kill', async () => { + it('retains PTY listeners until physical exit after runtime controller kill', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() + let exitCb: ((info: { exitCode: number }) => void) | undefined const killSpy = vi.fn() const proc = { onData: vi.fn(() => onDataDisposable), - onExit: vi.fn(() => onExitDisposable), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCb = cb + return onExitDisposable + }), write: vi.fn(), resize: vi.fn(), kill: killSpy, @@ -10872,21 +11082,26 @@ describe('registerPtyHandlers', () => { } expect(runtimeController.kill(spawnResult.id)).toBe(true) - expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killSpy.mock.invocationCallOrder[0] - ) - expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killSpy.mock.invocationCallOrder[0] - ) + await vi.waitFor(() => expect(killSpy).toHaveBeenCalledTimes(1)) + expect(onDataDisposable.dispose).not.toHaveBeenCalled() + expect(onExitDisposable.dispose).not.toHaveBeenCalled() + + exitCb?.({ exitCode: -1 }) + await vi.waitFor(() => expect(onExitDisposable.dispose).toHaveBeenCalledTimes(1)) + expect(onDataDisposable.dispose).toHaveBeenCalledTimes(1) }) - it('disposes PTY listeners before did-finish-load orphan cleanup', async () => { + it('retains the PTY exit listener through did-finish-load orphan cleanup', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() + let exitCb: ((info: { exitCode: number }) => void) | undefined const killSpy = vi.fn() const proc = { onData: vi.fn(() => onDataDisposable), - onExit: vi.fn(() => onExitDisposable), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCb = cb + return onExitDisposable + }), write: vi.fn(), resize: vi.fn(), kill: killSpy, @@ -10925,9 +11140,10 @@ describe('registerPtyHandlers', () => { expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( killSpy.mock.invocationCallOrder[0] ) - expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( - killSpy.mock.invocationCallOrder[0] - ) + expect(onExitDisposable.dispose).not.toHaveBeenCalled() + + exitCb?.({ exitCode: -1 }) + expect(onExitDisposable.dispose).toHaveBeenCalledTimes(1) }) it('removes the previous orphan-cleanup listener from its original webContents', () => { @@ -11055,10 +11271,16 @@ describe('registerPtyHandlers', () => { // Why: guard against over-suppression — when no recovery reload is in flight the // sweep MUST still reclaim genuinely orphaned local PTYs. it('still sweeps orphaned local PTYs when no recovery reload is in flight', async () => { - const killSpy = vi.fn() + let exitCb: ((info: { exitCode: number }) => void) | undefined + const killSpy = vi.fn(() => { + queueMicrotask(() => exitCb?.({ exitCode: -1 })) + }) const proc = { onData: vi.fn(() => makeDisposable()), - onExit: vi.fn(() => makeDisposable()), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCb = cb + return makeDisposable() + }), write: vi.fn(), resize: vi.fn(), kill: killSpy, @@ -11099,6 +11321,7 @@ describe('registerPtyHandlers', () => { // prior-load orphan. With the flag false the guard must NOT suppress the sweep. didFinishLoad() didFinishLoad() + await Promise.resolve() expect(killSpy).toHaveBeenCalled() expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1) @@ -11171,10 +11394,14 @@ describe('registerPtyHandlers', () => { expect(ids).toContain(ptyB.id) }) - it('clears PTY state even when kill reports the process is already gone', async () => { + it('retains PTY state when kill fails until physical exit arrives', async () => { + let exitCb: ((info: { exitCode: number }) => void) | undefined const proc = { onData: vi.fn(() => makeDisposable()), - onExit: vi.fn(() => makeDisposable()), + onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { + exitCb = cb + return makeDisposable() + }), write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => { @@ -11191,9 +11418,21 @@ describe('registerPtyHandlers', () => { rows: 24 })) as { id: string } - await handlers.get('pty:kill')!(null, { id: spawnResult.id }) + await expect(handlers.get('pty:kill')!(null, { id: spawnResult.id })).rejects.toThrow( + 'already dead' + ) - expect(await handlers.get('pty:hasChildProcesses')!(null, { id: spawnResult.id })).toBe(false) + expect((await getLocalPtyProvider().listProcesses()).map(({ id }) => id)).toContain( + spawnResult.id + ) + expect(openCodeClearPtyMock).not.toHaveBeenCalled() + expect(piClearPtyMock).not.toHaveBeenCalled() + + exitCb?.({ exitCode: -1 }) + + expect((await getLocalPtyProvider().listProcesses()).map(({ id }) => id)).not.toContain( + spawnResult.id + ) expect(openCodeClearPtyMock).toHaveBeenCalledWith(spawnResult.id) expect(piClearPtyMock).toHaveBeenCalledWith(spawnResult.id) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 045a52009a8..d04a84a506e 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -19,6 +19,7 @@ export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-rea import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { Store } from '../persistence' import type { GlobalSettings, TuiAgent } from '../../shared/types' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' import type { PtyDeliveryWriteOff, @@ -96,6 +97,7 @@ import { } from '../../shared/terminal-input' import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure' +import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness' import { readShellStartupEnvVar } from '../pty/shell-startup-env' import { isTerminalLeafId, @@ -109,6 +111,7 @@ import { type TerminalStartupCwdMissingDirFallback } from '../../shared/terminal-startup-cwd' import { isWslUncPath } from '../../shared/wsl-paths' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { clearMigrationUnsupportedPty, clearMigrationUnsupportedPtysForPaneKey @@ -117,6 +120,7 @@ import { parseWslPath } from '../wsl' import { mergePersistedWindowsPath } from '../pty/windows-environment-path' import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' import { PtyProducerFlowController } from './pty-producer-flow-control' +import { beginTerminalInstall } from './watcher-removal-gate' import { clearHiddenRendererPtyDeliveryState, getHiddenRendererPtyDeliveryDebug, @@ -256,15 +260,13 @@ type PaneSpawnReservationResult = { // race to spawn the same tab/leaf. Key by stable paneKey so the loser adopts // the winner's PTY instead of creating a duplicate shell. const paneSpawnReservationsByPaneKey = new Map() -// Why: at PTY spawn time we capture the gen that was pending for the spawn's -// paneKey, so teardown can settle ONLY that gen. Without this, a paneKey -// remount that replaces the pending entry with a new gen would still get -// stomped by the old PTY's teardown firing settle on the wrong gen. -const ptyPendingGenByPtyId = new Map() +// Why: bind the declaration generation directly to its spawn result. PTY ids +// are reusable and teardown callbacks carry no incarnation token, so teardown +// must never guess which pending renderer generation it owns. +const pendingPtyIdBySerializerGeneration = new Map() // Why: the runtime's hasRendererSerializer probe needs a ptyId-keyed signal. -// Populated on settlePaneSerializer (renderer has registered for this ptyId) -// and cleared on PTY teardown. -const rendererSerializerByPtyId = new Set() +// A later spawn starts a fresh incarnation; subscription abort owns waiter cleanup. +const rendererSerializerReadiness = new RendererTerminalSerializerReadiness() function parseValidPaneKey(paneKey: unknown): ReturnType { if (typeof paneKey !== 'string' || paneKey.length > 256) { @@ -302,6 +304,7 @@ function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): voi for (const [paneKey, pending] of pendingByPaneKey) { if (pending.ownerWebContentsId === ownerWebContentsId) { pendingByPaneKey.delete(paneKey) + pendingPtyIdBySerializerGeneration.delete(pending.gen) } } } @@ -317,7 +320,15 @@ function registerPendingPaneSerializerCleanup(sender: WebContents | undefined): function declarePendingPaneSerializer(paneKey: string, sender: WebContents | undefined): number { const gen = ++pendingSerializerGenSeq registerPendingPaneSerializerCleanup(sender) + const replaced = pendingByPaneKey.get(paneKey) + if (replaced) { + pendingPtyIdBySerializerGeneration.delete(replaced.gen) + } pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null }) + const existingPtyId = paneKeyPtyId.get(paneKey) + if (existingPtyId) { + pendingPtyIdBySerializerGeneration.set(gen, existingPtyId) + } return gen } @@ -369,10 +380,12 @@ function resolvePaneSpawnReservation( return response } -function settlePendingPaneSerializer(paneKey: string, gen: number): void { - if (pendingByPaneKey.get(paneKey)?.gen === gen) { - pendingByPaneKey.delete(paneKey) +function settlePendingPaneSerializer(paneKey: string, gen: number): boolean { + if (pendingByPaneKey.get(paneKey)?.gen !== gen) { + return false } + pendingByPaneKey.delete(paneKey) + return true } export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean { @@ -1065,6 +1078,34 @@ function routesFreshSpawnsToLocalProvider( return (provider as FreshLocalFallbackProvider).routesFreshSpawnsToLocalProvider === true } +function beginPtySpawnForWorktree( + worktreeId: string | undefined, + cwd: string | undefined, + connectionId: string | null | undefined +): () => void { + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined + const installPaths = new Map() + for (const candidate of [worktreePath, cwd]) { + if (candidate) { + installPaths.set(normalizeRuntimePathForComparison(candidate), candidate) + } + } + const finishes: (() => void)[] = [] + try { + for (const candidate of installPaths.values()) { + finishes.push(beginTerminalInstall(candidate, connectionId ?? undefined)) + } + } catch (error) { + // Why: the worktree ID and actual cwd can belong to different roots. If + // either is deleting, release any earlier admission before rejecting. + finishes.toReversed().forEach((finish) => finish()) + throw error + } + return () => finishes.toReversed().forEach((finish) => finish()) +} + /** Register an SSH PTY provider for a connection. */ export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void { sshProviders.set(connectionId, provider) @@ -1180,7 +1221,6 @@ export function clearProviderPtyState(id: string): void { return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey) } }) - rendererSerializerByPtyId.delete(id) // Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would // otherwise accumulate entries for dead panes over the process lifetime. // Use the spawn-time paneKey mapping since the server has no other way to @@ -1191,17 +1231,6 @@ export function clearProviderPtyState(id: string): void { paneKeyPtyId.delete(paneKey) } ptyPaneKey.delete(id) - // Why: drop the pre-signal pending entry only if it still belongs to THIS - // PTY's spawn generation. If a remount for the same paneKey has already - // pre-signaled a new gen, this teardown must NOT touch it — otherwise - // the second mount's hydration loses to the daemon-snapshot seed. See - // the generation-token rationale in - // docs/mobile-prefer-renderer-scrollback.md. - const ownedGen = ptyPendingGenByPtyId.get(id) - if (ownedGen !== undefined) { - settlePendingPaneSerializer(paneKey, ownedGen) - } - ptyPendingGenByPtyId.delete(id) if (stillOwnsPaneKey) { // Why: notify registered consumers AFTER we've dropped the paneKey↔ptyId // entries so a listener that re-reads the map sees the post-teardown @@ -1522,6 +1551,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:declarePendingPaneSerializer') ipcMain.removeHandler('pty:settlePaneSerializer') ipcMain.removeHandler('pty:clearPendingPaneSerializer') + ipcMain.removeHandler('pty:reportRendererSerializerReady') ipcMain.removeHandler('pty:getMainBufferSnapshot') ipcMain.removeHandler('pty:sideEffectSnapshot') ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') @@ -2762,7 +2792,13 @@ export function registerPtyHandlers( // not stack listeners and trip Node's MaxListeners=10 warning. Many // sleeping PTYs waking at once (e.g. on relaunch) routinely fan out 10+ // concurrent calls. - type SerializeResult = { data: string; cols: number; rows: number; lastTitle?: string } | null + type SerializeResult = { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } | null const pendingSerializeRequests = new Map< string, { resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout } @@ -2788,6 +2824,7 @@ export function registerPtyHandlers( data?: unknown cols?: unknown rows?: unknown + seq?: unknown lastTitle?: unknown } | null } @@ -2802,11 +2839,20 @@ export function registerPtyHandlers( typeof snapshot.cols === 'number' && typeof snapshot.rows === 'number' ) { - const result: { data: string; cols: number; rows: number; lastTitle?: string } = { + const result: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } = { data: snapshot.data, cols: snapshot.cols, rows: snapshot.rows } + if (typeof snapshot.seq === 'number' && Number.isFinite(snapshot.seq)) { + result.seq = snapshot.seq + } if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { result.lastTitle = snapshot.lastTitle } @@ -2882,13 +2928,9 @@ export function registerPtyHandlers( if (options?.isRecoveryReloadInFlight?.(mainWindow.webContents.id)) { return } - const killed = lp.killOrphanedPtys(generation - 1) - for (const { id } of killed) { - clearProviderPtyState(id) - ptyOwnership.delete(id) - markClaudePtyExited(id) - runtime?.onPtyExit(id, -1) - } + // Why: the retained provider onExit callback is the only physical-exit + // proof; it clears ownership and notifies runtime after the OS reaps it. + lp.killOrphanedPtys(generation - 1) } didFinishLoadWebContents = mainWindow.webContents mainWindow.webContents.on('did-finish-load', didFinishLoadHandler) @@ -3138,6 +3180,11 @@ export function registerPtyHandlers( if (existingPaneSpawn) { return await existingPaneSpawn.promise } + const finishTerminalInstall = beginPtySpawnForWorktree( + args.worktreeId, + cwd, + args.connectionId + ) const paneSpawnReservation = materializedPaneKey ? reservePaneSpawn(materializedPaneKey) : null @@ -3303,6 +3350,15 @@ export function registerPtyHandlers( // so record their spawn-time paneKey here too. Synthetic hook titles and // paneKey-scoped cache cleanup both depend on this reverse lookup. const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY) + const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined + const inheritRendererReadiness = + result.isReattach === true && + !pendingSerializer && + rendererSerializerReadiness.has(result.id) + rendererSerializerReadiness.beginIncarnation(result.id, inheritRendererReadiness) + if (paneKey && pendingSerializer) { + pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, result.id) + } if (!args.connectionId) { registerPty({ ptyId: result.id, @@ -3326,6 +3382,8 @@ export function registerPtyHandlers( // no-op once the reservation has already resolved. rejectPaneSpawnReservation(materializedPaneKey, paneSpawnReservation, err) throw err + } finally { + finishTerminalInstall() } }, write: (ptyId, data) => { @@ -3514,7 +3572,13 @@ export function registerPtyHandlers( // seed (no renderer authoritative for this PTY). A registry write happens // when the renderer calls registerPtySerializer; we check via the same // pendingByPaneKey + ptyId pairing that the cooperation gate uses. - return rendererSerializerByPtyId.has(ptyId) + return rendererSerializerReadiness.has(ptyId) + }, + getRendererSerializerGeneration: (ptyId) => { + return rendererSerializerReadiness.generation(ptyId) + }, + waitForRendererSerializer: (ptyId, afterGeneration, timeoutMs, signal) => { + return rendererSerializerReadiness.wait(ptyId, afterGeneration, timeoutMs, signal) }, getSize: (ptyId) => ptySizes.get(ptyId) ?? null, resize: (ptyId, cols, rows) => { @@ -4049,6 +4113,11 @@ export function registerPtyHandlers( if (existingPaneSpawn) { return await existingPaneSpawn.promise } + const finishTerminalInstall = beginPtySpawnForWorktree( + args.worktreeId, + cwd, + args.connectionId + ) const paneSpawnReservation = reservationPaneKey ? reservePaneSpawn(reservationPaneKey) : null const initiallyHidden = args.initiallyHidden === true // Why pre-spawn for daemon-host sessions (id minted up front): daemon @@ -4278,14 +4347,18 @@ export function registerPtyHandlers( const rendererPreSignaled = validatedPaneKey ? pendingByPaneKey.has(validatedPaneKey) : false - const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id) + const rendererAlreadyRegistered = + result.isReattach === true && + !rendererPreSignaled && + rendererSerializerReadiness.has(result.id) + rendererSerializerReadiness.beginIncarnation(result.id, rendererAlreadyRegistered) // Why: capture the pending gen at spawn time so teardown for THIS PTY // only settles its own generation. A remount that replaces the entry // with a new gen must not be stomped by the old PTY's teardown. if (validatedPaneKey && rendererPreSignaled) { const pending = pendingByPaneKey.get(validatedPaneKey) if (pending) { - ptyPendingGenByPtyId.set(result.id, pending.gen) + pendingPtyIdBySerializerGeneration.set(pending.gen, result.id) } } @@ -4468,6 +4541,8 @@ export function registerPtyHandlers( // no-op once the reservation has already resolved. rejectPaneSpawnReservation(reservationPaneKey, paneSpawnReservation, err) throw err + } finally { + finishTerminalInstall() } } ) @@ -5231,15 +5306,13 @@ export function registerPtyHandlers( if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { return } - settlePendingPaneSerializer(args.paneKey, args.gen) - // Why: settle means the renderer has registered its serializer locally - // for whatever ptyId came back from spawn. The renderer doesn't carry - // the ptyId back through this IPC because the cooperation gate ran - // pre-spawn; instead we mark the pane as authoritative by paneKey → - // ptyId via the existing paneKeyPtyId mapping populated at spawn. - const ptyId = paneKeyPtyId.get(args.paneKey) - if (ptyId) { - rendererSerializerByPtyId.add(ptyId) + const ptyId = pendingPtyIdBySerializerGeneration.get(args.gen) + const settledCurrentGeneration = settlePendingPaneSerializer(args.paneKey, args.gen) + // Why: the generation-to-PTY binding survives late teardown of a reused + // id; paneKey reverse maps are provider lifecycle state and may already be gone. + pendingPtyIdBySerializerGeneration.delete(args.gen) + if (settledCurrentGeneration && ptyId) { + rendererSerializerReadiness.markReady(ptyId) } } ) @@ -5251,6 +5324,23 @@ export function registerPtyHandlers( return } settlePendingPaneSerializer(args.paneKey, args.gen) + pendingPtyIdBySerializerGeneration.delete(args.gen) + } + ) + + ipcMain.handle( + 'pty:reportRendererSerializerReady', + async (_event, args: { ptyId?: unknown }): Promise => { + if ( + typeof args.ptyId !== 'string' || + !args.ptyId.startsWith('remote:') || + args.ptyId.length > 512 + ) { + return + } + // Why: remote-runtime panes do not pass through the local spawn + // cooperation gate, so their exact PTY id is the only readiness key. + rendererSerializerReadiness.markReady(args.ptyId) } ) } diff --git a/src/main/ipc/renderer-terminal-serializer-readiness.test.ts b/src/main/ipc/renderer-terminal-serializer-readiness.test.ts new file mode 100644 index 00000000000..821aaa8f8aa --- /dev/null +++ b/src/main/ipc/renderer-terminal-serializer-readiness.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness' + +describe('RendererTerminalSerializerReadiness', () => { + it('resolves current and future waiters when the renderer settles', async () => { + const readiness = new RendererTerminalSerializerReadiness() + const pending = readiness.wait('pty-1', 0, 1_000) + + readiness.markReady('pty-1') + + await expect(pending).resolves.toBe(true) + expect(readiness.generation('pty-1')).toBe(1) + await expect(readiness.wait('pty-1', 0, 1_000)).resolves.toBe(true) + }) + + it('waits for a fresh settlement when historical readiness is stale', async () => { + const readiness = new RendererTerminalSerializerReadiness() + readiness.markReady('pty-1') + const pending = readiness.wait('pty-1', readiness.generation('pty-1'), 1_000) + + readiness.markReady('pty-1') + + await expect(pending).resolves.toBe(true) + expect(readiness.generation('pty-1')).toBe(2) + }) + + it('releases waiters when the PTY is cleared', async () => { + const readiness = new RendererTerminalSerializerReadiness() + readiness.markReady('pty-1') + const pending = readiness.wait('pty-1', readiness.generation('pty-1'), 1_000) + + readiness.clear('pty-1') + + await expect(pending).resolves.toBe(false) + expect(readiness.has('pty-1')).toBe(false) + }) + + it('releases waiters on abort and timeout', async () => { + vi.useFakeTimers() + const readiness = new RendererTerminalSerializerReadiness() + const abortController = new AbortController() + const aborted = readiness.wait('pty-abort', 0, 1_000, abortController.signal) + const timedOut = readiness.wait('pty-timeout', 0, 1_000) + + abortController.abort() + await vi.advanceTimersByTimeAsync(1_000) + + await expect(aborted).resolves.toBe(false) + await expect(timedOut).resolves.toBe(false) + vi.useRealTimers() + }) + + it('keeps a lifecycle waiter until readiness or abort when no timeout is supplied', async () => { + vi.useFakeTimers() + const readiness = new RendererTerminalSerializerReadiness() + const abortController = new AbortController() + const pending = readiness.wait('pty-lifecycle', 0, undefined, abortController.signal) + + await vi.advanceTimersByTimeAsync(60_000) + abortController.abort() + + await expect(pending).resolves.toBe(false) + vi.useRealTimers() + }) + + it('ignores late teardown from an older incarnation of a reused PTY id', async () => { + const readiness = new RendererTerminalSerializerReadiness() + const oldIncarnation = readiness.beginIncarnation('pty-reused') + readiness.markReady('pty-reused') + const priorGeneration = readiness.generation('pty-reused') + + readiness.beginIncarnation('pty-reused') + const pending = readiness.wait('pty-reused', priorGeneration, 1_000) + const oldTeardownCleared = readiness.clear('pty-reused', oldIncarnation) + readiness.markReady('pty-reused') + + expect(oldTeardownCleared).toBe(false) + await expect(pending).resolves.toBe(true) + expect(readiness.has('pty-reused')).toBe(true) + }) +}) diff --git a/src/main/ipc/renderer-terminal-serializer-readiness.ts b/src/main/ipc/renderer-terminal-serializer-readiness.ts new file mode 100644 index 00000000000..f58a9b2062f --- /dev/null +++ b/src/main/ipc/renderer-terminal-serializer-readiness.ts @@ -0,0 +1,129 @@ +type RendererTerminalSerializerWaiter = { + afterGeneration: number + finish: (ready: boolean) => void +} + +export class RendererTerminalSerializerReadiness { + private incarnationSequence = 0 + private readonly incarnationByPtyId = new Map() + private readonly readyIncarnationByPtyId = new Map() + private readonly generationByPtyId = new Map() + private readonly waitersByPtyId = new Map>() + + has(ptyId: string): boolean { + const incarnation = this.incarnationByPtyId.get(ptyId) + return incarnation !== undefined && this.readyIncarnationByPtyId.get(ptyId) === incarnation + } + + generation(ptyId: string): number { + return this.generationByPtyId.get(ptyId) ?? 0 + } + + beginIncarnation(ptyId: string, inheritReady = false): number { + const wasReady = this.has(ptyId) + const incarnation = ++this.incarnationSequence + this.incarnationByPtyId.set(ptyId, incarnation) + if (inheritReady && wasReady) { + this.readyIncarnationByPtyId.set(ptyId, incarnation) + } else { + this.readyIncarnationByPtyId.delete(ptyId) + } + return incarnation + } + + markReady(ptyId: string): void { + let incarnation = this.incarnationByPtyId.get(ptyId) + if (incarnation === undefined) { + incarnation = ++this.incarnationSequence + this.incarnationByPtyId.set(ptyId, incarnation) + } + this.readyIncarnationByPtyId.set(ptyId, incarnation) + const generation = this.generation(ptyId) + 1 + this.generationByPtyId.set(ptyId, generation) + const waiters = this.waitersByPtyId.get(ptyId) + if (!waiters) { + return + } + for (const waiter of waiters) { + if (generation > waiter.afterGeneration) { + waiter.finish(true) + } + } + } + + clear(ptyId: string, incarnation?: number): boolean { + if (incarnation !== undefined && this.incarnationByPtyId.get(ptyId) !== incarnation) { + return false + } + this.incarnationByPtyId.delete(ptyId) + this.readyIncarnationByPtyId.delete(ptyId) + this.generationByPtyId.delete(ptyId) + this.finishWaiters(ptyId, false) + return true + } + + private finishWaiters(ptyId: string, ready: boolean): void { + const waiters = this.waitersByPtyId.get(ptyId) + if (!waiters) { + return + } + for (const waiter of waiters) { + waiter.finish(ready) + } + } + + wait( + ptyId: string, + afterGeneration: number, + timeoutMs?: number, + signal?: AbortSignal + ): Promise { + if (this.generation(ptyId) > afterGeneration) { + return Promise.resolve(true) + } + if (signal?.aborted) { + return Promise.resolve(false) + } + + return new Promise((resolve) => { + let timer: ReturnType | null = null + let settled = false + let waiters = this.waitersByPtyId.get(ptyId) + if (!waiters) { + waiters = new Set() + this.waitersByPtyId.set(ptyId, waiters) + } + + const onAbort = (): void => finish(false) + const waiter: RendererTerminalSerializerWaiter = { + afterGeneration, + finish: (ready) => finish(ready) + } + const finish = (ready: boolean): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + timer = null + } + signal?.removeEventListener('abort', onAbort) + waiters.delete(waiter) + if (waiters.size === 0) { + this.waitersByPtyId.delete(ptyId) + } + resolve(ready) + } + + waiters.add(waiter) + signal?.addEventListener('abort', onAbort, { once: true }) + if (timeoutMs !== undefined) { + timer = setTimeout(() => finish(false), timeoutMs) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + }) + } +} diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 4c6fef6ef8a..007af79b5d2 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -1127,6 +1127,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:remove') ipcMain.removeHandler('repos:removeForHost') ipcMain.removeHandler('repos:reorder') + ipcMain.removeHandler('repos:reorderForHost') ipcMain.removeHandler('repos:update') ipcMain.removeHandler('projects:list') ipcMain.removeHandler('projects:update') @@ -1928,6 +1929,26 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } ) + ipcMain.handle( + 'repos:reorderForHost', + ( + _event, + args: { orderedIds: string[]; hostId: string } + ): { status: 'applied' | 'rejected' } => { + const hostId = normalizeExecutionHostId(args?.hostId) + if (!hostId) { + return { status: 'rejected' } + } + const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : [] + const applied = store.reorderReposForHost(ids, hostId) + if (applied) { + notifyReposChanged(mainWindow) + return { status: 'applied' } + } + return { status: 'rejected' } + } + ) + ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => { store.removeProject(args.repoId) invalidateAuthorizedRootsCache() diff --git a/src/main/ipc/runtime-watcher-pending-assignment.test.ts b/src/main/ipc/runtime-watcher-pending-assignment.test.ts new file mode 100644 index 00000000000..e73468aa799 --- /dev/null +++ b/src/main/ipc/runtime-watcher-pending-assignment.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' +import { RuntimeWatcherPendingAssignment } from './runtime-watcher-pending-assignment' + +describe('RuntimeWatcherPendingAssignment', () => { + it('removes ten thousand cancelled assignment callers while one anchor remains', async () => { + let resolveAssignment: (value: number) => void = () => undefined + const assignment = new RuntimeWatcherPendingAssignment( + new Promise((resolve) => { + resolveAssignment = resolve + }), + vi.fn(), + vi.fn() + ) + const anchor = assignment.wait({}, vi.fn()) + const controllers = Array.from({ length: 10_000 }, () => new AbortController()) + const cancelled = controllers.map((controller) => + assignment.wait({ signal: controller.signal }, vi.fn()).catch((error) => error) + ) + + for (const controller of controllers) { + controller.abort() + } + await Promise.all(cancelled) + + expect(assignment.waiterCount).toBe(1) + resolveAssignment(42) + await expect(anchor).resolves.toBe(42) + expect(assignment.waiterCount).toBe(0) + }) + + it('releases ownership for an already-aborted first caller', async () => { + const controller = new AbortController() + controller.abort() + const onNoWaiters = vi.fn() + const onSettled = vi.fn() + const assignment = new RuntimeWatcherPendingAssignment( + new Promise(() => {}), + onNoWaiters, + onSettled + ) + + await expect(assignment.wait({ signal: controller.signal }, vi.fn())).rejects.toMatchObject({ + code: 'subscribe_aborted' + }) + + expect(onNoWaiters).toHaveBeenCalledOnce() + expect(onSettled).toHaveBeenCalledOnce() + expect(assignment.waiterCount).toBe(0) + }) +}) diff --git a/src/main/ipc/runtime-watcher-pending-assignment.ts b/src/main/ipc/runtime-watcher-pending-assignment.ts new file mode 100644 index 00000000000..9a638fe1b7d --- /dev/null +++ b/src/main/ipc/runtime-watcher-pending-assignment.ts @@ -0,0 +1,64 @@ +import { WatcherProcessFailure } from './parcel-watcher-process-failure' +import type { WatcherProcessHooks } from './parcel-watcher-process-subscription' +import { PromiseSettlementWaiters } from '../../shared/promise-settlement-waiters' + +const DEFAULT_QUARANTINE_WAIT_TIMEOUT_MS = 60_000 + +export class RuntimeWatcherPendingAssignment { + private readonly settlementWaiters: PromiseSettlementWaiters + private settledNotified = false + + constructor( + basePromise: Promise, + private readonly onNoWaiters: () => void, + private readonly onSettled: () => void + ) { + this.settlementWaiters = new PromiseSettlementWaiters(basePromise, () => this.notifySettled()) + } + + get waiterCount(): number { + return this.settlementWaiters.waiterCount + } + + wait(hooks: WatcherProcessHooks, onGranted: (assignment: T) => void): Promise { + return this.settlementWaiters.wait({ + signal: hooks.signal, + timeoutMs: hooks.subscribeTimeoutMs ?? DEFAULT_QUARANTINE_WAIT_TIMEOUT_MS, + createAbortError: createAssignmentAbortError, + createTimeoutError: createAssignmentTimeoutError, + onFulfilled: onGranted, + onAbandon: () => { + if (this.settlementWaiters.waiterCount === 0) { + // Why: delete the logically abandoned assignment synchronously so a + // same-turn replacement cannot join its soon-to-reject promise. + this.notifySettled() + this.onNoWaiters() + } + } + }) + } + + private notifySettled(): void { + if (this.settledNotified) { + return + } + this.settledNotified = true + this.onSettled() + } +} + +function createAssignmentAbortError(): WatcherProcessFailure { + return new WatcherProcessFailure( + 'file watcher subscription aborted', + 'subscription', + 'subscribe_aborted' + ) +} + +function createAssignmentTimeoutError(): WatcherProcessFailure { + return new WatcherProcessFailure( + 'file watcher subscription timed out waiting for quarantine capacity', + 'subscription', + 'subscribe_timeout' + ) +} diff --git a/src/main/ipc/runtime-watcher-pool-lifecycle.ts b/src/main/ipc/runtime-watcher-pool-lifecycle.ts new file mode 100644 index 00000000000..041d9e7675f --- /dev/null +++ b/src/main/ipc/runtime-watcher-pool-lifecycle.ts @@ -0,0 +1,41 @@ +import { WatcherProcessFailure } from './parcel-watcher-process-failure' + +export class RuntimeWatcherPoolLifecycle { + readonly isolatedRoots = new Set() + readonly failedQuarantineRoots = new Set() + private disposed = false + + assertActive(): void { + if (this.disposed) { + throw new WatcherProcessFailure( + 'file watcher supervisor disposed', + 'supervisor', + 'supervisor_disposed' + ) + } + } + + isIsolated(root: string): boolean { + return this.isolatedRoots.has(root) + } + + quarantineOrFuse(root: string, isolated: boolean): void { + // Why: one quarantine attempt is the recovery budget; a second failure is fused. + if (isolated) { + this.isolatedRoots.delete(root) + this.failedQuarantineRoots.add(root) + return + } + this.isolatedRoots.add(root) + } + + dispose(): void { + this.disposed = true + this.isolatedRoots.clear() + this.failedQuarantineRoots.clear() + } + + reset(): void { + this.disposed = false + } +} diff --git a/src/main/ipc/runtime-watcher-pool-state.ts b/src/main/ipc/runtime-watcher-pool-state.ts new file mode 100644 index 00000000000..e6c016b9844 --- /dev/null +++ b/src/main/ipc/runtime-watcher-pool-state.ts @@ -0,0 +1,22 @@ +import type { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor' + +export type RuntimeWatcherPoolSupervisor = Pick + +export type RuntimeWatcherPoolSlot = { + supervisor: RuntimeWatcherPoolSupervisor + roots: Set + isolated: boolean + retired: boolean + disposed: boolean +} + +export type RuntimeWatcherPoolAssignment = { + slot: RuntimeWatcherPoolSlot + leases: number +} + +export type RuntimeWatcherProcessPoolOptions = { + maxSharedSupervisors?: number + maxQuarantineSupervisors?: number + createSupervisor?: () => RuntimeWatcherPoolSupervisor +} diff --git a/src/main/ipc/runtime-watcher-predecessor-barriers.ts b/src/main/ipc/runtime-watcher-predecessor-barriers.ts new file mode 100644 index 00000000000..f5301eb5432 --- /dev/null +++ b/src/main/ipc/runtime-watcher-predecessor-barriers.ts @@ -0,0 +1,35 @@ +import type { WatcherProcessFailure } from './parcel-watcher-process-failure' + +/** Retain exact failed-child ownership until its native handles are gone. */ +export class RuntimeWatcherPredecessorBarriers { + private readonly failures = new Map() + + throwIfRetained(rootPath: string): void { + const failure = this.failures.get(rootPath) + if (failure) { + throw failure + } + } + + retain(rootPaths: Iterable, failure: WatcherProcessFailure): void { + if (!failure.physicalExit) { + return + } + const retainedRoots = Array.from(rootPaths) + for (const rootPath of retainedRoots) { + this.failures.set(rootPath, failure) + } + const clearExactFailure = (): void => { + for (const rootPath of retainedRoots) { + if (this.failures.get(rootPath) === failure) { + this.failures.delete(rootPath) + } + } + } + void failure.physicalExit.then(clearExactFailure, clearExactFailure) + } + + clear(): void { + this.failures.clear() + } +} diff --git a/src/main/ipc/runtime-watcher-process-pool.test.ts b/src/main/ipc/runtime-watcher-process-pool.test.ts index 5cd624589b2..c722b5ccdd3 100644 --- a/src/main/ipc/runtime-watcher-process-pool.test.ts +++ b/src/main/ipc/runtime-watcher-process-pool.test.ts @@ -13,6 +13,14 @@ type InstalledSubscription = { unsubscribe: ReturnType Promise>> } +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => undefined + const promise = new Promise((nextResolve) => { + resolve = nextResolve + }) + return { promise, resolve } +} + class FakeSupervisor { readonly subscriptions: InstalledSubscription[] = [] readonly dispose = vi.fn() @@ -40,7 +48,6 @@ describe('RuntimeWatcherProcessPool', () => { beforeEach(() => { supervisors = [] pool = new RuntimeWatcherProcessPool({ - maxSharedSupervisors: 4, createSupervisor: () => { const supervisor = new FakeSupervisor() supervisors.push(supervisor) @@ -49,14 +56,14 @@ describe('RuntimeWatcherProcessPool', () => { }) }) - it('spreads roots across a bounded healthy pool before sharing children', async () => { + it('shares one healthy child until a fault requires quarantine isolation', async () => { for (const dir of ['/a', '/b', '/c', '/d', '/e']) { await pool.subscribe(dir, vi.fn(), {}, {}) } - expect(supervisors).toHaveLength(4) + expect(supervisors).toHaveLength(1) expect(supervisors.map((supervisor) => supervisor.subscriptions.map(({ dir }) => dir))).toEqual( - [['/a', '/e'], ['/b'], ['/c'], ['/d']] + [['/a', '/b', '/c', '/d', '/e']] ) }) @@ -104,7 +111,30 @@ describe('RuntimeWatcherProcessPool', () => { expect(supervisors[2].subscriptions.map(({ dir }) => dir)).toEqual(['/b']) }) - it('bounds quarantine supervisors when a fused shard contains many roots', async () => { + it('does not assign a replacement until the failed child physically exits', async () => { + const physicalExit = deferred() + await pool.subscribe('/held', vi.fn(), {}, {}) + const failure = new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable', + physicalExit.promise + ) + + supervisors[0].subscriptions[0].hooks.onTerminalError?.(failure) + + await expect(pool.subscribe('/held', vi.fn(), {}, {})).rejects.toBe(failure) + expect(supervisors).toHaveLength(1) + + physicalExit.resolve() + await physicalExit.promise + await expect(pool.subscribe('/held', vi.fn(), {}, {})).resolves.toBeDefined() + + expect(supervisors).toHaveLength(2) + expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/held']) + }) + + it('bounds single-root quarantine supervisors and their wait queue', async () => { pool = new RuntimeWatcherProcessPool({ maxSharedSupervisors: 1, createSupervisor: () => { @@ -113,7 +143,7 @@ describe('RuntimeWatcherProcessPool', () => { return supervisor } }) - const roots = Array.from({ length: 12 }, (_, index) => `/root-${index}`) + const roots = Array.from({ length: 9 }, (_, index) => `/root-${index}`) for (const root of roots) { await pool.subscribe(root, vi.fn(), {}, {}) } @@ -127,14 +157,278 @@ describe('RuntimeWatcherProcessPool', () => { for (const installed of failedShard.subscriptions) { installed.hooks.onTerminalError?.(failure) } - for (const root of roots) { - await pool.subscribe(root, vi.fn(), {}, {}) - } + const recoveries = roots.map((root) => pool.subscribe(root, vi.fn(), {}, {})) + const queueOverflow = expect(recoveries[8]).rejects.toThrow('quarantine capacity exhausted') + const active = await Promise.all(recoveries.slice(0, 4)) expect(supervisors).toHaveLength(5) expect(supervisors.slice(1).map(({ subscriptions }) => subscriptions.length)).toEqual([ - 3, 3, 3, 3 + 1, 1, 1, 1 ]) + + for (let index = 0; index < active.length; index++) { + await active[index].unsubscribe() + await expect(recoveries[index + 4]).resolves.toBeDefined() + } + await queueOverflow + + expect(supervisors).toHaveLength(5) + expect(supervisors.slice(1).map(({ subscriptions }) => subscriptions.length)).toEqual([ + 2, 2, 2, 2 + ]) + }) + + it('does not fail a healthy queued root when a quarantine root fuses again', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + maxQuarantineSupervisors: 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('/poison', vi.fn(), {}, {}) + await pool.subscribe('/healthy', vi.fn(), {}, {}) + for (const installed of supervisors[0].subscriptions) { + installed.hooks.onTerminalError?.(failure) + } + + await pool.subscribe('/poison', vi.fn(), {}, {}) + const healthyRecovery = pool.subscribe('/healthy', vi.fn(), {}, {}) + supervisors[1].subscriptions[0].hooks.onTerminalError?.(failure) + + await expect(healthyRecovery).resolves.toBeDefined() + await expect(pool.subscribe('/poison', vi.fn(), {}, {})).rejects.toThrow( + 'failed again in quarantine' + ) + expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/poison']) + expect(supervisors[2].subscriptions.map(({ dir }) => dir)).toEqual(['/healthy']) + }) + + it('bounds how long a root can wait for quarantine capacity', async () => { + vi.useFakeTimers() + try { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + maxQuarantineSupervisors: 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('/occupied', vi.fn(), {}, {}) + await pool.subscribe('/waiting', vi.fn(), {}, {}) + for (const installed of supervisors[0].subscriptions) { + installed.hooks.onTerminalError?.(failure) + } + await pool.subscribe('/occupied', vi.fn(), {}, {}) + + const waiting = pool.subscribe('/waiting', vi.fn(), {}, { subscribeTimeoutMs: 25 }) + const timedOut = expect(waiting).rejects.toThrow('timed out waiting for quarantine capacity') + await vi.advanceTimersByTimeAsync(25) + + await timedOut + expect(supervisors).toHaveLength(2) + } finally { + pool.dispose() + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + } + }) + + it('dedupes concurrent same-root assignments waiting for quarantine capacity', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + maxQuarantineSupervisors: 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('/occupied', vi.fn(), {}, {}) + await pool.subscribe('/same', vi.fn(), {}, {}) + for (const installed of supervisors[0].subscriptions) { + installed.hooks.onTerminalError?.(failure) + } + const occupied = await pool.subscribe('/occupied', vi.fn(), {}, {}) + const first = pool.subscribe('/same', vi.fn(), {}, {}) + const second = pool.subscribe('/same', vi.fn(), {}, {}) + + await occupied.unsubscribe() + const [firstSubscription, secondSubscription] = await Promise.all([first, second]) + + expect(supervisors).toHaveLength(2) + expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual([ + '/occupied', + '/same', + '/same' + ]) + await firstSubscription.unsubscribe() + await secondSubscription.unsubscribe() + }) + + it('keeps a healthy same-root quarantine waiter when the first waiter aborts', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + maxQuarantineSupervisors: 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('/occupied', vi.fn(), {}, {}) + await pool.subscribe('/same', vi.fn(), {}, {}) + for (const installed of supervisors[0].subscriptions) { + installed.hooks.onTerminalError?.(failure) + } + const occupied = await pool.subscribe('/occupied', vi.fn(), {}, {}) + const firstController = new AbortController() + const first = pool.subscribe('/same', vi.fn(), {}, { signal: firstController.signal }) + const second = pool.subscribe('/same', vi.fn(), {}, {}) + + firstController.abort() + await expect(first).rejects.toMatchObject({ code: 'subscribe_aborted' }) + await occupied.unsubscribe() + const secondSubscription = await second + + expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/occupied', '/same']) + await secondSubscription.unsubscribe() + }) + + it('does not let an aborted zero-waiter assignment poison a same-turn replacement', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + maxQuarantineSupervisors: 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('/occupied', vi.fn(), {}, {}) + await pool.subscribe('/same', vi.fn(), {}, {}) + for (const installed of supervisors[0].subscriptions) { + installed.hooks.onTerminalError?.(failure) + } + const occupied = await pool.subscribe('/occupied', vi.fn(), {}, {}) + const controller = new AbortController() + const abandoned = pool.subscribe('/same', vi.fn(), {}, { signal: controller.signal }) + + controller.abort() + const replacement = pool.subscribe('/same', vi.fn(), {}, {}) + await expect(abandoned).rejects.toMatchObject({ code: 'subscribe_aborted' }) + await occupied.unsubscribe() + const replacementSubscription = await replacement + + expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/occupied', '/same']) + await replacementSubscription.unsubscribe() + }) + + it('rejects an aborted later same-root waiter without cancelling the first', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + maxQuarantineSupervisors: 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('/occupied', vi.fn(), {}, {}) + await pool.subscribe('/same', vi.fn(), {}, {}) + for (const installed of supervisors[0].subscriptions) { + installed.hooks.onTerminalError?.(failure) + } + const occupied = await pool.subscribe('/occupied', vi.fn(), {}, {}) + const first = pool.subscribe('/same', vi.fn(), {}, {}) + const secondController = new AbortController() + const second = pool.subscribe('/same', vi.fn(), {}, { signal: secondController.signal }) + + secondController.abort() + await expect(second).rejects.toMatchObject({ code: 'subscribe_aborted' }) + let firstSettled = false + void first.finally(() => { + firstSettled = true + }) + await Promise.resolve() + expect(firstSettled).toBe(false) + + await occupied.unsubscribe() + const firstSubscription = await first + expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/occupied', '/same']) + await firstSubscription.unsubscribe() + }) + + it('releases a granted assignment when its last waiter aborts before lease publication', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + maxQuarantineSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisors.push(supervisor) + return supervisor + } + }) + const failure = new WatcherProcessFailure( + 'file watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ) + for (const root of ['/occupied', '/aborted', '/following']) { + await pool.subscribe(root, vi.fn(), {}, {}) + } + for (const installed of supervisors[0].subscriptions) { + installed.hooks.onTerminalError?.(failure) + } + const occupied = await pool.subscribe('/occupied', vi.fn(), {}, {}) + const controller = new AbortController() + const aborted = pool.subscribe('/aborted', vi.fn(), {}, { signal: controller.signal }) + + const release = occupied.unsubscribe() + queueMicrotask(() => controller.abort()) + await release + + await expect(aborted).rejects.toMatchObject({ code: 'subscribe_aborted' }) + const following = pool.subscribe('/following', vi.fn(), {}, { subscribeTimeoutMs: 25 }) + const followingSubscription = await following + expect(supervisors).toHaveLength(3) + expect(supervisors[2].subscriptions.map(({ dir }) => dir)).toEqual(['/following']) + expect(supervisors[1].dispose).toHaveBeenCalledTimes(1) + await followingSubscription.unsubscribe() }) it('moves an explicitly timed-out root into quarantine for its retry', async () => { @@ -324,5 +618,7 @@ describe('RuntimeWatcherProcessPool', () => { pool.resetForTest() expect(supervisors.every((supervisor) => supervisor.dispose.mock.calls.length === 1)).toBe(true) + await expect(pool.subscribe('/after-reset', vi.fn(), {}, {})).resolves.toBeDefined() + expect(supervisors).toHaveLength(2) }) }) diff --git a/src/main/ipc/runtime-watcher-process-pool.ts b/src/main/ipc/runtime-watcher-process-pool.ts index b4e46cb3d2c..4bafc38208d 100644 --- a/src/main/ipc/runtime-watcher-process-pool.ts +++ b/src/main/ipc/runtime-watcher-process-pool.ts @@ -6,44 +6,41 @@ import type { WatcherProcessHooks, WatcherProcessSubscription } from './parcel-watcher-process-subscription' +import { RuntimeWatcherPendingAssignment } from './runtime-watcher-pending-assignment' +import { RuntimeWatcherPoolLifecycle } from './runtime-watcher-pool-lifecycle' +import { RuntimeWatcherPredecessorBarriers } from './runtime-watcher-predecessor-barriers' +import { RuntimeWatcherQuarantineQueue } from './runtime-watcher-quarantine-queue' +import { handleRuntimeWatcherSubscriptionFailure } from './runtime-watcher-subscription-failure' +import type { + RuntimeWatcherPoolAssignment, + RuntimeWatcherPoolSlot, + RuntimeWatcherPoolSupervisor, + RuntimeWatcherProcessPoolOptions +} from './runtime-watcher-pool-state' -const DEFAULT_MAX_SHARED_SUPERVISORS = 4 +export type { RuntimeWatcherProcessPoolOptions } from './runtime-watcher-pool-state' + +// Why: extra ~50 MiB processes are justified only when quarantine isolates a fault. +const DEFAULT_MAX_SHARED_SUPERVISORS = 1 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. + * Share children while healthy, then move failed roots into bounded quarantine slots. */ export class RuntimeWatcherProcessPool { private readonly maxSharedSupervisors: number private readonly maxQuarantineSupervisors: number - private readonly createSupervisor: () => WatcherSupervisor + private readonly createSupervisor: () => RuntimeWatcherPoolSupervisor 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() + private readonly pendingAssignments = new Map< + string, + RuntimeWatcherPendingAssignment + >() + private readonly lifecycle = new RuntimeWatcherPoolLifecycle() + private readonly predecessorBarriers = new RuntimeWatcherPredecessorBarriers() + private readonly quarantineQueue: RuntimeWatcherQuarantineQueue constructor(options: RuntimeWatcherProcessPoolOptions = {}) { this.maxSharedSupervisors = Math.max( @@ -54,6 +51,7 @@ export class RuntimeWatcherProcessPool { 1, options.maxQuarantineSupervisors ?? DEFAULT_MAX_QUARANTINE_SUPERVISORS ) + this.quarantineQueue = new RuntimeWatcherQuarantineQueue(this.maxQuarantineSupervisors) this.createSupervisor = options.createSupervisor ?? (() => new WatcherProcessSupervisor()) } @@ -63,7 +61,11 @@ export class RuntimeWatcherProcessPool { opts: WatcherProcessSubscribeOptions, hooks: WatcherProcessHooks = {} ): Promise { - const assignment = this.assignmentForRoot(dir) + this.lifecycle.assertActive() + const assignmentOrPromise = this.assignmentForRoot(dir, hooks) + const assignment = + assignmentOrPromise instanceof Promise ? await assignmentOrPromise : assignmentOrPromise + this.lifecycle.assertActive() const { slot } = assignment let assignmentReleased = false const releaseAssignment = (): void => { @@ -74,11 +76,12 @@ export class RuntimeWatcherProcessPool { this.releaseRoot(assignment, dir) } const onTerminalError = (error: Error): void => { - if (isWatcherProcessFailure(error) && error.scope === 'supervisor') { - this.retireSlot(slot) - } else { - this.releaseFailedRoot(assignment, dir, error, releaseAssignment) - } + handleRuntimeWatcherSubscriptionFailure( + error, + releaseAssignment, + (failure) => this.retireSlot(slot, failure), + () => this.lifecycle.quarantineOrFuse(dir, slot.isolated) + ) hooks.onTerminalError?.(error) } let subscription: WatcherProcessSubscription @@ -88,20 +91,29 @@ export class RuntimeWatcherProcessPool { onTerminalError }) } catch (error) { - if (isWatcherProcessFailure(error) && error.scope === 'supervisor') { - this.retireSlot(slot) - } else { - this.releaseFailedRoot(assignment, dir, error, releaseAssignment) - } + handleRuntimeWatcherSubscriptionFailure( + error, + releaseAssignment, + (failure) => this.retireSlot(slot, failure), + () => this.lifecycle.quarantineOrFuse(dir, slot.isolated) + ) throw error } let unsubscribePromise: Promise | undefined return { unsubscribe: (): Promise => { - unsubscribePromise ??= subscription.unsubscribe().finally(() => { - releaseAssignment() - }) + unsubscribePromise ??= subscription.unsubscribe().then( + () => releaseAssignment(), + (error: unknown) => { + if (isWatcherProcessFailure(error) && error.scope === 'supervisor') { + this.retireSlot(slot, error) + } else { + releaseAssignment() + } + throw error + } + ) return unsubscribePromise } } @@ -109,33 +121,46 @@ export class RuntimeWatcherProcessPool { /** Kill every pooled watcher child (production shutdown or test reset). */ dispose(): void { + this.lifecycle.dispose() // disposeSlot deletes the current slot from allSlots; deleting the // in-progress element during Set iteration is safe, so no snapshot needed. + const disposedError = new WatcherProcessFailure( + 'file watcher supervisor disposed', + 'supervisor', + 'supervisor_disposed' + ) + this.quarantineQueue.failAll(disposedError) for (const slot of this.allSlots) { this.disposeSlot(slot) } this.activeSlots.clear() this.allSlots.clear() this.assignments.clear() - this.isolatedRoots.clear() - this.failedQuarantineRoots.clear() + this.pendingAssignments.clear() + this.predecessorBarriers.clear() } resetForTest(): void { this.dispose() + this.lifecycle.reset() } 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) + this.quarantineQueue.failRoot(dir) + this.lifecycle.isolatedRoots.delete(dir) + this.lifecycle.failedQuarantineRoots.delete(dir) } } - private assignmentForRoot(dir: string): RuntimeWatcherPoolAssignment { - if (this.failedQuarantineRoots.has(dir)) { + private assignmentForRoot( + dir: string, + hooks: WatcherProcessHooks + ): RuntimeWatcherPoolAssignment | Promise { + this.predecessorBarriers.throwIfRetained(dir) + if (this.lifecycle.failedQuarantineRoots.has(dir)) { throw new WatcherProcessFailure( 'file watcher process failed again in quarantine', 'supervisor', @@ -147,8 +172,49 @@ export class RuntimeWatcherProcessPool { assigned.leases++ return assigned } - const slot = this.isolatedRoots.has(dir) ? this.quarantineSlot() : this.sharedSlot() - const assignment = { slot, leases: 1 } + const pending = this.pendingAssignments.get(dir) + if (pending) { + return pending.wait(hooks, (assignment) => assignment.leases++) + } + const slot = this.lifecycle.isIsolated(dir) ? this.quarantineSlot(dir) : this.sharedSlot() + if (slot instanceof Promise) { + let abandoned = false + let grantedAssignment: RuntimeWatcherPoolAssignment | undefined + const basePromise = slot.then((resolvedSlot) => { + grantedAssignment = this.assignRootToSlot(dir, resolvedSlot, 0) + if (abandoned) { + this.releaseRoot(grantedAssignment, dir, false) + } + return grantedAssignment + }) + const pendingAssignment = new RuntimeWatcherPendingAssignment( + basePromise, + () => { + abandoned = true + if (grantedAssignment) { + this.releaseRoot(grantedAssignment, dir, false) + } else { + this.quarantineQueue.failRoot(dir) + } + }, + () => { + if (this.pendingAssignments.get(dir) === pendingAssignment) { + this.pendingAssignments.delete(dir) + } + } + ) + this.pendingAssignments.set(dir, pendingAssignment) + return pendingAssignment.wait(hooks, (assignment) => assignment.leases++) + } + return this.assignRootToSlot(dir, slot) + } + + private assignRootToSlot( + dir: string, + slot: RuntimeWatcherPoolSlot, + leases = 1 + ): RuntimeWatcherPoolAssignment { + const assignment = { slot, leases } slot.roots.add(dir) this.assignments.set(dir, assignment) return assignment @@ -166,16 +232,14 @@ export class RuntimeWatcherProcessPool { ) } - private quarantineSlot(): RuntimeWatcherPoolSlot { + private quarantineSlot(dir: string): RuntimeWatcherPoolSlot | Promise { 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 - ) + return this.quarantineQueue.wait(dir) } private createSlot(isolated: boolean): RuntimeWatcherPoolSlot { @@ -191,65 +255,64 @@ export class RuntimeWatcherProcessPool { return slot } - private retireSlot(slot: RuntimeWatcherPoolSlot): void { + private retireSlot(slot: RuntimeWatcherPoolSlot, error: WatcherProcessFailure): void { if (slot.retired) { return } slot.retired = true this.activeSlots.delete(slot) + // Why: a replacement cannot safely reacquire a root while the failed child + // may still own its native watcher handle (notably on Windows). + this.predecessorBarriers.retain(slot.roots, error) for (const root of slot.roots) { if (this.assignments.get(root)?.slot === slot) { this.assignments.delete(root) } - this.quarantineOrFuse(root, slot.isolated) + this.lifecycle.quarantineOrFuse(root, slot.isolated) } slot.roots.clear() // Why: failAllSubscriptions is still iterating callbacks; defer disposal // so every logical root receives the supervisor failure first. - queueMicrotask(() => this.disposeSlot(slot)) + queueMicrotask(() => { + this.disposeSlot(slot) + this.drainQuarantineWaiters() + }) } - private releaseRoot(assignment: RuntimeWatcherPoolAssignment, dir: string): void { + private releaseRoot( + assignment: RuntimeWatcherPoolAssignment, + dir: string, + releaseLease = true + ): void { if (this.assignments.get(dir) !== assignment) { return } - assignment.leases-- + if (releaseLease) { + assignment.leases-- + } if (assignment.leases > 0) { return } const { slot } = assignment this.assignments.delete(dir) slot.roots.delete(dir) - this.isolatedRoots.delete(dir) + this.lifecycle.isolatedRoots.delete(dir) if (slot.isolated && slot.roots.size === 0 && !slot.retired) { - this.activeSlots.delete(slot) - this.disposeSlot(slot) + if (!this.quarantineQueue.grantNext(slot)) { + this.activeSlots.delete(slot) + this.disposeSlot(slot) + } } } - private releaseFailedRoot( - assignment: RuntimeWatcherPoolAssignment, - dir: string, - error: unknown, - releaseAssignment: () => void - ): void { - releaseAssignment() - if (!isWatcherProcessFailure(error) || error.code !== 'subscribe_timeout') { - return + private drainQuarantineWaiters(): void { + while ( + this.quarantineQueue.length > 0 && + Array.from(this.activeSlots).filter((slot) => slot.isolated && !slot.retired).length < + this.maxQuarantineSupervisors + ) { + this.quarantineQueue.grantNext(this.createSlot(true)) } - this.quarantineOrFuse(dir, assignment.slot.isolated) - } - - // Why: one bounded quarantine attempt is the recovery budget per watch - // lifetime; an already-isolated root that fails again is fused, not re-isolated, - // so it cannot spawn another child generation. - private quarantineOrFuse(dir: string, isolated: boolean): void { - if (isolated) { - this.isolatedRoots.delete(dir) - this.failedQuarantineRoots.add(dir) - return - } - this.isolatedRoots.add(dir) } private disposeSlot(slot: RuntimeWatcherPoolSlot): void { diff --git a/src/main/ipc/runtime-watcher-quarantine-queue.ts b/src/main/ipc/runtime-watcher-quarantine-queue.ts new file mode 100644 index 00000000000..e326b299943 --- /dev/null +++ b/src/main/ipc/runtime-watcher-quarantine-queue.ts @@ -0,0 +1,77 @@ +import { WatcherProcessFailure } from './parcel-watcher-process-failure' + +type QuarantineWaiter = { + dir: string + grant: (value: T) => void + fail: (error: WatcherProcessFailure) => void +} + +export class RuntimeWatcherQuarantineQueue { + private readonly waiters: QuarantineWaiter[] = [] + + constructor(private readonly capacity: number) {} + + get length(): number { + return this.waiters.length + } + + wait(dir: string): Promise { + if (this.waiters.length >= this.capacity) { + return Promise.reject( + new WatcherProcessFailure( + 'file watcher quarantine capacity exhausted', + 'supervisor', + 'process_unavailable' + ) + ) + } + return new Promise((resolve, reject) => { + let settled = false + const finish = (callback: () => void): void => { + if (settled) { + return + } + settled = true + const index = this.waiters.indexOf(waiter) + if (index >= 0) { + this.waiters.splice(index, 1) + } + callback() + } + const waiter: QuarantineWaiter = { + dir, + grant: (value) => finish(() => resolve(value)), + fail: (error) => finish(() => reject(error)) + } + this.waiters.push(waiter) + }) + } + + grantNext(value: T): boolean { + const waiter = this.waiters.shift() + waiter?.grant(value) + return Boolean(waiter) + } + + failRoot(dir: string): void { + for (let index = this.waiters.length - 1; index >= 0; index--) { + if (this.waiters[index].dir === dir) { + this.waiters[index].fail(createQuarantineAbortError()) + } + } + } + + failAll(error: WatcherProcessFailure): void { + for (const waiter of this.waiters.splice(0)) { + waiter.fail(error) + } + } +} + +function createQuarantineAbortError(): WatcherProcessFailure { + return new WatcherProcessFailure( + 'file watcher subscription aborted', + 'subscription', + 'subscribe_aborted' + ) +} diff --git a/src/main/ipc/runtime-watcher-subscription-failure.ts b/src/main/ipc/runtime-watcher-subscription-failure.ts new file mode 100644 index 00000000000..5e238cab8ed --- /dev/null +++ b/src/main/ipc/runtime-watcher-subscription-failure.ts @@ -0,0 +1,20 @@ +import { + isWatcherProcessFailure, + type WatcherProcessFailure +} from './parcel-watcher-process-failure' + +export function handleRuntimeWatcherSubscriptionFailure( + error: unknown, + releaseAssignment: () => void, + retireSupervisor: (failure: WatcherProcessFailure) => void, + quarantineOrFuseRoot: () => void +): void { + if (isWatcherProcessFailure(error) && error.scope === 'supervisor') { + retireSupervisor(error) + return + } + releaseAssignment() + if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') { + quarantineOrFuseRoot() + } +} diff --git a/src/main/ipc/sender-scoped-request-cancellation.ts b/src/main/ipc/sender-scoped-request-cancellation.ts new file mode 100644 index 00000000000..9700be9c3da --- /dev/null +++ b/src/main/ipc/sender-scoped-request-cancellation.ts @@ -0,0 +1,50 @@ +import type { IpcMainInvokeEvent } from 'electron' + +export type SenderScopedRequestCancellations = { + /** Registers a cancellable request; aborts any previous request that reused the token. */ + begin: (event: IpcMainInvokeEvent, requestToken: string | undefined) => AbortController | null + /** Removes the registration once the request settles (no-op if it was replaced). */ + finish: ( + event: IpcMainInvokeEvent, + requestToken: string | undefined, + controller: AbortController | null + ) => void + /** Best-effort abort from the issuing webContents; a settled request is gone. */ + cancel: (event: IpcMainInvokeEvent, requestToken: string) => void +} + +/** + * Registry for renderer-cancellable IPC requests. Keys are scoped to the + * issuing webContents so one window's token can never cancel another window's + * request, and reusing a token aborts the previous request before the new one + * registers. + */ +export function createSenderScopedRequestCancellations(): SenderScopedRequestCancellations { + const controllers = new Map() + const keyFor = (event: IpcMainInvokeEvent, requestToken: string): string => + `${event.sender.id}\0${requestToken}` + return { + begin: (event, requestToken) => { + if (!requestToken) { + return null + } + const key = keyFor(event, requestToken) + controllers.get(key)?.abort() + const controller = new AbortController() + controllers.set(key, controller) + return controller + }, + finish: (event, requestToken, controller) => { + if (!requestToken || !controller) { + return + } + const key = keyFor(event, requestToken) + if (controllers.get(key) === controller) { + controllers.delete(key) + } + }, + cancel: (event, requestToken) => { + controllers.get(keyFor(event, requestToken))?.abort() + } + } +} diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 87f2f8b63d4..98015aa64a7 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -18,6 +18,12 @@ export function registerSessionHandlers(store: Store): void { store.patchWorkspaceSession(args, hostId) }) + ipcMain.handle('session:flush', () => { + // Why: durable lifecycle RPCs must propagate disk failures instead of + // returning success through Store.flush(), which intentionally only logs. + store.flushOrThrow() + }) + // Synchronous variant for the renderer's beforeunload handler. // sendSync blocks the renderer until this returns, guaranteeing the // data (including terminal scrollback buffers) is persisted to disk diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index 680163f8b5d..bc390b2beb7 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -1,13 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - discoverSkillsMock: vi.fn(), - getDefaultWslDistroMock: vi.fn(), - getWslHomeMock: vi.fn() - }) -) +const { + handleMock, + discoverSkillsMock, + inventorySkillFreshnessMock, + getDefaultWslDistroMock, + getWslHomeMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + discoverSkillsMock: vi.fn(), + inventorySkillFreshnessMock: vi.fn(), + getDefaultWslDistroMock: vi.fn(), + getWslHomeMock: vi.fn() +})) vi.mock('electron', () => ({ ipcMain: { @@ -19,6 +24,10 @@ vi.mock('../skills/discovery', () => ({ discoverSkills: discoverSkillsMock })) +vi.mock('../skills/skill-freshness-inventory', () => ({ + inventorySkillFreshness: inventorySkillFreshnessMock +})) + vi.mock('../wsl', () => ({ getDefaultWslDistro: getDefaultWslDistroMock, getWslHome: getWslHomeMock @@ -39,6 +48,12 @@ describe('registerSkillsHandlers', () => { getDefaultWslDistroMock.mockReset() getWslHomeMock.mockReset() discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 }) + inventorySkillFreshnessMock.mockResolvedValue({ + schemaVersion: 1, + installations: [], + eligibleUpdateNames: [], + scannedAt: 1 + }) getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice') Object.defineProperty(process, 'platform', { configurable: true, @@ -61,6 +76,17 @@ describe('registerSkillsHandlers', () => { return call[1] as (_event: unknown, target?: unknown) => Promise } + function getFreshnessHandler() { + registerSkillsHandlers(store as never) + const call = handleMock.mock.calls.find( + (entry: unknown[]) => entry[0] === 'skills:freshnessInventory' + ) + if (!call) { + throw new Error('skills:freshnessInventory handler was not registered') + } + return call[1] as (_event: unknown) => Promise + } + it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => { const handler = getDiscoverHandler() @@ -136,4 +162,13 @@ describe('registerSkillsHandlers', () => { ).rejects.toThrow('Project runtime requires repair before skill discovery') expect(discoverSkillsMock).not.toHaveBeenCalled() }) + + it('keeps freshness inventory local and read-only over known repositories', async () => { + const handler = getFreshnessHandler() + + await handler(null) + + expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ repos }) + expect(getWslHomeMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index b81fd695790..d210123a744 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -2,7 +2,9 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' import { discoverSkills } from '../skills/discovery' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' +import type { SkillFreshnessInventory } from '../../shared/skill-freshness' import { getDefaultWslDistro, getWslHome } from '../wsl' +import { inventorySkillFreshness } from '../skills/skill-freshness-inventory' type SkillDiscoveryRuntimeTarget = | { runtime: 'host' } @@ -55,4 +57,10 @@ export function registerSkillsHandlers(store: Store): void { return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() }) } ) + + ipcMain.handle('skills:freshnessInventory', async (): Promise => { + // Why: the update command targets this machine's global homes. WSL and SSH + // inventories stay out until their installer rail has an equivalent proof. + return inventorySkillFreshness({ repos: store.getRepos() }) + }) } diff --git a/src/main/ipc/watcher-removal-gate.test.ts b/src/main/ipc/watcher-removal-gate.test.ts new file mode 100644 index 00000000000..37eed327379 --- /dev/null +++ b/src/main/ipc/watcher-removal-gate.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { + acquireWatcherRemovalGate, + beginTerminalInstall, + beginWatcherInstall, + TerminalRemovalInProgressError, + WatcherRemovalInProgressError +} from './watcher-removal-gate' + +describe('watcher removal gate', () => { + it('waits for an existing install and rejects later equivalent-path installs', async () => { + const finishInstall = beginWatcherInstall('C:\\Repo') + const removal = acquireWatcherRemovalGate('c:/repo/') + let ready = false + void removal.ready.then(() => { + ready = true + }) + + await Promise.resolve() + expect(ready).toBe(false) + expect(() => beginWatcherInstall('C:/REPO')).toThrow(WatcherRemovalInProgressError) + + finishInstall() + await removal.ready + removal.release() + + const finishLaterInstall = beginWatcherInstall('C:/repo') + finishLaterInstall() + }) + + it('scopes identical roots to their execution host', async () => { + const removal = acquireWatcherRemovalGate('/repo', 'ssh-a') + await removal.ready + + const finishOtherHostInstall = beginWatcherInstall('/repo', 'ssh-b') + expect(() => beginWatcherInstall('/repo', 'ssh-a')).toThrow(WatcherRemovalInProgressError) + + finishOtherHostInstall() + removal.release() + }) + + it('rejects a second destructive owner across removal entry points', () => { + const removal = acquireWatcherRemovalGate('/repo') + + expect(() => acquireWatcherRemovalGate('/repo')).toThrow( + 'Worktree deletion already in progress' + ) + + removal.release() + const retry = acquireWatcherRemovalGate('/repo') + retry.release() + }) + + it('waits for an existing terminal spawn and rejects later same-host spawns', async () => { + const finishSpawn = beginTerminalInstall('/repo', 'ssh-a') + const removal = acquireWatcherRemovalGate('/repo', 'ssh-a') + let ready = false + void removal.ready.then(() => { + ready = true + }) + + await Promise.resolve() + expect(ready).toBe(false) + expect(() => beginTerminalInstall('/repo', 'ssh-a')).toThrow(TerminalRemovalInProgressError) + + finishSpawn() + await removal.ready + removal.release() + }) + + it('waits descendant installs and rejects overlapping removal roots', async () => { + const finishSpawn = beginTerminalInstall('/repo/nested') + const removal = acquireWatcherRemovalGate('/repo') + let ready = false + void removal.ready.then(() => { + ready = true + }) + + await Promise.resolve() + expect(ready).toBe(false) + expect(() => beginTerminalInstall('/repo/late')).toThrow(TerminalRemovalInProgressError) + expect(() => acquireWatcherRemovalGate('/repo/nested')).toThrow( + 'Worktree deletion already in progress' + ) + + finishSpawn() + await removal.ready + removal.release() + }) + + it('rejects enclosing installs while a nested root is being removed', async () => { + const removal = acquireWatcherRemovalGate('/repo/nested') + await removal.ready + + expect(() => beginWatcherInstall('/repo')).toThrow(WatcherRemovalInProgressError) + expect(() => beginTerminalInstall('/repo')).toThrow(TerminalRemovalInProgressError) + + removal.release() + }) + + it('does not fence a distinct POSIX root containing a literal backslash', async () => { + const removal = acquireWatcherRemovalGate('/srv/team\\repo') + await removal.ready + + const finishInstall = beginWatcherInstall('/srv/team/repo') + finishInstall() + removal.release() + }) +}) diff --git a/src/main/ipc/watcher-removal-gate.ts b/src/main/ipc/watcher-removal-gate.ts new file mode 100644 index 00000000000..5df7b7e098a --- /dev/null +++ b/src/main/ipc/watcher-removal-gate.ts @@ -0,0 +1,174 @@ +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison +} from '../../shared/cross-platform-path' + +type WatcherRemovalGateState = { + connectionId: string | null + rootPath: string + installCount: number + removalCount: number + installDrainWaiters: Set<() => void> +} + +export type WatcherRemovalGate = { + ready: Promise + release(): void +} + +export class WatcherRemovalInProgressError extends Error { + readonly code = 'watcher_removal_in_progress' + + constructor() { + super('File watcher cannot start while the worktree is being removed') + this.name = 'WatcherRemovalInProgressError' + } +} + +export class TerminalRemovalInProgressError extends Error { + readonly code = 'terminal_removal_in_progress' + + constructor() { + super('Terminal cannot start while the worktree is being removed') + this.name = 'TerminalRemovalInProgressError' + } +} + +const states = new Map() + +export function beginWatcherInstall(rootPath: string, connectionId?: string): () => void { + return beginRemovalSensitiveInstall( + rootPath, + connectionId, + () => new WatcherRemovalInProgressError() + ) +} + +export function beginTerminalInstall(rootPath: string, connectionId?: string): () => void { + return beginRemovalSensitiveInstall( + rootPath, + connectionId, + () => new TerminalRemovalInProgressError() + ) +} + +function beginRemovalSensitiveInstall( + rootPath: string, + connectionId: string | undefined, + createRemovalError: () => Error +): () => void { + const normalizedRoot = normalizeRuntimePathForComparison(rootPath) + // Why: PTY admission can fence both worktree identity and cwd, which may be + // parent/child roots; neither side may overlap an active removal. + if ( + matchingHostStates(connectionId).some( + (state) => + state.removalCount > 0 && + (isPathInsideOrEqual(state.rootPath, normalizedRoot) || + isPathInsideOrEqual(normalizedRoot, state.rootPath)) + ) + ) { + throw createRemovalError() + } + const key = watcherRemovalGateKey(normalizedRoot, connectionId) + const state = states.get(key) ?? createState(key, normalizedRoot, connectionId) + state.installCount++ + let released = false + return () => { + if (released) { + return + } + released = true + state.installCount-- + if (state.installCount === 0) { + for (const resolve of state.installDrainWaiters) { + resolve() + } + state.installDrainWaiters.clear() + deleteIdleState(key, state) + } + } +} + +export function acquireWatcherRemovalGate( + rootPath: string, + connectionId?: string +): WatcherRemovalGate { + const normalizedRoot = normalizeRuntimePathForComparison(rootPath) + const hostStates = matchingHostStates(connectionId) + if ( + hostStates.some( + (state) => + state.removalCount > 0 && + (isPathInsideOrEqual(state.rootPath, normalizedRoot) || + isPathInsideOrEqual(normalizedRoot, state.rootPath)) + ) + ) { + // Why: desktop and runtime removal entry points have separate request + // dedupe; the shared root fence must still prevent two destructive runs. + throw new Error('Worktree deletion already in progress') + } + const key = watcherRemovalGateKey(normalizedRoot, connectionId) + const state = states.get(key) ?? createState(key, normalizedRoot, connectionId) + state.removalCount++ + // Why: deleting a parent root must wait for native installs already admitted + // under that root, not only installs keyed to the exact same spelling. + const drains = matchingHostStates(connectionId) + .filter( + (candidate) => + candidate.installCount > 0 && isPathInsideOrEqual(normalizedRoot, candidate.rootPath) + ) + .map((candidate) => new Promise((resolve) => candidate.installDrainWaiters.add(resolve))) + const ready = drains.length === 0 ? Promise.resolve() : Promise.all(drains).then(() => undefined) + let released = false + return { + ready, + release: () => { + if (released) { + return + } + released = true + state.removalCount-- + deleteIdleState(key, state) + } + } +} + +export function isWatcherRemovalInProgressError( + error: unknown +): error is WatcherRemovalInProgressError { + return error instanceof WatcherRemovalInProgressError +} + +function watcherRemovalGateKey(normalizedRoot: string, connectionId?: string): string { + // Why: the same path can exist on local and multiple SSH hosts, while + // Windows-equivalent spellings must still share one physical-removal fence. + return JSON.stringify([connectionId ?? null, normalizedRoot]) +} + +function matchingHostStates(connectionId?: string): WatcherRemovalGateState[] { + const host = connectionId ?? null + return [...states.values()].filter((state) => state.connectionId === host) +} + +function createState( + key: string, + rootPath: string, + connectionId?: string +): WatcherRemovalGateState { + const state = { + connectionId: connectionId ?? null, + rootPath, + installCount: 0, + removalCount: 0, + installDrainWaiters: new Set<() => void>() + } + states.set(key, state) + return state +} + +function deleteIdleState(key: string, state: WatcherRemovalGateState): void { + if (state.installCount === 0 && state.removalCount === 0 && states.get(key) === state) { + states.delete(key) + } +} diff --git a/src/main/ipc/worktree-symlinks.test.ts b/src/main/ipc/worktree-symlinks.test.ts index d3c8de1ff8c..cfb53effcfe 100644 --- a/src/main/ipc/worktree-symlinks.test.ts +++ b/src/main/ipc/worktree-symlinks.test.ts @@ -17,6 +17,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createWorktreeLinkedPaths, createWorktreeSymlinks, + findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths, removeWorktreeSymlinks } from './worktree-symlinks' @@ -434,6 +435,16 @@ describe('removeWorktreeSymlinks', () => { expect(statSync(join(worktree, '.env')).isFile()).toBe(true) }) + it('identifies only actual symlinks for removal preflight', async () => { + writeFileSync(join(primary, '.env'), 'PRIMARY=1\n') + symlinkSync(join(primary, '.env'), join(worktree, '.env')) + writeFileSync(join(worktree, 'config.json'), '{}\n') + + await expect( + findExistingWorktreeSymlinkPaths(worktree, ['.env', 'config.json', 'missing']) + ).resolves.toEqual(['.env']) + }) + it('leaves APFS clone-copied regular files for git removal to judge', async () => { writeFileSync(join(worktree, '.env'), 'CLONED=1\n') diff --git a/src/main/ipc/worktree-symlinks.ts b/src/main/ipc/worktree-symlinks.ts index 27e45ac3450..98e74e753fb 100644 --- a/src/main/ipc/worktree-symlinks.ts +++ b/src/main/ipc/worktree-symlinks.ts @@ -336,6 +336,27 @@ export async function removeWorktreeLinkedPaths( } } +export async function findExistingWorktreeSymlinkPaths( + worktreePath: string, + paths: readonly string[] +): Promise { + const symlinkPaths: string[] = [] + for (const rawPath of paths) { + const safePath = getSafeRelativePath(rawPath) + if (!safePath.safe) { + continue + } + try { + if ((await lstat(resolve(worktreePath, safePath.rel))).isSymbolicLink()) { + symlinkPaths.push(safePath.rel) + } + } catch { + // Why: only a positively identified symlink may bypass dirty preflight. + } + } + return symlinkPaths +} + /** Remove previously-created symlinks from a worktree before deletion. * * Why: `git worktree remove` refuses to delete a worktree that has modified diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 73d10495b1f..d7745a132f6 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -264,7 +264,11 @@ describe('registerWorktreeHandlers – Windows path handling', () => { emitWorktreeBaseStatus: vi.fn(), recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'), reconcileWorktreeBaseStatus: vi.fn(), - clearOptimisticReconcileToken: vi.fn() + clearOptimisticReconcileToken: vi.fn(), + closeFileWatchersForRemoval: vi.fn().mockResolvedValue(undefined), + acquireFileWatcherRemoval: vi.fn().mockResolvedValue({ + finish: vi.fn().mockResolvedValue(undefined) + }) } registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index c40e4ad06df..201a85c35aa 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -9,6 +9,7 @@ import * as localWorktreeFilesystem from '../local-worktree-filesystem' const ORIGINAL_PLATFORM = process.platform const removeWorktreeLinkedPathsMock = vi.hoisted(() => vi.fn()) +const findExistingWorktreeSymlinkPathsMock = vi.hoisted(() => vi.fn()) function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { @@ -175,6 +176,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ vi.mock('./worktree-symlinks', () => ({ createWorktreeLinkedPaths: vi.fn(), + findExistingWorktreeSymlinkPaths: findExistingWorktreeSymlinkPathsMock, removeWorktreeLinkedPaths: removeWorktreeLinkedPathsMock })) @@ -225,12 +227,17 @@ vi.mock('../ports/advertised-url-watcher', () => ({ } })) -const { killAllProcessesForWorktreeMock, clearProviderPtyStateMock, getLocalPtyProviderMock } = - vi.hoisted(() => ({ - killAllProcessesForWorktreeMock: vi.fn(), - clearProviderPtyStateMock: vi.fn(), - getLocalPtyProviderMock: vi.fn() - })) +const { + killAllProcessesForWorktreeMock, + clearProviderPtyStateMock, + getLocalPtyProviderMock, + getSshPtyProviderMock +} = vi.hoisted(() => ({ + killAllProcessesForWorktreeMock: vi.fn(), + clearProviderPtyStateMock: vi.fn(), + getLocalPtyProviderMock: vi.fn(), + getSshPtyProviderMock: vi.fn() +})) vi.mock('../runtime/worktree-teardown', () => ({ killAllProcessesForWorktree: killAllProcessesForWorktreeMock @@ -238,7 +245,8 @@ vi.mock('../runtime/worktree-teardown', () => ({ vi.mock('./pty', () => ({ clearProviderPtyState: clearProviderPtyStateMock, - getLocalPtyProvider: getLocalPtyProviderMock + getLocalPtyProvider: getLocalPtyProviderMock, + getSshPtyProvider: getSshPtyProviderMock })) import { @@ -290,6 +298,8 @@ describe('registerWorktreeHandlers', () => { createTerminal: ReturnType splitTerminal: ReturnType notifyWorktreesChangedForRemoteClients: ReturnType + closeFileWatchersForRemoval: ReturnType + acquireFileWatcherRemoval: ReturnType } beforeEach(() => { @@ -351,8 +361,10 @@ describe('registerWorktreeHandlers', () => { killAllProcessesForWorktreeMock, clearProviderPtyStateMock, getLocalPtyProviderMock, + getSshPtyProviderMock, deleteWorktreeHistoryDirMock, advertisedUrlWatcherForgetWorktreeMock, + findExistingWorktreeSymlinkPathsMock, removeWorktreeLinkedPathsMock ]) { m.mockReset() @@ -363,7 +375,9 @@ describe('registerWorktreeHandlers', () => { registryStopped: 0 }) assertWorktreeCleanForRemovalMock.mockResolvedValue(undefined) + findExistingWorktreeSymlinkPathsMock.mockResolvedValue([]) getLocalPtyProviderMock.mockReturnValue({} as never) + getSshPtyProviderMock.mockReturnValue({} as never) for (const key of Object.keys(handlers)) { delete handlers[key] @@ -501,8 +515,23 @@ describe('registerWorktreeHandlers', () => { tabId: 'tab-startup', paneRuntimeId: -1 }), - notifyWorktreesChangedForRemoteClients: vi.fn() + notifyWorktreesChangedForRemoteClients: vi.fn(), + closeFileWatchersForRemoval: vi.fn().mockResolvedValue(undefined), + acquireFileWatcherRemoval: vi.fn() } + runtimeStub.acquireFileWatcherRemoval.mockImplementation( + async (worktreePath: string, connectionId?: string) => { + await ( + runtimeStub.closeFileWatchersForRemoval as ( + worktreePath: string, + connectionId?: string + ) => Promise + )(worktreePath, connectionId) + return { + finish: vi.fn().mockResolvedValue(undefined) + } + } + ) registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) @@ -5992,6 +6021,45 @@ describe('registerWorktreeHandlers', () => { }) }) + it('omits prunable worktrees from worktrees:listAll', async () => { + // Why: a prunable registration has no working directory (issue #8389), so + // surfacing it as a workspace yields repeated pty:spawn/fs:readDir + // failures and a blank pane. + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/stale-wt', + head: 'def456', + branch: 'refs/heads/stale', + isBare: false, + prunable: true, + prunableReason: 'gitdir file points to non-existent location', + isMainWorktree: false + }, + { + path: '/workspace/live-wt', + head: 'fed789', + branch: 'refs/heads/live', + isBare: false, + isMainWorktree: false + } + ]) + store.getWorktreeMeta.mockReturnValue(undefined) + store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 }) + + const listed = (await handlers['worktrees:listAll'](null, undefined)) as { id: string }[] + const listedIds = listed.map((worktree) => worktree.id) + + expect(listedIds).toContain('repo-1::/workspace/live-wt') + expect(listedIds).not.toContain('repo-1::/workspace/stale-wt') + }) + it('limits concurrent repo scans in worktrees:listAll while preserving order', async () => { const repos = Array.from({ length: 10 }, (_, index) => ({ id: `repo-${index}`, @@ -6778,6 +6846,49 @@ describe('registerWorktreeHandlers', () => { ) }) + it('does not remove a worktree when watcher teardown cannot release it', async () => { + mockKnownFeatureWorktree() + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + symlinkPaths: ['node_modules'] + }) + runtimeStub.closeFileWatchersForRemoval.mockRejectedValue( + new Error('file watcher process did not exit after termination deadline') + ) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + ).rejects.toThrow('file watcher process did not exit after termination deadline') + + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + + it('releases the watcher-install fence when worktree deletion fails', async () => { + mockKnownFeatureWorktree() + const finish = vi.fn().mockResolvedValue(undefined) + runtimeStub.acquireFileWatcherRemoval.mockResolvedValueOnce({ + finish + }) + removeWorktreeMock.mockRejectedValueOnce(new Error('delete failed')) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + ).rejects.toThrow('delete failed') + + expect(finish).toHaveBeenCalledWith(false) + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + it('skips the archive hook on remove when skipArchive is true', async () => { mockKnownFeatureWorktree() removeWorktreeMock.mockResolvedValue(undefined) @@ -6819,6 +6930,9 @@ describe('registerWorktreeHandlers', () => { worktreeBaseRef: null } const callOrder: string[] = [] + runtimeStub.closeFileWatchersForRemoval.mockImplementationOnce(async () => { + callOrder.push('watchers') + }) const provider = { listWorktrees: vi.fn().mockResolvedValue([ { @@ -6877,7 +6991,11 @@ describe('registerWorktreeHandlers', () => { }) ) expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) - expect(callOrder).toEqual(['archive', 'preflight', 'remove']) + expect(runtimeStub.closeFileWatchersForRemoval).toHaveBeenCalledWith( + '/remote/feature-wt', + 'conn-1' + ) + expect(callOrder).toEqual(['archive', 'preflight', 'watchers', 'remove']) expect(runHookMock).not.toHaveBeenCalled() }) @@ -7745,7 +7863,10 @@ describe('registerWorktreeHandlers', () => { }) await expect(lstat(orphanPath)).rejects.toMatchObject({ code: 'ENOENT' }) - expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith( + worktreeId, + expect.objectContaining({ requirePhysicalStop: true }) + ) expect(runHookMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) @@ -7837,7 +7958,10 @@ describe('registerWorktreeHandlers', () => { ).resolves.toEqual({}) await expect(lstat(leftoverPath)).rejects.toMatchObject({ code: 'ENOENT' }) - expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith( + worktreeId, + expect.objectContaining({ requirePhysicalStop: true }) + ) expect(runHookMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) @@ -8146,13 +8270,31 @@ describe('registerWorktreeHandlers', () => { 'repo-1::/workspace/feature-wt', expect.objectContaining({ localProvider: expect.anything(), - onPtyStopped: clearProviderPtyStateMock + onPtyStopped: clearProviderPtyStateMock, + requirePhysicalStop: true }) ) expect(removeWorktreeMock).toHaveBeenCalled() expect(callOrder).toEqual(['preflight', 'kill', 'git']) }) + it('does not start Git removal when physical PTY teardown cannot be proven', async () => { + mockKnownFeatureWorktree() + getEffectiveHooksMock.mockReturnValue(null) + killAllProcessesForWorktreeMock.mockRejectedValueOnce( + new Error('Timed out waiting for physical PTY teardown') + ) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + ).rejects.toThrow('Timed out waiting for physical PTY teardown') + + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + it('routes local worktree removal through the selected WSL project runtime', async () => { mockSelectedWslProjectRuntime() mockKnownFeatureWorktree() @@ -8193,6 +8335,16 @@ describe('registerWorktreeHandlers', () => { it('fails dirty non-force deletes before PTY teardown', async () => { mockKnownFeatureWorktree() + const repoWithConfiguredRegularFile = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + symlinkPaths: ['scratch.txt'] + } + store.getRepo.mockReturnValue(repoWithConfiguredRegularFile) + store.getRepos.mockReturnValue([repoWithConfiguredRegularFile]) getEffectiveHooksMock.mockReturnValue(null) assertWorktreeCleanForRemovalMock.mockRejectedValue( Object.assign(new Error('Worktree has uncommitted or untracked changes.'), { @@ -8206,6 +8358,28 @@ describe('registerWorktreeHandlers', () => { }) ).rejects.toThrow('Failed to delete worktree at /workspace/feature-wt. ?? scratch.txt') + expect(findExistingWorktreeSymlinkPathsMock).toHaveBeenCalledWith('/workspace/feature-wt', [ + 'scratch.txt' + ]) + expect(assertWorktreeCleanForRemovalMock).toHaveBeenCalledWith('/workspace/feature-wt', false) + expect(runtimeStub.closeFileWatchersForRemoval).not.toHaveBeenCalled() + expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled() + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('propagates a timed-out removal preflight before watcher teardown', async () => { + mockKnownFeatureWorktree() + getEffectiveHooksMock.mockReturnValue(null) + assertWorktreeCleanForRemovalMock.mockRejectedValue(new Error('git timed out.')) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + ).rejects.toThrow('Failed to delete worktree at /workspace/feature-wt. git timed out.') + + expect(runtimeStub.closeFileWatchersForRemoval).not.toHaveBeenCalled() expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() }) @@ -8330,17 +8504,17 @@ describe('registerWorktreeHandlers', () => { worktreeId: 'repo-1::/workspace/feature-wt' }) - expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith( + 'repo-1::/workspace/feature-wt', + expect.objectContaining({ requirePhysicalStop: true }) + ) expect(removeWorktreeMock).toHaveBeenCalled() expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { cwd: '/workspace/repo' }) }) - it('skips the PTY teardown for SSH-backed repos (design §6 out-of-scope)', async () => { - // Why: SSH-backed PTYs live on the remote host and are handled by the - // remote provider's own teardown. The local-host helper must not run for - // SSH repos, because it would sweep registry entries for other worktrees. + it('fails closed when the SSH PTY provider is unavailable', async () => { const repo = { id: 'repo-ssh', path: '/remote/repo', @@ -8352,16 +8526,32 @@ describe('registerWorktreeHandlers', () => { } store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) + const removeWorktree = vi.fn() + getSshGitProviderMock.mockReturnValue({ + listWorktrees: vi.fn().mockResolvedValue([ + { path: '/remote/repo', isMainWorktree: true }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isMainWorktree: false + } + ]), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), + removeWorktree + }) + getSshFilesystemProviderMock.mockReturnValue({ + readFile: vi.fn().mockRejectedValue(new Error('missing')) + }) + getSshPtyProviderMock.mockReturnValue(undefined) - // The test can't easily mock the SSH provider without more plumbing — the - // call will throw about 'no git provider for connection'. What matters - // here is that the kill helper was NOT called for the SSH branch. - await ( + await expect( handlers['worktrees:remove'](null, { worktreeId: 'repo-ssh::/remote/feature-wt' - }) as Promise - ).catch(() => {}) + }) + ).rejects.toThrow('PTY provider unavailable for worktree deletion') + expect(removeWorktree).not.toHaveBeenCalled() expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index a11a0012784..9e7ff509b88 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -88,11 +88,10 @@ import { isENOENT, registerWorktreeRootsForRepo } from './filesystem-auth' -import { closeLocalWatcherForWorktreePath } from './filesystem-watcher' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { killAllProcessesForWorktree } from '../runtime/worktree-teardown' -import { clearProviderPtyState, getLocalPtyProvider } from './pty' -import { removeWorktreeLinkedPaths } from './worktree-symlinks' +import { clearProviderPtyState, getLocalPtyProvider, getSshPtyProvider } from './pty' +import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from './worktree-symlinks' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' @@ -114,6 +113,31 @@ type RemoveWorktreeArgs = { skipArchive?: boolean } +async function stopPtysForDestructiveWorktreeRemoval( + runtime: OrcaRuntimeService, + worktreeId: string, + connectionId?: string +): Promise { + const provider = connectionId ? getSshPtyProvider(connectionId) : getLocalPtyProvider() + if (!provider) { + throw new Error(`PTY provider unavailable for worktree deletion: ${worktreeId}`) + } + const teardownResult = await killAllProcessesForWorktree(worktreeId, { + runtime, + localProvider: provider, + onPtyStopped: clearProviderPtyState, + requirePhysicalStop: true, + ...(connectionId ? { includeLocalRegistry: false } : {}) + }) + const total = + teardownResult.runtimeStopped + teardownResult.providerStopped + teardownResult.registryStopped + if (total > 0) { + console.info( + `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` + ) + } +} + function getRepoForWorktreeRemoval( store: Store, repoId: string, @@ -208,12 +232,6 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin pruneWorktreePRRefreshAliases(worktreeId) } -async function closeLocalWatcherForRemoval(worktreePath: string): Promise { - await closeLocalWatcherForWorktreePath(worktreePath).catch((err) => { - console.warn(`[filesystem-watcher] failed to close ${worktreePath}:`, err) - }) -} - function getProjectHostSetupMetaUpdates( store: Store, repo: Repo, @@ -733,7 +751,10 @@ function buildDetectedGitWorktrees( const settings = store.getSettings() const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) - return dedupeWorktreesByPath(gitWorktrees).map((gitWorktree) => { + // Why: a prunable registration has no working directory (issue #8389); only + // this listing omits it — removal/cleanup flows list worktrees separately. + const liveWorktrees = gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable) + return dedupeWorktreesByPath(liveWorktrees).map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` let meta = store.getWorktreeMeta(worktreeId) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) @@ -1498,7 +1519,22 @@ export function registerWorktreeHandlers( throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) } if (repo.connectionId) { - await fsProvider!.deletePath(worktreePath, true) + const removalGate = await runtime.acquireFileWatcherRemoval( + worktreePath, + repo.connectionId + ) + let removalCompleted = false + try { + await stopPtysForDestructiveWorktreeRemoval( + runtime, + args.worktreeId, + repo.connectionId + ) + await fsProvider!.deletePath(worktreePath, true) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } await cleanupUnusedWorktreePushTargetRemoteSsh( provider!, repo.path, @@ -1507,8 +1543,15 @@ export function registerWorktreeHandlers( store ) } else { - await closeLocalWatcherForRemoval(worktreePath) - await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) + let removalCompleted = false + try { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId) + await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } await cleanupUnusedWorktreePushTargetRemote( repo.path, args.worktreeId, @@ -1545,8 +1588,15 @@ export function registerWorktreeHandlers( if (!args.force) { throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) } - await closeLocalWatcherForRemoval(worktreePath) - await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) + let removalCompleted = false + try { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId) + await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } await cleanupUnusedWorktreePushTargetRemote( repo.path, args.worktreeId, @@ -1648,8 +1698,6 @@ export function registerWorktreeHandlers( return removalResult ?? {} } - let shouldTearDownPtys = true - // Run archive hook before removal so teardown scripts still see the worktree directory. const hooks = await getArchiveHooksForRemoval(repo) if (hooks?.scripts.archive && !args.skipArchive) { @@ -1683,9 +1731,21 @@ export function registerWorktreeHandlers( } const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} - const rawRemovalResult = await (Object.keys(remoteRemoveOptions).length > 0 - ? provider!.removeWorktree(canonicalWorktreePath, args.force, remoteRemoveOptions) - : provider!.removeWorktree(canonicalWorktreePath, args.force)) + const removalGate = await runtime.acquireFileWatcherRemoval( + canonicalWorktreePath, + repo.connectionId + ) + let rawRemovalResult: RemoveWorktreeResult | undefined + let removalCompleted = false + try { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, repo.connectionId) + rawRemovalResult = await (Object.keys(remoteRemoveOptions).length > 0 + ? provider!.removeWorktree(canonicalWorktreePath, args.force, remoteRemoveOptions) + : provider!.removeWorktree(canonicalWorktreePath, args.force)) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } const removalResult = preserveBranchHeadFallback( rawRemovalResult, registeredWorktree.head @@ -1732,139 +1792,135 @@ export function registerWorktreeHandlers( ) } - // Why: `git worktree remove` (non-force) refuses to delete a worktree - // that has untracked files. User-configured symlinks look untracked, - // so unlink them before the clean check; regular APFS clone-copied - // files are left for git to judge so we never delete user edits. - if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { - await removeWorktreeLinkedPaths(canonicalWorktreePath, repo.symlinkPaths) - } - + const linkedPaths = repo.symlinkPaths ?? [] + const ignoredLinkedPaths = args.force + ? [] + : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) try { await (hasLocalWorktreeGitOptions - ? assertWorktreeCleanForRemoval( - canonicalWorktreePath, - args.force ?? false, - localWorktreeGitOptions - ) - : assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false)) + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { + ...localWorktreeGitOptions, + ...(ignoredLinkedPaths.length > 0 + ? { ignoredUntrackedPaths: ignoredLinkedPaths } + : {}) + }) + : ignoredLinkedPaths.length > 0 + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { + ignoredUntrackedPaths: ignoredLinkedPaths + }) + : assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false)) } catch (error) { if (!isOrphanCompatiblePreflightError(error)) { throw new Error( formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) ) } - // Why: orphan cleanup does not need live shells to be killed first, - // and preflight did not prove the worktree is cleanly removable. - shouldTearDownPtys = false - } - - await closeLocalWatcherForRemoval(canonicalWorktreePath) - - if (shouldTearDownPtys) { - // Why: once preflight proves normal deletion is clean, kill PTYs before - // git-level removal so Windows handles cannot keep the directory busy. - await killAllProcessesForWorktree(args.worktreeId, { - runtime, - localProvider: getLocalPtyProvider(), - onPtyStopped: clearProviderPtyState - }) - .then((r) => { - const total = r.runtimeStopped + r.providerStopped + r.registryStopped - if (total > 0) { - console.info( - `[worktree-teardown] ${args.worktreeId} killed runtime=${r.runtimeStopped} provider=${r.providerStopped} registry=${r.registryStopped}` - ) - } - }) - .catch((err) => { - console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) - }) + // Why: Git can still classify this as an orphan after preflight; + // retain strict PTY teardown before any recursive fallback deletion. } let removalResult: RemoveWorktreeResult | undefined + const removalGate = await runtime.acquireFileWatcherRemoval(canonicalWorktreePath) + let removalCompleted = false try { - const removeOptions = { - ...(!deleteBranch ? { deleteBranch } : {}), - // Why: this handler already paid for an authoritative worktree - // list to validate the target; reuse it instead of rescanning - // every sibling worktree during the hot delete path. - knownRemovedWorktree: refreshedRegisteredWorktree, - ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}) + // Why: preflight ignores only these configured paths without mutating + // the worktree; keep new watcher installs fenced through Git removal. + if (linkedPaths.length > 0) { + await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths) } - removalResult = preserveBranchHeadFallback( - await removeWorktree( - repo.path, - canonicalWorktreePath, - args.force ?? false, - removeOptions - ), - refreshedRegisteredWorktree.head - ) - } catch (error) { - // Why: Git for Windows can deregister a clean worktree before its - // recursive filesystem deletion fails transiently. - const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ - error, - force: args.force ?? false, - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree: refreshedRegisteredWorktree, - deleteBranch, - closeWatcher: closeLocalWatcherForRemoval - }) - if (recoveredRemovalResult) { - removalResult = recoveredRemovalResult - } else if (isOrphanedWorktreeError(error)) { - // If git no longer tracks this worktree, clean up the directory and metadata - console.warn( - `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` + + // Why: hold the watcher/terminal gate until Git and any recursive + // fallback complete, so no late spawn can recreate a native handle. + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId) + + try { + const removeOptions = { + ...(!deleteBranch ? { deleteBranch } : {}), + // Why: this handler already paid for an authoritative worktree + // list to validate the target; reuse it instead of rescanning + // every sibling worktree during the hot delete path. + knownRemovedWorktree: refreshedRegisteredWorktree, + ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}) + } + removalResult = preserveBranchHeadFallback( + await removeWorktree( + repo.path, + canonicalWorktreePath, + args.force ?? false, + removeOptions + ), + refreshedRegisteredWorktree.head ) - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - if ( - await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - ) - ) { - await closeLocalWatcherForRemoval(canonicalWorktreePath) - await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( - () => {} - ) - } else { + } catch (error) { + // Why: Git for Windows can deregister a clean worktree before its + // recursive filesystem deletion fails transiently. + const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ + error, + force: args.force ?? false, + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree: refreshedRegisteredWorktree, + deleteBranch, + closeWatcher: (worktreePath) => runtime.closeFileWatchersForRemoval(worktreePath) + }) + if (recoveredRemovalResult) { + removalResult = recoveredRemovalResult + removalCompleted = true + } else if (isOrphanedWorktreeError(error)) { + // If git no longer tracks this worktree, clean up the directory and metadata console.warn( - `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` + `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` + ) + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + if ( + await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + ) + ) { + await runtime.closeFileWatchersForRemoval(canonicalWorktreePath) + await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( + () => {} + ) + } else { + console.warn( + `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` + ) + } + // Why: `git worktree remove` failed, so git's internal worktree tracking + // (`.git/worktrees/`) is still intact. Without pruning, `git worktree + // list` continues to show the stale entry and the branch it had checked out + // remains locked — other worktrees cannot check it out. + await gitExecFileAsync(['worktree', 'prune'], { + cwd: repo.path, + ...localWorktreeGitOptions + }).catch(() => {}) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + removalCompleted = true + return {} + } else { + throw new Error( + formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) ) } - // Why: `git worktree remove` failed, so git's internal worktree tracking - // (`.git/worktrees/`) is still intact. Without pruning, `git worktree - // list` continues to show the stale entry and the branch it had checked out - // remains locked — other worktrees cannot check it out. - await gitExecFileAsync(['worktree', 'prune'], { - cwd: repo.path, - ...localWorktreeGitOptions - }).catch(() => {}) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) - invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) - return {} - } else { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) } + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) } await cleanupUnusedWorktreePushTargetRemote( repo.path, diff --git a/src/main/ipc/wsl-watcher-process-exit.ts b/src/main/ipc/wsl-watcher-process-exit.ts new file mode 100644 index 00000000000..ebb897dfe96 --- /dev/null +++ b/src/main/ipc/wsl-watcher-process-exit.ts @@ -0,0 +1,106 @@ +import type { ChildProcessWithoutNullStreams } from 'node:child_process' +import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' +import { WatcherProcessFailure } from './parcel-watcher-process-failure' + +export const WSL_WATCHER_PHYSICAL_EXIT_TIMEOUT_MS = 8_000 + +export function createWslWatcherStartup(): { + ready: Promise + readonly settled: boolean + settle: (error?: Error) => void +} { + let settled = false + let resolve!: () => void + let reject!: (error: Error) => void + const ready = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { + ready, + get settled() { + return settled + }, + settle(error) { + if (settled) { + return + } + settled = true + if (error) { + reject(error) + } else { + resolve() + } + } + } +} + +export function createWslWatcherProcessExit( + child: ChildProcessWithoutNullStreams, + worktreePath: string, + onPhysicalExit?: () => void +): { + markPhysicalExit: () => void + requestStopBestEffort: () => void + stopAndWait: () => Promise +} { + let physicalExited = false + let stopRequested = false + const physicalExit = new PhysicalExitTracker() + const markPhysicalExit = (): void => { + if (!physicalExited) { + physicalExited = true + physicalExit.markExited() + onPhysicalExit?.() + } + } + const requestStop = (): void => { + if (physicalExited || stopRequested) { + return + } + stopRequested = true + try { + if (!child.kill()) { + throw new Error('WSL watcher process rejected the termination signal') + } + } catch (error) { + stopRequested = false + throw error + } + } + const requestStopBestEffort = (): void => { + try { + requestStop() + } catch { + // The awaited owner retries and retains the child on failure. + } + } + const stopAndWait = async (): Promise => { + try { + requestStop() + } catch (error) { + throw processExitFailure( + `Failed to terminate WSL watcher: ${String(error)}`, + physicalExit.exitedPromise + ) + } + await waitForPhysicalExit(worktreePath, physicalExit) + } + return { markPhysicalExit, requestStopBestEffort, stopAndWait } +} + +function waitForPhysicalExit( + worktreePath: string, + physicalExit: PhysicalExitTracker +): Promise { + return physicalExit.waitForExit(WSL_WATCHER_PHYSICAL_EXIT_TIMEOUT_MS, () => + processExitFailure( + `WSL watcher process did not exit after deadline: ${worktreePath}`, + physicalExit.exitedPromise + ) + ) +} + +function processExitFailure(message: string, physicalExit: Promise): WatcherProcessFailure { + return new WatcherProcessFailure(message, 'supervisor', 'process_unavailable', physicalExit) +} diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index 41cb70c5beb..0c0ee11511c 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' @@ -402,6 +402,227 @@ describe('Jira client credential storage', () => { expect(jira.isAuthError(new jira.JiraApiError('Forbidden', 403))).toBe(false) }) + it('connects to self-hosted Jira with a Bearer PAT against REST v2', async () => { + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + name: 'wquintal', + key: 'JIRAUSER10101', + displayName: 'William', + emailAddress: 'william@example.com' + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'jira.example.com', + email: '', + apiToken: 'pat-token', + authType: 'server' + }) + ).resolves.toMatchObject({ + ok: true, + // Server /myself has no accountId; the username stands in for it. + viewer: { displayName: 'William', accountId: 'wquintal' } + }) + + expect(netFetchMock).toHaveBeenCalledWith( + 'https://jira.example.com/rest/api/2/myself', + expect.objectContaining({ headers: expect.any(Headers) }) + ) + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe('Bearer pat-token') + }) + + it('connects to self-hosted Jira with Basic username/password against REST v2', async () => { + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ name: 'jdoe', key: 'JIRAUSER20202', displayName: 'Jane Doe' }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'jira.example.com', + // A username present means classic Basic auth (older Server/DC that + // predate PATs); the token slot carries the account password. + email: 'jdoe', + apiToken: 'account-password', + authType: 'server' + }) + ).resolves.toMatchObject({ + ok: true, + viewer: { displayName: 'Jane Doe', accountId: 'jdoe' } + }) + + expect(netFetchMock).toHaveBeenCalledWith( + 'https://jira.example.com/rest/api/2/myself', + expect.objectContaining({ headers: expect.any(Headers) }) + ) + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe( + `Basic ${Buffer.from('jdoe:account-password').toString('base64')}` + ) + }) + + it('uses Basic auth for stored self-hosted sites that carry a username', async () => { + const siteId = 'site-server-basic' + const orcaDir = join(tempHome, '.orca') + mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true }) + writeFileSync( + join(orcaDir, 'jira-sites.json'), + JSON.stringify({ + version: 1, + activeSiteId: siteId, + selectedSiteId: siteId, + sites: [ + { + id: siteId, + siteUrl: 'https://jira.example.com', + email: 'jdoe', + displayName: 'Jane Doe', + accountId: 'jdoe', + authType: 'server' + } + ] + }), + { encoding: 'utf-8' } + ) + writeFileSync(tokenPathForSite(siteId), 'account-password') + netFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'jdoe', displayName: 'Jane Doe' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + const jira = await loadClientModule() + + await expect(jira.testConnection(siteId)).resolves.toMatchObject({ ok: true }) + + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe( + `Basic ${Buffer.from('jdoe:account-password').toString('base64')}` + ) + }) + + it('requires a token but not an email for self-hosted connections', async () => { + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'jira.example.com', + email: '', + apiToken: '', + authType: 'server' + }) + ).resolves.toEqual({ ok: false, error: 'Personal access token is required.' }) + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('requires a password when a username is present on self-hosted', async () => { + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'jira.example.com', + email: 'jdoe', + apiToken: '', + authType: 'server' + }) + ).resolves.toEqual({ ok: false, error: 'Password is required.' }) + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('keeps distinct self-hosted PAT accounts on one host as separate sites', async () => { + netFetchMock + .mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'alice', displayName: 'Alice' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'bot', displayName: 'Bot' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + const jira = await loadClientModule() + + await jira.connect({ + siteUrl: 'jira.example.com', + email: '', + apiToken: 'alice-pat', + authType: 'server' + }) + await jira.connect({ + siteUrl: 'jira.example.com', + email: '', + apiToken: 'bot-pat', + authType: 'server' + }) + + // Two PATs (both with empty email) to the same host must not collide onto + // one id and silently overwrite each other — the viewer identity keys them. + const stored = JSON.parse( + readFileSync(join(tempHome, '.orca', 'jira-sites.json'), 'utf-8') + ) as { + sites: { accountId: string }[] + } + expect(stored.sites).toHaveLength(2) + expect(stored.sites.map((site) => site.accountId).sort()).toEqual(['alice', 'bot']) + }) + + it('uses Bearer auth and REST v2 for stored self-hosted sites', async () => { + const siteId = 'site-server' + const orcaDir = join(tempHome, '.orca') + mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true }) + writeFileSync( + join(orcaDir, 'jira-sites.json'), + JSON.stringify({ + version: 1, + activeSiteId: siteId, + selectedSiteId: siteId, + sites: [ + { + id: siteId, + siteUrl: 'https://jira.example.com', + email: '', + displayName: 'William', + accountId: 'wquintal', + authType: 'server' + } + ] + }), + { encoding: 'utf-8' } + ) + writeFileSync(tokenPathForSite(siteId), 'pat-token') + netFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'wquintal', displayName: 'William' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + const jira = await loadClientModule() + + await expect(jira.testConnection(siteId)).resolves.toMatchObject({ + ok: true, + viewer: { displayName: 'William' } + }) + + expect(netFetchMock).toHaveBeenCalledWith( + 'https://jira.example.com/rest/api/2/myself', + expect.objectContaining({ headers: expect.any(Headers) }) + ) + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe('Bearer pat-token') + }) + it('bridges proxy environment settings before Jira connect requests', async () => { netFetchMock.mockResolvedValueOnce( new Response( diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index cce8ee85b67..4c182ee2ad5 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -14,6 +14,7 @@ import { import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' import { withSpan } from '../observability/tracer' import type { + JiraAuthType, JiraConnectArgs, JiraConnectionStatus, JiraSite, @@ -65,6 +66,13 @@ export type JiraClientForSite = { authorization: string } +// Self-hosted Jira Server/Data Center only exposes REST v2; Cloud endpoints +// in this codebase are written against v3. Callers build paths with this +// prefix so one code path serves both deployments. +export function apiBasePath(site: JiraSite): string { + return site.authType === 'server' ? '/rest/api/2' : '/rest/api/3' +} + export class JiraApiError extends Error { status: number | null @@ -143,7 +151,9 @@ function normalizeSite(input: unknown): JiraSite | null { siteUrl: record.siteUrl, email: record.email, displayName: record.displayName, - accountId: record.accountId + accountId: record.accountId, + // Sites saved before self-hosted support have no authType; they are Cloud. + authType: record.authType === 'server' ? 'server' : 'cloud' } } @@ -284,8 +294,17 @@ function getSiteId(siteUrl: string, email: string): string { function toViewer(data: Record, fallbackEmail: string): JiraViewer { const avatarUrls = data.avatarUrls as Record | undefined + // Server/DC /myself has no accountId; its stable identifiers are name/key. + const accountId = + typeof data.accountId === 'string' + ? data.accountId + : typeof data.name === 'string' + ? data.name + : typeof data.key === 'string' + ? data.key + : '' return { - accountId: typeof data.accountId === 'string' ? data.accountId : '', + accountId, displayName: typeof data.displayName === 'string' ? data.displayName : fallbackEmail, email: typeof data.emailAddress === 'string' ? data.emailAddress : fallbackEmail, avatarUrl: @@ -308,7 +327,14 @@ function siteToViewer(site: JiraSite | null): JiraViewer | null { } } -function authHeader(email: string, apiToken: string): string { +function authHeader(email: string, apiToken: string, authType?: JiraAuthType): string { + // Self-hosted with no username = a personal access token (Bearer); Basic auth + // with a PAT in the password slot is what produces the 401s users report. + // Self-hosted WITH a username is classic username+password Basic auth, which + // older Server/DC instances (predating PATs) require. Cloud is always Basic. + if (authType === 'server' && !email) { + return `Bearer ${apiToken}` + } return `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}` } @@ -366,13 +392,14 @@ async function requestWithCredentials( email: string, apiToken: string, path: string, - init?: RequestInit + init?: RequestInit, + authType?: JiraAuthType ): Promise { const headers = new Headers(init?.headers) headers.set('Accept', 'application/json') headers.set('Content-Type', 'application/json') headers.set('User-Agent', JIRA_API_USER_AGENT) - headers.set('Authorization', authHeader(email, apiToken)) + headers.set('Authorization', authHeader(email, apiToken, authType)) const response = await jiraFetch(`${siteUrl}${path}`, { ...init, headers @@ -453,7 +480,7 @@ export function getClients(selection?: JiraSiteSelection | null): JiraClientForS } throw error } - return token ? [{ site, authorization: authHeader(site.email, token) }] : [] + return token ? [{ site, authorization: authHeader(site.email, token, site.authType) }] : [] }) } @@ -484,28 +511,48 @@ export async function connect( return { ok: false, error: 'Enter a valid Jira site URL.' } } + const authType: JiraAuthType = args.authType === 'server' ? 'server' : 'cloud' const email = args.email.trim() const apiToken = args.apiToken.trim() - if (!email || !apiToken) { + if (authType === 'server') { + if (!apiToken) { + // A username present means classic Basic auth (password); its absence + // means the credential is a personal access token sent as Bearer. + return { + ok: false, + error: email ? 'Password is required.' : 'Personal access token is required.' + } + } + } else if (!email || !apiToken) { return { ok: false, error: 'Email and API token are required.' } } await acquire() try { + const myselfPath = authType === 'server' ? '/rest/api/2/myself' : '/rest/api/3/myself' const viewer = toViewer( - (await requestWithCredentials(siteUrl, email, apiToken, '/rest/api/3/myself')) as Record< - string, - unknown - >, - email + (await requestWithCredentials( + siteUrl, + email, + apiToken, + myselfPath, + undefined, + authType + )) as Record, + email || siteUrl ) - const id = getSiteId(siteUrl, email) + // PAT sites have no email, so keying on it alone would collide every PAT + // connection to the same host into one id (silently overwriting a prior + // account + token). Fall back to the verified viewer identity so distinct + // accounts stay distinct. Cloud/Basic keep keying on their non-empty email. + const id = getSiteId(siteUrl, email || viewer.accountId) const site: JiraSite = { id, siteUrl, email, displayName: viewer.displayName, - accountId: viewer.accountId + accountId: viewer.accountId, + authType } saveToken(id, apiToken) const file = getSiteFile() @@ -565,7 +612,7 @@ export async function testConnection( await acquire() try { const viewer = toViewer( - (await jiraRequest(client, '/rest/api/3/myself')) as Record, + (await jiraRequest(client, `${apiBasePath(client.site)}/myself`)) as Record, client.site.email ) return { ok: true, viewer } diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 1cf9ab31212..4b20b0c4a2f 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -12,6 +12,8 @@ const { clearTokenMock, getClientsMock, isAuthErrorMock, jiraRequestMock } = vi. vi.mock('./client', () => ({ acquire: vi.fn().mockResolvedValue(undefined), release: vi.fn(), + apiBasePath: (site: { authType?: string }) => + site.authType === 'server' ? '/rest/api/2' : '/rest/api/3', clearToken: (...args: unknown[]) => clearTokenMock(...args), getClients: (...args: unknown[]) => getClientsMock(...args), isAuthError: (...args: unknown[]) => isAuthErrorMock(...args), @@ -31,6 +33,20 @@ function makeEntry(id = 'site-1'): JiraClientForSite { } } +function makeServerEntry(id = 'server-1'): JiraClientForSite { + return { + site: { + id, + siteUrl: 'https://jira.example.com', + email: '', + displayName: 'Self-hosted Jira', + accountId: 'wquintal', + authType: 'server' + }, + authorization: 'Bearer pat-token' + } +} + describe('Jira issue operations', () => { beforeEach(() => { vi.clearAllMocks() @@ -60,6 +76,67 @@ describe('Jira issue operations', () => { ).rejects.toThrow(error.message) }) + it('uses classic /search and REST v2 for self-hosted sites', async () => { + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock.mockResolvedValueOnce({ issues: [] }) + const { searchIssues } = await import('./issues') + + await searchIssues('project = ALP', 20, 'server-1') + + expect(jiraRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ site: expect.objectContaining({ authType: 'server' }) }), + '/rest/api/2/search', + expect.objectContaining({ method: 'POST' }) + ) + }) + + it('sends plain-text bodies and v2 paths for self-hosted issue creation', async () => { + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock.mockResolvedValueOnce({ id: '1', key: 'ALP-1', self: '' }) + const { createIssue } = await import('./issues') + + await createIssue({ + siteId: 'server-1', + projectId: '10000', + issueTypeId: '10001', + title: 'Fix auth', + description: 'Body text' + }) + + const [, path, init] = jiraRequestMock.mock.calls[0] + expect(path).toBe('/rest/api/2/issue') + const body = JSON.parse((init as { body: string }).body) as { + fields: { description: unknown } + } + // REST v2 rejects ADF documents; the description must stay a plain string. + expect(body.fields.description).toBe('Body text') + }) + + it('assigns by username on self-hosted sites', async () => { + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock.mockResolvedValue(null) + const { updateIssue } = await import('./issues') + + await updateIssue('ALP-1', { assigneeAccountId: 'wquintal' }, 'server-1') + + expect(jiraRequestMock).toHaveBeenCalledWith( + expect.anything(), + '/rest/api/2/issue/ALP-1/assignee', + expect.objectContaining({ body: JSON.stringify({ name: 'wquintal' }) }) + ) + }) + + it('lists self-hosted projects from the unpaged /project resource', async () => { + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock.mockResolvedValueOnce([{ id: '1', key: 'ALP', name: 'Alpha' }]) + const { listProjects } = await import('./issues') + + const projects = await listProjects('server-1') + + expect(jiraRequestMock).toHaveBeenCalledWith(expect.anything(), '/rest/api/2/project') + expect(projects).toMatchObject([{ key: 'ALP', name: 'Alpha' }]) + }) + it('rejects single-site search failures so the UI can surface them', async () => { getClientsMock.mockReturnValue([makeEntry('site-1')]) jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')) diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 5a19b9897b3..2ce24a87420 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -23,6 +23,7 @@ import type { } from '../../shared/types' import { acquire, + apiBasePath, clearToken, getClients, isAuthError, @@ -191,7 +192,8 @@ function avatarUrl(value: unknown): string | undefined { function mapUser(value: unknown): JiraUser | undefined { const user = asRecord(value) - const accountId = asString(user.accountId) + // Server/DC users have no accountId; name (login) and key are its stable ids. + const accountId = asString(user.accountId) || asString(user.name) || asString(user.key) if (!accountId) { return undefined } @@ -307,6 +309,11 @@ function issueUrl(site: JiraSite, key: string): string { return `${site.siteUrl}/browse/${encodeURIComponent(key)}` } +// REST v2 (Server/DC) bodies are plain text; v3 (Cloud) requires ADF documents. +function toBodyText(site: JiraSite, text: string): unknown { + return site.authType === 'server' ? text : textToAdf(text) +} + export function mapJiraIssue(site: JiraSite, raw: JiraRecord): JiraIssue { const fields = asRecord(raw.fields) const key = asString(raw.key) @@ -354,7 +361,12 @@ async function searchIssuesForClient( jql: string, limit: number ): Promise { - const result = await jiraRequest(entry, '/rest/api/3/search/jql', { + // Server/DC only has the classic /search resource; /search/jql is Cloud-only. + const searchPath = + entry.site.authType === 'server' + ? `${apiBasePath(entry.site)}/search` + : '/rest/api/3/search/jql' + const result = await jiraRequest(entry, searchPath, { method: 'POST', body: JSON.stringify({ jql, @@ -429,7 +441,7 @@ export async function getIssue( try { const issue = await jiraRequest( entry, - `/rest/api/3/issue/${encodeURIComponent(key)}?fields=${encodeURIComponent( + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?fields=${encodeURIComponent( ISSUE_FIELDS.join(',') )}` ) @@ -468,7 +480,7 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise( entry, - '/rest/api/3/issue', + `${apiBasePath(entry.site)}/issue`, { method: 'POST', body: JSON.stringify({ fields }) @@ -517,20 +529,27 @@ export async function updateIssue( if (updates.priorityId !== undefined) { fields.priority = updates.priorityId ? { id: updates.priorityId } : null } + const issueBase = `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}` if (Object.keys(fields).length > 0) { - await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}`, { + await jiraRequest(entry, issueBase, { method: 'PUT', body: JSON.stringify({ fields }) }) } if (updates.assigneeAccountId !== undefined) { - await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}/assignee`, { + // Server/DC identifies assignees by username (`name`), not accountId; + // mapUser stores the Server username in the accountId slot. + const assigneeBody = + entry.site.authType === 'server' + ? { name: updates.assigneeAccountId } + : { accountId: updates.assigneeAccountId } + await jiraRequest(entry, `${issueBase}/assignee`, { method: 'PUT', - body: JSON.stringify({ accountId: updates.assigneeAccountId }) + body: JSON.stringify(assigneeBody) }) } if (updates.transitionId) { - await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, { + await jiraRequest(entry, `${issueBase}/transitions`, { method: 'POST', body: JSON.stringify({ transition: { id: updates.transitionId } }) }) @@ -560,10 +579,10 @@ export async function addIssueComment( try { const comment = await jiraRequest<{ id: string }>( entry, - `/rest/api/3/issue/${encodeURIComponent(key)}/comment`, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment`, { method: 'POST', - body: JSON.stringify({ body: textToAdf(body) }) + body: JSON.stringify({ body: toBodyText(entry.site, body) }) } ) return { ok: true, id: comment.id } @@ -604,7 +623,7 @@ export async function getIssueComments( orderBy: 'created', startAt: String(startAt) }) - return `/rest/api/3/issue/${encodeURIComponent(key)}/comment?${params.toString()}` + return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}` }) return comments.map(mapComment) } catch (error) { @@ -628,13 +647,18 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise { await acquire() try { - const projects = await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { - const params = new URLSearchParams({ - maxResults: String(maxResults), - startAt: String(startAt) - }) - return `/rest/api/3/project/search?${params.toString()}` - }) + // Server/DC has no /project/search resource; /project returns the + // full list as a plain (unpaged) array. + const projects = + entry.site.authType === 'server' + ? await jiraRequest(entry, `${apiBasePath(entry.site)}/project`) + : await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + return `/rest/api/3/project/search?${params.toString()}` + }) return projects.map((project) => mapProject(project, entry.site)) } catch (error) { if (isAuthError(error)) { @@ -669,7 +693,8 @@ export async function listIssueTypes( maxResults: String(maxResults), startAt: String(startAt) }) - return `/rest/api/3/issue/createmeta/${encodeURIComponent( + // Per-project createmeta paths exist on Server/DC from Jira 8.4 onward. + return `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( projectIdOrKey )}/issuetypes?${params.toString()}` }) @@ -707,7 +732,7 @@ export async function listCreateFields( }) const response = await jiraRequest>( entry, - `/rest/api/3/issue/createmeta/${encodeURIComponent( + `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( projectIdOrKey )}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}` ) @@ -742,7 +767,7 @@ export async function listPriorities(siteId?: string | null): Promise(entry, '/rest/api/3/priority') + const response = await jiraRequest(entry, `${apiBasePath(entry.site)}/priority`) return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority) } catch (error) { if (isAuthError(error)) { @@ -765,15 +790,17 @@ export async function listAssignableUsers( if (!entry) { return [] } + const isServer = entry.site.authType === 'server' const params = new URLSearchParams({ issueKey: key, maxResults: '50' }) if (query?.trim()) { - params.set('query', query.trim()) + // Server/DC filters assignable users by `username`; `query` is Cloud-only. + params.set(isServer ? 'username' : 'query', query.trim()) } await acquire() try { const response = await jiraRequest( entry, - `/rest/api/3/user/assignable/search?${params.toString()}` + `${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}` ) return response.map(mapUser).filter((user): user is JiraUser => !!user) } catch (error) { @@ -800,7 +827,7 @@ export async function listTransitions( try { const response = await jiraRequest<{ transitions?: JiraRecord[] }>( entry, - `/rest/api/3/issue/${encodeURIComponent(key)}/transitions` + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/transitions` ) return (response.transitions ?? []).map((transition) => ({ id: asString(transition.id), diff --git a/src/main/linear/issues.test.ts b/src/main/linear/issues.test.ts index ef9ca38678e..2b7469caf7b 100644 --- a/src/main/linear/issues.test.ts +++ b/src/main/linear/issues.test.ts @@ -34,11 +34,12 @@ function makeEntry(options?: { } as unknown as LinearClientForWorkspace } -function rawIssue(id: string, updatedAt = '2026-01-01T00:00:00.000Z') { +function rawIssue(id: string, updatedAt = '2026-01-01T00:00:00.000Z', branchName?: string | null) { return { id, identifier: id, title: id, + branchName, description: 'Description', url: `https://linear.app/${id}`, estimate: 3, @@ -120,6 +121,17 @@ describe('Linear issue queries', () => { expect(rawRequest.mock.calls[0][0]).toContain('estimate') }) + it('maps Linear branch names from raw issue reads', async () => { + rawRequest.mockResolvedValueOnce({ + data: { issues: { nodes: [rawIssue('LIN-1', undefined, 'team/lin-1-fix')] } } + }) + const { listIssues } = await import('./issues') + + await expect(listIssues('all', 10, 'workspace-1')).resolves.toMatchObject({ + items: [{ branchName: 'team/lin-1-fix' }] + }) + }) + it('fetches issue comments with one request (no per-comment user N+1)', async () => { rawRequest.mockResolvedValueOnce({ data: { diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index f71ab7e35e1..e4509c9e6a6 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -38,6 +38,7 @@ type LinearIssueNode = { id: string identifier: string title: string + branchName?: string | null description?: string | null url: string dueDate?: string | null @@ -143,6 +144,7 @@ const LINEAR_ISSUE_NODE_FIELDS = ` id identifier title + branchName description url dueDate @@ -399,6 +401,7 @@ function mapRawIssueForWorkspace( id: issue.id, identifier: issue.identifier, title: issue.title, + branchName: issue.branchName ?? undefined, description: issue.description ?? undefined, url: issue.url, state: { diff --git a/src/main/linear/mappers.test.ts b/src/main/linear/mappers.test.ts index 4c5a25c635d..0fba648efc8 100644 --- a/src/main/linear/mappers.test.ts +++ b/src/main/linear/mappers.test.ts @@ -7,6 +7,7 @@ describe('mapLinearIssue', () => { id: 'issue-1', identifier: 'LIN-1', title: 'Investigate mobile detail', + branchName: 'team/lin-1-investigate-mobile-detail', description: 'Body', url: 'https://linear.app/acme/issue/LIN-1', estimate: 2, @@ -39,6 +40,7 @@ describe('mapLinearIssue', () => { title: 'Investigate mobile detail', labels: ['Bug'], subIssues: [{ id: 'child-1', identifier: 'LIN-2' }], + branchName: 'team/lin-1-investigate-mobile-detail', state: { name: '' }, team: { id: '' }, assignee: undefined, diff --git a/src/main/linear/mappers.ts b/src/main/linear/mappers.ts index b49e75cba07..7ea55be7fca 100644 --- a/src/main/linear/mappers.ts +++ b/src/main/linear/mappers.ts @@ -75,6 +75,10 @@ export async function mapLinearIssue( id: issue.id, identifier: issue.identifier, title: issue.title, + branchName: + 'branchName' in issue + ? ((issue.branchName as string | null | undefined) ?? undefined) + : undefined, description: issue.description ?? undefined, url: issue.url, state: { diff --git a/src/main/local-worktree-removal-recovery.test.ts b/src/main/local-worktree-removal-recovery.test.ts index cb6dbcbb51e..1be0fd02cce 100644 --- a/src/main/local-worktree-removal-recovery.test.ts +++ b/src/main/local-worktree-removal-recovery.test.ts @@ -116,6 +116,28 @@ describe('recoverLocalWindowsWorktreeRemoval', () => { }) }) + it('does not recursively delete while watcher teardown remains unconfirmed', async () => { + await withPlatform('win32', async () => { + const watcherError = new Error('file watcher process did not exit after termination deadline') + + await expect( + recoverLocalWindowsWorktreeRemoval({ + error: new Error('git worktree remove failed'), + force: true, + canonicalWorktreePath: 'C:/repo/worktree/feature', + repoPath: 'C:/repo', + localWorktreeGitOptions: {}, + registeredWorktree: { branch: 'refs/heads/feature', head: 'abc123' }, + deleteBranch: true, + closeWatcher: vi.fn().mockRejectedValue(watcherError) + }) + ).rejects.toBe(watcherError) + + expect(removeLocalWorktreePathMock).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + }) + it('recovers localized Windows failures after Git already removed the registration', async () => { await withPlatform('win32', async () => { const result = await recoverLocalWindowsWorktreeRemoval({ diff --git a/src/main/local-worktree-removal-recovery.ts b/src/main/local-worktree-removal-recovery.ts index 25f93e2b2de..5a920a4ee6f 100644 --- a/src/main/local-worktree-removal-recovery.ts +++ b/src/main/local-worktree-removal-recovery.ts @@ -120,7 +120,9 @@ export async function recoverLocalWindowsWorktreeRemoval( // Why: this error means Git accepted removal and started deleting the path; // finish that partial success with Windows retries, then verify Git metadata. - await args.closeWatcher(args.canonicalWorktreePath).catch(() => {}) + // Why: Windows recovery recursively deletes the remaining directory, so it + // must fail closed while a watcher process may still own a native handle. + await args.closeWatcher(args.canonicalWorktreePath) try { await removeLocalWorktreePath(args.canonicalWorktreePath, args.localWorktreeGitOptions) } catch (error) { diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 5dd3cbc8dab..85cd653185b 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -39,6 +39,19 @@ describe('resolveSessionFilePath', () => { expect(resolved).toBe(target) }) + it('resolves OpenClaude sessions from the Claude transcript layout', async () => { + const root = await makeRoot('orca-native-chat-resolve-openclaude-') + const claudeProjectsDir = join(root, 'claude-projects') + const projectDir = join(claudeProjectsDir, '-Users-ada-repo') + await mkdir(projectDir, { recursive: true }) + const target = join(projectDir, 'sess-openclaude.jsonl') + await writeFile(target, '{}\n') + + await expect( + resolveSessionFilePath('openclaude', 'sess-openclaude', { claudeProjectsDir }) + ).resolves.toBe(target) + }) + it('resolves Grok chat_history.jsonl under encodeURIComponent(cwd)/sessionId', async () => { const root = await makeRoot('orca-native-chat-resolve-grok-') const grokSessionsDir = join(root, 'grok-sessions') diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index adfa3856f0e..33cf8c23823 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' import { @@ -66,6 +67,10 @@ export async function resolveSessionFilePath( sessionId: string, options: ResolveSessionFileOptions = {} ): Promise { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (!transcriptAgent) { + return null + } // Why: the hook's transcript_path is the exact file the agent is writing, so it // beats reconstructing a path from the session id. Guard with existsSync so a // stale/remote path falls through to the id-based search rather than returning @@ -80,13 +85,13 @@ export async function resolveSessionFilePath( return null } - if (agent === 'claude') { + if (transcriptAgent === 'claude') { return resolveClaudeSessionFile(trimmedId, options.claudeProjectsDir ?? claudeProjectsDir()) } - if (agent === 'codex') { + if (transcriptAgent === 'codex') { return resolveCodexSessionFile(trimmedId, options.codexSessionsDirs ?? codexSessionsDirs()) } - if (agent === 'grok') { + if (transcriptAgent === 'grok') { return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir()) } return null diff --git a/src/main/native-chat/transcript-incremental-reader.ts b/src/main/native-chat/transcript-incremental-reader.ts new file mode 100644 index 00000000000..ffb3ad83cae --- /dev/null +++ b/src/main/native-chat/transcript-incremental-reader.ts @@ -0,0 +1,103 @@ +import { open, stat } from 'node:fs/promises' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { transcriptFallbackId } from './transcript-fallback-id' +import { + MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES, + type NativeChatLineDecoder +} from './transcript-tail-reader' + +const APPEND_BATCH_MESSAGE_LIMIT = 40 + +export type IncrementalTranscriptState = { + offset: number + pendingChunks: Buffer[] + pendingStart: number + pendingBytes: number + droppingOversizedRecord: boolean +} + +export function resetIncrementalTranscriptState(state: IncrementalTranscriptState): void { + state.offset = 0 + state.pendingChunks.length = 0 + state.pendingStart = 0 + state.pendingBytes = 0 + state.droppingOversizedRecord = false +} + +export async function readIncrementalTranscriptMessages( + filePath: string, + state: IncrementalTranscriptState, + decode: NativeChatLineDecoder, + onBatch?: (messages: NativeChatMessage[]) => void +): Promise { + const end = (await stat(filePath)).size + if (end <= state.offset) { + return [] + } + const messages: NativeChatMessage[] = [] + const handle = await open(filePath, 'r') + try { + const stream = handle.createReadStream({ start: state.offset, end: end - 1, autoClose: false }) + let absoluteOffset = state.offset + for await (const rawChunk of stream) { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) + let segmentStart = 0 + let newline = chunk.indexOf(0x0a) + while (newline >= 0) { + retainPart(chunk.subarray(segmentStart, newline)) + if (!state.droppingOversizedRecord) { + decodeLine() + } + resetPendingLine(absoluteOffset + newline + 1) + segmentStart = newline + 1 + newline = chunk.indexOf(0x0a, segmentStart) + } + if (segmentStart < chunk.length) { + retainPart(chunk.subarray(segmentStart)) + } + absoluteOffset += chunk.length + state.offset = absoluteOffset + } + return messages + } finally { + await handle.close() + } + + function retainPart(part: Buffer): void { + if (state.droppingOversizedRecord) { + return + } + state.pendingBytes += part.length + if (state.pendingBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { + state.pendingChunks.length = 0 + state.droppingOversizedRecord = true + return + } + state.pendingChunks.push(part) + } + + function resetPendingLine(nextStart: number): void { + state.pendingChunks.length = 0 + state.pendingBytes = 0 + state.droppingOversizedRecord = false + state.pendingStart = nextStart + } + + function decodeLine(): void { + let line = Buffer.concat(state.pendingChunks).toString('utf8') + if (line.endsWith('\r')) { + line = line.slice(0, -1) + } + if (!line) { + return + } + const message = decode(line, transcriptFallbackId(filePath, state.pendingStart)) + if (!message) { + return + } + messages.push(message) + if (onBatch && messages.length >= APPEND_BATCH_MESSAGE_LIMIT) { + onBatch(messages.splice(0)) + } + } +} diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index cd87f2bdd59..ff48548804d 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -3,6 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { readNativeChatTranscript } from './transcript-reader' +import { + nativeChatLineDecoderForAgent, + readNativeChatTranscriptTail, + readNativeChatTranscriptTailFile +} from './transcript-tail-reader' let tempRoots: string[] = [] @@ -24,6 +29,20 @@ async function writeFixture(prefix: string, records: unknown[]): Promise } describe('readNativeChatTranscript (claude)', () => { + it('decodes OpenClaude with the Claude transcript format', async () => { + const filePath = await writeFixture('orca-native-chat-openclaude-', [ + { + type: 'assistant', + uuid: 'openclaude-assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] } + } + ]) + + await expect( + readNativeChatTranscript('openclaude', 'session', { filePath }) + ).resolves.toMatchObject({ messages: [{ id: 'openclaude-assistant' }] }) + }) + it('returns ordered user/assistant/tool messages with no 5-message cap', async () => { const records: unknown[] = [] // 4 user/assistant turns = 8 messages, well past the AI-Vault preview cap. @@ -280,3 +299,37 @@ describe('readNativeChatTranscript (errors)', () => { } }) }) + +describe('readNativeChatTranscriptTailFile', () => { + it('keeps a missing tail retry-worthy for the live-session seed', async () => { + const result = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'sess', + filePath: join(tmpdir(), 'orca-native-chat-tail-does-not-exist.jsonl'), + limit: 40 + }) + + expect(result).toMatchObject({ notFound: true }) + }) + + it('windows to nothing for a non-positive limit instead of the whole tail', async () => { + const decode = nativeChatLineDecoderForAgent('claude')! + const filePath = await writeFixture('orca-native-chat-tail-limit-', [ + { + type: 'assistant', + uuid: 'a-1', + message: { role: 'assistant', content: [{ type: 'text', text: 'one' }] } + }, + { + type: 'assistant', + uuid: 'a-2', + message: { role: 'assistant', content: [{ type: 'text', text: 'two' }] } + } + ]) + + const result = await readNativeChatTranscriptTailFile(filePath, 0, decode, true) + + expect(result.messages).toEqual([]) + expect(result.hasMore).toBe(false) + }) +}) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index e2ea727c809..182296bda1d 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -1,5 +1,6 @@ import { createReadStream } from 'node:fs' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { errorMessage } from '../ai-vault/session-scanner-values' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' import { @@ -37,13 +38,14 @@ export async function readNativeChatTranscript( return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true } } try { - if (agent === 'claude') { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (transcriptAgent === 'claude') { return { messages: await readTranscript(filePath, decodeClaudeTranscriptLine) } } - if (agent === 'codex') { + if (transcriptAgent === 'codex') { return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) } } - if (agent === 'grok') { + if (transcriptAgent === 'grok') { return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) } } return { error: `Unsupported agent for native chat transcript: ${agent}` } diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts new file mode 100644 index 00000000000..9ac029d5913 --- /dev/null +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -0,0 +1,200 @@ +import { open, stat } from 'node:fs/promises' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' +import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { + decodeClaudeTranscriptLine, + decodeCodexTranscriptLine, + decodeGrokTranscriptLine +} from './transcript-line-decoders' +import { transcriptFallbackId } from './transcript-fallback-id' + +export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024 +const TAIL_CHUNK_BYTES = 64 * 1024 + +export type NativeChatLineDecoder = (line: string, fallbackId: string) => NativeChatMessage | null + +export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineDecoder | null { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (transcriptAgent === 'claude') { + return decodeClaudeTranscriptLine + } + if (transcriptAgent === 'codex') { + return decodeCodexTranscriptLine + } + if (transcriptAgent === 'grok') { + return decodeGrokTranscriptLine + } + return null +} + +export async function readNativeChatTranscriptTailFile( + filePath: string, + limit: number, + decode: NativeChatLineDecoder, + includeTrailingLine = false, + endOffset?: number +): Promise<{ + messages: NativeChatMessage[] + consumedTo: number + hasMore: boolean + beforeOffset: number +}> { + const end = Math.min((await stat(filePath)).size, endOffset ?? Number.MAX_SAFE_INTEGER) + if (end === 0) { + return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } + } + const handle = await open(filePath, 'r') + const lineParts: Buffer[] = [] + let lineBytes = 0 + let lineOversized = false + try { + const consumedTo = includeTrailingLine ? end : await findLastCompleteLineEnd(handle, end) + if (consumedTo === 0) { + return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } + } + const newestFirst: { message: NativeChatMessage; offset: number }[] = [] + const finalByte = Buffer.allocUnsafe(1) + await handle.read(finalByte, 0, 1, consumedTo - 1) + let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0) + while (cursor > 0 && newestFirst.length <= limit) { + const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) + const buffer = Buffer.allocUnsafe(cursor - start) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + let segmentEnd = bytesRead + for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) { + if (buffer[index] !== 0x0a) { + continue + } + retainPart(buffer.subarray(index + 1, segmentEnd)) + if (!lineOversized) { + decodeLine(start + index + 1, newestFirst) + } + resetLine() + segmentEnd = index + } + if (segmentEnd > 0) { + retainPart(buffer.subarray(0, segmentEnd)) + } + cursor = start + } + if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit) { + decodeLine(0, newestFirst) + } + const chronological = newestFirst.toReversed() + // Why: slice(-0) returns the whole array, so a non-positive limit must + // window to nothing explicitly rather than leak every buffered record. + const selected = limit > 0 ? chronological.slice(Math.max(0, chronological.length - limit)) : [] + return { + messages: selected.map((entry) => entry.message), + consumedTo, + hasMore: limit > 0 && chronological.length > limit, + beforeOffset: selected[0]?.offset ?? end + } + } finally { + await handle.close() + } + + function retainPart(part: Buffer): void { + if (lineOversized) { + return + } + lineBytes += part.length + if (lineBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { + lineParts.length = 0 + lineOversized = true + return + } + lineParts.push(part) + } + + function resetLine(): void { + lineParts.length = 0 + lineBytes = 0 + lineOversized = false + } + + function decodeLine( + lineOffset: number, + messages: { message: NativeChatMessage; offset: number }[] + ): void { + let line = Buffer.concat([...lineParts].toReversed()).toString('utf8') + if (line.endsWith('\r')) { + line = line.slice(0, -1) + } + if (!line) { + return + } + const message = decode(line, transcriptFallbackId(filePath, lineOffset)) + if (message) { + messages.push({ message, offset: lineOffset }) + } + } +} + +async function findLastCompleteLineEnd( + handle: Awaited>, + end: number +): Promise { + const lastByte = Buffer.allocUnsafe(1) + await handle.read(lastByte, 0, 1, end - 1) + if (lastByte[0] === 0x0a) { + return end + } + let cursor = end + while (cursor > 0) { + const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) + const buffer = Buffer.allocUnsafe(cursor - start) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a) + if (newline >= 0) { + return start + newline + 1 + } + cursor = start + } + return 0 +} + +export async function readNativeChatTranscriptTail( + args: ResolveSessionFileOptions & { + agent: AgentType + sessionId: string + transcriptPath?: string + filePath?: string + limit: number + beforeOffset?: number + } +): Promise< + | { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number } + | { error: string; notFound?: true } +> { + const decode = nativeChatLineDecoderForAgent(args.agent) + const filePath = args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args)) + if (!decode) { + return { error: 'Transcript unavailable' } + } + // Why: a new agent session can report its id before the first JSONL flush; + // callers keep that miss in loading/retry rather than showing a false error. + if (!filePath) { + return { error: 'Transcript unavailable', notFound: true } + } + try { + const result = await readNativeChatTranscriptTailFile( + filePath, + args.limit, + decode, + true, + args.beforeOffset + ) + return { + messages: result.messages, + hasMore: result.hasMore, + beforeOffset: result.beforeOffset + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' + ? { error: message, notFound: true } + : { error: message } + } +} diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts new file mode 100644 index 00000000000..d610f5a1536 --- /dev/null +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -0,0 +1,310 @@ +import { watch, type FSWatcher } from 'node:fs' +import { open, stat } from 'node:fs/promises' +import { basename, dirname } from 'node:path' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import type { ResolveSessionFileOptions } from './session-file-resolver' +import { + readIncrementalTranscriptMessages, + resetIncrementalTranscriptState, + type IncrementalTranscriptState +} from './transcript-incremental-reader' +import { readNativeChatTranscriptTailFile } from './transcript-tail-reader' + +export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { + agent: AgentType + sessionId: string + onAppend: (messages: NativeChatMessage[]) => void + onInitialSnapshot?: ( + messages: NativeChatMessage[], + hasMore: boolean, + beforeOffset: number, + /** Set when the initial drain could not deliver a transcript; the subscriber + * surfaces it as an error snapshot so a watching client never sticks on + * 'loading'. Empty messages accompany it. */ + error?: string + ) => void + onReplace?: (messages: NativeChatMessage[], hasMore: boolean, beforeOffset: number) => void + initialLimit?: number + filePath?: string + debounceMs?: number + /** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests + * don't wait out the production backoff. Production ignores this and backs + * off from 500ms to a 5s cap. */ + resolvePollIntervalMs?: number +} + +export type NativeChatTranscriptSubscription = { + unsubscribe: () => void + watching: boolean +} + +const DEFAULT_DEBOUNCE_MS = 40 +const ROTATION_RETRY_MS = 25 +const MAX_ROTATION_RETRY_MS = 2_000 +let activeWatcherCount = 0 + +export function getActiveNativeChatWatcherCount(): number { + return activeWatcherCount +} + +async function fileVersion(filePath: string): Promise<{ identity: string; size: number }> { + const value = await stat(filePath) + return { identity: `${value.dev}:${value.ino}`, size: value.size } +} + +async function boundaryFingerprint(filePath: string, offset: number): Promise { + if (offset <= 0) { + return '' + } + const start = Math.max(0, offset - 64) + const handle = await open(filePath, 'r') + try { + const buffer = Buffer.allocUnsafe(offset - start) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + return buffer.subarray(0, bytesRead).toString('base64') + } finally { + await handle.close() + } +} + +/** + * Install the live-tail engine on an already-resolved file path. Returns null + * (rather than throwing) when the file doesn't exist yet or `watch()` fails — + * e.g. an explicit transcriptPath whose first JSONL line hasn't flushed, or a + * file that vanished between resolve and install — so the caller falls back to + * the resolve-poll below instead of surfacing a hard error (#8401). The + * existence gate matters because the engine watches the parent directory (see + * bindWatcher), which succeeds even when the file itself is missing. + */ +export async function installTranscriptWatcher( + filePath: string, + decode: (line: string, fallbackId: string) => NativeChatMessage | null, + args: SubscribeNativeChatTranscriptArgs +): Promise { + try { + await stat(filePath) + } catch { + return null + } + const { onAppend, onInitialSnapshot, onReplace, initialLimit, debounceMs } = args + + const state: IncrementalTranscriptState = { + offset: 0, + pendingChunks: [], + pendingStart: 0, + pendingBytes: 0, + droppingOversizedRecord: false + } + let watchedIdentity: string | null = null + let watchedBoundary = '' + let initialDrain = true + // Guards the one-time error snapshot emitted when the initial drain throws, so + // a persistently-failing retry loop can't spam the subscriber with error frames. + let initialErrorEmitted = false + let closed = false + let reading = false + let pendingReadRequested = false + let rotationRetryCount = 0 + let debounceTimer: ReturnType | null = null + let watcher: FSWatcher | null = null + let watcherNeedsRebind = false + + function scheduleDrain(): void { + if (closed) { + return + } + if (debounceTimer) { + clearTimeout(debounceTimer) + } + debounceTimer = setTimeout(() => { + debounceTimer = null + void drain() + }, debounceMs ?? DEFAULT_DEBOUNCE_MS) + } + + function scheduleRotationRetry(): void { + if (closed || debounceTimer) { + return + } + const retryDelay = Math.min( + ROTATION_RETRY_MS * 2 ** Math.min(rotationRetryCount, 7), + MAX_ROTATION_RETRY_MS + ) + rotationRetryCount += 1 + debounceTimer = setTimeout(() => { + debounceTimer = null + void drain() + }, retryDelay) + } + + async function readAndEmitAppends(): Promise { + const remaining = await readIncrementalTranscriptMessages( + filePath, + state, + decode, + (messages) => { + if (!closed) { + onAppend(messages) + } + } + ) + if (!closed && remaining.length > 0) { + onAppend(remaining) + } + } + + async function drainOnce(): Promise { + const current = await fileVersion(filePath) + const currentBoundary = await boundaryFingerprint(filePath, state.offset) + if (closed) { + return + } + if (watcherNeedsRebind) { + bindWatcher() + } + const identityChanged = watchedIdentity !== null && current.identity !== watchedIdentity + const contentReplaced = + identityChanged || + current.size < state.offset || + (state.offset > 0 && watchedBoundary !== currentBoundary) + if (contentReplaced) { + resetIncrementalTranscriptState(state) + } + watchedIdentity = current.identity + + const replacementSnapshot = + // Why: 0 is a valid window — an explicit undefined check keeps an empty + // snapshot empty instead of falling back to an unbounded incremental read. + contentReplaced && !initialDrain && onReplace && initialLimit !== undefined + ? await readNativeChatTranscriptTailFile(filePath, initialLimit, decode) + : null + if (closed) { + return + } + if (replacementSnapshot && onReplace) { + state.offset = replacementSnapshot.consumedTo + state.pendingStart = state.offset + onReplace( + replacementSnapshot.messages, + replacementSnapshot.hasMore, + replacementSnapshot.beforeOffset + ) + await readAndEmitAppends() + watchedBoundary = await boundaryFingerprint(filePath, state.offset) + rotationRetryCount = 0 + return + } + + const initialSnapshot = + initialDrain && onInitialSnapshot && initialLimit !== undefined + ? await readNativeChatTranscriptTailFile(filePath, initialLimit, decode) + : null + if (closed) { + return + } + if (initialDrain && onInitialSnapshot) { + initialDrain = false + if (initialSnapshot) { + state.offset = initialSnapshot.consumedTo + state.pendingStart = state.offset + onInitialSnapshot( + initialSnapshot.messages, + initialSnapshot.hasMore, + initialSnapshot.beforeOffset + ) + await readAndEmitAppends() + } else { + const messages = await readIncrementalTranscriptMessages(filePath, state, decode) + onInitialSnapshot(messages, false, 0) + } + } else { + initialDrain = false + await readAndEmitAppends() + } + watchedBoundary = await boundaryFingerprint(filePath, state.offset) + rotationRetryCount = 0 + } + + async function drain(): Promise { + if (closed) { + return + } + if (reading) { + pendingReadRequested = true + return + } + reading = true + try { + do { + pendingReadRequested = false + try { + await drainOnce() + } catch { + // Why: unlink/recreate can detach fs.watch from the pathname. Keep one + // capped-backoff retry alive until a successor appears or we unsubscribe. + // A still-pending initial drain also surfaces one error snapshot so a + // watching client isn't stranded at 'loading' when the read keeps + // throwing; initialDrain stays true so a recovered read can still win. + if (initialDrain && onInitialSnapshot && !initialErrorEmitted) { + initialErrorEmitted = true + onInitialSnapshot([], false, 0, 'Transcript unavailable') + } + scheduleRotationRetry() + break + } + } while (pendingReadRequested && !closed) + } finally { + reading = false + } + } + + function bindWatcher(): void { + const watchedName = basename(filePath) + // Why: file watchers stay bound to an unlinked inode on macOS. Watching + // the parent keeps observing a successor even after a long recreate gap. + const nextWatcher = watch(dirname(filePath), (_event, changedName) => { + if (changedName === null || changedName.toString() === watchedName) { + scheduleDrain() + } + }) + nextWatcher.on('error', () => { + // Why: Windows can emit EPERM when a watched directory disappears. An + // error listener prevents a process crash and retries against its successor. + if (closed || watcher !== nextWatcher) { + return + } + watcherNeedsRebind = true + nextWatcher.close() + watcher = null + scheduleRotationRetry() + }) + watcher = nextWatcher + watcherNeedsRebind = false + } + + try { + bindWatcher() + } catch { + // File vanished between resolve and watch. + return null + } + activeWatcherCount++ + scheduleDrain() + + return { + watching: true, + unsubscribe: () => { + if (closed) { + return + } + closed = true + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + watcher?.close() + watcher = null + activeWatcherCount-- + } + } +} diff --git a/src/main/native-chat/transcript-watch-error.test.ts b/src/main/native-chat/transcript-watch-error.test.ts new file mode 100644 index 00000000000..2d3b0828664 --- /dev/null +++ b/src/main/native-chat/transcript-watch-error.test.ts @@ -0,0 +1,172 @@ +import { EventEmitter } from 'node:events' +import type { FSWatcher } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { watchers, watchCallbacks, watchMock } = vi.hoisted(() => ({ + watchers: [] as (EventEmitter & { close: ReturnType })[], + watchCallbacks: [] as ((event: string, filename: string | Buffer | null) => void)[], + watchMock: vi.fn() +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + watchMock.mockImplementation((_path, callback) => { + const watcher = Object.assign(new EventEmitter(), { close: vi.fn() }) + watchers.push(watcher) + watchCallbacks.push(callback) + return watcher as unknown as FSWatcher + }) + return { ...actual, watch: watchMock } +}) + +import { subscribeNativeChatTranscript } from './transcript-watch' + +const roots: string[] = [] + +afterEach(async () => { + watchers.length = 0 + watchCallbacks.length = 0 + watchMock.mockClear() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('native chat transcript watcher errors', () => { + it('handles a watcher error and rebinds after the directory is readable', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-error-')) + roots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, '') + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onAppend: () => {}, + debounceMs: 0 + }) + + expect(() => watchers[0]!.emit('error', new Error('EPERM'))).not.toThrow() + await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2)) + + subscription.unsubscribe() + expect(watchers[0]!.close).toHaveBeenCalledOnce() + expect(watchers[1]!.close).toHaveBeenCalledOnce() + }) + + it('keeps retrying after the old recovery window and tails a recreated directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-gap-')) + roots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, '') + const onInitialSnapshot = vi.fn() + const onAppend = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot, + onAppend, + initialLimit: 40, + debounceMs: 0 + }) + await vi.waitFor(() => expect(onInitialSnapshot).toHaveBeenCalledOnce()) + + await rm(root, { recursive: true, force: true }) + watchers[0]!.emit('error', new Error('EPERM')) + await new Promise((resolve) => setTimeout(resolve, 1_200)) + expect(watchMock).toHaveBeenCalledTimes(1) + + await mkdir(root, { recursive: true }) + await writeFile(filePath, claudeLine('u-recreated', 'user', 'back')) + await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2), { timeout: 2_000 }) + + await appendFile(filePath, claudeLine('a-recreated', 'assistant', 'reply')) + watchCallbacks[1]!('change', 'transcript.jsonl') + await vi.waitFor(() => + expect(onAppend.mock.calls.flat(2)).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'a-recreated' })]) + ) + ) + + subscription.unsubscribe() + expect(watchers[1]!.close).toHaveBeenCalledOnce() + }) + + it('surfaces an error snapshot when the initial drain throws', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-error-')) + roots.push(root) + // A directory sitting at the transcript path: it exists (so install does not + // defer to the not-yet-flushed resolve poll, #8401) but every tail read + // throws EISDIR — a persistent real read error, not a missing file. + const filePath = join(root, 'transcript.jsonl') + await mkdir(filePath) + const onInitialSnapshot = vi.fn() + const onAppend = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot, + onAppend, + initialLimit: 40, + debounceMs: 0 + }) + expect(subscription.watching).toBe(true) + + await vi.waitFor(() => + expect(onInitialSnapshot).toHaveBeenCalledWith([], false, 0, 'Transcript unavailable') + ) + // Surfaced once, not spammed by the capped rotation retry loop. + await new Promise((resolve) => setTimeout(resolve, 120)) + expect(onInitialSnapshot).toHaveBeenCalledOnce() + + subscription.unsubscribe() + }) + + it('still wins with a real initial snapshot once the transcript becomes readable', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-recover-')) + roots.push(root) + // Same unreadable-directory setup as above; the error frame must not be + // terminal once the path is replaced with a readable transcript. + const filePath = join(root, 'transcript.jsonl') + await mkdir(filePath) + const onInitialSnapshot = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot, + onAppend: () => {}, + initialLimit: 40, + debounceMs: 0 + }) + await vi.waitFor(() => + expect(onInitialSnapshot).toHaveBeenCalledWith([], false, 0, 'Transcript unavailable') + ) + + // initialDrain stays true after the error, so a recovered read delivers the + // real snapshot instead of stranding the client on the error frame. + await rm(filePath, { recursive: true, force: true }) + await writeFile(filePath, claudeLine('u-recovered', 'user', 'back')) + watchCallbacks[0]!('change', 'transcript.jsonl') + await vi.waitFor(() => + expect(onInitialSnapshot.mock.calls.flat(2)).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'u-recovered' })]) + ) + ) + + subscription.unsubscribe() + }) +}) + +function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string { + return `${JSON.stringify({ + type: role, + uuid, + timestamp: '2026-06-01T10:00:00.000Z', + message: { role, content: role === 'user' ? text : [{ type: 'text', text }] } + })}\n` +} diff --git a/src/main/native-chat/transcript-watch-resolve-poll.test.ts b/src/main/native-chat/transcript-watch-resolve-poll.test.ts new file mode 100644 index 00000000000..be4e60dede3 --- /dev/null +++ b/src/main/native-chat/transcript-watch-resolve-poll.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + install: vi.fn(), + resolve: vi.fn() +})) + +vi.mock('./session-file-resolver', () => ({ + resolveSessionFilePath: mocks.resolve +})) +vi.mock('./transcript-watch-engine', () => ({ + getActiveNativeChatWatcherCount: vi.fn(() => 0), + installTranscriptWatcher: mocks.install +})) + +import { subscribeNativeChatTranscript } from './transcript-watch' + +describe('native chat transcript resolve polling', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.install.mockReset().mockReturnValue(null) + mocks.resolve.mockReset().mockResolvedValue(null) + }) + + afterEach(() => vi.useRealTimers()) + + it('fast-probes an exact hook path without repeatedly scanning the session tree', async () => { + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + transcriptPath: '/missing/exact.jsonl', + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + expect(mocks.resolve).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(100) + expect(mocks.install.mock.calls.length).toBeGreaterThan(1) + expect(mocks.resolve).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(4_900) + expect(mocks.resolve).toHaveBeenCalledTimes(2) + + subscription.unsubscribe() + const callsAfterUnsubscribe = mocks.install.mock.calls.length + await vi.advanceTimersByTimeAsync(100) + expect(mocks.install).toHaveBeenCalledTimes(callsAfterUnsubscribe) + }) + + it('keeps resolving on every retry when no exact hook path is available', async () => { + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + + await vi.advanceTimersByTimeAsync(35) + expect(mocks.resolve.mock.calls.length).toBeGreaterThan(1) + subscription.unsubscribe() + }) +}) diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts index 4ff621d05bc..512da6b014e 100644 --- a/src/main/native-chat/transcript-watch.test.ts +++ b/src/main/native-chat/transcript-watch.test.ts @@ -1,9 +1,13 @@ -import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { appendFile, mkdtemp, rename, 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 type { NativeChatMessage } from '../../shared/native-chat-types' -import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch' +import { + getActiveNativeChatWatcherCount, + readNativeChatTranscriptTail, + subscribeNativeChatTranscript +} from './transcript-watch' let tempRoots: string[] = [] @@ -52,6 +56,191 @@ async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + it('delivers the offset-0 drain as one initial snapshot, then only live appends', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'first')) + const snapshots: NativeChatMessage[][] = [] + const appends: NativeChatMessage[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onInitialSnapshot: (messages) => snapshots.push(messages), + onAppend: (messages) => appends.push(messages), + debounceMs: 5 + }) + + expect(sub.watching).toBe(true) + await waitFor(() => snapshots.length === 1) + expect(snapshots[0]?.map((message) => message.id)).toEqual(['u-1']) + expect(appends).toEqual([]) + + await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply')) + await waitFor(() => appends.flat().some((message) => message.id === 'a-1')) + sub.unsubscribe() + + expect(snapshots).toHaveLength(1) + expect(appends.flat().map((message) => message.id)).toEqual(['a-1']) + }) + + it('delivers an empty initial snapshot so clients do not remain loading', async () => { + const filePath = await tempFile('') + const snapshots: NativeChatMessage[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onInitialSnapshot: (messages) => snapshots.push(messages), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([[]]) + }) + + it('emits a bulk append in bounded ordered batches', async () => { + const filePath = await tempFile('') + const batches: NativeChatMessage[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => batches.push(messages), + debounceMs: 5 + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + await appendFile( + filePath, + Array.from({ length: 95 }, (_unused, index) => + claudeLine(`bulk-${index}`, 'user', `message-${index}`) + ).join('') + ) + + await waitFor(() => batches.flat().length === 95) + sub.unsubscribe() + expect(batches.map((batch) => batch.length)).toEqual([40, 40, 15]) + expect(batches.flat().map((message) => message.id)).toEqual( + Array.from({ length: 95 }, (_unused, index) => `bulk-${index}`) + ) + }) + + it('drops one oversized record without retaining or blocking the next append', async () => { + const filePath = await tempFile('') + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + await appendFile( + filePath, + claudeLine('oversized', 'user', 'x'.repeat(2 * 1024 * 1024)) + + claudeLine('after-oversized', 'user', 'still delivered') + ) + + await waitFor(() => seen.some((message) => message.id === 'after-oversized')) + sub.unsubscribe() + expect(seen.some((message) => message.id === 'oversized')).toBe(false) + }) + + it('returns a bounded tail snapshot with exact pagination state', async () => { + const transcript = Array.from({ length: 800 }, (_unused, index) => + claudeLine(`u-${index}`, 'user', `message-${index}-${'x'.repeat(100)}`) + ).join('') + const filePath = await tempFile(transcript) + const snapshots: { ids: string[]; hasMore: boolean }[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 2, + onInitialSnapshot: (messages, hasMore) => + snapshots.push({ ids: messages.map((message) => message.id), hasMore }), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([{ ids: ['u-798', 'u-799'], hasMore: true }]) + }) + + it('keeps an explicit zero-limit snapshot empty instead of reading unbounded', async () => { + const filePath = await tempFile(claudeLine('u-0', 'user', 'hello')) + const snapshots: { ids: string[]; hasMore: boolean }[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 0, + onInitialSnapshot: (messages, hasMore) => + snapshots.push({ ids: messages.map((message) => message.id), hasMore }), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([{ ids: [], hasMore: false }]) + }) + + it('pages older history by byte cursor without resending the growing tail', async () => { + const filePath = await tempFile( + Array.from({ length: 10 }, (_unused, index) => + claudeLine(`page-${index}`, 'user', `message-${index}`) + ).join('') + ) + const newest = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'ignored', + filePath, + limit: 3 + }) + if ('error' in newest) { + throw new Error(newest.error) + } + const older = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'ignored', + filePath, + limit: 3, + beforeOffset: newest.beforeOffset + }) + + expect(newest.messages.map((message) => message.id)).toEqual(['page-7', 'page-8', 'page-9']) + expect('messages' in older && older.messages.map((message) => message.id)).toEqual([ + 'page-4', + 'page-5', + 'page-6' + ]) + }) + + it('decodes multi-chunk records once into the bounded tail order', async () => { + const large = 'x'.repeat(200_000) + const filePath = await tempFile( + claudeLine('u-large-1', 'user', large) + claudeLine('u-large-2', 'user', large) + ) + const snapshots: { ids: string[]; hasMore: boolean }[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 1, + onInitialSnapshot: (messages, hasMore) => + snapshots.push({ ids: messages.map((message) => message.id), hasMore }), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([{ ids: ['u-large-2'], hasMore: true }]) + }) + it('re-emits from the top on first drain so appended turns are never dropped', async () => { const filePath = await tempFile(claudeLine('u-1', 'user', 'first')) const batches: NativeChatMessage[][] = [] @@ -233,6 +422,109 @@ describe('subscribeNativeChatTranscript', () => { expect(ids).toContain('a-2') }) + it('detects same-size and larger in-place transcript replacement', async () => { + const filePath = await tempFile(claudeLine('u-old', 'user', 'old')) + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + await waitFor(() => seen.some((message) => message.id === 'u-old')) + + await writeFile(filePath, claudeLine('u-new', 'user', 'new')) + await waitFor(() => seen.some((message) => message.id === 'u-new')) + await writeFile(filePath, claudeLine('u-bigger', 'user', 'larger replacement text')) + await waitFor(() => seen.some((message) => message.id === 'u-bigger')) + sub.unsubscribe() + + expect(seen.filter((message) => message.id === 'u-new')).toHaveLength(1) + expect(seen.filter((message) => message.id === 'u-bigger')).toHaveLength(1) + }) + + it('keeps watching after atomic rename replacement', async () => { + const filePath = await tempFile(claudeLine('atomic-old', 'user', 'old')) + const replacementPath = `${filePath}.replacement` + const seen: NativeChatMessage[] = [] + const replacements: string[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 40, + onInitialSnapshot: (messages) => seen.push(...messages), + onReplace: (messages) => { + replacements.push(messages.map((message) => message.id)) + seen.splice(0, seen.length, ...messages) + }, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + await waitFor(() => seen.some((message) => message.id === 'atomic-old')) + + await writeFile(replacementPath, claudeLine('atomic-new', 'user', 'replacement')) + await rename(replacementPath, filePath) + await waitFor(() => seen.some((message) => message.id === 'atomic-new')) + await appendFile(filePath, claudeLine('atomic-followup', 'assistant', 'still watched')) + await waitFor(() => seen.some((message) => message.id === 'atomic-followup')) + sub.unsubscribe() + + expect(replacements).toEqual([['atomic-new']]) + expect(seen.some((message) => message.id === 'atomic-old')).toBe(false) + expect(seen.filter((message) => message.id === 'atomic-followup')).toHaveLength(1) + }) + + it('does not resurrect the watcher when unsubscribe races an atomic replacement', async () => { + const filePath = await tempFile(claudeLine('race-old', 'user', 'old')) + const replacementPath = `${filePath}.replacement` + const before = getActiveNativeChatWatcherCount() + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 0 + }) + await waitFor(() => seen.some((message) => message.id === 'race-old')) + + await writeFile(replacementPath, claudeLine('race-new', 'user', 'replacement')) + await rename(replacementPath, filePath) + await new Promise((resolve) => setTimeout(resolve, 0)) + sub.unsubscribe() + await new Promise((resolve) => setTimeout(resolve, 30)) + + expect(getActiveNativeChatWatcherCount()).toBe(before) + await appendFile(filePath, claudeLine('race-after', 'assistant', 'must stay closed')) + await new Promise((resolve) => setTimeout(resolve, 30)) + expect(seen.some((message) => message.id === 'race-after')).toBe(false) + }) + + it('recovers after an unlink/recreate gap outlasts the fast retry window', async () => { + const filePath = await tempFile(claudeLine('unlink-old', 'user', 'old')) + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 0 + }) + await waitFor(() => seen.some((message) => message.id === 'unlink-old')) + + await rm(filePath) + await new Promise((resolve) => setTimeout(resolve, 1_200)) + await writeFile(filePath, claudeLine('unlink-new', 'user', 'replacement')) + await waitFor(() => seen.some((message) => message.id === 'unlink-new')) + await appendFile(filePath, claudeLine('unlink-after', 'assistant', 'still watched')) + await waitFor(() => seen.some((message) => message.id === 'unlink-after')) + sub.unsubscribe() + + expect(seen.filter((message) => message.id === 'unlink-after')).toHaveLength(1) + }) + it('returns a no-op unsubscribe when the file cannot be resolved', async () => { const before = getActiveNativeChatWatcherCount() const sub = await subscribeNativeChatTranscript({ @@ -240,6 +532,7 @@ describe('subscribeNativeChatTranscript', () => { sessionId: '', onAppend: () => {} }) + expect(sub.watching).toBe(false) expect(getActiveNativeChatWatcherCount()).toBe(before) // Must not throw. sub.unsubscribe() diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 1f55c33124a..f3228d970fc 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -1,225 +1,19 @@ -// Live transcript tailing: watch a resolved session JSONL file and emit only -// the messages parsed from bytes appended since the last read. Modeled on the -// incremental byte-offset read in codex-usage/scanner.ts (parseCodexUsageFile's -// skipInitialBytes), but specialized to the NativeChatMessage record decoders. -// -// Teardown discipline (plan U4 risk: file-watch fd leaks): every subscription -// owns exactly one fs.FSWatcher and one debounce timer. unsubscribe() closes -// the watcher and clears the timer synchronously, and the module tracks the live -// watcher count so tests can assert no watcher survives teardown. - -import { watch, type FSWatcher } from 'node:fs' -import { open, stat } from 'node:fs/promises' -import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' -import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { extname } from 'node:path' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { resolveSessionFilePath } from './session-file-resolver' import { - decodeClaudeTranscriptLine, - decodeCodexTranscriptLine, - decodeGrokTranscriptLine -} from './transcript-line-decoders' -import { decodeTranscriptStream } from './transcript-stream-lines' + installTranscriptWatcher, + type NativeChatTranscriptSubscription, + type SubscribeNativeChatTranscriptArgs +} from './transcript-watch-engine' +import { nativeChatLineDecoderForAgent } from './transcript-tail-reader' -export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { - agent: AgentType - sessionId: string - /** Called with the newly-appended messages whenever the file grows. Never - * called with an empty array. */ - onAppend: (messages: NativeChatMessage[]) => void - /** Resolve directly to this file, skipping path discovery (used by tests). */ - filePath?: string - /** Coalesce window for rapid fs.watch events (ms). Defaults to 40ms. */ - debounceMs?: number - /** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests - * don't wait out the production backoff. Production ignores this and backs - * off from 500ms to a 5s cap. */ - resolvePollIntervalMs?: number -} - -export type NativeChatTranscriptSubscription = { - /** Closes the watcher and releases the file handle. Idempotent. */ - unsubscribe: () => void -} - -// Why: a single watch event can fire several times for one append; we read from -// the last byte offset so re-entrant reads never re-emit prior messages. Each -// decoder is stateless per-line, so tailing reuses the same record→message -// mapping the full reader uses. -const DEFAULT_DEBOUNCE_MS = 40 - -// Why: process-wide count of live FSWatchers opened by this module. The U4 leak -// test asserts this returns to zero after unsubscribe so a forgotten handle is -// caught deterministically rather than relying on OS fd inspection. -let activeWatcherCount = 0 - -/** Test-only: number of fs watchers this module currently holds open. */ -export function getActiveNativeChatWatcherCount(): number { - return activeWatcherCount -} - -function lineDecoderForAgent( - agent: AgentType -): ((line: string, fallbackId: string) => NativeChatMessage | null) | null { - if (agent === 'claude') { - return decodeClaudeTranscriptLine - } - if (agent === 'codex') { - return decodeCodexTranscriptLine - } - if (agent === 'grok') { - return decodeGrokTranscriptLine - } - return null -} - -async function fileSize(filePath: string): Promise { - try { - return (await stat(filePath)).size - } catch { - return 0 - } -} - -/** - * Read bytes [start, end) of the file and decode each complete line into a - * NativeChatMessage. Opens its own fd and always closes it (no leak on the read - * path, distinct from the long-lived watcher). Returns the messages plus the - * byte offset actually consumed so a partially-written trailing line is re-read - * on the next append rather than dropped. - */ -async function readAppendedMessages( - filePath: string, - start: number, - decode: (line: string, fallbackId: string) => NativeChatMessage | null -): Promise<{ messages: NativeChatMessage[]; consumedTo: number }> { - const end = await fileSize(filePath) - if (end <= start) { - // File shrank (rotation/replacement) or unchanged — caller resets offset. - return { messages: [], consumedTo: end } - } - - const handle = await open(filePath, 'r') - try { - const stream = handle.createReadStream({ - encoding: 'utf-8', - start, - end: end - 1, - autoClose: false - }) - const { messages, consumedBytes } = await decodeTranscriptStream( - stream, - filePath, - start, - decode, - false - ) - return { messages, consumedTo: start + consumedBytes } - } finally { - await handle.close() - } -} - -/** - * Install a live-tail watcher on an already-resolved file path. Returns null - * (rather than throwing) when `watch()` fails — e.g. the file vanished between - * resolve and install — so the caller can fall back to the resolve-poll below - * instead of surfacing a hard error. - */ -function installTranscriptWatcher( - filePath: string, - decode: (line: string, fallbackId: string) => NativeChatMessage | null, - onAppend: (messages: NativeChatMessage[]) => void, - debounceMs?: number -): NativeChatTranscriptSubscription | null { - // Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This - // closes the read/subscribe race — a turn appended between the caller's - // readSession EOF and the watcher install is still emitted. Re-emitted lines - // collapse by deterministic id in the assembler (no dup, no drop). Subsequent - // drains use the incremental offset so the full re-read happens only once. - let offset = 0 - let closed = false - let reading = false - let pendingReadRequested = false - let debounceTimer: ReturnType | null = null - - async function drain(): Promise { - if (closed) { - return - } - if (reading) { - // A read is already in flight; mark that another pass is needed so rapid - // successive appends coalesce without dropping the trailing one. - pendingReadRequested = true - return - } - reading = true - try { - do { - pendingReadRequested = false - try { - const currentSize = await fileSize(filePath) - if (currentSize < offset) { - // Rotation/replacement/truncation: re-read from the top. - offset = 0 - } - const { messages, consumedTo } = await readAppendedMessages(filePath, offset, decode) - offset = consumedTo - if (!closed && messages.length > 0) { - onAppend(messages) - } - } catch { - // Why: a transient read failure (EACCES/EIO/ENOENT during rotation) - // must not leave the subscription permanently deaf. Stop this drain; - // the finally resets `reading` so a later fs event re-arms the read. - break - } - } while (pendingReadRequested && !closed) - } finally { - reading = false - } - } - - function scheduleDrain(): void { - if (closed) { - return - } - if (debounceTimer) { - clearTimeout(debounceTimer) - } - debounceTimer = setTimeout(() => { - debounceTimer = null - void drain() - }, debounceMs ?? DEFAULT_DEBOUNCE_MS) - } - - let watcher: FSWatcher - try { - watcher = watch(filePath, scheduleDrain) - } catch { - // File vanished between resolve and watch. - return null - } - activeWatcherCount++ - - // Why: on some platforms fs.watch can miss the very first append that lands - // between offset-seed and watcher install. Kick one debounced drain so a - // turn written immediately after subscribe is still picked up. - scheduleDrain() - - return { - unsubscribe: () => { - if (closed) { - return - } - closed = true - if (debounceTimer) { - clearTimeout(debounceTimer) - debounceTimer = null - } - watcher.close() - activeWatcherCount-- - } - } -} +export { readNativeChatTranscriptTail } from './transcript-tail-reader' +export { + getActiveNativeChatWatcherCount, + type NativeChatTranscriptSubscription, + type SubscribeNativeChatTranscriptArgs +} from './transcript-watch-engine' /** One resolve+install attempt. Returns null when the file isn't resolvable * yet, or vanished between resolve and `watch()` — either case is retried by @@ -232,22 +26,30 @@ async function attemptInstall( if (!filePath) { return null } - return installTranscriptWatcher(filePath, decode, args.onAppend, args.debounceMs) + return installTranscriptWatcher(filePath, decode, args) } // Why: Claude Code (and other agents) can take from ~3s to minutes to flush a // brand-new session's first JSONL line (#8401) — resolveSessionFilePath -// genuinely has nothing to find yet. Poll for it instead of going deaf; each -// attempt is a cheap glob, so the cost of polling is negligible next to the -// gap it covers. +// genuinely has nothing to find yet. Poll for it instead of going deaf. Exact +// hook paths are probed on every retry; the recursive +// session-id fallback runs less often because a large Claude tree is expensive. const INITIAL_RESOLVE_POLL_MS = 500 const MAX_RESOLVE_POLL_MS = 5_000 +const FALLBACK_RESOLVE_POLL_MS = 5_000 + +function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | null { + const path = args.transcriptPath?.trim() + return path && extname(path) === '.jsonl' ? path : null +} /** * Background retry loop for a transcript that hasn't been resolvable yet. * Returns a subscription immediately (per subscribeNativeChatTranscript's * contract); the loop keeps retrying resolve+install until it succeeds or - * unsubscribe() cancels it. + * unsubscribe() cancels it. Reports watching:true — the engine's first drain + * delivers the initial snapshot once the file appears, so subscribers must not + * settle a merely not-yet-flushed transcript into a permanent error (#8401). */ function subscribeViaResolvePoll( args: SubscribeNativeChatTranscriptArgs, @@ -257,15 +59,23 @@ function subscribeViaResolvePoll( let installed: NativeChatTranscriptSubscription | null = null let pollTimer: ReturnType | null = null let delay = args.resolvePollIntervalMs ?? INITIAL_RESOLVE_POLL_MS + let lastFallbackResolveAt = Date.now() + const exactPath = exactTranscriptPath(args) function scheduleAttempt(): void { if (closed) { return } - pollTimer = setTimeout(() => { - pollTimer = null - void runAttempt() - }, delay) + const untilFallbackResolve = exactPath + ? Math.max(0, FALLBACK_RESOLVE_POLL_MS - (Date.now() - lastFallbackResolveAt)) + : delay + pollTimer = setTimeout( + () => { + pollTimer = null + void runAttempt() + }, + Math.min(delay, untilFallbackResolve) + ) // Why: never hold the event loop open (headless `orca serve` shutdown) for // a session that may genuinely never resolve. pollTimer.unref?.() @@ -282,7 +92,14 @@ function subscribeViaResolvePoll( } let result: NativeChatTranscriptSubscription | null try { - result = await attemptInstall(args, decode) + result = exactPath ? await attemptInstall({ ...args, filePath: exactPath }, decode) : null + if ( + !result && + (!exactPath || Date.now() - lastFallbackResolveAt >= FALLBACK_RESOLVE_POLL_MS) + ) { + lastFallbackResolveAt = Date.now() + result = await attemptInstall(args, decode) + } } catch { // Why: a transient resolve failure (EACCES/EIO during the glob) must not // kill the poll loop with an unhandled rejection — retry like a miss. @@ -303,6 +120,7 @@ function subscribeViaResolvePoll( scheduleAttempt() return { + watching: true, unsubscribe: () => { if (closed) { return @@ -334,16 +152,16 @@ function subscribeViaResolvePoll( export async function subscribeNativeChatTranscript( args: SubscribeNativeChatTranscriptArgs ): Promise { - const decode = lineDecoderForAgent(args.agent) + const decode = nativeChatLineDecoderForAgent(args.agent) if (!decode) { // Nothing watchable — return a no-op teardown so callers can unconditionally // unsubscribe without null-checks. - return { unsubscribe: () => {} } + return { unsubscribe: () => {}, watching: false } } // Why: a blank session id (and no explicit file) can never resolve — bail out // instead of resolve-polling an unresolvable target forever. if (!args.filePath && !args.sessionId.trim()) { - return { unsubscribe: () => {} } + return { unsubscribe: () => {}, watching: false } } const installed = await attemptInstall(args, decode) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b2919fc88e4..acde5dd02c3 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -21,6 +21,7 @@ import type { ProjectGroup, ProjectHostSetup, Repo, + GlobalSettings, TerminalPaneLayoutNode, TerminalTab, WorktreeLineage, @@ -41,6 +42,7 @@ import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execut import { SshConnectionStore } from './ssh/ssh-connection-store' import { setSourceControlActionDefault } from '../shared/source-control-ai-actions' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' +import { closeTerminalTabInWorkspaceSession } from '../shared/workspace-session-terminal-tab-close' // Shared mutable state so the electron mock can reference a per-test directory const testState = { dir: '' } @@ -337,6 +339,58 @@ describe('Store', () => { expect(store.getRepos()).toEqual([]) }, 15_000) + it('does not restore a terminal tab after its durable close flush returns', async () => { + const store = await createStore() + const worktreeId = 'repo-1::/tmp/worktree-1' + const tabId = 'terminal-1' + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeWorktreeId: worktreeId, + activeTabId: tabId, + tabsByWorktree: { + [worktreeId]: [ + { + id: tabId, + ptyId: 'pty-1', + worktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + activeTabIdByWorktree: { [worktreeId]: tabId }, + defaultTerminalTabsAppliedByWorktreeId: { [worktreeId]: true } + } + store.setWorkspaceSession(session) + store.flushOrThrow() + + const closed = closeTerminalTabInWorkspaceSession( + store.getWorkspaceSession(), + worktreeId, + tabId + ) + store.setWorkspaceSession(closed.session) + store.flushOrThrow() + + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession().tabsByWorktree[worktreeId]).toEqual([]) + expect(reloaded.getWorkspaceSession().terminalLayoutsByTabId[tabId]).toBeUndefined() + expect( + reloaded.getWorkspaceSession().defaultTerminalTabsAppliedByWorktreeId?.[worktreeId] + ).toBe(true) + }) + it('loads state from an explicit profile data file path', async () => { const profileDataDirectory = join(testState.dir, 'profiles', 'local-default') const profileDataFile = join(profileDataDirectory, 'orca-data.json') @@ -648,6 +702,72 @@ describe('Store', () => { expect(store.getSettings().minimizeToTrayOnClose).toBe(false) }) + it('defaults the menu bar icon on regardless of platform', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + + await withPlatform('linux', async () => { + const store = await createStore() + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + }) + + it('enables the menu bar icon when an existing macOS profile has no stored value', async () => { + await withPlatform('darwin', async () => { + const persisted = getDefaultPersistedState(testState.dir) + delete (persisted.settings as Partial).showMenuBarIcon + writeDataFile(persisted) + + const store = await createStore() + + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + }) + + it('persists an explicit macOS menu bar opt-out', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + store.updateSettings({ showMenuBarIcon: false }) + store.flush() + + expect((readDataFile() as PersistedState).settings.showMenuBarIcon).toBe(false) + expect((await createStore()).getSettings().showMenuBarIcon).toBe(false) + }) + }) + + it('normalizes menu bar icon writes to a strict boolean', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + store.updateSettings({ showMenuBarIcon: 'true' as unknown as boolean }) + expect(store.getSettings().showMenuBarIcon).toBe(false) + store.updateSettings({ showMenuBarIcon: true }) + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + }) + + it('round-trips a macOS menu bar opt-out through a non-mac host unchanged', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + store.updateSettings({ showMenuBarIcon: false }) + store.flush() + }) + + // Why: a profile opened on another OS must not rewrite the mac preference + // on its next flush; only the darwin consumers act on the value. + await withPlatform('win32', async () => { + const store = await createStore() + store.updateSettings({ minimizeToTrayOnClose: true }) + store.flush() + expect((readDataFile() as PersistedState).settings.showMenuBarIcon).toBe(false) + }) + + await withPlatform('darwin', async () => { + expect((await createStore()).getSettings().showMenuBarIcon).toBe(false) + }) + }) + it('defaults trayMinimizeNoticeShown to false and persists it strictly', async () => { const store = await createStore() expect(store.getUI().trayMinimizeNoticeShown).toBe(false) @@ -3317,6 +3437,62 @@ describe('Store', () => { expect(store.getWorktreeMeta('shared::/remote/repo/wt')).toBeUndefined() }) + it('reorderReposForHost independently reorders local and SSH rows with shared ids', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'shared', path: '/local/shared' })) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/ssh/shared', + connectionId: 'target' + }) + ) + store.addRepo(makeRepo({ id: 'local-two', path: '/local/two' })) + store.addRepo( + makeRepo({ + id: 'ssh-two', + path: '/ssh/two', + connectionId: 'target' + }) + ) + + expect(store.reorderReposForHost(['local-two', 'shared'], 'local')).toBe(true) + expect(store.getRepos().map((repo) => repo.path)).toEqual([ + '/local/two', + '/ssh/shared', + '/local/shared', + '/ssh/two' + ]) + + expect(store.reorderReposForHost(['ssh-two', 'shared'], 'ssh:target')).toBe(true) + expect(store.getRepos().map((repo) => repo.path)).toEqual([ + '/local/two', + '/ssh/two', + '/local/shared', + '/ssh/shared' + ]) + }) + + it('reorderReposForHost rejects stale or duplicate host permutations without mutation', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'local-one', path: '/local/one' })) + store.addRepo(makeRepo({ id: 'local-two', path: '/local/two' })) + store.addRepo( + makeRepo({ + id: 'ssh-one', + path: '/ssh/one', + connectionId: 'target', + executionHostId: 'ssh:target' + }) + ) + const originalPaths = store.getRepos().map((repo) => repo.path) + + expect(store.reorderReposForHost(['local-two'], 'local')).toBe(false) + expect(store.reorderReposForHost(['local-one', 'local-one'], 'local')).toBe(false) + expect(store.reorderReposForHost(['missing', 'local-two'], 'local')).toBe(false) + expect(store.getRepos().map((repo) => repo.path)).toEqual(originalPaths) + }) + it('removeProjectForHost prunes the SSH host meta (tagged hostId) for a shared id', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' })) @@ -5298,6 +5474,25 @@ describe('Store', () => { expect(ui.dismissedUpdateVersion).toBeNull() }) + it('round-trips and normalizes the host-qualified manual repo order', async () => { + const store = await createStore() + store.updateUI({ + manualRepoOrder: [ + { hostId: 'runtime:node-b', repoId: 'shared' }, + { hostId: 'bogus', repoId: 'ignored' }, + { hostId: 'runtime:node-b', repoId: 'shared' }, + { hostId: 'local', repoId: 'alpha' } + ] as never + }) + store.flush() + + const reloaded = await createStore() + expect(reloaded.getUI().manualRepoOrder).toEqual([ + { hostId: 'runtime:node-b', repoId: 'shared' }, + { hostId: 'local', repoId: 'alpha' } + ]) + }) + it('updateUI persists sanitized per-worktree dotfile visibility', async () => { const store = await createStore() store.updateUI({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index a72a9238fdd..a237e941150 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -172,6 +172,7 @@ import { } from '../shared/feature-interactions' import { normalizeContextualTourIds } from '../shared/contextual-tours' import { normalizeFeatureTipIds } from '../shared/feature-tips' +import { normalizeManualRepoOrder } from '../shared/manual-repo-order' import { DEFAULT_WORKSPACE_STATUS_ID, clampWorkspaceBoardColumnWidth, @@ -3199,6 +3200,9 @@ export class Store { // Why: persisted settings can be user-edited or written by older // builds; keep tray-minimize false unless the stored value is true. minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, + // Why: missing means default-on, and the value must round-trip + // unchanged on non-mac hosts; the darwin consumers gate the effect. + showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), defaultTaskSource: taskProviderSettings.defaultTaskSource, visibleTaskProviders: taskProviderSettings.visibleTaskProviders, @@ -3805,7 +3809,7 @@ export class Store { } } - private flushOrThrow(): void { + flushOrThrow(): void { if (this.writeTimer) { clearTimeout(this.writeTimer) this.writeTimer = null @@ -4270,6 +4274,37 @@ export class Store { return true } + // Why: repo ids are unique only within an execution host, and renderer drags + // persist one complete permutation per host when local and SSH repos coexist. + reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean { + const current = this.state.repos + const hostRepos = current.filter((repo) => getRepoExecutionHostId(repo) === hostId) + if (orderedIds.length !== hostRepos.length) { + return false + } + const byId = new Map(hostRepos.map((repo) => [repo.id, repo])) + if (byId.size !== hostRepos.length) { + return false + } + const seen = new Set() + const reorderedHostRepos: Repo[] = [] + for (const id of orderedIds) { + const repo = typeof id === 'string' && !seen.has(id) ? byId.get(id) : undefined + if (!repo) { + return false + } + seen.add(id) + reorderedHostRepos.push(repo) + } + let nextHostIndex = 0 + this.state.repos = current.map((repo) => + getRepoExecutionHostId(repo) === hostId ? reorderedHostRepos[nextHostIndex++] : repo + ) + this.syncProjectHostSetupCompatibilityState() + this.scheduleSave() + return true + } + removeProject(id: string): void { this.state.repos = this.state.repos.filter((r) => r.id !== id) this.syncProjectHostSetupCompatibilityState() @@ -5233,6 +5268,9 @@ export class Store { if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true } + if ('showMenuBarIcon' in updates) { + sanitizedUpdates.showMenuBarIcon = updates.showMenuBarIcon === true + } if ('disabledTuiAgents' in updates) { sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) } @@ -5409,6 +5447,7 @@ export class Store { this.state.ui?.visibleWorkspaceHostIds ), workspaceHostOrder: normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder), + manualRepoOrder: normalizeManualRepoOrder(this.state.ui?.manualRepoOrder), browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( this.state.ui?.browserDefaultZoomLevel ), @@ -5494,6 +5533,10 @@ export class Store { updates.workspaceHostOrder !== undefined ? normalizeExecutionHostOrder(updates.workspaceHostOrder) : normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder), + manualRepoOrder: + updates.manualRepoOrder !== undefined + ? normalizeManualRepoOrder(updates.manualRepoOrder) + : normalizeManualRepoOrder(this.state.ui?.manualRepoOrder), browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( updates.browserDefaultZoomLevel ?? this.state.ui?.browserDefaultZoomLevel ), diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index 47ac259f1bb..c77b094b97a 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -5,7 +5,11 @@ import { describe, expect, it, vi } from 'vitest' import { getPiAgentStatusExtensionSource } from './agent-status-extension-source' -type HookHandler = (event?: unknown) => Promise | void +type HookContext = { + isIdle: () => boolean +} + +type HookHandler = (event?: unknown, context?: HookContext) => Promise | void type FakeCurlChild = { on: ReturnType @@ -24,7 +28,11 @@ type Harness = { readFileSync: ReturnType } handlers: Record - callHook: (name: string, event?: unknown) => Promise + processEnv: Record + callHook: (name: string, event?: unknown, context?: HookContext) => Promise + // Re-invoke the extension factory in the same process (as Pi does on an + // in-process extension reload), swapping in the freshly registered handlers. + reload: () => void } const BASE_ENV = { @@ -38,9 +46,14 @@ const BASE_ENV = { ORCA_AGENT_HOOK_VERSION: '1.2.3' } satisfies Record +// Why: ownership keys on process.pid, so reload and child-process tests need +// stable, distinct identities. +const SELF_PID = 4242 + function createHarness(args: { kind: 'pi' | 'omp' env?: Record + pid?: number title?: string argv?: string[] existsSync?: (path: string) => boolean @@ -95,6 +108,7 @@ function createHarness(args: { ...BASE_ENV, ...args.env }, + pid: args.pid ?? SELF_PID, title: args.title ?? 'node', argv: args.argv ?? ['node', '/usr/bin/orca'] } @@ -134,11 +148,14 @@ function createHarness(args: { } const handlers: Record = {} - register({ - on(name: string, handler: HookHandler) { - handlers[name] = handler - } - }) + const registerInto = (target: Record): void => { + register({ + on(name: string, handler: HookHandler) { + target[name] = handler + } + }) + } + registerInto(handlers) return { fetchMock, @@ -146,8 +163,15 @@ function createHarness(args: { spawnedChildren, fsMock, handlers, - callHook: async (name, event) => { - await handlers[name]?.(event) + processEnv: processMock.env, + callHook: async (name, event, hookContext) => { + await handlers[name]?.(event, hookContext) + }, + reload: () => { + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + registerInto(handlers) } } } @@ -167,6 +191,51 @@ describe('getPiAgentStatusExtensionSource', () => { expect(harness.spawnMock).not.toHaveBeenCalled() }) + it.each(['pi', 'omp'] as const)( + 'registers no status handlers for a nested %s subagent process', + (kind) => { + // Why: inheriting the lead's owner PID must disable the extension as a + // whole, so future hook additions cannot reopen the notification leak. + const lead = createHarness({ kind, pid: SELF_PID }) + const child = createHarness({ kind, pid: SELF_PID + 1, env: lead.processEnv }) + const grandchild = createHarness({ kind, pid: SELF_PID + 2, env: child.processEnv }) + + expect(child.handlers).toEqual({}) + expect(grandchild.handlers).toEqual({}) + expect(child.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + expect(grandchild.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + expect(child.fetchMock).not.toHaveBeenCalled() + expect(child.spawnMock).not.toHaveBeenCalled() + } + ) + + it('reports agent_end for a top-level run (including non-interactive) and claims the pane by pid', async () => { + // Why: non-interactive top-level runs still own their pane and must report. + const harness = createHarness({ kind: 'pi', pid: SELF_PID, argv: ['node', 'pi', '-p'] }) + + await harness.callHook('agent_end') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + const body = JSON.parse(String(harness.fetchMock.mock.calls[0]?.[1]?.body)) + expect(body.payload).toEqual({ hook_event_name: 'agent_end' }) + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + }) + + it('keeps reporting after the lead re-runs the extension factory on reload', async () => { + // Why: Pi reloads extensions in-process, so the lead must recognize its PID + // instead of mistaking its own marker for a nested child. + const harness = createHarness({ kind: 'pi', pid: SELF_PID }) + + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + + harness.reload() + await harness.callHook('agent_end') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + const body = JSON.parse(String(harness.fetchMock.mock.calls[0]?.[1]?.body)) + expect(body.payload).toEqual({ hook_event_name: 'agent_end' }) + }) + it('keeps native fetch as the only path even when the runtime looks like WSL', async () => { const harness = createHarness({ kind: 'omp', @@ -371,6 +440,130 @@ describe('getPiAgentStatusExtensionSource', () => { } }) + it('reports only agent_settled after multiple first-run agent_end events', async () => { + vi.useFakeTimers() + try { + const harness = createHarness({ kind: 'pi' }) + const context = { isIdle: vi.fn(() => false) } + + for (let index = 0; index < 3; index += 1) { + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(700) + } + expect(harness.fetchMock).not.toHaveBeenCalled() + + await harness.callHook('agent_settled') + await vi.advanceTimersByTimeAsync(0) + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(JSON.parse(String(harness.fetchMock.mock.calls[0]?.[1]?.body)).payload).toEqual({ + hook_event_name: 'agent_end' + }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('does not duplicate completion when idle is observed before agent_settled', async () => { + vi.useFakeTimers() + try { + const harness = createHarness({ kind: 'pi' }) + const context = { isIdle: vi.fn(() => true) } + + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(0) + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + + await harness.callHook('agent_settled') + await vi.advanceTimersByTimeAsync(0) + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('cancels an ambiguous agent_end when modern Pi resumes work', async () => { + vi.useFakeTimers() + try { + const harness = createHarness({ kind: 'pi' }) + const context = { isIdle: vi.fn(() => false) } + + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(100) + await harness.callHook('agent_start') + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(2_000) + await harness.callHook('agent_settled') + await vi.advanceTimersByTimeAsync(0) + + const events = harness.fetchMock.mock.calls.map( + (call) => JSON.parse(String(call[1]?.body)).payload.hook_event_name + ) + expect(events).toEqual(['agent_start', 'agent_end']) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('drops a pending legacy fallback when its context becomes stale on reload', async () => { + vi.useFakeTimers() + try { + const harness = createHarness({ kind: 'pi' }) + let active = true + const context = { + isIdle: vi.fn(() => { + if (!active) { + throw new Error('stale extension context') + } + return false + }) + } + + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(100) + active = false + harness.reload() + await vi.advanceTimersByTimeAsync(100) + + expect(harness.fetchMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('keeps reporting legacy Pi and OMP once their agent_end handlers settle', async () => { + vi.useFakeTimers() + try { + for (const kind of ['pi', 'omp'] as const) { + const harness = createHarness({ kind }) + let idle = false + const context = { isIdle: vi.fn(() => idle) } + + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(100) + expect(harness.fetchMock).not.toHaveBeenCalled() + + idle = true + await vi.advanceTimersByTimeAsync(100) + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + } + } finally { + vi.useRealTimers() + } + }) + + it('keeps immediate agent_end fallback for runtimes without an idle context', async () => { + const harness = createHarness({ kind: 'omp' }) + + await harness.callHook('agent_end') + await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(1)) + }) + it('does not treat WSLENV alone as WSL evidence', async () => { const harness = createHarness({ kind: 'omp', diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index 625e89df7c0..38c4354ee13 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -29,12 +29,20 @@ export function getPiAgentStatusHandlerSourceLines(): string[] { '// etc.), so we forward the raw object verbatim under the same field', '// names Claude uses (tool_name / tool_input) and let the server pick the', '// preview. Keeps tool-name knowledge centralized on the receiver side.', + "// Why: child agents inherit the lead's pane env; only its process may", + '// register status hooks. PID identity keeps in-process reloads reporting.', 'export default function (pi): void {', + ' const ownerPid = process.env.ORCA_PI_STATUS_OWNED', + ' const selfPid = String(process.pid)', + ' if (ownerPid && ownerPid !== selfPid) return', + ' process.env.ORCA_PI_STATUS_OWNED = selfPid', " pi.on('before_agent_start', (event) => {", " post('before_agent_start', { prompt: event.prompt ?? '' })", ' })', '', " pi.on('agent_start', () => {", + ' clearPendingAgentEndCheck()', + ' agentEndReported = false', " post('agent_start')", ' })', '', @@ -69,8 +77,69 @@ export function getPiAgentStatusHandlerSourceLines(): string[] { " post('message_end', { role: 'assistant', text })", ' })', '', - " pi.on('agent_end', () => {", + ' // Why: modern Pi stays non-idle across retry/compaction/follow-up work,', + ' // while legacy Pi/OMP becomes idle after its final agent_end handlers.', + ' const AGENT_END_IDLE_RECHECK_MS = 25', + ' const AGENT_END_IDLE_RECHECK_MAX_MS = 250', + ' let agentSettledSupported = false', + ' let agentEndReported = false', + ' let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS', + ' let pendingAgentEndCheck: ReturnType | null = null', + ' let pendingAgentEndContext: { isIdle: () => boolean } | null = null', + '', + ' function clearPendingAgentEndCheck(): void {', + ' if (pendingAgentEndCheck !== null) clearTimeout(pendingAgentEndCheck)', + ' pendingAgentEndCheck = null', + ' pendingAgentEndContext = null', + ' }', + '', + ' // Why: isIdle flips before agent_settled handlers run, so both paths', + ' // share a per-run guard instead of racing duplicate completion posts.', + ' function postAgentEndOnce(): void {', + ' if (agentEndReported) return', + ' agentEndReported = true', " post('agent_end')", + ' }', + '', + ' function checkPendingAgentEnd(): void {', + ' pendingAgentEndCheck = null', + ' const ctx = pendingAgentEndContext', + ' if (!ctx || agentSettledSupported || agentEndReported) {', + ' pendingAgentEndContext = null', + ' return', + ' }', + ' try {', + ' if (ctx.isIdle()) {', + ' pendingAgentEndContext = null', + ' postAgentEndOnce()', + ' return', + ' }', + ' } catch {', + ' pendingAgentEndContext = null', + ' return', + ' }', + ' pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, agentEndIdleRecheckMs)', + " if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()", + ' agentEndIdleRecheckMs = Math.min(agentEndIdleRecheckMs * 2, AGENT_END_IDLE_RECHECK_MAX_MS)', + ' }', + '', + " pi.on('agent_settled', () => {", + ' agentSettledSupported = true', + ' clearPendingAgentEndCheck()', + ' postAgentEndOnce()', + ' })', + '', + " pi.on('agent_end', (_event, ctx) => {", + ' if (agentSettledSupported) return', + " if (!ctx || typeof ctx.isIdle !== 'function') {", + ' postAgentEndOnce()', + ' return', + ' }', + ' clearPendingAgentEndCheck()', + ' agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS', + ' pendingAgentEndContext = ctx', + ' pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, 0)', + " if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()", ' })', '}', '' diff --git a/src/main/providers/git-provider-status-options.ts b/src/main/providers/git-provider-status-options.ts new file mode 100644 index 00000000000..3d03ca6a269 --- /dev/null +++ b/src/main/providers/git-provider-status-options.ts @@ -0,0 +1,7 @@ +// Kept separate so the central cross-provider contract stays within its enforced size limit. +export type GitProviderStatusOptions = { + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + signal?: AbortSignal +} diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index cdb7656b236..f69505161f2 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -9,7 +9,9 @@ const { mkdirSyncMock, writeFileSyncMock, spawnMock, - resolveAgentForegroundProcessMock + resolveAgentForegroundProcessMock, + captureDescendantSnapshotMock, + terminateDescendantSnapshotMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), statSyncMock: vi.fn(), @@ -17,7 +19,9 @@ const { mkdirSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), spawnMock: vi.fn(), - resolveAgentForegroundProcessMock: vi.fn() + resolveAgentForegroundProcessMock: vi.fn(), + captureDescendantSnapshotMock: vi.fn(), + terminateDescendantSnapshotMock: vi.fn() })) vi.mock('fs', () => ({ @@ -40,6 +44,11 @@ vi.mock('node-pty', () => ({ spawn: spawnMock })) +vi.mock('../pty-descendant-termination', () => ({ + captureDescendantSnapshot: captureDescendantSnapshotMock, + terminateDescendantSnapshot: terminateDescendantSnapshotMock +})) + // Resolve PowerShell family names to deterministic absolute paths (the fs mock // above otherwise makes every probe miss). The real resolver — which skips the // Store App Execution Alias stub — is covered in @@ -84,7 +93,13 @@ vi.mock('../wsl', () => ({ wslUncDirectoryExists: () => true })) -import { LocalPtyProvider } from './local-pty-provider' +import { + _resetLocalPtyProviderStateForTest, + LOCAL_PTY_FORCE_KILL_RETRY_MS, + LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS, + LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS, + LocalPtyProvider +} from './local-pty-provider' import { isRootLikePath } from './pty-path-safety' import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env' @@ -123,6 +138,9 @@ describe('LocalPtyProvider', () => { accessSyncMock.mockReturnValue(undefined) mkdirSyncMock.mockReset() writeFileSyncMock.mockReset() + captureDescendantSnapshotMock.mockReset() + captureDescendantSnapshotMock.mockResolvedValue(null) + terminateDescendantSnapshotMock.mockReset() resolveAgentForegroundProcessMock.mockReset() resolveAgentForegroundProcessMock.mockImplementation( async (_pid: number, fallbackProcess: string | null) => fallbackProcess @@ -157,6 +175,7 @@ describe('LocalPtyProvider', () => { }) afterEach(() => { + _resetLocalPtyProviderStateForTest() if (origPlatform) { Object.defineProperty(process, 'platform', origPlatform) } @@ -1016,12 +1035,191 @@ describe('LocalPtyProvider', () => { }) const { id } = await provider.spawn({ cols: 80, rows: 24 }) - await provider.shutdown(id, { immediate: true }) + const shutdown = provider.shutdown(id, { immediate: true }) + exitCb?.({ exitCode: -1 }) + await shutdown expect(killSpy).toHaveBeenCalledTimes(1) expect(destroySpy).not.toHaveBeenCalled() }) + it('keeps shutdown and ownership pending until node-pty reports physical exit', async () => { + const killSpy = vi.fn() + mockProc.kill = killSpy + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + let settled = false + const shutdown = provider.shutdown(id, { immediate: true }).finally(() => { + settled = true + }) + await Promise.resolve() + + expect(killSpy).toHaveBeenCalledWith('SIGKILL') + expect(settled).toBe(false) + expect(provider.hasPty(id)).toBe(true) + exitCb?.({ exitCode: 137 }) + await shutdown + expect(provider.hasPty(id)).toBe(false) + }) + + it('keeps physical-exit tracking when orphan cleanup races immediate shutdown', async () => { + const killSpy = vi.fn(() => { + queueMicrotask(() => exitCb?.({ exitCode: 137 })) + }) + mockProc.kill = killSpy + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + const shutdown = provider.shutdown(id, { immediate: true }) + expect(provider.killOrphanedPtys(1)).toEqual([{ id }]) + expect(provider.hasPty(id)).toBe(true) + + await expect(shutdown).resolves.toBeUndefined() + expect(killSpy).toHaveBeenCalledTimes(1) + expect(provider.hasPty(id)).toBe(false) + }) + + it('escalates graceful shutdown before orphan cleanup disables the kill handle', async () => { + vi.useFakeTimers() + try { + const killSpy = vi.fn() + mockProc.kill = killSpy + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + const graceful = provider.shutdown(id, { immediate: false }) + expect(killSpy.mock.calls).toEqual([['SIGTERM']]) + + expect(provider.killOrphanedPtys(1)).toEqual([{ id }]) + expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) + + // The original graceful deadline was replaced by the immediate + // escalation, so it cannot call the now-neutralized proc.kill later. + await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS) + expect(killSpy).toHaveBeenCalledTimes(2) + exitCb?.({ exitCode: 137 }) + await graceful + expect(provider.hasPty(id)).toBe(false) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('escalates a graceful shutdown when destructive cleanup joins it', async () => { + const killSpy = vi.fn() + mockProc.kill = killSpy + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + const graceful = provider.shutdown(id, { immediate: false }) + const immediate = provider.shutdown(id, { immediate: true }) + expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) + + exitCb?.({ exitCode: 137 }) + await Promise.all([graceful, immediate]) + expect(provider.hasPty(id)).toBe(false) + }) + + it('force-kills a POSIX PTY that ignores graceful shutdown', async () => { + vi.useFakeTimers() + try { + const killSpy = vi.fn() + mockProc.kill = killSpy + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + const graceful = provider.shutdown(id, { immediate: false }) + expect(killSpy.mock.calls).toEqual([['SIGTERM']]) + + await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS) + expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) + exitCb?.({ exitCode: 137 }) + await graceful + expect(provider.hasPty(id)).toBe(false) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('retries a rejected graceful-deadline SIGKILL before the physical timeout', async () => { + vi.useFakeTimers() + try { + let forceAttempts = 0 + const killSpy = vi.fn((signal: string) => { + if (signal === 'SIGKILL' && forceAttempts++ === 0) { + throw new Error('transient force-kill failure') + } + }) + mockProc.kill = killSpy + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + const graceful = provider.shutdown(id, { immediate: false }) + await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS) + expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) + expect(provider.hasPty(id)).toBe(true) + + await vi.advanceTimersByTimeAsync(LOCAL_PTY_FORCE_KILL_RETRY_MS) + expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGKILL']]) + exitCb?.({ exitCode: 137 }) + await graceful + expect(provider.hasPty(id)).toBe(false) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('does not double-kill ConPTY when destructive cleanup joins shutdown', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const killSpy = vi.fn() + mockProc.kill = killSpy + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + const graceful = provider.shutdown(id, { immediate: false }) + const immediate = provider.shutdown(id, { immediate: true }) + expect(killSpy.mock.calls).toEqual([[]]) + + exitCb?.({ exitCode: 137 }) + await Promise.all([graceful, immediate]) + expect(killSpy).toHaveBeenCalledTimes(1) + expect(provider.hasPty(id)).toBe(false) + }) + + it('rejects a physical-exit timeout but retains the owner for a successful retry', async () => { + vi.useFakeTimers() + try { + const killSpy = vi.fn() + mockProc.kill = killSpy + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + const shutdown = provider.shutdown(id, { immediate: true }) + const rejected = expect(shutdown).rejects.toThrow('Timed out waiting for PTY process exit') + await vi.advanceTimersByTimeAsync(LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS) + await rejected + expect(provider.hasPty(id)).toBe(true) + + const retry = provider.shutdown(id, { immediate: true }) + expect(killSpy).toHaveBeenCalledTimes(1) + exitCb?.({ exitCode: 137 }) + await expect(retry).resolves.toBeUndefined() + expect(provider.hasPty(id)).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('propagates kill failure without dropping the physical owner', async () => { + mockProc.kill = vi.fn(() => { + throw new Error('kill denied') + }) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.shutdown(id, { immediate: true })).rejects.toThrow('kill denied') + expect(provider.hasPty(id)).toBe(true) + + mockProc.kill = vi.fn(() => exitCb?.({ exitCode: 137 })) + await expect(provider.shutdown(id, { immediate: true })).resolves.toBeUndefined() + expect(provider.hasPty(id)).toBe(false) + }) + it('cancels pending shell-ready startup delivery on forced shutdown', async () => { vi.useFakeTimers() try { @@ -1041,6 +1239,79 @@ describe('LocalPtyProvider', () => { await provider.shutdown('nonexistent', { immediate: true }) expect(mockProc.kill).not.toHaveBeenCalled() }) + + it('waits for an in-flight agent shutdown before reusing the same session id', async () => { + let resolveSnapshot!: (value: null) => void + captureDescendantSnapshotMock.mockReturnValue( + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + const spawnArgs = { + cols: 80, + rows: 24, + sessionId: 'stable-agent-session', + launchAgent: 'claude' as const + } + const spawnCallsBefore = spawnMock.mock.calls.length + const { id } = await provider.spawn(spawnArgs) + + const shutdown = provider.shutdown(id, { immediate: true }) + const respawn = provider.spawn(spawnArgs) + await Promise.resolve() + expect(spawnMock).toHaveBeenCalledTimes(spawnCallsBefore + 1) + + resolveSnapshot(null) + await shutdown + await respawn + expect(spawnMock).toHaveBeenCalledTimes(spawnCallsBefore + 2) + }) + + it('coalesces duplicate shutdown while descendant capture is pending', async () => { + let resolveSnapshot!: (value: null) => void + captureDescendantSnapshotMock.mockReturnValue( + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + const { id } = await provider.spawn({ + cols: 80, + rows: 24, + launchAgent: 'claude' + }) + + const first = provider.shutdown(id, { immediate: true }) + const second = provider.shutdown(id, { immediate: true }) + expect(captureDescendantSnapshotMock).toHaveBeenCalledOnce() + resolveSnapshot(null) + await Promise.all([first, second]) + expect(captureDescendantSnapshotMock).toHaveBeenCalledOnce() + }) + + it('does not signal a captured tree after the tracked root exits naturally', async () => { + let resolveSnapshot!: (value: { + rootPgid: number + descendants: [] + capturedAtMs: number + }) => void + captureDescendantSnapshotMock.mockReturnValue( + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + const { id } = await provider.spawn({ + cols: 80, + rows: 24, + launchAgent: 'claude' + }) + + const shutdown = provider.shutdown(id, { immediate: true }) + exitCb?.({ exitCode: 0 }) + resolveSnapshot({ rootPgid: mockProc.pid, descendants: [], capturedAtMs: Date.now() }) + await shutdown + + expect(terminateDescendantSnapshotMock).not.toHaveBeenCalled() + }) }) describe('hasChildProcesses', () => { @@ -1147,13 +1418,14 @@ describe('LocalPtyProvider', () => { describe('listProcesses', () => { it('returns spawned PTYs', async () => { const before = await provider.listProcesses() - await provider.spawn({ cols: 80, rows: 24 }) + await provider.spawn({ cols: 80, rows: 24, cwd: '/tmp/owned-cwd' }) await provider.spawn({ cols: 80, rows: 24 }) const after = await provider.listProcesses() expect(after.length - before.length).toBe(2) const newEntries = after.slice(before.length) expect(newEntries[0]).toHaveProperty('id') expect(newEntries[0]).toHaveProperty('title', 'zsh') + expect(newEntries[0]).toHaveProperty('cwd', '/tmp/owned-cwd') }) }) @@ -1230,5 +1502,21 @@ describe('LocalPtyProvider', () => { expect(killSpy).toHaveBeenCalledTimes(1) expect(destroySpy).not.toHaveBeenCalled() }) + + it('settles an overlapping shutdown when app quit takes final ownership', async () => { + mockProc.kill.mockImplementation(() => undefined) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + const shutdown = provider.shutdown(id, { immediate: true }) + let settled = false + void shutdown.then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + provider.killAll() + + await expect(shutdown).resolves.toBeUndefined() + }) }) }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 03abbb1e54b..70e05d8a7e4 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -54,10 +54,16 @@ import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' import { resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' import { getAgentForegroundContextPaths } from './agent-foreground-context-paths' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { + captureDescendantSnapshot, + terminateDescendantSnapshot +} from '../pty-descendant-termination' import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' +import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' +import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' const PANE_IDENTITY_ENV_KEYS = [ @@ -69,15 +75,36 @@ const PANE_IDENTITY_ENV_KEYS = [ let ptyCounter = 0 const ptyProcesses = new Map() +// Why: only agent sessions get descendant tree-kill on shutdown. Agent CLIs +// spawn tool children in detached process groups the PTY's SIGHUP can never +// reach; plain user terminals keep classic semantics where deliberately +// detached (nohup-style) children survive the pane. +const ptyAgentSessionIds = new Set() +// Why: descendant capture is async. Reattach and duplicate shutdown must wait +// for the original owner instead of returning a PTY that is about to die. +type PtyShutdownOperation = { + promise: Promise + immediate: boolean + rootSignalled: boolean + proc: pty.IPty +} +const ptyShutdownOperations = new Map() const ptyShellName = new Map() const ptyAgentForegroundContextPaths = new Map() const ptyTerminalHandle = new Map() -// Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction -// callbacks. If the PTY is killed without disposing these listeners, the -// stale callbacks survive into node::FreeEnvironment() where NAPI attempts -// to invoke/clean them up on a destroyed environment, triggering a SIGABRT. +const ptyInitialCwd = new Map() +// Why: node-pty callbacks must be disposed before environment teardown, but +// onExit separately owns physical process-exit proof during termination. const ptyDisposables = new Map void }[]>() +const ptyExitDisposables = new Map void }>() const ptyCleanupCallbacks = new Map void>() +const ptyTerminationMode = new Map() +const ptyPhysicalExits = new Map() +const ptyForceKillTimers = new Map>() + +export const LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS = 8_000 +export const LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS = 5_000 +export const LOCAL_PTY_FORCE_KILL_RETRY_MS = 250 let loadGeneration = 0 const ptyLoadGeneration = new Map() @@ -140,6 +167,19 @@ function disposePtyListeners(id: string): void { } } +function disposePtyExitListener(id: string): void { + ptyExitDisposables.get(id)?.dispose() + ptyExitDisposables.delete(id) +} + +function clearLocalPtyForceKillTimer(id: string): void { + const timer = ptyForceKillTimers.get(id) + if (timer) { + clearTimeout(timer) + ptyForceKillTimers.delete(id) + } +} + function runPtyCleanup(id: string): void { const cleanup = ptyCleanupCallbacks.get(id) if (!cleanup) { @@ -178,13 +218,79 @@ function getWslContextFromPreferredDistro( * Removes all local tracking state for a PTY id after teardown. */ function clearPtyState(id: string): void { + clearLocalPtyForceKillTimer(id) runPtyCleanup(id) disposePtyListeners(id) + disposePtyExitListener(id) ptyProcesses.delete(id) + ptyAgentSessionIds.delete(id) ptyShellName.delete(id) ptyAgentForegroundContextPaths.delete(id) ptyTerminalHandle.delete(id) + ptyInitialCwd.delete(id) ptyLoadGeneration.delete(id) + ptyTerminationMode.delete(id) + ptyPhysicalExits.delete(id) +} + +function createPtyPhysicalExit(id: string): void { + ptyPhysicalExits.set(id, new PhysicalExitTracker()) +} + +function waitForPtyPhysicalExit(id: string, physicalExit?: PhysicalExitTracker): Promise { + if (!physicalExit) { + return Promise.reject(new Error(`PTY "${id}" exit tracking unavailable`)) + } + return physicalExit.waitForExit( + LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS, + () => new Error(`Timed out waiting for PTY process exit: ${id}`) + ) +} + +function killLocalPtyProcess(proc: pty.IPty, immediate: boolean): void { + if (process.platform === 'win32') { + proc.kill() + return + } + if (!immediate) { + proc.kill('SIGTERM') + return + } + forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL')) +} + +function armLocalPtyForceKill( + id: string, + proc: pty.IPty, + options: { delayMs?: number; attemptsRemaining?: number } = {} +): void { + if (ptyProcesses.get(id) !== proc || ptyTerminationMode.get(id) !== 'graceful') { + return + } + const attemptsRemaining = options.attemptsRemaining ?? 2 + const timer = setTimeout(() => { + ptyForceKillTimers.delete(id) + if (ptyProcesses.get(id) !== proc || ptyTerminationMode.get(id) !== 'graceful') { + return + } + ptyTerminationMode.set(id, 'force') + try { + killLocalPtyProcess(proc, true) + } catch (error) { + ptyTerminationMode.set(id, 'graceful') + console.error('[pty] failed to force-kill PTY after graceful deadline', { id, error }) + // Why: a transient native rejection must not consume the only SIGKILL + // owner while the logical shutdown continues waiting for physical exit. + if (attemptsRemaining > 1) { + armLocalPtyForceKill(id, proc, { + delayMs: LOCAL_PTY_FORCE_KILL_RETRY_MS, + attemptsRemaining: attemptsRemaining - 1 + }) + } + } + }, options.delayMs ?? LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS) + timer.unref?.() + ptyForceKillTimers.set(id, timer) } /** @@ -275,18 +381,36 @@ function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } } /** - * Kills a local PTY and clears all associated local provider state. + * Requests local PTY termination while retaining physical-exit ownership. */ -function safeKillAndClean(id: string, proc: pty.IPty): void { +function requestPtyTermination(id: string, proc: pty.IPty): void { runPtyCleanup(id) disposePtyListeners(id) - try { - proc.kill() - } catch { - /* Process may already be dead */ + const previousMode = ptyTerminationMode.get(id) + // Why: destructive cleanup neutralizes proc.kill below, so an outstanding + // graceful request must be escalated before its deadline can be disabled. + if (previousMode !== 'force') { + clearLocalPtyForceKillTimer(id) + ptyTerminationMode.set(id, 'force') + try { + killLocalPtyProcess(proc, true) + } catch { + if (previousMode === 'graceful') { + ptyTerminationMode.set(id, previousMode) + armLocalPtyForceKill(id, proc, { + delayMs: LOCAL_PTY_FORCE_KILL_RETRY_MS, + attemptsRemaining: 1 + }) + } else { + ptyTerminationMode.delete(id) + } + /* Process may already be dead. */ + return + } } + // Why: shutdown and orphan cleanup can race; node-pty's onExit listener and + // tracker must remain installed until the OS proves the child was reaped. destroyPtyProcess(proc, { alreadyKilled: true }) - clearPtyState(id) } export type LocalPtyProviderOptions = { @@ -341,6 +465,10 @@ export class LocalPtyProvider implements IPtyProvider { async spawn(args: PtySpawnOptions): Promise { const reattachId = normalizeLocalCallerSessionId(args.sessionId) if (reattachId) { + const pendingShutdown = ptyShutdownOperations.get(reattachId) + if (pendingShutdown) { + await pendingShutdown.promise + } const existing = ptyProcesses.get(reattachId) if (existing) { try { @@ -695,7 +823,15 @@ export class LocalPtyProvider implements IPtyProvider { } const proc = spawnResult.process + createPtyPhysicalExit(id) ptyProcesses.set(id, proc) + ptyInitialCwd.set(id, cwd) + // Why both signals: launchAgent is the caller's explicit intent and + // survives command rewriting (e.g. auth env prefixes); recognition covers + // callers that pass a bare agent command line without the flag. + if (args.launchAgent || startupAgentRecognition) { + ptyAgentSessionIds.add(id) + } ptyShellName.set(id, getSpawnedShellName(shellPath)) if (finalEnv.ORCA_TERMINAL_HANDLE) { ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE) @@ -790,6 +926,8 @@ export class LocalPtyProvider implements IPtyProvider { } const onExitDisposable = proc.onExit(({ exitCode }) => { + const wasTerminationRequested = ptyTerminationMode.has(id) + ptyPhysicalExits.get(id)?.markExited() // Why: neutralize proc.kill the instant the child is reaped, before any // other work in this callback. node-pty's UnixTerminal installs a // `_socket.once('close', () => this.kill('SIGHUP'))` handler at destroy @@ -811,14 +949,14 @@ export class LocalPtyProvider implements IPtyProvider { // Why: release the master ptmx fd on the natural-exit path — without // this, a shell that exits cleanly (the common case) never releases its // fd until the next GC. See docs/fix-pty-fd-leak.md. - destroyPtyProcess(proc) + destroyPtyProcess(proc, { alreadyKilled: wasTerminationRequested }) this.opts.onExit?.(id, exitCode) for (const cb of exitListeners) { cb({ id, code: exitCode }) } }) if (onExitDisposable) { - disposables.push(onExitDisposable) + ptyExitDisposables.set(id, onExitDisposable) } ptyDisposables.set(id, disposables) @@ -893,28 +1031,87 @@ export class LocalPtyProvider implements IPtyProvider { return { cols: proc.cols, rows: proc.rows } } - async shutdown(id: string, _opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + const pending = ptyShutdownOperations.get(id) + if (pending) { + if (opts.immediate === true) { + pending.immediate = true + if (pending.rootSignalled && ptyProcesses.get(id) === pending.proc) { + this.requestTrackedPtyShutdown(id, pending.proc, true) + } + } + await pending.promise + return + } const proc = ptyProcesses.get(id) if (!proc) { return } - // Why: disposePtyListeners removes the onExit callback, so the natural - // exit cleanup path from node-pty won't fire. Cleanup and notification - // must happen unconditionally after the try/catch. - // Timer/writer cleanup must happen here too: disposing listeners prevents - // the natural onExit callback from running the usual clearPtyState path. - runPtyCleanup(id) - disposePtyListeners(id) - try { - proc.kill() - } catch { - /* Process may already be dead */ + const entry: PtyShutdownOperation = { + promise: Promise.resolve(), + immediate: opts.immediate === true, + rootSignalled: false, + proc } - destroyPtyProcess(proc, { alreadyKilled: true }) - clearPtyState(id) - this.opts.onExit?.(id, -1) - for (const cb of exitListeners) { - cb({ id, code: -1 }) + entry.promise = this.shutdownTrackedPty(id, proc, entry) + ptyShutdownOperations.set(id, entry) + try { + await entry.promise + } finally { + if (ptyShutdownOperations.get(id) === entry) { + ptyShutdownOperations.delete(id) + } + } + } + + private async shutdownTrackedPty( + id: string, + proc: pty.IPty, + operation: PtyShutdownOperation + ): Promise { + const physicalExit = ptyPhysicalExits.get(id) + // Why: the snapshot must precede any signal — once the shell dies, + // surviving descendants reparent to pid 1 and a ppid walk can't find them. + const descendants = ptyAgentSessionIds.has(id) + ? await captureDescendantSnapshot(proc.pid) + : null + // Why: a natural exit can race the snapshot. Never signal descendants or + // a root PID after this exact PTY has lost ownership. + if (ptyProcesses.get(id) === proc) { + if (descendants) { + terminateDescendantSnapshot(descendants) + } + // Cancel startup delivery now, but preserve the exit listener and all + // ownership maps until node-pty reports the physical process exit. + runPtyCleanup(id) + operation.rootSignalled = true + this.requestTrackedPtyShutdown(id, proc, operation.immediate) + } + await waitForPtyPhysicalExit(id, physicalExit) + } + + private requestTrackedPtyShutdown(id: string, proc: pty.IPty, immediate: boolean): void { + const previousMode = ptyTerminationMode.get(id) + // Why: ConPTY has no graceful signal; its first bare node-pty kill closes + // the pseudoconsole and must be treated as the final force request. + const requestedMode = immediate || process.platform === 'win32' ? 'force' : 'graceful' + if (!previousMode || (requestedMode === 'force' && previousMode !== 'force')) { + ptyTerminationMode.set(id, requestedMode) + try { + killLocalPtyProcess(proc, immediate) + if (requestedMode === 'graceful') { + armLocalPtyForceKill(id, proc) + } else { + clearLocalPtyForceKillTimer(id) + } + } catch (error) { + if (previousMode) { + ptyTerminationMode.set(id, previousMode) + } else { + ptyTerminationMode.delete(id) + } + throw error + } } } @@ -1043,7 +1240,7 @@ export class LocalPtyProvider implements IPtyProvider { async listProcesses(): Promise { return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ id, - cwd: '', + cwd: ptyInitialCwd.get(id) ?? '', title: proc.process || ptyShellName.get(id) || 'shell', ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}) })) @@ -1097,7 +1294,7 @@ export class LocalPtyProvider implements IPtyProvider { const killed: { id: string }[] = [] for (const [id, proc] of ptyProcesses) { if ((ptyLoadGeneration.get(id) ?? -1) < currentGeneration) { - safeKillAndClean(id, proc) + requestPtyTermination(id, proc) killed.push({ id }) } } @@ -1117,7 +1314,30 @@ export class LocalPtyProvider implements IPtyProvider { /** Kill all in-process local PTYs. Call on app quit. */ killAll(): void { for (const [id, proc] of ptyProcesses) { - safeKillAndClean(id, proc) + runPtyCleanup(id) + disposePtyListeners(id) + disposePtyExitListener(id) + if (!(process.platform === 'win32' && ptyTerminationMode.has(id))) { + try { + proc.kill() + } catch { + /* Process may already be dead. */ + } + } + // Why: app quit cannot retain NAPI callbacks into FreeEnvironment; the + // process exit itself is the final physical handle boundary here. + destroyPtyProcess(proc, { alreadyKilled: true }) + // Why: app quit replaces node-pty's onExit callback as the final owner; + // overlapping shutdown waiters must join that same terminal boundary. + ptyPhysicalExits.get(id)?.markExited() + clearPtyState(id) } } } + +export function _resetLocalPtyProviderStateForTest(): void { + for (const id of ptyProcesses.keys()) { + clearPtyState(id) + } + loadGeneration = 0 +} diff --git a/src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts b/src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts new file mode 100644 index 00000000000..375ed34848d --- /dev/null +++ b/src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { registerSshFilesystemWatch, type WatchRegistration } from './ssh-filesystem-provider-watch' + +describe('registerSshFilesystemWatch waiters', () => { + it('removes ten thousand cancelled callers while one registration anchor remains', async () => { + let resolveWatch: () => void = () => undefined + const mux = { + request: vi.fn( + () => + new Promise((resolve) => { + resolveWatch = resolve + }) + ), + notify: vi.fn() + } as unknown as SshChannelMultiplexer + const registrations = new Map() + const watch = (signal?: AbortSignal) => + registerSshFilesystemWatch({ + mux, + registrations, + rootPath: '/home/user/project', + callback: vi.fn(), + signal, + disposed: () => false + }) + const anchor = watch() + const controllers = Array.from({ length: 10_000 }, () => new AbortController()) + const cancelled = controllers.map((controller) => + watch(controller.signal).catch((error) => error) + ) + + for (const controller of controllers) { + controller.abort() + } + await Promise.all(cancelled) + + expect(registrations.values().next().value?.setupWaiters.waiterCount).toBe(1) + resolveWatch() + await expect(anchor).resolves.toEqual(expect.any(Function)) + expect(registrations.values().next().value?.setupWaiters.waiterCount).toBe(0) + }) +}) diff --git a/src/main/providers/ssh-filesystem-provider-watch.ts b/src/main/providers/ssh-filesystem-provider-watch.ts index 02b87f3d005..8ef523860bd 100644 --- a/src/main/providers/ssh-filesystem-provider-watch.ts +++ b/src/main/providers/ssh-filesystem-provider-watch.ts @@ -1,10 +1,18 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { FsChangeEvent } from '../../shared/types' +import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { PromiseSettlementWaiters } from '../../shared/promise-settlement-waiters' + +let nextRemoteWatchId = 1 export type WatchRegistration = { + rootPath: string callbacks: Set<(events: FsChangeEvent[]) => void> - setupPromise: Promise + terminalCallbacks: Map<(events: FsChangeEvent[]) => void, (error: Error) => void> + setupWaiters: PromiseSettlementWaiters setupAbortController: AbortController + remoteWatchId: number | null ready: boolean stopping: boolean unwatchSent: boolean @@ -17,37 +25,12 @@ function createWatchAbortError(): Error { } async function awaitSetupWithOptionalAbort( - setupPromise: Promise, + setupWaiters: PromiseSettlementWaiters, 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) - ) + await setupWaiters.wait({ + signal, + createAbortError: createWatchAbortError }) } @@ -57,6 +40,7 @@ export async function registerSshFilesystemWatch(args: { registrations: Map rootPath: string callback: (events: FsChangeEvent[]) => void + onTerminalError?: (error: Error) => void signal?: AbortSignal }): Promise<() => void> { if (args.disposed()) { @@ -66,16 +50,18 @@ export async function registerSshFilesystemWatch(args: { throw createWatchAbortError() } - let registration = args.registrations.get(args.rootPath) + const rootKey = sshFilesystemWatchKey(args.rootPath) + let registration = args.registrations.get(rootKey) if (registration) { if (registration.stopping) { throw createWatchAbortError() } registration.callbacks.add(args.callback) + registration.terminalCallbacks.set(args.callback, args.onTerminalError ?? (() => undefined)) 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) + await awaitSetupWithOptionalAbort(registration.setupWaiters, args.signal) assertActiveWatch(args, registration) return createSshFilesystemWatchUnsubscribe(args, registration) } catch (error) { @@ -86,19 +72,30 @@ export async function registerSshFilesystemWatch(args: { const callbacks = new Set<(events: FsChangeEvent[]) => void>([args.callback]) const setupAbortController = new AbortController() + const remoteWatchId = nextRemoteWatchId++ + if (!Number.isSafeInteger(nextRemoteWatchId)) { + nextRemoteWatchId = 1 + } registration = { + rootPath: args.rootPath, callbacks, + terminalCallbacks: new Map([[args.callback, args.onTerminalError ?? (() => undefined)]]), setupAbortController, + remoteWatchId, ready: false, stopping: false, unwatchSent: false, - setupPromise: Promise.resolve() + setupWaiters: new PromiseSettlementWaiters(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 }) + const setupPromise = args.mux + .request( + 'fs.watch', + { rootPath: args.rootPath, watchId: remoteWatchId }, + { signal: setupAbortController.signal } + ) .then( () => { createdRegistration.ready = true @@ -106,24 +103,25 @@ export async function registerSshFilesystemWatch(args: { createdRegistration.stopping || createdRegistration.callbacks.size === 0 || args.disposed() || - args.registrations.get(args.rootPath) !== createdRegistration + args.registrations.get(rootKey) !== createdRegistration ) { - if (args.registrations.get(args.rootPath) === createdRegistration) { - args.registrations.delete(args.rootPath) + if (args.registrations.get(rootKey) === createdRegistration) { + args.registrations.delete(rootKey) } - sendSshFilesystemUnwatchOnce(args.mux, args.rootPath, createdRegistration) + sendSshFilesystemUnwatchOnce(args.mux, createdRegistration) } }, (error) => { - if (args.registrations.get(args.rootPath) === createdRegistration) { - args.registrations.delete(args.rootPath) + if (args.registrations.get(rootKey) === createdRegistration) { + args.registrations.delete(rootKey) } throw error } ) - args.registrations.set(args.rootPath, registration) + registration.setupWaiters = new PromiseSettlementWaiters(setupPromise) + args.registrations.set(rootKey, registration) try { - await awaitSetupWithOptionalAbort(registration.setupPromise, args.signal) + await awaitSetupWithOptionalAbort(registration.setupWaiters, args.signal) assertActiveWatch(args, registration) return createSshFilesystemWatchUnsubscribe(args, registration) } catch (error) { @@ -138,6 +136,51 @@ export function notifySshFilesystemUnwatch(mux: SshChannelMultiplexer, rootPath: } catch {} } +export async function closeSshFilesystemWatch( + mux: SshChannelMultiplexer, + registrations: Map, + rootPath: string +): Promise { + const rootKey = sshFilesystemWatchKey(rootPath) + try { + await mux.request('fs.unwatchAndWait', { rootPath }) + } catch (error) { + if (!isMethodNotFoundError(error)) { + throw error + } + throw new Error('Remote watcher teardown is unavailable. Reconnect the SSH target and retry.') + } + registrations.get(rootKey)?.callbacks.clear() + registrations.get(rootKey)?.terminalCallbacks.clear() + registrations.delete(rootKey) +} + +export function failSshFilesystemWatchRegistration( + registrations: Map, + rootPath: string, + remoteWatchId: number, + error: Error +): void { + const rootKey = sshFilesystemWatchKey(rootPath) + const registration = registrations.get(rootKey) + if (!registration || registration.remoteWatchId !== remoteWatchId) { + return + } + registrations.delete(rootKey) + registration.stopping = true + registration.unwatchSent = true + const terminalCallbacks = Array.from(registration.terminalCallbacks.values()) + registration.callbacks.clear() + registration.terminalCallbacks.clear() + for (const onTerminalError of terminalCallbacks) { + try { + onTerminalError(error) + } catch (callbackError) { + console.error('[ssh-fs] terminal watch callback failed', callbackError) + } + } +} + function assertActiveWatch( args: { disposed: () => boolean @@ -146,7 +189,10 @@ function assertActiveWatch( }, registration: WatchRegistration ): void { - if (args.disposed() || args.registrations.get(args.rootPath) !== registration) { + if ( + args.disposed() || + args.registrations.get(sshFilesystemWatchKey(args.rootPath)) !== registration + ) { throw new Error('SSH filesystem provider disposed') } } @@ -175,13 +221,15 @@ function releaseSshFilesystemWatchCallback( registration: WatchRegistration ): void { registration.callbacks.delete(args.callback) - if (registration.callbacks.size > 0 || args.registrations.get(args.rootPath) !== registration) { + registration.terminalCallbacks.delete(args.callback) + const rootKey = sshFilesystemWatchKey(args.rootPath) + if (registration.callbacks.size > 0 || args.registrations.get(rootKey) !== registration) { return } registration.stopping = true if (registration.ready) { - args.registrations.delete(args.rootPath) - sendSshFilesystemUnwatchOnce(args.mux, args.rootPath, registration) + args.registrations.delete(rootKey) + sendSshFilesystemUnwatchOnce(args.mux, 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. @@ -191,22 +239,24 @@ function releaseSshFilesystemWatchCallback( export function stopSshFilesystemWatchRegistration( mux: SshChannelMultiplexer, - rootPath: string, registration: WatchRegistration ): void { registration.stopping = true registration.setupAbortController.abort() - sendSshFilesystemUnwatchOnce(mux, rootPath, registration) + sendSshFilesystemUnwatchOnce(mux, registration) } function sendSshFilesystemUnwatchOnce( mux: SshChannelMultiplexer, - rootPath: string, registration: WatchRegistration ): void { if (registration.unwatchSent) { return } registration.unwatchSent = true - notifySshFilesystemUnwatch(mux, rootPath) + notifySshFilesystemUnwatch(mux, registration.rootPath) +} + +function sshFilesystemWatchKey(rootPath: string): string { + return normalizeRuntimePathForComparison(rootPath) } diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index d583a93d7cc..55659b18c20 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -497,16 +497,18 @@ describe('SshFilesystemProvider', () => { expect(result).toEqual(['src/index.ts', 'package.json']) }) - it('listFiles forwards excludePaths when provided', async () => { + it('listFiles forwards supported list options', async () => { mux.request.mockResolvedValue([]) await provider.listFiles('/home/user/project', { - excludePaths: ['/home/user/project/worktrees/b'] + excludePaths: ['/home/user/project/worktrees/b'], + maxResults: 20_000 }) expect(mux.request).toHaveBeenCalledWith( 'fs.listFiles', { rootPath: '/home/user/project', - excludePaths: ['/home/user/project/worktrees/b'] + excludePaths: ['/home/user/project/worktrees/b'], + maxResults: 20_000 }, { signal: undefined } ) @@ -540,7 +542,7 @@ describe('SshFilesystemProvider', () => { expect(mux.request).toHaveBeenCalledWith( 'fs.watch', - { rootPath: '/home/user/project' }, + { rootPath: '/home/user/project', watchId: expect.any(Number) }, { signal: expect.any(AbortSignal) } ) expect(typeof unsub).toBe('function') @@ -555,7 +557,7 @@ describe('SshFilesystemProvider', () => { expect(mux.request).toHaveBeenCalledWith( 'fs.watch', - { rootPath: '/home/user/project' }, + { rootPath: '/home/user/project', watchId: expect.any(Number) }, { signal: expect.any(AbortSignal) } ) const physicalSignal = mux.request.mock.calls[0][2]?.signal @@ -642,6 +644,50 @@ describe('SshFilesystemProvider', () => { expect(callback).toHaveBeenCalledWith(events) }) + it('invalidates only the matching watch generation after relay recovery terminates', async () => { + const firstTerminal = vi.fn() + const secondTerminal = vi.fn() + await provider.watch('/home/user/project', vi.fn(), { + onTerminalError: firstTerminal + }) + await provider.watch('/home/user/project', vi.fn(), { + onTerminalError: secondTerminal + }) + const notifHandler = mux.onNotification.mock.calls[0][0] + const firstWatchId = mux.request.mock.calls[0][1].watchId as number + + notifHandler('fs.watchFailed', { + rootPath: '/home/user/project', + watchId: firstWatchId + 1, + message: 'stale failure' + }) + expect(firstTerminal).not.toHaveBeenCalled() + + notifHandler('fs.watchFailed', { + rootPath: '/home/user/project', + watchId: firstWatchId, + message: 'bounded recovery failed' + }) + expect(firstTerminal).toHaveBeenCalledWith( + expect.objectContaining({ message: 'bounded recovery failed' }) + ) + expect(secondTerminal).toHaveBeenCalledTimes(1) + + const replacementTerminal = vi.fn() + await provider.watch('/home/user/project', vi.fn(), { + onTerminalError: replacementTerminal + }) + const replacementWatchId = mux.request.mock.calls[1][1].watchId as number + expect(replacementWatchId).not.toBe(firstWatchId) + + notifHandler('fs.watchFailed', { + rootPath: '/home/user/project', + watchId: firstWatchId, + message: 'late stale failure' + }) + expect(replacementTerminal).not.toHaveBeenCalled() + }) + it('fans out same-root watch events and unwatches only after the last subscriber', async () => { const first = vi.fn() const second = vi.fn() @@ -651,7 +697,7 @@ describe('SshFilesystemProvider', () => { expect(mux.request).toHaveBeenCalledTimes(1) expect(mux.request).toHaveBeenCalledWith( 'fs.watch', - { rootPath: '/home/user/project' }, + { rootPath: '/home/user/project', watchId: expect.any(Number) }, { signal: expect.any(AbortSignal) } ) @@ -670,6 +716,40 @@ describe('SshFilesystemProvider', () => { expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) }) + it('shares equivalent Windows spellings under one provider watch generation', async () => { + const first = vi.fn() + const second = vi.fn() + const firstTerminal = vi.fn() + const secondTerminal = vi.fn() + const unsubFirst = await provider.watch('C:\\Repo', first, { + onTerminalError: firstTerminal + }) + const unsubSecond = await provider.watch('c:/repo/', second, { + onTerminalError: secondTerminal + }) + + expect(mux.request).toHaveBeenCalledTimes(1) + const watchId = mux.request.mock.calls[0][1].watchId as number + const notification = mux.onNotification.mock.calls[0][0] + notification('fs.changed', { + events: [{ kind: 'update', absolutePath: 'c:/repo/file.ts' }] + }) + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + + unsubFirst() + expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: 'C:\\Repo' }) + notification('fs.watchFailed', { + rootPath: 'c:/repo', + watchId, + message: 'bounded recovery failed' + }) + expect(firstTerminal).not.toHaveBeenCalled() + expect(secondTerminal).toHaveBeenCalledTimes(1) + + unsubSecond() + }) + it('shares an in-flight same-root watch setup across concurrent subscribers', async () => { let resolveWatch: () => void = () => {} mux.request.mockImplementationOnce( @@ -760,6 +840,42 @@ describe('SshFilesystemProvider', () => { expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) }) + it('awaits acknowledged watcher teardown for destructive cleanup', async () => { + const callback = vi.fn() + await provider.watch('/home/user/project', callback) + mux.request.mockClear() + + await provider.closeWatch('/home/user/project') + + expect(mux.request).toHaveBeenCalledWith('fs.unwatchAndWait', { + rootPath: '/home/user/project' + }) + const notifHandler = mux.onNotification.mock.calls[0][0] + notifHandler('fs.changed', { + events: [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] + }) + expect(callback).not.toHaveBeenCalled() + }) + + it('fails destructive teardown closed on an older relay', async () => { + const callback = vi.fn() + await provider.watch('/home/user/project', callback) + mux.request.mockRejectedValueOnce( + Object.assign(new Error('Method not found'), { code: JsonRpcErrorCode.MethodNotFound }) + ) + + await expect(provider.closeWatch('/home/user/project')).rejects.toThrow( + 'Reconnect the SSH target' + ) + expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { + rootPath: '/home/user/project' + }) + const notifHandler = mux.onNotification.mock.calls[0][0] + const events = [{ kind: 'update' as const, absolutePath: '/home/user/project/file.ts' }] + notifHandler('fs.changed', { events }) + expect(callback).toHaveBeenCalledWith(events) + }) + it('sends fs.unwatch for active roots when disposed', async () => { const callback = vi.fn() await provider.watch('/home/user/project', callback) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index ed4520e8f5a..b17b3c4f783 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -8,6 +8,7 @@ import { type SshRawTransferOptions } from './ssh-filesystem-file-upload' import { + closeSshFilesystemWatch, registerSshFilesystemWatch, stopSshFilesystemWatchRegistration, type WatchRegistration @@ -20,7 +21,7 @@ import type { TerminalArtifactAccessOptions } from './types' import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' -import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 @@ -42,19 +43,9 @@ export class SshFilesystemProvider implements IFilesystemProvider { this.connectionId = connectionId this.mux = mux - this.unsubscribeNotifications = mux.onNotification((method, params) => { - if (method === 'fs.changed') { - const events = params.events as FsChangeEvent[] - for (const [rootPath, registration] of this.watchListeners) { - const matching = events.filter((e) => isPathInsideOrEqual(rootPath, e.absolutePath)) - if (matching.length > 0) { - for (const cb of registration.callbacks) { - cb(matching) - } - } - } - } - }) + this.unsubscribeNotifications = mux.onNotification((method, params) => + routeSshFilesystemWatchNotification(this.watchListeners, method, params) + ) } dispose(): void { @@ -66,8 +57,8 @@ export class SshFilesystemProvider implements IFilesystemProvider { this.unsubscribeNotifications() this.unsubscribeNotifications = null } - for (const [rootPath, registration] of this.watchListeners) { - stopSshFilesystemWatchRegistration(this.mux, rootPath, registration) + for (const registration of this.watchListeners.values()) { + stopSshFilesystemWatchRegistration(this.mux, registration) } this.watchListeners.clear() } @@ -302,12 +293,15 @@ export class SshFilesystemProvider implements IFilesystemProvider { async listFiles( rootPath: string, - options?: { excludePaths?: string[]; signal?: AbortSignal } + options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } ): Promise { const params: Record = { rootPath } if (options?.excludePaths && options.excludePaths.length > 0) { params.excludePaths = options.excludePaths } + if (options?.maxResults !== undefined) { + params.maxResults = options.maxResults + } // Why #7721: the signal lets a workspace switch send rpc.cancel so the // relay aborts the full-tree scan instead of stacking abandoned scans // that starve interactive fs.readDir/fs.stat on the shared SSH channel. @@ -319,7 +313,7 @@ export class SshFilesystemProvider implements IFilesystemProvider { async watch( rootPath: string, callback: (events: FsChangeEvent[]) => void, - options?: { signal?: AbortSignal } + options?: { signal?: AbortSignal; onTerminalError?: (error: Error) => void } ): Promise<() => void> { return registerSshFilesystemWatch({ mux: this.mux, @@ -327,7 +321,12 @@ export class SshFilesystemProvider implements IFilesystemProvider { registrations: this.watchListeners, rootPath, callback, + onTerminalError: options?.onTerminalError, signal: options?.signal }) } + + async closeWatch(rootPath: string): Promise { + await closeSshFilesystemWatch(this.mux, this.watchListeners, rootPath) + } } diff --git a/src/main/providers/ssh-filesystem-watch-notifications.ts b/src/main/providers/ssh-filesystem-watch-notifications.ts new file mode 100644 index 00000000000..97e3a3a4cdb --- /dev/null +++ b/src/main/providers/ssh-filesystem-watch-notifications.ts @@ -0,0 +1,37 @@ +import type { FsChangeEvent } from '../../shared/types' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { + failSshFilesystemWatchRegistration, + type WatchRegistration +} from './ssh-filesystem-provider-watch' + +export function routeSshFilesystemWatchNotification( + registrations: Map, + method: string, + params: Record +): void { + if (method === 'fs.changed') { + const events = params.events as FsChangeEvent[] + for (const registration of registrations.values()) { + const matching = events.filter((event) => + isPathInsideOrEqual(registration.rootPath, event.absolutePath) + ) + if (matching.length > 0) { + for (const callback of registration.callbacks) { + callback(matching) + } + } + } + return + } + if (method !== 'fs.watchFailed') { + return + } + const rootPath = typeof params.rootPath === 'string' ? params.rootPath : null + const watchId = typeof params.watchId === 'number' ? params.watchId : null + if (rootPath && watchId !== null) { + const message = + typeof params.message === 'string' ? params.message : 'remote file watcher failed' + failSshFilesystemWatchRegistration(registrations, rootPath, watchId, new Error(message)) + } +} diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index eb8383a3f60..d4a275a89ba 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -97,6 +97,22 @@ describe('SshGitProvider', () => { }) }) + it('getStatus forwards line-stat reuse and cancellation to the relay', async () => { + const controller = new AbortController() + mux.request.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + + await provider.getStatus('/home/user/repo', { + reuseLineStats: true, + signal: controller.signal + }) + + expect(mux.request).toHaveBeenCalledWith( + 'git.status', + { worktreePath: '/home/user/repo', reuseLineStats: true }, + { signal: controller.signal } + ) + }) + it('getSubmoduleStatus sends git.submoduleStatus request', async () => { const statusResult = { entries: [], conflictOperation: 'unknown' } mux.request.mockResolvedValue(statusResult) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 4bca707a0fb..1aa1cd271a2 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -106,10 +106,12 @@ export class SshGitProvider implements IGitProvider { const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache ? { bypassEffectiveUpstreamNegativeCache: true } : {} + const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} const request = { worktreePath, ...includeIgnoredArgs, - ...upstreamCacheBypassArgs + ...upstreamCacheBypassArgs, + ...lineStatsReuseArgs } return (await (options?.signal ? this.mux.request('git.status', request, { signal: options.signal }) diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index e2fbde7be26..375aeb86b54 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -505,10 +505,12 @@ describe('SshPtyProvider', () => { }) it('listProcesses returns process list', async () => { - const processes = [{ id: 'pty-1', cwd: '/home', title: 'zsh' }] + const processes = [{ id: 'pty-1', cwd: '/home', title: 'zsh', worktreeId: 'repo::/home' }] mux.request.mockResolvedValue(processes) const result = await provider.listProcesses() - expect(result).toEqual([{ id: scopedPty1, cwd: '/home', title: 'zsh' }]) + expect(result).toEqual([ + { id: scopedPty1, cwd: '/home', title: 'zsh', worktreeId: 'repo::/home' } + ]) }) it('getDefaultShell returns shell path', async () => { diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 6395174dc1b..6dc7b6ec1a3 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -23,6 +23,7 @@ import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-s import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' +import type { GitProviderStatusOptions } from './git-provider-status-options' // ─── PTY Provider ─────────────────────────────────────────────────── @@ -154,6 +155,8 @@ export type PtyProcessInfo = { id: string cwd: string title: string + /** Owning worktree when the provider can report it authoritatively. */ + worktreeId?: string /** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */ terminalHandle?: string } @@ -283,7 +286,7 @@ export type IFilesystemProvider = { search(opts: SearchOptions): Promise listFiles( rootPath: string, - options?: { excludePaths?: string[]; signal?: AbortSignal } + options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } ): Promise scanWorkspaceSpace?( rootPath: string, @@ -292,8 +295,9 @@ export type IFilesystemProvider = { watch( rootPath: string, callback: (events: FsChangeEvent[]) => void, - options?: { signal?: AbortSignal } + options?: { signal?: AbortSignal; onTerminalError?: (error: Error) => void } ): Promise<() => void> + closeWatch?(rootPath: string): Promise } export type FileUploadSession = { @@ -313,11 +317,7 @@ export type TerminalArtifactAccessOptions = { // ─── Git Provider ─────────────────────────────────────────────────── -export type GitProviderStatusOptions = { - includeIgnored?: boolean - bypassEffectiveUpstreamNegativeCache?: boolean - signal?: AbortSignal -} +export type { GitProviderStatusOptions } from './git-provider-status-options' export type IGitProvider = { getStatus(worktreePath: string, options?: GitProviderStatusOptions): Promise diff --git a/src/main/pty-descendant-termination.test.ts b/src/main/pty-descendant-termination.test.ts new file mode 100644 index 00000000000..776384f69b5 --- /dev/null +++ b/src/main/pty-descendant-termination.test.ts @@ -0,0 +1,383 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const execFileMock = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ execFile: execFileMock })) + +import { + captureDescendantSnapshot, + collectDescendantRows, + createProcessTableSnapshotReader, + DESCENDANT_KILL_GRACE_MS, + DESCENDANT_SNAPSHOT_TIMEOUT_MS, + killWithDescendantSweep, + parseProcessTable, + terminateDescendantSnapshot, + type ProcessTableCapture, + type ProcessTableRow +} from './pty-descendant-termination' + +const CAPTURED_AT_MS = Date.parse('Tue Jul 14 12:00:00 2026') + +beforeEach(() => { + execFileMock.mockReset() + execFileMock.mockImplementation((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error | null, stdout: string) => void + callback(null, '10 1 10 Mon Jul 13 12:54:47 2026') + }) +}) + +function row( + pid: number, + ppid: number, + pgid: number, + startedAt = 'Mon Jul 13 12:54:47 2026' +): ProcessTableRow { + return { pid, ppid, pgid, startedAt } +} + +function tableCapture(rows: ProcessTableRow[], capturedAtMs = CAPTURED_AT_MS): ProcessTableCapture { + return { rows, capturedAtMs } +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +function snapshot( + descendants: ProcessTableRow[], + rootPgid: number | null = 10, + capturedAtMs = CAPTURED_AT_MS +) { + return { rootPgid, descendants, capturedAtMs } +} + +describe('parseProcessTable', () => { + it('parses pid/ppid/pgid and keeps the space-containing lstart verbatim', () => { + const rows = parseProcessTable( + [ + ' 101 1 101 Mon Jul 13 12:54:47 2026', + '42017 101 42017 Tue Jul 14 01:02:03 2026 ', + '', + 'not a process line' + ].join('\n') + ) + expect(rows).toEqual([ + { pid: 101, ppid: 1, pgid: 101, startedAt: 'Mon Jul 13 12:54:47 2026' }, + { pid: 42017, ppid: 101, pgid: 42017, startedAt: 'Tue Jul 14 01:02:03 2026' } + ]) + }) +}) + +describe('collectDescendantRows', () => { + it('walks detached-pgid descendants once even when a non-atomic table looks cyclic', () => { + // shell(10) -> agent(20, own job pgid) -> detached tool shell(30, own + // session-style pgid) -> git(31). 99 is unrelated. + const table = [ + row(10, 1, 10), + row(20, 10, 20), + row(30, 20, 30), + row(31, 30, 30), + row(20, 31, 20), // PID reuse can make a non-atomic ps read look cyclic. + row(99, 1, 99) + ] + const snapshot = collectDescendantRows(10, table, CAPTURED_AT_MS) + expect(snapshot.rootPgid).toBe(10) + expect(snapshot.descendants.map((r) => r.pid)).toEqual([20, 30, 31]) + expect(snapshot.capturedAtMs).toBe(CAPTURED_AT_MS) + }) + + it('returns a null root pgid when the root row is already gone', () => { + const snapshot = collectDescendantRows(10, [row(20, 10, 20)], CAPTURED_AT_MS) + expect(snapshot.rootPgid).toBeNull() + expect(snapshot.descendants.map((r) => r.pid)).toEqual([20]) + }) +}) + +describe('captureDescendantSnapshot', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('resolves the descendant tree on POSIX', async () => { + const readTable = vi.fn().mockResolvedValue(tableCapture([row(10, 1, 10), row(20, 10, 20)])) + const result = await captureDescendantSnapshot(10, { + readTable, + platform: 'darwin' + }) + expect(result).toEqual(snapshot([row(20, 10, 20)])) + expect(vi.getTimerCount()).toBe(0) + }) + + it('is a null no-op on Windows', async () => { + const readTable = vi.fn() + expect(await captureDescendantSnapshot(10, { readTable, platform: 'win32' })).toBeNull() + expect(readTable).not.toHaveBeenCalled() + }) + + it('degrades to null when ps fails', async () => { + const readTable = vi.fn().mockRejectedValue(new Error('ps exploded')) + expect(await captureDescendantSnapshot(10, { readTable, platform: 'linux' })).toBeNull() + }) + + it('degrades to null when a custom process-table reader throws synchronously', async () => { + const readTable = vi.fn(() => { + throw new Error('reader exploded') + }) + expect(await captureDescendantSnapshot(10, { readTable, platform: 'linux' })).toBeNull() + }) + + it('degrades to null when ps hangs past the timeout instead of blocking teardown', async () => { + const readTable = vi.fn().mockReturnValue(new Promise(() => {})) + const pending = captureDescendantSnapshot(10, { + readTable, + platform: 'darwin', + timeoutMs: 1_000 + }) + await vi.advanceTimersByTimeAsync(1_000) + expect(await pending).toBeNull() + }) + + it('gives the production ps subprocess a hard SIGKILL timeout', async () => { + const result = await captureDescendantSnapshot(10, { + platform: 'darwin', + timeoutMs: 321 + }) + expect(result).not.toBeNull() + expect(execFileMock).toHaveBeenCalledWith( + 'ps', + ['-axo', 'pid=,ppid=,pgid=,lstart='], + expect.objectContaining({ + timeout: 321, + killSignal: 'SIGKILL', + env: expect.objectContaining({ LANG: 'C', LC_ALL: 'C' }) + }), + expect.any(Function) + ) + }) + + it('records the identity boundary before ps starts even when it crosses a second', async () => { + vi.setSystemTime(CAPTURED_AT_MS + 900) + execFileMock.mockImplementation((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error | null, stdout: string) => void + vi.setSystemTime(CAPTURED_AT_MS + 1_100) + callback( + null, + ['10 1 10 Tue Jul 14 12:00:00 2026', '20 10 20 Tue Jul 14 12:00:00 2026'].join('\n') + ) + }) + + const result = await captureDescendantSnapshot(10, { platform: 'darwin' }) + + expect(result?.capturedAtMs).toBe(CAPTURED_AT_MS + 900) + expect(result?.descendants).toEqual([row(20, 10, 20, 'Tue Jul 14 12:00:00 2026')]) + }) +}) + +describe('terminateDescendantSnapshot', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('SIGTERMs every snapshotted descendant immediately', () => { + const sendSignal = vi.fn() + terminateDescendantSnapshot(snapshot([row(20, 10, 20), row(30, 20, 30)]), { + sendSignal, + readTable: vi.fn().mockResolvedValue(tableCapture([])) + }) + expect(sendSignal.mock.calls).toEqual([ + [20, 'SIGTERM'], + [30, 'SIGTERM'] + ]) + }) + + it('SIGKILLs only identity-matched survivors after the grace window', async () => { + const survivor = row(30, 20, 30) + const exited = row(20, 10, 20) + const recycled = row(40, 30, 40) + const ambiguous = row(50, 30, 50) + const sendSignal = vi.fn() + // At escalation time: 30 survives unchanged, 20 is gone, 40's pid now + // belongs to a different (recycled) process with a different start time. + const readTable = vi + .fn() + .mockResolvedValue( + tableCapture([ + survivor, + { ...recycled, startedAt: 'Tue Jul 14 09:00:00 2026' }, + ambiguous, + { ...ambiguous, startedAt: 'Tue Jul 14 10:00:00 2026' } + ]) + ) + terminateDescendantSnapshot(snapshot([exited, survivor, recycled, ambiguous]), { + sendSignal, + readTable + }) + sendSignal.mockClear() + await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS) + expect(sendSignal.mock.calls).toEqual([[30, 'SIGKILL']]) + }) + + it('never escalates when the identity re-read fails', async () => { + const sendSignal = vi.fn() + const readTable = vi.fn().mockRejectedValue(new Error('ps exploded')) + terminateDescendantSnapshot(snapshot([row(20, 10, 20)]), { sendSignal, readTable }) + sendSignal.mockClear() + await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS) + expect(sendSignal).not.toHaveBeenCalled() + }) + + it('schedules no escalation for an empty descendant set', () => { + const sendSignal = vi.fn() + const readTable = vi.fn() + terminateDescendantSnapshot(snapshot([]), { sendSignal, readTable }) + expect(vi.getTimerCount()).toBe(0) + expect(sendSignal).not.toHaveBeenCalled() + }) + + it('uses the source scan boundary when a caller crosses into the next second', async () => { + const sameSecond = row(20, 10, 20, 'Tue Jul 14 12:00:00 2026') + const sendSignal = vi.fn() + terminateDescendantSnapshot(snapshot([sameSecond], 10, CAPTURED_AT_MS + 900), { + sendSignal, + readTable: vi.fn().mockResolvedValue(tableCapture([sameSecond], CAPTURED_AT_MS + 3_000)) + }) + sendSignal.mockClear() + await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS) + expect(sendSignal).not.toHaveBeenCalled() + }) + + it('bounds a wedged escalation read and releases its deadline timer', async () => { + const sendSignal = vi.fn() + terminateDescendantSnapshot(snapshot([row(20, 10, 20)]), { + sendSignal, + readTable: vi.fn().mockReturnValue(new Promise(() => {})) + }) + sendSignal.mockClear() + await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS + DESCENDANT_SNAPSHOT_TIMEOUT_MS) + expect(sendSignal).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) +}) + +describe('createProcessTableSnapshotReader', () => { + it('coalesces same-turn teardown requests onto one fresh scan', async () => { + const capture = tableCapture([row(10, 1, 10)]) + const readFresh = vi.fn().mockResolvedValue(capture) + const readTable = createProcessTableSnapshotReader(readFresh) + + const results = await Promise.all(Array.from({ length: 20 }, () => readTable(1_000))) + expect(results.every((result) => result === capture)).toBe(true) + expect(readFresh).toHaveBeenCalledOnce() + + await readTable(1_000) + expect(readFresh).toHaveBeenCalledTimes(2) + }) + + it('queues a post-request scan when another scan has already started', async () => { + const firstGate = deferred() + const secondGate = deferred() + const readFresh = vi + .fn() + .mockReturnValueOnce(firstGate.promise) + .mockReturnValueOnce(secondGate.promise) + const readTable = createProcessTableSnapshotReader(readFresh) + + const first = readTable() + await Promise.resolve() + expect(readFresh).toHaveBeenCalledOnce() + + const laterA = readTable() + const laterB = readTable() + await Promise.resolve() + // A successor must begin inside its callers' deadline. Waiting for the + // prior scan can make both callers time out before their own scan starts. + expect(readFresh).toHaveBeenCalledTimes(2) + firstGate.resolve(tableCapture([row(10, 1, 10)], CAPTURED_AT_MS)) + await first + + const newer = tableCapture([row(20, 1, 20)], CAPTURED_AT_MS + 1_000) + secondGate.resolve(newer) + await expect(Promise.all([laterA, laterB])).resolves.toEqual([newer, newer]) + }) + + it('retries after failed process-table reads', async () => { + const readFresh = vi + .fn() + .mockRejectedValueOnce(new Error('ps failed')) + .mockResolvedValueOnce(tableCapture([])) + const readTable = createProcessTableSnapshotReader(readFresh) + await expect(readTable()).rejects.toThrow('ps failed') + await expect(readTable()).resolves.toEqual(tableCapture([])) + expect(readFresh).toHaveBeenCalledTimes(2) + }) +}) + +describe('killWithDescendantSweep', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('signals descendants after snapshot resolution, then kills the root', async () => { + const events: string[] = [] + const sendSignal = vi.fn(() => events.push('descendant-term')) + const readTable = vi.fn().mockResolvedValue(tableCapture([row(10, 1, 10), row(20, 10, 20)])) + const killRoot = vi.fn(() => events.push('root-kill')) + const pending = killWithDescendantSweep(10, killRoot, { + readTable, + sendSignal, + platform: 'darwin' + }) + expect(killRoot).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(0) + await pending + expect(killRoot).toHaveBeenCalledOnce() + expect(sendSignal.mock.calls).toEqual([[20, 'SIGTERM']]) + expect(events).toEqual(['descendant-term', 'root-kill']) + }) + + it('still kills the root when the snapshot is unavailable', async () => { + const sendSignal = vi.fn() + const readTable = vi.fn().mockRejectedValue(new Error('ps exploded')) + const killRoot = vi.fn() + const pending = killWithDescendantSweep(10, killRoot, { + readTable, + sendSignal, + platform: 'darwin' + }) + await vi.advanceTimersByTimeAsync(0) + await pending + expect(killRoot).toHaveBeenCalledOnce() + expect(sendSignal).not.toHaveBeenCalled() + }) + + it('does not signal a captured tree after the caller loses root ownership', async () => { + const sendSignal = vi.fn() + const killRoot = vi.fn() + const readTable = vi.fn().mockResolvedValue(tableCapture([row(10, 1, 10), row(20, 10, 20)])) + const ownsRoot = vi.fn(() => false) + + await killWithDescendantSweep(10, killRoot, { + readTable, + sendSignal, + platform: 'darwin', + ownsRoot + }) + + expect(ownsRoot).toHaveBeenCalledOnce() + expect(sendSignal).not.toHaveBeenCalled() + expect(killRoot).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/pty-descendant-termination.ts b/src/main/pty-descendant-termination.ts new file mode 100644 index 00000000000..6869a287b0e --- /dev/null +++ b/src/main/pty-descendant-termination.ts @@ -0,0 +1,313 @@ +import { execFile } from 'node:child_process' + +export const DESCENDANT_KILL_GRACE_MS = 2_000 +export const DESCENDANT_SNAPSHOT_TIMEOUT_MS = 1_000 +// Why: a full process table on a busy host can exceed execFile's 1MB default; +// truncation would silently drop descendants from the snapshot. +const PS_MAX_BUFFER_BYTES = 32 * 1024 * 1024 + +export type ProcessTableRow = { + pid: number + ppid: number + pgid: number + /** ps lstart text, kept verbatim. Delayed SIGKILL additionally requires an + * unambiguous capture-second boundary and matching pgid. */ + startedAt: string +} + +export type DescendantSnapshot = { + rootPgid: number | null + descendants: ProcessTableRow[] + /** Wall-clock boundary for deciding whether ps's second-resolution lstart + * can safely distinguish this process from a later PID reuse. */ + capturedAtMs: number +} + +export type ProcessTableCapture = { + rows: ProcessTableRow[] + /** Start boundary of the scan that produced rows, never a later consumer's time. */ + capturedAtMs: number +} + +export type ProcessTableReader = (timeoutMs?: number) => Promise +export type SignalSender = (pid: number, signal: NodeJS.Signals) => void + +export function parseProcessTable(psOutput: string): ProcessTableRow[] { + const rows: ProcessTableRow[] = [] + for (const line of psOutput.split('\n')) { + // lstart itself contains spaces ("Mon Jul 13 12:54:47 2026"), so only the + // three leading numeric columns are positional. + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.+?)\s*$/) + if (!match) { + continue + } + rows.push({ + pid: Number(match[1]), + ppid: Number(match[2]), + pgid: Number(match[3]), + startedAt: match[4] + }) + } + return rows +} + +function readFreshProcessTable( + timeoutMs = DESCENDANT_SNAPSHOT_TIMEOUT_MS +): Promise { + // Why: identity safety must use the boundary before ps starts. Stamping the + // result later could make a capture-second PID look safe after a rollover. + const capturedAtMs = Date.now() + return new Promise((resolve, reject) => { + execFile( + 'ps', + ['-axo', 'pid=,ppid=,pgid=,lstart='], + { + maxBuffer: PS_MAX_BUFFER_BYTES, + timeout: timeoutMs, + killSignal: 'SIGKILL', + // Why: ps localizes lstart, but delayed identity checks must parse it + // identically for every user locale. + env: { ...process.env, LANG: 'C', LC_ALL: 'C' } + }, + (error, stdout) => { + if (error) { + reject(error) + return + } + resolve({ rows: parseProcessTable(stdout), capturedAtMs }) + } + ) + }) +} + +/** Coalesces same-turn teardown bursts but never serves a completed or already + * started scan to a later request, because stale PIDs are unsafe to signal. */ +export function createProcessTableSnapshotReader( + readFresh: ProcessTableReader +): ProcessTableReader { + let queued: { promise: Promise; started: boolean } | null = null + + return (timeoutMs) => { + if (queued && !queued.started) { + return queued.promise + } + + const entry: { promise: Promise; started: boolean } = { + promise: Promise.resolve(undefined as never), + started: false + } + entry.promise = Promise.resolve().then(() => { + // Why: a later caller's deadline starts when it requests a fresh table. + // Waiting behind an older scan can consume that entire budget, then run + // this subprocess after nobody can use its result. + entry.started = true + return readFresh(timeoutMs) + }) + queued = entry + const clearQueued = (): void => { + if (queued === entry) { + queued = null + } + } + void entry.promise.then(clearQueued, clearQueued) + return entry.promise + } +} + +const readProcessTable = createProcessTableSnapshotReader(readFreshProcessTable) + +function readProcessTableBeforeDeadline( + readTable: ProcessTableReader, + timeoutMs: number +): Promise { + return new Promise((resolve) => { + let settled = false + const finish = (capture: ProcessTableCapture | null): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + resolve(capture) + } + const timer = setTimeout(() => finish(null), timeoutMs) + timer.unref?.() + try { + void readTable(timeoutMs).then( + (rows) => finish(rows), + () => finish(null) + ) + } catch { + finish(null) + } + }) +} + +export function collectDescendantRows( + rootPid: number, + table: ProcessTableRow[], + capturedAtMs = Date.now() +): DescendantSnapshot { + const childrenByPpid = new Map() + let rootRow: ProcessTableRow | null = null + for (const row of table) { + if (row.pid === rootPid) { + rootRow = row + continue + } + const siblings = childrenByPpid.get(row.ppid) + if (siblings) { + siblings.push(row) + } else { + childrenByPpid.set(row.ppid, [row]) + } + } + const descendants: ProcessTableRow[] = [] + const queue = [rootPid] + const visited = new Set(queue) + for (let nextIndex = 0; nextIndex < queue.length; nextIndex += 1) { + const pid = queue[nextIndex] + for (const child of childrenByPpid.get(pid) ?? []) { + // Why: ps is not an atomic snapshot. PID reuse can produce duplicate or + // cyclic-looking rows, which must not hang the Electron main thread. + if (visited.has(child.pid)) { + continue + } + visited.add(child.pid) + descendants.push(child) + queue.push(child.pid) + } + } + return { rootPgid: rootRow?.pgid ?? null, descendants, capturedAtMs } +} + +type SnapshotDeps = { + readTable?: ProcessTableReader + platform?: NodeJS.Platform + timeoutMs?: number +} + +/** + * Snapshots a PTY root's live descendant tree. Must run BEFORE the root is + * signalled: once the root dies, surviving descendants reparent to pid 1 and + * can no longer be found by a ppid walk. Resolves null (never rejects) on + * Windows, ps failure, or timeout — callers then degrade to today's + * shell-only kill. + */ +export async function captureDescendantSnapshot( + rootPid: number, + deps: SnapshotDeps = {} +): Promise { + const platform = deps.platform ?? process.platform + if (platform === 'win32' || !Number.isInteger(rootPid) || rootPid <= 0) { + return null + } + const readTable = deps.readTable ?? readProcessTable + const timeoutMs = deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS + // Why both layers: the deadline keeps injected/custom readers bounded while + // the production execFile timeout actually kills a wedged ps subprocess. + const capture = await readProcessTableBeforeDeadline(readTable, timeoutMs) + if (!capture) { + return null + } + return collectDescendantRows(rootPid, capture.rows, capture.capturedAtMs) +} + +/** + * Standard agent-session kill sequencing: snapshot the descendant tree, + * signal its members, then run the caller's root kill. Callers must not signal + * the root before this runs — a dead root's descendants reparent to pid 1 and + * become unfindable. Snapshot failure degrades to killRoot alone. + */ +export async function killWithDescendantSweep( + rootPid: number, + killRoot: () => void, + deps: SnapshotDeps & TerminateDeps & { ownsRoot?: () => boolean } = {} +): Promise { + const snapshot = await captureDescendantSnapshot(rootPid, deps) + try { + // Signal the captured descendants while their parent links still exist; + // killing the root first creates a reparent/PID-reuse window. + if (snapshot && (deps.ownsRoot?.() ?? true)) { + terminateDescendantSnapshot(snapshot, deps) + } + } finally { + killRoot() + } +} + +function defaultSendSignal(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(pid, signal) + } catch { + /* already gone */ + } +} + +type TerminateDeps = { + readTable?: ProcessTableReader + sendSignal?: SignalSender + graceMs?: number + timeoutMs?: number +} + +function hasUnambiguousStartIdentity(row: ProcessTableRow, capturedAtMs: number): boolean { + const startedAtMs = Date.parse(row.startedAt) + if (!Number.isFinite(startedAtMs)) { + return false + } + // ps lstart is second-resolution. A process born in the capture second can + // be replaced by a different process with the same displayed timestamp. + return startedAtMs < Math.floor(capturedAtMs / 1_000) * 1_000 +} + +/** + * Terminates a snapshotted descendant tree: SIGTERM every descendant now, + * reaching detached-pgid children the PTY's SIGHUP cannot, then after a grace + * window SIGKILL identity-safe survivors. Processes born in the capture second + * are not escalated because ps cannot distinguish same-second PID reuse. + */ +export function terminateDescendantSnapshot( + snapshot: DescendantSnapshot, + deps: TerminateDeps = {} +): void { + const sendSignal = deps.sendSignal ?? defaultSendSignal + const readTable = deps.readTable ?? readProcessTable + for (const row of snapshot.descendants) { + sendSignal(row.pid, 'SIGTERM') + } + if (snapshot.descendants.length === 0) { + return + } + const timer = setTimeout(() => { + void readProcessTableBeforeDeadline( + readTable, + deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS + ).then((capture) => { + if (!capture) { + return + } + const expectedPids = new Set(snapshot.descendants.map((row) => row.pid)) + const liveTargets = new Map() + // Why: a process table may be large, while one agent's descendants are + // normally few. Index only signal targets instead of duplicating every row. + for (const live of capture.rows) { + if (expectedPids.has(live.pid)) { + // Duplicate PID rows make identity ambiguous, so never escalate them. + liveTargets.set(live.pid, liveTargets.has(live.pid) ? null : live) + } + } + for (const row of snapshot.descendants) { + const live = liveTargets.get(row.pid) + if ( + hasUnambiguousStartIdentity(row, snapshot.capturedAtMs) && + live?.startedAt === row.startedAt && + live.pgid === row.pgid + ) { + sendSignal(row.pid, 'SIGKILL') + } + } + }) + }, deps.graceMs ?? DESCENDANT_KILL_GRACE_MS) + timer.unref?.() +} diff --git a/src/main/pty/posix-pty-process-groups.integration.test.ts b/src/main/pty/posix-pty-process-groups.integration.test.ts new file mode 100644 index 00000000000..f8b191f9856 --- /dev/null +++ b/src/main/pty/posix-pty-process-groups.integration.test.ts @@ -0,0 +1,94 @@ +import { execFileSync } from 'node:child_process' +import * as pty from 'node-pty' +import { expect, it } from 'vitest' +import { forceKillPosixPtyProcessGroups } from './posix-pty-process-groups' + +type TaggedProcess = { pid: number; pgid: number } + +const itOnPosix = process.platform === 'win32' ? it.skip : it + +function findTaggedProcesses(token: string): TaggedProcess[] { + const output = execFileSync('ps', ['-axo', 'pid=,pgid=,state=,command='], { + encoding: 'utf8' + }) + const matches: TaggedProcess[] = [] + for (const line of output.split(/\r?\n/)) { + if (!line.includes(token)) { + continue + } + const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+/.exec(line) + if (match && !match[3].includes('Z')) { + matches.push({ pid: Number(match[1]), pgid: Number(match[2]) }) + } + } + return matches +} + +async function waitFor( + predicate: () => boolean, + timeoutMs = 5_000, + intervalMs = 25 +): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error('Timed out waiting for PTY process state') + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } +} + +function cleanupTaggedProcesses(token: string): void { + for (const pgid of new Set(findTaggedProcesses(token).map((process) => process.pgid))) { + try { + process.kill(-pgid, 'SIGKILL') + } catch { + // The tagged test group may already be gone. + } + } +} + +function readProcessGroup(pid: number): number { + return Number(execFileSync('ps', ['-p', String(pid), '-o', 'pgid='], { encoding: 'utf8' }).trim()) +} + +itOnPosix( + 'reaps a foreground job that ignores terminal shutdown signals', + async () => { + const token = `ORCA_PTY_GROUP_TEST_${process.pid}_${Date.now()}` + const proc = pty.spawn('/bin/sh', [], { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd: process.cwd(), + env: process.env as Record + }) + const exited = new Promise((resolve) => proc.onExit(() => resolve())) + + try { + proc.write(`/bin/sh -lc 'trap "" HUP TERM; while :; do sleep 60; done' ${token}\n`) + await waitFor(() => findTaggedProcesses(token).length > 0) + + const tagged = findTaggedProcesses(token) + const rootProcessGroup = readProcessGroup(proc.pid) + expect(tagged.some((process) => process.pgid !== rootProcessGroup)).toBe(true) + + forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL')) + await Promise.race([ + exited, + new Promise((_, reject) => + setTimeout(() => reject(new Error('PTY leader did not exit')), 5_000) + ) + ]) + await waitFor(() => findTaggedProcesses(token).length === 0) + } finally { + cleanupTaggedProcesses(token) + try { + proc.kill('SIGKILL') + } catch { + // node-pty may already have reaped and disposed the leader. + } + } + }, + 10_000 +) diff --git a/src/main/pty/posix-pty-process-groups.test.ts b/src/main/pty/posix-pty-process-groups.test.ts new file mode 100644 index 00000000000..59c287a303a --- /dev/null +++ b/src/main/pty/posix-pty-process-groups.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest' +import { + forceKillPosixPtyProcessGroups, + getPosixPtyProcessGroups +} from './posix-pty-process-groups' + +const TABLE = ` + 100 100 ttys001 + 101 101 ttys001 + 102 101 ttys001 + 103 103 ttys001 + 200 200 ttys002 + 300 300 ?? +` + +describe('POSIX PTY process-group termination', () => { + it('returns every group attached to the root PTY with the root group last', () => { + expect(getPosixPtyProcessGroups(TABLE, 100, 999)).toEqual([101, 103, 100]) + }) + + it('refuses an unbound root or a PTY shared with Orca itself', () => { + expect(getPosixPtyProcessGroups(TABLE, 300, 999)).toBeNull() + expect(getPosixPtyProcessGroups(TABLE, 100, 102)).toBeNull() + expect(getPosixPtyProcessGroups(TABLE, 999, 998)).toBeNull() + }) + + it('kills foreground and background groups before the PTY leader', () => { + const fallback = vi.fn() + const signalProcessGroup = vi.fn() + + forceKillPosixPtyProcessGroups(100, fallback, { + platform: 'darwin', + currentPid: 999, + readProcessTable: () => TABLE, + signalProcessGroup + }) + + expect(signalProcessGroup.mock.calls.map(([pgid]) => pgid)).toEqual([101, 103, 100]) + expect(fallback).not.toHaveBeenCalled() + }) + + it('falls back when the process table cannot prove PTY ownership', () => { + const fallback = vi.fn() + + forceKillPosixPtyProcessGroups(100, fallback, { + platform: 'linux', + currentPid: 102, + readProcessTable: () => TABLE, + signalProcessGroup: vi.fn() + }) + + expect(fallback).toHaveBeenCalledOnce() + }) + + it('ignores groups that exited after the snapshot but preserves real signal errors', () => { + const gone = Object.assign(new Error('gone'), { code: 'ESRCH' }) + const denied = Object.assign(new Error('denied'), { code: 'EPERM' }) + const signalProcessGroup = vi + .fn<(pgid: number) => void>() + .mockImplementationOnce(() => { + throw gone + }) + .mockImplementationOnce(() => { + throw denied + }) + + expect(() => + forceKillPosixPtyProcessGroups(100, vi.fn(), { + platform: 'darwin', + currentPid: 999, + readProcessTable: () => TABLE, + signalProcessGroup + }) + ).toThrow('denied') + expect(signalProcessGroup).toHaveBeenCalledTimes(3) + }) + + it('uses the existing fallback on Windows without reading ps', () => { + const fallback = vi.fn() + const readProcessTable = vi.fn(() => TABLE) + + forceKillPosixPtyProcessGroups(100, fallback, { + platform: 'win32', + readProcessTable + }) + + expect(fallback).toHaveBeenCalledOnce() + expect(readProcessTable).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pty/posix-pty-process-groups.ts b/src/main/pty/posix-pty-process-groups.ts new file mode 100644 index 00000000000..21b18808d6d --- /dev/null +++ b/src/main/pty/posix-pty-process-groups.ts @@ -0,0 +1,130 @@ +import { execFileSync } from 'node:child_process' + +const PROCESS_TABLE_TIMEOUT_MS = 1_000 +const PROCESS_TABLE_MAX_BYTES = 1024 * 1024 + +type ProcessRow = { + pid: number + pgid: number + tty: string +} + +export type PosixPtyProcessGroupTerminationDeps = { + platform?: NodeJS.Platform + currentPid?: number + readProcessTable?: () => string + signalProcessGroup?: (pgid: number) => void +} + +function runPs(args: string[]): string { + return execFileSync('ps', args, { + encoding: 'utf8', + timeout: PROCESS_TABLE_TIMEOUT_MS, + maxBuffer: PROCESS_TABLE_MAX_BYTES + }) +} + +function readPtyProcessTable(rootPid: number): string { + const root = runPs(['-p', String(rootPid), '-o', 'pid=,pgid=,tty=']) + const rootRow = parseProcessRows(root).find((row) => row.pid === rootPid) + if (!rootRow || rootRow.tty === '?' || rootRow.tty === '??') { + return root + } + // Why: a whole-host `ps -ax` takes nearly a second on large machines. TTY + // selection keeps forced terminal teardown proportional to one terminal. + return `${root}\n${runPs(['-t', rootRow.tty, '-o', 'pid=,pgid=,tty='])}` +} + +function parseProcessRows(output: string): ProcessRow[] { + const rows: ProcessRow[] = [] + for (const line of output.split(/\r?\n/)) { + const match = /^\s*(\d+)\s+(\d+)\s+(\S+)/.exec(line) + if (!match) { + continue + } + const pid = Number(match[1]) + const pgid = Number(match[2]) + if (pid > 0 && pgid > 1) { + rows.push({ pid, pgid, tty: match[3] }) + } + } + return rows +} + +export function getPosixPtyProcessGroups( + output: string, + rootPid: number, + currentPid = process.pid +): number[] | null { + const rows = parseProcessRows(output) + const root = rows.find((row) => row.pid === rootPid) + if (!root || root.tty === '?' || root.tty === '??') { + return null + } + // Why: a development daemon can inherit its launch TTY. Never group-signal + // when Orca itself shares the PTY; fall back to the already-scoped root kill. + if (rows.some((row) => row.pid === currentPid && row.tty === root.tty)) { + return null + } + const groups = new Set(rows.filter((row) => row.tty === root.tty).map((row) => row.pgid)) + if (!groups.has(root.pgid)) { + return null + } + return [...groups].sort((left, right) => { + if (left === root.pgid) { + return 1 + } + if (right === root.pgid) { + return -1 + } + return left - right + }) +} + +function isProcessAlreadyGone(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === 'ESRCH' +} + +/** Force-kill every process group still attached to one POSIX PTY. */ +export function forceKillPosixPtyProcessGroups( + rootPid: number, + fallback: () => void, + deps: PosixPtyProcessGroupTerminationDeps = {} +): void { + if ((deps.platform ?? process.platform) === 'win32') { + fallback() + return + } + let groups: number[] | null + try { + groups = getPosixPtyProcessGroups( + (deps.readProcessTable ?? (() => readPtyProcessTable(rootPid)))(), + rootPid, + deps.currentPid ?? process.pid + ) + } catch { + groups = null + } + if (!groups || groups.length === 0) { + fallback() + return + } + + const signalProcessGroup = + deps.signalProcessGroup ?? ((pgid: number) => process.kill(-pgid, 'SIGKILL')) + let firstError: unknown + for (const pgid of groups) { + try { + signalProcessGroup(pgid) + } catch (error) { + // Why: the PTY exit callback may reap a group between `ps` and killpg. + // ESRCH is proof that this captured owner is already gone, not failure. + if (!isProcessAlreadyGone(error) && firstError === undefined) { + firstError = error + } + } + } + if (firstError !== undefined) { + throw firstError + } +} diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index e387a13e3a1..b077b880576 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -241,6 +241,13 @@ describe('fetchGrokRateLimits', () => { const result = await fetchGrokRateLimits() expect(result.status).toBe('error') expect(result.error).toMatch(/expired/i) + expect(result.error).toMatch(/run grok on the computer running Orca/i) + expect(result.error).toMatch(/sign in if prompted/i) + expect(result.error).toMatch(/no chat message is needed/i) + expect(result.usageMetadata).toEqual({ + failureKind: 'delegated-refresh-required', + source: 'oauth' + }) // Why: a stored-but-expired access token is refreshed by Grok CLI on next // use (a genuine sign-out returns 'missing'), so the message must not tell // users to re-run `grok login` (#8497). diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index eaa140eb2be..1e6d20f5cce 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -1,5 +1,9 @@ import { net } from 'electron' -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import type { + ProviderRateLimits, + RateLimitWindow, + UsageRateLimitMetadata +} from '../../shared/rate-limit-types' import { isGrokAccessTokenFresh, readGrokAuthSession, @@ -47,14 +51,19 @@ type GrokBillingResponse = GrokBillingConfig & { config?: GrokBillingConfig } -function result(status: ProviderRateLimits['status'], error: string | null): ProviderRateLimits { +function result( + status: ProviderRateLimits['status'], + error: string | null, + usageMetadata?: UsageRateLimitMetadata +): ProviderRateLimits { return { provider: 'grok', session: null, weekly: null, updatedAt: Date.now(), error, - status + status, + ...(usageMetadata ? { usageMetadata } : {}) } } @@ -225,7 +234,11 @@ export async function fetchGrokRateLimits( // Why: a genuine sign-out returns 'missing' earlier, so reaching here always // means a stored, refreshable session — Grok CLI refreshes the access token // on its next run, so don't tell users to re-run `grok login` (#8497). - return result('error', 'Grok access token expired — Grok CLI will refresh it on next use') + return result( + 'error', + 'Grok sign-in expired — run grok on the computer running Orca; sign in if prompted. No chat message is needed.', + { failureKind: 'delegated-refresh-required', source: 'oauth' } + ) } try { diff --git a/src/main/rate-limits/kimi-fetcher.test.ts b/src/main/rate-limits/kimi-fetcher.test.ts index a6274097f5f..58f11dfdb03 100644 --- a/src/main/rate-limits/kimi-fetcher.test.ts +++ b/src/main/rate-limits/kimi-fetcher.test.ts @@ -140,6 +140,11 @@ describe('fetchKimiRateLimits', () => { const result = await fetchKimiRateLimits() expect(result.status).toBe('error') expect(result.error).toMatch(/expired/i) + expect(result.error).toMatch(/run kimi on the computer running Orca/i) + expect(result.usageMetadata).toEqual({ + failureKind: 'delegated-refresh-required', + source: 'oauth' + }) expect(netFetchMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/rate-limits/kimi-fetcher.ts b/src/main/rate-limits/kimi-fetcher.ts index f957e171d88..778b90b796c 100644 --- a/src/main/rate-limits/kimi-fetcher.ts +++ b/src/main/rate-limits/kimi-fetcher.ts @@ -2,7 +2,11 @@ import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { net } from 'electron' -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import type { + ProviderRateLimits, + RateLimitWindow, + UsageRateLimitMetadata +} from '../../shared/rate-limit-types' // Why: Kimi Code's managed coding plan exposes subscription usage at // `${base}/usages` (see packages/oauth/src/managed-usage.ts in the CLI bundle). @@ -208,8 +212,20 @@ function mapUsageResponse(data: KimiUsageResponse): ProviderRateLimits { } } -function result(status: ProviderRateLimits['status'], error: string | null): ProviderRateLimits { - return { provider: 'kimi', session: null, weekly: null, updatedAt: Date.now(), error, status } +function result( + status: ProviderRateLimits['status'], + error: string | null, + usageMetadata?: UsageRateLimitMetadata +): ProviderRateLimits { + return { + provider: 'kimi', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status, + ...(usageMetadata ? { usageMetadata } : {}) + } } /** @@ -239,7 +255,11 @@ export async function fetchKimiRateLimits(): Promise { // Why: don't refresh — the CLI owns the token lifecycle. Report a transient // error so the rate-limit service keeps the last good snapshot (stale // policy) until the user next runs Kimi and the CLI refreshes the file. - return result('error', 'Kimi token expired — open Kimi to refresh') + return result( + 'error', + 'Kimi session expired — run kimi on the computer running Orca, then retry usage.', + { failureKind: 'delegated-refresh-required', source: 'oauth' } + ) } try { diff --git a/src/main/runtime/file-watcher-host.test.ts b/src/main/runtime/file-watcher-host.test.ts index e64a7e9d145..10f88f228f5 100644 --- a/src/main/runtime/file-watcher-host.test.ts +++ b/src/main/runtime/file-watcher-host.test.ts @@ -28,6 +28,8 @@ vi.mock('../ipc/parcel-watcher-process', () => ({ })) import { + closeFileExplorerWatcherInWatcherProcess, + getRuntimeRootWatchWaiterCountForTest, resetRuntimeRootWatchersForTest, watchFileExplorerInWatcherProcess } from './file-watcher-host' @@ -108,6 +110,31 @@ describe('watchFileExplorerInWatcherProcess', () => { expect(watch.unsubscribe).toHaveBeenCalledTimes(1) }) + it('joins root-scoped cleanup to an in-progress subscriber teardown', async () => { + let resolveUnsubscribe: () => void = () => undefined + const unsubscribe = vi.fn( + () => + new Promise((resolve) => { + resolveUnsubscribe = resolve + }) + ) + subscribeViaRuntimeWatcherProcessMock.mockResolvedValue({ unsubscribe }) + const dispose = await watchFileExplorerInWatcherProcess('/repo', vi.fn()) + + let rootCloseSettled = false + const subscriberClose = dispose() + const rootClose = closeFileExplorerWatcherInWatcherProcess('/repo').then(() => { + rootCloseSettled = true + }) + await Promise.resolve() + expect(rootCloseSettled).toBe(false) + expect(unsubscribe).toHaveBeenCalledTimes(1) + + resolveUnsubscribe() + await Promise.all([subscriberClose, rootClose]) + expect(rootCloseSettled).toBe(true) + }) + it('maps child-enriched watcher events without host-process stat work', async () => { const watch = installSuccessfulWatch() const onEvents = vi.fn<(events: FsChangeEvent[]) => void>() @@ -175,6 +202,116 @@ describe('watchFileExplorerInWatcherProcess', () => { expect(secondEvents).toHaveBeenCalledTimes(2) }) + it('waits for recovery-generation cancellation before releasing the root', async () => { + const watch = installSuccessfulWatch() + let rejectRecovery: ((error: Error) => void) | undefined + let abortObserved = false + subscribeViaRuntimeWatcherProcessMock.mockImplementationOnce( + (_rootPath, _callback, _options, hooks: WatcherProcessHooks) => + new Promise((_resolve, reject) => { + rejectRecovery = reject + hooks.signal?.addEventListener( + 'abort', + () => { + abortObserved = true + }, + { once: true } + ) + }) + ) + const dispose = await watchFileExplorerInWatcherProcess('/repo', vi.fn()) + watch.hooks.onTerminalError?.( + new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') + ) + await vi.waitFor(() => expect(rejectRecovery).toBeDefined()) + + let released = false + const release = dispose().then(() => { + released = true + }) + await Promise.resolve() + expect(released).toBe(false) + expect(abortObserved).toBe(true) + + rejectRecovery?.( + new WatcherProcessFailure( + 'file watcher subscription aborted', + 'subscription', + 'subscribe_aborted' + ) + ) + await release + expect(released).toBe(true) + }) + + it('propagates recovery-generation physical termination failure', async () => { + const watch = installSuccessfulWatch() + let rejectRecovery: ((error: Error) => void) | undefined + subscribeViaRuntimeWatcherProcessMock.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRecovery = reject + }) + ) + const dispose = await watchFileExplorerInWatcherProcess('/repo', vi.fn()) + watch.hooks.onTerminalError?.( + new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') + ) + await vi.waitFor(() => expect(rejectRecovery).toBeDefined()) + const release = dispose() + const teardownError = new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable' + ) + + rejectRecovery?.(teardownError) + await expect(release).rejects.toBe(teardownError) + }) + + it('retains an early recovery termination failure until physical exit', async () => { + const watch = installSuccessfulWatch() + let rejectRecovery: ((error: Error) => void) | undefined + subscribeViaRuntimeWatcherProcessMock.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRecovery = reject + }) + ) + let resolvePhysicalExit: () => void = () => {} + const physicalExit = new Promise((resolve) => { + resolvePhysicalExit = resolve + }) + const teardownError = new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable', + physicalExit + ) + const dispose = await watchFileExplorerInWatcherProcess('/repo', vi.fn()) + watch.hooks.onTerminalError?.( + new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') + ) + await vi.waitFor(() => expect(rejectRecovery).toBeDefined()) + rejectRecovery?.(teardownError) + await Promise.resolve() + + await expect(dispose()).rejects.toBe(teardownError) + await expect(watchFileExplorerInWatcherProcess('/repo', vi.fn())).rejects.toBe(teardownError) + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(2) + resolvePhysicalExit() + await physicalExit + await vi.waitFor(() => + expect(forgetRuntimeWatcherProcessRootMock).toHaveBeenCalledWith('/repo') + ) + + const replacement = installSuccessfulWatch() + await expect(watchFileExplorerInWatcherProcess('/repo', vi.fn())).resolves.toEqual( + expect.any(Function) + ) + expect(replacement.unsubscribe).not.toHaveBeenCalled() + }) + it('ends subscribers only after isolated recovery also fails', async () => { const watch = installSuccessfulWatch() subscribeViaRuntimeWatcherProcessMock.mockRejectedValueOnce(new Error('isolated failure')) @@ -216,6 +353,61 @@ describe('watchFileExplorerInWatcherProcess', () => { expect(physicalSignal?.aborted).toBe(true) }) + it('keeps pre-ready cancellation pending until the root can be replaced', async () => { + let physicalSignal: AbortSignal | undefined + let rejectPhysical: ((error: unknown) => void) | undefined + subscribeViaRuntimeWatcherProcessMock.mockImplementationOnce( + (_rootPath, _callback, _options, hooks: WatcherProcessHooks) => { + physicalSignal = hooks.signal + return new Promise((_resolve, reject) => { + rejectPhysical = reject + }) + } + ) + const controllers = [new AbortController(), new AbortController()] + const pending = controllers.map((controller) => + watchFileExplorerInWatcherProcess('/repo', vi.fn(), vi.fn(), controller.signal) + ) + const settled = [false, false] + pending.forEach((promise, index) => { + void promise.then( + () => { + settled[index] = true + }, + () => { + settled[index] = true + } + ) + }) + + controllers.forEach((controller) => controller.abort()) + await Promise.resolve() + + expect(physicalSignal?.aborted).toBe(true) + expect(settled).toEqual([false, false]) + + rejectPhysical?.( + new WatcherProcessFailure('physical watch aborted', 'subscription', 'subscribe_aborted') + ) + const results = await Promise.allSettled(pending) + expect(results).toEqual([ + expect.objectContaining({ + status: 'rejected', + reason: expect.objectContaining({ message: 'file watcher subscription aborted' }) + }), + expect.objectContaining({ + status: 'rejected', + reason: expect.objectContaining({ message: 'file watcher subscription aborted' }) + }) + ]) + + installSuccessfulWatch() + await expect(watchFileExplorerInWatcherProcess('/repo', vi.fn())).resolves.toEqual( + expect.any(Function) + ) + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(2) + }) + it('keeps pending setup alive when another same-root client still owns it', async () => { let physicalSignal: AbortSignal | undefined let resolvePhysical: ((subscription: WatcherProcessSubscription) => void) | undefined @@ -241,6 +433,40 @@ describe('watchFileExplorerInWatcherProcess', () => { expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(1) }) + it('removes ten thousand cancelled root callers while one anchor remains', async () => { + let resolvePhysical: ((subscription: WatcherProcessSubscription) => void) | undefined + const unsubscribe = vi.fn(async () => undefined) + subscribeViaRuntimeWatcherProcessMock.mockImplementation( + () => + new Promise((resolve) => { + resolvePhysical = resolve + }) + ) + const anchorController = new AbortController() + const anchor = watchFileExplorerInWatcherProcess( + '/repo', + vi.fn(), + vi.fn(), + anchorController.signal + ) + const controllers = Array.from({ length: 10_000 }, () => new AbortController()) + const cancelled = controllers.map((controller) => + watchFileExplorerInWatcherProcess('/repo', vi.fn(), vi.fn(), controller.signal).catch( + (error) => error + ) + ) + + for (const controller of controllers) { + controller.abort() + } + await Promise.all(cancelled) + + expect(getRuntimeRootWatchWaiterCountForTest('/repo')).toBe(1) + resolvePhysical?.({ unsubscribe }) + await expect(anchor).resolves.toEqual(expect.any(Function)) + expect(getRuntimeRootWatchWaiterCountForTest('/repo')).toBe(0) + }) + it('shares one native subscription per root across same-root callers', async () => { const firstRoot = installSuccessfulWatch() const secondRoot = installSuccessfulWatch() @@ -298,4 +524,33 @@ describe('watchFileExplorerInWatcherProcess', () => { ) expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(1) }) + + it('retains failed initial root ownership until the exact child physically exits', async () => { + let resolvePhysicalExit: () => void = () => {} + const physicalExit = new Promise((resolve) => { + resolvePhysicalExit = resolve + }) + const teardownError = new WatcherProcessFailure( + 'initial watcher child did not exit', + 'subscription', + 'subscribe_failed', + physicalExit + ) + subscribeViaRuntimeWatcherProcessMock.mockRejectedValueOnce(teardownError) + + await expect(watchFileExplorerInWatcherProcess('/repo', vi.fn())).rejects.toBe(teardownError) + await expect(closeFileExplorerWatcherInWatcherProcess('/repo')).rejects.toBe(teardownError) + await expect(watchFileExplorerInWatcherProcess('/repo', vi.fn())).rejects.toBe(teardownError) + expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(1) + expect(forgetRuntimeWatcherProcessRootMock).not.toHaveBeenCalled() + + resolvePhysicalExit() + await physicalExit + const replacement = installSuccessfulWatch() + await expect(watchFileExplorerInWatcherProcess('/repo', vi.fn())).resolves.toEqual( + expect.any(Function) + ) + expect(replacement.unsubscribe).not.toHaveBeenCalled() + await expect(closeFileExplorerWatcherInWatcherProcess('/repo')).resolves.toBeUndefined() + }) }) diff --git a/src/main/runtime/file-watcher-host.ts b/src/main/runtime/file-watcher-host.ts index 68e5711150d..59a8071bd58 100644 --- a/src/main/runtime/file-watcher-host.ts +++ b/src/main/runtime/file-watcher-host.ts @@ -6,15 +6,24 @@ import { type WatcherProcessEvent, type WatcherProcessSubscription } from '../ipc/parcel-watcher-process' -import { isWatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' +import { + isWatcherProcessFailure, + type WatcherProcessFailure +} from '../ipc/parcel-watcher-process-failure' import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from '../ipc/filesystem-watcher-ignore' +import { createRuntimeRootOwnershipReleaser } from './runtime-root-watch-ownership' +import { closeRuntimeRootWatch } from './runtime-root-watch-teardown' +import { + RUNTIME_FILE_WATCH_CRAWL_TIMEOUT_MS, + RUNTIME_FILE_WATCH_MAX_SETUP_ATTEMPTS +} from '../../shared/runtime-file-watch-limits' +import { PromiseSettlementWaiters } from '../../shared/promise-settlement-waiters' 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 type RuntimeFileWatchSubscriber = { onEvents: (events: FsChangeEvent[]) => void @@ -24,16 +33,22 @@ type RuntimeFileWatchSubscriber = { type RuntimeRootWatch = { rootPath: string subscribers: Set - start: Promise + startWaiters: PromiseSettlementWaiters subscription: WatcherProcessSubscription | null abortController: AbortController generation: number closed: boolean + closePromise: Promise | null + terminalReleaseError: WatcherProcessFailure | null } // 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() +const { + release: releaseRuntimeRootOwnership, + releaseAfterFailure: releaseRuntimeRootAfterFailure +} = createRuntimeRootOwnershipReleaser(runtimeRootWatches, forgetRuntimeWatcherProcessRoot) function overflowEvent(rootPath: string): FsChangeEvent[] { return [{ kind: 'overflow', absolutePath: rootPath }] @@ -63,10 +78,7 @@ function terminateRootWatch(root: RuntimeRootWatch, error: Error): void { root.closed = true root.generation++ root.abortController.abort() - if (runtimeRootWatches.get(root.rootPath) === root) { - runtimeRootWatches.delete(root.rootPath) - } - forgetRuntimeWatcherProcessRoot(root.rootPath) + releaseRuntimeRootAfterFailure(root, error) const subscribers = Array.from(root.subscribers) root.subscribers.clear() for (const subscriber of subscribers) { @@ -74,14 +86,14 @@ function terminateRootWatch(root: RuntimeRootWatch, error: Error): void { } } -function closeInitialRootWatch(root: RuntimeRootWatch): void { +function closeInitialRootWatch(root: RuntimeRootWatch, error: unknown): void { root.closed = true root.generation++ root.abortController.abort() - if (runtimeRootWatches.get(root.rootPath) === root) { - runtimeRootWatches.delete(root.rootPath) - } - forgetRuntimeWatcherProcessRoot(root.rootPath) + releaseRuntimeRootAfterFailure( + root, + error instanceof Error ? error : new Error('file watcher setup failed') + ) root.subscribers.clear() } @@ -142,19 +154,22 @@ function subscribeRuntimeRootWatch(root: RuntimeRootWatch): Promise { } async function startInitialRuntimeRootWatch(root: RuntimeRootWatch): Promise { - try { - await subscribeRuntimeRootWatch(root) - } catch (firstError) { - if (root.closed || !shouldRetryInitialWatch(firstError)) { - closeInitialRootWatch(root) - throw firstError - } + for (let attempt = 0; attempt < RUNTIME_FILE_WATCH_MAX_SETUP_ATTEMPTS; attempt++) { try { await subscribeRuntimeRootWatch(root) - emitToSubscribers(root, overflowEvent(root.rootPath)) - } catch (isolatedError) { - closeInitialRootWatch(root) - throw isolatedError + if (attempt > 0) { + emitToSubscribers(root, overflowEvent(root.rootPath)) + } + return + } catch (error) { + if ( + root.closed || + attempt + 1 >= RUNTIME_FILE_WATCH_MAX_SETUP_ATTEMPTS || + !shouldRetryInitialWatch(error) + ) { + closeInitialRootWatch(root, error) + throw error + } } } } @@ -169,25 +184,30 @@ function recoverRuntimeRootWatch( } root.subscription = null emitToSubscribers(root, overflowEvent(root.rootPath)) - root.start = subscribeRuntimeRootWatch(root).catch((recoveryError: unknown) => { + const recovery = subscribeRuntimeRootWatch(root).catch((recoveryError: unknown) => { if (!root.closed) { terminateRootWatch(root, recoveryError instanceof Error ? recoveryError : terminalError) + return } + throw recoveryError }) + root.startWaiters = new PromiseSettlementWaiters(recovery) } function createRuntimeRootWatch(rootPath: string): RuntimeRootWatch { const root: RuntimeRootWatch = { rootPath, subscribers: new Set(), - start: Promise.resolve(), + startWaiters: new PromiseSettlementWaiters(Promise.resolve()), subscription: null, abortController: new AbortController(), generation: 0, - closed: false + closed: false, + closePromise: null, + terminalReleaseError: null } runtimeRootWatches.set(rootPath, root) - root.start = startInitialRuntimeRootWatch(root) + root.startWaiters = new PromiseSettlementWaiters(startInitialRuntimeRootWatch(root)) return root } @@ -196,16 +216,40 @@ async function releaseRuntimeRootWatch( subscriber: RuntimeFileWatchSubscriber ): Promise { root.subscribers.delete(subscriber) - if (root.closed || root.subscribers.size > 0) { + if (root.closed) { + if (root.closePromise) { + await root.closePromise + } + if (root.terminalReleaseError) { + throw root.terminalReleaseError + } return } - root.closed = true - root.generation++ - root.abortController.abort() - if (runtimeRootWatches.get(root.rootPath) === root) { - runtimeRootWatches.delete(root.rootPath) + if (root.subscribers.size > 0) { + return } - await root.subscription?.unsubscribe() + await closeRuntimeRootWatchOnce(root) +} + +function closeRuntimeRootWatchOnce(root: RuntimeRootWatch): Promise { + root.closePromise ??= closeRuntimeRootWatch( + root, + root.startWaiters.promise, + () => releaseRuntimeRootOwnership(root), + (error) => releaseRuntimeRootAfterFailure(root, error) + ) + return root.closePromise +} + +export async function closeFileExplorerWatcherInWatcherProcess(rootPath: string): Promise { + const root = runtimeRootWatches.get(rootPath) + if (!root) { + return + } + // Why: destructive cleanup owns every same-root subscriber, including a + // setup that failed before it could publish an unsubscribe callback. + root.subscribers.clear() + await closeRuntimeRootWatchOnce(root) } /** @@ -219,13 +263,28 @@ export async function watchFileExplorerInWatcherProcess( onTerminalError: (error: Error) => void = () => undefined, signal?: AbortSignal ): Promise<() => Promise> { - const root = runtimeRootWatches.get(rootPath) ?? createRuntimeRootWatch(rootPath) + const existingRoot = runtimeRootWatches.get(rootPath) + if (existingRoot?.closed) { + if (!existingRoot.closePromise) { + throw existingRoot.terminalReleaseError ?? new Error('file watcher closed during setup') + } + // Why: a replacement cannot share a native root whose prior generation + // is still closing; join its exact release before creating the next owner. + await existingRoot.closePromise + if (signal?.aborted) { + throw createRuntimeRootAbortError() + } + return watchFileExplorerInWatcherProcess(rootPath, callback, onTerminalError, signal) + } + const root = existingRoot ?? createRuntimeRootWatch(rootPath) const subscriber: RuntimeFileWatchSubscriber = { onEvents: callback, onTerminalError } root.subscribers.add(subscriber) try { await waitForRuntimeRootStart(root, subscriber, signal) } catch (error) { - root.subscribers.delete(subscriber) + // Why: paired unwatch cannot acknowledge until native cancellation or + // physical exit releases this root for an immediate replacement watch. + await releaseRuntimeRootWatch(root, subscriber) throw error } if (root.closed) { @@ -245,34 +304,23 @@ function waitForRuntimeRootStart( signal: AbortSignal | undefined ): Promise { if (!signal) { - return root.start + return root.startWaiters.promise } if (signal.aborted) { - void releaseRuntimeRootWatch(root, subscriber) + root.subscribers.delete(subscriber) return Promise.reject(new Error('file watcher subscription aborted')) } - return new Promise((resolve, reject) => { - let settled = false - const finish = (callback: () => void): void => { - if (settled) { - return - } - settled = true - signal.removeEventListener('abort', handleAbort) - callback() - } - 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)) - ) + return root.startWaiters.wait({ + signal, + createAbortError: createRuntimeRootAbortError, + onAbandon: () => root.subscribers.delete(subscriber) }) } +function createRuntimeRootAbortError(): Error { + return new Error('file watcher subscription aborted') +} + export function resetRuntimeRootWatchersForTest(): void { for (const root of runtimeRootWatches.values()) { root.closed = true @@ -284,3 +332,7 @@ export function resetRuntimeRootWatchersForTest(): void { runtimeRootWatches.clear() resetRuntimeWatcherProcessForTest() } + +export function getRuntimeRootWatchWaiterCountForTest(rootPath: string): number { + return runtimeRootWatches.get(rootPath)?.startWaiters.waiterCount ?? 0 +} diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index 6213765bb20..b10a268ac79 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -193,6 +193,87 @@ describe('mobile presence lock — driver state machine', () => { expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) }) + it('restores the pre-write driver after overlapping claims from one phone both fail', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + const first = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + const second = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + first.rollback() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + second.rollback() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + }) + + it('keeps a successful overlapping claim as the rollback baseline', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + const successful = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + const rejected = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + await successful.commit() + rejected.rollback() + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + }) + + it('does not let an older phone-fit completion retake a newer writer floor', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + let releaseFirstLayout!: () => void + vi.spyOn(runtime, 'applyMobileDisplayMode').mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstLayout = () => resolve(true) + }) + ) + const first = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + const firstCommit = first.commit() + await vi.waitFor(() => expect(releaseFirstLayout).toBeTypeOf('function')) + + const second = runtime.beginMobileInputFloor('pty-1', 'phone-B')! + await second.commit() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + + releaseFirstLayout() + await firstCommit + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + }) + + it('mobile input without an active subscriber cannot create an orphaned floor lock', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + await vi.advanceTimersByTimeAsync(250) + + await runtime.mobileTookFloor('pty-1', 'phone-A') + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + }) + + it('admits a soft-leaving client to reserve the input floor within the grace window', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + + // Inside the soft-leave grace a late write still reserves and commits the floor. + const claim = runtime.beginMobileInputFloor('pty-1', 'phone-A') + expect(claim).not.toBeNull() + await claim!.commit() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + // Past the grace the client is fully gone and is rejected. + await vi.advanceTimersByTimeAsync(250) + expect(runtime.beginMobileInputFloor('pty-1', 'phone-A')).toBeNull() + }) + it('handleMobileUnsubscribe last leaver flips driver to idle after soft-leave grace', async () => { const { runtime, driverEvents } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) diff --git a/src/main/runtime/orca-runtime-emulator.ts b/src/main/runtime/orca-runtime-emulator.ts index 31e4ad1d36a..9bda55ca899 100644 --- a/src/main/runtime/orca-runtime-emulator.ts +++ b/src/main/runtime/orca-runtime-emulator.ts @@ -7,7 +7,6 @@ import { } from '../emulator/emulator-availability' import { resolveDefaultAttachDevice } from '../emulator/emulator-default-attach-device' import { setConfiguredAndroidSdkPath } from '../emulator/android/android-sdk-host-discovery' -import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher' import type { EmulatorGesturePoint } from '../emulator/emulator-gesture-sender' import type { EmulatorSessionInfo } from '../emulator/emulator-types' import type { SimulatorDevice } from '../emulator/simctl-simulator-devices' @@ -148,7 +147,6 @@ export class RuntimeEmulatorCommands { if (reusable) { // Why: renderer remounts should reconnect to the existing stream, not // kill it and create the stream-disconnected reload loop users see. - serveSimStateWatcher.markOrcaManaged(reusable) this.notifyRendererEmulatorAutoAttach(worktreeId, reusable) if (params.focus) { this.notifyRendererEmulatorPaneFocus(worktreeId) @@ -157,15 +155,11 @@ export class RuntimeEmulatorCommands { } // A different requested device is an explicit switch; the bridge keeps a // slow-to-boot Android emulator alive for instant switch-back. - const stoppedUdid = await bridge.stopActiveForSwitch(worktreeId) - if (stoppedUdid) { - serveSimStateWatcher.unmarkOrcaManaged(stoppedUdid) - } + await bridge.stopActiveForSwitch(worktreeId) } const info = await bridge.startHelperForDevice(device) if (worktreeId) { bridge.registerActiveEmulator(worktreeId, info, { managed: true }) - serveSimStateWatcher.markOrcaManaged(info) this.notifyRendererEmulatorAutoAttach(worktreeId, info) if (params.focus) { this.notifyRendererEmulatorPaneFocus(worktreeId) @@ -281,7 +275,6 @@ export class RuntimeEmulatorCommands { const bridge = this.requireEmulatorBridge() const worktreeId = await this.resolveWorktreeId(params.worktree) const killedUdid = await bridge.kill(params.device ?? params.emulator, worktreeId) - serveSimStateWatcher.unmarkOrcaManaged(killedUdid) return { ok: true, deviceUdid: killedUdid } } @@ -295,13 +288,9 @@ export class RuntimeEmulatorCommands { const worktreeId = await this.resolveWorktreeId(params.worktree) if (params.managedOnly && worktreeId && !params.device && !params.emulator) { const shutdownUdid = await bridge.shutdownActiveManagedForWorktree(worktreeId) - if (shutdownUdid) { - serveSimStateWatcher.unmarkOrcaManaged(shutdownUdid) - } return { ok: true, deviceUdid: shutdownUdid ?? undefined } } const shutdownUdid = await bridge.shutdown(params.device ?? params.emulator, worktreeId) - serveSimStateWatcher.unmarkOrcaManaged(shutdownUdid) return { ok: true, deviceUdid: shutdownUdid } } diff --git a/src/main/runtime/orca-runtime-files-watch.test.ts b/src/main/runtime/orca-runtime-files-watch.test.ts index 7986e0fc9b0..23d5739ecd2 100644 --- a/src/main/runtime/orca-runtime-files-watch.test.ts +++ b/src/main/runtime/orca-runtime-files-watch.test.ts @@ -1,20 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' import type * as Fs from 'node:fs' import type * as FsPromises from 'node:fs/promises' import type * as FilesystemAuth from '../ipc/filesystem-auth' import type { FsChangeEvent } from '../../shared/types' +import { WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' +import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate' const { resolveAuthorizedPathMock, statMock, watchMock, watchInWatcherProcessMock, + closeWatcherInWatcherProcessMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ resolveAuthorizedPathMock: vi.fn(), statMock: vi.fn(), watchMock: vi.fn(), watchInWatcherProcessMock: vi.fn(), + closeWatcherInWatcherProcessMock: vi.fn(), getSshFilesystemProviderMock: vi.fn() })) @@ -36,6 +41,7 @@ vi.mock('fs/promises', async () => { // The local (non-Windows, non-SSH) watch path delegates to the isolated watcher process. vi.mock('./file-watcher-host', () => ({ + closeFileExplorerWatcherInWatcherProcess: closeWatcherInWatcherProcessMock, watchFileExplorerInWatcherProcess: watchInWatcherProcessMock })) @@ -51,7 +57,19 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: getSshFilesystemProviderMock })) -import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files' +import { + _getRuntimeFileWatcherReleaseCountForTests, + _resetRuntimeFileWatcherLeasesForTests, + awaitRuntimeFileWatcherUnsubscribes, + RuntimeFileCommands, + WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS +} from './orca-runtime-files' + +function createWindowsWatcher(close: () => void) { + const watcher = new EventEmitter() as EventEmitter & { close: ReturnType } + watcher.close = vi.fn(close) + return watcher +} function createRuntimeFileCommands(rootPath: string) { const store = { getRepo: vi.fn(() => undefined) } @@ -87,6 +105,7 @@ describe('RuntimeFileCommands file watching', () => { statMock.mockReset() watchMock.mockReset() watchInWatcherProcessMock.mockReset() + closeWatcherInWatcherProcessMock.mockReset() getSshFilesystemProviderMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, @@ -96,6 +115,7 @@ describe('RuntimeFileCommands file watching', () => { afterEach(async () => { await awaitRuntimeFileWatcherUnsubscribes() + _resetRuntimeFileWatcherLeasesForTests() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -109,12 +129,13 @@ describe('RuntimeFileCommands file watching', () => { value: 'win32' }) - const close = vi.fn() - const on = vi.fn() + const watcher = createWindowsWatcher(() => { + queueMicrotask(() => watcher.emit('close')) + }) let listener: (() => void) | null = null watchMock.mockImplementation((_rootPath, _options, callback) => { listener = callback - return { close, on } + return watcher }) resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') statMock.mockResolvedValue({ isDirectory: () => true }) @@ -138,8 +159,113 @@ describe('RuntimeFileCommands file watching', () => { expect(onEvents).toHaveBeenCalledTimes(1) expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: 'C:\\repo' }]) - unsubscribe() - expect(close).toHaveBeenCalledTimes(1) + await unsubscribe() + expect(watcher.close).toHaveBeenCalledTimes(1) + }) + + it('waits for the Windows watcher close event before allowing deletion', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const watcher = createWindowsWatcher(() => undefined) + watchMock.mockReturnValue(watcher) + resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + const { commands } = createRuntimeFileCommands('C:\\repo') + await commands.watchFileExplorer('id:wt-1', vi.fn()) + + let settled = false + const closePromise = commands.closeFileExplorerWatchersForPath('C:\\repo').then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + watcher.emit('close') + await closePromise + expect(settled).toBe(true) + }) + + it('treats a Windows watcher error before cleanup as physical close', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const watcher = createWindowsWatcher(() => undefined) + watchMock.mockReturnValue(watcher) + resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + const { commands } = createRuntimeFileCommands('C:\\repo') + const onEvents = vi.fn() + const onTerminalError = vi.fn() + await commands.watchFileExplorer('id:wt-1', onEvents, onTerminalError) + const watchError = new Error('native directory handle closed') + + watcher.emit('error', watchError) + + expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: 'C:\\repo' }]) + expect(onTerminalError).toHaveBeenCalledWith(watchError) + await expect(commands.closeFileExplorerWatchersForPath('C:\\repo')).resolves.toBeUndefined() + expect(watcher.close).toHaveBeenCalledTimes(1) + expect(watcher.listenerCount('close')).toBe(0) + expect(watcher.listenerCount('error')).toBe(0) + commands.forgetFileExplorerWatchersAfterRemoval('C:\\repo') + expect(_getRuntimeFileWatcherReleaseCountForTests()).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('retains Windows close ownership until late physical exit without retry leaks', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const watcher = createWindowsWatcher(() => undefined) + watchMock.mockReturnValue(watcher) + resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + const { commands } = createRuntimeFileCommands('C:\\repo') + await commands.watchFileExplorer('id:wt-1', vi.fn()) + + const closePromise = commands.closeFileExplorerWatchersForPath('C:\\repo') + const closeResult = closePromise.catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS) + const closeError = (await closeResult) as WatcherProcessFailure + expect(closeError).toBeInstanceOf(WatcherProcessFailure) + expect(closeError).toMatchObject({ + message: 'Windows watcher did not close before deletion deadline', + physicalExit: expect.any(Promise) + }) + expect(_getRuntimeFileWatcherReleaseCountForTests()).toBe(1) + expect(vi.getTimerCount()).toBe(0) + + const retries = await Promise.allSettled( + Array.from({ length: 100 }, () => commands.closeFileExplorerWatchersForPath('C:\\repo')) + ) + expect(retries.every((result) => result.status === 'rejected')).toBe(true) + expect(watcher.close).toHaveBeenCalledTimes(1) + expect(watcher.listenerCount('close')).toBe(1) + expect(vi.getTimerCount()).toBe(0) + + watcher.emit('close') + await closeError.physicalExit + await Promise.resolve() + expect(_getRuntimeFileWatcherReleaseCountForTests()).toBe(1) + commands.forgetFileExplorerWatchersAfterRemoval('C:\\repo') + expect(_getRuntimeFileWatcherReleaseCountForTests()).toBe(0) + await expect(commands.closeFileExplorerWatchersForPath('C:\\repo')).resolves.toBeUndefined() + expect(watcher.close).toHaveBeenCalledTimes(1) + }) + + it('retries a failed Windows watcher close before allowing deletion', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const closeError = new Error('Windows watcher handle still active') + const watcher = createWindowsWatcher(() => { + if (watcher.close.mock.calls.length === 1) { + throw closeError + } + queueMicrotask(() => watcher.emit('close')) + }) + watchMock.mockReturnValue(watcher) + resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + const { commands } = createRuntimeFileCommands('C:\\repo') + await commands.watchFileExplorer('id:wt-1', vi.fn()) + + await expect(commands.closeFileExplorerWatchersForPath('C:\\repo')).rejects.toBe(closeError) + await expect(commands.closeFileExplorerWatchersForPath('C:\\repo')).resolves.toBeUndefined() + expect(watcher.close).toHaveBeenCalledTimes(2) }) // Issues #5308/#8212: the local recursive watch runs out of process so the @@ -196,6 +322,194 @@ describe('RuntimeFileCommands file watching', () => { ) }) + posixWatcherProcessIt( + 'retains pre-publication watcher setup ownership through destructive cleanup', + async () => { + resolveAuthorizedPathMock.mockResolvedValue('/repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + let resolvePhysicalExit: () => void = () => undefined + const physicalExit = new Promise((resolve) => { + resolvePhysicalExit = resolve + }) + const teardownError = new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable', + physicalExit + ) + watchInWatcherProcessMock.mockRejectedValue(teardownError) + closeWatcherInWatcherProcessMock.mockRejectedValueOnce(teardownError) + const { commands } = createRuntimeFileCommands('/repo') + + await expect(commands.watchFileExplorer('id:wt-1', vi.fn())).rejects.toBe(teardownError) + await expect(commands.closeFileExplorerWatchersForPath('/repo')).rejects.toBe(teardownError) + expect(closeWatcherInWatcherProcessMock).toHaveBeenCalledWith('/repo') + + resolvePhysicalExit() + await physicalExit + await expect(commands.closeFileExplorerWatchersForPath('/repo')).resolves.toBeUndefined() + } + ) + + posixWatcherProcessIt('rejects a runtime watch throughout destructive removal', async () => { + resolveAuthorizedPathMock.mockResolvedValue('/repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + const watcherDispose = vi.fn() + watchInWatcherProcessMock.mockResolvedValue(watcherDispose) + const { commands } = createRuntimeFileCommands('/repo') + const removal = acquireWatcherRemovalGate('/repo') + await removal.ready + + await expect(commands.watchFileExplorer('id:wt-1', vi.fn())).rejects.toMatchObject({ + code: 'watcher_removal_in_progress' + }) + expect(watchInWatcherProcessMock).not.toHaveBeenCalled() + + removal.release() + const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn()) + await unsubscribe() + expect(watchInWatcherProcessMock).toHaveBeenCalledTimes(1) + }) + + posixWatcherProcessIt('awaits root-scoped runtime watcher teardown for deletion', async () => { + resolveAuthorizedPathMock.mockResolvedValue('/repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + const teardownError = new Error('runtime watcher teardown failed') + const watcherDispose = vi.fn().mockRejectedValueOnce(teardownError) + watchInWatcherProcessMock.mockResolvedValue(watcherDispose) + const { commands } = createRuntimeFileCommands('/repo') + + await commands.watchFileExplorer('id:wt-1', vi.fn()) + + await expect(commands.closeFileExplorerWatchersForPath('/repo')).rejects.toBe(teardownError) + await expect(commands.closeFileExplorerWatchersForPath('/repo')).resolves.toBeUndefined() + expect(watcherDispose).toHaveBeenCalledTimes(2) + }) + + posixWatcherProcessIt('re-arms a logical runtime watch after removal aborts', async () => { + resolveAuthorizedPathMock.mockResolvedValue('/repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + const firstDispose = vi.fn().mockResolvedValue(undefined) + const replacementDispose = vi.fn().mockResolvedValue(undefined) + watchInWatcherProcessMock + .mockResolvedValueOnce(firstDispose) + .mockResolvedValueOnce(replacementDispose) + const onEvents = vi.fn() + const onTerminalError = vi.fn() + const { commands } = createRuntimeFileCommands('/repo') + + const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents, onTerminalError) + await commands.closeFileExplorerWatchersForPath('/repo') + expect(firstDispose).toHaveBeenCalledTimes(1) + + await commands.restoreFileExplorerWatchersAfterFailedRemoval('/repo') + + expect(watchInWatcherProcessMock).toHaveBeenCalledTimes(2) + expect(onTerminalError).not.toHaveBeenCalled() + await unsubscribe() + expect(replacementDispose).toHaveBeenCalledTimes(1) + }) + + posixWatcherProcessIt( + 're-arms a logical runtime watch after a timed-out child physically exits', + async () => { + resolveAuthorizedPathMock.mockResolvedValue('/repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + let resolvePhysicalExit: () => void = () => {} + const physicalExit = new Promise((resolve) => { + resolvePhysicalExit = resolve + }) + const teardownError = new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable', + physicalExit + ) + const firstDispose = vi.fn().mockRejectedValue(teardownError) + const replacementDispose = vi.fn().mockResolvedValue(undefined) + watchInWatcherProcessMock + .mockResolvedValueOnce(firstDispose) + .mockResolvedValueOnce(replacementDispose) + const { commands } = createRuntimeFileCommands('/repo') + + const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn()) + await expect(commands.closeFileExplorerWatchersForPath('/repo')).rejects.toBe(teardownError) + await commands.restoreFileExplorerWatchersAfterFailedRemoval('/repo') + expect(watchInWatcherProcessMock).toHaveBeenCalledTimes(1) + + resolvePhysicalExit() + await physicalExit + await vi.waitFor(() => expect(watchInWatcherProcessMock).toHaveBeenCalledTimes(2)) + + await unsubscribe() + expect(replacementDispose).toHaveBeenCalledTimes(1) + } + ) + + posixWatcherProcessIt( + 'does not re-arm a stopped runtime watch after a timed-out child exits', + async () => { + resolveAuthorizedPathMock.mockResolvedValue('/repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + let resolvePhysicalExit: () => void = () => {} + const physicalExit = new Promise((resolve) => { + resolvePhysicalExit = resolve + }) + const teardownError = new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable', + physicalExit + ) + const firstDispose = vi.fn().mockRejectedValue(teardownError) + watchInWatcherProcessMock.mockResolvedValue(firstDispose) + const { commands } = createRuntimeFileCommands('/repo') + + const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn()) + await expect(commands.closeFileExplorerWatchersForPath('/repo')).rejects.toBe(teardownError) + await commands.restoreFileExplorerWatchersAfterFailedRemoval('/repo') + await expect(unsubscribe()).rejects.toBe(teardownError) + + resolvePhysicalExit() + await physicalExit + await Promise.resolve() + + expect(watchInWatcherProcessMock).toHaveBeenCalledTimes(1) + expect(_getRuntimeFileWatcherReleaseCountForTests()).toBe(0) + } + ) + + posixWatcherProcessIt('clears a failed runtime release after physical child exit', async () => { + resolveAuthorizedPathMock.mockResolvedValue('/repo') + statMock.mockResolvedValue({ isDirectory: () => true }) + let resolvePhysicalExit: () => void = () => {} + const physicalExit = new Promise((resolve) => { + resolvePhysicalExit = resolve + }) + const teardownError = new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable', + physicalExit + ) + const watcherDispose = vi.fn().mockRejectedValue(teardownError) + watchInWatcherProcessMock.mockResolvedValue(watcherDispose) + const { commands } = createRuntimeFileCommands('/repo') + + await commands.watchFileExplorer('id:wt-1', vi.fn()) + + await expect(commands.closeFileExplorerWatchersForPath('/repo')).rejects.toBe(teardownError) + await expect(commands.closeFileExplorerWatchersForPath('/repo')).rejects.toBe(teardownError) + expect(watcherDispose).toHaveBeenCalledTimes(1) + + resolvePhysicalExit() + await physicalExit + await Promise.resolve() + + await expect(commands.closeFileExplorerWatchersForPath('/repo')).resolves.toBeUndefined() + expect(watcherDispose).toHaveBeenCalledTimes(1) + }) + posixWatcherProcessIt('tracks watcher unsubscribe work so shutdown can await it', async () => { resolveAuthorizedPathMock.mockResolvedValue('/repo') statMock.mockResolvedValue({ isDirectory: () => true }) @@ -251,11 +565,13 @@ describe('RuntimeFileCommands file watching', () => { openFile: vi.fn() } as never) const controller = new AbortController() + const onTerminalError = vi.fn() - await commands.watchFileExplorer('id:wt-1', vi.fn(), vi.fn(), controller.signal) + await commands.watchFileExplorer('id:wt-1', vi.fn(), onTerminalError, controller.signal) expect(watch).toHaveBeenCalledWith('/remote/repo', expect.any(Function), { - signal: controller.signal + signal: controller.signal, + onTerminalError }) 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 d43e48c4695..e38d0adf0d0 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -19,6 +19,7 @@ const { resolveAuthorizedPathMock, statMock, watchInWatcherProcessMock, + closeWatcherInWatcherProcessMock, checkRgAvailableMock, getLocalGitOptionsForRegisteredWorktreeMock, wslAwareSpawnMock, @@ -33,6 +34,7 @@ const { resolveAuthorizedPathMock: vi.fn(), statMock: vi.fn(), watchInWatcherProcessMock: vi.fn(), + closeWatcherInWatcherProcessMock: vi.fn(), wslAwareSpawnMock: vi.fn(), watchMock: vi.fn() })) @@ -61,6 +63,7 @@ vi.mock('fs/promises', async () => { }) vi.mock('./file-watcher-host', () => ({ + closeFileExplorerWatcherInWatcherProcess: closeWatcherInWatcherProcessMock, watchFileExplorerInWatcherProcess: watchInWatcherProcessMock })) @@ -200,6 +203,7 @@ describe('RuntimeFileCommands', () => { resolveAuthorizedPathMock.mockReset() statMock.mockReset() watchInWatcherProcessMock.mockReset() + closeWatcherInWatcherProcessMock.mockReset() watchMock.mockReset() checkRgAvailableMock.mockReset() vi.mocked(getSshFilesystemProvider).mockReset() @@ -410,12 +414,12 @@ describe('RuntimeFileCommands', () => { value: 'win32' }) - const close = vi.fn() - const on = vi.fn() + const watcher = new EventEmitter() as EventEmitter & { close: ReturnType } + watcher.close = vi.fn(() => queueMicrotask(() => watcher.emit('close'))) let listener: (() => void) | null = null watchMock.mockImplementation((_rootPath, _options, callback) => { listener = callback - return { close, on } + return watcher }) resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') statMock.mockResolvedValue({ isDirectory: () => true }) @@ -437,8 +441,8 @@ describe('RuntimeFileCommands', () => { expect(onEvents).toHaveBeenCalledTimes(1) expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: 'C:\\repo' }]) - unsubscribe() - expect(close).toHaveBeenCalledTimes(1) + await unsubscribe() + expect(watcher.close).toHaveBeenCalledTimes(1) }) it('delegates local recursive watching to the watcher process', async () => { @@ -465,6 +469,77 @@ describe('RuntimeFileCommands', () => { expect(dispose).toHaveBeenCalledTimes(1) }) + it('keeps SSH runtime watches on the remote filesystem provider', async () => { + const remoteDispose = vi.fn() + const providerWatch = vi.fn(() => remoteDispose) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ watch: providerWatch } as never) + const { commands, store } = createRuntimeFileCommands({ path: '/remote/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const onEvents = vi.fn() + + const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents) + + expect(providerWatch).toHaveBeenCalledWith('/remote/repo', onEvents, { + signal: undefined, + onTerminalError: expect.any(Function) + }) + expect(watchInWatcherProcessMock).not.toHaveBeenCalled() + await unsubscribe() + expect(remoteDispose).toHaveBeenCalledTimes(1) + }) + + it('indexes SSH runtime watches so remote deletion can await them', async () => { + let resolveDispose: () => void = () => {} + const remoteDispose = vi.fn( + () => + new Promise((resolve) => { + resolveDispose = resolve + }) + ) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + watch: vi.fn(() => remoteDispose) + } as never) + const { commands, store } = createRuntimeFileCommands({ path: '/remote/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + await commands.watchFileExplorer('id:wt-1', vi.fn()) + + let closed = false + const close = commands.closeFileExplorerWatchersForPath('/remote/repo', 'ssh-1').then(() => { + closed = true + }) + await Promise.resolve() + expect(remoteDispose).toHaveBeenCalledTimes(1) + expect(closed).toBe(false) + + resolveDispose() + await close + }) + + it('scopes same-path runtime watcher teardown to its SSH execution host', async () => { + const firstDispose = vi.fn() + const secondDispose = vi.fn() + vi.mocked(getSshFilesystemProvider).mockImplementation( + (connectionId) => + ({ + watch: vi.fn(() => (connectionId === 'ssh-1' ? firstDispose : secondDispose)) + }) as never + ) + const first = createRuntimeFileCommands({ path: '/same/repo' }) + const second = createRuntimeFileCommands({ path: '/same/repo' }) + first.store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + second.store.getRepo.mockReturnValue({ connectionId: 'ssh-2' }) + + await first.commands.watchFileExplorer('id:wt-1', vi.fn()) + await second.commands.watchFileExplorer('id:wt-1', vi.fn()) + await first.commands.closeFileExplorerWatchersForPath('/same/repo', 'ssh-1') + + expect(firstDispose).toHaveBeenCalledTimes(1) + expect(secondDispose).not.toHaveBeenCalled() + + await second.commands.closeFileExplorerWatchersForPath('/same/repo', 'ssh-2') + expect(secondDispose).toHaveBeenCalledTimes(1) + }) + it('settles and detaches runtime rg searches when timeout kill is ignored', async () => { const resolveRuntimeFileTarget = vi.fn(async () => ({ worktree: { diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index acdf3616f47..5af3d0d22a3 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -33,9 +33,11 @@ import { isPathInsideOrEqual, isRuntimePathAbsolute, isWindowsAbsolutePathLike, + normalizeRuntimePathForComparison, relativePathInsideRoot, resolveRuntimePath } from '../../shared/cross-platform-path' +import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import type { RuntimeFileListResult, RuntimeFileOpenResult, @@ -44,7 +46,10 @@ import type { RuntimeFileReadResult, RuntimeTerminalPathResolution } from '../../shared/runtime-types' -import { watchFileExplorerInWatcherProcess } from './file-watcher-host' +import { + closeFileExplorerWatcherInWatcherProcess, + watchFileExplorerInWatcherProcess +} from './file-watcher-host' import { wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' import { isENOENT, resolveAuthorizedPath } from '../ipc/filesystem-auth' @@ -70,18 +75,37 @@ import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' import type { FileStat, IFilesystemProvider } from '../providers/types' +import { + isWatcherProcessFailure, + WatcherProcessFailure +} from '../ipc/parcel-watcher-process-failure' import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' +import { + rankRuntimeMobileFilePaths, + RuntimeMobileFilePathSearchCache +} from './runtime-mobile-file-path-search' +import { beginWatcherInstall } from '../ipc/watcher-removal-gate' const MOBILE_FILE_LIST_LIMIT = 5000 +const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 +const MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES = 8 +const MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS = 30_000 const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 +export const WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS = 10_000 const TERMINAL_FILE_GRANT_TTL_MS = 10 * 60 * 1000 const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0 // Why: runtime files.watch subscriptions are cleaned up through synchronous RPC // callbacks. Track native Parcel unsubscribe work so app shutdown can drain it. const pendingRuntimeFileWatcherUnsubscribes = new Set>() +type RuntimeFileWatcherLease = { + suspend(): Promise + resume(): Promise + forget(): void +} +const runtimeFileWatcherLeasesByOwnerAndRoot = new Map>() const MOBILE_BINARY_EXTENSIONS = new Set([ '.avif', '.bmp', @@ -157,22 +181,198 @@ const RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES: Record = { function trackRuntimeFileWatcherUnsubscribe( rootPath: string, unsubscribe: () => Promise -): void { +): Promise { const promise = Promise.resolve() .then(unsubscribe) - .catch((err: unknown) => { - console.error('[runtime-files.watch] unsubscribe error', { rootPath, err }) - }) .finally(() => { pendingRuntimeFileWatcherUnsubscribes.delete(promise) }) pendingRuntimeFileWatcherUnsubscribes.add(promise) + void promise.catch((err: unknown) => { + console.error('[runtime-files.watch] unsubscribe error', { rootPath, err }) + }) + return promise +} + +function normalizeRuntimeWatcherRoot(rootPath: string): string { + return normalizeRuntimePathForComparison(rootPath) +} + +function runtimeWatcherReleaseKey( + runtimeId: string, + connectionId: string | undefined, + rootPath: string +): string { + // Why: identical absolute paths are valid on local and multiple SSH hosts; + // destructive teardown must stay scoped to the execution host that owns it. + return JSON.stringify([runtimeId, connectionId ?? null, normalizeRuntimeWatcherRoot(rootPath)]) +} + +function registerRuntimeFileWatcherRelease( + runtimeId: string, + connectionId: string | undefined, + rootPaths: string[], + unsubscribe: () => Promise, + restart: () => Promise<() => Promise>, + onRestoreError: (error: Error) => void +): () => Promise { + const keys = Array.from( + new Set( + rootPaths.map((rootPath) => runtimeWatcherReleaseKey(runtimeId, connectionId, rootPath)) + ) + ) + let currentUnsubscribe: (() => Promise) | null = unsubscribe + let releasePromise: Promise | null = null + let physicalExitPromise: Promise | null = null + let resumePromise: Promise | null = null + let stopPromise: Promise | null = null + let logicallyStopped = false + const removeLease = (): void => { + for (const key of keys) { + const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) + leases?.delete(lease) + if (leases?.size === 0) { + runtimeFileWatcherLeasesByOwnerAndRoot.delete(key) + } + } + } + const suspend = (): Promise => { + if (releasePromise) { + return releasePromise + } + const release = currentUnsubscribe + if (!release) { + return Promise.resolve() + } + const attempt = trackRuntimeFileWatcherUnsubscribe(rootPaths[0], release) + releasePromise = attempt + void attempt.then( + () => { + if (currentUnsubscribe === release) { + currentUnsubscribe = null + } + releasePromise = null + }, + (error: unknown) => { + if (isWatcherProcessFailure(error) && error.physicalExit) { + const physicalExit = error.physicalExit.then(() => { + if (currentUnsubscribe === release) { + currentUnsubscribe = null + } + releasePromise = null + if (physicalExitPromise === physicalExit) { + physicalExitPromise = null + } + if (logicallyStopped) { + removeLease() + } + }) + physicalExitPromise = physicalExit + } else { + // Why: a synchronous close failure retains the native owner so a + // later removal or logical unsubscribe can retry the same handle. + releasePromise = null + } + } + ) + return attempt + } + const lease: RuntimeFileWatcherLease = { + suspend, + resume: () => { + if (logicallyStopped || (currentUnsubscribe && !physicalExitPromise)) { + return Promise.resolve() + } + if (resumePromise) { + return physicalExitPromise ? Promise.resolve() : resumePromise + } + // Why: a timed-out child still owns native handles until its physical + // exit; restoration must join that owner before starting a replacement. + const resumesAfterPhysicalExit = physicalExitPromise !== null + const attempt = Promise.resolve(physicalExitPromise ?? releasePromise) + .then(async () => { + if (logicallyStopped) { + return + } + const nextUnsubscribe = await restart() + if (logicallyStopped) { + await nextUnsubscribe() + return + } + currentUnsubscribe = nextUnsubscribe + }) + .catch((error: unknown) => { + const restoreError = error instanceof Error ? error : new Error(String(error)) + queueMicrotask(() => onRestoreError(restoreError)) + throw restoreError + }) + .finally(() => { + resumePromise = null + }) + resumePromise = attempt + if (resumesAfterPhysicalExit) { + void attempt.catch(() => {}) + return Promise.resolve() + } + return attempt + }, + forget: () => { + logicallyStopped = true + removeLease() + } + } + for (const key of keys) { + const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) ?? new Set() + leases.add(lease) + runtimeFileWatcherLeasesByOwnerAndRoot.set(key, leases) + } + return () => { + if (stopPromise) { + return stopPromise + } + logicallyStopped = true + const release = + resumePromise && !physicalExitPromise + ? Promise.resolve(resumePromise) + .catch(() => undefined) + .then(suspend) + : suspend() + const attempt = release.then(removeLease).catch((error: unknown) => { + stopPromise = null + throw error + }) + stopPromise = attempt + return attempt + } } export async function awaitRuntimeFileWatcherUnsubscribes(): Promise { await Promise.allSettled(Array.from(pendingRuntimeFileWatcherUnsubscribes)) } +export function _getRuntimeFileWatcherReleaseCountForTests(): number { + const leases = new Set() + for (const rootLeases of runtimeFileWatcherLeasesByOwnerAndRoot.values()) { + for (const lease of rootLeases) { + leases.add(lease) + } + } + return leases.size +} + +export function _resetRuntimeFileWatcherLeasesForTests(): void { + const leases = new Set() + for (const rootLeases of runtimeFileWatcherLeasesByOwnerAndRoot.values()) { + for (const lease of rootLeases) { + leases.add(lease) + } + } + for (const lease of leases) { + lease.forget() + } + runtimeFileWatcherLeasesByOwnerAndRoot.clear() +} + export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo } export type ResolvedRuntimeFileTarget = { worktree: ResolvedRuntimeFileWorktree @@ -215,6 +415,10 @@ export type RuntimeFileCommandHost = { export class RuntimeFileCommands { private activeRuntimeTextSearches = new Map() private terminalFileGrants = new Map() + private mobileFilePathSearchCache = new RuntimeMobileFilePathSearchCache( + MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES, + MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS + ) constructor(private readonly host: RuntimeFileCommandHost) {} @@ -244,6 +448,52 @@ export class RuntimeFileCommands { } } + async searchMobileFilePaths( + worktreeSelector: string, + query: string, + limit: number + ): Promise { + const store = this.host.requireStore() + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = target + const cacheKey = `${connectionId ?? 'local'}:${worktree.id}:${worktree.path}` + const inventory = await this.mobileFilePathSearchCache.get(cacheKey, async () => { + const listed = connectionId + ? await this.listRemoteMobileFiles( + worktree.path, + connectionId, + MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 + ) + : await listQuickOpenFiles( + worktree.path, + store, + undefined, + undefined, + MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 + ) + const safePaths = listed + .filter((relativePath) => isSafeMobileRelativePath(relativePath)) + .sort((a, b) => a.localeCompare(b)) + return { + paths: safePaths.slice(0, MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT), + totalCount: safePaths.length, + truncated: safePaths.length > MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + } + }) + const matches = rankRuntimeMobileFilePaths(inventory.paths, query, limit) + return { + worktree: worktree.id, + rootPath: worktree.path, + files: matches.paths.map((relativePath) => ({ + relativePath, + basename: basenameFromRelativePath(relativePath), + kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) + })), + totalCount: matches.totalCount, + truncated: inventory.truncated || matches.totalCount > limit + } + } + async openMobileFile( worktreeSelector: string, relativePath: string @@ -926,34 +1176,88 @@ export class RuntimeFileCommands { signal?: AbortSignal ): Promise<() => void> { const target = await this.resolveFileExplorerPath(worktreeSelector, '') - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - // 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 open = async (): Promise<{ + unsubscribe: () => Promise + rootPaths: string[] + }> => { + const finishInstall = beginWatcherInstall(target.path, target.connectionId) + try { + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + // Why: the RPC layer already threads AbortSignal for local watches; SSH + // must cancel the remote fs.watch request instead of waiting it out. + const close = await provider.watch(target.path, callback, { signal, onTerminalError }) + return { unsubscribe: async () => close(), rootPaths: [target.path] } + } - const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const rootStats = await stat(rootPath) - if (!rootStats.isDirectory()) { - throw new Error('not_a_directory') + const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const rootStats = await stat(rootPath) + if (!rootStats.isDirectory()) { + throw new Error('not_a_directory') + } + if (process.platform === 'win32') { + const close = watchWindowsRuntimeFileExplorer(rootPath, callback, onTerminalError) + return { unsubscribe: close, rootPaths: [target.path, rootPath] } + } + // 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 { unsubscribe: dispose, rootPaths: [target.path, rootPath] } + } finally { + finishInstall() + } } - if (process.platform === 'win32') { - return watchWindowsRuntimeFileExplorer(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 + const initial = await open() + return registerRuntimeFileWatcherRelease( + this.host.getRuntimeId(), + target.connectionId, + initial.rootPaths, + initial.unsubscribe, + async () => (await open()).unsubscribe, + onTerminalError ) - return () => { - trackRuntimeFileWatcherUnsubscribe(rootPath, dispose) + } + + async closeFileExplorerWatchersForPath(rootPath: string, connectionId?: string): Promise { + const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) + const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) + if (leases) { + await Promise.all(Array.from(leases, (lease) => lease.suspend())) + } + if (!connectionId) { + // Why: setup can fail before registerRuntimeFileWatcherRelease publishes + // its callback, while the host still retains an unkillable child owner. + const resolvedRootPath = await resolveAuthorizedPath(rootPath, this.host.requireStore()) + await closeFileExplorerWatcherInWatcherProcess(resolvedRootPath) + } + } + + async restoreFileExplorerWatchersAfterFailedRemoval( + rootPath: string, + connectionId?: string + ): Promise { + const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) + const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) + if (leases) { + await Promise.all(Array.from(leases, (lease) => lease.resume())) + } + } + + forgetFileExplorerWatchersAfterRemoval(rootPath: string, connectionId?: string): void { + const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) + const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) + if (leases) { + for (const lease of Array.from(leases)) { + lease.forget() + } } } @@ -1483,12 +1787,16 @@ export class RuntimeFileCommands { } } - private async listRemoteMobileFiles(rootPath: string, connectionId: string): Promise { + private async listRemoteMobileFiles( + rootPath: string, + connectionId: string, + maxResults?: number + ): Promise { const provider = getSshFilesystemProvider(connectionId) if (!provider) { return [] } - return provider.listFiles(rootPath) + return provider.listFiles(rootPath, { maxResults }) } private async readRemoteMobileFile(filePath: string, connectionId: string): Promise { @@ -1512,10 +1820,13 @@ export class RuntimeFileCommands { function watchWindowsRuntimeFileExplorer( rootPath: string, - callback: (events: FsChangeEvent[]) => void -): () => void { + callback: (events: FsChangeEvent[]) => void, + onTerminalError: (error: Error) => void +): () => Promise { let disposed = false let timer: ReturnType | null = null + let closeStarted = false + const physicalClose = new PhysicalExitTracker() const emitOverflow = (): void => { timer = null @@ -1539,21 +1850,61 @@ function watchWindowsRuntimeFileExplorer( // watcher can abort the headless server process. For remote Windows runtimes, // a conservative overflow refresh is safer than a process-wide native crash. const watcher = watchFs(rootPath, { recursive: true }, scheduleOverflow) - watcher.on('error', (err) => { + const onClose = (): void => { + watcher.removeListener('error', onError) + physicalClose.markExited() + } + const onError = (err: Error): void => { console.error('[runtime-files.watch] Windows watcher error', { rootPath, err }) - scheduleOverflow() - }) + if (timer) { + clearTimeout(timer) + timer = null + } + watcher.removeListener('close', onClose) + watcher.removeListener('error', onError) + // Why: Node closes and nulls FSWatcher's native handle on error without a + // close event; that error is positive physical-exit proof for deletion. + physicalClose.markExited() + if (!disposed) { + try { + callback([{ kind: 'overflow', absolutePath: rootPath }]) + } finally { + onTerminalError(err) + } + } + } + watcher.once('close', onClose) + watcher.on('error', onError) - return () => { + return async () => { disposed = true if (timer) { clearTimeout(timer) timer = null } + if (!closeStarted) { + try { + watcher.close() + } catch (err) { + console.error('[runtime-files.watch] Windows watcher close error', { rootPath, err }) + throw err + } + closeStarted = true + } try { - watcher.close() - } catch (err) { - console.error('[runtime-files.watch] Windows watcher close error', { rootPath, err }) + await physicalClose.waitForExit( + WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS, + () => new Error('Windows watcher did not close before deletion deadline') + ) + } catch (error) { + // Why: late Windows close still owns native directory handles; expose its + // exact completion so destructive cleanup retains and then clears the root. + throw new WatcherProcessFailure( + error instanceof Error ? error.message : String(error), + 'supervisor', + 'process_unavailable', + physicalClose.exitedPromise + ) } } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b5d4cef08b5..15a59321409 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -92,14 +92,17 @@ import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' import { RpcDispatcher } from './rpc/dispatcher' import type { RpcRequest } from './rpc/core' import { TERMINAL_METHODS } from './rpc/methods/terminal' +import { beginWatcherInstall } from '../ipc/watcher-removal-gate' const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') const removeWorktreeLinkedPathsMock = vi.hoisted(() => vi.fn()) +const findExistingWorktreeSymlinkPathsMock = vi.hoisted(() => vi.fn()) const resolveLocalGitUsernameMock = vi.hoisted(() => vi.fn(async () => '')) vi.mock('../ipc/worktree-symlinks', () => ({ createWorktreeLinkedPaths: vi.fn(), + findExistingWorktreeSymlinkPaths: findExistingWorktreeSymlinkPathsMock, removeWorktreeLinkedPaths: removeWorktreeLinkedPathsMock })) @@ -152,7 +155,22 @@ const electronMocks = vi.hoisted(() => { } }) +const closeLocalWatcherForWorktreePathMock = vi.hoisted(() => vi.fn()) +const closeRemoteWatcherForWorktreePathMock = vi.hoisted(() => vi.fn()) +const restoreLocalWatcherAfterFailedRemovalMock = vi.hoisted(() => vi.fn()) +const restoreRemoteWatcherAfterFailedRemovalMock = vi.hoisted(() => vi.fn()) +const forgetLocalWatcherRemovalSnapshotMock = vi.hoisted(() => vi.fn()) +const forgetRemoteWatcherRemovalSnapshotMock = vi.hoisted(() => vi.fn()) + vi.mock('electron', () => electronMocks) +vi.mock('../ipc/filesystem-watcher', () => ({ + closeLocalWatcherForWorktreePath: closeLocalWatcherForWorktreePathMock, + closeRemoteWatcherForWorktreePath: closeRemoteWatcherForWorktreePathMock, + restoreLocalWatcherAfterFailedRemoval: restoreLocalWatcherAfterFailedRemovalMock, + restoreRemoteWatcherAfterFailedRemoval: restoreRemoteWatcherAfterFailedRemovalMock, + forgetLocalWatcherRemovalSnapshot: forgetLocalWatcherRemovalSnapshotMock, + forgetRemoteWatcherRemovalSnapshot: forgetRemoteWatcherRemovalSnapshotMock +})) const { MOCK_GIT_WORKTREES, @@ -558,12 +576,19 @@ function resetRuntimeTestMocks(): void { electronMocks.ipcMain.on.mockClear() electronMocks.ipcMain.removeListener.mockClear() electronMocks.ipcMain.emit.mockClear() + closeLocalWatcherForWorktreePathMock.mockReset().mockResolvedValue(undefined) + closeRemoteWatcherForWorktreePathMock.mockReset().mockResolvedValue(undefined) + restoreLocalWatcherAfterFailedRemovalMock.mockReset().mockResolvedValue(undefined) + restoreRemoteWatcherAfterFailedRemovalMock.mockReset().mockResolvedValue(undefined) + forgetLocalWatcherRemovalSnapshotMock.mockReset() + forgetRemoteWatcherRemovalSnapshotMock.mockReset() vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES) vi.mocked(listWorktreesStrict).mockResolvedValue(MOCK_GIT_WORKTREES) vi.mocked(addWorktree).mockReset() vi.mocked(assertWorktreeCleanForRemoval).mockReset() vi.mocked(assertWorktreeCleanForRemoval).mockResolvedValue(undefined) vi.mocked(removeWorktree).mockReset() + findExistingWorktreeSymlinkPathsMock.mockReset().mockResolvedValue([]) removeWorktreeLinkedPathsMock.mockReset() resolveLocalGitUsernameMock.mockReset().mockResolvedValue('') vi.mocked(forceDeleteLocalBranchMock).mockReset() @@ -4766,7 +4791,20 @@ describe('OrcaRuntimeService', () => { removeWorktree: vi.fn().mockResolvedValue(undefined) } registerSshGitProvider('ssh-1', gitProvider as never) - const runtime = new OrcaRuntimeService(remoteStore as never) + const ptyProvider = { + listProcesses: vi.fn().mockResolvedValue([ + { + id: 'pty-remote', + cwd: '/remote/feature', + title: 'shell', + worktreeId: `${TEST_REPO_ID}::/remote/feature` + } + ]), + shutdown: vi.fn().mockResolvedValue(undefined) + } + const runtime = new OrcaRuntimeService(remoteStore as never, undefined, { + getSshProvider: () => ptyProvider as never + }) try { await runtime.removeManagedWorktree('path:/remote/feature', true, false) @@ -4775,6 +4813,14 @@ describe('OrcaRuntimeService', () => { } expect(gitProvider.removeWorktree).toHaveBeenCalledWith('/remote/feature', true) + expect(ptyProvider.shutdown).toHaveBeenCalledWith('pty-remote', { immediate: true }) + expect(ptyProvider.shutdown.mock.invocationCallOrder[0]).toBeLessThan( + gitProvider.removeWorktree.mock.invocationCallOrder[0] + ) + expect(closeRemoteWatcherForWorktreePathMock).toHaveBeenCalledWith('ssh-1', '/remote/feature') + expect(closeRemoteWatcherForWorktreePathMock.mock.invocationCallOrder[0]).toBeLessThan( + gitProvider.removeWorktree.mock.invocationCallOrder[0] + ) expect(removeWorktree).not.toHaveBeenCalled() expect(listWorktrees).not.toHaveBeenCalled() expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(`${TEST_REPO_ID}::/remote/feature`) @@ -5139,7 +5185,7 @@ describe('OrcaRuntimeService', () => { listGitHubLabelsMock.mockResolvedValueOnce([]) listGitHubAssignableUsersMock.mockResolvedValueOnce([]) - await runtime.listRepoWorkItems('id:repo-1', 7, 'is:open', 'cursor', true) + await runtime.listRepoWorkItems('id:repo-1', 7, 'is:open', 1, true) await runtime.countRepoWorkItems('id:repo-1', 'is:issue') await runtime.listRepoIssues('id:repo-1', 5) await runtime.getRepoIssue('id:repo-1', 12) @@ -5153,7 +5199,7 @@ describe('OrcaRuntimeService', () => { TEST_REPO_PATH, 7, 'is:open', - 'cursor', + 1, undefined, null, true, @@ -8132,6 +8178,28 @@ describe('OrcaRuntimeService', () => { ) }) + it('replaces suffix-only headless state with the recovered renderer snapshot', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + runtime.seedHeadlessTerminal('pty-1', 'suffix-only redraw', { cols: 80, rows: 24 }) + + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery('pty-1', { + data: 'restored history\r\nprompt $ ', + cols: 80, + rows: 24, + cwd: '/projects/restored' + }) + runtime.onPtyData('pty-1', 'after recovery\r\n', 100) + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { + scrollbackRows: 100 + }) + expect(snapshot?.data).toContain('restored history') + expect(snapshot?.data).toContain('after recovery') + expect(snapshot?.data).not.toContain('suffix-only redraw') + expect(snapshot?.cwd).toBe('/projects/restored') + }) + it('adopts OSC7 host metadata from seeded headless terminal scrollback', async () => { const runtime = createRuntime() syncSinglePty(runtime, 'pty-1') @@ -9584,6 +9652,8 @@ describe('OrcaRuntimeService', () => { it('keeps ordinary terminal send suffix failures on the existing not-writable contract', async () => { const writes: string[] = [] + const beforeWrite = vi.fn() + const afterWrite = vi.fn() const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), @@ -9619,9 +9689,15 @@ describe('OrcaRuntimeService', () => { const [terminal] = (await runtime.listTerminals()).terminals await expect( - runtime.sendTerminal(terminal.handle, { text: 'notes', enter: true }) + runtime.sendTerminal( + terminal.handle, + { text: 'notes', enter: true }, + { beforeWrite, afterWrite } + ) ).rejects.toThrow('terminal_not_writable') expect(writes).toEqual(['notes', '\r']) + expect(beforeWrite).toHaveBeenCalledTimes(2) + expect(afterWrite).toHaveBeenCalledOnce() }) it('creates visible terminal sessions without asking the renderer to focus a tab', async () => { @@ -18370,6 +18446,110 @@ describe('OrcaRuntimeService', () => { expect(kill).toHaveBeenCalledWith('laptop-created-pty') }) + it('waits for renderer acknowledgement before returning a whole-tab close receipt', async () => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const acknowledged = makeDeferred() + const closeTerminalTab = vi.fn(() => acknowledged.promise) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab } as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Durable', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'persisted-pty' + } + ] + }) + const [terminal] = (await runtime.listTerminals()).terminals + const pending = runtime.closeTerminalTab(terminal.handle) + let settled = false + void pending.finally(() => { + settled = true + }) + + await vi.waitFor(() => expect(closeTerminalTab).toHaveBeenCalledWith('host-tab')) + expect(settled).toBe(false) + + acknowledged.resolve() + await expect(pending).resolves.toEqual({ + handle: terminal.handle, + tabId: 'host-tab', + closeMode: 'tab', + ptyKilled: false + }) + }) + + it('durably closes every split leaf without a renderer', async () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'durable-tab', + ptyId: null, + worktreeId: TEST_WORKTREE_ID, + title: 'Durable', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'durable-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: undefined }) + } + }) + ) + const flushOrThrow = vi.fn() + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'headless-left' }) + .mockResolvedValueOnce({ id: 'headless-right' }) + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'durable-tab', + leafId: HEADLESS_LEAF_ID + }) + await runtime.splitTerminal(terminal.handle, { direction: 'vertical' }) + + await runtime.closeTerminalTab(terminal.handle) + + expect(kill).toHaveBeenCalledWith('headless-left') + expect(kill).toHaveBeenCalledWith('headless-right') + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(getSession().terminalLayoutsByTabId['durable-tab']).toBeUndefined() + expect(flushOrThrow).toHaveBeenCalledTimes(1) + }) + it('lists PTY-backed mobile session terminals without a renderer graph', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) const runtime = new OrcaRuntimeService(store) @@ -21924,6 +22104,70 @@ describe('OrcaRuntimeService', () => { expect(secondStop).toHaveBeenCalledTimes(1) }) + it('dedupes async subscription cleanup and retains a failed cleanup for retry', async () => { + const runtime = createRuntime() + const cleanupError = new Error('physical teardown incomplete') + const firstCleanup = deferred() + const cleanup = vi + .fn() + .mockReturnValueOnce(firstCleanup.promise) + .mockRejectedValue(cleanupError) + runtime.registerSubscriptionCleanup('files-watch-1', cleanup, 'conn-1') + + const first = runtime.cleanupSubscriptionAndWait('files-watch-1') + const duplicate = runtime.cleanupSubscriptionAndWait('files-watch-1') + expect(cleanup).toHaveBeenCalledTimes(1) + firstCleanup.reject(cleanupError) + await expect(first).rejects.toBe(cleanupError) + await expect(duplicate).rejects.toBe(cleanupError) + + await expect(runtime.cleanupSubscriptionAndWait('files-watch-1')).rejects.toBe(cleanupError) + expect(cleanup).toHaveBeenCalledTimes(2) + }) + + it('does not let an old connection cleanup tear down its replacement subscription', async () => { + const runtime = createRuntime() + const oldDone = deferred() + const oldCleanup = vi.fn(() => oldDone.promise) + const replacementCleanup = vi.fn() + + runtime.registerSubscriptionCleanup('terminal:stable', oldCleanup, 'conn-old') + runtime.registerSubscriptionCleanup('terminal:stable', replacementCleanup, 'conn-new') + expect(oldCleanup).toHaveBeenCalledTimes(1) + + runtime.cleanupSubscriptionsForConnection('conn-old') + expect(replacementCleanup).not.toHaveBeenCalled() + + runtime.cleanupSubscriptionsForConnection('conn-new') + expect(replacementCleanup).toHaveBeenCalledTimes(1) + oldDone.resolve() + await Promise.resolve() + }) + + it('does not let a delayed cleanup retry tear down its replacement subscription', async () => { + const runtime = createRuntime() + const physicalExit = deferred() + const oldDone = deferred() + const cleanupError = new Error('physical teardown incomplete') + const oldCleanup = vi.fn(() => oldDone.promise) + const replacementCleanup = vi.fn() + + runtime.registerSubscriptionCleanup('files-watch:stable', oldCleanup, 'conn-old') + const oldAttempt = runtime.cleanupSubscriptionAndWait('files-watch:stable') + runtime.retrySubscriptionCleanupAfter('files-watch:stable', oldCleanup, physicalExit.promise) + runtime.registerSubscriptionCleanup('files-watch:stable', replacementCleanup, 'conn-new') + + oldDone.reject(cleanupError) + await expect(oldAttempt).rejects.toBe(cleanupError) + physicalExit.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(oldCleanup).toHaveBeenCalledTimes(1) + expect(replacementCleanup).not.toHaveBeenCalled() + await runtime.cleanupSubscriptionAndWait('files-watch:stable') + expect(replacementCleanup).toHaveBeenCalledTimes(1) + }) + it('does not deliver or accept browser screencast frames before ready', async () => { const runtime = createRuntime() const done = deferred() @@ -23594,6 +23838,40 @@ describe('OrcaRuntimeService', () => { expect(killed).toBe(false) }) + it('awaits physical PTY stop when destructive teardown supplies shared dedupe', async () => { + const runtime = new OrcaRuntimeService(store) + const physicalStop = makeDeferred() + const kill = vi.fn(() => true) + const stopAndWait = vi.fn(async () => { + await physicalStop.promise + return true + }) + runtime.setPtyController({ + write: () => true, + kill, + stopAndWait, + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + const stopPty = vi.fn(async (_ptyId: string, stop: () => boolean | Promise) => ({ + stopped: await stop(), + owner: true + })) + + const stopping = runtime.stopTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`, { stopPty }) + await vi.waitFor(() => expect(stopAndWait).toHaveBeenCalledWith('pty-1')) + expect(kill).not.toHaveBeenCalled() + let settled = false + void stopping.then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + physicalStop.resolve() + await expect(stopping).resolves.toEqual({ stopped: 1 }) + }) + it('fails terminal listing closed if the graph reloads during selector resolution', async () => { const runtime = new OrcaRuntimeService(store) @@ -23684,6 +23962,59 @@ describe('OrcaRuntimeService', () => { expect(killed).toBe(false) }) + it('does not stop a reused PTY after the teardown deadline passes', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const killed = vi.fn(() => true) + runtime.setPtyController({ + write: () => true, + kill: killed, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'reused-pty' + } + ] + }) + + let releaseListWorktrees = () => {} + vi.mocked(listWorktrees).mockImplementationOnce( + () => + new Promise((resolve) => { + releaseListWorktrees = () => resolve(MOCK_GIT_WORKTREES) + }) + ) + const stopPromise = runtime.stopTerminalsForWorktree('branch:feature/foo', { + deadline: Date.now() + 25 + }) + + await vi.advanceTimersByTimeAsync(25) + releaseListWorktrees() + + await expect(stopPromise).resolves.toEqual({ stopped: 0 }) + expect(killed).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('stops exactly the expected live PTYs for a worktree', async () => { const runtime = new OrcaRuntimeService(store) const stopped: string[] = [] @@ -29049,8 +29380,19 @@ describe('OrcaRuntimeService', () => { } }) + function createWorktreeRemovalRuntime(runtimeStore: unknown = store): OrcaRuntimeService { + const emptyPtyProvider = { + listProcesses: vi.fn(async () => []), + shutdown: vi.fn(async () => {}) + } + return new OrcaRuntimeService(runtimeStore as never, undefined, { + getLocalProvider: () => emptyPtyProvider as never, + getSshProvider: () => emptyPtyProvider as never + }) + } + it('skips archive hooks for CLI worktree removal by default', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = createWorktreeRemovalRuntime() vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: { archive: 'pnpm worktree:archive' @@ -29075,9 +29417,120 @@ describe('OrcaRuntimeService', () => { ) }) + it('does not remove a runtime worktree when watcher teardown cannot release it', async () => { + const repo = { ...store.getRepos()[0], symlinkPaths: ['node_modules'] } + const runtimeStore = { ...store, getRepos: () => [repo], getRepo: () => repo } + const runtime = createWorktreeRemovalRuntime(runtimeStore) + vi.mocked(getEffectiveHooks).mockReturnValue(null) + deleteWorktreeHistoryDirMock.mockClear() + closeLocalWatcherForWorktreePathMock.mockRejectedValue( + new Error('file watcher process did not exit after termination deadline') + ) + + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).rejects.toThrow( + 'file watcher process did not exit after termination deadline' + ) + + expect(removeWorktree).not.toHaveBeenCalled() + expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled() + expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() + }) + + it('stops worktree PTYs before removing linked paths', async () => { + const repo = { ...store.getRepos()[0], symlinkPaths: ['node_modules'] } + const runtimeStore = { ...store, getRepos: () => [repo], getRepo: () => repo } + const runtime = createWorktreeRemovalRuntime(runtimeStore) + const stopAndWait = vi.fn().mockResolvedValue(true) + runtime.setPtyController({ + write: () => true, + kill: () => true, + stopAndWait, + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, 'pty-1') + vi.mocked(removeWorktree).mockResolvedValue({}) + removeWorktreeLinkedPathsMock.mockImplementationOnce(async () => { + expect(stopAndWait).toHaveBeenCalledWith('pty-1') + }) + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + + expect(removeWorktreeLinkedPathsMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH, ['node_modules']) + }) + + it('waits for every watcher layer to settle before restoring after teardown failure', async () => { + const runtime = createRuntime() + let finishRuntimeClose: () => void = () => {} + const runtimeClose = new Promise((resolve) => { + finishRuntimeClose = resolve + }) + const fileCommands = ( + runtime as unknown as { + fileCommands: { + closeFileExplorerWatchersForPath: (path: string) => Promise + restoreFileExplorerWatchersAfterFailedRemoval: (path: string) => Promise + } + } + ).fileCommands + vi.spyOn(fileCommands, 'closeFileExplorerWatchersForPath').mockReturnValueOnce(runtimeClose) + const restoreRuntime = vi + .spyOn(fileCommands, 'restoreFileExplorerWatchersAfterFailedRemoval') + .mockResolvedValue(undefined) + closeLocalWatcherForWorktreePathMock.mockRejectedValueOnce( + new Error('desktop watcher close failed') + ) + + const result = runtime.acquireFileWatcherRemoval(TEST_WORKTREE_PATH).catch((error) => error) + await Promise.resolve() + expect(restoreLocalWatcherAfterFailedRemovalMock).not.toHaveBeenCalled() + expect(restoreRuntime).not.toHaveBeenCalled() + + finishRuntimeClose() + await expect(result).resolves.toEqual( + expect.objectContaining({ + message: 'desktop watcher close failed' + }) + ) + expect(restoreLocalWatcherAfterFailedRemovalMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH) + expect(restoreRuntime).toHaveBeenCalledWith(TEST_WORKTREE_PATH, undefined) + }) + + it('restores runtime watchers when CLI worktree deletion fails after teardown', async () => { + const runtime = createWorktreeRemovalRuntime() + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(removeWorktree).mockRejectedValue(new Error('delete failed')) + + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).rejects.toThrow('delete failed') + + expect(restoreLocalWatcherAfterFailedRemovalMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH) + expect(forgetLocalWatcherRemovalSnapshotMock).not.toHaveBeenCalled() + const finishRetry = beginWatcherInstall(TEST_WORKTREE_PATH) + finishRetry() + }) + + it('closes before and after draining a pre-removal watcher install', async () => { + const runtime = createRuntime() + const finishInstall = beginWatcherInstall(TEST_WORKTREE_PATH) + + const acquiring = runtime.acquireFileWatcherRemoval(TEST_WORKTREE_PATH) + await vi.waitFor(() => expect(closeLocalWatcherForWorktreePathMock).toHaveBeenCalledTimes(1)) + expect(() => beginWatcherInstall(TEST_WORKTREE_PATH)).toThrow( + 'cannot start while the worktree is being removed' + ) + + finishInstall() + const gate = await acquiring + expect(closeLocalWatcherForWorktreePathMock).toHaveBeenCalledTimes(2) + await gate.finish(false) + expect(restoreLocalWatcherAfterFailedRemovalMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH) + + const finishRetry = beginWatcherInstall(TEST_WORKTREE_PATH) + finishRetry() + }) + it('recovers forced Windows runtime long-path removal and keeps skipped-hook warnings', async () => { setPlatform('win32') - const runtime = new OrcaRuntimeService(store) + const runtime = createWorktreeRemovalRuntime() await mkdir(TEST_WORKTREE_PATH, { recursive: true }) await writeFile(join(TEST_WORKTREE_PATH, 'scratch.txt'), 'delete me') const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ @@ -29126,7 +29579,7 @@ describe('OrcaRuntimeService', () => { ...store, removeWorktreeMeta } - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ stdout: '', stderr: '' @@ -29159,7 +29612,7 @@ describe('OrcaRuntimeService', () => { const missingWorktreePath = 'C:\\workspace\\already-removed' const worktreeId = `${TEST_REPO_ID}::${missingWorktreePath}` const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) const registeredWorktrees = [ { path: TEST_REPO_PATH, @@ -29210,7 +29663,7 @@ describe('OrcaRuntimeService', () => { const missingWorktreePath = 'C:\\workspace\\locked-already-removed' const worktreeId = `${TEST_REPO_ID}::${missingWorktreePath}` const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) const registeredWorktrees = [ { path: missingWorktreePath, @@ -29253,7 +29706,7 @@ describe('OrcaRuntimeService', () => { localWindowsRuntimeDefault: { kind: 'windows-host' } }) } - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) vi.mocked(getEffectiveHooks).mockReturnValue(null) vi.mocked(removeWorktree).mockResolvedValue({}) @@ -29310,7 +29763,7 @@ describe('OrcaRuntimeService', () => { localWindowsRuntimeDefault: { kind: 'windows-host' } }) } - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) vi.mocked(getEffectiveHooks).mockReturnValue(null) vi.mocked(listWorktrees).mockResolvedValue([ { @@ -29367,7 +29820,7 @@ describe('OrcaRuntimeService', () => { localWindowsRuntimeDefault: { kind: 'windows-host' } }) } - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES) vi.mocked(listWorktreesStrict).mockRejectedValue(new Error('wsl git list failed')) @@ -29382,7 +29835,7 @@ describe('OrcaRuntimeService', () => { }) it('force-deletes a branch that was preserved by runtime worktree removal', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = createWorktreeRemovalRuntime() vi.mocked(removeWorktree).mockResolvedValue({ preservedBranch: { branchName: 'feature/test', head: 'def456' } }) @@ -29451,7 +29904,7 @@ describe('OrcaRuntimeService', () => { }) } registerSshGitProvider('ssh-1', provider as never) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) try { await runtime.removeManagedWorktree(remoteWorktreeId) @@ -29493,7 +29946,7 @@ describe('OrcaRuntimeService', () => { localWindowsRuntimeDefault: { kind: 'windows-host' } }) } - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) vi.mocked(removeWorktree).mockResolvedValue({ preservedBranch: { branchName: 'feature/test', head: 'def456' } }) @@ -29515,7 +29968,7 @@ describe('OrcaRuntimeService', () => { }) it('rejects stale preserved-branch runtime cleanup actions with an old head', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = createWorktreeRemovalRuntime() vi.mocked(removeWorktree).mockResolvedValue({ preservedBranch: { branchName: 'feature/test', head: 'new456' } }) @@ -29529,7 +29982,7 @@ describe('OrcaRuntimeService', () => { }) it('coalesces concurrent runtime worktree removals for the same worktree id', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = createWorktreeRemovalRuntime() const removeStarted = deferred() const finishRemoval = deferred() vi.mocked(removeWorktree).mockImplementation(async () => { @@ -29550,7 +30003,7 @@ describe('OrcaRuntimeService', () => { }) it('rejects concurrent runtime worktree removals for the same id with different options', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = createWorktreeRemovalRuntime() const removeStarted = deferred() const finishRemoval = deferred() vi.mocked(removeWorktree).mockImplementation(async () => { @@ -29576,7 +30029,7 @@ describe('OrcaRuntimeService', () => { const missingWorktreePath = join(parentDir, 'already-deleted') const worktreeId = `${TEST_REPO_ID}::${missingWorktreePath}` const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) const notifier = { worktreesChanged: vi.fn() } runtime.setNotifier(notifier as never) @@ -29600,7 +30053,7 @@ describe('OrcaRuntimeService', () => { const missingWorktreePath = join(parentDir, 'already-deleted') const worktreeId = `${TEST_REPO_ID}::${missingWorktreePath}` const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) const notifier = { worktreesChanged: vi.fn() } runtime.setNotifier(notifier as never) @@ -29654,7 +30107,7 @@ describe('OrcaRuntimeService', () => { } : undefined } - const runtime = new OrcaRuntimeService(runtimeStoreWithRepoPath as never) + const runtime = createWorktreeRemovalRuntime(runtimeStoreWithRepoPath) const notifier = { worktreesChanged: vi.fn() } runtime.setNotifier(notifier as never) @@ -29664,6 +30117,7 @@ describe('OrcaRuntimeService', () => { await expect(runtime.removeManagedWorktree(worktreeId, true)).resolves.toEqual({}) await expect(lstat(orphanPath)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(closeLocalWatcherForWorktreePathMock).toHaveBeenCalledWith(orphanPath) expect(removeWorktree).not.toHaveBeenCalled() expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) @@ -29708,7 +30162,7 @@ describe('OrcaRuntimeService', () => { } : undefined } - const runtime = new OrcaRuntimeService(runtimeStoreWithRepoPath as never) + const runtime = createWorktreeRemovalRuntime(runtimeStoreWithRepoPath) const notifier = { worktreesChanged: vi.fn() } runtime.setNotifier(notifier as never) const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { @@ -29777,7 +30231,7 @@ describe('OrcaRuntimeService', () => { } : undefined } - const runtime = new OrcaRuntimeService(runtimeStoreWithRepoPath as never) + const runtime = createWorktreeRemovalRuntime(runtimeStoreWithRepoPath) try { vi.mocked(listWorktrees).mockResolvedValue([]) @@ -29838,7 +30292,7 @@ describe('OrcaRuntimeService', () => { ]) } registerSshGitProvider(repo.connectionId, gitProvider as never) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) try { await expect(runtime.removeManagedWorktree(`id:${worktreeId}`, true)).rejects.toThrow( @@ -29858,7 +30312,7 @@ describe('OrcaRuntimeService', () => { const existingWorktreePath = await mkdtemp(join(tmpdir(), 'orca-runtime-remove-existing-')) const worktreeId = `${TEST_REPO_ID}::${existingWorktreePath}` const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) try { vi.mocked(listWorktrees).mockResolvedValue([]) @@ -29875,7 +30329,7 @@ describe('OrcaRuntimeService', () => { }) it('rejects CLI worktree removal when the target contains another registered worktree', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = createWorktreeRemovalRuntime() vi.mocked(listWorktrees).mockResolvedValue([ { path: TEST_REPO_PATH, @@ -29938,7 +30392,7 @@ describe('OrcaRuntimeService', () => { }) it('fails dirty non-force deletes before PTY teardown', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = createWorktreeRemovalRuntime() const killSpy = vi.fn().mockReturnValue(true) runtime.setPtyController({ write: () => true, @@ -29957,6 +30411,8 @@ describe('OrcaRuntimeService', () => { `Failed to delete worktree at ${TEST_WORKTREE_PATH}. ?? scratch.txt` ) + expect(closeLocalWatcherForWorktreePathMock).not.toHaveBeenCalled() + expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled() expect(killSpy).not.toHaveBeenCalled() expect(removeWorktree).not.toHaveBeenCalled() }) @@ -29968,7 +30424,7 @@ describe('OrcaRuntimeService', () => { getRepos: () => [repo], getRepo: () => repo } - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) const killSpy = vi.fn().mockReturnValue(true) runtime.setPtyController({ write: () => true, @@ -30005,7 +30461,7 @@ describe('OrcaRuntimeService', () => { getRepos: () => [repo], getRepo: () => repo } - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) const killSpy = vi.fn().mockReturnValue(true) runtime.setPtyController({ write: () => true, @@ -30039,7 +30495,7 @@ describe('OrcaRuntimeService', () => { }) it('formats preflight subprocess failures and skips PTY teardown', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = createWorktreeRemovalRuntime() const killSpy = vi.fn().mockReturnValue(true) runtime.setPtyController({ write: () => true, @@ -30063,7 +30519,7 @@ describe('OrcaRuntimeService', () => { }) it('falls through to orphan cleanup when preflight reports missing/non-repo worktree', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = createWorktreeRemovalRuntime() vi.mocked(getEffectiveHooks).mockReturnValue(null) vi.mocked(assertWorktreeCleanForRemoval).mockRejectedValue( Object.assign(new Error('status failed'), { @@ -30090,7 +30546,7 @@ describe('OrcaRuntimeService', () => { }) it('runs archive hooks for CLI worktree removal when hooks are explicitly enabled', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = createWorktreeRemovalRuntime() vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: { archive: 'pnpm worktree:archive' @@ -30119,7 +30575,7 @@ describe('OrcaRuntimeService', () => { }) it('clears optimistic reconcile tokens when a CLI worktree removal succeeds', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = createWorktreeRemovalRuntime() const worktreeBaseStatus = vi.fn() runtime.setNotifier({ worktreesChanged: vi.fn(), @@ -30706,11 +31162,14 @@ describe('OrcaRuntimeService', () => { } } - it('RPC-initiated delete kills matching PTYs before git', async () => { + it('RPC-initiated delete awaits matching PTYs before git', async () => { // Seed the runtime with a live leaf whose worktreeId matches the target. - const killSpy = vi.fn().mockReturnValue(true) - const localProvider = createProviderStub(async () => []) const callOrder: string[] = [] + const stopAndWait = vi.fn(async (id: string) => { + callOrder.push(`stop-and-wait:${id}`) + return true + }) + const localProvider = createProviderStub(async () => []) vi.mocked(assertWorktreeCleanForRemoval).mockImplementation(async () => { callOrder.push('preflight') }) @@ -30727,26 +31186,44 @@ describe('OrcaRuntimeService', () => { }) runtime.setPtyController({ write: () => true, - kill: (id) => { - callOrder.push(`kill:${id}`) - return killSpy(id) as boolean - }, + kill: vi.fn(() => true), + stopAndWait, getForegroundProcess: async () => null }) syncSinglePty(runtime, 'pty-1') await runtime.removeManagedWorktree(TEST_WORKTREE_ID) - expect(killSpy).toHaveBeenCalledWith('pty-1') + expect(stopAndWait).toHaveBeenCalledWith('pty-1') // The provider-prefix sweep and the git removal must happen AFTER the - // runtime-graph kill. Git removal must NOT happen before any kill. + // runtime-graph physical stop. Git removal must NOT start before it. const preflightIdx = callOrder.indexOf('preflight') - const killIdx = callOrder.indexOf('kill:pty-1') + const stopIdx = callOrder.indexOf('stop-and-wait:pty-1') const gitIdx = callOrder.indexOf('git-removeWorktree') expect(preflightIdx).toBeGreaterThanOrEqual(0) - expect(killIdx).toBeGreaterThan(preflightIdx) - expect(killIdx).toBeGreaterThanOrEqual(0) - expect(gitIdx).toBeGreaterThan(killIdx) + expect(stopIdx).toBeGreaterThan(preflightIdx) + expect(stopIdx).toBeGreaterThanOrEqual(0) + expect(gitIdx).toBeGreaterThan(stopIdx) + }) + + it('does not start Git removal when physical PTY stop cannot be proven', async () => { + const localProvider = createProviderStub(async () => []) + const runtime = new OrcaRuntimeService(store, undefined, { + getLocalProvider: () => localProvider as never + }) + runtime.setPtyController({ + write: () => true, + kill: vi.fn(() => true), + stopAndWait: vi.fn(async () => false), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, 'pty-1') + + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).rejects.toThrow( + 'Failed to physically stop every PTY' + ) + + expect(removeWorktree).not.toHaveBeenCalled() }) it('thunk resolves the installed provider lazily, not at construction time', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 674cf218e2b..40bfc0e5691 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -153,6 +153,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resum import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' import type { SshConnectionState } from '../../shared/ssh-types' +import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' import type { LinearCurrentIssueContextHints, LinearAttachResult, @@ -348,7 +349,6 @@ import { buildHeadlessTabGroupSplit } from './headless-tab-group-split-layout' import { RuntimeEmulatorCommands, setEmulatorBridge } from './orca-runtime-emulator' -import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher' import type { EmulatorBridge } from '../emulator/emulator-bridge' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' @@ -403,7 +403,8 @@ import { addPRReviewComment, addPRReviewCommentReply, listLabels, - listAssignableUsers + listAssignableUsers, + type MainWorkItem } from '../github/client' import type { GitHubPRBranchLookupOptions } from '../github/client' import { resolveGitHubPrStartPoint } from '../github/pr-start-point' @@ -452,6 +453,7 @@ import type { GitLabMRInlineCommentInput, GitLabProjectRef, GitLabWorkItem, + ListWorkItemsResult, MRListState } from '../../shared/types' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' @@ -666,7 +668,11 @@ import { getDefaultVoiceSettings } from '../../shared/constants' import { listRepoWorktrees } from '../repo-worktrees' -import { createWorktreeLinkedPaths, removeWorktreeLinkedPaths } from '../ipc/worktree-symlinks' +import { + createWorktreeLinkedPaths, + findExistingWorktreeSymlinkPaths, + removeWorktreeLinkedPaths +} from '../ipc/worktree-symlinks' import { deleteWorktreeHistoryDir } from '../terminal-history' import { cleanupUnusedWorktreePushTargetRemote, @@ -715,7 +721,15 @@ import { import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' -import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher' +import { + closeLocalWatcherForWorktreePath, + closeRemoteWatcherForWorktreePath, + forgetLocalWatcherRemovalSnapshot, + forgetRemoteWatcherRemovalSnapshot, + restoreLocalWatcherAfterFailedRemoval, + restoreRemoteWatcherAfterFailedRemoval +} from '../ipc/filesystem-watcher' +import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate' import { HeadlessEmulator } from '../daemon/headless-emulator' import { isNativeWindowsConptyPty, @@ -836,6 +850,7 @@ type RuntimeStore = { getGitHubCache: Store['getGitHubCache'] getWorkspaceSession?: Store['getWorkspaceSession'] setWorkspaceSession?: Store['setWorkspaceSession'] + flushOrThrow?: Store['flushOrThrow'] persistPtyBinding?: Store['persistPtyBinding'] getUI?: Store['getUI'] updateUI?: Store['updateUI'] @@ -1222,7 +1237,7 @@ type RuntimePtyController = { serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - ): Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null> + ): Promise<{ data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null> /** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */ serializeProviderBuffer?( ptyId: string, @@ -1232,6 +1247,13 @@ type RuntimePtyController = { // hydration when no renderer is authoritative for this PTY. See // docs/mobile-prefer-renderer-scrollback.md. hasRendererSerializer?(ptyId: string): boolean + getRendererSerializerGeneration?(ptyId: string): number + waitForRendererSerializer?( + ptyId: string, + afterGeneration: number, + timeoutMs?: number, + signal?: AbortSignal + ): Promise getSize?(ptyId: string): { cols: number; rows: number } | null } @@ -1385,6 +1407,7 @@ type RuntimeNotifier = { content: string ): Promise closeTerminal(tabId: string, paneRuntimeId?: number): void + closeTerminalTab?(tabId: string): Promise sleepWorktree(worktreeId: string): void // Why: a phone opening a worktree wakes its slept agents by asking the host // renderer to run its own navigation-free wake (experimental agent sleep); @@ -2229,7 +2252,11 @@ export class OrcaRuntimeService { > >() private driverListeners = new Map void>>() - private subscriptionCleanups = new Map void>() + private subscriptionCleanups = new Map void | Promise>() + private subscriptionCleanupPromises = new Map< + string, + { cleanup: () => void | Promise; promise: Promise } + >() // Why: index of subscriptionIds by per-WebSocket connectionId so the // server can sweep all subscriptions for a closing socket without // touching subscriptions on other live sockets that share the same @@ -2381,6 +2408,15 @@ export class OrcaRuntimeService { // `applyMobileDisplayMode` to pick the active phone-fit viewport. See // docs/mobile-presence-lock.md. private currentDriver = new Map() + private mobileInputFloorClaims = new Map< + string, + { + base: DriverState + generation: number + committedGeneration: number + pending: Map + } + >() private currentBrowserDriver = new Map() // Why: remote (relay/shared-control) desktop viewers of a PTY are keyed by @@ -2531,6 +2567,7 @@ export class OrcaRuntimeService { private removeManagedWorktreeInFlight = new Map() private preservedBranchCleanupByWorktreeId = new Map() private readonly getLocalProviderFn: (() => IPtyProvider) | null + private readonly getSshProviderFn: ((connectionId: string) => IPtyProvider | undefined) | null private readonly onPtyStopped: ((ptyId: string) => void) | null private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null @@ -2558,6 +2595,7 @@ export class OrcaRuntimeService { stats?: StatsCollector, deps?: { getLocalProvider?: () => IPtyProvider + getSshProvider?: (connectionId: string) => IPtyProvider | undefined onPtyStopped?: (ptyId: string) => void onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void @@ -2595,6 +2633,7 @@ export class OrcaRuntimeService { // lazily via thunk so teardown always sees the currently-installed // provider (design §4.3 wire-up). this.getLocalProviderFn = deps?.getLocalProvider ?? null + this.getSshProviderFn = deps?.getSshProvider ?? null this.onPtyStopped = deps?.onPtyStopped ?? null this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null @@ -2619,6 +2658,32 @@ export class OrcaRuntimeService { return this.getLocalProviderFn ? this.getLocalProviderFn() : null } + private async stopPtysForDestructiveWorktreeRemoval( + worktreeId: string, + connectionId?: string + ): Promise { + const provider = connectionId ? this.getSshProviderFn?.(connectionId) : this.getLocalProvider() + if (!provider) { + throw new Error(`PTY provider unavailable for worktree deletion: ${worktreeId}`) + } + const teardownResult = await killAllProcessesForWorktree(worktreeId, { + runtime: this, + localProvider: provider, + onPtyStopped: this.onPtyStopped ?? undefined, + requirePhysicalStop: true, + ...(connectionId ? { includeLocalRegistry: false } : {}) + }) + const total = + teardownResult.runtimeStopped + + teardownResult.providerStopped + + teardownResult.registryStopped + if (total > 0) { + console.info( + `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` + ) + } + } + getStatsSummary(): StatsSummary | null { return this.stats?.getSummary() ?? null } @@ -3254,7 +3319,13 @@ export class OrcaRuntimeService { } if (existing && (existing.ptyId !== ptyId || existing.ptyGeneration !== ptyGeneration)) { - this.invalidateLeafHandle(leafKey) + // Why: mobile can subscribe while the pane is waiting for its first PTY. + // Keep that handle usable after the recovery mount binds it. + const adoptedFirstPty = + existing.ptyId === null && this.adoptFirstPtyForLeafHandle(leafKey, ptyId, ptyGeneration) + if (!adoptedFirstPty) { + this.invalidateLeafHandle(leafKey) + } } } @@ -4250,33 +4321,20 @@ export class OrcaRuntimeService { } } - private removePersistedHeadlessTerminalTab(worktreeId: string, parentTabId: string): void { + private removePersistedHeadlessTerminalTab(worktreeId: string, parentTabId: string): string[] { const session = this.store?.getWorkspaceSession?.() if (!session || !this.store?.setWorkspaceSession) { - return + throw new Error('workspace_session_unavailable') } - const tabs = session.tabsByWorktree[worktreeId] ?? [] - const nextTabs = tabs.filter((tab) => tab.id !== parentTabId) - const nextTabsByWorktree = { - ...session.tabsByWorktree, - [worktreeId]: nextTabs + const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId) + if (result.pinned) { + throw new Error('terminal_tab_pinned') } - const nextLayouts = { ...session.terminalLayoutsByTabId } - delete nextLayouts[parentTabId] - const nextActiveTabId = - session.activeTabIdByWorktree?.[worktreeId] === parentTabId - ? (nextTabs[0]?.id ?? null) - : (session.activeTabIdByWorktree?.[worktreeId] ?? null) - this.store.setWorkspaceSession({ - ...session, - activeTabId: session.activeTabId === parentTabId ? nextActiveTabId : session.activeTabId, - tabsByWorktree: nextTabsByWorktree, - terminalLayoutsByTabId: nextLayouts, - activeTabIdByWorktree: { - ...session.activeTabIdByWorktree, - [worktreeId]: nextActiveTabId - } - }) + if (!result.closed) { + throw new Error('tab_not_found') + } + this.store.setWorkspaceSession(result.session) + return result.ptyIdsToKill } private persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { @@ -4623,8 +4681,19 @@ export class OrcaRuntimeService { throw new Error('tab_not_found') } if (tab.type === 'terminal') { + const parentLeafCount = snapshot!.tabs.filter( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId + ).length + const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 + if (closingWholeParent && this.notifier?.closeTerminalTab) { + // Why: whole-tab close is a lifecycle transaction. The renderer reply + // arrives only after canonical retirement and a forced session flush. + await this.notifier.closeTerminalTab(tab.parentTabId) + return { closed: true } + } if (!this.notifier?.closeTerminal) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + this.store?.flushOrThrow?.() return { closed: true } } // Why: a runtime-owned headless tab whose whole parent is being closed must @@ -4633,13 +4702,10 @@ export class OrcaRuntimeService { // keeps republishing the "closed" tab with a live PTY. Best-effort notify the // renderer too so any adopted pane closes (no dead pane). A single split leaf // (exact id, multi-leaf parent) keeps the per-leaf path so siblings survive. - const parentLeafCount = snapshot!.tabs.filter( - (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId - ).length - const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) this.notifier?.closeTerminal(tab.parentTabId) + this.store?.flushOrThrow?.() return { closed: true } } if (tab.id === tabId) { @@ -4759,24 +4825,39 @@ export class OrcaRuntimeService { tab: RuntimeMobileSessionTerminalTab ): void { const closedParentTabId = tab.parentTabId + const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId) + // Why: local provider ids can be reused after restart, so a dormant + // persisted id is not kill authority. SSH relay ids remain durable exact + // identities even before pane metadata reconnects. + const ptyIdsToKill = new Set(projectedPtyIds.filter((ptyId) => parseAppSshPtyId(ptyId))) + for (const candidate of snapshot.tabs) { + if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { + continue + } + const livePty = this.findPtyForMobileTerminalTab(worktreeId, candidate) + const ptyId = livePty?.ptyId ?? candidate.ptyId + const hasOtherOwner = snapshot.tabs.some( + (other) => + other.type === 'terminal' && + other.parentTabId !== closedParentTabId && + other.ptyId === ptyId + ) + if (ptyId && !hasOtherOwner && (livePty || parseAppSshPtyId(ptyId))) { + // Why: a live serve leaf can exist before its debounced binding reaches + // persistence. Include it from the authoritative snapshot so split + // close cannot leave a provider process behind. + ptyIdsToKill.add(ptyId) + } + } + for (const ptyId of ptyIdsToKill) { + this.ptyController?.kill(ptyId) + } const nextTabs = snapshot.tabs.filter((candidate) => { if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { return true } - const pty = this.findPtyForMobileTerminalTab(worktreeId, candidate) - if (pty?.connected) { - this.ptyController?.kill(pty.ptyId) - } else { - const persistedSshPtyId = this.getPersistedSshPtyIdForMobileTerminalTab(candidate) - if (persistedSshPtyId) { - // Why: close is an explicit deletion. Hydrated SSH PTYs can be known - // only by durable id before reconnect repopulates pane metadata. - this.ptyController?.kill(persistedSshPtyId) - } - } return false }) - this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, @@ -5393,6 +5474,8 @@ export class OrcaRuntimeService { listMobileFiles: RuntimeFileCommands['listMobileFiles'] = this.fileCommands.listMobileFiles.bind( this.fileCommands ) + searchMobileFilePaths: RuntimeFileCommands['searchMobileFilePaths'] = + this.fileCommands.searchMobileFilePaths.bind(this.fileCommands) openMobileFile: RuntimeFileCommands['openMobileFile'] = this.fileCommands.openMobileFile.bind( this.fileCommands ) @@ -5416,6 +5499,91 @@ export class OrcaRuntimeService { this.fileCommands.readFileExplorerDir.bind(this.fileCommands) watchFileExplorer: RuntimeFileCommands['watchFileExplorer'] = this.fileCommands.watchFileExplorer.bind(this.fileCommands) + closeFileWatchersForRemoval = async ( + worktreePath: string, + connectionId?: string + ): Promise => { + const results = await Promise.allSettled([ + connectionId + ? closeRemoteWatcherForWorktreePath(connectionId, worktreePath) + : closeLocalWatcherForWorktreePath(worktreePath), + this.fileCommands.closeFileExplorerWatchersForPath(worktreePath, connectionId) + ]) + const failure = results.find((result): result is PromiseRejectedResult => { + return result.status === 'rejected' + }) + if (failure) { + // Why: restoration must start only after every bounded teardown settles; + // otherwise a late close can stale a just-restored logical subscription. + throw failure.reason + } + } + restoreFileWatchersAfterFailedRemoval = async ( + worktreePath: string, + connectionId?: string + ): Promise => { + await Promise.all([ + connectionId + ? restoreRemoteWatcherAfterFailedRemoval(connectionId, worktreePath) + : restoreLocalWatcherAfterFailedRemoval(worktreePath), + this.fileCommands.restoreFileExplorerWatchersAfterFailedRemoval(worktreePath, connectionId) + ]) + } + forgetFileWatchersAfterRemoval = (worktreePath: string, connectionId?: string): void => { + if (connectionId) { + forgetRemoteWatcherRemovalSnapshot(connectionId, worktreePath) + } else { + forgetLocalWatcherRemovalSnapshot(worktreePath) + } + this.fileCommands.forgetFileExplorerWatchersAfterRemoval(worktreePath, connectionId) + } + acquireFileWatcherRemoval = async ( + worktreePath: string, + connectionId?: string + ): Promise<{ finish(removed: boolean): Promise }> => { + const gate = acquireWatcherRemovalGate(worktreePath, connectionId) + try { + // Why: the first pass aborts desktop setup immediately; the second catches + // any pre-gate runtime install that published after the first snapshot. + await this.closeFileWatchersForRemoval(worktreePath, connectionId) + await gate.ready + await this.closeFileWatchersForRemoval(worktreePath, connectionId) + let finished = false + return { + finish: async (removed) => { + if (finished) { + return + } + finished = true + if (removed) { + this.forgetFileWatchersAfterRemoval(worktreePath, connectionId) + } + gate.release() + if (!removed) { + await this.restoreFileWatchersAfterFailedRemoval(worktreePath, connectionId).catch( + (restoreError: unknown) => { + console.error('[worktrees] failed to restore watchers after removal failed', { + worktreePath, + restoreError + }) + } + ) + } + } + } + } catch (error) { + gate.release() + await this.restoreFileWatchersAfterFailedRemoval(worktreePath, connectionId).catch( + (restoreError: unknown) => { + console.error('[worktrees] failed to restore watchers after removal setup failed', { + worktreePath, + restoreError + }) + } + ) + throw error + } + } readFileExplorerPreview: RuntimeFileCommands['readFileExplorerPreview'] = this.fileCommands.readFileExplorerPreview.bind(this.fileCommands) readFileExplorerChunk: RuntimeFileCommands['readFileExplorerChunk'] = @@ -5713,7 +5881,6 @@ export class OrcaRuntimeService { // `Network: https://local.example.com:3001/`) so the workspace ports // panel can surface them in place of the kernel bind address. advertisedUrlWatcher.ingest(ptyId, data, at) - serveSimStateWatcher.ingestPtyOutput(ptyId, data) // Why: reply ownership is captured per chunk, here at ingestion — the // same module state and tick as the hidden-gate drop sites — and rides // the writeChain link. A mark/setting/subscriber flip before the queued @@ -6828,9 +6995,9 @@ export class OrcaRuntimeService { data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string - seq?: number source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean @@ -6840,6 +7007,10 @@ export class OrcaRuntimeService { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } + hasHeadlessTerminalState(ptyId: string): boolean { + return this.headlessTerminals.has(ptyId) + } + serializeMainTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} @@ -6847,9 +7018,9 @@ export class OrcaRuntimeService { data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string - seq?: number source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean @@ -7285,13 +7456,14 @@ export class OrcaRuntimeService { : rendererSnapshot } - private async serializeRendererTerminalBuffer( + async serializeRendererTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} ): Promise<{ data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string source?: 'renderer' @@ -7304,6 +7476,7 @@ export class OrcaRuntimeService { data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string oscLinks?: TerminalOscLinkRange[] @@ -7613,7 +7786,7 @@ export class OrcaRuntimeService { registerSubscriptionCleanup( subscriptionId: string, - cleanup: () => void, + cleanup: () => void | Promise, connectionId?: string ): void { // Why: mobile clients reconnect frequently (phone lock, network switch). @@ -7622,6 +7795,9 @@ export class OrcaRuntimeService { // listener leaks in dataListeners and duplicates every PTY data event. const existing = this.subscriptionCleanups.get(subscriptionId) if (existing) { + // Why: the stable id is about to belong to a newer connection; detach + // the old owner before its asynchronous cleanup can overlap the rebind. + this.removeSubscriptionConnectionIndex(subscriptionId) this.cleanupSubscription(subscriptionId) } this.subscriptionCleanups.set(subscriptionId, cleanup) @@ -7637,21 +7813,83 @@ export class OrcaRuntimeService { } cleanupSubscription(subscriptionId: string): void { - const cleanup = this.subscriptionCleanups.get(subscriptionId) - if (cleanup) { - this.subscriptionCleanups.delete(subscriptionId) - const connectionId = this.subscriptionConnectionByEntry.get(subscriptionId) - if (connectionId) { - this.subscriptionConnectionByEntry.delete(subscriptionId) - const set = this.subscriptionsByConnection.get(connectionId) - if (set) { - set.delete(subscriptionId) - if (set.size === 0) { - this.subscriptionsByConnection.delete(connectionId) + void this.cleanupSubscriptionAndWait(subscriptionId).catch((error) => { + console.error(`[runtime] subscription cleanup failed for ${subscriptionId}:`, error) + }) + } + + retrySubscriptionCleanupAfter( + subscriptionId: string, + cleanupOwner: () => void | Promise, + gate: Promise + ): void { + const failedGeneration = this.subscriptionCleanupPromises.get(subscriptionId) + void gate.then( + async () => { + await (failedGeneration?.cleanup === cleanupOwner + ? failedGeneration.promise.catch(() => undefined) + : undefined) + while (this.subscriptionCleanups.get(subscriptionId) === cleanupOwner) { + const newerGeneration = this.subscriptionCleanupPromises.get(subscriptionId) + if (newerGeneration?.cleanup === cleanupOwner) { + // Why: a caller may already be retrying this owner; wait for that + // exact generation so a rejected join cannot consume our retry. + await newerGeneration.promise.catch(() => undefined) + continue } + this.cleanupSubscription(subscriptionId) + return + } + }, + () => undefined + ) + } + + async cleanupSubscriptionAndWait(subscriptionId: string): Promise { + const cleanup = this.subscriptionCleanups.get(subscriptionId) + if (!cleanup) { + return + } + const inFlight = this.subscriptionCleanupPromises.get(subscriptionId) + if (inFlight?.cleanup === cleanup) { + return inFlight.promise + } + let cleanupResult: void | Promise + try { + cleanupResult = cleanup() + } catch (error) { + cleanupResult = Promise.reject(error) + } + const promise = Promise.resolve(cleanupResult) + .then(() => { + // Why: a reconnect can replace this id while old async cleanup runs; + // only the generation that registered this callback may remove it. + if (this.subscriptionCleanups.get(subscriptionId) !== cleanup) { + return + } + this.subscriptionCleanups.delete(subscriptionId) + this.removeSubscriptionConnectionIndex(subscriptionId) + }) + .finally(() => { + if (this.subscriptionCleanupPromises.get(subscriptionId)?.promise === promise) { + this.subscriptionCleanupPromises.delete(subscriptionId) + } + }) + this.subscriptionCleanupPromises.set(subscriptionId, { cleanup, promise }) + return promise + } + + private removeSubscriptionConnectionIndex(subscriptionId: string): void { + const connectionId = this.subscriptionConnectionByEntry.get(subscriptionId) + if (connectionId) { + this.subscriptionConnectionByEntry.delete(subscriptionId) + const set = this.subscriptionsByConnection.get(connectionId) + if (set) { + set.delete(subscriptionId) + if (set.size === 0) { + this.subscriptionsByConnection.delete(connectionId) } } - cleanup() } } @@ -7675,8 +7913,15 @@ export class OrcaRuntimeService { // mutates both the set and the index map. const ids = Array.from(set) for (const id of ids) { + if (this.subscriptionConnectionByEntry.get(id) !== connectionId) { + set.delete(id) + continue + } this.cleanupSubscription(id) } + if (set.size === 0) { + this.subscriptionsByConnection.delete(connectionId) + } } // Why: mobile clients subscribe via notifications.subscribe streaming RPC. @@ -8458,7 +8703,6 @@ export class OrcaRuntimeService { onPtyExit(ptyId: string, exitCode: number): void { advertisedUrlWatcher.unbindPty(ptyId) - serveSimStateWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) this.remoteTerminalViewSubscriberCounts.delete(ptyId) @@ -8909,18 +9153,108 @@ export class OrcaRuntimeService { this.setDriver(ptyId, { kind: 'mobile', clientId }) } + beginMobileInputFloor( + ptyId: string, + clientId: string + ): { commit: () => Promise; rollback: () => void } | null { + // Why: admit a client still inside its soft-leave grace (mirrors + // mobileTookFloor) so a write landing in that window reserves the floor + // instead of being dropped; post-grace/orphaned writers stay rejected. + const softLeaver = this.pendingSoftLeavers.get(ptyId) + if (!this.mobileSubscribers.get(ptyId)?.has(clientId) && softLeaver?.clientId !== clientId) { + return null + } + const state = this.mobileInputFloorClaims.get(ptyId) ?? { + base: this.getDriver(ptyId), + generation: 0, + committedGeneration: 0, + pending: new Map() + } + this.mobileInputFloorClaims.set(ptyId, state) + const token = Symbol('mobile-input-floor') + const generation = ++state.generation + state.pending.set(token, { clientId, generation }) + this.setDriver(ptyId, { kind: 'mobile', clientId }) + let settled = false + return { + commit: async () => { + if (settled) { + return + } + settled = true + state.pending.delete(token) + // Why: a newer accepted write owns the floor; an older claim that was + // delayed before commit must not replace its rollback baseline or driver. + if (generation < state.committedGeneration) { + if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { + this.mobileInputFloorClaims.delete(ptyId) + } + return + } + const previousFloor = state.base + // Why: a successful write becomes the rollback baseline for any + // overlapping reservations that have not reached the PTY yet. + state.committedGeneration = generation + state.base = { kind: 'mobile', clientId } + await this.mobileTookFloor( + ptyId, + clientId, + previousFloor, + () => + this.mobileInputFloorClaims.get(ptyId) === state && + state.committedGeneration === generation + ) + if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { + this.mobileInputFloorClaims.delete(ptyId) + } + }, + rollback: () => { + if (settled) { + return + } + settled = true + state.pending.delete(token) + if (this.mobileInputFloorClaims.get(ptyId) !== state) { + return + } + const current = this.getDriver(ptyId) + if (current.kind === 'mobile' && current.clientId === clientId) { + const pendingClientId = Array.from(state.pending.values()).at(-1)?.clientId + this.setDriver( + ptyId, + pendingClientId ? { kind: 'mobile', clientId: pendingClientId } : state.base + ) + } + if (state.pending.size === 0) { + this.mobileInputFloorClaims.delete(ptyId) + } + } + } + } + // Why: invoked from mobile RPC method handlers (terminal.send / setDisplayMode / // resizeForClient / fresh subscribe with auto). Records the actor as the // most recent mobile driver and re-applies phone-fit if we were previously // in `desktop` mode (mobile reclaims a take-back). Mobile-to-mobile hand-offs // are no-ops for resize. - async mobileTookFloor(ptyId: string, clientId: string): Promise { + async mobileTookFloor( + ptyId: string, + clientId: string, + previousFloor?: DriverState, + isCurrent: () => boolean = () => true + ): Promise { const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) + const softLeaver = this.pendingSoftLeavers.get(ptyId) + // Why: native chat pauses terminal output, so its later sends have no + // subscriber lifecycle that could release a newly-created desktop lock. + if (!sub && softLeaver?.clientId !== clientId) { + return + } if (sub) { sub.lastActedAt = Date.now() } - const prev = this.getDriver(ptyId) + const prev = previousFloor ?? this.getDriver(ptyId) const currentMode = this.mobileDisplayModes.get(ptyId) // Why: a deliberate mobile action implies mobile is resuming control. // If the display mode is currently 'desktop' (set by an earlier @@ -8932,6 +9266,11 @@ export class OrcaRuntimeService { } await this.applyMobileDisplayMode(ptyId) } + // Why: display changes are async; a later PTY write must keep the floor + // when an older phone-fit operation eventually completes. + if (!isCurrent()) { + return + } this.setDriver(ptyId, { kind: 'mobile', clientId }) } @@ -10583,6 +10922,8 @@ export class OrcaRuntimeService { }, options: { beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string } = {} ): Promise { @@ -10995,6 +11336,8 @@ export class OrcaRuntimeService { payload: string, options: { beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string } = {} ): Promise { @@ -11012,6 +11355,7 @@ export class OrcaRuntimeService { } try { await options.beforeWrite?.(ptyId) + options.reserveWrite?.(ptyId) } catch (error) { if (options.suffixFailureError) { throw new Error(options.suffixFailureError) @@ -11022,6 +11366,7 @@ export class OrcaRuntimeService { if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } + await options.afterWrite?.(ptyId) return } if (hasText) { @@ -11029,10 +11374,12 @@ export class OrcaRuntimeService { } await options.beforeWrite?.(ptyId) + options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, payload) ?? false if (!wrote) { throw new Error('terminal_not_writable') } + await options.afterWrite?.(ptyId) } private async writeTerminalInputChunks( @@ -11040,16 +11387,20 @@ export class OrcaRuntimeService { text: string, options: { beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise } = {} ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() while (!chunk.done) { await options.beforeWrite?.(ptyId) + options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false if (!wrote) { throw new Error('terminal_not_writable') } + await options.afterWrite?.(ptyId) chunk = chunks.next() if (!chunk.done) { await new Promise((resolve) => setTimeout(resolve, 0)) @@ -12983,15 +13334,15 @@ export class OrcaRuntimeService { repoSelector: string, limit?: number, query?: string, - before?: string, + page?: number, noCache?: boolean - ): Promise>> { + ): Promise> { const repo = await this.resolveRepoSelector(repoSelector) return listWorkItems( repo.path, limit, query, - before, + page, repo.issueSourcePreference, repo.connectionId ?? null, noCache, @@ -17254,7 +17605,6 @@ export class OrcaRuntimeService { // purge history and process-local caches before the ID points at new state. store.removeWorktreeMeta(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) - serveSimStateWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) this.closeHeadlessBrowserPagesForWorktree(worktreeId) } @@ -17485,7 +17835,18 @@ export class OrcaRuntimeService { throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) } if (repo.connectionId) { - await fsProvider!.deletePath(removalTarget.path, true) + const removalGate = await this.acquireFileWatcherRemoval( + removalTarget.path, + repo.connectionId + ) + let removalCompleted = false + try { + await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, repo.connectionId) + await fsProvider!.deletePath(removalTarget.path, true) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } await cleanupUnusedWorktreePushTargetRemoteSsh( provider!, repo.path, @@ -17494,7 +17855,15 @@ export class OrcaRuntimeService { store ) } else { - await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) + const removalGate = await this.acquireFileWatcherRemoval(removalTarget.path) + let removalCompleted = false + try { + await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id) + await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, @@ -17532,10 +17901,15 @@ export class OrcaRuntimeService { if (!force) { throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) } - await closeLocalWatcherForWorktreePath(removalTarget.path).catch((err) => { - console.warn(`[filesystem-watcher] failed to close ${removalTarget.path}:`, err) - }) - await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) + const removalGate = await this.acquireFileWatcherRemoval(removalTarget.path) + let removalCompleted = false + try { + await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id) + await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, @@ -17636,9 +18010,21 @@ export class OrcaRuntimeService { } if (repo.connectionId) { const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} - const rawRemovalResult = await (Object.keys(remoteRemoveOptions).length > 0 - ? provider!.removeWorktree(canonicalWorktreePath, force, remoteRemoveOptions) - : provider!.removeWorktree(canonicalWorktreePath, force)) + const removalGate = await this.acquireFileWatcherRemoval( + canonicalWorktreePath, + repo.connectionId + ) + let rawRemovalResult: RemoveWorktreeResult | undefined + let removalCompleted = false + try { + await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, repo.connectionId) + rawRemovalResult = await (Object.keys(remoteRemoveOptions).length > 0 + ? provider!.removeWorktree(canonicalWorktreePath, force, remoteRemoveOptions) + : provider!.removeWorktree(canonicalWorktreePath, force)) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } const removalResult = this.preserveBranchHeadFallback( rawRemovalResult, registeredWorktree.head @@ -17704,133 +18090,122 @@ export class OrcaRuntimeService { throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) } - let shouldTearDownPtys = true - if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { - await removeWorktreeLinkedPaths(canonicalWorktreePath, repo.symlinkPaths) - } + const linkedPaths = repo.symlinkPaths ?? [] + const ignoredLinkedPaths = force + ? [] + : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) try { await (hasLocalWorktreeGitOptions - ? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, localWorktreeGitOptions) - : assertWorktreeCleanForRemoval(canonicalWorktreePath, force)) + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, { + ...localWorktreeGitOptions, + ...(ignoredLinkedPaths.length > 0 + ? { ignoredUntrackedPaths: ignoredLinkedPaths } + : {}) + }) + : ignoredLinkedPaths.length > 0 + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, { + ignoredUntrackedPaths: ignoredLinkedPaths + }) + : assertWorktreeCleanForRemoval(canonicalWorktreePath, force)) } catch (error) { if (!isOrphanCompatiblePreflightError(error)) { throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) } - // Why: orphan cleanup does not need live shells to be killed first, - // and preflight did not prove the worktree is cleanly removable. - shouldTearDownPtys = false - } - - const localProvider = this.getLocalProvider() - await closeLocalWatcherForWorktreePath(canonicalWorktreePath).catch((err) => { - console.warn(`[filesystem-watcher] failed to close ${canonicalWorktreePath}:`, err) - }) - if (localProvider && shouldTearDownPtys) { - // Why: once preflight proves normal deletion is clean, kill PTYs before - // git-level removal so Windows handles cannot keep the directory busy. This also - // closes the headless-CLI leak for confirmed-removable worktrees. - await killAllProcessesForWorktree(removalTarget.id, { - runtime: this, - localProvider, - onPtyStopped: this.onPtyStopped ?? undefined - }) - .then((r) => { - const total = r.runtimeStopped + r.providerStopped + r.registryStopped - if (total > 0) { - // Why (design §4.4 observability): breadcrumb lets ops - // distinguish a renderer-state-induced leak (diff-path purge - // non-empty) from a backend-induced one (nothing to kill but - // memory still pinned). Emit only when the sweep actually did - // work so steady-state logs stay quiet. - console.info( - `[worktree-teardown] ${removalTarget.id} killed runtime=${r.runtimeStopped} provider=${r.providerStopped} registry=${r.registryStopped}` - ) - } - }) - .catch((err) => { - console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) - }) + // Why: Git can still classify this as an orphan after preflight; + // retain strict PTY teardown before any recursive fallback deletion. } let removalResult: RemoveWorktreeResult | undefined + const removalGate = await this.acquireFileWatcherRemoval(canonicalWorktreePath) + let removalCompleted = false try { - const removeOptions = { - ...(!deleteBranch ? { deleteBranch } : {}), - // Why: removal already validated the Git row under the selected - // project runtime; keep branch cleanup on that same canonical row. - knownRemovedWorktree: refreshedRegisteredWorktree, - ...localWorktreeGitOptions + // Why: linked-path deletion is destructive too; PTYs must release every + // handle before Windows or WSL filesystem cleanup starts. + await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id) + + if (linkedPaths.length > 0) { + await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths) } - removalResult = this.preserveBranchHeadFallback( - await removeWorktree(repo.path, canonicalWorktreePath, force, removeOptions), - refreshedRegisteredWorktree.head - ) - } catch (error) { - // Why: Git for Windows can deregister a clean worktree before its - // recursive filesystem deletion fails transiently. - const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ - error, - force, - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree: refreshedRegisteredWorktree, - deleteBranch, - closeWatcher: (worktreePath) => - closeLocalWatcherForWorktreePath(worktreePath).catch((err) => { - console.warn(`[filesystem-watcher] failed to close ${worktreePath}:`, err) - }) - }) - if (recoveredRemovalResult) { - removalResult = recoveredRemovalResult - } else if (isOrphanedWorktreeError(error)) { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - if ( - await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - ) - ) { - await closeLocalWatcherForWorktreePath(canonicalWorktreePath).catch((err) => { - console.warn(`[filesystem-watcher] failed to close ${canonicalWorktreePath}:`, err) - }) - await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( - () => {} - ) - } else { - console.warn( - `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` - ) - } - // Why: `git worktree remove` failed, so git's internal worktree tracking - // (`.git/worktrees/`) is still intact. Without pruning, `git worktree - // list` continues to show the stale entry and the branch it had checked out - // remains locked — other worktrees cannot check it out. - await gitExecFileAsync(['worktree', 'prune'], { - cwd: repo.path, + + try { + const removeOptions = { + ...(!deleteBranch ? { deleteBranch } : {}), + // Why: removal already validated the Git row under the selected + // project runtime; keep branch cleanup on that same canonical row. + knownRemovedWorktree: refreshedRegisteredWorktree, ...localWorktreeGitOptions - }).catch(() => {}) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - removalTarget.id, - removedPushTarget, - store, - localWorktreeGitOptions - ) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) - this.invalidateResolvedWorktreeCache() - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - return { - ...(warning ? { warning } : {}) } - } else { - throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) + removalResult = this.preserveBranchHeadFallback( + await removeWorktree(repo.path, canonicalWorktreePath, force, removeOptions), + refreshedRegisteredWorktree.head + ) + } catch (error) { + // Why: Git for Windows can deregister a clean worktree before its + // recursive filesystem deletion fails transiently. + const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ + error, + force, + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree: refreshedRegisteredWorktree, + deleteBranch, + closeWatcher: (worktreePath) => this.closeFileWatchersForRemoval(worktreePath) + }) + if (recoveredRemovalResult) { + removalResult = recoveredRemovalResult + removalCompleted = true + } else if (isOrphanedWorktreeError(error)) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + if ( + await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + ) + ) { + await this.closeFileWatchersForRemoval(canonicalWorktreePath) + await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( + () => {} + ) + } else { + console.warn( + `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` + ) + } + // Why: `git worktree remove` failed, so git's internal worktree tracking + // (`.git/worktrees/`) is still intact. Without pruning, `git worktree + // list` continues to show the stale entry and the branch it had checked out + // remains locked — other worktrees cannot check it out. + await gitExecFileAsync(['worktree', 'prune'], { + cwd: repo.path, + ...localWorktreeGitOptions + }).catch(() => {}) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + removalTarget.id, + removedPushTarget, + store, + localWorktreeGitOptions + ) + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.invalidateResolvedWorktreeCache() + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(repo.id) + removalCompleted = true + return { + ...(warning ? { warning } : {}) + } + } else { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) + } } + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) } await cleanupUnusedWorktreePushTargetRemote( @@ -18990,6 +19365,85 @@ export class OrcaRuntimeService { }) } + // Why: never-mounted tabs have no attached PTY or mobile snapshot; synthetic + // handles need the ptyId so the renderer can mount the exact owning tab. + requestRendererTerminalTabMount(handle: string): boolean { + const record = this.handles.get(handle) + if (!record?.worktreeId) { + return false + } + const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId + const ptyId = record.ptyId ?? undefined + if (!tabId && !ptyId) { + return false + } + try { + this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', { + worktreeId: record.worktreeId, + ...(tabId ? { tabId } : {}), + ...(ptyId ? { ptyId } : {}) + }) + return true + } catch { + // No authoritative window (shutdown/headless): the subscribe keeps its + // existing empty-snapshot fallback. + return false + } + } + + getRendererTerminalSerializerGeneration(ptyId: string): number { + return this.ptyController?.getRendererSerializerGeneration?.(ptyId) ?? 0 + } + + getRendererTerminalSerializerGenerationForHandle(handle: string): number { + const ptyId = this.handles.get(handle)?.ptyId + return ptyId ? this.getRendererTerminalSerializerGeneration(ptyId) : 0 + } + + replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId: string, + snapshot: { + data: string + cols: number + rows: number + cwd?: string | null + oscLinks?: TerminalOscLinkRange[] + }, + trailingOutput: { data: string; seq: number }[] = [] + ): void { + if (!snapshot.data) { + return + } + // Why: a redraw byte can create a suffix-only model before the restored + // renderer settles. Replace it with the exact snapshot already sent mobile. + this.providerSnapshotPreferredPtys.add(ptyId) + this.disposeHeadlessTerminal(ptyId) + this.seedHeadlessTerminal( + ptyId, + snapshot.data, + { cols: snapshot.cols, rows: snapshot.rows }, + { cwd: snapshot.cwd, oscLinks: snapshot.oscLinks } + ) + for (const chunk of trailingOutput) { + this.trackHeadlessTerminalData(ptyId, chunk.data, chunk.seq) + } + // The seed's write chain already owns subsequent live bytes; suppress the + // ordinary on-data hydration path from replacing this known-good seed. + this.headlessHydrationState.set(ptyId, 'done') + } + + waitForRendererTerminalSerializer( + ptyId: string, + afterGeneration: number, + timeoutMs?: number, + signal?: AbortSignal + ): Promise { + return ( + this.ptyController?.waitForRendererSerializer?.(ptyId, afterGeneration, timeoutMs, signal) ?? + Promise.resolve(false) + ) + } + // Why: a leaf appears in the graph before its PTY spawns. If we issue a // handle while ptyId is null, the next graph sync after PTY spawn will // change ptyId and invalidate the handle. Wait for a connected PTY so @@ -19070,6 +19524,24 @@ export class OrcaRuntimeService { return { handle, tabId: leaf.tabId, ptyKilled } } + async closeTerminalTab(handle: string): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + const tabId = pty.pty.tabId + if (!tabId) { + throw new Error('terminal_tab_not_found') + } + await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return { handle, tabId, closeMode: 'tab', ptyKilled: false } + } + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return { handle, tabId: leaf.tabId, closeMode: 'tab', ptyKilled: false } + } + async splitTerminal( handle: string, opts: { @@ -19281,12 +19753,24 @@ export class OrcaRuntimeService { }) } - async stopTerminalsForWorktree(worktreeSelector: string): Promise<{ stopped: number }> { + async stopTerminalsForWorktree( + worktreeSelector: string, + options: { + deadline?: number + stopPty?: ( + ptyId: string, + stop: () => boolean | Promise + ) => Promise<{ stopped: boolean; owner: boolean }> + } = {} + ): Promise<{ stopped: number }> { // Why: this mutates live PTYs, so the runtime must reject it while the // renderer graph is reloading instead of acting on cached leaf ownership. const graphEpoch = this.captureReadyGraphEpoch() const worktree = await this.resolveWorktreeSelector(worktreeSelector) this.assertStableReadyGraph(graphEpoch) + if (options.deadline !== undefined && Date.now() >= options.deadline) { + return { stopped: 0 } + } const ptyIds = new Set() for (const leaf of this.leaves.values()) { if (leaf.worktreeId === worktree.id && leaf.ptyId) { @@ -19301,7 +19785,24 @@ export class OrcaRuntimeService { let stopped = 0 for (const ptyId of ptyIds) { - if (this.ptyController?.kill(ptyId)) { + if (options.deadline !== undefined && Date.now() >= options.deadline) { + break + } + const stop = (): boolean | Promise => { + if (options.deadline !== undefined && Date.now() >= options.deadline) { + return false + } + if (options.stopPty) { + // Why: destructive worktree cleanup must not let its cross-surface + // dedupe treat fire-and-forget controller.kill as physical exit. + return this.ptyController?.stopAndWait?.(ptyId) ?? false + } + return Boolean(this.ptyController?.kill(ptyId)) + } + const stopResult = options.stopPty + ? await options.stopPty(ptyId, stop) + : { stopped: stop(), owner: true } + if (stopResult.owner && stopResult.stopped) { stopped += 1 } } @@ -20579,7 +21080,6 @@ export class OrcaRuntimeService { // Why: restored/controller-discovered PTYs learn their worktree here // without registerPty(), so URL enrichment must bind at this source. advertisedUrlWatcher.bindPty(ptyId, worktreeId) - serveSimStateWatcher.bindPty(ptyId, worktreeId) return pty } @@ -20615,7 +21115,6 @@ export class OrcaRuntimeService { // Why: recordPtyWorktree is the common lifecycle point for every path that // resolves a PTY's worktree, including renderer restore and controller list. advertisedUrlWatcher.bindPty(ptyId, worktreeId) - serveSimStateWatcher.bindPty(ptyId, worktreeId) return pty } @@ -20728,8 +21227,6 @@ export class OrcaRuntimeService { } private dropDisconnectedPtyRecord(ptyId: string): void { - // Why: pruning can remove a PTY without the normal exit callback. - serveSimStateWatcher.unbindPty(ptyId) this.ptysById.delete(ptyId) this.recentPtyOutputById.delete(ptyId) this.clearWaitBlockedCheckState(ptyId) @@ -21551,13 +22048,6 @@ export class OrcaRuntimeService { return null } - private getPersistedSshPtyIdForMobileTerminalTab( - tab: RuntimeMobileSessionTerminalTab - ): string | null { - const ptyId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? null - return ptyId && parseAppSshPtyId(ptyId) ? ptyId : null - } - private getMobileTerminalPaneKey(tab: RuntimeMobileSessionTerminalTab): string { if (isTerminalLeafId(tab.leafId)) { return makePaneKey(tab.parentTabId, tab.leafId) @@ -22259,6 +22749,20 @@ export class OrcaRuntimeService { this.rejectWaitersForHandle(handle, 'terminal_handle_stale') } + private adoptFirstPtyForLeafHandle( + leafKey: string, + ptyId: string | null, + ptyGeneration: number + ): boolean { + const handle = this.handleByLeafKey.get(leafKey) + const record = handle ? this.handles.get(handle) : null + if (!handle || !record || record.ptyId !== null || ptyId === null) { + return false + } + this.handles.set(handle, { ...record, ptyId, ptyGeneration }) + return true + } + private rememberDetachedPreAllocatedLeaves(): void { for (const leaf of this.leaves.values()) { if (leaf.ptyId && this.handleByPtyId.has(leaf.ptyId)) { @@ -25214,18 +25718,6 @@ export class OrcaRuntimeService { emulatorUnregisterActive: RuntimeEmulatorCommands['emulatorUnregisterActive'] = this.emulatorCommands.emulatorUnregisterActive.bind(this.emulatorCommands) - // Why: serve-sim-state-watcher runs from main/index.ts startup; keep window IPC behind runtime (getAuthoritativeWindow is private). - notifyEmulatorAutoAttachFromWatcher( - worktreeId: string, - info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } - ): void { - try { - this.getAuthoritativeWindow().webContents.send('ui:emulatorAutoAttach', { worktreeId, info }) - } catch { - // Window may not exist during shutdown - } - } - private getAuthoritativeWindow(): BrowserWindow { const win = this.getAvailableAuthoritativeWindow() if (!win || win.isDestroyed()) { diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index a14dc809c54..b1670590a9c 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -9,6 +9,16 @@ class LineageError extends Error { } describe('mapRuntimeError', () => { + it.each(['terminal_tab_close_timeout', 'terminal_tab_not_found', 'terminal_tab_pinned'])( + 'preserves the durable terminal tab close failure %s', + (code) => { + expect(mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, new Error(code))).toMatchObject({ + ok: false, + error: { code, message: code } + }) + } + ) + it.each([ ['window_not_focused', 'keyboard input requires focus', 'restore-window'], ['permission_denied', 'missing DBUS_SESSION_BUS_ADDRESS', 'permissions'], diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index bb800b54617..15c592499f6 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -43,6 +43,9 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'terminal_not_writable', 'terminal_exited', 'terminal_gone', + 'terminal_tab_close_timeout', + 'terminal_tab_not_found', + 'terminal_tab_pinned', 'no_active_terminal', 'repo_not_found', 'timeout', diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 5c1823a4e8e..b5adcf617b4 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -191,6 +191,9 @@ export const UiUpdate = z workspaceHostScope: z.string().optional(), visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), workspaceHostOrder: z.array(z.string()).optional(), + manualRepoOrder: z + .array(z.object({ hostId: z.string(), repoId: z.string() }).strict()) + .optional(), hideDefaultBranchWorkspace: z.boolean().optional(), hideAutomationGeneratedWorkspaces: z.boolean().optional(), filterRepoIds: StringArray.optional(), diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index a3f3dbe88c2..bf55cfb07e7 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -269,7 +269,8 @@ describe('client UI RPC methods', () => { contextualToursSeenIds: ['tasks'], contextualToursAutoEligible: true, usageEmptyStateDismissed: true, - browserDefaultZoomLevel: 1.5 + browserDefaultZoomLevel: 1.5, + manualRepoOrder: [{ hostId: 'runtime:node-b', repoId: 'repo-b' }] } const runtime = { getRuntimeId: () => 'test-runtime', @@ -310,7 +311,8 @@ describe('client UI RPC methods', () => { contextualToursSeenIds: ['tasks'], contextualToursAutoEligible: true, usageEmptyStateDismissed: true, - browserDefaultZoomLevel: 1.5 + browserDefaultZoomLevel: 1.5, + manualRepoOrder: [{ hostId: 'runtime:node-b', repoId: 'repo-b' }] } const response = await dispatcher.dispatch(makeRequest('ui.set', payload)) diff --git a/src/main/runtime/rpc/methods/file-watch-stream-lifecycle.ts b/src/main/runtime/rpc/methods/file-watch-stream-lifecycle.ts new file mode 100644 index 00000000000..f612f27dc0d --- /dev/null +++ b/src/main/runtime/rpc/methods/file-watch-stream-lifecycle.ts @@ -0,0 +1,146 @@ +import { isWatcherProcessFailure } from '../../../ipc/parcel-watcher-process-failure' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { createFileWatchEventBatcher } from './file-watch-event-batcher' + +export async function runFileWatchStream(args: { + runtime: OrcaRuntimeService + worktree: string + connectionId?: string + signal?: AbortSignal + subscriptionId: string + emit: (event: unknown) => void +}): Promise { + if (args.signal?.aborted) { + return + } + await new Promise((resolve, reject) => { + let settled = false + let setupFailed = false + let watchReady = false + let unwatch: (() => void) | null = null + let terminalError: Error | null = null + let setupPromise: Promise<() => void> | null = null + let cleanupPromise: Promise | null = null + let logicalCleanupStarted = false + let endEmitted = false + const setupAbortController = new AbortController() + const eventBatcher = createFileWatchEventBatcher(args.worktree, args.emit) + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + args.signal?.removeEventListener('abort', handleAbort) + callback() + } + const cleanup = (): Promise => { + if (cleanupPromise) { + return cleanupPromise + } + const attempt = (async () => { + if (!logicalCleanupStarted) { + logicalCleanupStarted = true + // Mark cleanup complete before emitting so a synchronous transport + // abort cannot re-enter this function and duplicate error/end. + if (!setupFailed) { + settle(resolve) + } + setupAbortController.abort() + 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() + args.emit({ type: 'error', message: terminalError.message }) + } else if (watchReady) { + eventBatcher.flush() + eventBatcher.dispose() + } else { + // Why: setup can queue events before it returns an unsubscribe; + // cancellation before ready must not publish that partial crawl. + eventBatcher.dispose() + } + } + try { + if (!unwatch && setupPromise) { + try { + unwatch = await setupPromise + } catch (error) { + if (!setupAbortController.signal.aborted) { + throw error + } + } + } + await unwatch?.() + } catch (error) { + if (isWatcherProcessFailure(error) && error.physicalExit) { + args.runtime.retrySubscriptionCleanupAfter( + args.subscriptionId, + cleanup, + error.physicalExit + ) + } + throw error + } finally { + if (!setupFailed && !endEmitted) { + endEmitted = true + args.emit({ type: 'end' }) + } + } + })() + cleanupPromise = attempt + void attempt.catch(() => { + if (cleanupPromise === attempt) { + // Why: callers deliberately retry transient unwatch failures; only + // concurrent callers should share the rejected cleanup generation. + cleanupPromise = null + } + }) + return attempt + } + const handleTerminalError = (error: Error): void => { + if (settled || terminalError) { + return + } + terminalError = error + args.runtime.cleanupSubscription(args.subscriptionId) + } + function handleAbort(): void { + args.runtime.cleanupSubscription(args.subscriptionId) + } + + args.signal?.addEventListener('abort', handleAbort, { once: true }) + // Why: paired clients must be able to cancel while native setup is + // waiting indefinitely for global child capacity, before ready exists. + args.runtime.registerSubscriptionCleanup(args.subscriptionId, cleanup, args.connectionId) + args.emit({ type: 'starting', subscriptionId: args.subscriptionId }) + setupPromise = args.runtime.watchFileExplorer( + args.worktree, + (events) => eventBatcher.push(events), + handleTerminalError, + setupAbortController.signal + ) + void setupPromise + .then((nextUnwatch) => { + if (cleanupPromise || settled) { + return + } + unwatch = nextUnwatch + watchReady = true + args.emit({ type: 'ready', subscriptionId: args.subscriptionId }) + }) + .catch(async (error) => { + if (cleanupPromise || settled || setupAbortController.signal.aborted) { + return + } + setupFailed = true + await args.runtime.cleanupSubscriptionAndWait(args.subscriptionId).catch((cleanupError) => { + console.error('[runtime-files.watch] failed-setup cleanup failed', cleanupError) + }) + settle(() => reject(error)) + }) + if (args.signal?.aborted) { + handleAbort() + } + }) +} diff --git a/src/main/runtime/rpc/methods/files-path-search.test.ts b/src/main/runtime/rpc/methods/files-path-search.test.ts new file mode 100644 index 00000000000..cd47aa8fd7c --- /dev/null +++ b/src/main/runtime/rpc/methods/files-path-search.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { FILE_METHODS } from './files' + +describe('file path search RPC method', () => { + it('returns a bounded server-side result for mobile autocomplete', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + searchMobileFilePaths: vi.fn().mockResolvedValue({ + worktree: 'wt-1', + rootPath: '/repo', + files: [{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }], + totalCount: 1, + truncated: false + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'files.searchPaths', + params: { worktree: 'id:wt-1', query: 'app', limit: 8 } + } + + const response = await dispatcher.dispatch(request) + + expect(runtime.searchMobileFilePaths).toHaveBeenCalledWith('id:wt-1', 'app', 8) + expect(response).toMatchObject({ + ok: true, + result: { files: [{ relativePath: 'src/app.ts' }] } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/files-watch-cancellation.test.ts b/src/main/runtime/rpc/methods/files-watch-cancellation.test.ts new file mode 100644 index 00000000000..067c23aa83a --- /dev/null +++ b/src/main/runtime/rpc/methods/files-watch-cancellation.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { FILE_METHODS } from './files' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('file watch RPC cancellation', () => { + it('cancels a file watch waiting for setup before ready', async () => { + let setupSignal: AbortSignal | undefined + const watchFileExplorer = vi.fn( + ( + _worktree: string, + _callback: (events: unknown[]) => void, + _onTerminalError: (error: Error) => void, + signal?: AbortSignal + ) => { + setupSignal = signal + return new Promise<() => void>((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new Error('setup aborted')), { + once: true + }) + }) + } + ) + const cleanups = new Map void | Promise>() + const cleanupSubscriptionAndWait = vi.fn(async (id: string) => { + const cleanup = cleanups.get(id) + await cleanup?.() + cleanups.delete(id) + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + watchFileExplorer, + registerSubscriptionCleanup: vi.fn((id, cleanup) => cleanups.set(id, cleanup)), + cleanupSubscriptionAndWait, + cleanupSubscription: vi.fn((id) => { + void cleanupSubscriptionAndWait(id) + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const replies: { result?: { type?: string; subscriptionId?: string } }[] = [] + + const stream = dispatcher.dispatchStreaming( + makeRequest('files.watch', { worktree: 'id:wt-1' }), + (response) => replies.push(JSON.parse(response)), + { connectionId: 'conn-1' } + ) + await vi.waitFor(() => expect(replies[0]?.result?.type).toBe('starting')) + const subscriptionId = replies[0]?.result?.subscriptionId + expect(subscriptionId).toBeTruthy() + + const response = await dispatcher.dispatch(makeRequest('files.unwatch', { subscriptionId })) + await stream + + expect(response).toMatchObject({ ok: true, result: { unsubscribed: true } }) + expect(setupSignal?.aborted).toBe(true) + expect(replies.map((reply) => reply.result?.type)).toEqual(['starting', 'end']) + expect(cleanups.size).toBe(0) + }) + + it('drops queued file watch events when aborted before setup resolves', async () => { + vi.useFakeTimers() + try { + 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) => { + callback([{ kind: 'update', absolutePath: '/repo/queued.ts', isDirectory: false }]) + return new Promise<() => void>((resolve) => { + resolveWatch = resolve + }) + }) + const cleanups = new Map void | Promise>() + const runtime = { + getRuntimeId: () => 'test-runtime', + watchFileExplorer, + registerSubscriptionCleanup: vi.fn((id, cleanup) => cleanups.set(id, cleanup)), + cleanupSubscription: vi.fn((id) => { + void Promise.resolve(cleanups.get(id)?.()) + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const abortController = new AbortController() + const replies: unknown[] = [] + + const dispatch = dispatcher.dispatchStreaming( + makeRequest('files.watch', { worktree: 'id:wt-1' }), + (response) => replies.push(JSON.parse(response)), + { connectionId: 'conn-1', signal: abortController.signal } + ) + await vi.waitFor(() => expect(watchFileExplorer).toHaveBeenCalled()) + + abortController.abort() + await dispatch + await vi.runOnlyPendingTimersAsync() + resolveWatch(unwatch) + await vi.waitFor(() => expect(unwatch).toHaveBeenCalled()) + + expect(runtime.registerSubscriptionCleanup).toHaveBeenCalledTimes(1) + expect(replies).toEqual([ + expect.objectContaining({ result: expect.objectContaining({ type: 'starting' }) }), + expect.objectContaining({ result: { type: 'end' } }) + ]) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/rpc/methods/files-watch-cleanup.test.ts b/src/main/runtime/rpc/methods/files-watch-cleanup.test.ts new file mode 100644 index 00000000000..991dc23b732 --- /dev/null +++ b/src/main/runtime/rpc/methods/files-watch-cleanup.test.ts @@ -0,0 +1,104 @@ +import { expect, it, vi } from 'vitest' +import { WatcherProcessFailure } from '../../../ipc/parcel-watcher-process-failure' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { FILE_METHODS } from './files' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +it.each(['late', 'already-resolved', 'transient'] as const)( + 'retries retained file-watch cleanup after %s failure', + async (failureTiming) => { + let resolvePhysicalExit: () => void = () => {} + const physicalExit = + failureTiming === 'already-resolved' + ? Promise.resolve() + : failureTiming === 'late' + ? new Promise((resolve) => { + resolvePhysicalExit = resolve + }) + : undefined + const cleanupError = physicalExit + ? new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable', + physicalExit + ) + : new Error('transient unwatch failure') + const unwatch = vi.fn().mockRejectedValueOnce(cleanupError).mockResolvedValue(undefined) + let cleanup: (() => Promise) | undefined + let subscriptionId = '' + let inFlight: Promise | null = null + const cleanupSubscriptionAndWait = vi.fn(() => { + if (inFlight) { + return inFlight + } + let tracked: Promise + tracked = Promise.resolve(cleanup?.()).finally(() => { + if (inFlight === tracked) { + inFlight = null + } + }) + inFlight = tracked + return tracked + }) + const cleanupSubscription = vi.fn(() => { + void cleanupSubscriptionAndWait().catch(() => {}) + }) + const retrySubscriptionCleanupAfter = vi.fn( + (_id: string, _owner: () => void | Promise, gate: Promise) => { + void gate.then(async () => { + await inFlight?.catch(() => undefined) + cleanupSubscription() + }) + } + ) + const runtime = { + getRuntimeId: () => 'test-runtime', + watchFileExplorer: vi.fn().mockResolvedValue(unwatch), + registerSubscriptionCleanup: vi.fn((id, nextCleanup) => { + subscriptionId = id + cleanup = nextCleanup + }), + cleanupSubscription, + cleanupSubscriptionAndWait, + retrySubscriptionCleanupAfter + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const responses: { result?: { type?: string } }[] = [] + + const dispatch = dispatcher.dispatchStreaming( + makeRequest('files.watch', { worktree: 'id:wt-1' }), + (response) => responses.push(JSON.parse(response)) + ) + await vi.waitFor(() => expect(cleanup).toBeDefined()) + + await expect(cleanupSubscriptionAndWait()).rejects.toBe(cleanupError) + await dispatch + + if (failureTiming === 'late') { + expect(unwatch).toHaveBeenCalledTimes(1) + resolvePhysicalExit() + } else if (failureTiming === 'transient') { + await expect(cleanupSubscriptionAndWait()).resolves.toBeUndefined() + } + await vi.waitFor(() => expect(unwatch).toHaveBeenCalledTimes(2)) + expect(cleanupSubscriptionAndWait).toHaveBeenCalledTimes(2) + if (physicalExit) { + await vi.waitFor(() => expect(cleanupSubscription).toHaveBeenCalledTimes(1)) + expect(retrySubscriptionCleanupAfter).toHaveBeenCalledWith( + subscriptionId, + cleanup, + physicalExit + ) + } else { + expect(cleanupSubscription).not.toHaveBeenCalled() + expect(retrySubscriptionCleanupAfter).not.toHaveBeenCalled() + } + expect(responses.filter((response) => response.result?.type === 'end')).toHaveLength(1) + } +) diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 9f535d9a637..24b415e751b 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -126,15 +126,20 @@ describe('file RPC methods', () => { ) await vi.waitFor(() => { - expect(replies).toHaveLength(1) + expect(replies).toHaveLength(2) }) expect(runtime.watchFileExplorer).toHaveBeenCalledWith( 'id:wt-1', expect.any(Function), expect.any(Function), - undefined + expect.any(AbortSignal) ) expect(replies[0]).toMatchObject({ + ok: true, + streaming: true, + result: { type: 'starting', subscriptionId: expect.stringContaining('files-watch-') } + }) + expect(replies[1]).toMatchObject({ ok: true, streaming: true, result: { type: 'ready', subscriptionId: expect.stringContaining('files-watch-') } @@ -146,11 +151,11 @@ describe('file RPC methods', () => { emitWatchChange?.([ { kind: 'update', absolutePath: '/repo/package.json', isDirectory: false } ]) - expect(replies).toHaveLength(1) + expect(replies).toHaveLength(2) await vi.runOnlyPendingTimersAsync() - expect(replies[1]).toMatchObject({ + expect(replies[2]).toMatchObject({ ok: true, streaming: true, result: { @@ -163,11 +168,11 @@ describe('file RPC methods', () => { } }) - const ready = replies[0] as { result: { subscriptionId: string } } + const ready = replies[1] as { result: { subscriptionId: string } } cleanups.get(ready.result.subscriptionId)?.() await dispatch - expect(replies[2]).toMatchObject({ + expect(replies[3]).toMatchObject({ ok: true, streaming: true, result: { type: 'end' } @@ -205,14 +210,20 @@ describe('file RPC methods', () => { makeRequest('files.watch', { worktree: 'id:wt-1' }), (response) => replies.push(JSON.parse(response)) ) - await vi.waitFor(() => expect(replies[0]?.result?.type).toBe('ready')) + await vi.waitFor(() => expect(replies[1]?.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(replies.map((reply) => reply.result?.type)).toEqual([ + 'starting', + 'ready', + 'changed', + 'error', + 'end' + ]) + expect(replies[3]?.result?.message).toContain('crashed repeatedly') expect(unwatch).toHaveBeenCalledTimes(1) expect(cleanups.size).toBe(0) }) @@ -234,10 +245,14 @@ describe('file RPC methods', () => { resolveWatch = resolve }) ) + const cleanups = new Map void | Promise>() const runtime = { getRuntimeId: () => 'test-runtime', watchFileExplorer, - registerSubscriptionCleanup: vi.fn() + registerSubscriptionCleanup: vi.fn((id, cleanup) => cleanups.set(id, cleanup)), + cleanupSubscription: vi.fn((id) => { + void Promise.resolve(cleanups.get(id)?.()) + }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) const abortController = new AbortController() @@ -251,63 +266,39 @@ describe('file RPC methods', () => { await vi.waitFor(() => { expect(watchFileExplorer).toHaveBeenCalled() }) - expect(watchFileExplorer.mock.calls[0]?.[3]).toBe(abortController.signal) + const setupSignal = watchFileExplorer.mock.calls[0]?.[3] + expect(setupSignal).not.toBe(abortController.signal) abortController.abort() + expect(setupSignal?.aborted).toBe(true) await dispatch resolveWatch(unwatch) await vi.waitFor(() => { expect(unwatch).toHaveBeenCalled() }) - expect(runtime.registerSubscriptionCleanup).not.toHaveBeenCalled() - expect(replies).toEqual([]) + expect(runtime.registerSubscriptionCleanup).toHaveBeenCalledTimes(1) + expect(replies).toEqual([ + expect.objectContaining({ result: expect.objectContaining({ type: 'starting' }) }), + expect.objectContaining({ result: { type: 'end' } }) + ]) }) - it('drops queued file watch events when aborted before setup resolves', async () => { - vi.useFakeTimers() - try { - 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) => { - callback([{ kind: 'update', absolutePath: '/repo/queued.ts', isDirectory: false }]) - return new Promise<() => void>((resolve) => { - resolveWatch = resolve - }) - }) - const runtime = { - getRuntimeId: () => 'test-runtime', - watchFileExplorer, - registerSubscriptionCleanup: vi.fn() - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) - const abortController = new AbortController() - const replies: unknown[] = [] + it('reports files.unwatch failure instead of acknowledging incomplete teardown', async () => { + const cleanupError = new Error('file watcher process did not exit after termination deadline') + const runtime = { + getRuntimeId: () => 'test-runtime', + cleanupSubscriptionAndWait: vi.fn().mockRejectedValue(cleanupError) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) - const dispatch = dispatcher.dispatchStreaming( - makeRequest('files.watch', { worktree: 'id:wt-1' }), - (response) => replies.push(JSON.parse(response)), - { connectionId: 'conn-1', signal: abortController.signal } - ) - await vi.waitFor(() => { - expect(watchFileExplorer).toHaveBeenCalled() - }) + const response = await dispatcher.dispatch( + makeRequest('files.unwatch', { subscriptionId: 'files-watch-inproc-1' }) + ) - abortController.abort() - await dispatch - await vi.runOnlyPendingTimersAsync() - resolveWatch(unwatch) - await vi.waitFor(() => { - expect(unwatch).toHaveBeenCalled() - }) - - expect(runtime.registerSubscriptionCleanup).not.toHaveBeenCalled() - expect(replies).toEqual([]) - } finally { - vi.useRealTimers() - } + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining(cleanupError.message) } + }) }) it('reads a relative file path for a selected worktree', async () => { diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index b1fb70234a6..b9af390152b 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -1,7 +1,7 @@ /* oxlint-disable max-lines -- Why: file RPC routing coverage stays together so the dispatcher contract for read, write, mutation, and watch methods is easy to audit. */ import { z } from 'zod' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' -import { createFileWatchEventBatcher } from './file-watch-event-batcher' +import { runFileWatchStream } from './file-watch-stream-lifecycle' let filesWatchSubscriptionSeq = 0 const RUNTIME_FILE_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ @@ -19,6 +19,11 @@ const WorktreeSelector = z.object({ .pipe(z.string().min(1, 'Missing worktree selector')) }) +const FilePathSearch = WorktreeSelector.extend({ + query: z.string().max(256).default(''), + limit: z.number().int().positive().max(32).default(16) +}) + const FileOpen = WorktreeSelector.extend({ relativePath: z .unknown() @@ -176,6 +181,12 @@ export const FILE_METHODS: RpcAnyMethod[] = [ params: WorktreeSelector, handler: async (params, { runtime }) => runtime.listMobileFiles(params.worktree) }), + defineMethod({ + name: 'files.searchPaths', + params: FilePathSearch, + handler: async (params, { runtime }) => + runtime.searchMobileFilePaths(params.worktree, params.query, params.limit) + }), defineMethod({ name: 'files.open', params: FileOpen, @@ -386,91 +397,13 @@ export const FILE_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime, connectionId, signal }, emit) => { const seq = ++filesWatchSubscriptionSeq const subscriptionId = `files-watch-${connectionId ?? 'inproc'}-${seq}` - if (signal?.aborted) { - return - } - 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) { - return - } - settled = true - signal?.removeEventListener('abort', handleAbort) - resolve() - } - const cleanup = (): void => { - // 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' }) - } - const handleTerminalError = (error: Error): void => { - if (settled || terminalError) { - return - } - terminalError = error - if (unwatch) { - runtime.cleanupSubscription(subscriptionId) - } else { - cleanup() - } - } - const handleAbort = (): void => { - if (unwatch) { - cleanup() - } else { - // Why: watch setup may have queued events before resolving its - // unwatch callback. Dispose that transient batcher on early abort. - eventBatcher.dispose() - finish() - } - } - signal?.addEventListener('abort', handleAbort, { once: true }) - void runtime - .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 - // resolving. Tear down the late watcher immediately instead of - // registering cleanup on a connection that was already reaped. - eventBatcher.dispose() - nextUnwatch() - return - } - unwatch = nextUnwatch - runtime.registerSubscriptionCleanup(subscriptionId, cleanup, connectionId) - emit({ type: 'ready', subscriptionId }) - }) - .catch((error) => { - if (!settled) { - settled = true - signal?.removeEventListener('abort', handleAbort) - eventBatcher.dispose() - reject(error) - } - }) + await runFileWatchStream({ + runtime, + worktree: params.worktree, + connectionId, + signal, + subscriptionId, + emit }) } }), @@ -478,7 +411,7 @@ export const FILE_METHODS: RpcAnyMethod[] = [ name: 'files.unwatch', params: FileUnwatch, handler: async (params, { runtime }) => { - runtime.cleanupSubscription(params.subscriptionId) + await runtime.cleanupSubscriptionAndWait(params.subscriptionId) return { unsubscribed: true } } }) diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 8f9d511dfca..7e13e584c42 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -9,7 +9,8 @@ export const WorktreeSelector = z.object({ export const GitStatusParams = WorktreeSelector.extend({ includeIgnored: z.boolean().optional(), - bypassEffectiveUpstreamNegativeCache: z.boolean().optional() + bypassEffectiveUpstreamNegativeCache: z.boolean().optional(), + reuseLineStats: z.boolean().optional() }) export const GitCheckIgnored = WorktreeSelector.extend({ diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index bb69dfc5958..1655bb0b3d5 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -80,6 +80,28 @@ describe('git RPC methods', () => { }) }) + it('forwards line-stat reuse and request cancellation for status requests', async () => { + const controller = new AbortController() + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitStatus: vi.fn().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + await dispatcher.dispatch( + makeRequest('git.status', { worktree: 'id:wt-1', reuseLineStats: true }), + { signal: controller.signal } + ) + + expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { + reuseLineStats: true, + signal: controller.signal + }) + }) + it('returns ignored paths for selected explorer rows', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 1e4923f672a..c4ccdf071b6 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -88,10 +88,12 @@ export const GIT_METHODS: RpcMethod[] = [ defineMethod({ name: 'git.status', params: GitStatusParams, - handler: async (params, { runtime }) => { + handler: async (params, { runtime, signal }) => { const options = params.includeIgnored === undefined && - params.bypassEffectiveUpstreamNegativeCache === undefined + params.bypassEffectiveUpstreamNegativeCache === undefined && + params.reuseLineStats === undefined && + signal === undefined ? undefined : { ...(params.includeIgnored === undefined @@ -99,7 +101,9 @@ export const GIT_METHODS: RpcMethod[] = [ : { includeIgnored: params.includeIgnored }), ...(params.bypassEffectiveUpstreamNegativeCache === true ? { bypassEffectiveUpstreamNegativeCache: true } - : {}) + : {}), + ...(params.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(signal ? { signal } : {}) } return options === undefined ? runtime.getRuntimeGitStatus(params.worktree) diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index c1419af1651..44375181da0 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -10,7 +10,7 @@ const RepoSelector = z.object({ const WorkItemsList = RepoSelector.extend({ limit: OptionalFiniteNumber, query: OptionalString, - before: OptionalString, + page: z.number().int().positive().optional(), noCache: z.boolean().optional() }) @@ -306,7 +306,7 @@ export const GITHUB_METHODS: RpcMethod[] = [ params.repo, params.limit, params.query, - params.before, + params.page, params.noCache ) }), diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 35d1d067ca1..a18eb2d02d3 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -17,8 +17,10 @@ const SiteSelection = z const Connect = z.object({ siteUrl: requiredString('Site URL is required'), - email: requiredString('Email is required'), - apiToken: requiredString('API token is required') + // Self-hosted PAT auth needs no email; connect() enforces it for Cloud. + email: OptionalPlainString, + apiToken: requiredString('API token is required'), + authType: z.enum(['cloud', 'server']).optional() }) const SelectSite = z.object({ @@ -100,8 +102,9 @@ export const JIRA_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.jiraConnect({ siteUrl: params.siteUrl.trim(), - email: params.email.trim(), - apiToken: params.apiToken.trim() + email: params.email?.trim() ?? '', + apiToken: params.apiToken.trim(), + authType: params.authType }) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 720ce127d2c..15bb6b3a24a 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -2,12 +2,35 @@ import { describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import type { RpcContext } from '../core' -// Stub the shared cache so the handler returns a deterministic transcript with +// Stub the bounded tail reader so the handler returns a deterministic transcript with // one oversized tool-result block; the test then asserts clip behavior per client. const OVERSIZED = 'x'.repeat(5000) const cachedResult = vi.hoisted(() => ({ value: { messages: [] as NativeChatMessage[] } })) -vi.mock('../../../native-chat/transcript-read-cache', () => ({ - readNativeChatTranscriptCached: () => Promise.resolve(cachedResult.value) +const watcher = vi.hoisted(() => ({ + args: null as null | { + onInitialSnapshot?: ( + messages: NativeChatMessage[], + hasMore: boolean, + beforeOffset: number, + error?: string + ) => void + onAppend: (messages: NativeChatMessage[]) => void + }, + watching: true +})) +vi.mock('../../../native-chat/transcript-watch', () => ({ + readNativeChatTranscriptTail: ({ limit }: { limit: number }) => { + const messages = cachedResult.value.messages + return Promise.resolve({ + messages: messages.slice(-limit), + hasMore: messages.length > limit, + beforeOffset: 123 + }) + }, + subscribeNativeChatTranscript: (args: NonNullable) => { + watcher.args = args + return Promise.resolve({ unsubscribe: vi.fn(), watching: watcher.watching }) + } })) import { NATIVE_CHAT_METHODS } from './native-chat' @@ -30,6 +53,34 @@ function readSessionHandler(): (params: unknown, ctx: RpcContext) => Promise Promise } +function subscribeHandler(): ( + params: unknown, + ctx: RpcContext, + emit: (value: unknown) => void +) => Promise { + const method = NATIVE_CHAT_METHODS.find((candidate) => candidate.name === 'nativeChat.subscribe') + if (!method) { + throw new Error('subscribe method not registered') + } + return method.handler as ( + params: unknown, + ctx: RpcContext, + emit: (value: unknown) => void + ) => Promise +} + +function streamingContext(clientKind: RpcContext['clientKind']): RpcContext { + return { + runtime: { + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn(), + cleanupSubscriptionsByPrefix: vi.fn() + } as unknown as RpcContext['runtime'], + connectionId: 'connection-1', + clientKind + } +} + function ctxWith(clientKind: RpcContext['clientKind']): RpcContext { return { runtime: {} as RpcContext['runtime'], clientKind } } @@ -40,6 +91,13 @@ function firstOutput(result: unknown): string { return block.output } +function activeWatcherArgs(): NonNullable { + if (!watcher.args) { + throw new Error('native-chat transcript watcher was not subscribed') + } + return watcher.args +} + describe('nativeChat.readSession clientKind truncation gating', () => { it('clips oversized tool output for mobile clients', async () => { cachedResult.value = { messages: [makeMessage(OVERSIZED)] } @@ -52,6 +110,131 @@ describe('nativeChat.readSession clientKind truncation gating', () => { expect(output).toContain('truncated') }) + it('bounds raw tool-call inputs before sending them to mobile', async () => { + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'tool-call', + name: 'Write', + input: { file_path: 'src/a.ts', content: OVERSIZED } + } + ] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const input = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] + + expect(JSON.stringify(input).length).toBeLessThan(OVERSIZED.length) + expect(JSON.stringify(input)).toContain('truncated') + }) + + it('preserves AskUserQuestion option objects at the supported nesting depth', async () => { + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { + questions: [ + { + question: 'Pick one', + header: 'Choice', + options: [ + { label: 'Alpha', description: 'First option' }, + { label: 'Beta', description: 'Second option' } + ] + } + ] + } + } + ] + } + ] + } + + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] + + expect(block).toMatchObject({ + type: 'tool-call', + input: { + questions: [ + { + options: [ + { label: 'Alpha', description: 'First option' }, + { label: 'Beta', description: 'Second option' } + ] + } + ] + } + }) + }) + + it('bounds tool-call keys and primitive node fanout', async () => { + const wide = Object.fromEntries( + Array.from({ length: 200 }, (_unused, index) => [`${'k'.repeat(200)}-${index}`, index]) + ) + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [{ type: 'tool-call', name: 'Write', input: wide }] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const encoded = JSON.stringify((result as { messages: NativeChatMessage[] }).messages[0]) + + expect(encoded.length).toBeLessThan(10_000) + expect(encoded).toContain('truncated') + }) + + it('keeps sibling tool-call keys that share a 128-char prefix distinct', async () => { + const prefix = 'p'.repeat(128) + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'tool-call', + name: 'Write', + input: { [`${prefix}A`]: 'first', [`${prefix}B`]: 'second' } + } + ] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] as { + input: Record + } + const keys = Object.keys(block.input) + + expect(keys).toHaveLength(2) + expect(new Set(keys).size).toBe(2) + expect(Object.values(block.input)).toEqual(expect.arrayContaining(['first', 'second'])) + }) + it('passes oversized tool output through intact for runtime (web/desktop) clients', async () => { cachedResult.value = { messages: [makeMessage(OVERSIZED)] } const result = await readSessionHandler()( @@ -98,5 +281,102 @@ describe('nativeChat.readSession clientKind truncation gating', () => { // Tail-only: the last id survives, the first is dropped. expect(messages.at(-1)?.id).toBe('m-59') expect(messages[0].id).toBe('m-20') + expect(result).toMatchObject({ hasMore: true, beforeOffset: 123 }) + }) +}) + +describe('nativeChat.subscribe initial snapshot', () => { + it('emits one windowed snapshot with pagination state before live appends', async () => { + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's', limit: 40 }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + const messages = Array.from({ length: 60 }, (_unused, index) => ({ + ...makeMessage('small'), + id: `m-${index}` + })) + + const callbacks = activeWatcherArgs() + callbacks.onInitialSnapshot?.(messages, true, 123) + const live = Array.from({ length: 60 }, (_unused, index) => ({ + ...makeMessage('live'), + id: `m-live-${index}` + })) + callbacks.onAppend(live) + + expect(emitted[0]).toMatchObject({ + type: 'snapshot', + hasMore: true, + beforeOffset: 123 + }) + const snapshotMessages = (emitted[0] as { messages: NativeChatMessage[] }).messages + expect(snapshotMessages).toHaveLength(40) + expect(snapshotMessages[0].id).toBe('m-20') + expect(emitted[1]).toMatchObject({ type: 'appended' }) + expect((emitted[1] as { messages: NativeChatMessage[] }).messages).toHaveLength(60) + }) + + it('emits an explicit error snapshot when no transcript can be watched', async () => { + watcher.watching = false + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 'missing' }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + expect(emitted).toEqual([ + { type: 'snapshot', messages: [], hasMore: false, error: 'Transcript unavailable' } + ]) + }) + + it('forwards an initial-drain error onto the snapshot frame', async () => { + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's' }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + activeWatcherArgs().onInitialSnapshot?.([], false, 0, 'Transcript unavailable') + + expect(emitted).toEqual([ + { + type: 'snapshot', + messages: [], + hasMore: false, + beforeOffset: 0, + error: 'Transcript unavailable' + } + ]) + }) + + it('omits error from the snapshot frame on a clean initial drain', async () => { + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's' }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + activeWatcherArgs().onInitialSnapshot?.([makeMessage('hi')], false, 7) + + expect(emitted).toEqual([ + { + type: 'snapshot', + messages: [expect.objectContaining({ id: 'a-1' })], + hasMore: false, + beforeOffset: 7 + } + ]) }) }) diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 05558c8b76c..513a2134011 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,8 +1,10 @@ import { z } from 'zod' import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' import type { AgentType } from '../../../../shared/native-chat-types' -import { readNativeChatTranscriptCached } from '../../../native-chat/transcript-read-cache' -import { subscribeNativeChatTranscript } from '../../../native-chat/transcript-watch' +import { + readNativeChatTranscriptTail, + subscribeNativeChatTranscript +} from '../../../native-chat/transcript-watch' import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' // Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The @@ -39,7 +41,8 @@ const NativeChatSession = z.object({ // Authoritative transcript path from the agent hook (providerSession), used to // locate the file directly when the session id no longer names it (recent // Claude Code). Optional for back-compat with older clients. - transcriptPath: z.string().min(1).optional() + transcriptPath: z.string().min(1).optional(), + beforeOffset: z.number().int().nonnegative().optional() }) const NativeChatUnsubscribe = z.object({ @@ -59,6 +62,8 @@ const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 // The mobile view only previews block bodies, so truncate them on the wire to // keep the payload small; the marker tells the user content was clipped. const MOBILE_BLOCK_CHAR_CAP = 4000 +const MOBILE_TOOL_INPUT_ITEMS_CAP = 20 +const MOBILE_TOOL_INPUT_NODE_CAP = 100 const TRUNCATION_MARKER = '\n… (truncated)' function clip(text: string): string { @@ -76,13 +81,77 @@ function clipBlock(block: NativeChatBlock): NativeChatBlock { ? { ...block, output: clip(block.output) } : block } + if (block.type === 'tool-call') { + const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP } + return { ...block, input: sanitizeToolInput(block.input, budget, 0) } + } return block } +function sanitizeToolInput( + value: unknown, + budget: { remaining: number; nodes: number }, + depth: number +): unknown { + budget.nodes-- + if (budget.nodes < 0 || budget.remaining <= 0) { + return '… (truncated)' + } + if (typeof value === 'string') { + const length = Math.min(value.length, budget.remaining) + budget.remaining -= length + return length < value.length ? `${value.slice(0, length)}… (truncated)` : value + } + if (!value || typeof value !== 'object' || depth >= 5) { + return value && typeof value === 'object' ? '… (truncated)' : value + } + if (Array.isArray(value)) { + const result = value + .slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP) + .map((item) => sanitizeToolInput(item, budget, depth + 1)) + if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) { + result.push('… (truncated)') + } + return result + } + const result: Record = {} + let count = 0 + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + continue + } + if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { + result['…'] = 'truncated' + break + } + let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) + // Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse + // to the same bounded key; suffix collisions so neither field is silently lost. + if (Object.prototype.hasOwnProperty.call(result, boundedKey)) { + boundedKey = `${boundedKey}~${count}` + } + budget.remaining -= boundedKey.length + result[boundedKey] = sanitizeToolInput( + (value as Record)[key], + budget, + depth + 1 + ) + count++ + } + return result +} + function sanitizeMessage(message: NativeChatMessage): NativeChatMessage { return { ...message, blocks: message.blocks.map(clipBlock) } } +function sanitizeAppendForClient( + messages: readonly NativeChatMessage[], + clientKind: RpcContext['clientKind'] +): NativeChatMessage[] { + return clientKind === 'mobile' ? messages.map(sanitizeMessage) : messages.slice() +} + /** Window a transcript to its most recent `limit` messages so a long session * can't freeze the client. Windowing by count applies to ALL RPC clients — * shipping thousands of turns over the paired link is bad for web and mobile @@ -113,14 +182,20 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ name: 'nativeChat.readSession', params: NativeChatSession, handler: async (params, { clientKind }) => { - const result = await readNativeChatTranscriptCached( - params.agent, - params.sessionId, - params.transcriptPath - ) - // Window to the conversation tail (all clients); clip blocks for mobile only. + const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW + const result = await readNativeChatTranscriptTail({ + agent: params.agent, + sessionId: params.sessionId, + transcriptPath: params.transcriptPath, + limit, + beforeOffset: params.beforeOffset + }) return 'messages' in result - ? { messages: windowForClient(result.messages, clientKind, params.limit) } + ? { + messages: windowForClient(result.messages, clientKind, limit), + hasMore: result.hasMore, + beforeOffset: result.beforeOffset + } : result } }), @@ -130,10 +205,8 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ handler: async (params, { runtime, connectionId, clientKind }, emit) => { let closed = false let unsubscribe = (): void => {} - // Why: the subscriber seeds its read offset at 0, so the first drain emits - // the whole transcript and later drains emit only appended turns. The first - // batch is windowed to the tail (a full transcript would freeze mobile); - // later incremental batches are smaller than the window so they pass through. + // Why: the first drain is a bounded tail snapshot; later drains emit only + // appended turns. This avoids parsing or shipping full long transcripts. // Clients merge by message id, so the initial windowed batch doubles as the // snapshot. Keyed by the client-supplied subscriptionId when present so // registration and unsubscribe derive from the same token; otherwise by @@ -141,6 +214,7 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ // unsubscribe (no wire break). const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}` const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}` + const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW runtime.registerSubscriptionCleanup( subscriptionId, () => { @@ -157,11 +231,37 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ agent: params.agent, sessionId: params.sessionId, transcriptPath: params.transcriptPath, + initialLimit: limit, + onInitialSnapshot: (messages, hasMore, beforeOffset, error) => { + if (closed) { + return + } + // Forward an initial-drain error so a watching client's first frame carries it + // instead of stranding the view at 'loading' when the read keeps throwing. + emit({ + type: 'snapshot', + messages: windowForClient(messages, clientKind, limit), + hasMore, + beforeOffset, + ...(error ? { error } : {}) + }) + }, + onReplace: (messages, hasMore, beforeOffset) => { + if (closed) { + return + } + emit({ + type: 'replacement', + messages: windowForClient(messages, clientKind, limit), + hasMore, + beforeOffset + }) + }, onAppend: (messages) => { if (closed) { return } - emit({ type: 'appended', messages: windowForClient(messages, clientKind) }) + emit({ type: 'appended', messages: sanitizeAppendForClient(messages, clientKind) }) } }) // The connection may have closed while the file was being resolved. @@ -169,6 +269,9 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ subscription.unsubscribe() return } + if (!subscription.watching) { + emit({ type: 'snapshot', messages: [], hasMore: false, error: 'Transcript unavailable' }) + } unsubscribe = subscription.unsubscribe } }), diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 7a9b9e448a6..6d5879d9cf8 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -50,6 +50,9 @@ const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024 // payload bytes rather than UTF-16 code units. const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024 const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 +// Why: keep initial subscribe latency bounded; readiness remains observed after +// this deadline and triggers an in-stream recovery snapshot when it arrives. +const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 let nextTerminalStreamId = 1 type SnapshotFrameOptions = { @@ -332,6 +335,61 @@ function resolveMobileFloorClientId( return null } +async function sendTerminalStreamInput( + runtime: OrcaRuntimeService, + args: { + terminal: string + text: string + client: TerminalViewportClient | undefined + isMobile: boolean + } +): Promise { + const action = { text: args.text, enter: false, interrupt: false } + const clientId = args.isMobile ? args.client?.id : undefined + const floorClaim: MobileInputFloorClaimHolder = { current: null } + try { + if (!clientId) { + await runtime.sendTerminal(args.terminal, action) + return + } + const result = await runtime.sendTerminal(args.terminal, action, { + reserveWrite: (writePtyId) => { + const claim = runtime.beginMobileInputFloor(writePtyId, clientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + floorClaim.current = claim + }, + afterWrite: () => commitMobileInputFloorClaim(floorClaim) + }) + if (!result.accepted) { + floorClaim.current?.rollback() + } + } catch { + floorClaim.current?.rollback() + } +} + +type MobileInputFloorClaimHolder = { + current: ReturnType +} + +async function commitMobileInputFloorClaim(claim: MobileInputFloorClaimHolder): Promise { + const current = claim.current + if (!current) { + return + } + try { + await current.commit() + } finally { + // Why: the runtime may yield before the next chunk/suffix, so that write + // needs a fresh reservation if desktop reclaimed the floor meanwhile. + if (claim.current === current) { + claim.current = null + } + } +} + function getTerminalSendGuardRefusedReason(error: unknown): 'no-agent' | 'permission' | undefined { const message = error instanceof Error ? error.message : String(error) if (message.includes('terminal_guard_permission')) { @@ -650,6 +708,44 @@ async function serializeBudgetedMobileSnapshot( return null } +async function serializeStableMobileRendererSnapshot( + runtime: OrcaRuntimeService, + ptyId: string +): Promise { + const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] + let candidateIndex = 0 + for (let attempt = 0; attempt < candidates.length; attempt += 1) { + // Why: stability retries share the six-call snapshot budget. Advance + // toward zero scrollback so the final attempt always has a bounded payload. + candidateIndex = Math.max(candidateIndex, attempt) + const rows = candidates[candidateIndex] + const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId) + const serialized = await runtime.serializeRendererTerminalBuffer(ptyId, { + scrollbackRows: rows + }) + const outputSequenceAfter = runtime.getPtyOutputSequence(ptyId) + if (outputSequenceBefore !== outputSequenceAfter) { + continue + } + if (!serialized) { + return null + } + const overByteBudget = terminalStreamByteLengthExceeds( + serialized.data, + MOBILE_SNAPSHOT_BYTE_BUDGET + ) + if (!overByteBudget || rows === 0) { + return { + ...serialized, + scrollbackRows: rows, + truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget + } + } + candidateIndex += 1 + } + return null +} + // Why: mobile xterm can only re-wrap SOFT-wrapped lines on a client-side // term.resize(); the restored scrollback snapshot contains HARD newlines from // the host serialization, so a width change leaves prior output wrapped at the @@ -905,7 +1001,8 @@ const TerminalSubscribe = TerminalHandle.extend({ capabilities: z .object({ terminalBinaryStream: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional() + desktopViewportClaims: z.literal(1).optional(), + mobileInputLeaseOnly: z.literal(1).optional() }) .optional() }) @@ -1224,6 +1321,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } } } + const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) + const mobileFloorClaim: MobileInputFloorClaimHolder = { current: null } + const beforeWrite = assertSendPreconditions + const reserveWrite = + params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId + ? (ptyId: string): void => { + const claim = runtime.beginMobileInputFloor(ptyId, mobileFloorClientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + mobileFloorClaim.current = claim + } + : undefined let result try { result = await runtime.sendTerminal( @@ -1233,9 +1343,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ enter: params.enter === true, interrupt: params.interrupt === true }, - { beforeWrite: assertSendPreconditions } + { + beforeWrite, + ...(reserveWrite ? { reserveWrite } : {}), + ...(params.inputKind !== 'query-reply' && mobileFloorClientId + ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } + : {}) + } ) } catch (error) { + mobileFloorClaim.current?.rollback() const refusedReason = getTerminalSendGuardRefusedReason(error) if (refusedReason) { return { @@ -1258,15 +1375,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } throw error } + if (result.accepted !== true) { + mobileFloorClaim.current?.rollback() + } // Why: deliberate mobile input is a take-floor action. Drives the // `* → mobile{clientId}` driver transition so the desktop banner // remounts (if previously reclaimed) and active phone-fit dims follow // the most recent actor. Clientless sends are old mobile builds, so use // the current mobile driver as their compatibility identity. - const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - if (params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId) { - await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId) - } return { send: result } } }), @@ -1368,6 +1484,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ close: await runtime.closeTerminal(params.terminal) }) }), + defineMethod({ + name: 'terminal.closeTab', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + close: await runtime.closeTerminalTab(params.terminal) + }) + }), defineMethod({ name: 'agentTeams.tmuxCompat', params: AgentTeamsTmuxCompat, @@ -1751,22 +1874,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // Mobile already has the higher-priority floor; a rejected desktop // viewport claim must never suppress later phone input. const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail - void inputClaimTail - .then((claimed) => - !claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client) - ? null - : runtime.sendTerminal(stream.terminal, { - text, - enter: false, - interrupt: false - }) - ) - .then(async () => { - if (stream.isMobile && stream.client?.id) { - await runtime.mobileTookFloor(stream.ptyId, stream.client.id) - } + void inputClaimTail.then((claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + return + } + return sendTerminalStreamInput(runtime, { + terminal: stream.terminal, + text, + client: stream.client, + isMobile: stream.isMobile }) - .catch(() => {}) + }) return } if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { @@ -2355,13 +2473,25 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) => { let leaf = runtime.resolveLeafForHandle(params.terminal) const isMobile = params.client?.type === 'mobile' + const serializerGenerationBeforeAnyMount = isMobile + ? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0) + : 0 + let rendererMountRequestedBeforePty = false const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) + // Why: a closed stream must not allocate listeners, mobile-fit state, or + // a hidden renderer surface that no client remains to consume. + if (signal?.aborted) { + return + } // Why: the left pane's PTY spawns asynchronously after the tab is created. // Mobile clients that subscribe before the PTY is ready would get a bare // scrollback+end with no live stream or phone-fit. Wait for the PTY so // the subscribe can proceed normally. if (!leaf?.ptyId && isMobile) { + // Why: a never-mounted tab has no graph leaf to await; mounting the + // exact tab lets its PTY attach without activating the worktree. + rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) try { const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) leaf = { ptyId } @@ -2391,7 +2521,61 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const ptyId = leaf.ptyId const clientId = params.client?.id + const mobileInputLeaseOnly = + isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId) + // Why: the initial mount/PTY wait and phone-fit can both emit a redraw + // that creates suffix-only state, so preserve the pre-mount absence signal. + const missingHeadlessStateBeforeMobileFit = + isMobile && + (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) + const serializerGenerationBeforeMobileFit = missingHeadlessStateBeforeMobileFit + ? rendererMountRequestedBeforePty + ? serializerGenerationBeforeAnyMount + : runtime.getRendererTerminalSerializerGeneration(ptyId) + : 0 const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 + if (mobileInputLeaseOnly && clientId) { + let closed = false + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + const subscriptionId = `${params.terminal}:${clientId}` + // Why: chat needs the input-floor acknowledgement without registering + // a view subscriber or transporting duplicate PTY output. + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + runtime.handleMobileUnsubscribe(ptyId, clientId) + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + void runtime + .waitForTerminal(params.terminal, { condition: 'exit', signal }) + .then(() => runtime.cleanupSubscription(subscriptionId)) + .catch(() => runtime.cleanupSubscription(subscriptionId)) + try { + await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + if (closed || signal?.aborted) { + // Why: a disconnect can win the awaited subscribe and otherwise + // resurrect mobile presence after cleanup already released it. + runtime.handleMobileUnsubscribe(ptyId, clientId) + if (!closed) { + runtime.cleanupSubscription(subscriptionId) + } + return + } + emit({ type: 'subscribed', streamId: null, lines: [], truncated: false }) + await streamClosed + } catch (error) { + runtime.cleanupSubscription(subscriptionId) + throw error + } + return + } // Why: only unregister the width floor this subscription took (see the // multiplex stream's registeredRemoteDesktopDriver note). let registeredRemoteDesktopDriver = false @@ -2532,6 +2716,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let unsubscribeResize = (): void => {} let unsubscribeFit = (): void => {} let unregisterBinaryHandler = (): void => {} + let abortRendererMountWait = (): void => {} + let lateRendererReadyPromise: Promise | null = null let outputBatcher: ReturnType | null = null let resolveStream = (): void => {} const streamClosed = new Promise((resolve) => { @@ -2551,6 +2737,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ unsubscribeResize() unsubscribeFit() unregisterBinaryHandler() + abortRendererMountWait() if (isMobile && clientId) { runtime.handleMobileUnsubscribe(ptyId, clientId) } else if (registeredRemoteDesktopDriver && clientId) { @@ -2602,22 +2789,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { return } - void desktopClaimTail - .then((claimed) => - !claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client) - ? null - : runtime.sendTerminal(params.terminal, { - text, - enter: false, - interrupt: false - }) - ) - .then(async () => { - if (isMobile && clientId) { - await runtime.mobileTookFloor(ptyId, clientId) - } + void desktopClaimTail.then(async (claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + return + } + await sendTerminalStreamInput(runtime, { + terminal: params.terminal, + text, + client: params.client, + isMobile }) - .catch(() => {}) + }) return } if (frame.opcode === TerminalStreamOpcode.Resize && params.client) { @@ -2772,6 +2954,83 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (closed) { return } + // Why: missing model state—not snapshot text—is the signal that this + // PTY may never have attached; avoid remounting legitimate blank panes. + // A renderer-sourced snapshot also proves the exact pane is already + // attached, so waiting for a fresh mount generation would only stall. + const mountRequested = + missingHeadlessStateBeforeMobileFit && + serialized?.source !== 'renderer' && + (rendererMountRequestedBeforePty || + runtime.requestRendererTerminalTabMount(params.terminal)) + if (missingHeadlessStateBeforeMobileFit && mountRequested) { + // Why: an idle legacy PTY emits no later byte; a fresh settle proves + // this exact remount completed before we replay its restored screen. + const mountWaitController = new AbortController() + const abortMountWait = (): void => mountWaitController.abort() + abortRendererMountWait = abortMountWait + if (signal?.aborted) { + abortMountWait() + } else { + signal?.addEventListener('abort', abortMountWait, { once: true }) + } + const rendererReadyPromise = runtime + .waitForRendererTerminalSerializer( + ptyId, + serializerGenerationBeforeMobileFit, + undefined, + mountWaitController.signal + ) + .catch(() => false) + const finishMountWait = (): void => { + signal?.removeEventListener('abort', abortMountWait) + if (abortRendererMountWait === abortMountWait) { + abortRendererMountWait = () => {} + } + } + void rendererReadyPromise.then(finishMountWait, finishMountWait) + let deadlineTimer: ReturnType | null = null + const initialDeadline = new Promise((resolve) => { + deadlineTimer = setTimeout(() => resolve(false), MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS) + if (typeof deadlineTimer.unref === 'function') { + deadlineTimer.unref() + } + }) + const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) + if (deadlineTimer) { + clearTimeout(deadlineTimer) + } + if (closed || signal?.aborted) { + return + } + if (rendererReady) { + read = await runtime.readTerminal(params.terminal) + const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( + runtime, + ptyId + ) + if (closed) { + return + } + if (stableRendererSnapshot?.data.length) { + serialized = stableRendererSnapshot + const trailingOutput = pendingOutput.flatMap((item) => { + const data = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) + const seq = item.meta?.seq + return data && typeof seq === 'number' ? [{ data, seq }] : [] + }) + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId, + stableRendererSnapshot, + trailingOutput + ) + } + } else { + // Why: a renderer can settle after the bounded initial response. + // Keep observing it so an idle PTY still self-heals without bytes. + lateRendererReadyPromise = rendererReadyPromise + } + } let initialOutputOverflowed = false if (pendingOutputOverflowed) { pendingOutput.splice(0) @@ -2932,6 +3191,54 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } pendingOutputBytes = 0 outputBatcher.flush() + const lateRendererReady = lateRendererReadyPromise + lateRendererReadyPromise = null + if (lateRendererReady) { + void lateRendererReady + .then(async (rendererReady) => { + if (!rendererReady || closed) { + return + } + outputBatcher?.flush() + const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId) + if (closed) { + return + } + if (!recovery?.data.length) { + return + } + // Why: late recovery has no buffered-output gate. Only an exact + // renderer high-water may reset mobile without erasing live bytes. + if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) { + return + } + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery) + // Why: shipped mobile clients apply resized snapshots in place, + // allowing a blank initialized xterm to recover without resubscribe. + const recoveryStats = sendSnapshotFrames(sendFrame, { + kind: 'resized', + cols: recovery.cols, + rows: recovery.rows, + displayMode, + reason: 'renderer-mount-ready', + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + lastResizeCols = recovery.cols + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId, + reason: 'renderer-mount-ready', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + }) + .catch(() => {}) + } const sendResizedFrame = (event: { cols: number rows: number diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 20b608c94c2..99a77fb9afd 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -692,6 +692,9 @@ describe('terminal multiplex RPC', () => { }) => void) | undefined const restreamResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] + const write = vi.fn() + const commit = vi.fn().mockResolvedValue(undefined) + const beginMobileInputFloor = vi.fn(() => ({ commit, rollback: vi.fn() })) const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), @@ -723,7 +726,13 @@ describe('terminal multiplex RPC', () => { cleanups.set(id, cleanup) }), waitForTerminal: vi.fn(() => new Promise(() => {})), - sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options.reserveWrite('pty-1') + write() + await options.afterWrite('pty-1') + return { accepted: true } + }), + beginMobileInputFloor, updateMobileViewport: vi.fn().mockResolvedValue({ updated: false, applied: false }) }) const dispatcher = new RpcDispatcher({ @@ -787,12 +796,16 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'x', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { text: 'x', enter: false, interrupt: false }, + { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + ) ) + expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( + write.mock.invocationCallOrder[0]! + ) + expect(write.mock.invocationCallOrder[0]).toBeLessThan(commit.mock.invocationCallOrder[0]!) binaryFrames.splice(0) resizeListener?.({ diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index d10456c8fa6..c1ee755ee83 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -277,12 +277,18 @@ describe('terminal output batching', () => { } }) - it('routes binary terminal input frames back to the subscribed PTY', async () => { + it('claims the mobile floor before binary terminal input and rolls back rejection', async () => { const handlers = new Map< number, (frame: NonNullable>) => void >() const cleanups = new Map void>() + const write = vi.fn() + const commit = vi.fn().mockResolvedValue(undefined) + const rollback = vi.fn() + const beginMobileInputFloor = vi.fn( + (): ReturnType => ({ commit, rollback }) + ) const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), @@ -293,7 +299,11 @@ describe('terminal output batching', () => { subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + handleMobileSubscribe: vi.fn().mockResolvedValue(undefined), + handleMobileUnsubscribe: vi.fn(), registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { cleanups.set(id, cleanup) }), @@ -302,7 +312,13 @@ describe('terminal output batching', () => { cleanups.delete(id) }), waitForTerminal: vi.fn(() => new Promise(() => {})), - sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options.reserveWrite('pty-1') + write() + await options.afterWrite('pty-1') + return { accepted: true } + }), + beginMobileInputFloor, updateMobileViewport: vi.fn().mockResolvedValue(false) }) const dispatcher = new RpcDispatcher({ @@ -314,7 +330,7 @@ describe('terminal output batching', () => { const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { terminal: 'terminal-1', - client: { id: 'desktop-1', type: 'desktop' }, + client: { id: 'mobile-1', type: 'mobile' }, capabilities: { terminalBinaryStream: 1 } }), (msg) => messages.push(msg), @@ -345,14 +361,54 @@ describe('terminal output batching', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'ls\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { text: 'ls\r', enter: false, interrupt: false }, + { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + ) ) + expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( + write.mock.invocationCallOrder[0]! + ) + expect(write.mock.invocationCallOrder[0]).toBeLessThan(commit.mock.invocationCallOrder[0]!) + + vi.mocked(runtime.sendTerminal).mockImplementationOnce(async (_handle, _action, options) => { + options?.reserveWrite?.('pty-1') + return { handle: 'terminal-1', accepted: false, bytesWritten: 0 } + }) + handlers.get(streamId)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 2, + payload: encodeTerminalStreamText('rejected') + }) + )! + ) + await vi.waitFor(() => expect(rollback).toHaveBeenCalledOnce()) + expect(commit).toHaveBeenCalledOnce() + + // Post-unsubscribe: capture the still-registered handler, tear down the + // subscription, then replay a frame. The closed guard must drop it, so no + // further sendTerminal dispatch and no write reach the PTY. + const staleHandler = handlers.get(streamId)! + runtime.cleanupSubscription('terminal-1:mobile-1') + staleHandler( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 3, + payload: encodeTerminalStreamText('after-unsubscribe') + }) + )! + ) + await Promise.resolve() + expect(runtime.sendTerminal).toHaveBeenCalledTimes(2) + expect(write).toHaveBeenCalledOnce() - runtime.cleanupSubscription('terminal-1:desktop-1') await dispatchPromise }) }) diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 58b37bcd7fc..e9fb481aae0 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -16,6 +16,12 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + beginMobileInputFloor: vi.fn((ptyId: string, clientId: string) => ({ + commit: async () => { + await overrides.mobileTookFloor?.(ptyId, clientId) + }, + rollback: vi.fn() + })), ...overrides } as OrcaRuntimeService } @@ -144,10 +150,9 @@ describe('terminal send RPC', () => { releaseClaim = () => resolve(true) }) ) - const sendTerminal = vi.fn().mockResolvedValue({ - handle: 'terminal-1', - accepted: true, - bytesWritten: 1 + const sendTerminal = vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } }) const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), @@ -177,15 +182,33 @@ describe('terminal send RPC', () => { }) it('accepts legacy clientless mobile input when the current driver is mobile', async () => { - const runtime = stubRuntime({ - resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), - sendTerminal: vi.fn().mockResolvedValue({ + const write = vi.fn() + const sendTerminal = vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + options.reserveWrite?.('pty-1') + write() + await options.afterWrite?.('pty-1') + await options.beforeWrite?.('pty-1') + options.reserveWrite?.('pty-1') + write() + await options.afterWrite?.('pty-1') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 - }), - mobileTookFloor: vi.fn().mockResolvedValue(undefined) + } + }) + const mobileTookFloor = vi.fn().mockResolvedValue(undefined) + const beginMobileInputFloor = vi.fn(() => ({ + commit: async () => mobileTookFloor('pty-1', 'mobile-1'), + rollback: vi.fn() + })) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + sendTerminal, + beginMobileInputFloor, + mobileTookFloor }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) @@ -208,9 +231,115 @@ describe('terminal send RPC', () => { enter: false, interrupt: false }, - { beforeWrite: undefined } + { + beforeWrite: undefined, + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + expect(beginMobileInputFloor).toHaveBeenCalledTimes(2) + expect(runtime.mobileTookFloor).toHaveBeenCalledTimes(2) + expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( + write.mock.invocationCallOrder[0]! + ) + expect(write.mock.invocationCallOrder[0]).toBeLessThan( + mobileTookFloor.mock.invocationCallOrder[0]! + ) + }) + + it.each([ + ['rejects', async () => ({ handle: 'terminal-1', accepted: false, bytesWritten: 0 })], + [ + 'throws', + async () => { + throw new Error('write failed') + } + ] + ])('rolls back a mobile floor claim when the terminal write %s', async (_case, finishWrite) => { + const commit = vi.fn() + const rollback = vi.fn() + const beginMobileInputFloor = vi.fn(() => ({ commit, rollback })) + const sendTerminal = vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + options.reserveWrite?.('pty-1') + return finishWrite() + }) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + sendTerminal, + beginMobileInputFloor + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'x', + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + expect(beginMobileInputFloor).toHaveBeenCalledOnce() + expect(rollback).toHaveBeenCalledOnce() + expect(commit).not.toHaveBeenCalled() + }) + + it('writes zero bytes when the mobile subscriber disappeared before reservation', async () => { + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + beginMobileInputFloor: vi.fn().mockReturnValue(null), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options.reserveWrite('pty-1') + write() + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { terminal: 'terminal-1', text: 'stale' }) + ) + + expect(response.ok).toBe(false) + expect(write).not.toHaveBeenCalled() + }) + + it('reclaims the mobile floor for a delayed suffix and preserves the earlier commit', async () => { + const commit = vi.fn() + const rollback = vi.fn() + const beginMobileInputFloor = vi.fn(() => ({ commit: async () => commit(), rollback })) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + beginMobileInputFloor, + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + options.reserveWrite('pty-1') + await options.afterWrite('pty-1') + await options.beforeWrite?.('pty-1') + options.reserveWrite('pty-1') + throw new Error('suffix failed') + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'partial', + enter: true, + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(beginMobileInputFloor).toHaveBeenCalledTimes(2) + expect(commit).toHaveBeenCalledOnce() + expect(rollback).toHaveBeenCalledOnce() }) it('writes a validated terminal query reply without taking the mobile floor', async () => { @@ -446,6 +575,7 @@ describe('terminal send RPC', () => { }) it('refuses guarded terminal sends when the agent needs permission', async () => { + const beginMobileInputFloor = vi.fn() const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), @@ -454,7 +584,8 @@ describe('terminal send RPC', () => { isRunningAgent: true, status: 'permission' }), - sendTerminal: vi.fn() + sendTerminal: vi.fn(), + beginMobileInputFloor }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) @@ -463,7 +594,7 @@ describe('terminal send RPC', () => { terminal: 'terminal-1', enter: true, requireAgentStatus: 'sendable', - client: { id: 'desktop-1', type: 'desktop' } + client: { id: 'mobile-1', type: 'mobile' } }) ) @@ -481,6 +612,7 @@ describe('terminal send RPC', () => { }) expect(runtime.getTerminalAgentStatus).toHaveBeenCalledWith('terminal-1') expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(beginMobileInputFloor).not.toHaveBeenCalled() }) it('allows guarded terminal sends when the agent is sendable', async () => { diff --git a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts new file mode 100644 index 00000000000..89a46d36073 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -0,0 +1,235 @@ +/** STA-1840 regression: missing mobile terminal models request an exact renderer tab mount. */ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + registerRemoteTerminalViewSubscriber: () => () => {}, + requestRendererTerminalTabMount: () => false, + getRendererTerminalSerializerGenerationForHandle: () => 0, + getRendererTerminalSerializerGeneration: () => 0, + waitForRendererTerminalSerializer: async () => false, + getPtyOutputSequence: () => 0, + replaceHeadlessTerminalFromRendererSnapshotForRecovery: () => {}, + serializeRendererTerminalBuffer: async () => null, + hasHeadlessTerminalState: () => true, + ...overrides + } as OrcaRuntimeService +} + +const makeRequest = (method: string, params?: unknown): RpcRequest => ({ + id: 'req-1', + authToken: 'tok', + method, + params +}) + +describe('terminal.subscribe blank-tab background mount', () => { + it('does not mount a hidden tab for an already-aborted mobile subscribe', async () => { + const controller = new AbortController() + controller.abort() + const requestRendererTerminalTabMount = vi.fn(() => true) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue(null), + requestRendererTerminalTabMount, + waitForLeafPtyId: vi.fn(), + readTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + signal: controller.signal, + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + expect(requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(runtime.waitForLeafPtyId).not.toHaveBeenCalled() + expect(runtime.readTerminal).not.toHaveBeenCalled() + }) + + it('requests a renderer tab mount when a mobile subscribe has no headless model', async () => { + // Why: stale preview text must not hide the missing live model/attachment. + const cleanups = new Map void>() + const callOrder: string[] = [] + const unsubscribeData = vi.fn() + const requestRendererTerminalTabMount = vi.fn(() => { + callOrder.push('request-mount') + return true + }) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + requestRendererTerminalTabMount, + hasHeadlessTerminalState: vi.fn().mockReturnValue(false), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn(() => { + callOrder.push('subscribe-data') + return unsubscribeData + }), + readTerminal: vi.fn().mockResolvedValue({ tail: ['stale preview'], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'stale snapshot', cols: 80, rows: 24, seq: 4 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => + expect(requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') + ) + expect(callOrder).toEqual(['subscribe-data', 'request-mount']) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + expect(unsubscribeData).toHaveBeenCalledTimes(1) + }) + + it('does not request a renderer tab mount when an attached terminal is legitimately blank', async () => { + const cleanups = new Map void>() + const requestRendererTerminalTabMount = vi.fn(() => true) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + requestRendererTerminalTabMount, + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(requestRendererTerminalTabMount).not.toHaveBeenCalled() + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('does not wait for a remount when the current snapshot came from the renderer', async () => { + const cleanups = new Map void>() + const requestRendererTerminalTabMount = vi.fn(() => true) + const waitForRendererTerminalSerializer = vi.fn() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + requestRendererTerminalTabMount, + waitForRendererTerminalSerializer, + hasHeadlessTerminalState: vi.fn().mockReturnValue(false), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'current renderer prompt $ ', + cols: 80, + rows: 24, + source: 'renderer' + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(waitForRendererTerminalSerializer).not.toHaveBeenCalled() + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 4a674f9df91..5c02a25326f 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -17,6 +17,7 @@ function stubRuntime(overrides: Partial = {}): OrcaRuntimeSe // Why: subscribe streams register as remote view subscribers for Phase-5 // query-authority suppression (terminal-query-authority.md). registerRemoteTerminalViewSubscriber: () => () => {}, + requestRendererTerminalTabMount: () => false, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts new file mode 100644 index 00000000000..fab3d5a09cd --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } + } +} + +describe('terminal lease-only subscription', () => { + it('keeps mobile input ownership without registering output delivery', async () => { + const messages: string[] = [] + const cleanups = new Map void>() + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn(), + registerRemoteTerminalViewSubscriber: vi.fn(), + readTerminal: vi.fn(), + serializeTerminalBuffer: vi.fn(), + subscribeToTerminalResize: vi.fn(), + subscribeToFitOverrideChanges: vi.fn(), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + request, + (message) => messages.push(message), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => + expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe( + true + ) + ) + expect(runtime.handleMobileSubscribe).toHaveBeenCalledWith('pty-1', 'phone-1', undefined) + expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() + expect(runtime.registerRemoteTerminalViewSubscriber).not.toHaveBeenCalled() + expect(runtime.readTerminal).not.toHaveBeenCalled() + expect(runtime.serializeTerminalBuffer).not.toHaveBeenCalled() + expect(runtime.subscribeToTerminalResize).not.toHaveBeenCalled() + expect(runtime.subscribeToFitOverrideChanges).not.toHaveBeenCalled() + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + expect(runtime.handleMobileUnsubscribe).toHaveBeenCalledWith('pty-1', 'phone-1') + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts new file mode 100644 index 00000000000..3a7614587f1 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts @@ -0,0 +1,437 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } +} + +describe('terminal subscribe mount replay', () => { + it('includes the restored idle screen when a missing model is background-mounted', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let mounted = false + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + hasHeadlessTerminalState: vi.fn(() => mounted), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 3), + getRendererTerminalSerializerGeneration: vi.fn(() => 3), + getPtyOutputSequence: vi.fn(() => (mounted ? 4 : 0)), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { + expect(afterGeneration).toBe(3) + mounted = true + return true + }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async () => + mounted + ? { data: 'idle prompt $ ', cols: 80, rows: 24, seq: 4 } + : { data: '', cols: 80, rows: 24, seq: 1 } + ), + serializeRendererTerminalBuffer: vi.fn(async () => + mounted ? { data: 'idle prompt $ ', cols: 80, rows: 24, seq: 4 } : null + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => expect(runtime.requestRendererTerminalTabMount).toHaveBeenCalled()) + await vi.waitFor(() => + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('idle prompt $ ') + ) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('prefers restored history when phone-fit creates suffix-only headless state', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let generation = 1 + let headlessPresent = false + let serializeCalls = 0 + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + hasHeadlessTerminalState: vi.fn(() => headlessPresent), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => generation), + getRendererTerminalSerializerGeneration: vi.fn(() => generation), + getPtyOutputSequence: vi.fn(() => 0), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { + return generation > afterGeneration + }), + handleMobileSubscribe: vi.fn(async () => { + headlessPresent = true + generation = 2 + return true + }), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async () => { + serializeCalls += 1 + if (serializeCalls === 1) { + return { data: 'suffix-only redraw', cols: 80, rows: 24, seq: 2 } + } + return { data: 'raced idle prompt $ ', cols: 80, rows: 24, seq: 5 } + }), + serializeRendererTerminalBuffer: vi.fn(async () => ({ + data: 'raced idle prompt $ ', + cols: 80, + rows: 24, + seq: 5 + })), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('raced idle prompt $ ') + ) + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).not.toContain('suffix-only redraw') + expect(runtime.waitForRendererTerminalSerializer).toHaveBeenCalledWith( + 'pty-1', + 1, + undefined, + expect.any(AbortSignal) + ) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('replays a late recovery when readiness lands after the bounded initial response', async () => { + vi.useFakeTimers() + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let mounted = false + let signalWaitStarted!: () => void + const waitStarted = new Promise((resolve) => { + signalWaitStarted = resolve + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-delayed' }), + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + getPtyOutputSequence: vi.fn(() => (mounted ? 4 : 0)), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn( + (_ptyId: string, _afterGeneration: number, timeoutMs: number | undefined) => { + expect(timeoutMs).toBeUndefined() + signalWaitStarted() + return new Promise((resolve) => { + setTimeout(() => { + mounted = true + resolve(true) + }, 3_001) + }) + } + ), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async () => ({ + data: mounted ? 'delayed idle prompt $ ' : 'late suffix-only redraw', + cols: 80, + rows: 24, + seq: mounted ? 4 : 1 + })), + serializeRendererTerminalBuffer: vi.fn(async () => + mounted ? { data: 'delayed idle prompt $ ', cols: 80, rows: 24, seq: 4 } : null + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await waitStarted + await vi.advanceTimersByTimeAsync(3_000) + await Promise.resolve() + expect(binaryFrames.length).toBeGreaterThan(0) + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('late suffix-only redraw') + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).not.toContain('delayed idle prompt $ ') + await vi.advanceTimersByTimeAsync(1) + await Promise.resolve() + await Promise.resolve() + + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('delayed idle prompt $ ') + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + vi.useRealTimers() + }) + + it('recovers from the pre-mount generation when suffix state appears during the PTY wait', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let generation = 0 + let headlessPresent = false + const requestRendererTerminalTabMount = vi.fn(() => { + generation = 1 + return true + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue(null), + waitForLeafPtyId: vi.fn(async () => { + // A redraw may reach main before the PTY wait completes, but it does + // not contain the legacy terminal's restored history. + headlessPresent = true + return 'pty-late-leaf' + }), + hasHeadlessTerminalState: vi.fn(() => headlessPresent), + requestRendererTerminalTabMount, + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 0), + getRendererTerminalSerializerGeneration: vi.fn(() => generation), + getPtyOutputSequence: vi.fn(() => 0), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { + return generation > afterGeneration + }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'suffix-only redraw', + cols: 80, + rows: 24, + seq: 1 + }), + serializeRendererTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'late leaf prompt $ ', + cols: 80, + rows: 24 + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('late leaf prompt $ ') + ) + expect(requestRendererTerminalTabMount).toHaveBeenCalledTimes(1) + expect(runtime.waitForRendererTerminalSerializer).toHaveBeenCalledWith( + 'pty-late-leaf', + 0, + undefined, + expect.any(AbortSignal) + ) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('cancels the mount-ready wait when the mobile subscription closes', async () => { + const cleanups = new Map void>() + let waitSignal: AbortSignal | undefined + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + getPtyOutputSequence: vi.fn(() => 0), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn( + (_ptyId: string, _afterGeneration: number, _timeoutMs: number, signal?: AbortSignal) => { + waitSignal = signal + return new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + } + ), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + serializeRendererTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => expect(runtime.waitForRendererTerminalSerializer).toHaveBeenCalled()) + runtime.cleanupSubscription('terminal-1:phone-1') + + expect(waitSignal?.aborted).toBe(true) + await dispatchPromise + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts b/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts new file mode 100644 index 00000000000..91cd571e0f4 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +const request: RpcRequest = { + id: 'req-output-race', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-output-race', + client: { id: 'phone-output-race', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } +} + +describe('terminal subscribe renderer recovery output ordering', () => { + it('replays bytes absent from the renderer snapshot using its exact sequence boundary', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let outputSequence = 0 + let rendererSerializeCalls = 0 + let onData: + | ((data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void) + | undefined + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-output-race' }), + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + waitForRendererTerminalSerializer: vi.fn().mockResolvedValue(true), + getPtyOutputSequence: vi.fn(() => outputSequence), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + onData = listener + return vi.fn() + }), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + serializeRendererTerminalBuffer: vi.fn(async () => { + rendererSerializeCalls += 1 + if (rendererSerializeCalls === 1) { + outputSequence = 6 + onData?.('during', { seq: 6, rawLength: 6 }) + return { data: 'stale renderer', cols: 80, rows: 24 } + } + return { data: 'restored history', cols: 80, rows: 24, seq: 0 } + }), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(() => { + outputSequence = 11 + onData?.('after', { seq: 11, rawLength: 5 }) + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => { + const output = binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + expect(output).toBe('duringafter') + }) + + const snapshot = binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + expect(snapshot).toBe('restored history') + expect(rendererSerializeCalls).toBe(2) + + runtime.cleanupSubscription('terminal-output-race:phone-output-race') + await dispatchPromise + }) +}) diff --git a/src/main/runtime/runtime-mobile-file-path-search.test.ts b/src/main/runtime/runtime-mobile-file-path-search.test.ts new file mode 100644 index 00000000000..495a7933842 --- /dev/null +++ b/src/main/runtime/runtime-mobile-file-path-search.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { + rankRuntimeMobileFilePaths, + type RuntimeMobileFilePathInventory, + RuntimeMobileFilePathSearchCache +} from './runtime-mobile-file-path-search' + +describe('rankRuntimeMobileFilePaths', () => { + it('ranks path and basename prefixes before substrings and caps output', () => { + expect( + rankRuntimeMobileFilePaths( + ['docs/apple.md', 'src/apple.ts', 'src/deep/pineapple.ts', 'z/apple.test.ts'], + 'apple', + 3 + ) + ).toEqual({ + paths: ['docs/apple.md', 'src/apple.ts', 'z/apple.test.ts'], + totalCount: 4 + }) + }) +}) + +describe('RuntimeMobileFilePathSearchCache', () => { + it('reuses a live entry, reloads after expiry, and evicts the oldest key', async () => { + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + const load = vi.fn(async (path: string) => ({ + paths: [path], + totalCount: 1, + truncated: false + })) + + await cache.get('a', () => load('a'), 0) + await cache.get('a', () => load('a-again'), 50) + await cache.get('b', () => load('b'), 50) + await cache.get('c', () => load('c'), 50) + await cache.get('a', () => load('a-reloaded'), 50) + + expect(load.mock.calls.map((call) => call[0])).toEqual(['a', 'b', 'c', 'a-reloaded']) + }) + + it('reloads an unevicted key once its TTL has elapsed', async () => { + // TTL expiry without capacity pressure — exercises the now >= expiresAt path + // that LRU eviction otherwise masks. + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + const load = vi.fn(async (path: string) => ({ paths: [path], totalCount: 1, truncated: false })) + + await cache.get('a', () => load('a'), 0) + await cache.get('a', () => load('a-live'), 50) + await cache.get('a', () => load('a-expired'), 150) + + expect(load.mock.calls.map((call) => call[0])).toEqual(['a', 'a-expired']) + }) + + it('coalesces concurrent cold loads for the same worktree', async () => { + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + let resolveLoad: (value: RuntimeMobileFilePathInventory) => void = () => {} + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve + }) + ) + + const first = cache.get('a', load, 0) + const second = cache.get('a', load, 0) + expect(load).toHaveBeenCalledOnce() + + resolveLoad({ paths: ['a'], totalCount: 1, truncated: false }) + await expect(Promise.all([first, second])).resolves.toEqual([ + { paths: ['a'], totalCount: 1, truncated: false }, + { paths: ['a'], totalCount: 1, truncated: false } + ]) + }) + + it('starts the TTL when a slow inventory becomes usable', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(0) + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + let resolveLoad: (value: RuntimeMobileFilePathInventory) => void = () => {} + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve + }) + ) + const first = cache.get('slow-ssh', load) + vi.setSystemTime(200) + resolveLoad({ paths: ['a'], totalCount: 1, truncated: false }) + await first + + await cache.get('slow-ssh', load) + expect(load).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/runtime-mobile-file-path-search.ts b/src/main/runtime/runtime-mobile-file-path-search.ts new file mode 100644 index 00000000000..13213d6dfcf --- /dev/null +++ b/src/main/runtime/runtime-mobile-file-path-search.ts @@ -0,0 +1,93 @@ +export type RuntimeMobileFilePathInventory = { + paths: string[] + totalCount: number + truncated: boolean +} + +type CacheEntry = RuntimeMobileFilePathInventory & { expiresAt: number } + +/** Lazy TTL/LRU cache for autocomplete inventories. It avoids launching rg for + * every mobile keystroke while bounding retained worktrees and paths. */ +export class RuntimeMobileFilePathSearchCache { + private readonly entries = new Map() + private readonly inFlight = new Map>() + + constructor( + private readonly maxEntries: number, + private readonly ttlMs: number + ) {} + + async get( + key: string, + load: () => Promise, + now?: number + ): Promise { + const requestedAt = now ?? Date.now() + const cached = this.entries.get(key) + if (cached && cached.expiresAt > requestedAt) { + this.entries.delete(key) + this.entries.set(key, cached) + return cached + } + this.entries.delete(key) + const pending = this.inFlight.get(key) + if (pending) { + return pending + } + const next = load() + .then((loaded) => { + // Why: a slow SSH scan should receive a full TTL after it becomes usable, + // not arrive already expired because the clock started before its I/O. + this.entries.set(key, { ...loaded, expiresAt: (now ?? Date.now()) + this.ttlMs }) + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value as string | undefined + if (!oldest) { + break + } + this.entries.delete(oldest) + } + return loaded + }) + .finally(() => { + if (this.inFlight.get(key) === next) { + this.inFlight.delete(key) + } + }) + // Why: debounced clients can overlap on a cold key; sharing this promise + // prevents duplicate local rg or SSH inventory scans. + this.inFlight.set(key, next) + return next + } +} + +/** Preserves composer ranking: full-path/basename prefixes first, then substring + * matches, while returning only the requested bounded candidate slice. */ +export function rankRuntimeMobileFilePaths( + paths: readonly string[], + query: string, + limit: number +): { paths: string[]; totalCount: number } { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return { paths: paths.slice(0, limit), totalCount: paths.length } + } + const prefix: string[] = [] + const substring: string[] = [] + let totalCount = 0 + for (const path of paths) { + const lower = path.toLowerCase() + const basename = lower.split('/').pop() ?? lower + if (lower.startsWith(normalizedQuery) || basename.startsWith(normalizedQuery)) { + totalCount++ + if (prefix.length < limit) { + prefix.push(path) + } + } else if (lower.includes(normalizedQuery)) { + totalCount++ + if (substring.length < limit) { + substring.push(path) + } + } + } + return { paths: [...prefix, ...substring].slice(0, limit), totalCount } +} diff --git a/src/main/runtime/runtime-root-watch-ownership.ts b/src/main/runtime/runtime-root-watch-ownership.ts new file mode 100644 index 00000000000..890fa802033 --- /dev/null +++ b/src/main/runtime/runtime-root-watch-ownership.ts @@ -0,0 +1,41 @@ +import { + isWatcherProcessFailure, + type WatcherProcessFailure +} from '../ipc/parcel-watcher-process-failure' + +type RuntimeRootOwnership = { + rootPath: string + terminalReleaseError: WatcherProcessFailure | null +} + +export function createRuntimeRootOwnershipReleaser( + roots: Map, + forgetRoot: (rootPath: string) => void +): { + release: (root: T) => void + releaseAfterFailure: (root: T, error: Error) => void +} { + const release = (root: T): void => { + if (roots.get(root.rootPath) !== root) { + return + } + roots.delete(root.rootPath) + forgetRoot(root.rootPath) + } + const releaseAfterFailure = (root: T, error: Error): void => { + if (!(isWatcherProcessFailure(error) && error.physicalExit)) { + release(root) + return + } + root.terminalReleaseError = error + // Why: an unkillable child can still own the native root after logical + // teardown; reserve the exact root until that child's physical exit. + void error.physicalExit.then(() => { + if (root.terminalReleaseError === error) { + root.terminalReleaseError = null + release(root) + } + }) + } + return { release, releaseAfterFailure } +} diff --git a/src/main/runtime/runtime-root-watch-teardown.ts b/src/main/runtime/runtime-root-watch-teardown.ts new file mode 100644 index 00000000000..e9353d1da49 --- /dev/null +++ b/src/main/runtime/runtime-root-watch-teardown.ts @@ -0,0 +1,49 @@ +import { isWatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' + +type RuntimeRootWatchTeardown = { + closed: boolean + generation: number + abortController: AbortController + subscription: { unsubscribe(): Promise } | null + terminalReleaseError: Error | null +} + +export async function closeRuntimeRootWatch( + root: RuntimeRootWatchTeardown, + pendingGeneration: Promise, + release: () => void, + releaseAfterFailure: (error: Error) => void +): Promise { + if (root.closed) { + if (root.terminalReleaseError) { + throw root.terminalReleaseError + } + return + } + root.closed = true + root.generation++ + root.abortController.abort() + try { + await pendingGeneration + } catch (error) { + if (!(isWatcherProcessFailure(error) && error.code === 'subscribe_aborted')) { + if (error instanceof Error) { + releaseAfterFailure(error) + } else { + release() + } + throw error + } + } + try { + await root.subscription?.unsubscribe() + release() + } catch (error) { + if (error instanceof Error) { + releaseAfterFailure(error) + } else { + release() + } + throw error + } +} diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 62e1af606df..d7623065634 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -199,6 +199,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'files.readTerminalArtifact', 'files.readTerminalArtifactPreview', 'files.resolveTerminalPath', + 'files.searchPaths', 'files.writeTerminalArtifact', 'folderWorkspace.list', 'git.abortMerge', @@ -376,6 +377,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentTeams.tmuxCompat', 'terminal.clearBuffer', 'terminal.close', + 'terminal.closeTab', 'terminal.create', 'terminal.focus', 'terminal.agentStatus', diff --git a/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts new file mode 100644 index 00000000000..d84283e9181 --- /dev/null +++ b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts @@ -0,0 +1,162 @@ +/** STA-1840 regression: known blank-terminal handles request a bounded renderer mount. */ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +type HandleSeed = { + handle: string + worktreeId: string + tabId: string + ptyId: string | null +} + +type RuntimeInternals = { + handles: Map< + string, + HandleSeed & { + runtimeId: string + rendererGraphEpoch: number + leafId: string + ptyGeneration: number + } + > + getAuthoritativeWindow: () => { + webContents: { send: (channel: string, payload: unknown) => void } + } + leaves: Map + issueHandle: (leaf: unknown) => string +} + +function seedRuntime(seeds: HandleSeed[]): { + runtime: OrcaRuntimeService + send: ReturnType +} { + const runtime = new OrcaRuntimeService() + const internals = runtime as unknown as RuntimeInternals + for (const seed of seeds) { + internals.handles.set(seed.handle, { + ...seed, + runtimeId: 'rt-test', + rendererGraphEpoch: 1, + leafId: 'leaf-1', + ptyGeneration: 1 + }) + } + const send = vi.fn() + internals.getAuthoritativeWindow = () => ({ webContents: { send } }) + return { runtime, send } +} + +describe('mobile terminal subscribe tab mount', () => { + it('keeps a waiting real-tab handle usable when the mount binds its first PTY', async () => { + const runtime = new OrcaRuntimeService() + const syncGraph = (ptyId: string | null): void => { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + title: 'Terminal', + activeLeafId: 'leaf-1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: 'leaf-1', + paneRuntimeId: 1, + ptyId + } + ] + }) + } + + runtime.attachWindow(1) + syncGraph(null) + const internals = runtime as unknown as RuntimeInternals + const leaf = internals.leaves.values().next().value + if (!leaf) { + throw new Error('expected terminal leaf') + } + const handle = internals.issueHandle(leaf) + const ptyWait = runtime.waitForLeafPtyId(handle) + + syncGraph('pty-1') + + await expect(ptyWait).resolves.toBe('pty-1') + expect(runtime.resolveLiveLeafForHandle(handle)).toEqual({ ptyId: 'pty-1' }) + await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ handle }) + }) + + it('requests a tab mount by tabId for a real-tab handle awaiting its PTY (null-leaf blank path)', () => { + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: null } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).toHaveBeenCalledWith('terminal:requestTabMount', { + worktreeId: 'wt-1', + tabId: 'tab-1' + }) + }) + + it('requests a tab mount by tabId even when a real-tab handle carries a ptyId', () => { + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: 'wt-1@@abc' } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).toHaveBeenCalledWith('terminal:requestTabMount', { + worktreeId: 'wt-1', + tabId: 'tab-1', + ptyId: 'wt-1@@abc' + }) + }) + + it('requests a mount by ptyId for a synthetic pty-form handle (never-mounted workspace)', () => { + // Why: never-mounted workspaces expose only synthetic pty handles to mobile. + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:wt-1@@abc', ptyId: 'wt-1@@abc' } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).toHaveBeenCalledWith('terminal:requestTabMount', { + worktreeId: 'wt-1', + ptyId: 'wt-1@@abc' + }) + }) + + it('does not request a mount when a pty-form handle has no ptyId', () => { + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:abc', ptyId: null } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).not.toHaveBeenCalled() + }) + + it('does not request a mount for an unknown handle', () => { + const { runtime, send } = seedRuntime([]) + + runtime.requestRendererTerminalTabMount('missing') + + expect(send).not.toHaveBeenCalled() + }) + + it('swallows window lookup failures so subscribe keeps its fallback', () => { + const { runtime } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: null } + ]) + const internals = runtime as unknown as RuntimeInternals + internals.getAuthoritativeWindow = () => { + throw new Error('no window') + } + + expect(() => runtime.requestRendererTerminalTabMount('h1')).not.toThrow() + }) +}) diff --git a/src/main/runtime/windows-mobile-firewall.test.ts b/src/main/runtime/windows-mobile-firewall.test.ts index 56464c15bf1..19109a444b8 100644 --- a/src/main/runtime/windows-mobile-firewall.test.ts +++ b/src/main/runtime/windows-mobile-firewall.test.ts @@ -25,6 +25,7 @@ describe('windows mobile firewall', () => { const runPowerShell = vi.fn().mockResolvedValue( JSON.stringify({ matchingRuleScopes: [{ remoteAddresses: ['192.168.0.0/24'] }], + blockingRuleDetected: false, localAddress: '192.168.0.108', localPrefixLength: 24, privateFirewallEnabled: true, @@ -38,22 +39,55 @@ describe('windows mobile firewall', () => { supported: true, port: 6768, ruleAllowed: true, + blockingRuleDetected: false, privateFirewallEnabled: true, networkCategory: 'private', inspectionAvailable: true }) const script = runPowerShell.mock.calls[0]![0] as string + // Why: without ActiveStore, GPO-applied Block rules are invisible and the + // post-repair re-inspection could report a false success on managed hosts. + expect(script).toContain( + "Get-NetFirewallApplicationFilter -PolicyStore ActiveStore -Program 'C:\\Users\\O''Brien\\Orca\\Orca.exe'" + ) + expect(script).toContain('Get-NetFirewallProfile -PolicyStore ActiveStore -Name Private') 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'") expect(script).toContain('Get-NetFirewallAddressFilter') + expect(script).toContain("[string]$rule.Action -eq 'Block'") expect(script).toContain('remoteAddresses = @($addressFilter.RemoteAddress') expect(script).toContain('$localPrefixLength = [int]$ip.PrefixLength') }) + it('treats an overlapping inbound Block rule as overriding a matching Allow rule', async () => { + const runPowerShell = vi.fn().mockResolvedValue( + JSON.stringify({ + matchingRuleScopes: [{ remoteAddresses: ['Any'] }], + blockingRuleDetected: true, + localAddress: '192.168.0.108', + localPrefixLength: 24, + privateFirewallEnabled: true, + networkCategory: 'Private' + }) + ) + + await expect( + inspectWindowsMobileFirewall(6768, '192.168.0.108', environment(runPowerShell)) + ).resolves.toEqual({ + supported: true, + port: 6768, + ruleAllowed: false, + blockingRuleDetected: true, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + }) + }) + it('does not accept a qualifying rule whose remote-address scope excludes the phone subnet', async () => { const runPowerShell = vi.fn().mockResolvedValue( JSON.stringify({ @@ -100,6 +134,7 @@ describe('windows mobile firewall', () => { supported: true, port: 6768, ruleAllowed: false, + blockingRuleDetected: false, privateFirewallEnabled: true, networkCategory: 'unknown', inspectionAvailable: false @@ -133,6 +168,10 @@ describe('windows mobile firewall', () => { expect(encoded).toBeTruthy() const repairScript = Buffer.from(encoded!, 'base64').toString('utf16le') expect(repairScript).toContain("-Name 'Orca.MobilePairing'") + expect(repairScript).toContain( + "Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Block' }" + ) + expect(repairScript).toContain('$rule | Remove-NetFirewallRule') expect(repairScript).toContain('-Profile Private') expect(repairScript).toContain('-Protocol TCP') expect(repairScript).toContain('-LocalPort 6769') diff --git a/src/main/runtime/windows-mobile-firewall.ts b/src/main/runtime/windows-mobile-firewall.ts index 36797b83eb2..88b885ab5f0 100644 --- a/src/main/runtime/windows-mobile-firewall.ts +++ b/src/main/runtime/windows-mobile-firewall.ts @@ -24,6 +24,7 @@ export type WindowsMobileFirewallEnvironment = { type FirewallInspection = { matchingRuleScopes?: unknown + blockingRuleDetected?: unknown localAddress?: unknown localPrefixLength?: unknown privateFirewallEnabled: boolean @@ -51,14 +52,20 @@ export async function inspectWindowsMobileFirewall( POWERSHELL_TIMEOUT_MS ) const result = JSON.parse(stdout.trim()) as FirewallInspection + // Why: the phone address is unknown before pairing, so any matching Block + // rule must fail this advisory check closed instead of risking false success. + const blockingRuleDetected = result.blockingRuleDetected === true return { supported: true, port, - ruleAllowed: hasSufficientWindowsFirewallRemoteScope( - result.matchingRuleScopes, - result.localAddress, - result.localPrefixLength - ), + ruleAllowed: + !blockingRuleDetected && + hasSufficientWindowsFirewallRemoteScope( + result.matchingRuleScopes, + result.localAddress, + result.localPrefixLength + ), + blockingRuleDetected, privateFirewallEnabled: result.privateFirewallEnabled !== false, networkCategory: parseNetworkCategory(result.networkCategory), inspectionAvailable: true @@ -131,6 +138,7 @@ function unavailableStatus(port: number): WindowsMobileFirewallStatus { supported: true, port, ruleAllowed: false, + blockingRuleDetected: false, privateFirewallEnabled: true, networkCategory: 'unknown', inspectionAvailable: false @@ -166,26 +174,34 @@ try { } catch {}` : '' // Why: NetSecurity filter properties are stable across localized Windows - // display output and keep every rule's address scope independent. + // display output and keep every rule's address scope independent. ActiveStore + // includes GPO-applied rules the default persistent store hides, so managed + // Block rules cannot produce a false success after repair. return `$ErrorActionPreference = 'Stop' $matchingRuleScopes = @() -$rules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' }) +$blockingRuleDetected = $false +$rules = @(Get-NetFirewallApplicationFilter -PolicyStore ActiveStore -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' }) 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) { - $addressFilter = $rule | Get-NetFirewallAddressFilter - $matchingRuleScopes += [pscustomobject]@{ - remoteAddresses = @($addressFilter.RemoteAddress | ForEach-Object { [string]$_ }) + if ([string]$rule.Action -eq 'Block') { + $blockingRuleDetected = $true + } elseif ([string]$rule.Action -eq 'Allow') { + $addressFilter = $rule | Get-NetFirewallAddressFilter + $matchingRuleScopes += [pscustomobject]@{ + remoteAddresses = @($addressFilter.RemoteAddress | ForEach-Object { [string]$_ }) + } } } } -$privateFirewallEnabled = [bool](Get-NetFirewallProfile -Name Private).Enabled +$privateFirewallEnabled = [bool](Get-NetFirewallProfile -PolicyStore ActiveStore -Name Private).Enabled $networkCategory = 'Unknown'${addressLookup} [pscustomobject]@{ matchingRuleScopes = @($matchingRuleScopes) + blockingRuleDetected = $blockingRuleDetected localAddress = $localAddress localPrefixLength = $localPrefixLength privateFirewallEnabled = $privateFirewallEnabled @@ -194,7 +210,21 @@ $networkCategory = 'Unknown'${addressLookup} } function buildRepairScript(port: number, executablePath: string): string { + // Why: Windows gives explicit Block rules precedence over narrower Allow + // rules, so the user's repair action must remove exact-app conflicts first. + // Removal deliberately ignores the Block rule's remote-address scope, + // mirroring the fail-closed inspection (the phone address is unknown). return `$ErrorActionPreference = 'Stop' +$blockingRules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Block' }) +foreach ($rule in $blockingRules) { + $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) { + $rule | Remove-NetFirewallRule + } +} 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` } diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index 63e207cd8da..102c47465b4 100644 --- a/src/main/runtime/worktree-teardown.test.ts +++ b/src/main/runtime/worktree-teardown.test.ts @@ -8,12 +8,10 @@ vi.mock('../memory/pty-registry', () => ({ listRegisteredPtys: listRegisteredPtysMock })) -import { killAllProcessesForWorktree } from './worktree-teardown' -import type { IPtyProvider } from '../providers/types' +import { killAllProcessesForWorktree, WORKTREE_PROCESS_SWEEP_TIMEOUT_MS } from './worktree-teardown' +import type { IPtyProvider, PtyProcessInfo } from '../providers/types' -function createProviderStub( - listProcesses: () => Promise<{ id: string; cwd: string; title: string }[]> -): IPtyProvider { +function createProviderStub(listProcesses: () => Promise): IPtyProvider { return { spawn: vi.fn(), attach: vi.fn(), @@ -92,6 +90,69 @@ describe('killAllProcessesForWorktree', () => { expect(onPtyStopped).toHaveBeenCalledWith('1') }) + it('stops cwd-owned PTYs even when their ids have no worktree prefix', async () => { + const localProvider = createProviderStub(async () => [ + { id: 'floating-1', cwd: '/repo/app/nested', title: 'shell' }, + { id: 'outside-2', cwd: '/repo/application', title: 'shell' }, + { id: 'repo-1::/repo/app@@legacy-3', title: 'shell' } as PtyProcessInfo + ]) + listRegisteredPtysMock.mockReturnValue([]) + + const result = await killAllProcessesForWorktree('repo-1::/repo/app', { + localProvider, + requirePhysicalStop: true + }) + + expect(localProvider.shutdown).toHaveBeenCalledWith('floating-1', { immediate: true }) + expect(localProvider.shutdown).toHaveBeenCalledWith('repo-1::/repo/app@@legacy-3', { + immediate: true + }) + expect(localProvider.shutdown).not.toHaveBeenCalledWith('outside-2', { immediate: true }) + expect(result.providerStopped).toBe(2) + }) + + it('does not use cwd fallback against a different authoritative worktree id', async () => { + const localProvider = createProviderStub(async () => [ + { + id: 'owned-by-sibling', + cwd: '/repo/app/nested', + title: 'shell', + worktreeId: 'repo-1::/repo/sibling' + } + ]) + listRegisteredPtysMock.mockReturnValue([]) + + const result = await killAllProcessesForWorktree('repo-1::/repo/app', { + localProvider, + requirePhysicalStop: true + }) + + expect(localProvider.shutdown).not.toHaveBeenCalled() + expect(result.providerStopped).toBe(0) + }) + + it('uses authoritative remote worktree ownership without sweeping the local registry', async () => { + const remoteProvider = createProviderStub(async () => [ + { id: 'pty-remote', cwd: '/remote/w1', title: 'shell', worktreeId: 'w1' }, + { id: 'pty-sibling', cwd: '/remote/w2', title: 'shell', worktreeId: 'w2' } + ]) + listRegisteredPtysMock.mockReturnValue([ + { ptyId: 'local-1', worktreeId: 'w1', sessionId: null, paneKey: null, pid: 200 } + ]) + + const result = await killAllProcessesForWorktree('w1', { + localProvider: remoteProvider, + includeLocalRegistry: false, + requirePhysicalStop: true + }) + + expect(remoteProvider.shutdown).toHaveBeenCalledWith('pty-remote', { immediate: true }) + expect(remoteProvider.shutdown).not.toHaveBeenCalledWith('pty-sibling', { immediate: true }) + expect(remoteProvider.shutdown).not.toHaveBeenCalledWith('local-1', { immediate: true }) + expect(listRegisteredPtysMock).not.toHaveBeenCalled() + expect(result).toEqual({ runtimeStopped: 0, providerStopped: 1, registryStopped: 0 }) + }) + it('best-effort: swallows errors from listProcesses and shutdown', async () => { const localProvider = createProviderStub(() => Promise.reject(new Error('boom'))) listRegisteredPtysMock.mockReturnValue([ @@ -146,6 +207,71 @@ describe('killAllProcessesForWorktree', () => { expect(r2.providerStopped).toBe(1) }) + it('starts owned provider shutdowns together so agent snapshots can coalesce', async () => { + const localProvider = createProviderStub(async () => [ + { id: 'w1@@aaaa', cwd: '/tmp', title: 'shell' }, + { id: 'w1@@bbbb', cwd: '/tmp', title: 'shell' } + ]) + listRegisteredPtysMock.mockReturnValue([]) + const releases: (() => void)[] = [] + ;(localProvider.shutdown as unknown as ReturnType).mockImplementation( + () => + new Promise((resolve) => { + releases.push(resolve) + }) + ) + + const teardown = killAllProcessesForWorktree('w1', { localProvider }) + await vi.waitFor(() => expect(localProvider.shutdown).toHaveBeenCalledTimes(2)) + expect(releases).toHaveLength(2) + + for (const release of releases) { + release() + } + await expect(teardown).resolves.toEqual({ + runtimeStopped: 0, + providerStopped: 2, + registryStopped: 0 + }) + }) + + it('bounds provider shutdown fanout while preserving concurrent batches', async () => { + const sessions = Array.from({ length: 40 }, (_, index) => ({ + id: `w1@@${index}`, + cwd: '/tmp', + title: 'shell' + })) + const localProvider = createProviderStub(async () => sessions) + listRegisteredPtysMock.mockReturnValue([]) + let active = 0 + let maxActive = 0 + const releases: (() => void)[] = [] + ;(localProvider.shutdown as unknown as ReturnType).mockImplementation( + () => + new Promise((resolve) => { + active += 1 + maxActive = Math.max(maxActive, active) + releases.push(() => { + active -= 1 + resolve() + }) + }) + ) + + const teardown = killAllProcessesForWorktree('w1', { localProvider }) + await vi.waitFor(() => expect(localProvider.shutdown).toHaveBeenCalledTimes(32)) + expect(maxActive).toBe(32) + releases.splice(0).forEach((release) => release()) + await vi.waitFor(() => expect(localProvider.shutdown).toHaveBeenCalledTimes(40)) + releases.splice(0).forEach((release) => release()) + + await expect(teardown).resolves.toEqual({ + runtimeStopped: 0, + providerStopped: 40, + registryStopped: 0 + }) + }) + it('invokes runtime.stopTerminalsForWorktree when runtime is provided', async () => { const stopTerminalsForWorktree = vi.fn().mockResolvedValue({ stopped: 3 }) const runtime = { @@ -157,12 +283,147 @@ describe('killAllProcessesForWorktree', () => { const result = await killAllProcessesForWorktree('w1', { runtime, localProvider }) - expect(stopTerminalsForWorktree).toHaveBeenCalledWith('w1') + expect(stopTerminalsForWorktree).toHaveBeenCalledWith('w1', { + deadline: expect.any(Number), + stopPty: expect.any(Function) + }) expect(result.runtimeStopped).toBe(3) }) + it('claims duplicate provider and registry PTY ids only once', async () => { + const localProvider = createProviderStub(async () => [ + { id: 'w1@@same', cwd: '/tmp/w1', title: 'shell' } + ]) + listRegisteredPtysMock.mockReturnValue([ + { ptyId: 'w1@@same', worktreeId: 'w1', sessionId: null, paneKey: null, pid: 100 } + ]) + + const result = await killAllProcessesForWorktree('w1', { localProvider }) + + expect(localProvider.shutdown).toHaveBeenCalledTimes(1) + expect(localProvider.shutdown).toHaveBeenCalledWith('w1@@same', { immediate: true }) + expect(result.providerStopped + result.registryStopped).toBe(1) + }) + + it('falls back to provider shutdown when the runtime cannot stop an overlapping PTY', async () => { + const stopTerminalsForWorktree = vi.fn( + async ( + _worktreeId: string, + options: { + stopPty: ( + ptyId: string, + stop: () => boolean + ) => Promise<{ stopped: boolean; owner: boolean }> + } + ) => ({ + stopped: (await options.stopPty('w1@@same', () => false)).owner ? 1 : 0 + }) + ) + const runtime = { + stopTerminalsForWorktree + } as unknown as Parameters[1]['runtime'] + const localProvider = createProviderStub(async () => []) + listRegisteredPtysMock.mockReturnValue([ + { ptyId: 'w1@@same', worktreeId: 'w1', sessionId: null, paneKey: null, pid: 100 } + ]) + + const result = await killAllProcessesForWorktree('w1', { runtime, localProvider }) + + expect(result.runtimeStopped).toBe(0) + expect(result.registryStopped).toBe(1) + expect(localProvider.shutdown).toHaveBeenCalledTimes(1) + }) + + it('keeps duplicate sweeps behind the runtime physical-stop promise', async () => { + let releasePhysicalStop: () => void = () => undefined + const physicalStop = new Promise((resolve) => { + releasePhysicalStop = () => resolve(true) + }) + const stopTerminalsForWorktree = vi.fn( + async ( + _worktreeId: string, + options: { + stopPty: ( + ptyId: string, + stop: () => Promise + ) => Promise<{ stopped: boolean; owner: boolean }> + } + ) => ({ + stopped: (await options.stopPty('w1@@same', () => physicalStop)).owner ? 1 : 0 + }) + ) + const runtime = { + stopTerminalsForWorktree + } as unknown as Parameters[1]['runtime'] + const localProvider = createProviderStub(async () => [ + { id: 'w1@@same', cwd: '/tmp/w1', title: 'shell' } + ]) + listRegisteredPtysMock.mockReturnValue([ + { ptyId: 'w1@@same', worktreeId: 'w1', sessionId: null, paneKey: null, pid: 100 } + ]) + + const teardown = killAllProcessesForWorktree('w1', { runtime, localProvider }) + await vi.waitFor(() => expect(stopTerminalsForWorktree).toHaveBeenCalledTimes(1)) + expect(localProvider.shutdown).not.toHaveBeenCalled() + + releasePhysicalStop() + await expect(teardown).resolves.toEqual({ + runtimeStopped: 1, + providerStopped: 0, + registryStopped: 0 + }) + expect(localProvider.shutdown).not.toHaveBeenCalled() + }) + + it('fails destructive teardown closed when physical stop misses the deadline', async () => { + vi.useFakeTimers() + try { + const physicalStop = new Promise(() => {}) + const stopTerminalsForWorktree = vi.fn( + async ( + _worktreeId: string, + options: { + stopPty: ( + ptyId: string, + stop: () => Promise + ) => Promise<{ stopped: boolean; owner: boolean }> + } + ) => ({ + stopped: (await options.stopPty('w1@@same', () => physicalStop)).owner ? 1 : 0 + }) + ) + const runtime = { + stopTerminalsForWorktree + } as unknown as Parameters[1]['runtime'] + const localProvider = createProviderStub(async () => [ + { id: 'w1@@same', cwd: '/tmp/w1', title: 'shell' } + ]) + listRegisteredPtysMock.mockReturnValue([ + { ptyId: 'w1@@same', worktreeId: 'w1', sessionId: null, paneKey: null, pid: 100 } + ]) + + const teardown = killAllProcessesForWorktree('w1', { + runtime, + localProvider, + timeoutMs: 25, + requirePhysicalStop: true + }) + const failure = expect(teardown).rejects.toThrow( + 'Timed out waiting for physical PTY teardown' + ) + await vi.advanceTimersByTimeAsync(24) + expect(localProvider.shutdown).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + + await failure + expect(localProvider.shutdown).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('tolerates runtime.stopTerminalsForWorktree throwing (headless assertGraphReady reject)', async () => { - const stopTerminalsForWorktree = vi.fn().mockRejectedValue(new Error('graph not ready')) + const stopTerminalsForWorktree = vi.fn().mockRejectedValue(new Error('runtime_unavailable')) const runtime = { stopTerminalsForWorktree } as unknown as Parameters[1]['runtime'] @@ -174,4 +435,109 @@ describe('killAllProcessesForWorktree', () => { expect(result.runtimeStopped).toBe(0) }) + + it('fails destructive teardown closed when the runtime sweep rejects', async () => { + const stopTerminalsForWorktree = vi.fn().mockRejectedValue(new Error('runtime sweep failed')) + const runtime = { + stopTerminalsForWorktree + } as unknown as Parameters[1]['runtime'] + const localProvider = createProviderStub(async () => []) + listRegisteredPtysMock.mockReturnValue([]) + + await expect( + killAllProcessesForWorktree('w1', { + runtime, + localProvider, + requirePhysicalStop: true + }) + ).rejects.toThrow('runtime sweep failed') + }) + + it('bounds the entire process sweep when a provider never settles', async () => { + vi.useFakeTimers() + try { + const localProvider = createProviderStub(() => new Promise(() => {})) + listRegisteredPtysMock.mockReturnValue([ + { ptyId: 'registry-1', worktreeId: 'w1', sessionId: null, paneKey: null, pid: 12 } + ]) + let completed = false + const teardown = killAllProcessesForWorktree('w1', { + localProvider, + timeoutMs: WORKTREE_PROCESS_SWEEP_TIMEOUT_MS + }).then((result) => { + completed = true + return result + }) + + await vi.advanceTimersByTimeAsync(WORKTREE_PROCESS_SWEEP_TIMEOUT_MS - 1) + expect(completed).toBe(false) + await vi.advanceTimersByTimeAsync(1) + + await expect(teardown).resolves.toEqual({ + runtimeStopped: 0, + providerStopped: 0, + registryStopped: 1 + }) + expect(localProvider.shutdown).toHaveBeenCalledWith('registry-1', { immediate: true }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('does not issue shutdown after a timed-out provider list settles late', async () => { + vi.useFakeTimers() + try { + let resolveList: (sessions: { id: string; cwd: string; title: string }[]) => void = () => {} + const localProvider = createProviderStub( + () => + new Promise((resolve) => { + resolveList = resolve + }) + ) + listRegisteredPtysMock.mockReturnValue([]) + const teardown = killAllProcessesForWorktree('w1', { localProvider, timeoutMs: 25 }) + + await vi.advanceTimersByTimeAsync(25) + await expect(teardown).resolves.toMatchObject({ providerStopped: 0 }) + resolveList([{ id: 'w1@@replacement', cwd: '/new', title: 'new' }]) + await Promise.resolve() + + expect(localProvider.shutdown).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('does not mutate PTY state after a shutdown settles beyond the deadline', async () => { + vi.useFakeTimers() + try { + let resolveShutdown: () => void = () => {} + const localProvider = createProviderStub(async () => []) + ;(localProvider.shutdown as unknown as ReturnType).mockImplementation( + () => + new Promise((resolve) => { + resolveShutdown = resolve + }) + ) + listRegisteredPtysMock.mockReturnValue([ + { ptyId: 'registry-1', worktreeId: 'w1', sessionId: null, paneKey: null, pid: 12 } + ]) + const onPtyStopped = vi.fn() + const teardown = killAllProcessesForWorktree('w1', { + localProvider, + onPtyStopped, + timeoutMs: 25 + }) + + await vi.advanceTimersByTimeAsync(25) + await expect(teardown).resolves.toMatchObject({ registryStopped: 0 }) + resolveShutdown() + await Promise.resolve() + + expect(onPtyStopped).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 7572b9bbd77..c54b6863208 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -1,11 +1,21 @@ import type { IPtyProvider } from '../providers/types' import type { OrcaRuntimeService } from './orca-runtime' import { listRegisteredPtys } from '../memory/pty-registry' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' + +// Why: normal inventories still coalesce into one process scan, while a stale +// or pathological inventory cannot fan out unbounded provider/RPC shutdowns. +const WORKTREE_TEARDOWN_CONCURRENCY = 32 export type WorktreeTeardownDeps = { runtime?: OrcaRuntimeService localProvider: IPtyProvider onPtyStopped?: (ptyId: string) => void + timeoutMs?: number + requirePhysicalStop?: boolean + includeLocalRegistry?: boolean } export type WorktreeTeardownResult = { @@ -14,6 +24,8 @@ export type WorktreeTeardownResult = { registryStopped: number } +export const WORKTREE_PROCESS_SWEEP_TIMEOUT_MS = 10_000 + /** * Kills every PTY we can prove belongs to `worktreeId`, across all three * registration surfaces (renderer graph, installed PTY provider session list, @@ -32,9 +44,8 @@ export type WorktreeTeardownResult = { * canonical source for memory attribution; it also redundantly backstops * daemon spawns. * - * Best-effort throughout: each sweep catches its own errors. The caller - * (removeManagedWorktree, worktrees:remove IPC) must run the git-level - * removal regardless of what this returns. + * Sweeps are best-effort by default. Destructive removal callers set + * `requirePhysicalStop` so a timeout or unproven stop blocks filesystem work. */ export async function killAllProcessesForWorktree( worktreeId: string, @@ -45,67 +56,228 @@ export async function killAllProcessesForWorktree( providerStopped: 0, registryStopped: 0 } - - if (deps.runtime) { - const r = await deps.runtime.stopTerminalsForWorktree(worktreeId).catch(() => ({ stopped: 0 })) - result.runtimeStopped = r.stopped + const deadline = Date.now() + Math.max(1, deps.timeoutMs ?? WORKTREE_PROCESS_SWEEP_TIMEOUT_MS) + const deadlineError = new Error(`Timed out waiting for physical PTY teardown: ${worktreeId}`) + const worktreePath = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + const stopAttempts = new Map>() + const stopPty = ( + ptyId: string, + stop: () => boolean | Promise + ): Promise<{ stopped: boolean; owner: boolean }> => { + const previous = stopAttempts.get(ptyId) ?? Promise.resolve(false) + const current = previous + .then(async (stopped) => { + if (stopped) { + return { stopped: true, owner: false } + } + const didStop = await stop() + return { stopped: didStop, owner: didStop } + }) + .catch(() => ({ stopped: false, owner: false })) + stopAttempts.set( + ptyId, + current.then(({ stopped }) => stopped) + ) + return current } - result.providerStopped = await sweepProviderByPrefix( - worktreeId, - deps.localProvider, - deps.onPtyStopped - ) - result.registryStopped = await sweepRegistryForWorktree( - worktreeId, - deps.localProvider, - deps.onPtyStopped + // Why: headless CLI has no ready renderer graph; only that known sentinel + // may fall through to the provider and registry physical-owner sweeps. + const runtimeSweep = deps.runtime + ? settleBeforeDeadline( + () => deps.runtime!.stopTerminalsForWorktree(worktreeId, { deadline, stopPty }), + { stopped: 0 }, + deadline, + deps.requirePhysicalStop ? deadlineError : undefined, + (error) => !(error instanceof Error && error.message === 'runtime_unavailable') + ) + : Promise.resolve({ stopped: 0 }) + const providerSweep = settleBeforeDeadline( + () => + sweepProviderByPrefix( + worktreeId, + worktreePath, + deps.localProvider, + deadline, + stopPty, + deps.onPtyStopped, + deps.requirePhysicalStop + ), + 0, + deadline, + deps.requirePhysicalStop ? deadlineError : undefined ) + const registrySweep = + deps.includeLocalRegistry === false + ? Promise.resolve(0) + : settleBeforeDeadline( + () => + sweepRegistryForWorktree( + worktreeId, + deps.localProvider, + deadline, + stopPty, + deps.onPtyStopped + ), + 0, + deadline, + deps.requirePhysicalStop ? deadlineError : undefined + ) + const [runtimeResult, providerStopped, registryStopped] = await Promise.all([ + runtimeSweep, + providerSweep, + registrySweep + ]) + result.runtimeStopped = runtimeResult.stopped + result.providerStopped = providerStopped + result.registryStopped = registryStopped + if (deps.requirePhysicalStop) { + const stops = await Promise.all(stopAttempts.values()) + if (stops.some((stopped) => !stopped)) { + throw new Error(`Failed to physically stop every PTY for worktree: ${worktreeId}`) + } + } return result } +async function settleBeforeDeadline( + run: () => Promise, + fallback: T, + deadline: number, + failClosedError?: Error, + failClosedOnRunError: (error: unknown) => boolean = () => true +): Promise { + const remaining = deadline - Date.now() + if (remaining <= 0) { + if (failClosedError) { + throw failClosedError + } + return fallback + } + return new Promise((resolve, reject) => { + let settled = false + const finish = (value: T): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + resolve(value) + } + const fail = (error: unknown): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + reject(error) + } + const timer = setTimeout( + () => (failClosedError ? fail(failClosedError) : finish(fallback)), + remaining + ) + timer.unref?.() + void run().then(finish, (error: unknown) => + failClosedError && failClosedOnRunError(error) ? fail(error) : finish(fallback) + ) + }) +} + async function sweepProviderByPrefix( worktreeId: string, + worktreePath: string | undefined, provider: IPtyProvider, - onPtyStopped?: (ptyId: string) => void + deadline: number, + stopPty: ( + ptyId: string, + stop: () => Promise + ) => Promise<{ stopped: boolean; owner: boolean }>, + onPtyStopped?: (ptyId: string) => void, + failClosed = false ): Promise { const prefix = `${worktreeId}@@` - const sessions = await provider.listProcesses().catch(() => []) - let killed = 0 - for (const s of sessions) { - if (!s.id.startsWith(prefix)) { - continue + const sessions = failClosed + ? await provider.listProcesses() + : await provider.listProcesses().catch(() => []) + const ownedSessions = sessions.filter((session) => { + // Why: older daemon/relay process rows may omit cwd; their established ID + // and authoritative worktree ownership must remain usable during teardown. + const cwdOwned = + worktreePath !== undefined && + session.worktreeId === undefined && + typeof session.cwd === 'string' && + session.cwd.length > 0 && + isPathInsideOrEqual(worktreePath, session.cwd) + return session.id.startsWith(prefix) || session.worktreeId === worktreeId || cwdOwned + }) + // Why: agent shutdown snapshots coalesce only when requests begin together; + // bounded concurrency avoids serial process scans without unbounded fanout. + const stopped = await mapWithConcurrency( + ownedSessions, + WORKTREE_TEARDOWN_CONCURRENCY, + async (session) => { + if (Date.now() >= deadline) { + return 0 + } + const stopResult = await stopPty(session.id, async () => { + if (Date.now() >= deadline) { + return false + } + try { + await provider.shutdown(session.id, { immediate: true }) + return Date.now() < deadline + } catch { + return false + } + }) + if (stopResult.owner && Date.now() < deadline) { + clearStoppedPtyState(session.id, onPtyStopped) + return 1 + } + return 0 } - try { - await provider.shutdown(s.id, { immediate: true }) - clearStoppedPtyState(s.id, onPtyStopped) - killed += 1 - } catch { - // Already dead, or the backend dropped the session — treat as success. - killed += 1 - } - } - return killed + ) + return stopped.reduce((count, value) => count + value, 0) } async function sweepRegistryForWorktree( worktreeId: string, localProvider: IPtyProvider, + deadline: number, + stopPty: ( + ptyId: string, + stop: () => Promise + ) => Promise<{ stopped: boolean; owner: boolean }>, onPtyStopped?: (ptyId: string) => void ): Promise { const entries = listRegisteredPtys().filter((r) => r.worktreeId === worktreeId) - let killed = 0 - for (const entry of entries) { - try { - await localProvider.shutdown(entry.ptyId, { immediate: true }) - clearStoppedPtyState(entry.ptyId, onPtyStopped) - killed += 1 - } catch { - /* ignore — best-effort */ + const stopped = await mapWithConcurrency( + entries, + WORKTREE_TEARDOWN_CONCURRENCY, + async (entry) => { + if (Date.now() >= deadline) { + return 0 + } + const stopResult = await stopPty(entry.ptyId, async () => { + if (Date.now() >= deadline) { + return false + } + try { + await localProvider.shutdown(entry.ptyId, { immediate: true }) + return Date.now() < deadline + } catch { + return false + } + }) + if (stopResult.owner && Date.now() < deadline) { + clearStoppedPtyState(entry.ptyId, onPtyStopped) + return 1 + } + return 0 } - } - return killed + ) + return stopped.reduce((count, value) => count + value, 0) } function clearStoppedPtyState(ptyId: string, onPtyStopped?: (ptyId: string) => void): void { diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index 68ff5e9d017..d65f30dc391 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -83,6 +83,41 @@ describe('skill discovery', () => { } }) + it('does not add runtime-owned repository paths to local scan roots', () => { + const runtimeRepo = makeRepo('/runtime/repo') + runtimeRepo.executionHostId = 'runtime:environment-1' + + const roots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current', + repos: [runtimeRepo] + }) + + expect(roots.map((root) => root.path.replace(/\\/g, '/'))).not.toContain( + '/runtime/repo/.agents/skills' + ) + }) + + it('can exclude the implicit cwd without excluding explicit local repositories', () => { + const defaultRoots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current', + repos: [makeRepo('/workspace/known')] + }) + const explicitRoots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current', + repos: [makeRepo('/workspace/known')], + includeCwd: false + }) + + const normalizedDefaultPaths = defaultRoots.map((root) => root.path.replace(/\\/g, '/')) + const normalizedExplicitPaths = explicitRoots.map((root) => root.path.replace(/\\/g, '/')) + expect(normalizedDefaultPaths).toContain('/workspace/current/.agents/skills') + expect(normalizedExplicitPaths).not.toContain('/workspace/current/.agents/skills') + expect(normalizedExplicitPaths).toContain('/workspace/known/.agents/skills') + }) + it('discovers skill packages through symlinked skill directories', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index d84f82af426..ed74ef3ffa6 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -234,6 +234,7 @@ export async function discoverSkills(args: { repos?: Repo[] homeDir?: string cwd?: string + includeCwd?: boolean }): Promise { const roots = buildSkillDiscoverySources(args) const sources: SkillDiscoverySource[] = [] diff --git a/src/main/skills/skill-bundle-artifacts.test.ts b/src/main/skills/skill-bundle-artifacts.test.ts new file mode 100644 index 00000000000..f86d5c382d7 --- /dev/null +++ b/src/main/skills/skill-bundle-artifacts.test.ts @@ -0,0 +1,37 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { loadSkillBundleArtifacts } from './skill-bundle-artifacts' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('skill bundle artifacts', () => { + it('rejects malformed nested release entries before building provenance', async () => { + const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-')) + temporaryDirectories.push(resourceRoot) + const target = join(resourceRoot, 'skills') + const source = resolve('resources', 'skills') + await mkdir(target, { recursive: true }) + const [manifest, registry, releaseMapping] = await Promise.all( + ['current-manifest.json', 'snapshot-registry.json', 'release-mapping.json'].map((name) => + readFile(join(source, name), 'utf8') + ) + ) + const malformedMapping = JSON.parse(releaseMapping) + malformedMapping.releases[0] = { appVersion: 'invalid' } + await Promise.all([ + writeFile(join(target, 'current-manifest.json'), manifest), + writeFile(join(target, 'snapshot-registry.json'), registry), + writeFile(join(target, 'release-mapping.json'), JSON.stringify(malformedMapping)) + ]) + + await expect(loadSkillBundleArtifacts(resourceRoot)).rejects.toThrow( + 'Invalid skill release mapping' + ) + }) +}) diff --git a/src/main/skills/skill-bundle-artifacts.ts b/src/main/skills/skill-bundle-artifacts.ts new file mode 100644 index 00000000000..e7d403442b5 --- /dev/null +++ b/src/main/skills/skill-bundle-artifacts.ts @@ -0,0 +1,164 @@ +import { app } from 'electron' +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { z, type ZodType } from 'zod' +import type { + SkillBundleManifest, + SkillKnownSnapshot, + SkillReleaseMapping, + SkillSnapshotRegistry +} from '../../shared/skill-freshness' + +export type SkillBundleArtifacts = { + manifest: SkillBundleManifest + registry: SkillSnapshotRegistry + releaseMapping: SkillReleaseMapping + knownSnapshots: Record + releasedAppVersions: Record> +} + +const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/) +const snapshotShape = { + releaseRevision: z.number().int().positive(), + packageDigest: sha256Schema, + gitTreeSha: z.string().regex(/^[a-f0-9]{40}$/), + files: z + .array( + z + .object({ + path: z.string().min(1), + size: z.number().int().nonnegative(), + executable: z.boolean(), + classification: z.enum(['text', 'binary']), + exactSha256: sha256Schema, + textNormalizedSha256: sha256Schema.nullable(), + identitySha256: sha256Schema + }) + .strict() + ) + .min(1) +} +const knownSnapshotSchema = z.object(snapshotShape).strict() +const manifestSchema = z + .object({ + schemaVersion: z.literal(1), + appVersion: z.string().min(1), + skills: z.array( + z + .object({ + name: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/), + sourcePath: z.string().min(1), + appVersion: z.string().min(1), + ...snapshotShape + }) + .strict() + ) + }) + .strict() +const registrySchema = z + .object({ + schemaVersion: z.literal(1), + skills: z.record(z.string().min(1), z.array(knownSnapshotSchema).min(1)) + }) + .strict() +const releaseMappingSchema = z + .object({ + schemaVersion: z.literal(1), + releases: z.array( + z + .object({ + appVersion: z.string().min(1), + skills: z.record(z.string().min(1), z.number().int().positive()) + }) + .strict() + ) + }) + .strict() + +function parseArtifact(schema: ZodType, value: unknown, label: string): T { + const result = schema.safeParse(value) + if (!result.success) { + throw new Error(`Invalid ${label}: ${result.error.issues[0]?.message ?? 'schema mismatch'}`) + } + return result.data +} + +const artifactsByResourceRoot = new Map>() + +// Why: the artifacts ship with the binary and never change within a run, while +// focus-triggered rescans would otherwise re-read and re-parse them every time. +export function loadSkillBundleArtifacts( + resourceRoot = app.isPackaged ? process.resourcesPath : resolve(process.cwd(), 'resources') +): Promise { + const cached = artifactsByResourceRoot.get(resourceRoot) + if (cached) { + return cached + } + const loading = readSkillBundleArtifacts(resourceRoot) + artifactsByResourceRoot.set(resourceRoot, loading) + loading.catch(() => { + artifactsByResourceRoot.delete(resourceRoot) + }) + return loading +} + +async function readSkillBundleArtifacts(resourceRoot: string): Promise { + const bundleRoot = join(resourceRoot, 'skills') + const [manifestValue, registryValue, releaseMappingValue] = await Promise.all([ + readFile(join(bundleRoot, 'current-manifest.json'), 'utf8').then(JSON.parse), + readFile(join(bundleRoot, 'snapshot-registry.json'), 'utf8').then(JSON.parse), + readFile(join(bundleRoot, 'release-mapping.json'), 'utf8').then(JSON.parse) + ]) + const manifest: SkillBundleManifest = parseArtifact( + manifestSchema, + manifestValue, + 'skill bundle manifest' + ) + const registry: SkillSnapshotRegistry = parseArtifact( + registrySchema, + registryValue, + 'skill snapshot registry' + ) + const releaseMapping: SkillReleaseMapping = parseArtifact( + releaseMappingSchema, + releaseMappingValue, + 'skill release mapping' + ) + for (const current of manifest.skills) { + if ( + current.appVersion !== manifest.appVersion || + !registry.skills[current.name]?.some( + (snapshot) => + snapshot.releaseRevision === current.releaseRevision && + snapshot.packageDigest === current.packageDigest + ) + ) { + throw new Error(`Inconsistent current skill snapshot: ${current.name}`) + } + } + + const releasedAppVersions: Record> = {} + for (const release of releaseMapping.releases) { + for (const [name, revision] of Object.entries(release.skills)) { + if (!registry.skills[name]?.some((snapshot) => snapshot.releaseRevision === revision)) { + throw new Error(`Unknown released skill revision: ${name}@${revision}`) + } + releasedAppVersions[name] ??= {} + releasedAppVersions[name][revision] ??= release.appVersion + } + } + for (const current of manifest.skills) { + releasedAppVersions[current.name] ??= {} + releasedAppVersions[current.name][current.releaseRevision] = current.appVersion + } + + return { + manifest, + registry, + releaseMapping, + // Why: newer-known classification needs every identity packaged with this + // build, while release mapping remains the provenance record for shipped revisions. + knownSnapshots: registry.skills, + releasedAppVersions + } +} diff --git a/src/main/skills/skill-candidate-concurrency.ts b/src/main/skills/skill-candidate-concurrency.ts new file mode 100644 index 00000000000..c81f1942ce9 --- /dev/null +++ b/src/main/skills/skill-candidate-concurrency.ts @@ -0,0 +1,24 @@ +const MAX_CONCURRENT_SKILL_CANDIDATES = 4 + +export async function runSkillCandidateTasks( + tasks: readonly (() => Promise)[] +): Promise { + const results = Array.from({ length: tasks.length }) + let nextIndex = 0 + + async function worker(): Promise { + for (;;) { + const index = nextIndex + nextIndex += 1 + if (index >= tasks.length) { + return + } + results[index] = await tasks[index]() + } + } + + await Promise.all( + Array.from({ length: Math.min(MAX_CONCURRENT_SKILL_CANDIDATES, tasks.length) }, () => worker()) + ) + return results +} diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index bec34117c4c..ba3726f43e0 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os' import { basename, join } from 'node:path' import type { SkillDiscoverySource, SkillProvider, SkillSourceKind } from '../../shared/skills' import type { Repo } from '../../shared/types' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' export type SkillScanRoot = Omit @@ -25,6 +26,7 @@ export function buildSkillDiscoverySources( homeDir?: string cwd?: string repos?: Repo[] + includeCwd?: boolean } = {} ): SkillScanRoot[] { const home = args.homeDir ?? homedir() @@ -62,12 +64,16 @@ export function buildSkillDiscoverySources( const projectPaths = new Set() for (const repo of args.repos ?? []) { - if (repo.connectionId) { + // Why: runtime-owned repos can have no legacy connectionId while their + // paths are meaningful only on a remote host. + if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { continue } projectPaths.add(repo.path) } - projectPaths.add(cwd) + if (args.includeCwd !== false) { + projectPaths.add(cwd) + } for (const repoPath of projectPaths) { const label = `Repo ${basename(repoPath)}` diff --git a/src/main/skills/skill-freshness-eligibility.test.ts b/src/main/skills/skill-freshness-eligibility.test.ts new file mode 100644 index 00000000000..e3754197559 --- /dev/null +++ b/src/main/skills/skill-freshness-eligibility.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import { + buildTargetedSkillUpdateCommand, + type SkillFreshnessInstallation +} from '../../shared/skill-freshness' +import { eligibleSkillUpdateNames } from './skill-freshness-eligibility' + +function placement( + name: string, + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: `${name}-${overrides.rootId ?? 'home-agents'}`, + name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${name}`, + resolvedPath: `/home/.agents/skills/${name}`, + physicalIdentity: `physical-${name}`, + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +describe('skill freshness name-scoped update eligibility', () => { + it('offers a name when at least one supported placement is outdated and all are official', () => { + expect( + eligibleSkillUpdateNames([ + placement('orca-cli'), + placement('orca-cli', { + id: 'orca-cli-claude', + rootId: 'home-claude', + topology: 'provider-alias', + status: 'current' + }) + ]) + ).toEqual(['orca-cli']) + }) + + it.each([ + ['newer-known', 'independent-copy'], + ['unrecognized', 'independent-copy'], + ['inaccessible', 'broken-link'], + ['current', 'external-link'], + ['current', 'read-only'], + ['current', 'repo-scope'], + ['current', 'plugin-cache'] + ] as const)('poisons a name for a %s placement in %s topology', (status, topology) => { + expect( + eligibleSkillUpdateNames([ + placement('orca-cli'), + placement('orca-cli', { id: `poison-${status}-${topology}`, status, topology }) + ]) + ).toEqual([]) + }) + + it('still updates the canonical copy when a clean standalone duplicate exists', () => { + // Why: a duplicate no longer omits the whole name — the canonical copy converges + // and the duplicate row is flagged as maybe-not-reached rather than blocking. + expect( + eligibleSkillUpdateNames([ + placement('orca-cli'), + placement('orca-cli', { + id: 'orca-cli-gemini', + rootId: 'home-gemini', + unresolvedPath: '/home/.gemini/skills/orca-cli', + resolvedPath: '/home/.gemini/skills/orca-cli', + topology: 'independent-copy', + status: 'current' + }) + ]) + ).toEqual(['orca-cli']) + }) + + it('does not offer a skill that exists only as a standalone copy', () => { + // Why: with no canonical or alias to anchor `--global`, the command has no + // reliable target, so a duplicate-only skill stays unoffered. + expect( + eligibleSkillUpdateNames([ + placement('orca-cli', { + rootId: 'home-gemini', + unresolvedPath: '/home/.gemini/skills/orca-cli', + resolvedPath: '/home/.gemini/skills/orca-cli', + topology: 'independent-copy', + status: 'outdated' + }) + ]) + ).toEqual([]) + }) + + it('does not offer an all-current name or let another safe name hide a poisoned one', () => { + expect( + eligibleSkillUpdateNames([ + placement('computer-use', { status: 'current' }), + placement('orchestration'), + placement('orchestration', { + id: 'orchestration-project', + status: 'unrecognized', + topology: 'repo-scope' + }) + ]) + ).toEqual([]) + }) + + it('builds only an explicit, deterministic global command', () => { + expect(buildTargetedSkillUpdateCommand(['orchestration', 'orca-cli', 'orca-cli'])).toBe( + 'npx skills update orca-cli orchestration --global' + ) + expect(buildTargetedSkillUpdateCommand([])).toBeNull() + expect(buildTargetedSkillUpdateCommand(['orca-cli;echo unsafe'])).toBeNull() + }) +}) diff --git a/src/main/skills/skill-freshness-eligibility.ts b/src/main/skills/skill-freshness-eligibility.ts new file mode 100644 index 00000000000..f4eb27a9f5a --- /dev/null +++ b/src/main/skills/skill-freshness-eligibility.ts @@ -0,0 +1,41 @@ +import { + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, + type SkillFreshnessInstallation +} from '../../shared/skill-freshness' + +export function eligibleSkillUpdateNames( + installations: readonly SkillFreshnessInstallation[] +): string[] { + const byName = new Map() + for (const installation of installations) { + const entries = byName.get(installation.name) ?? [] + entries.push(installation) + byName.set(installation.name, entries) + } + + const eligible: string[] = [] + for (const [name, entries] of byName) { + const hasOutdated = entries.some((entry) => entry.status === 'outdated') + const everyPlacementIsOfficialAndUpdatable = entries.every( + (entry) => + (entry.status === 'current' || entry.status === 'outdated') && + // Why: the rail reliably converges the canonical copy and its symlink aliases. + // A standalone duplicate no longer blocks the whole name — the canonical copy + // still updates and the duplicate row is flagged as maybe-not-reached — while + // data-loss topologies (unrecognized/read-only/etc.) still poison via these checks. + (SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) || + entry.topology === 'independent-copy') && + Boolean(entry.resolvedPath && entry.physicalIdentity) + ) + // Why: only offer the global command when a reliably-convergent placement anchors it, + // so a skill that exists solely as a standalone copy never draws a command that could + // no-op or error against a canonical install that isn't there. + const hasReliableTarget = entries.some((entry) => + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) + ) + if (hasOutdated && everyPlacementIsOfficialAndUpdatable && hasReliableTarget) { + eligible.push(name) + } + } + return eligible.sort((left, right) => left.localeCompare(right, 'en')) +} diff --git a/src/main/skills/skill-freshness-inventory-limits.test.ts b/src/main/skills/skill-freshness-inventory-limits.test.ts new file mode 100644 index 00000000000..c43d137fcd0 --- /dev/null +++ b/src/main/skills/skill-freshness-inventory-limits.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { + boundRepositorySkillRoots, + MAXIMUM_REPOSITORY_SKILL_ROOTS +} from './skill-freshness-inventory' +import type { SkillScanRoot } from './skill-discovery-sources' + +describe('skill freshness inventory limits', () => { + it('caps repository roots before creating candidate probes', () => { + const roots = Array.from( + { length: MAXIMUM_REPOSITORY_SKILL_ROOTS + 3 }, + (_, index): SkillScanRoot => ({ + id: `repo-${index}`, + label: `Repo ${index}`, + path: `/repo-${index}/.agents/skills`, + sourceKind: 'repo', + providers: ['agent-skills'] + }) + ) + + const bounded = boundRepositorySkillRoots(roots) + + expect(bounded.scanned).toHaveLength(MAXIMUM_REPOSITORY_SKILL_ROOTS) + expect(bounded.omitted).toHaveLength(3) + }) +}) diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts new file mode 100644 index 00000000000..990fbdba1a3 --- /dev/null +++ b/src/main/skills/skill-freshness-inventory.test.ts @@ -0,0 +1,363 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Repo } from '../../shared/types' +import type { + SkillBundleFileIdentity, + SkillCurrentBundleEntry, + SkillKnownSnapshot +} from '../../shared/skill-freshness' +import { + inventorySkillFreshness, + MAXIMUM_REPOSITORY_SKILL_ROOTS +} from './skill-freshness-inventory' +import { describeObservedSkillFile, skillPackageDigest } from './skill-package-identity' + +const temporaryDirectories: string[] = [] + +function snapshot(releaseRevision: number, markdown: string): SkillKnownSnapshot { + const observed = describeObservedSkillFile('SKILL.md', Buffer.from(markdown), false) + const file: SkillBundleFileIdentity = { + path: observed.path, + size: observed.size, + executable: observed.executable, + classification: observed.classification, + exactSha256: observed.exactSha256, + textNormalizedSha256: observed.textNormalizedSha256, + identitySha256: observed.identitySha256 + } + return { + releaseRevision, + packageDigest: skillPackageDigest([file]), + gitTreeSha: releaseRevision.toString(16).padStart(40, '0'), + files: [file] + } +} + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-inventory-')) + temporaryDirectories.push(root) + const homeDir = join(root, 'home') + const resourceRoot = join(root, 'resources') + const skillResourceRoot = join(resourceRoot, 'skills') + await mkdir(skillResourceRoot, { recursive: true }) + + const oldMarkdown = '---\nname: orca-cli\ndescription: Old official guide.\n---\n\n# Old\n' + const currentMarkdown = + '---\nname: orca-cli\ndescription: Current official guide.\n---\n\n# Current\n' + const newerMarkdown = '---\nname: orca-cli\ndescription: Newer official guide.\n---\n\n# Newer\n' + const snapshots = [ + snapshot(1, oldMarkdown), + snapshot(2, currentMarkdown), + snapshot(3, newerMarkdown) + ] + const current: SkillCurrentBundleEntry = { + name: 'orca-cli', + sourcePath: 'skills/orca-cli', + appVersion: '2.0.0', + ...snapshots[1] + } + await Promise.all([ + writeFile( + join(skillResourceRoot, 'current-manifest.json'), + `${JSON.stringify({ schemaVersion: 1, appVersion: '2.0.0', skills: [current] }, null, 2)}\n` + ), + writeFile( + join(skillResourceRoot, 'snapshot-registry.json'), + `${JSON.stringify({ schemaVersion: 1, skills: { 'orca-cli': snapshots } }, null, 2)}\n` + ), + writeFile( + join(skillResourceRoot, 'release-mapping.json'), + `${JSON.stringify( + { + schemaVersion: 1, + releases: [ + { appVersion: '1.0.0', skills: { 'orca-cli': 1 } }, + { appVersion: '2.0.0', skills: { 'orca-cli': 2 } }, + { appVersion: '3.0.0', skills: { 'orca-cli': 3 } } + ] + }, + null, + 2 + )}\n` + ) + ]) + + const writeSkill = async (rootPath: string, markdown: string): Promise => { + const directory = join(rootPath, 'orca-cli') + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), markdown) + return directory + } + return { + root, + homeDir, + resourceRoot, + oldMarkdown, + currentMarkdown, + newerMarkdown, + writeSkill + } +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('read-only skill freshness inventory', () => { + it('offers an exact older official name only when all global placements are safe', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations.map((entry) => entry.status)).toEqual(['outdated']) + expect(inventory.installations[0]?.installedAppVersion).toBe('1.0.0') + expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) + }) + + it('labels newer known and unrecognized bytes honestly without calling them modified', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.newerMarkdown) + await test.writeSkill( + join(test.homeDir, '.claude', 'skills'), + '---\nname: orca-cli\ndescription: User copy.\n---\n' + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations.map((entry) => entry.status)).toEqual([ + 'newer-known', + 'unrecognized' + ]) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + + it('retains full-file identity without projecting unused metadata', async () => { + const test = await fixture() + const lateDescription = 'Description beyond the metadata parsing budget.' + await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + `${' '.repeat(256 * 1024)}\n${lateDescription}` + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + status: 'unrecognized' + }) + expect(inventory.installations[0]).not.toHaveProperty('description') + expect(inventory.installations[0]?.observedPackageDigest).toMatch(/^[a-f0-9]{64}$/) + }) + + it.runIf(process.platform !== 'win32')( + 'deduplicates a provider alias to the canonical copy', + async () => { + const test = await fixture() + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + test.oldMarkdown + ) + const claudeRoot = join(test.homeDir, '.claude', 'skills') + await mkdir(claudeRoot, { recursive: true }) + await symlink(canonical, join(claudeRoot, 'orca-cli')) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations).toHaveLength(1) + expect(inventory.installations[0]?.providers).toEqual(['agent-skills', 'claude']) + expect(inventory.installations[0]?.topology).toBe('canonical-copy') + expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) + } + ) + + it.runIf(process.platform !== 'win32')( + 'deduplicates aliases within an unsupported topology without hiding its poison', + async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const shared = await test.writeSkill(join(test.root, 'shared'), test.currentMarkdown) + const repos = await Promise.all( + ['one', 'two'].map(async (id) => { + const repoPath = join(test.root, `repo-${id}`) + const root = join(repoPath, '.agents', 'skills') + await mkdir(root, { recursive: true }) + await symlink(shared, join(root, 'orca-cli')) + return { id, path: repoPath } as unknown as Repo + }) + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos, + resourceRoot: test.resourceRoot + }) + + expect( + inventory.installations.filter((entry) => entry.topology === 'repo-scope') + ).toHaveLength(1) + expect(inventory.eligibleUpdateNames).toEqual([]) + } + ) + + it('keeps inaccessible placements visible and lets them poison the name', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const inaccessiblePath = join(test.homeDir, '.codex', 'skills', 'orca-cli') + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot, + candidateLstat: async (path) => { + if (path === inaccessiblePath) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + } + return import('node:fs/promises').then(({ lstat }) => lstat(path)) + } + }) + + expect(inventory.installations.map((entry) => entry.status)).toEqual([ + 'outdated', + 'inaccessible' + ]) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + + it('does not lose an inaccessible known repository placement', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const repoPath = join(test.root, 'repo') + const inaccessiblePath = join(repoPath, '.agents', 'skills', 'orca-cli') + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [{ id: 'repo', path: repoPath }] as unknown as Repo[], + resourceRoot: test.resourceRoot, + candidateLstat: async (path) => { + if (path === inaccessiblePath) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + } + return import('node:fs/promises').then(({ lstat }) => lstat(path)) + } + }) + + expect(inventory.installations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + unresolvedPath: inaccessiblePath, + topology: 'repo-scope', + status: 'inaccessible' + }) + ]) + ) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + + it.each([ + ['repo', 'repo-scope'], + ['plugin', 'plugin-cache'] + ] as const)( + 'keeps an official %s placement informational and name-poisoning', + async (kind, topology) => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + let repos: Repo[] = [] + if (kind === 'repo') { + const repoPath = join(test.root, 'repo') + await test.writeSkill(join(repoPath, '.agents', 'skills'), test.currentMarkdown) + repos = [{ id: 'repo', path: repoPath }] as unknown as Repo[] + } else { + await test.writeSkill( + join(test.homeDir, '.codex', 'plugins', 'cache', 'vendor', 'skills'), + test.currentMarkdown + ) + } + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos, + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations.some((entry) => entry.topology === topology)).toBe(true) + expect(inventory.eligibleUpdateNames).toEqual([]) + } + ) + + it('accepts CRLF as the same official text identity', async () => { + const test = await fixture() + await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + test.oldMarkdown.replaceAll('\n', '\r\n') + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + expect(inventory.installations[0]?.status).toBe('outdated') + }) + + it('classifies exact current bytes as current when a later snapshot reuses them', async () => { + const test = await fixture() + const resourceRoot = join(test.resourceRoot, 'skills') + const registryPath = join(resourceRoot, 'snapshot-registry.json') + const registry = JSON.parse(await readFile(registryPath, 'utf8')) + registry.skills['orca-cli'].push(snapshot(4, test.currentMarkdown)) + await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`) + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + status: 'current', + installedReleaseRevision: 2, + installedAppVersion: '2.0.0' + }) + }) + + it('withholds updates when stored repositories exceed the probe budget', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const repos = Array.from( + { length: MAXIMUM_REPOSITORY_SKILL_ROOTS / 2 + 1 }, + (_, index) => ({ id: `repo-${index}`, path: join(test.root, `repo-${index}`) }) as Repo + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos, + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ errorCategory: 'repository-scan-limit', status: 'inaccessible' }) + ]) + ) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) +}) diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts new file mode 100644 index 00000000000..e5bbae31da9 --- /dev/null +++ b/src/main/skills/skill-freshness-inventory.ts @@ -0,0 +1,179 @@ +import { lstat } from 'node:fs/promises' +import { join } from 'node:path' +import type { Repo } from '../../shared/types' +import type { + SkillFreshnessInstallation, + SkillFreshnessInventory +} from '../../shared/skill-freshness' +import { buildSkillDiscoverySources, type SkillScanRoot } from './skill-discovery-sources' +import { loadSkillBundleArtifacts } from './skill-bundle-artifacts' +import { eligibleSkillUpdateNames } from './skill-freshness-eligibility' +import { runSkillCandidateTasks } from './skill-candidate-concurrency' +import { + classifyHomeSkillCandidate, + classifyUnsupportedSkillCandidate, + dedupeSkillFreshnessPlacements, + observeSkillFreshnessInstallation, + type CandidateLstat +} from './skill-freshness-placement-observation' +import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' + +export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128 + +export function boundRepositorySkillRoots(roots: readonly SkillScanRoot[]): { + scanned: SkillScanRoot[] + omitted: SkillScanRoot[] +} { + return { + scanned: roots.slice(0, MAXIMUM_REPOSITORY_SKILL_ROOTS), + omitted: roots.slice(MAXIMUM_REPOSITORY_SKILL_ROOTS) + } +} + +export async function inventorySkillFreshness( + args: { + homeDir?: string + cwd?: string + repos?: Repo[] + resourceRoot?: string + candidateLstat?: CandidateLstat + } = {} +): Promise { + const artifacts = await loadSkillBundleArtifacts(args.resourceRoot) + const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill])) + const discoveryArgs = { + homeDir: args.homeDir, + cwd: args.cwd, + repos: args.repos, + // Why: freshness scans known repositories explicitly; treating the app's + // launch cwd as another repo would create phantom poison placements. + includeCwd: false + } + const roots = buildSkillDiscoverySources(discoveryArgs) + const homeRoots = roots.filter((root) => root.sourceKind === 'home') + const allRepoRoots = roots.filter((root) => root.sourceKind === 'repo') + const { scanned: repoRoots, omitted: omittedRepoRoots } = boundRepositorySkillRoots(allRepoRoots) + const pluginRoots = roots.filter((root) => root.sourceKind === 'plugin') + const canonicalRootPath = homeRoots.find((root) => root.id === 'home-agents')?.path + if (!canonicalRootPath) { + throw new Error('Missing canonical agent skills root') + } + + const homeTasks = artifacts.manifest.skills.flatMap((current) => + homeRoots.map( + (root) => () => + classifyHomeSkillCandidate({ + root, + current, + artifacts, + canonicalRootPath, + candidateLstat: args.candidateLstat ?? ((path) => lstat(path)) + }) + ) + ) + // Why: each observation may retain the package byte ceiling while hashing; + // launch/focus scans must not fan out across every known placement. + const homeInstallations = (await runSkillCandidateTasks(homeTasks)).filter( + (installation): installation is SkillFreshnessInstallation => installation !== null + ) + + const candidateLstat = args.candidateLstat ?? ((path) => lstat(path)) + const repoTasks = artifacts.manifest.skills.flatMap((current) => + repoRoots.map( + (root) => () => + classifyUnsupportedSkillCandidate({ + root, + current, + artifacts, + unresolvedPath: join(root.path, current.name), + candidateLstat + }) + ) + ) + // Why: stored repositories can grow without bound. If the probe budget is + // exhausted, one sentinel per name preserves safety without hashing more packages. + const omittedRepoTasks = + omittedRepoRoots.length === 0 + ? [] + : artifacts.manifest.skills.map( + (current) => () => + observeSkillFreshnessInstallation({ + current, + artifacts, + rootId: 'repo-scan-limit', + providers: [...new Set(omittedRepoRoots.flatMap((root) => root.providers))], + sourceKind: 'repo', + sourceLabel: 'Additional repositories', + unresolvedPath: omittedRepoRoots[0]?.path ?? 'repo-scan-limit', + topology: { + topology: 'repo-scope', + resolvedPath: null, + identity: null, + errorCategory: 'repository-scan-limit' + } + }) + ) + const pluginScans = await Promise.all( + pluginRoots.map(async (root) => ({ + root, + scan: await scanKnownPluginSkillCandidates(root.path, new Set(currentByName.keys())) + })) + ) + const pluginTasks = pluginScans.flatMap(({ root, scan }) => [ + ...scan.candidates.flatMap((candidate) => { + const current = currentByName.get(candidate.name) + return current + ? [ + () => + classifyUnsupportedSkillCandidate({ + root, + current, + artifacts, + unresolvedPath: candidate.path, + candidateLstat + }) + ] + : [] + }), + // Why: unreadable plugin subtrees could hide any official name. An + // incomplete scan must conservatively poison every name rather than imply absence. + ...scan.incompletePaths.flatMap((incompletePath) => + artifacts.manifest.skills.map( + (current) => () => + observeSkillFreshnessInstallation({ + current, + artifacts, + rootId: root.id, + providers: root.providers, + sourceKind: 'plugin', + sourceLabel: root.label, + unresolvedPath: join(incompletePath, current.name), + topology: { + topology: 'plugin-cache', + resolvedPath: null, + identity: null, + errorCategory: 'plugin-cache-scan-incomplete' + } + }) + ) + ) + ]) + const unsupportedInstallations = ( + await runSkillCandidateTasks([...repoTasks, ...omittedRepoTasks, ...pluginTasks]) + ).filter((installation): installation is SkillFreshnessInstallation => installation !== null) + const installations = dedupeSkillFreshnessPlacements([ + ...homeInstallations, + ...unsupportedInstallations + ]).sort( + (left, right) => + left.name.localeCompare(right.name, 'en') || + left.unresolvedPath.localeCompare(right.unresolvedPath, 'en') + ) + + return { + schemaVersion: 1, + installations, + eligibleUpdateNames: eligibleSkillUpdateNames(installations), + scannedAt: Date.now() + } +} diff --git a/src/main/skills/skill-freshness-placement-observation.ts b/src/main/skills/skill-freshness-placement-observation.ts new file mode 100644 index 00000000000..2f8d32ca663 --- /dev/null +++ b/src/main/skills/skill-freshness-placement-observation.ts @@ -0,0 +1,251 @@ +import type { Stats } from 'node:fs' +import { join } from 'node:path' +import type { + SkillCurrentBundleEntry, + SkillFreshnessInstallation, + SkillFreshnessStatus, + SkillKnownSnapshot +} from '../../shared/skill-freshness' +import type { SkillScanRoot } from './skill-discovery-sources' +import type { SkillBundleArtifacts } from './skill-bundle-artifacts' +import { matchingKnownSnapshot, observeSkillPackage } from './skill-package-identity' +import { + classifyHomeSkillTopology, + classifyUnsupportedSkillTopology, + skillPlacementId, + skillTopologyPriority, + type ClassifiedSkillTopology +} from './skill-installation-topology' + +export type CandidateLstat = (path: string) => Promise + +function freshnessStatus( + snapshot: SkillKnownSnapshot | null, + current: SkillCurrentBundleEntry +): SkillFreshnessStatus { + if (!snapshot) { + return 'unrecognized' + } + if (snapshot.releaseRevision > current.releaseRevision) { + return 'newer-known' + } + return snapshot.packageDigest === current.packageDigest ? 'current' : 'outdated' +} + +function errorCategory(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback +} + +function isInaccessibleError(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error.code === 'EACCES' || error.code === 'EPERM') + ) +} + +function knownSnapshots( + artifacts: SkillBundleArtifacts, + current: SkillCurrentBundleEntry +): SkillKnownSnapshot[] { + const snapshots = artifacts.knownSnapshots[current.name] ?? [] + return snapshots.some((snapshot) => snapshot.packageDigest === current.packageDigest) + ? snapshots + : [...snapshots, current] +} + +export async function observeSkillFreshnessInstallation(args: { + current: SkillCurrentBundleEntry + artifacts: SkillBundleArtifacts + rootId: string + providers: SkillFreshnessInstallation['providers'] + sourceKind: SkillFreshnessInstallation['sourceKind'] + sourceLabel: string + unresolvedPath: string + topology: ClassifiedSkillTopology +}): Promise { + const base = { + id: skillPlacementId(args.unresolvedPath, args.current.name), + name: args.current.name, + rootId: args.rootId, + providers: args.providers, + sourceKind: args.sourceKind, + sourceLabel: args.sourceLabel, + unresolvedPath: args.unresolvedPath, + resolvedPath: args.topology.resolvedPath, + physicalIdentity: args.topology.identity, + topology: args.topology.topology, + currentReleaseRevision: args.current.releaseRevision, + currentPackageDigest: args.current.packageDigest, + currentAppVersion: args.current.appVersion, + errorCategory: args.topology.errorCategory + } + if (!args.topology.resolvedPath || !args.topology.identity) { + return { + ...base, + status: 'inaccessible', + installedReleaseRevision: null, + installedAppVersion: null, + observedPackageDigest: null + } + } + + try { + const observed = await observeSkillPackage(args.topology.resolvedPath) + const matchedSnapshot = matchingKnownSnapshot( + observed, + knownSnapshots(args.artifacts, args.current) + ) + // Why: a later release can reintroduce identical bytes. Exact current + // identity is still current, and cannot honestly be attributed to the later tag. + const snapshot = + observed.observedDigest === args.current.packageDigest ? args.current : matchedSnapshot + return { + ...base, + status: freshnessStatus(snapshot, args.current), + installedReleaseRevision: snapshot?.releaseRevision ?? null, + installedAppVersion: snapshot + ? (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ?? + null) + : null, + observedPackageDigest: observed.observedDigest + } + } catch (error) { + return { + ...base, + status: isInaccessibleError(error) ? 'inaccessible' : 'unrecognized', + installedReleaseRevision: null, + installedAppVersion: null, + observedPackageDigest: null, + errorCategory: errorCategory(error, 'skill-package-read-failed') + } + } +} + +export async function classifyHomeSkillCandidate(args: { + root: SkillScanRoot + current: SkillCurrentBundleEntry + artifacts: SkillBundleArtifacts + canonicalRootPath: string + candidateLstat: CandidateLstat +}): Promise { + const unresolvedPath = join(args.root.path, args.current.name) + try { + await args.candidateLstat(unresolvedPath) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return null + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath, + topology: { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: errorCategory(error, 'skill-candidate-inaccessible') + } + }) + } + + let topology: ClassifiedSkillTopology + try { + topology = await classifyHomeSkillTopology(args.root, unresolvedPath, args.canonicalRootPath) + } catch (error) { + topology = { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: errorCategory(error, 'skill-candidate-topology-failed') + } + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath, + topology + }) +} + +export async function classifyUnsupportedSkillCandidate(args: { + root: SkillScanRoot + current: SkillCurrentBundleEntry + artifacts: SkillBundleArtifacts + unresolvedPath: string + candidateLstat: CandidateLstat +}): Promise { + try { + await args.candidateLstat(args.unresolvedPath) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return null + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath: args.unresolvedPath, + topology: { + topology: args.root.sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', + resolvedPath: null, + identity: null, + errorCategory: errorCategory(error, 'unsupported-candidate-inaccessible') + } + }) + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath: args.unresolvedPath, + topology: await classifyUnsupportedSkillTopology( + args.unresolvedPath, + args.root.sourceKind === 'repo' ? 'repo' : 'plugin' + ) + }) +} + +function topologyDedupeBucket(installation: SkillFreshnessInstallation): string { + return installation.topology === 'canonical-copy' || installation.topology === 'provider-alias' + ? 'managed-global' + : installation.topology +} + +export function dedupeSkillFreshnessPlacements( + installations: readonly SkillFreshnessInstallation[] +): SkillFreshnessInstallation[] { + const deduped = new Map() + for (const installation of installations) { + const key = installation.physicalIdentity + ? `${installation.name}\0${installation.physicalIdentity}\0${topologyDedupeBucket(installation)}` + : `logical\0${installation.id}` + const existing = deduped.get(key) + if (!existing) { + deduped.set(key, installation) + continue + } + const providers = [...new Set([...existing.providers, ...installation.providers])] + if (skillTopologyPriority(installation.topology) > skillTopologyPriority(existing.topology)) { + deduped.set(key, { ...installation, providers }) + } else { + existing.providers = providers + } + } + return [...deduped.values()] +} diff --git a/src/main/skills/skill-installation-topology.ts b/src/main/skills/skill-installation-topology.ts new file mode 100644 index 00000000000..673fcb3d1e1 --- /dev/null +++ b/src/main/skills/skill-installation-topology.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto' +import { constants } from 'node:fs' +import { access, lstat, realpath, stat } from 'node:fs/promises' +import { dirname, normalize, resolve } from 'node:path' +import type { SkillInstallationTopology } from '../../shared/skill-freshness' +import type { SkillScanRoot } from './skill-discovery-sources' + +export type ClassifiedSkillTopology = { + topology: SkillInstallationTopology + resolvedPath: string | null + identity: string | null + errorCategory: string | null +} + +export function skillPlacementId(unresolvedPath: string, name: string): string { + return createHash('sha256') + .update(normalizedSkillIdentityPath(unresolvedPath)) + .update('\0') + .update(name) + .digest('hex') + .slice(0, 24) +} + +export function normalizedSkillIdentityPath(value: string): string { + const normalized = normalize(value) + return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized +} + +export function skillPhysicalIdentity( + resolvedPath: string, + fileStat: Awaited> +): string { + const inodeIdentity = fileStat.dev || fileStat.ino ? `${fileStat.dev}:${fileStat.ino}` : null + return inodeIdentity ?? normalizedSkillIdentityPath(resolvedPath) +} + +export function skillTopologyPriority(topology: SkillInstallationTopology): number { + switch (topology) { + case 'canonical-copy': + return 3 + case 'independent-copy': + return 2 + case 'provider-alias': + return 1 + case 'external-link': + case 'broken-link': + case 'read-only': + case 'repo-scope': + case 'plugin-cache': + return 0 + } +} + +async function writableDestination(path: string): Promise { + try { + await Promise.all([ + access(path, constants.R_OK | constants.W_OK), + access(dirname(path), constants.W_OK) + ]) + return true + } catch { + return false + } +} + +async function hasSymlinkedAncestor(path: string, boundary: string): Promise { + let current = resolve(path) + const stop = resolve(boundary) + for (;;) { + const entry = await lstat(current).catch(() => null) + if (!entry || entry.isSymbolicLink()) { + return true + } + const parent = dirname(current) + if (current === stop) { + return false + } + if (parent === current) { + return true + } + current = parent + } +} + +export async function classifyHomeSkillTopology( + root: SkillScanRoot, + unresolvedPath: string, + canonicalRootPath: string +): Promise { + let logicalStat: Awaited> + try { + logicalStat = await lstat(unresolvedPath) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: 'missing' + } + } + throw error + } + const linked = logicalStat.isSymbolicLink() + let resolvedPath: string + let resolvedStat: Awaited> + try { + resolvedPath = await realpath(unresolvedPath) + resolvedStat = await stat(resolvedPath) + } catch { + return { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: 'dangling-link' + } + } + if (!resolvedStat.isDirectory()) { + return { + topology: 'broken-link', + resolvedPath, + identity: null, + errorCategory: 'not-directory' + } + } + + const identity = skillPhysicalIdentity(resolvedPath, resolvedStat) + const canonicalRoot = await realpath(canonicalRootPath).catch(() => resolve(canonicalRootPath)) + const homeBoundary = dirname(dirname(canonicalRootPath)) + const rootOrProviderParentLinked = await hasSymlinkedAncestor(root.path, homeBoundary) + const isCanonicalTarget = + normalizedSkillIdentityPath(dirname(resolvedPath)) === + normalizedSkillIdentityPath(canonicalRoot) + let topology: SkillInstallationTopology + if (linked) { + topology = isCanonicalTarget ? 'provider-alias' : 'external-link' + } else if (rootOrProviderParentLinked) { + topology = 'external-link' + } else { + topology = root.id === 'home-agents' ? 'canonical-copy' : 'independent-copy' + } + if (topology !== 'external-link' && !(await writableDestination(resolvedPath))) { + topology = 'read-only' + } + return { topology, resolvedPath, identity, errorCategory: null } +} + +export async function classifyUnsupportedSkillTopology( + directoryPath: string, + sourceKind: 'repo' | 'plugin' +): Promise { + try { + const resolvedPath = await realpath(directoryPath) + const resolvedStat = await stat(resolvedPath) + if (!resolvedStat.isDirectory()) { + throw new Error('not-directory') + } + return { + topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', + resolvedPath, + identity: skillPhysicalIdentity(resolvedPath, resolvedStat), + errorCategory: null + } + } catch (error) { + return { + topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', + resolvedPath: null, + identity: null, + errorCategory: error instanceof Error ? error.message : 'read-failed' + } + } +} diff --git a/src/main/skills/skill-package-identity.test.ts b/src/main/skills/skill-package-identity.test.ts new file mode 100644 index 00000000000..648b628e82c --- /dev/null +++ b/src/main/skills/skill-package-identity.test.ts @@ -0,0 +1,92 @@ +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + describeObservedSkillFile, + matchingKnownSnapshot, + observeSkillPackage, + skillPackageDigest +} from './skill-package-identity' + +const temporaryDirectories: string[] = [] + +async function temporarySkill(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-freshness-')) + temporaryDirectories.push(root) + return root +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('skill package identity', () => { + it('matches CRLF installed text to an LF official snapshot', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'SKILL.md'), 'first\r\nsecond\r\n') + const observed = await observeSkillPackage(root) + const expected = describeObservedSkillFile('SKILL.md', Buffer.from('first\nsecond\n'), false) + + // Why: scans can observe several package byte budgets concurrently; only + // hashes, not raw file buffers, should survive each file's identity pass. + expect(observed.files[0]).not.toHaveProperty('bytes') + expect( + matchingKnownSnapshot(observed, [ + { + releaseRevision: 1, + packageDigest: skillPackageDigest([expected]), + gitTreeSha: 'tree', + files: [expected] + } + ])?.releaseRevision + ).toBe(1) + }) + + it('uses exact bytes for executable and binary files', async () => { + const executable = describeObservedSkillFile('run.sh', Buffer.from('#!/bin/sh\r\n'), true) + const binary = describeObservedSkillFile('asset.bin', Buffer.from([0, 13, 10]), false) + expect(executable.identitySha256).toBe(executable.exactSha256) + expect(binary.identitySha256).toBe(binary.exactSha256) + expect(binary.classification).toBe('binary') + }) + + it('orders package files by locale-independent code units', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'apple.md'), 'apple') + await writeFile(join(root, 'Zebra.md'), 'zebra') + + const observed = await observeSkillPackage(root) + + expect(observed.files.map((file) => file.path)).toEqual(['Zebra.md', 'apple.md']) + }) + + it('rejects links and bounded-observation overflows', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'SKILL.md'), 'skill') + if (process.platform !== 'win32') { + await symlink(join(root, 'SKILL.md'), join(root, 'linked.md')) + await expect(observeSkillPackage(root)).rejects.toThrow('skill-package-link') + await rm(join(root, 'linked.md')) + } + await expect( + observeSkillPackage(root, { + maximumDepth: 1, + maximumEntries: 0, + maximumFiles: 1, + maximumSingleFileBytes: 10, + maximumTotalBytes: 10 + }) + ).rejects.toThrow('skill-package-entry-limit') + }) + + it.runIf(process.platform !== 'win32')('tracks executable mode in package identity', async () => { + const root = await temporarySkill() + await mkdir(join(root, 'scripts')) + const script = join(root, 'scripts', 'run.sh') + await writeFile(script, '#!/bin/sh\n') + await chmod(script, 0o755) + const observed = await observeSkillPackage(root) + expect(observed.files[0]?.executable).toBe(true) + }) +}) diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts new file mode 100644 index 00000000000..8189ee0b28b --- /dev/null +++ b/src/main/skills/skill-package-identity.ts @@ -0,0 +1,229 @@ +import { createHash } from 'node:crypto' +import type { Dirent } from 'node:fs' +import { lstat, open, opendir } from 'node:fs/promises' +import { isAbsolute, join, relative, sep } from 'node:path' +import type { SkillBundleFileIdentity, SkillKnownSnapshot } from '../../shared/skill-freshness' + +type ObservedSkillFile = SkillBundleFileIdentity + +export type ObservedSkillPackage = { + files: ObservedSkillFile[] + observedDigest: string +} + +export const SKILL_PACKAGE_OBSERVATION_LIMITS = { + maximumDepth: 16, + maximumEntries: 2_048, + maximumFiles: 512, + maximumSingleFileBytes: 4 * 1024 * 1024, + maximumTotalBytes: 32 * 1024 * 1024 +} as const + +type SkillPackageObservationLimits = { + maximumDepth: number + maximumEntries: number + maximumFiles: number + maximumSingleFileBytes: number + maximumTotalBytes: number +} + +async function readBoundedSkillFile( + path: string, + remainingTotalBytes: number, + maximumSingleFileBytes: number +): Promise { + const handle = await open(path, 'r') + try { + const before = await handle.stat() + if (before.size > maximumSingleFileBytes) { + throw new Error('skill-package-file-size-limit') + } + if (before.size > remainingTotalBytes) { + throw new Error('skill-package-total-size-limit') + } + const bytes = Buffer.alloc(before.size) + let offset = 0 + while (offset < bytes.length) { + const result = await handle.read(bytes, offset, bytes.length - offset, offset) + if (result.bytesRead === 0) { + throw new Error('skill-package-changed-during-read') + } + offset += result.bytesRead + } + if ((await handle.stat()).size !== before.size) { + throw new Error('skill-package-changed-during-read') + } + return bytes + } finally { + await handle.close() + } +} + +function sha256(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex') +} + +function compareCodeUnits(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1 +} + +function normalizedText(bytes: Buffer): Buffer { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8') +} + +export function describeObservedSkillFile( + path: string, + bytes: Buffer, + executable: boolean +): ObservedSkillFile { + let normalized: Buffer | null = null + if (!bytes.includes(0)) { + try { + normalized = normalizedText(bytes) + } catch { + normalized = null + } + } + const classification = normalized ? 'text' : 'binary' + const exactSha256 = sha256(bytes) + const textNormalizedSha256 = normalized ? sha256(normalized) : null + return { + path, + size: bytes.length, + executable, + classification, + exactSha256, + textNormalizedSha256, + identitySha256: + textNormalizedSha256 !== null && !executable ? textNormalizedSha256 : exactSha256 + } +} + +export function skillPackageDigest(files: readonly SkillBundleFileIdentity[]): string { + return sha256( + Buffer.from( + JSON.stringify( + files.map((file) => ({ + path: file.path, + executable: file.executable, + classification: file.classification, + identitySha256: file.identitySha256 + })) + ) + ) + ) +} + +function matchesFileIdentity( + actual: ObservedSkillFile, + expected: SkillBundleFileIdentity +): boolean { + if ( + actual.path !== expected.path || + actual.executable !== expected.executable || + actual.classification !== expected.classification + ) { + return false + } + return expected.classification === 'text' && !expected.executable + ? actual.textNormalizedSha256 === expected.textNormalizedSha256 + : actual.exactSha256 === expected.exactSha256 +} + +export async function observeSkillPackage( + packageRoot: string, + limits: SkillPackageObservationLimits = SKILL_PACKAGE_OBSERVATION_LIMITS +): Promise { + const files: ObservedSkillFile[] = [] + const caseFoldedPaths = new Map() + let entryCount = 0 + let totalBytes = 0 + + async function visit(directory: string, depth: number): Promise { + const directoryHandle = await opendir(directory) + const entries: Dirent[] = [] + try { + for (;;) { + const entry = await directoryHandle.read() + if (!entry) { + break + } + entryCount += 1 + if (entryCount > limits.maximumEntries) { + throw new Error('skill-package-entry-limit') + } + entries.push(entry) + } + } finally { + await directoryHandle.close().catch(() => undefined) + } + // Why: runtime Electron and the build's Node may carry different ICU data; + // identity order must match the generator without locale-sensitive collation. + entries.sort((left, right) => compareCodeUnits(left.name, right.name)) + for (const entry of entries) { + const absolutePath = join(directory, entry.name) + const relativePath = relative(packageRoot, absolutePath) + if ( + isAbsolute(relativePath) || + relativePath === '..' || + relativePath.startsWith(`..${sep}`) + ) { + throw new Error('skill-path-escape') + } + const manifestPath = relativePath.split(sep).join('/') + const folded = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(folded) + if (collision && collision !== manifestPath) { + throw new Error('skill-case-collision') + } + caseFoldedPaths.set(folded, manifestPath) + const fileStat = await lstat(absolutePath) + if (fileStat.isSymbolicLink()) { + throw new Error('skill-package-link') + } + if (fileStat.isDirectory()) { + if (depth >= limits.maximumDepth) { + throw new Error('skill-package-depth-limit') + } + await visit(absolutePath, depth + 1) + } else if (fileStat.isFile()) { + if (files.length >= limits.maximumFiles) { + throw new Error('skill-package-file-count-limit') + } + const bytes = await readBoundedSkillFile( + absolutePath, + limits.maximumTotalBytes - totalBytes, + limits.maximumSingleFileBytes + ) + totalBytes += bytes.length + files.push(describeObservedSkillFile(manifestPath, bytes, (fileStat.mode & 0o111) !== 0)) + } else { + throw new Error('skill-package-special-file') + } + } + } + + await visit(packageRoot, 0) + return { files, observedDigest: skillPackageDigest(files) } +} + +export function matchingKnownSnapshot( + observed: ObservedSkillPackage, + snapshots: readonly SkillKnownSnapshot[] +): SkillKnownSnapshot | null { + for (const snapshot of snapshots.toReversed()) { + if (snapshot.files.length !== observed.files.length) { + continue + } + if ( + snapshot.files.every((expected, index) => { + const actual = observed.files[index] + return Boolean(actual && matchesFileIdentity(actual, expected)) + }) + ) { + return snapshot + } + } + return null +} diff --git a/src/main/skills/skill-plugin-cache-scan.test.ts b/src/main/skills/skill-plugin-cache-scan.test.ts new file mode 100644 index 00000000000..46216ce6c04 --- /dev/null +++ b/src/main/skills/skill-plugin-cache-scan.test.ts @@ -0,0 +1,40 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('plugin skill candidate scan', () => { + it('stops at the package candidate budget and marks the scan incomplete', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-scan-')) + temporaryDirectories.push(root) + await Promise.all( + ['one', 'two'].map((vendor) => mkdir(join(root, vendor, 'orca-cli'), { recursive: true })) + ) + + const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), 1) + + expect(result.candidates).toHaveLength(1) + expect(result.incompletePaths).toEqual([root]) + }) + + it('marks depth-truncated subtrees incomplete so hidden skills poison eligibility', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-depth-')) + temporaryDirectories.push(root) + const segments = Array.from({ length: 11 }, (_, index) => `level-${index}`) + const hiddenSkill = join(root, ...segments, 'orca-cli') + await mkdir(hiddenSkill, { recursive: true }) + + const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) + + expect(result.candidates).toEqual([]) + expect(result.incompletePaths).toHaveLength(1) + expect(hiddenSkill.startsWith(result.incompletePaths[0] ?? '')).toBe(true) + }) +}) diff --git a/src/main/skills/skill-plugin-cache-scan.ts b/src/main/skills/skill-plugin-cache-scan.ts new file mode 100644 index 00000000000..7162cedbc47 --- /dev/null +++ b/src/main/skills/skill-plugin-cache-scan.ts @@ -0,0 +1,142 @@ +import type { Dirent } from 'node:fs' +import { opendir, realpath, stat } from 'node:fs/promises' +import { join } from 'node:path' + +const MAXIMUM_PLUGIN_SCAN_DEPTH = 9 +const MAXIMUM_PLUGIN_SCAN_ENTRIES = 4_096 +export const MAXIMUM_PLUGIN_SKILL_CANDIDATES = 64 +const MAXIMUM_PLUGIN_INCOMPLETE_PATHS = 16 + +export type KnownPluginSkillCandidate = { + name: string + path: string +} + +export type KnownPluginSkillScan = { + candidates: KnownPluginSkillCandidate[] + incompletePaths: string[] +} + +function errorCode(error: unknown): string | null { + return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null +} + +export async function scanKnownPluginSkillCandidates( + rootPath: string, + knownNames: ReadonlySet, + maximumCandidates = MAXIMUM_PLUGIN_SKILL_CANDIDATES +): Promise { + const candidates: KnownPluginSkillCandidate[] = [] + const incompletePaths = new Set() + const visited = new Set() + let entryCount = 0 + let limitReached = false + + function recordIncomplete(path: string): void { + if (incompletePaths.has(path)) { + return + } + if (incompletePaths.size >= MAXIMUM_PLUGIN_INCOMPLETE_PATHS) { + // Why: each incomplete path expands to one conservative row per official + // skill. Collapse a hostile cache into one poison sentinel before IPC/render fanout. + incompletePaths.clear() + incompletePaths.add(rootPath) + limitReached = true + return + } + incompletePaths.add(path) + } + + async function visit(directory: string, depth: number): Promise { + if (limitReached) { + return + } + if (depth > MAXIMUM_PLUGIN_SCAN_DEPTH) { + recordIncomplete(directory) + return + } + let resolved: string + try { + resolved = await realpath(directory) + } catch (error) { + if (errorCode(error) !== 'ENOENT') { + recordIncomplete(directory) + } + return + } + if (visited.has(resolved)) { + return + } + visited.add(resolved) + + let handle: Awaited> + try { + handle = await opendir(directory) + } catch { + recordIncomplete(directory) + return + } + const entries: Dirent[] = [] + try { + for (;;) { + const entry = await handle.read() + if (!entry) { + break + } + entryCount += 1 + if (entryCount > MAXIMUM_PLUGIN_SCAN_ENTRIES) { + limitReached = true + recordIncomplete(rootPath) + break + } + entries.push(entry) + } + } catch { + recordIncomplete(directory) + } finally { + await handle.close().catch(() => undefined) + } + + entries.sort((left, right) => (left.name === right.name ? 0 : left.name < right.name ? -1 : 1)) + for (const entry of entries) { + if (limitReached) { + return + } + const entryPath = join(directory, entry.name) + let directoryEntry = entry.isDirectory() + if (entry.isSymbolicLink()) { + try { + directoryEntry = (await stat(entryPath)).isDirectory() + } catch { + if (knownNames.has(entry.name)) { + if (candidates.length >= maximumCandidates) { + limitReached = true + recordIncomplete(rootPath) + return + } + candidates.push({ name: entry.name, path: entryPath }) + } + continue + } + } + if (!directoryEntry) { + continue + } + if (knownNames.has(entry.name)) { + if (candidates.length >= maximumCandidates) { + limitReached = true + recordIncomplete(rootPath) + return + } + candidates.push({ name: entry.name, path: entryPath }) + continue + } + await visit(entryPath, depth + 1) + } + } + + await visit(rootPath, 0) + return { candidates, incompletePaths: [...incompletePaths] } +} diff --git a/src/main/ssh/ssh-config-parser.test.ts b/src/main/ssh/ssh-config-parser.test.ts index f550c833a0f..a9eda1f0248 100644 --- a/src/main/ssh/ssh-config-parser.test.ts +++ b/src/main/ssh/ssh-config-parser.test.ts @@ -163,6 +163,40 @@ Host myserver expect(hosts[0].identitiesOnly).toBe(true) }) + it('parses GSSAPIAuthentication', () => { + const config = ` +Host krb-host + HostName krb.example.com + GSSAPIAuthentication yes + +Host plain-host + HostName plain.example.com + GSSAPIAuthentication no + +Host silent-host + HostName silent.example.com +` + const hosts = parseSshConfig(config) + expect(hosts[0].gssapiAuthentication).toBe(true) + expect(hosts[1].gssapiAuthentication).toBe(false) + expect(hosts[2].gssapiAuthentication).toBeUndefined() + }) + + it('keeps the first GSSAPIAuthentication value like OpenSSH', () => { + const hosts = parseSshConfig(` +Host enabled + GSSAPIAuthentication yes + GSSAPIAuthentication no + +Host disabled + GSSAPIAuthentication no + GSSAPIAuthentication yes +`) + + expect(hosts[0].gssapiAuthentication).toBe(true) + expect(hosts[1].gssapiAuthentication).toBe(false) + }) + it('parses ProxyCommand, ProxyUseFdpass, and ProxyJump', () => { const config = ` Host internal @@ -359,6 +393,12 @@ describe('sshConfigHostsToTargets', () => { expect(targets[0].jumpHost).toBe('bastion.example.com') }) + it('carries through gssapiAuthentication', () => { + const hosts = [{ host: 'krb-host', hostname: 'krb.example.com', gssapiAuthentication: true }] + const targets = sshConfigHostsToTargets(hosts, new Set()) + expect(targets[0].gssapiAuthentication).toBe(true) + }) + it('imports duplicate aliases only once and keeps the first concrete host', () => { const hosts = [ { host: 'dup', hostname: 'first.example.com', user: 'first' }, @@ -508,6 +548,15 @@ describe('parseSshGOutput', () => { expect(result.identitiesOnly).toBe(true) }) + it('parses gssapiauthentication', () => { + const output = 'hostname example.com\ngssapiauthentication yes\nport 22' + const result = parseSshGOutput(output) + expect(result.gssapiAuthentication).toBe(true) + + const offResult = parseSshGOutput('hostname example.com\ngssapiauthentication no\nport 22') + expect(offResult.gssapiAuthentication).toBe(false) + }) + it('parses controlmaster options and filters controlpath none', () => { const output = [ 'hostname example.com', diff --git a/src/main/ssh/ssh-config-parser.ts b/src/main/ssh/ssh-config-parser.ts index 9bd02f49eb6..92cec9dac0e 100644 --- a/src/main/ssh/ssh-config-parser.ts +++ b/src/main/ssh/ssh-config-parser.ts @@ -14,6 +14,7 @@ export type SshConfigHost = { identityFile?: string identityAgent?: string identitiesOnly?: boolean + gssapiAuthentication?: boolean proxyCommand?: string proxyUseFdpass?: boolean proxyJump?: string @@ -104,6 +105,12 @@ export function parseSshConfig(content: string): SshConfigHost[] { host.identitiesOnly = value.toLowerCase() === 'yes' } break + case 'gssapiauthentication': + for (const host of current) { + // Why: OpenSSH uses the first obtained value for each parameter. + host.gssapiAuthentication ??= value.toLowerCase() === 'yes' + } + break case 'proxycommand': for (const host of current) { // Why: OpenSSH treats ProxyCommand as a shell snippet and preserves @@ -249,6 +256,7 @@ export function sshConfigHostsToTargets( identityFile: entry.identityFile, identityAgent: entry.identityAgent, identitiesOnly: entry.identitiesOnly, + gssapiAuthentication: entry.gssapiAuthentication, proxyCommand: entry.proxyCommand, jumpHost: entry.proxyJump }) diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index 4bfa7259dab..90d92a1499e 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -260,6 +260,34 @@ describe('SshConnectionStore', () => { ) }) + it('refreshes gssapiAuthentication on sync', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'krb-box', + configHost: 'krb-box', + host: 'krb.example.com', + port: 22, + username: 'dev', + source: 'ssh-config' + }) + loadUserSshConfigMock.mockReturnValue([{ host: 'krb-box' }]) + sshConfigHostsToTargetsMock.mockReturnValue([ + candidate({ + configHost: 'krb-box', + host: 'krb.example.com', + username: 'dev', + gssapiAuthentication: true + }) + ]) + + sshStore.importFromSshConfig() + + expect(mockStore.updateSshTarget).toHaveBeenCalledWith( + 'ssh-1', + expect.objectContaining({ gssapiAuthentication: true }) + ) + }) + it('never overwrites a manual target that owns the alias', () => { mockStore.addSshTarget({ id: 'ssh-m', diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index 295e7d57ac3..408b4522afa 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -184,6 +184,7 @@ export class SshConnectionStore { identityFile: candidate.identityFile, identityAgent: candidate.identityAgent, identitiesOnly: candidate.identitiesOnly, + gssapiAuthentication: candidate.gssapiAuthentication, proxyCommand: candidate.proxyCommand, jumpHost: candidate.jumpHost } diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index 0c578cff7d2..50f2eb66ff2 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -31,6 +31,7 @@ vi.mock('fs', () => ({ import { isTransientError, isSystemSshFallbackError, + isGssapiSystemSshFallbackCandidate, isAuthError, isAgentFallbackError, sleep, @@ -166,6 +167,49 @@ describe('isSystemSshFallbackError', () => { }) }) +// ── isGssapiSystemSshFallbackCandidate ─────────────────────────────── + +describe('isGssapiSystemSshFallbackCandidate', () => { + const authErr = new Error('All configured authentication methods failed') + + it('returns true for auth failures when resolved config enables GSSAPI', () => { + expect(isGssapiSystemSshFallbackCandidate(authErr, {}, { gssapiAuthentication: true })).toBe( + true + ) + }) + + it('returns true for passphrase failures so Kerberos SSO runs before prompting', () => { + const passphraseErr = new Error('Encrypted private OpenSSH key detected, but no passphrase') + expect( + isGssapiSystemSshFallbackCandidate(passphraseErr, {}, { gssapiAuthentication: true }) + ).toBe(true) + }) + + it('returns false when the target already tried system ssh proactively', () => { + expect( + isGssapiSystemSshFallbackCandidate( + authErr, + { gssapiAuthentication: true }, + { gssapiAuthentication: true } + ) + ).toBe(false) + }) + + it('returns false without GSSAPI in the resolved config', () => { + expect(isGssapiSystemSshFallbackCandidate(authErr, {}, { gssapiAuthentication: false })).toBe( + false + ) + expect(isGssapiSystemSshFallbackCandidate(authErr, {}, null)).toBe(false) + }) + + it('returns false for network errors so retry semantics stay unchanged', () => { + const netErr = new Error('connect ETIMEDOUT 1.2.3.4:22') + expect(isGssapiSystemSshFallbackCandidate(netErr, {}, { gssapiAuthentication: true })).toBe( + false + ) + }) +}) + // ── isAuthError ────────────────────────────────────────────────────── describe('isAuthError', () => { diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index cc8269c1b48..de9e0b6c63f 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -84,6 +84,24 @@ export function isSystemSshFallbackError(err: Error): boolean { return err.message.includes('EHOSTUNREACH') || err.message.includes('ENETUNREACH') } +// Why: ssh2 has no gssapi-with-mic support. When the effective OpenSSH config +// enables GSSAPIAuthentication (often a distro-wide /etc/ssh default), a +// Kerberos ticket can still authenticate through the system ssh binary after +// key/agent auth fails — but only auth-shaped failures qualify, so network +// errors keep their existing retry semantics. +export function isGssapiSystemSshFallbackCandidate( + err: Error, + target: Pick, + resolved: Pick | null +): boolean { + // Why: targets with an explicit per-host flag already tried system ssh + // proactively during this attempt; probing again cannot succeed. + if (target.gssapiAuthentication === true) { + return false + } + return (isAuthError(err) || isPassphraseError(err)) && resolved?.gssapiAuthentication === true +} + export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } @@ -92,10 +110,44 @@ export function shellEscape(s: string): string { return `'${s.replace(/'/g, "'\\''")}'` } +const REMOTE_COMMAND_CHUNK_MAX_BYTES = 1_024 +const REMOTE_COMMAND_PRINTF_ESCAPED_BYTES = new Set([0x21, 0x27, 0x5c]) + +function encodeRemoteCommandForPrintf(command: string): string[] { + const chunks: string[] = [] + let chunk = '' + let chunkBytes = 0 + for (const character of command) { + const codePoint = character.codePointAt(0)! + const isSafePrintableAscii = + codePoint >= 0x20 && codePoint <= 0x7e && !REMOTE_COMMAND_PRINTF_ESCAPED_BYTES.has(codePoint) + const encodedCharacter = + codePoint > 0x7f || isSafePrintableAscii + ? character + : `\\0${codePoint.toString(8).padStart(3, '0')}` + const encodedBytes = codePoint > 0x7f ? Buffer.byteLength(character) : encodedCharacter.length + if (chunkBytes + encodedBytes > REMOTE_COMMAND_CHUNK_MAX_BYTES) { + chunks.push(chunk) + chunk = '' + chunkBytes = 0 + } + chunk += encodedCharacter + chunkBytes += encodedBytes + } + chunks.push(chunk) + return chunks +} + +/** Wrap a POSIX snippet into one line that non-POSIX SSH login shells can forward. */ export function wrapRemoteCommandForPosixShell(command: string): string { - // Why: sshd asks the user's login shell to parse exec commands. Orca emits - // POSIX sh snippets; `exec` avoids leaving that shell around for relay bridges. - return `exec /bin/sh -c ${shellEscape(command)}` + // Why: csh/tcsh split multiline SSH exec strings before /bin/sh sees them. + // POSIX printf rebuilds bounded argument chunks without consuming relay stdin. + const encodedChunks = encodeRemoteCommandForPrintf(command) + const decodeAndRun = + 'decoded=$(printf %b "$@" && printf _) || exit $?; ' + + 'decoded=${decoded%_}; exec /bin/sh -c "$decoded"' + const chunkArguments = encodedChunks.map(shellEscape).join(' ') + return `exec /bin/sh -c ${shellEscape(decodeAndRun)} orca-command ${chunkArguments}` } export type SshExecOptions = { diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 005d43c9ca8..e8c5d71234c 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -657,15 +657,19 @@ describe('SshConnection', () => { } }) - it('wraps exec commands in /bin/sh so non-POSIX login shells do not parse relay snippets', async () => { + it('wraps exec commands as a single line that csh/tcsh login shells cannot break', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) await conn.connect() - await conn.exec("cd '/tmp' && ('/usr/bin/node' -e 'console.log(1)' || echo MISSING)") + const original = "cd '/tmp' && ('/usr/bin/node' -e 'console.log(1)' || echo MISSING)" + await conn.exec(original) - expect(clientInstances[0].lastExecCommand).toBe( - "exec /bin/sh -c 'cd '\\''/tmp'\\'' && ('\\''/usr/bin/node'\\'' -e '\\''console.log(1)'\\'' || echo MISSING)'" - ) + const wrapped = clientInstances[0].lastExecCommand! + // Why: sshd lets the login shell parse this first, so raw newlines let + // csh/tcsh split the command before /bin/sh receives it (issue #8701). + expect(wrapped).not.toContain('\n') + expect(wrapped).toMatch(/^exec \/bin\/sh -c '.*printf %b .*' orca-command /) + expect(wrapped).not.toContain('base64') }) it('can execute native remote commands without the POSIX shell wrapper', async () => { @@ -1060,6 +1064,236 @@ describe('SshConnection', () => { expect(conn.usesSystemSshTransport()).toBe(false) }) + it('tries system SSH first for targets that explicitly request GSSAPI authentication', async () => { + const conn = new SshConnection(createTarget({ gssapiAuthentication: true }), createCallbacks()) + + await conn.connect() + await conn.exec('echo after-connect') + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + expect(clientInstances).toHaveLength(0) + expect(spawnSystemSshCommandMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ gssapiAuthentication: true }), + 'echo ORCA-SYSTEM-SSH-OK', + { + gssapiOnly: true, + wrapCommand: false + } + ) + expect(spawnSystemSshCommandMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ gssapiAuthentication: true }), + 'echo after-connect', + { gssapiOnly: true } + ) + }) + + it('falls back to ssh2 when the GSSAPI-first system SSH attempt fails', async () => { + spawnSystemSshCommandMock.mockImplementation(() => + createFailingSystemCommandChannel(255, 'Permission denied (gssapi-with-mic,publickey)') + ) + const conn = new SshConnection(createTarget({ gssapiAuthentication: true }), createCallbacks()) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(false) + expect(clientInstances).toHaveLength(1) + // Why: proves the GSSAPI-first probe actually ran before the ssh2 fallback, + // so the test fails if the proactive block is removed. + expect(spawnSystemSshCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ gssapiAuthentication: true }), + 'echo ORCA-SYSTEM-SSH-OK', + { + gssapiOnly: true, + wrapCommand: false + } + ) + }) + + it('falls back to system SSH after an ssh2 auth failure when resolved config enables GSSAPI', async () => { + connectBehavior = 'error' + connectErrorMessage = 'All configured authentication methods failed' + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true }) + ) + const onCredentialRequest = vi.fn(async () => 'password-123') + const conn = new SshConnection( + createTarget({ configHost: 'krb-host' }), + createCallbacks({ onCredentialRequest }) + ) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + expect(onCredentialRequest).not.toHaveBeenCalled() + }) + + it('connects through the GSSAPI fallback without credential callbacks (headless)', async () => { + connectBehavior = 'error' + connectErrorMessage = 'All configured authentication methods failed' + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true }) + ) + const conn = new SshConnection(createTarget({ configHost: 'krb-host' }), createCallbacks()) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + }) + + it('keeps prompting for credentials when the GSSAPI fallback probe fails', async () => { + // Why: identityAgent 'none' makes resolveAgentSocket return undefined on + // every platform (SSH_AUTH_SOCK='' alone leaves the Windows agent pipe), so + // ssh2's first connect carries any default key directly and the agent + // fallback retry never consumes the second connectSequence entry — + // deterministic on dev machines with both ~/.ssh/id_* and a live agent. + vi.stubEnv('SSH_AUTH_SOCK', '') + connectSequence = [new Error('All configured authentication methods failed'), 'ready'] + spawnSystemSshCommandMock.mockImplementation(() => + createFailingSystemCommandChannel(255, 'Permission denied (gssapi-with-mic,password)') + ) + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ + proxyUseFdpass: false, + gssapiAuthentication: true, + identityAgent: 'none' + }) + ) + const onCredentialRequest = vi.fn(async () => 'password-123') + const conn = new SshConnection( + createTarget({ configHost: 'krb-host' }), + createCallbacks({ onCredentialRequest }) + ) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(false) + // Why: proves the reactive GSSAPI probe actually ran before prompting, so + // the test fails if the reactive fallback block is removed. + expect(spawnSystemSshCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'krb-host' }), + 'echo ORCA-SYSTEM-SSH-OK', + expect.objectContaining({ wrapCommand: false }) + ) + expect(onCredentialRequest).toHaveBeenCalledWith('target-1', 'password', expect.any(String)) + }) + + it('tries the GSSAPI probe before prompting for an encrypted key passphrase', async () => { + // Why: a valid Kerberos ticket should connect silently before the user is + // ever asked for the key passphrase. Agent auth fails, the explicit-key + // retry fails with a passphrase error, and resolved GSSAPI is on — so the + // reactive probe must run before onCredentialRequest. + vi.stubEnv('SSH_AUTH_SOCK', '/tmp/agent.sock') + const tempDir = mkdtempSync(join(tmpdir(), 'orca-ssh-key-')) + const keyPath = join(tempDir, 'id_ed25519') + writeFileSync(keyPath, 'test-key') + connectSequence = [ + new Error('All configured authentication methods failed'), + new Error('Encrypted private OpenSSH key detected, but no passphrase given') + ] + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true }) + ) + const order: string[] = [] + spawnSystemSshCommandMock.mockImplementation(() => { + order.push('probe') + return createSystemCommandChannel() + }) + const onCredentialRequest = vi.fn(async () => { + order.push('prompt') + return 'secret' + }) + + try { + const conn = new SshConnection( + createTarget({ configHost: 'krb-host', identityFile: keyPath }), + createCallbacks({ onCredentialRequest }) + ) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + // Why: the probe must precede any passphrase prompt (which here never runs). + expect(order[0]).toBe('probe') + expect(onCredentialRequest).not.toHaveBeenCalled() + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('does not try system SSH for auth failures when resolved config leaves GSSAPI off', async () => { + connectBehavior = 'error' + connectErrorMessage = 'All configured authentication methods failed' + vi.mocked(resolveWithSshG).mockResolvedValue(createResolvedConfig({ proxyUseFdpass: false })) + const conn = new SshConnection(createTarget({ configHost: 'plain-host' }), createCallbacks()) + + await expect(conn.connect()).rejects.toThrow('All configured authentication methods failed') + expect(conn.getState().status).toBe('auth-failed') + expect(spawnSystemSshCommandMock).not.toHaveBeenCalled() + }) + + it('clears system SSH transport when the GSSAPI-first probe throws synchronously', async () => { + // Why: no system ssh binary makes spawnSystemSshCommand throw before the + // probe's try/catch, so the ssh2 fall-through must still reset the flag — + // otherwise exec/sftp keep routing through the unusable system transport. + spawnSystemSshCommandMock.mockImplementation(() => { + throw new Error('No system ssh binary found. Install OpenSSH.') + }) + connectSequence = ['ready'] + const conn = new SshConnection(createTarget({ gssapiAuthentication: true }), createCallbacks()) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(false) + expect(clientInstances).toHaveLength(1) + }) + + it('keeps disconnected state when a disconnect cancels the reactive GSSAPI probe', async () => { + connectBehavior = 'error' + connectErrorMessage = 'All configured authentication methods failed' + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true }) + ) + // Why: a probe channel that stays open until close() leaves the reactive + // fallback pending, so we can disconnect mid-probe; disconnect() then calls + // close() (bumping the generation first), which settles the probe as a + // cancellation rather than a probe failure. + let pendingChannel: ReturnType | null = null + spawnSystemSshCommandMock.mockImplementation(() => { + const channel = new EventEmitter() as ReturnType + channel.stdin = { end: vi.fn(), write: vi.fn() } + channel.stderr = new EventEmitter() + channel.close = vi.fn(() => channel.emit('close', null)) + pendingChannel = channel + return channel + }) + const onStateChange = vi.fn() + const conn = new SshConnection( + createTarget({ configHost: 'krb-host' }), + createCallbacks({ onStateChange }) + ) + + const connectPromise = conn.connect() + // Wait until the reactive probe has spawned its (never-closing) channel. + await vi.waitFor(() => expect(pendingChannel).not.toBeNull()) + + await conn.disconnect() + await connectPromise.catch(() => {}) + + expect(conn.getState().status).toBe('disconnected') + const statuses = onStateChange.mock.calls.map((call) => call[1].status) + expect(statuses).not.toContain('auth-failed') + expect(statuses).not.toContain('error') + }) + it('passes the detected host platform to system SSH file operations', async () => { vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index af74bdc712a..2856d33b2f0 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -27,6 +27,7 @@ import { isAuthError, isAgentFallbackError, isSystemSshFallbackError, + isGssapiSystemSshFallbackCandidate, isPassphraseError, sleep, buildConnectConfig, @@ -73,6 +74,7 @@ export class SshConnection { private systemOperationAbortController = new AbortController() private systemSshResolvedConfig: SshResolvedConfig | null = null private systemSshControlMasterDisabledForSession = false + private systemSshGssapiOnlyForSession = false private useSystemSshTransport = false private state: SshConnectionState private callbacks: SshConnectionCallbacks @@ -491,6 +493,12 @@ export class SshConnection { } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)) + // Why: a concurrent disconnect() already set 'disconnected'; a cancelled + // attempt's late error must not overwrite it with auth-failed/error. + if (this.disposed) { + throw lastError + } + if (isAuthError(lastError) || isPassphraseError(lastError)) { this.setState('auth-failed', lastError.message) throw lastError @@ -525,8 +533,27 @@ export class SshConnection { await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved) return } + // Why: ssh2 has no gssapi-with-mic support, so hosts that explicitly + // request GSSAPIAuthentication try Kerberos SSO via the system OpenSSH + // binary first. Restrict the probe to GSSAPI so missing tickets fall through + // to Orca's existing key and credential-prompt path. + if (this.target.gssapiAuthentication === true) { + try { + await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved, true) + return + } catch (probeErr) { + if (this.disposed || !this.isCurrentConnectAttempt(connectGeneration)) { + throw probeErr + } + } + } + // Why: a synchronous spawn throw (no system ssh binary) bypasses the probe's + // own catch, so the ssh2 fall-through must clear all system-transport state + // itself — otherwise exec/sftp keep routing through the failed transport. this.systemSshResolvedConfig = null this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false + this.useSystemSshTransport = false const config = buildConnectConfig(this.target, resolved) @@ -566,6 +593,7 @@ export class SshConnection { } catch { this.systemSshResolvedConfig = null this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false this.useSystemSshTransport = false throw err } @@ -604,7 +632,15 @@ export class SshConnection { throw keyErr } authError = keyErr - if (isPassphraseError(authError) && !this.cachedPassphrase) { + // Why: when the effective config enables GSSAPI, let the reactive + // system-ssh probe (below) try a Kerberos ticket before prompting + // for the key passphrase; the general passphrase prompt still runs + // if that probe fails, since passphrasePromptHandled stays false. + if ( + isPassphraseError(authError) && + !this.cachedPassphrase && + !isGssapiSystemSshFallbackCandidate(authError, this.target, resolved) + ) { passphrasePromptHandled = true const detail = this.target.identityFile || resolved?.identityFile?.[0] || '(unknown)' const val = await this.callbacks.onCredentialRequest?.( @@ -624,6 +660,28 @@ export class SshConnection { } } + // Why: a Kerberos ticket may authenticate where keys did not; try the + // system ssh binary before falling back to interactive prompts. + if (isGssapiSystemSshFallbackCandidate(authError, this.target, resolved)) { + this.proxyProcess?.kill() + this.proxyProcess = null + try { + await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved, true) + return + } catch { + this.systemSshResolvedConfig = null + this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false + this.useSystemSshTransport = false + } + // Why: if a disconnect/reconnect superseded this attempt mid-probe, throw + // the cancellation error — not the stale ssh2 authError — so connect() + // does not post auth-failed after the target was deliberately disconnected. + if (this.disposed || !this.isCurrentConnectAttempt(connectGeneration)) { + throw this.createCancelledConnectAttemptError() + } + } + if (!this.callbacks.onCredentialRequest) { this.proxyProcess?.kill() this.proxyProcess = null @@ -762,12 +820,15 @@ export class SshConnection { private async doSystemSshProbeWithControlMasterRetry( connectGeneration: number, - resolved: SshResolvedConfig | null + resolved: SshResolvedConfig | null, + gssapiOnly = false ): Promise { this.systemSshResolvedConfig = cloneResolvedConfig(resolved) this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = gssapiOnly const controlPath = getOrcaControlSocketPath(this.target, { - resolvedConfig: this.systemSshResolvedConfig + resolvedConfig: this.systemSshResolvedConfig, + gssapiOnly: this.systemSshGssapiOnlyForSession }) try { await this.doSystemSshProbe(connectGeneration) @@ -937,6 +998,9 @@ export class SshConnection { if (this.systemSshControlMasterDisabledForSession) { options.disableControlMaster = true } + if (this.systemSshGssapiOnlyForSession) { + options.gssapiOnly = true + } return options } @@ -1117,6 +1181,7 @@ export class SshConnection { this.systemSsh = null this.systemSshResolvedConfig = null this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false this.useSystemSshTransport = false } @@ -1129,6 +1194,7 @@ export class SshConnection { this.systemSsh = null this.systemSshResolvedConfig = null this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false this.useSystemSshTransport = false this.setState('connecting') try { @@ -1170,6 +1236,7 @@ export class SshConnection { this.useSystemSshTransport = false this.systemSshResolvedConfig = null this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false this.setState('error', err instanceof Error ? err.message : String(err)) throw err } @@ -1198,6 +1265,7 @@ export class SshConnection { this.systemSsh = null this.systemSshResolvedConfig = null this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false this.useSystemSshTransport = false this.setState('disconnected') } diff --git a/src/main/ssh/ssh-control-socket.test.ts b/src/main/ssh/ssh-control-socket.test.ts index 98ae45f0d34..c08bfc739e0 100644 --- a/src/main/ssh/ssh-control-socket.test.ts +++ b/src/main/ssh/ssh-control-socket.test.ts @@ -84,6 +84,13 @@ describe.skipIf(process.platform === 'win32')('getControlSocketPath', () => { }) }) + it('uses a separate socket for GSSAPI-only authentication', () => { + const ordinary = getControlSocketPath(createTarget(), createResolved()) + const gssapiOnly = getControlSocketPath(createTarget(), createResolved(), true) + + expect(gssapiOnly).not.toBe(ordinary) + }) + it('changes the path when a config-backed target resolves to a different host', () => { const before = getControlSocketPath(createTarget({ host: '10.0.0.5' }), createResolved()) const after = getControlSocketPath( diff --git a/src/main/ssh/ssh-control-socket.ts b/src/main/ssh/ssh-control-socket.ts index 5cbd0fdec5a..73622ba504e 100644 --- a/src/main/ssh/ssh-control-socket.ts +++ b/src/main/ssh/ssh-control-socket.ts @@ -28,7 +28,8 @@ const CONTROL_SOCKET_PATH_MAX_LENGTH = UNIX_SOCKET_PATH_LIMIT - OPENSSH_CONTROL_ export function getControlSocketPath( target: SshTarget, - resolvedConfig?: SystemSshResolvedConfig | null + resolvedConfig?: SystemSshResolvedConfig | null, + gssapiOnly = false ): string | null { if (process.platform === 'win32') { return null @@ -58,7 +59,9 @@ export function getControlSocketPath( identityAgent: target.identityAgent || '', identitiesOnly: target.identitiesOnly || false }, - resolved: normalizeResolvedConfig(resolvedConfig) + resolved: normalizeResolvedConfig(resolvedConfig), + // Why: a Kerberos-only session must not reuse a master authenticated by a key. + gssapiOnly }) const hash = createHash('sha256').update(key).digest('hex').slice(0, 16) const socketPath = pathJoin(dir, hash) diff --git a/src/main/ssh/ssh-g-config-resolution.ts b/src/main/ssh/ssh-g-config-resolution.ts index c004701e932..29c6d5eaae3 100644 --- a/src/main/ssh/ssh-g-config-resolution.ts +++ b/src/main/ssh/ssh-g-config-resolution.ts @@ -9,6 +9,8 @@ export type SshResolvedConfig = { identityAgent?: string identitiesOnly: boolean forwardAgent: boolean + /** Effective GSSAPIAuthentication, including distro-wide /etc/ssh defaults. */ + gssapiAuthentication?: boolean proxyCommand?: string proxyUseFdpass: boolean proxyJump?: string @@ -106,6 +108,7 @@ function buildSshResolvedConfig( identityAgent, identitiesOnly: map.get('identitiesonly') === 'yes', forwardAgent: map.get('forwardagent') === 'yes', + gssapiAuthentication: map.get('gssapiauthentication') === 'yes', proxyCommand, proxyUseFdpass: map.get('proxyusefdpass') === 'yes', proxyJump, diff --git a/src/main/ssh/ssh-login-shell-command.ts b/src/main/ssh/ssh-login-shell-command.ts new file mode 100644 index 00000000000..3b44bafbb49 --- /dev/null +++ b/src/main/ssh/ssh-login-shell-command.ts @@ -0,0 +1,11 @@ +import { shellEscape } from './ssh-connection-utils' + +const COMMAND_ONLY_SHELLS = new Set(['sh', 'dash', 'csh', 'tcsh']) + +/** Build a command using the startup mode supported by the configured login shell. */ +export function buildSshLoginShellCommand(shell: string, command: string): string { + const shellName = shell.split('/').at(-1) + // Why: csh/tcsh reject combined -lc, while sh/dash do not need login mode here. + const mode = shellName && COMMAND_ONLY_SHELLS.has(shellName) ? '-c' : '-lc' + return `${shellEscape(shell)} ${mode} ${shellEscape(command)}` +} diff --git a/src/main/ssh/ssh-posix-command-wrapper.test.ts b/src/main/ssh/ssh-posix-command-wrapper.test.ts new file mode 100644 index 00000000000..1497dba4d40 --- /dev/null +++ b/src/main/ssh/ssh-posix-command-wrapper.test.ts @@ -0,0 +1,94 @@ +import { existsSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { shellEscape, wrapRemoteCommandForPosixShell } from './ssh-connection-utils' + +describe('wrapRemoteCommandForPosixShell', () => { + it('emits one physical line without requiring a remote decoder binary', () => { + const wrapped = wrapRemoteCommandForPosixShell("printf 'a\\n'\nprintf 'b\\n'") + expect(wrapped).not.toContain('\n') + expect(wrapped).toContain('printf %b "$@"') + expect(wrapped).not.toContain('base64') + }) + + it('does not expand long commands made only of parser-safe ASCII', () => { + const command = `: #${'x'.repeat(7_000)}` + expect(wrapRemoteCommandForPosixShell(command).length - command.length).toBeLessThan(200) + }) + + it('does not octal-expand literal UTF-8 near SSH command-length limits', () => { + const command = `: #${'☃'.repeat(7_000)}` + expect(wrapRemoteCommandForPosixShell(command).length - command.length).toBeLessThan(200) + }) + + it('does not octal-expand quoted shell metacharacters near command-length limits', () => { + const command = `: #${'$"`'.repeat(2_000)}` + expect(wrapRemoteCommandForPosixShell(command).length - command.length).toBeLessThan(200) + }) + + it.skipIf(process.platform === 'win32')( + 'reconstructs multiline UTF-8 and shell metacharacters exactly', + () => { + const expected = "line one\nline 'two' \"$`\\ ! ☃\n" + const command = `printf '%s' ${shellEscape(expected)}` + const result = runThroughShell('/bin/sh', command) + + expect(result.status).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toBe(expected) + } + ) + + it.skipIf(process.platform === 'win32')('keeps stdin free for relay protocol bytes', () => { + const command = 'IFS= read -r frame; printf \'frame:%s\' "$frame"' + const result = runThroughShell('/bin/sh', command, 'rpc-payload\n') + + expect(result.status).toBe(0) + expect(result.stdout).toBe('frame:rpc-payload') + }) + + it.skipIf(process.platform === 'win32' || !existsSync('/bin/tcsh'))( + 'keeps every login-shell word bounded for long metacharacter-heavy commands', + () => { + const expected = '$"`'.repeat(2_000) + const command = `printf '%s' ${shellEscape(expected)}` + const result = runThroughShell('/bin/tcsh', command) + + expect(result.status).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toBe(expected) + } + ) + + for (const shell of [ + '/bin/sh', + '/bin/bash', + '/bin/dash', + '/bin/zsh', + '/bin/ksh', + '/bin/csh', + '/bin/tcsh', + '/usr/bin/fish', + '/usr/local/bin/fish', + '/opt/homebrew/bin/fish' + ]) { + it.skipIf(process.platform === 'win32' || !existsSync(shell))( + `survives the real ${shell} parser`, + () => { + const expected = "first\nsecond '$`\\n\\\\ ! ☃\n" + const command = `printf '%s' ${shellEscape(expected)}` + const result = runThroughShell(shell, command) + expect(result.status).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toBe(expected) + } + ) + } +}) + +function runThroughShell(shell: string, command: string, input?: string) { + return spawnSync(shell, ['-c', wrapRemoteCommandForPosixShell(command)], { + encoding: 'utf8', + input + }) +} diff --git a/src/main/ssh/ssh-relay-reset.test.ts b/src/main/ssh/ssh-relay-reset.test.ts index 5b4e56ab9ee..47364fe5ed9 100644 --- a/src/main/ssh/ssh-relay-reset.test.ts +++ b/src/main/ssh/ssh-relay-reset.test.ts @@ -1,4 +1,9 @@ -import { describe, expect, it, vi } from 'vitest' +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type Server } from 'node:net' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') @@ -9,7 +14,112 @@ import { execCommand } from './ssh-relay-deploy-helpers' import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' import type { SshConnection } from './ssh-connection' +const TARGET_PID = '11111' +const UNRELATED_PID = '22222' + +type LsofMode = 'match' | 'empty' + +function writeExecutable(filePath: string, body: string): void { + writeFileSync(filePath, `#!/bin/sh\n${body}\n`, { mode: 0o755 }) +} + +async function listenOnSocket(server: Server, socketPath: string): Promise { + await new Promise((resolve, reject) => { + const onError = (error: Error): void => reject(error) + server.once('error', onError) + server.listen(socketPath, () => { + server.off('error', onError) + resolve() + }) + }) +} + +async function closeServer(server: Server): Promise { + if (!server.listening) { + return + } + await new Promise((resolve) => server.close(() => resolve())) +} + +async function capturedResetScript(): Promise { + const conn = {} as SshConnection + await forceStopRelayForTarget(conn, 'ssh-1') + return vi.mocked(execCommand).mock.lastCall?.[1] ?? '' +} + +async function runResetScript(lsofMode: LsofMode): Promise<{ + killCalls: string[] + pgrepCalls: string[] + socketExists: boolean +}> { + const script = await capturedResetScript() + const home = mkdtempSync(join(tmpdir(), 'orca-')) + const binDir = join(home, 'bin') + const socketDir = join(home, '.orca-remote') + const socketPath = join(socketDir, relaySocketNameForInstanceId('ssh-1')) + const killLog = join(home, 'kill.log') + const pgrepLog = join(home, 'pgrep.log') + mkdirSync(binDir) + mkdirSync(socketDir, { recursive: true }) + writeFileSync(killLog, '') + writeFileSync(pgrepLog, '') + + const lsofBody = + lsofMode === 'empty' + ? 'exit 1' + : `case " $* " in + *" -a "*) printf '%s\\n' "$TARGET_PID" ;; + *) printf '%s\\n' "$TARGET_PID" "$UNRELATED_PID" ;; +esac` + writeExecutable(join(binDir, 'lsof'), lsofBody) + writeExecutable( + join(binDir, 'pgrep'), + `printf 'called\\n' >> "$PGREP_LOG" +printf '%s\\n' "$TARGET_PID"` + ) + writeExecutable(join(binDir, 'sleep'), 'exit 0') + + const server = createServer() + try { + await listenOnSocket(server, socketPath) + execFileSync( + '/bin/sh', + [ + '-c', + `kill() { + printf '%s\\n' "$*" >> "$KILL_LOG" +} +eval "$RESET_SCRIPT"` + ], + { + env: { + ...process.env, + HOME: home, + KILL_LOG: killLog, + PATH: `${binDir}${delimiter}${process.env.PATH ?? ''}`, + PGREP_LOG: pgrepLog, + RESET_SCRIPT: script, + TARGET_PID, + UNRELATED_PID + } + } + ) + return { + killCalls: readFileSync(killLog, 'utf8').split('\n').filter(Boolean), + pgrepCalls: readFileSync(pgrepLog, 'utf8').split('\n').filter(Boolean), + socketExists: existsSync(socketPath) + } + } finally { + await closeServer(server) + rmSync(home, { recursive: true, force: true }) + } +} + describe('forceStopRelayForTarget', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + it('targets only the relay socket for the requested SSH target', async () => { const conn = {} as SshConnection @@ -18,8 +128,31 @@ describe('forceStopRelayForTarget', () => { const command = vi.mocked(execCommand).mock.calls[0]?.[1] ?? '' expect(execCommand).toHaveBeenCalledWith(conn, expect.any(String)) expect(command).toContain(`sock_name='${relaySocketNameForInstanceId('ssh-1')}'`) - expect(command).toContain('lsof -t -U "$sock"') + expect(command).toContain('lsof -t -a -U "$sock"') expect(command).toContain('pgrep -f "$sock_name"') expect(command).toContain('rm -f "$sock"') }) + + it.skipIf(process.platform === 'win32')( + 'never passes unrelated unix-socket holders to kill', + async () => { + const result = await runResetScript('match') + + expect(result.killCalls).toEqual([`-TERM ${TARGET_PID}`, `-KILL ${TARGET_PID}`]) + expect(result.killCalls.join(' ')).not.toContain(UNRELATED_PID) + expect(result.pgrepCalls).toEqual([]) + expect(result.socketExists).toBe(false) + } + ) + + it.skipIf(process.platform === 'win32')( + 'uses the command-line fallback when lsof cannot match the socket', + async () => { + const result = await runResetScript('empty') + + expect(result.killCalls).toEqual([`-TERM ${TARGET_PID}`, `-KILL ${TARGET_PID}`]) + expect(result.pgrepCalls).toEqual(['called']) + expect(result.socketExists).toBe(false) + } + ) }) diff --git a/src/main/ssh/ssh-relay-reset.ts b/src/main/ssh/ssh-relay-reset.ts index b121b5a9c26..50101084478 100644 --- a/src/main/ssh/ssh-relay-reset.ts +++ b/src/main/ssh/ssh-relay-reset.ts @@ -16,8 +16,10 @@ export async function forceStopRelayForTarget( ' for sock in "$base"/relay-*/"$sock_name" "$base"/"$sock_name"; do', ' [ -S "$sock" ] || continue', ' pid=""', + // Why: lsof ORs selectors by default; -a prevents reset from targeting + // every Unix-socket holder instead of only the per-relay socket (#8762). ' if command -v lsof >/dev/null 2>&1; then', - ' pid=$(lsof -t -U "$sock" 2>/dev/null | tr "\\n" " ")', + ' pid=$(lsof -t -a -U "$sock" 2>/dev/null | tr "\\n" " ")', ' fi', ' if [ -z "$pid" ] && command -v pgrep >/dev/null 2>&1; then', ' pid=$(pgrep -f "$sock_name" 2>/dev/null | ' + diff --git a/src/main/ssh/ssh-remote-cli-launcher.test.ts b/src/main/ssh/ssh-remote-cli-launcher.test.ts index ae0a2b18619..62aa7bec660 100644 --- a/src/main/ssh/ssh-remote-cli-launcher.test.ts +++ b/src/main/ssh/ssh-remote-cli-launcher.test.ts @@ -1,13 +1,18 @@ import { Buffer } from 'node:buffer' import { spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' import { getRemoteHostPlatform } from './ssh-remote-platform' -const itWindows = process.platform === 'win32' ? it : it.skip +// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows; +// keep the larger allowance scoped to the real compiler integration test. +function itWindows(name: string, test: () => void): void { + const runner = process.platform === 'win32' ? it : it.skip + runner(name, { timeout: 15_000 }, test) +} function decodePowerShellCommand(command: string): string { const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)?.[1] @@ -18,15 +23,18 @@ function decodePowerShellCommand(command: string): string { } describe('SSH remote Orca CLI launcher', () => { - it('compiles a native Windows launcher without a cmd.exe argument bridge', () => { - const hostPlatform = getRemoteHostPlatform('win32-x64') - const plan = createRemoteCliInstallPlan({ + function windowsInstallPlan(): ReturnType { + return createRemoteCliInstallPlan({ binDir: 'C:/Users/me user/.orca-relay/bin', relayDir: 'C:/Users/me user/.orca-remote/relay-v1', nodePath: 'C:/Program Files/nodejs/node.exe', sockPath: '\\\\.\\pipe\\orca-relay-123', - hostPlatform + hostPlatform: getRemoteHostPlatform('win32-x64') }) + } + + it('compiles a native Windows launcher without a cmd.exe argument bridge', () => { + const plan = windowsInstallPlan() expect(plan.launcherPath).toBe('C:/Users/me user/.orca-relay/bin/orca.exe') expect(plan.files).toHaveLength(1) @@ -49,7 +57,24 @@ describe('SSH remote Orca CLI launcher', () => { expect(compileScript).toContain('/out:orca.exe') expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca.cmd') - expect(compileScript.indexOf('orca.cmd')).toBeLessThan(compileScript.indexOf('csc.exe')) + }) + + it('removes the legacy orca.cmd only after every compile guard has passed', () => { + const script = decodePowerShellCommand(windowsInstallPlan().postWriteCommands[0] ?? '') + const legacyShimRemoval = + "Remove-Item -LiteralPath 'C:/Users/me user/.orca-relay/bin/orca.cmd' -Force -ErrorAction SilentlyContinue" + // Why: a host missing csc.exe or failing the compile must keep its existing + // CLI, so every fail-closed guard precedes the legacy %* shim removal. + const guards = [ + "if (-not $compiler) { Write-Error 'Unable to find the .NET Framework C# compiler required for the Orca SSH CLI launcher.'; exit 1 }", + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', + "if (-not (Test-Path -LiteralPath 'C:/Users/me user/.orca-relay/bin/orca.exe' -PathType Leaf))" + ] + expect(script).toContain(legacyShimRemoval) + for (const guard of guards) { + expect(script).toContain(guard) + expect(script.indexOf(guard)).toBeLessThan(script.indexOf(legacyShimRemoval)) + } }) itWindows('preserves a multiline argument through the compiled remote launcher', () => { @@ -123,6 +148,51 @@ describe('SSH remote Orca CLI launcher', () => { } }) + itWindows('preserves the existing orca.cmd when the compiler is missing', () => { + const root = mkdtempSync(join(tmpdir(), 'orca remote cli ')) + try { + const binDir = join(root, 'bin').replaceAll('\\', '/') + mkdirSync(binDir, { recursive: true }) + const legacyShimPath = join(binDir, 'orca.cmd') + writeFileSync(legacyShimPath, '@echo legacy orca cli\r\n', 'utf8') + + const plan = createRemoteCliInstallPlan({ + binDir, + relayDir: join(root, 'relay').replaceAll('\\', '/'), + nodePath: process.execPath, + sockPath: '\\\\.\\pipe\\orca-relay-test', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + for (const file of plan.files) { + mkdirSync(dirname(file.path), { recursive: true }) + writeFileSync(file.path, file.contents, 'utf8') + } + + const encoded = plan.postWriteCommands[0]?.match(/-EncodedCommand\s+(\S+)/)?.[1] + expect(encoded).toBeTruthy() + // Point WINDIR at a directory with no csc.exe so compiler discovery fails. + const compile = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand', + encoded! + ], + { encoding: 'utf8', env: { ...process.env, WINDIR: root, SystemRoot: root } } + ) + + expect(compile.status).not.toBe(0) + expect(existsSync(legacyShimPath), 'existing orca.cmd must survive a failed install').toBe( + true + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('keeps the POSIX launcher as an argv-preserving shell exec', () => { const plan = createRemoteCliInstallPlan({ binDir: '/home/me/.orca-relay/bin', diff --git a/src/main/ssh/ssh-remote-cli-launcher.ts b/src/main/ssh/ssh-remote-cli-launcher.ts index a246ce9c9b8..4122e0dc09d 100644 --- a/src/main/ssh/ssh-remote-cli-launcher.ts +++ b/src/main/ssh/ssh-remote-cli-launcher.ts @@ -169,9 +169,6 @@ function createWindowsLauncherCompileCommand( .join(' ') return powerShellCommand( [ - // Why: an upgrade must not leave the old %* batch bridge callable when - // compiler discovery fails; losing the convenience CLI is safer. - `Remove-Item -LiteralPath ${powerShellLiteral(legacyShimPath)} -Force -ErrorAction SilentlyContinue`, `Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(binDir)}`, '$windowsDirectory = if ($env:WINDIR) { $env:WINDIR } else { $env:SystemRoot }', `$compilerCandidates = @((Join-Path $windowsDirectory 'Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe'), (Join-Path $windowsDirectory 'Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'))`, @@ -180,6 +177,9 @@ function createWindowsLauncherCompileCommand( `& $compiler ${compilerArgs}`, 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', `if (-not (Test-Path -LiteralPath ${powerShellLiteral(launcherPath)} -PathType Leaf)) { Write-Error 'The Orca SSH CLI launcher compiler produced no executable.'; exit 1 }`, + // Why: remove the legacy %* bridge only after a successful compile, so a + // host missing csc.exe keeps its existing CLI (orca.exe shadows orca.cmd). + `Remove-Item -LiteralPath ${powerShellLiteral(legacyShimPath)} -Force -ErrorAction SilentlyContinue`, `Remove-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Force` ].join('; ') ) diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts index 84d9b9b48e7..109725e549c 100644 --- a/src/main/ssh/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -272,6 +272,41 @@ describe('resolveRemoteNodePath', () => { }) }) + it('uses -c (not -lc) for a csh login shell, which rejects combined -lc', async () => { + // Why: csh/tcsh reject `-lc` and mis-handle `-l` here ("Bad : modifier", + // issue #8701), so the login-shell probe must drop to plain `-c`. + execCommandMock + .mockResolvedValueOnce('\n') // path probe: empty + .mockResolvedValueOnce('/bin/csh\n') // $SHELL + .mockResolvedValueOnce('/usr/local/bin/node\n') + .mockResolvedValueOnce('v20.0.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') + expect(execCommandMock).toHaveBeenNthCalledWith(3, conn, `'/bin/csh' -c 'command -v node'`, { + wrapCommand: false, + timeoutMs: 8_000 + }) + }) + + it('uses -c (not -lc) for a tcsh login shell', async () => { + execCommandMock + .mockResolvedValueOnce('\n') // path probe: empty + .mockResolvedValueOnce('/usr/bin/tcsh\n') // $SHELL + .mockResolvedValueOnce('/usr/local/bin/node\n') + .mockResolvedValueOnce('v20.0.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node') + expect(execCommandMock).toHaveBeenNthCalledWith( + 3, + conn, + `'/usr/bin/tcsh' -c 'command -v node'`, + { + wrapCommand: false, + timeoutMs: 8_000 + } + ) + }) + // ── Failure ─────────────────────────────────────────────────────────── it('throws when both strategies find no usable node', async () => { diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index 52baa610d3f..01d6a0c7d48 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -9,6 +9,7 @@ import { } from './ssh-remote-node-install-guidance' import { execCommand } from './ssh-relay-deploy-helpers' import { isSshSessionLimitError } from './ssh-session-limit-error' +import { buildSshLoginShellCommand } from './ssh-login-shell-command' // Why: the relay requires Node.js 18+. Version managers like nvm keep every // installed version on disk, so a naive "highest version" glob can hand back @@ -150,7 +151,7 @@ async function tryResolveViaLoginShell( const nodePath = await execCommand( conn, - buildCommandInShell(shell, 'command -v node'), + buildSshLoginShellCommand(shell, 'command -v node'), commandOptions({ wrapCommand: false, timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options) ) const candidate = nodePath.trim().split('\n')[0] @@ -171,14 +172,6 @@ async function tryResolveViaLoginShell( return null } -function buildCommandInShell(shell: string, command: string): string { - const shellName = shell.split('/').at(-1) - // Why: dash and POSIX sh do not require `-l`; when $SHELL falls back to - // /bin/sh, prefer a portable command over login-shell semantics. - const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' - return `${shellEscape(shell)} ${mode} ${shellEscape(command)}` -} - // Returns true if `nodePath` runs and reports Node >= MIN_NODE_MAJOR. // Caches nothing — this runs at most a few times per resolution (one per // candidate), and the exec round-trip dominates. diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index 324a0ed6e48..c7f297be9a6 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -291,6 +291,53 @@ describe('spawnSystemSsh', () => { expect(args).toContain('deploy@127.0.0.1') }) + it('requests GSSAPI authentication explicitly for manual targets', () => { + const args = buildSshArgs( + createTarget({ source: 'manual', configHost: 'krb.example.com', gssapiAuthentication: true }) + ) + + expect(args).toContain('GSSAPIAuthentication=yes') + }) + + it('restricts Kerberos probes to non-interactive GSSAPI authentication', () => { + spawnSystemSshCommand( + createTarget({ + configHost: 'krb-host; touch /tmp/not-run', + source: 'ssh-config', + gssapiAuthentication: true + }), + 'echo ready', + { gssapiOnly: true, wrapCommand: false } + ) + + const args = spawnMock.mock.calls[0][1] as string[] + expect(args).toEqual( + expect.arrayContaining([ + '-o', + 'BatchMode=yes', + '-o', + 'GSSAPIAuthentication=yes', + '-o', + 'PreferredAuthentications=gssapi-with-mic' + ]) + ) + expect(args).not.toContain('BatchMode=no') + const standaloneControlIdx = args.indexOf('-S') + expect(standaloneControlIdx).toBeGreaterThan(-1) + expect(args[standaloneControlIdx + 1]).toBe('none') + expect(args.at(-2)).toBe('deploy@krb-host; touch /tmp/not-run') + expect(args.at(-1)).toBe('echo ready') + }) + + it('leaves GSSAPI to the Host block for ssh-config targets', () => { + const args = buildSshArgs( + createTarget({ configHost: 'krb-host', source: 'ssh-config', gssapiAuthentication: true }) + ) + + expect(args).not.toContain('GSSAPIAuthentication=yes') + expect(args).toContain('deploy@krb-host') + }) + it('does not inject Orca ControlMaster flags when ssh config already owns muxing', () => { const args = buildSshArgs(createTarget({ configHost: 'workbox', source: 'ssh-config' }), { resolvedConfig: createResolvedConfig({ @@ -394,9 +441,18 @@ describe('spawnSystemSsh', () => { it('spawns a remote command through the system ssh target', () => { spawnSystemSshCommand(createTarget({ configHost: 'fdpass-host' }), 'echo hello') + // Why: the remote command stays on one line so csh/tcsh login shells cannot + // split it before /bin/sh receives it. + const args = spawnMock.mock.calls[0][1] as string[] + expect(args).toContain('--') + expect(args).toContain('deploy@fdpass-host') + const wrapped = args.at(-1)! + expect(wrapped).not.toContain('\n') + expect(wrapped).toContain('printf %b "$@"') + expect(wrapped).not.toContain('base64') expect(spawnMock).toHaveBeenCalledWith( SYSTEM_SSH_PATH, - expect.arrayContaining(['--', 'deploy@fdpass-host', "exec /bin/sh -c 'echo hello'"]), + expect.any(Array), expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] }) ) }) diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index ffe5bf9c862..b949dc411af 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -32,7 +32,7 @@ function writeFakeSsh(dir: string): string { `#!/bin/sh while [ "$#" -gt 0 ]; do case "$1" in - -o|-p|-i|-J) shift 2 ;; + -o|-p|-i|-J|-S) shift 2 ;; -T) shift ;; --) shift; break ;; -*) shift ;; @@ -221,6 +221,25 @@ describe('system SSH transport integration', () => { 20_000 ) + it.skipIf(process.platform === 'win32')( + 'connects GSSAPI-flagged targets through system ssh without the force override', + async () => { + delete process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT + const conn = new SshConnection( + { ...makeTarget(), gssapiAuthentication: true }, + { onStateChange: vi.fn() } + ) + await conn.connect() + try { + expect(conn.usesSystemSshTransport()).toBe(true) + expect(conn.getState().status).toBe('connected') + } finally { + await conn.disconnect() + } + }, + 20_000 + ) + it.skipIf(process.platform === 'win32')( 'uploads a directory through the system ssh stdin/stdout path', async () => { diff --git a/src/main/ssh/ssh-target-id-migration.test.ts b/src/main/ssh/ssh-target-id-migration.test.ts index fa6507d6260..7628428654e 100644 --- a/src/main/ssh/ssh-target-id-migration.test.ts +++ b/src/main/ssh/ssh-target-id-migration.test.ts @@ -114,24 +114,34 @@ describe('migrateUiHostScopeSshTargetId', () => { const makeUi = (overrides: Partial): PersistedUIState => ({ ...overrides }) as PersistedUIState - it('re-points scope, visible hosts, and host order, deduping collisions', () => { + it('re-points scope, visible hosts, host order, and manual repo order', () => { const ui = makeUi({ workspaceHostScope: `ssh:${OLD_ID}`, visibleWorkspaceHostIds: ['local', `ssh:${OLD_ID}`, `ssh:${NEW_ID}`], - workspaceHostOrder: [`ssh:${OLD_ID}`, 'local'] + workspaceHostOrder: [`ssh:${OLD_ID}`, 'local'], + manualRepoOrder: [ + { hostId: `ssh:${OLD_ID}`, repoId: 'remote-repo' }, + { hostId: `ssh:${NEW_ID}`, repoId: 'remote-repo' }, + { hostId: 'local', repoId: 'local-repo' } + ] }) expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(true) expect(ui.workspaceHostScope).toBe(`ssh:${NEW_ID}`) expect(ui.visibleWorkspaceHostIds).toEqual(['local', `ssh:${NEW_ID}`]) expect(ui.workspaceHostOrder).toEqual([`ssh:${NEW_ID}`, 'local']) + expect(ui.manualRepoOrder).toEqual([ + { hostId: `ssh:${NEW_ID}`, repoId: 'remote-repo' }, + { hostId: 'local', repoId: 'local-repo' } + ]) }) it('returns false when the old host id appears nowhere', () => { const ui = makeUi({ workspaceHostScope: 'all', visibleWorkspaceHostIds: ['local'], - workspaceHostOrder: ['local'] + workspaceHostOrder: ['local'], + manualRepoOrder: [{ hostId: 'local', repoId: 'local-repo' }] }) expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(false) diff --git a/src/main/ssh/ssh-target-id-migration.ts b/src/main/ssh/ssh-target-id-migration.ts index a917c4393e9..f7fe153a2be 100644 --- a/src/main/ssh/ssh-target-id-migration.ts +++ b/src/main/ssh/ssh-target-id-migration.ts @@ -1,6 +1,7 @@ import type { PersistedUIState, WorkspaceSessionState } from '../../shared/types' import { parseAppSshPtyId, toAppSshPtyId } from '../../shared/ssh-pty-id' import { toSshExecutionHostId } from '../../shared/execution-host' +import { normalizeManualRepoOrder } from '../../shared/manual-repo-order' /** * Carrier sweep for SSH target re-adoption (see ssh-target-readoption.ts). @@ -112,5 +113,13 @@ export function migrateUiHostScopeSshTargetId( ] changed = true } + if (ui.manualRepoOrder?.some((entry) => entry.hostId === oldHostId)) { + ui.manualRepoOrder = normalizeManualRepoOrder( + ui.manualRepoOrder.map((entry) => + entry.hostId === oldHostId ? { ...entry, hostId: newHostId } : entry + ) + ) + changed = true + } return changed } diff --git a/src/main/ssh/system-ssh-args.ts b/src/main/ssh/system-ssh-args.ts index 8e812a0b68b..810b77b122f 100644 --- a/src/main/ssh/system-ssh-args.ts +++ b/src/main/ssh/system-ssh-args.ts @@ -5,21 +5,30 @@ export type SystemSshBuildArgsOptions = { resolvedConfig?: SystemSshResolvedConfig | null disableControlMaster?: boolean suppressOrcaControlMaster?: boolean + gssapiOnly?: boolean } export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOptions): string[] { const args: string[] = [] - args.push('-o', 'BatchMode=no') + args.push('-o', options?.gssapiOnly ? 'BatchMode=yes' : 'BatchMode=no') + if (options?.gssapiOnly) { + // Why: the probe must neither authenticate with a key nor open an OpenSSH + // credential prompt; failure belongs to Orca's existing ssh2 prompt path. + args.push('-o', 'GSSAPIAuthentication=yes') + args.push('-o', 'PreferredAuthentications=gssapi-with-mic') + } // Forward stdin/stdout for relay communication args.push('-T') // Why: ControlMaster multiplexes all SSH exec commands over a single connection, // eliminating the ~9s handshake overhead per command. Without this, each // spawnSystemSshCommand call opens a new TCP connection. - const forceDisableControlMaster = - options?.disableControlMaster === true || target.systemSshConnectionReuse === false const controlPath = getOrcaControlSocketPath(target, options) + const forceDisableControlMaster = + options?.disableControlMaster === true || + target.systemSshConnectionReuse === false || + (options?.gssapiOnly === true && controlPath === null) if (forceDisableControlMaster) { // Why: muxed OpenSSH forwards remain registered on the master after the // client exits. Also honors the per-target compatibility opt-out even if @@ -53,6 +62,12 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti args.push('-o', 'IdentitiesOnly=yes') } + if (!useConfigHost && target.gssapiAuthentication && !options?.gssapiOnly) { + // Why: manual targets bypass ssh_config, so Kerberos auth must be + // requested explicitly; config-backed hosts inherit it from their entry. + args.push('-o', 'GSSAPIAuthentication=yes') + } + if (!useConfigHost && target.jumpHost) { args.push('-J', target.jumpHost) } @@ -75,7 +90,7 @@ export function getOrcaControlSocketPath( if (shouldDisableOrcaControlMaster(target, options)) { return null } - return getControlSocketPath(target, options?.resolvedConfig) + return getControlSocketPath(target, options?.resolvedConfig, options?.gssapiOnly === true) } export function getSystemSshBuildArgsFromOperationOptions( @@ -91,6 +106,9 @@ export function getSystemSshBuildArgsFromOperationOptions( if (options?.suppressOrcaControlMaster === true) { buildArgsOptions.suppressOrcaControlMaster = true } + if (options?.gssapiOnly === true) { + buildArgsOptions.gssapiOnly = true + } return Object.keys(buildArgsOptions).length === 0 ? undefined : buildArgsOptions } @@ -107,7 +125,7 @@ function shouldDisableOrcaControlMaster( options?.suppressOrcaControlMaster === true || target.systemSshConnectionReuse === false || unresolvedConfigBackedTarget || - hasUserConfiguredControlMaster(options?.resolvedConfig) + (hasUserConfiguredControlMaster(options?.resolvedConfig) && options?.gssapiOnly !== true) ) } diff --git a/src/main/tray/system-tray.test.ts b/src/main/tray/system-tray.test.ts index 9f5ad72caad..71c94f84e74 100644 --- a/src/main/tray/system-tray.test.ts +++ b/src/main/tray/system-tray.test.ts @@ -2,19 +2,60 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as SystemTrayModule from './system-tray' const { - trayInstances, - menuFromTemplateMock, - createAppIconImageMock, + attentionImage, + baseMacImage, composeAttentionMock, - resizedImage + createAppIconImageMock, + createFromPathMock, + menuFromTemplateMock, + nativeThemeMock, + resizedImage, + retinaMacImage, + themeState, + tintedMacImage, + tintTemplateMock, + trayInstances } = vi.hoisted(() => { - const resizedImage = { resized: true } + const makeImage = (name: string) => ({ + name, + addRepresentation: vi.fn(), + getScaleFactors: vi.fn(() => [1, 2]), + getSize: vi.fn(() => ({ width: 16, height: 16 })), + setTemplateImage: vi.fn(), + toBitmap: vi.fn(() => Buffer.alloc(16 * 16 * 4)), + toDataURL: vi.fn(() => `data:image/png;base64,${name}`) + }) + const resizedImage = makeImage('windows') + const baseMacImage = makeImage('mac-base') + const retinaMacImage = makeImage('mac-retina') + const tintedMacImage = makeImage('mac-tinted') + const attentionImage = makeImage('attention') + const themeState = { updatedListener: null as (() => void) | null } + const nativeThemeMock = { + shouldUseDarkColors: false, + on: vi.fn((_event: string, listener: () => void) => { + themeState.updatedListener = listener + }), + removeListener: vi.fn((_event: string, listener: () => void) => { + if (themeState.updatedListener === listener) { + themeState.updatedListener = null + } + }) + } return { - trayInstances: [] as FakeTray[], - menuFromTemplateMock: vi.fn((template: unknown) => ({ template })), + attentionImage, + baseMacImage, + composeAttentionMock: vi.fn(() => attentionImage), createAppIconImageMock: vi.fn(), - composeAttentionMock: vi.fn((image: unknown) => ({ dotted: image })), - resizedImage + createFromPathMock: vi.fn(), + menuFromTemplateMock: vi.fn((template: unknown) => ({ template })), + nativeThemeMock, + resizedImage, + retinaMacImage, + themeState, + tintedMacImage, + tintTemplateMock: vi.fn(() => tintedMacImage), + trayInstances: [] as FakeTray[] } }) @@ -32,7 +73,17 @@ class FakeTray { vi.mock('electron', () => ({ Tray: FakeTray, - Menu: { buildFromTemplate: menuFromTemplateMock } + Menu: { buildFromTemplate: menuFromTemplateMock }, + nativeImage: { createFromPath: createFromPathMock }, + nativeTheme: nativeThemeMock +})) + +vi.mock('../../../resources/tray/orca-menu-barTemplate.png?asset&asarUnpack', () => ({ + default: '/assets/orca-menu-barTemplate.png' +})) + +vi.mock('../../../resources/tray/orca-menu-barTemplate@2x.png?asset&asarUnpack', () => ({ + default: '/assets/orca-menu-barTemplate@2x.png' })) vi.mock('../app-icon', () => ({ @@ -40,10 +91,12 @@ vi.mock('../app-icon', () => ({ })) vi.mock('./tray-attention-icon', () => ({ - composeTrayAttentionIcon: composeAttentionMock + composeTrayAttentionIcon: composeAttentionMock, + tintTrayTemplateForAttention: tintTemplateMock })) type TrayModule = typeof SystemTrayModule +type MenuItem = { label?: string; type?: string; click?: () => void } const originalPlatform = process.platform @@ -56,7 +109,15 @@ async function loadModule(): Promise { return import('./system-tray') } -type MenuItem = { label?: string; type?: string; click?: () => void } +function createOptions() { + return { + appIcon: 'classic', + onOpen: vi.fn(), + onOpenSettings: vi.fn(), + onCheckForUpdates: vi.fn(), + onQuit: vi.fn() + } +} function builtMenuItems(): MenuItem[] { return menuFromTemplateMock.mock.calls.at(-1)?.[0] as MenuItem[] @@ -66,139 +127,289 @@ beforeEach(() => { trayInstances.length = 0 menuFromTemplateMock.mockClear() composeAttentionMock.mockClear() + tintTemplateMock.mockClear() createAppIconImageMock.mockReset() createAppIconImageMock.mockReturnValue({ resize: vi.fn(() => resizedImage) }) + createFromPathMock.mockReset() + createFromPathMock.mockImplementation((path: string) => + path.includes('@2x') ? retinaMacImage : baseMacImage + ) + for (const image of [baseMacImage, retinaMacImage, tintedMacImage, attentionImage]) { + image.addRepresentation.mockClear() + image.getScaleFactors.mockReset().mockReturnValue([1, 2]) + image.getSize.mockReset().mockReturnValue({ width: 16, height: 16 }) + image.setTemplateImage.mockClear() + image.toBitmap.mockClear() + image.toDataURL.mockClear().mockReturnValue(`data:image/png;base64,${image.name}`) + } + nativeThemeMock.shouldUseDarkColors = false + nativeThemeMock.on.mockClear() + nativeThemeMock.removeListener.mockClear() + themeState.updatedListener = null }) afterEach(() => { setPlatform(originalPlatform) + vi.restoreAllMocks() }) describe('createSystemTray', () => { - it('creates a tray with an Orca tooltip and Open/Quit menu on win32', async () => { + it('keeps the Windows icon, Open/Quit menu, and left-click behavior', async () => { setPlatform('win32') const { createSystemTray } = await loadModule() - const onOpen = vi.fn() - const onQuit = vi.fn() + const options = createOptions() - const tray = createSystemTray({ appIcon: 'classic', onOpen, onQuit }) + createSystemTray(options) - expect(tray).not.toBeNull() expect(trayInstances).toHaveLength(1) expect(trayInstances[0].image).toBe(resizedImage) expect(trayInstances[0].setToolTip).toHaveBeenCalledWith('Orca') - const items = builtMenuItems() - expect(items.map((i) => i.label)).toEqual(['Open Orca', undefined, 'Quit']) - expect(items[1].type).toBe('separator') + expect(builtMenuItems().map((item) => item.label)).toEqual(['Open Orca', undefined, 'Quit']) + const clickHandler = trayInstances[0].on.mock.calls.find((call) => call[0] === 'click')?.[1] + expect(clickHandler).toBeTypeOf('function') + + builtMenuItems()[0].click?.() + ;(clickHandler as () => void)() + builtMenuItems()[2].click?.() + expect(options.onOpen).toHaveBeenCalledTimes(2) + expect(options.onQuit).toHaveBeenCalledOnce() }) - it('wires Open Orca, the tray click, and Quit to their callbacks', async () => { - setPlatform('win32') - const { createSystemTray } = await loadModule() - const onOpen = vi.fn() - const onQuit = vi.fn() - - createSystemTray({ appIcon: 'classic', onOpen, onQuit }) - - const items = builtMenuItems() - items.find((i) => i.label === 'Open Orca')?.click?.() - expect(onOpen).toHaveBeenCalledTimes(1) - - const clickHandler = trayInstances[0].on.mock.calls.find((c) => c[0] === 'click')?.[1] as - | (() => void) - | undefined - clickHandler?.() - expect(onOpen).toHaveBeenCalledTimes(2) - - items.find((i) => i.label === 'Quit')?.click?.() - expect(onQuit).toHaveBeenCalledTimes(1) - }) - - it('is idempotent: a second call does not create a duplicate tray', async () => { - setPlatform('win32') - const { createSystemTray } = await loadModule() - const opts = { appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() } - - const first = createSystemTray(opts) - const second = createSystemTray(opts) - - expect(trayInstances).toHaveLength(1) - expect(second).toBe(first) - }) - - it('is a no-op on non-win32 platforms', async () => { + it('creates a macOS template status item with the full native menu', async () => { setPlatform('darwin') const { createSystemTray } = await loadModule() + const options = createOptions() - const tray = createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + createSystemTray(options) - expect(tray).toBeNull() + expect(trayInstances).toHaveLength(1) + expect(trayInstances[0].image).toBe(baseMacImage) + expect(baseMacImage.setTemplateImage).toHaveBeenCalledWith(true) + expect(baseMacImage.addRepresentation).toHaveBeenCalledWith({ + scaleFactor: 2, + dataURL: 'data:image/png;base64,mac-retina' + }) + expect(builtMenuItems().map((item) => item.label)).toEqual([ + 'Open Orca', + undefined, + 'Settings', + 'Check for Updates...', + undefined, + 'Quit' + ]) + expect(trayInstances[0].on).not.toHaveBeenCalled() + expect(nativeThemeMock.on).toHaveBeenCalledWith('updated', expect.any(Function)) + + for (const [label, callback] of [ + ['Open Orca', options.onOpen], + ['Settings', options.onOpenSettings], + ['Check for Updates...', options.onCheckForUpdates], + ['Quit', options.onQuit] + ] as const) { + builtMenuItems() + .find((item) => item.label === label) + ?.click?.() + expect(callback).toHaveBeenCalledOnce() + } + }) + + it('does not create a blank macOS item when the template asset fails to load', async () => { + setPlatform('darwin') + baseMacImage.getSize.mockReturnValue({ width: 0, height: 0 }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { createSystemTray } = await loadModule() + + expect(createSystemTray(createOptions())).toBeNull() expect(trayInstances).toHaveLength(0) + expect(warn).toHaveBeenCalledWith('[system-tray] macOS menu bar icon could not be loaded') + }) + + it('is idempotent and remains disabled on Linux', async () => { + setPlatform('darwin') + const { createSystemTray } = await loadModule() + const options = createOptions() + expect(createSystemTray(options)).toBe(createSystemTray(options)) + expect(trayInstances).toHaveLength(1) + + setPlatform('linux') + const linuxModule = await loadModule() + expect(linuxModule.createSystemTray(options)).toBeNull() + }) +}) + +describe('macOS visibility', () => { + it('creates and destroys the item as the setting toggles', async () => { + setPlatform('darwin') + const { setMacMenuBarIconVisible } = await loadModule() + const options = createOptions() + + expect(setMacMenuBarIconVisible(false, options)).toBeNull() + expect(trayInstances).toHaveLength(0) + expect(setMacMenuBarIconVisible(true, options)).not.toBeNull() + expect(trayInstances).toHaveLength(1) + + setMacMenuBarIconVisible(false, options) + setMacMenuBarIconVisible(false, options) + expect(trayInstances[0].destroy).toHaveBeenCalledOnce() }) }) describe('setTrayAttention', () => { - it('swaps in the dotted icon when active and restores the base when cleared', async () => { + it('preserves the Windows attention swap behavior', async () => { setPlatform('win32') const { createSystemTray, setTrayAttention } = await loadModule() - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] - tray.setImage.mockClear() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() setTrayAttention(true) expect(composeAttentionMock).toHaveBeenCalledWith(resizedImage) - expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage }) - - tray.setImage.mockClear() + expect(created.setImage).toHaveBeenCalledWith(attentionImage) setTrayAttention(false) - expect(tray.setImage).toHaveBeenCalledWith(resizedImage) + expect(created.setImage).toHaveBeenLastCalledWith(resizedImage) }) - it('ignores repeated same-state calls', async () => { - setPlatform('win32') - const { createSystemTray, setTrayAttention } = await loadModule() - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] - tray.setImage.mockClear() - - setTrayAttention(true) - setTrayAttention(true) - - expect(tray.setImage).toHaveBeenCalledTimes(1) - }) - - it('reflects attention that was requested before the tray was created', async () => { - setPlatform('win32') - const { createSystemTray, setTrayAttention } = await loadModule() - - // Fire the event before the (deferred) tray exists. - setTrayAttention(true) - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] - - expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage }) - }) - - it('is a safe no-op on non-win32 platforms', async () => { + it('uses a literal theme-aware glyph for macOS attention, then restores template mode', async () => { setPlatform('darwin') - const { setTrayAttention } = await loadModule() + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() + created.setToolTip.mockClear() - expect(() => setTrayAttention(true)).not.toThrow() - expect(composeAttentionMock).not.toHaveBeenCalled() + setTrayAttention(true) + expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, false) + expect(composeAttentionMock).toHaveBeenCalledWith(tintedMacImage) + // Why: the attention image is rebuilt from 1x pixels, so the @2x + // representation must be re-added or the glyph blurs on Retina. + expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, false, 2) + expect(attentionImage.addRepresentation).toHaveBeenCalledWith({ + scaleFactor: 2, + dataURL: 'data:image/png;base64,attention' + }) + expect(attentionImage.setTemplateImage).toHaveBeenCalledWith(false) + expect(created.setImage).toHaveBeenCalledWith(attentionImage) + expect(created.setToolTip).toHaveBeenCalledWith('Orca - activity waiting') + + setTrayAttention(false) + expect(baseMacImage.setTemplateImage).toHaveBeenLastCalledWith(true) + expect(created.setImage).toHaveBeenLastCalledWith(baseMacImage) + expect(created.setToolTip).toHaveBeenLastCalledWith('Orca') + }) + + it('recomposes active macOS attention when the system appearance changes', async () => { + setPlatform('darwin') + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + setTrayAttention(true) + tintTemplateMock.mockClear() + nativeThemeMock.shouldUseDarkColors = true + + themeState.updatedListener?.() + + expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, true) + }) + + it('reflects attention requested before deferred macOS creation', async () => { + setPlatform('darwin') + const { createSystemTray, setTrayAttention } = await loadModule() + setTrayAttention(true) + createSystemTray(createOptions()) + + expect(trayInstances[0].setImage).toHaveBeenCalledWith(attentionImage) + }) + + it('ignores repeated same-state attention calls', async () => { + setPlatform('win32') + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() + + setTrayAttention(true) + setTrayAttention(true) + + expect(created.setImage).toHaveBeenCalledTimes(1) + }) + + it('keeps a pending attention dot across a macOS hide/show toggle', async () => { + setPlatform('darwin') + const { setMacMenuBarIconVisible, setTrayAttention } = await loadModule() + const options = createOptions() + setMacMenuBarIconVisible(true, options) + setTrayAttention(true) + + setMacMenuBarIconVisible(false, options) + setMacMenuBarIconVisible(true, options) + + expect(trayInstances).toHaveLength(2) + expect(trayInstances[1].setImage).toHaveBeenCalledWith(attentionImage) }) }) describe('destroySystemTray', () => { - it('destroys an existing tray and is safe to call without one', async () => { - setPlatform('win32') + it('destroys idempotently and removes the macOS appearance listener', async () => { + setPlatform('darwin') const { createSystemTray, destroySystemTray } = await loadModule() - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + createSystemTray(createOptions()) const created = trayInstances[0] + const listener = themeState.updatedListener destroySystemTray() - expect(created.destroy).toHaveBeenCalledTimes(1) + destroySystemTray() - // Second call with no live tray must not throw. - expect(() => destroySystemTray()).not.toThrow() + expect(created.destroy).toHaveBeenCalledOnce() + expect(nativeThemeMock.removeListener).toHaveBeenCalledWith('updated', listener) + }) +}) + +describe('macOS hardening', () => { + it('warns but still creates the item when only the @2x asset fails to load', async () => { + setPlatform('darwin') + retinaMacImage.getSize.mockReturnValue({ width: 0, height: 0 }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { createSystemTray } = await loadModule() + + expect(createSystemTray(createOptions())).not.toBeNull() + expect(trayInstances).toHaveLength(1) + expect(baseMacImage.addRepresentation).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[system-tray] macOS retina menu bar icon could not be loaded' + ) + }) + + it('degrades to the plain icon instead of throwing when attention composition fails', async () => { + setPlatform('darwin') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() + composeAttentionMock.mockImplementationOnce(() => { + throw new Error('native image failure') + }) + + expect(() => setTrayAttention(true)).not.toThrow() + expect(created.setImage).toHaveBeenLastCalledWith(baseMacImage) + expect(created.setToolTip).toHaveBeenLastCalledWith('Orca') + expect(warn).toHaveBeenCalledWith( + '[system-tray] macOS attention icon failed; showing plain icon', + expect.any(Error) + ) + }) + + it('contains a throwing menu-item callback instead of crashing', async () => { + setPlatform('darwin') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { createSystemTray } = await loadModule() + const options = createOptions() + options.onOpenSettings.mockImplementation(() => { + throw new Error('boom') + }) + createSystemTray(options) + + const settingsItem = builtMenuItems().find((item) => item.label === 'Settings') + expect(() => settingsItem?.click?.()).not.toThrow() + expect(error).toHaveBeenCalledWith('[system-tray] menu action failed', expect.any(Error)) }) }) diff --git a/src/main/tray/system-tray.ts b/src/main/tray/system-tray.ts index ada6f58c4e9..27d2487a3c5 100644 --- a/src/main/tray/system-tray.ts +++ b/src/main/tray/system-tray.ts @@ -1,13 +1,19 @@ -import { Menu, Tray, type NativeImage } from 'electron' +import { Menu, Tray, nativeImage, nativeTheme, type NativeImage } from 'electron' +import menuBarIconPath from '../../../resources/tray/orca-menu-barTemplate.png?asset&asarUnpack' +import menuBarIconRetinaPath from '../../../resources/tray/orca-menu-barTemplate@2x.png?asset&asarUnpack' import { createAppIconImage } from '../app-icon' import { translateMain } from '../i18n/main-i18n' -import { composeTrayAttentionIcon } from './tray-attention-icon' +import { composeTrayAttentionIcon, tintTrayTemplateForAttention } from './tray-attention-icon' -type SystemTrayOptions = { +export type SystemTrayOptions = { /** App icon id from settings; the tray reuses the app icon image. */ appIcon: unknown /** Restore + show + focus the main window (recreating it if needed). */ onOpen: () => void + /** Restore the main window and open its Settings surface. */ + onOpenSettings: () => void + /** Run the existing user-initiated update check. */ + onCheckForUpdates: () => void /** Quit Orca for real (caller must set the quitting latch before quitting). */ onQuit: () => void } @@ -25,6 +31,8 @@ let baseTrayImage: NativeImage | null = null // freshly created tray reflects it immediately. let attentionActive = false +let nativeThemeUpdatedListener: (() => void) | null = null + // Why: on Windows the notification area expects a 16px icon; the app icon PNG // is larger, so downscale to avoid a cropped/blurry tray glyph. const TRAY_ICON_SIZE = 16 @@ -35,46 +43,195 @@ function applyTrayImage(): void { if (!tray || tray.isDestroyed() || !baseTrayImage) { return } + + if (process.platform === 'darwin') { + if (attentionActive) { + try { + // Why: disabling template tinting makes the amber dot possible, but the + // glyph then needs literal pixels chosen for the current menu-bar theme. + const useLightGlyph = nativeTheme.shouldUseDarkColors + const attentionImage = composeTrayAttentionIcon( + tintTrayTemplateForAttention(baseTrayImage, useLightGlyph) + ) + if (baseTrayImage.getScaleFactors().includes(2)) { + // Why: toBitmap reads only the 1x pixels, so rebuild the @2x + // representation or the glyph blurs on Retina during attention. + const retinaAttentionImage = composeTrayAttentionIcon( + tintTrayTemplateForAttention(baseTrayImage, useLightGlyph, 2) + ) + attentionImage.addRepresentation({ + scaleFactor: 2, + dataURL: retinaAttentionImage.toDataURL() + }) + } + attentionImage.setTemplateImage(false) + tray.setImage(attentionImage) + tray.setToolTip(translateMain('tray.activityWaiting', 'Orca - activity waiting')) + return + } catch (error) { + // Why: this path runs inside unguarded callbacks (nativeTheme 'updated', + // the notification bell), so a NativeImage failure must degrade to the + // plain template icon rather than throw an uncaught exception into them. + console.warn('[system-tray] macOS attention icon failed; showing plain icon', error) + } + } + + baseTrayImage.setTemplateImage(true) + tray.setImage(baseTrayImage) + tray.setToolTip('Orca') + return + } + tray.setImage(attentionActive ? composeTrayAttentionIcon(baseTrayImage) : baseTrayImage) } +function createMacMenuBarImage(): NativeImage | null { + const image = nativeImage.createFromPath(menuBarIconPath) + const { width, height } = image.getSize() + if (width <= 0 || height <= 0) { + console.warn('[system-tray] macOS menu bar icon could not be loaded') + return null + } + + const retinaImage = nativeImage.createFromPath(menuBarIconRetinaPath) + const retinaSize = retinaImage.getSize() + if (retinaSize.width > 0 && retinaSize.height > 0) { + try { + // Why: importing the @2x asset guarantees it is packaged; adding the + // representation explicitly avoids relying on bundler-renamed siblings. + image.addRepresentation({ + scaleFactor: 2, + dataURL: retinaImage.toDataURL() + }) + } catch (error) { + // Why: a bad @2x representation must not abort tray creation; the 1x image + // still renders (just blurrier on Retina). + console.warn('[system-tray] macOS retina menu bar icon could not be added', error) + } + } else { + // Why: surface the silently-degraded (non-Retina) icon so a blurry menu-bar + // glyph on a modern display is diagnosable, matching the base-image warning. + console.warn('[system-tray] macOS retina menu bar icon could not be loaded') + } + image.setTemplateImage(true) + return image +} + +function watchMacAppearance(): void { + if (nativeThemeUpdatedListener) { + return + } + nativeThemeUpdatedListener = () => { + if (attentionActive) { + applyTrayImage() + } + } + nativeTheme.on('updated', nativeThemeUpdatedListener) +} + +function stopWatchingMacAppearance(): void { + if (!nativeThemeUpdatedListener) { + return + } + nativeTheme.removeListener('updated', nativeThemeUpdatedListener) + nativeThemeUpdatedListener = null +} + +// Why: Electron Menu/Tray click callbacks are plain event listeners (not +// promise-wrapped like ipcMain.handle), so an uncaught throw here is fatal to +// the main process; contain it to a logged, failed menu action instead. +function safeMenuAction(action: () => void): () => void { + return () => { + try { + action() + } catch (error) { + console.error('[system-tray] menu action failed', error) + } + } +} + /** - * Creates the Windows system tray icon. No-op on macOS/Linux. Idempotent: a - * second call while a tray is alive returns the existing one instead of - * stacking a duplicate ghost icon. + * Creates the Windows notification icon or macOS menu bar status item. No-op + * on Linux. Idempotent: repeated calls never stack duplicate icons. */ export function createSystemTray(opts: SystemTrayOptions): Tray | null { - if (process.platform !== 'win32') { + if (process.platform !== 'win32' && process.platform !== 'darwin') { return null } if (tray && !tray.isDestroyed()) { return tray } - baseTrayImage = createAppIconImage(opts.appIcon).resize({ - width: TRAY_ICON_SIZE, - height: TRAY_ICON_SIZE - }) + + if (process.platform === 'darwin') { + baseTrayImage = createMacMenuBarImage() + if (!baseTrayImage) { + return null + } + } else { + baseTrayImage = createAppIconImage(opts.appIcon).resize({ + width: TRAY_ICON_SIZE, + height: TRAY_ICON_SIZE + }) + } + tray = new Tray(baseTrayImage) // Why: reflect any attention event that fired before the tray existed. applyTrayImage() - tray.setToolTip('Orca') + const menu = Menu.buildFromTemplate([ - { label: translateMain('tray.openOrca', 'Open Orca'), click: () => opts.onOpen() }, + { + label: translateMain('tray.openOrca', 'Open Orca'), + click: safeMenuAction(() => opts.onOpen()) + }, { type: 'separator' }, - { label: translateMain('tray.quit', 'Quit'), click: () => opts.onQuit() } + // Why: reuse the app menu's keys so the two entry points never drift. + ...(process.platform === 'darwin' + ? ([ + { + label: translateMain('menu.settings', 'Settings'), + click: safeMenuAction(() => opts.onOpenSettings()) + }, + { + label: translateMain('menu.checkForUpdates', 'Check for Updates...'), + click: safeMenuAction(() => opts.onCheckForUpdates()) + }, + { type: 'separator' } + ] as Electron.MenuItemConstructorOptions[]) + : []), + { label: translateMain('tray.quit', 'Quit'), click: safeMenuAction(() => opts.onQuit()) } ]) tray.setContextMenu(menu) - // Why: a left-click on the tray icon is the conventional Windows gesture to - // restore a minimized-to-tray app. - tray.on('click', () => opts.onOpen()) + if (process.platform === 'win32') { + tray.setToolTip('Orca') + // Why: a left-click on the tray icon is the conventional Windows gesture to + // restore a minimized-to-tray app; macOS opens the attached menu instead. + tray.on( + 'click', + safeMenuAction(() => opts.onOpen()) + ) + } else { + watchMacAppearance() + } return tray } +/** Applies the persisted macOS visibility preference without affecting Windows. */ +export function setMacMenuBarIconVisible(visible: boolean, opts: SystemTrayOptions): Tray | null { + if (process.platform !== 'darwin') { + return null + } + if (!visible) { + destroySystemTray() + return null + } + return createSystemTray(opts) +} + /** * Shows or hides a red/amber attention dot on the tray icon. Call with `true` * when a terminal bell or agent completion fires while the window is - * minimized/hidden, and `false` once the window is shown again. No-op on - * macOS/Linux (no tray) and safe to call before the tray is created. + * minimized/hidden, and `false` once the window is shown again. Safe to call + * before the tray is created or on Linux where no tray is available. */ export function setTrayAttention(active: boolean): void { if (attentionActive === active) { @@ -86,10 +243,12 @@ export function setTrayAttention(active: boolean): void { /** Destroys the tray icon if present. Safe to call repeatedly or with no tray. */ export function destroySystemTray(): void { + stopWatchingMacAppearance() if (tray && !tray.isDestroyed()) { tray.destroy() } tray = null baseTrayImage = null - attentionActive = false + // Why: attention is owned by the notification/visibility flow, and must + // survive the macOS hide/show toggle so a re-shown icon keeps its dot. } diff --git a/src/main/tray/tray-attention-icon.test.ts b/src/main/tray/tray-attention-icon.test.ts index 42d554417e0..090847e284e 100644 --- a/src/main/tray/tray-attention-icon.test.ts +++ b/src/main/tray/tray-attention-icon.test.ts @@ -12,7 +12,7 @@ vi.mock('electron', () => ({ nativeImage: { createFromBitmap: createFromBitmapMock } })) -import { composeTrayAttentionIcon } from './tray-attention-icon' +import { composeTrayAttentionIcon, tintTrayTemplateForAttention } from './tray-attention-icon' type FakeImage = { getSize: () => { width: number; height: number } @@ -86,3 +86,59 @@ describe('composeTrayAttentionIcon', () => { expect(paintedInBottomLeft).toBe(0) // the opposite corner is never touched }) }) + +describe('tintTrayTemplateForAttention', () => { + it('preserves alpha while selecting literal black pixels for a light menu bar', () => { + createFromBitmapMock.mockClear() + const bitmap = Buffer.from([0x22, 0x33, 0x44, 0x80, 0xaa, 0xbb, 0xcc, 0x00]) + const base = { + getSize: () => ({ width: 2, height: 1 }), + toBitmap: () => bitmap + } + + tintTrayTemplateForAttention(base as never, false) + + expect(createFromBitmapMock.mock.calls[0][0]).toEqual( + Buffer.from([0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00]) + ) + }) + + it('selects literal white pixels for a dark menu bar', () => { + createFromBitmapMock.mockClear() + const base = { + getSize: () => ({ width: 1, height: 1 }), + toBitmap: () => Buffer.from([0x00, 0x00, 0x00, 0xff]) + } + + tintTrayTemplateForAttention(base as never, true) + + expect(createFromBitmapMock.mock.calls[0][0]).toEqual(Buffer.from([0xff, 0xff, 0xff, 0xff])) + }) + + it('keeps white edge pixels premultiplied-valid at partial alpha', () => { + createFromBitmapMock.mockClear() + const base = { + getSize: () => ({ width: 1, height: 1 }), + toBitmap: () => Buffer.from([0x00, 0x00, 0x00, 0x80]) + } + + tintTrayTemplateForAttention(base as never, true) + + // Why: the bitmap is premultiplied — white at 50% coverage is 0x80, not 0xff. + expect(createFromBitmapMock.mock.calls[0][0]).toEqual(Buffer.from([0x80, 0x80, 0x80, 0x80])) + }) + + it('reads and sizes the requested scale factor', () => { + createFromBitmapMock.mockClear() + const toBitmap = vi.fn(() => Buffer.from([0x00, 0x00, 0x00, 0xff])) + const base = { + getSize: () => ({ width: 1, height: 1 }), + toBitmap + } + + tintTrayTemplateForAttention(base as never, true, 2) + + expect(toBitmap).toHaveBeenCalledWith({ scaleFactor: 2 }) + expect(createFromBitmapMock.mock.calls[0][1]).toEqual({ width: 2, height: 2 }) + }) +}) diff --git a/src/main/tray/tray-attention-icon.ts b/src/main/tray/tray-attention-icon.ts index d9b042db37f..ffdf4528d47 100644 --- a/src/main/tray/tray-attention-icon.ts +++ b/src/main/tray/tray-attention-icon.ts @@ -7,6 +7,36 @@ const DOT_RGB = { r: 0xf5, g: 0x9e, b: 0x0b } // A near-white halo separates the dot from the icon glyph on any tray theme. const RING_RGB = { r: 0xff, g: 0xff, b: 0xff } +/** + * Converts a template mask into a literal glyph for the attention state. A + * non-template image needs real RGB values because macOS no longer tints it. + */ +export function tintTrayTemplateForAttention( + base: NativeImage, + useLightGlyph: boolean, + scaleFactor = 1 +): NativeImage { + const { width, height } = base.getSize() + if (width <= 0 || height <= 0) { + return base + } + + const bitmap = Buffer.from(base.toBitmap({ scaleFactor })) + for (let offset = 0; offset < bitmap.length; offset += 4) { + // Why: the bitmap is premultiplied, so a light glyph must write the pixel's + // alpha (not 0xff) or antialiased edges become invalid over-bright pixels. + const channel = useLightGlyph ? bitmap[offset + 3] : 0x00 + bitmap[offset] = channel + bitmap[offset + 1] = channel + bitmap[offset + 2] = channel + } + + return nativeImage.createFromBitmap(bitmap, { + width: width * scaleFactor, + height: height * scaleFactor + }) +} + /** * Returns a copy of `base` with a small amber attention dot composited into the * top-right corner. Electron's NativeImage has no compositing API, so we merge diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 0dd54c2c598..21325c3e036 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -38,6 +38,7 @@ import type { import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../shared/native-file-drop' import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' +import { requestTerminalTabCloseFromRenderer } from './terminal-tab-close-request-relay' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' @@ -389,6 +390,7 @@ function registerRuntimeWindowLifecycle( content }) as Promise, closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }), + closeTerminalTab: (tabId) => requestTerminalTabCloseFromRenderer(mainWindow, tabId), sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), resumeSleepingAgents: (worktreeId) => send('ui:resumeSleepingAgents', { worktreeId }), terminalFitOverrideChanged: (ptyId, mode, cols, rows) => diff --git a/src/main/window/terminal-tab-close-request-relay.test.ts b/src/main/window/terminal-tab-close-request-relay.test.ts new file mode 100644 index 00000000000..49eee1ce299 --- /dev/null +++ b/src/main/window/terminal-tab-close-request-relay.test.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ipcEmitter = new EventEmitter() +const ipcMainMock = { + on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { + ipcEmitter.on(channel, listener) + }), + removeListener: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { + ipcEmitter.removeListener(channel, listener) + }) +} + +vi.mock('electron', () => ({ ipcMain: ipcMainMock })) + +describe('requestTerminalTabCloseFromRenderer', () => { + beforeEach(() => { + ipcEmitter.removeAllListeners() + ipcMainMock.on.mockClear() + ipcMainMock.removeListener.mockClear() + }) + + it('waits for the targeted renderer durability acknowledgement', async () => { + const { requestTerminalTabCloseFromRenderer } = + await import('./terminal-tab-close-request-relay') + const webContents = { isDestroyed: () => false, send: vi.fn() } + const otherWebContents = {} + const mainWindow = { isDestroyed: () => false, webContents } + const pending = requestTerminalTabCloseFromRenderer(mainWindow as never, 'tab-1') + const request = webContents.send.mock.calls[0]?.[1] as { requestId: string; tabId: string } + + expect(request.tabId).toBe('tab-1') + ipcEmitter.emit( + 'ui:terminalTabCloseResponse', + { sender: otherWebContents }, + { requestId: request.requestId } + ) + let settled = false + void pending.finally(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + ipcEmitter.emit( + 'ui:terminalTabCloseResponse', + { sender: webContents }, + { requestId: request.requestId } + ) + await expect(pending).resolves.toBeUndefined() + }) + + it('propagates renderer cancellation instead of reporting success', async () => { + const { requestTerminalTabCloseFromRenderer } = + await import('./terminal-tab-close-request-relay') + const webContents = { isDestroyed: () => false, send: vi.fn() } + const pending = requestTerminalTabCloseFromRenderer( + { isDestroyed: () => false, webContents } as never, + 'tab-pinned' + ) + const request = webContents.send.mock.calls[0]?.[1] as { requestId: string } + + ipcEmitter.emit( + 'ui:terminalTabCloseResponse', + { sender: webContents }, + { requestId: request.requestId, error: 'terminal_tab_pinned' } + ) + + await expect(pending).rejects.toThrow('terminal_tab_pinned') + }) +}) diff --git a/src/main/window/terminal-tab-close-request-relay.ts b/src/main/window/terminal-tab-close-request-relay.ts new file mode 100644 index 00000000000..c788f79a95b --- /dev/null +++ b/src/main/window/terminal-tab-close-request-relay.ts @@ -0,0 +1,43 @@ +import { randomUUID } from 'node:crypto' + +import { ipcMain } from 'electron' +import type { BrowserWindow } from 'electron' +import type { + TerminalTabCloseRequest, + TerminalTabCloseResponse +} from '../../shared/terminal-tab-close' + +const TERMINAL_TAB_CLOSE_TIMEOUT_MS = 20_000 + +export async function requestTerminalTabCloseFromRenderer( + mainWindow: BrowserWindow, + tabId: string +): Promise { + if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) { + throw new Error('renderer_unavailable') + } + const requestId = randomUUID() + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ipcMain.removeListener('ui:terminalTabCloseResponse', onResponse) + reject(new Error('terminal_tab_close_timeout')) + }, TERMINAL_TAB_CLOSE_TIMEOUT_MS) + const onResponse = (event: Electron.IpcMainEvent, response: TerminalTabCloseResponse): void => { + // Why: request IDs are visible to renderer code; only the selected main + // window may commit or reject its lifecycle transaction. + if (event.sender !== mainWindow.webContents || response.requestId !== requestId) { + return + } + clearTimeout(timeout) + ipcMain.removeListener('ui:terminalTabCloseResponse', onResponse) + if (response.error) { + reject(new Error(response.error)) + } else { + resolve() + } + } + ipcMain.on('ui:terminalTabCloseResponse', onResponse) + const request: TerminalTabCloseRequest = { requestId, tabId } + mainWindow.webContents.send('ui:terminalTabCloseRequest', request) + }) +} diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index bfee5295635..86af032aefb 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -123,6 +123,50 @@ describe('analyzeWorkspaceSpace', () => { expect(result.reclaimableBytes).toBe(feature?.sizeBytes) }) + it('omits prunable worktree registrations from the Space scan', async () => { + // Why: a prunable registration has no directory to size or reclaim (issue + // #8389); before this filter it rendered as a dead "Missing" row whose + // checkbox stayed disabled with no prune/remove action. + const root = tempDir! + const mainPath = join(root, 'repo') + const stalePath = join(root, 'stale') + await mkdir(join(mainPath, 'src'), { recursive: true }) + await writeSizedFile(join(mainPath, 'src', 'main.ts'), 256) + + const repo: Repo = { + id: 'repo-1', + path: mainPath, + displayName: 'orca', + badgeColor: '#000', + addedAt: 0 + } + listRepoWorktreesMock.mockResolvedValue([ + { + path: mainPath, + head: 'a', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: stalePath, + head: 'b', + branch: 'refs/heads/stale', + isBare: false, + prunable: true, + prunableReason: 'gitdir file points to non-existent location', + isMainWorktree: false + } + ]) + + const result = await analyzeWorkspaceSpace(createStore([repo])) + + expect(result.worktreeCount).toBe(1) + expect(result.worktrees.find((row) => row.path === stalePath)).toBeUndefined() + expect(result.worktrees.find((row) => row.path === mainPath)?.status).toBe('ok') + expect(result.repos[0]?.unavailableWorktreeCount).toBe(0) + }) + it('reports scan progress as repos and worktrees are scanned', async () => { const root = tempDir! const repoPath = join(root, 'repo') diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index f035a426ae3..cc792202fb8 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -850,9 +850,12 @@ async function scanRepo( } } - const worktrees = listed.worktrees.map((gitWorktree) => - mergeForSpaceScan(repo, gitWorktree, store) - ) + // Why: a prunable registration has no directory to size or reclaim (issue + // #8389); it would only render a dead "Missing" row with no available + // action. Removal flows list worktrees separately and still see it. + const worktrees = listed.worktrees + .filter((gitWorktree) => !gitWorktree.prunable) + .map((gitWorktree) => mergeForSpaceScan(repo, gitWorktree, store)) reportProgress( progress, { totalWorktreeCount: progress.totalWorktreeCount + worktrees.length }, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 62ad82b8748..a3db4bbbc55 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,10 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { + TerminalTabCloseRequest, + TerminalTabCloseResponse +} from '../shared/terminal-tab-close' import type { LocalLogTailChangedPayload, LocalLogTailReadArgs, @@ -317,6 +321,7 @@ import type { ResolvedSourceControlAiGenerationParams } from '../shared/source-c import type { SourceControlAiSettings } from '../shared/source-control-ai-types' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' +import type { SkillFreshnessInventory } from '../shared/skill-freshness' import type { CrashReportBreadcrumbData, CrashReportCopyDiagnosticsArgs, @@ -828,10 +833,15 @@ export type NativeChatReadSessionResult = /** Messages appended to a live-tailed transcript since the previous emit. */ export type NativeChatAppendedMessages = NativeChatMessage[] +export type NativeChatSubscriptionFrame = + | { type: 'snapshot'; messages: NativeChatMessage[]; hasMore: boolean; error?: string } + | { type: 'replacement'; messages: NativeChatMessage[]; hasMore: boolean } + | { type: 'appended'; messages: NativeChatMessage[] } + /** Wire payload for the `nativeChat:appended` push channel. */ export type NativeChatAppendedPayload = { subscriptionId: string - messages: NativeChatAppendedMessages + frame: NativeChatSubscriptionFrame } export type NativeChatSubscribeArgs = { @@ -842,6 +852,8 @@ export type NativeChatSubscribeArgs = { sessionId: string /** Authoritative transcript path from the agent hook (providerSession). */ transcriptPath?: string + /** First snapshot size; later readSession calls grow this for pagination. */ + limit?: number } export type NativeChatApi = { @@ -855,11 +867,11 @@ export type NativeChatApi = { limit?: number, transcriptPath?: string ) => Promise - /** Live-tail a transcript: `onAppended` fires with only newly-appended - * messages. Returns an unsubscribe fn that closes the main-process watcher. */ + /** Live-tail a transcript. The first frame is a bounded race-safe snapshot; + * later frames contain only newly appended messages. */ subscribe: ( args: NativeChatSubscribeArgs, - onAppended: (messages: NativeChatAppendedMessages) => void + onFrame: (frame: NativeChatSubscriptionFrame) => void ) => () => void } @@ -964,6 +976,10 @@ export type PreloadApi = { // other hosts (local or a re-added SSH target) intact. removeForHost: (args: { repoId: string; hostId: string }) => Promise reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }> + reorderForHost: (args: { + orderedIds: string[] + hostId: string + }) => Promise<{ status: 'applied' | 'rejected' }> update: (args: { repoId: string updates: Partial< @@ -1407,11 +1423,18 @@ export type PreloadApi = { onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void sendSerializedBuffer: ( requestId: string, - snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null + snapshot: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } | null ) => void declarePendingPaneSerializer: (paneKey: string) => Promise settlePaneSerializer: (paneKey: string, gen: number) => Promise clearPendingPaneSerializer: (paneKey: string, gen: number) => Promise + reportRendererSerializerReady?: (ptyId: string) => Promise management: PtyManagementApi } feedback: { @@ -1529,7 +1552,7 @@ export type PreloadApi = { repoId?: string limit?: number query?: string - before?: string + page?: number noCache?: boolean }) => Promise>> prChecks: ( @@ -1992,6 +2015,7 @@ export type PreloadApi = { siteUrl: string email: string apiToken: string + authType?: 'cloud' | 'server' }) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> disconnect: (args?: { siteId?: string }) => Promise selectSite: (args: { siteId: JiraSiteSelection }) => Promise @@ -2232,6 +2256,7 @@ export type PreloadApi = { } skills: { discover: (target?: SkillDiscoveryTarget) => Promise + freshnessInventory: () => Promise } pet: { import: () => Promise @@ -2350,6 +2375,7 @@ export type PreloadApi = { get: (hostId?: ExecutionHostId) => Promise set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise + flush: () => Promise readTerminalScrollback: (args: { ref: string }) => string | null setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void } @@ -2534,7 +2560,10 @@ export type PreloadApi = { connectionId?: string includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + requestToken?: string }) => Promise + cancelStatus: (args: { requestToken: string }) => Promise submoduleStatus: (args: { worktreePath: string submodulePath: string @@ -2738,6 +2767,8 @@ export type PreloadApi = { recordFeatureInteraction: (id: FeatureInteractionId) => Promise onStateChanged: (callback: (ui: PersistedUIState) => void) => () => void onOpenSettings: (callback: () => void) => () => void + /** Consumes a one-shot tray/menu-bar "open settings" intent queued before mount. */ + consumePendingOpenSettings: () => Promise onOpenSetupGuide: (callback: () => void) => () => void onOpenFeatureTour: (callback: () => void) => () => void onOpenCrashReport: (callback: () => void) => () => void @@ -2837,6 +2868,9 @@ export type PreloadApi = { onRequestTerminalCreate: ( callback: (data: RuntimeTerminalCreateRequestPayload) => void ) => () => void + onRequestTerminalTabMount: ( + callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + ) => () => void replyTerminalCreate: (reply: { requestId: string tabId?: string @@ -2898,6 +2932,8 @@ export type PreloadApi = { onCloseTerminal: ( callback: (data: { tabId: string; paneRuntimeId?: number }) => void ) => () => void + onTerminalTabCloseRequest: (callback: (request: TerminalTabCloseRequest) => void) => () => void + respondTerminalTabClose: (response: TerminalTabCloseResponse) => void onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void @@ -3180,6 +3216,7 @@ export type PreloadApi = { supported: true port: number ruleAllowed: boolean + blockingRuleDetected: boolean privateFirewallEnabled: boolean networkCategory: 'private' | 'public' | 'domain' | 'unknown' inspectionAvailable: boolean diff --git a/src/preload/index.ts b/src/preload/index.ts index 24b67568fa2..4aa685d7b56 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -72,6 +72,7 @@ import type { import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' +import type { SkillFreshnessInventory } from '../shared/skill-freshness' import type { RuntimeBrowserDriverState, RuntimeMobileSessionTabMove, @@ -176,9 +177,9 @@ import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybi import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types' import type { AgentType } from '../shared/native-chat-types' import type { - NativeChatAppendedMessages, NativeChatAppendedPayload, - NativeChatReadSessionResult + NativeChatReadSessionResult, + NativeChatSubscriptionFrame } from './api-types' import { ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, @@ -568,6 +569,8 @@ const api = { reorder: (args) => ipcRenderer.invoke('repos:reorder', args), + reorderForHost: (args) => ipcRenderer.invoke('repos:reorderForHost', args), + update: (args) => ipcRenderer.invoke('repos:update', args), pickFolder: () => ipcRenderer.invoke('repos:pickFolder'), @@ -1140,7 +1143,13 @@ const api = { sendSerializedBuffer: ( requestId: string, - snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null + snapshot: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } | null ): void => { ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) }, @@ -1159,6 +1168,9 @@ const api = { clearPendingPaneSerializer: (paneKey: string, gen: number): Promise => ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), + reportRendererSerializerReady: (ptyId: string): Promise => + ipcRenderer.invoke('pty:reportRendererSerializerReady', { ptyId }), + management: { listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), killAll: () => ipcRenderer.invoke('pty:management:killAll'), @@ -1317,7 +1329,7 @@ const api = { repoId?: string limit?: number query?: string - before?: string + page?: number noCache?: boolean }): Promise>> => ipcRenderer.invoke('gh:listWorkItems', args), @@ -1733,6 +1745,7 @@ const api = { siteUrl: string email: string apiToken: string + authType?: 'cloud' | 'server' }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => ipcRenderer.invoke('jira:connect', args), @@ -2204,7 +2217,9 @@ const api = { skills: { discover: (target?: SkillDiscoveryTarget): Promise => - ipcRenderer.invoke('skills:discover', target) + ipcRenderer.invoke('skills:discover', target), + freshnessInventory: (): Promise => + ipcRenderer.invoke('skills:freshnessInventory') }, pet: { @@ -2271,7 +2286,7 @@ const api = { callback: (event: { browserPageId: string origin: string - action: 'opened-external' | 'blocked' + action: 'opened-in-orca' | 'opened-external' | 'blocked' }) => void ): (() => void) => { const listener = ( @@ -2279,7 +2294,7 @@ const api = { data: { browserPageId: string origin: string - action: 'opened-external' | 'blocked' + action: 'opened-in-orca' | 'opened-external' | 'blocked' } ) => callback(data) ipcRenderer.on('browser:popup', listener) @@ -2700,6 +2715,7 @@ const api = { get: (hostId) => ipcRenderer.invoke('session:get', hostId), set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), + flush: () => ipcRenderer.invoke('session:flush'), readTerminalScrollback: (args) => ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), /** Synchronous session save for beforeunload — blocks until flushed to disk. */ @@ -2960,7 +2976,11 @@ const api = { connectionId?: string includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + requestToken?: string }): Promise => ipcRenderer.invoke('git:status', args), + cancelStatus: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('git:cancelStatus', args), submoduleStatus: (args: { worktreePath: string submodulePath: string @@ -3152,6 +3172,8 @@ const api = { ipcRenderer.on('ui:openSettings', listener) return () => ipcRenderer.removeListener('ui:openSettings', listener) }, + consumePendingOpenSettings: (): Promise => + ipcRenderer.invoke('ui:consumePendingOpenSettings'), onOpenSetupGuide: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openSetupGuide', listener) @@ -3510,6 +3532,16 @@ const api = { ipcRenderer.on('terminal:requestTabCreate', listener) return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) }, + onRequestTerminalTabMount: ( + callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string; tabId?: string; ptyId?: string } + ) => callback(data) + ipcRenderer.on('terminal:requestTabMount', listener) + return () => ipcRenderer.removeListener('terminal:requestTabMount', listener) + }, replyTerminalCreate: (reply: { requestId: string tabId?: string @@ -3667,6 +3699,17 @@ const api = { ipcRenderer.on('ui:closeTerminal', listener) return () => ipcRenderer.removeListener('ui:closeTerminal', listener) }, + onTerminalTabCloseRequest: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + request: Parameters[0] + ) => callback(request) + ipcRenderer.on('ui:terminalTabCloseRequest', listener) + return () => ipcRenderer.removeListener('ui:terminalTabCloseRequest', listener) + }, + respondTerminalTabClose: (response) => { + ipcRenderer.send('ui:terminalTabCloseResponse', response) + }, onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => callback(data) @@ -3904,12 +3947,13 @@ const api = { agent: AgentType sessionId: string transcriptPath?: string + limit?: number }, - onAppended: (messages: NativeChatAppendedMessages) => void + onFrame: (frame: NativeChatSubscriptionFrame) => void ): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => { if (payload.subscriptionId === args.subscriptionId) { - onAppended(payload.messages) + onFrame(payload.frame) } } ipcRenderer.on('nativeChat:appended', listener) diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index 0a9a3908172..87f5cb69053 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -201,6 +201,23 @@ describe('RelayDispatcher', () => { expect(socketWritten).toHaveLength(1) }) + it('targets terminal ownership notifications to one attached client', () => { + const firstWritten: Buffer[] = [] + const secondWritten: Buffer[] = [] + const firstId = dispatcher.attachClient((data) => { + firstWritten.push(Buffer.from(data)) + }) + dispatcher.attachClient((data) => { + secondWritten.push(Buffer.from(data)) + }) + + dispatcher.notifyClient(firstId, 'fs.watchFailed', { watchId: 7 }) + + expect(written).toHaveLength(0) + expect(firstWritten).toHaveLength(1) + expect(secondWritten).toHaveLength(0) + }) + it('forwards relay-originated requests to an owning socket client instead of the caller', async () => { dispatcher.invalidateClient() const ownerWritten: Buffer[] = [] diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 7dfdf5dc5f4..a036d5449bb 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -189,6 +189,21 @@ export class RelayDispatcher { } } + notifyClient(clientId: number, method: string, params?: Record): void { + if (this.disposed) { + return + } + const client = this.clients.get(clientId) + if (!client || client.closed) { + return + } + this.sendFrame(client, { + jsonrpc: '2.0', + method, + ...(params !== undefined ? { params } : {}) + }) + } + /** * Bulk-lane notification. Sends are serialized per client and the returned * promise resolves only after the sink accepted the frame without reporting diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index ae418f9e881..8de2ad3394c 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -9,8 +9,15 @@ import { spawn } from 'node:child_process' import { fileListingCancellationError } from '../shared/file-listing-cancellation' import type { SearchOptions, SearchResult } from './fs-handler-utils' -import { buildGitLsFilesArgsForQuickOpen } from '../shared/quick-open-filter' -import { expandQuickOpenGitFileListing } from '../shared/quick-open-readdir-walk' +import { + buildGitLsFilesArgsForQuickOpen, + shouldExcludeQuickOpenRelPath, + shouldIncludeQuickOpenPath +} from '../shared/quick-open-filter' +import { + expandQuickOpenGitFileListing, + parseQuickOpenGitLsFilesEntry +} from '../shared/quick-open-readdir-walk' import { buildGitGrepArgs, buildSubmatchRegex, @@ -33,19 +40,21 @@ import { buildRelayGitEnv } from './relay-command-env' export function listFilesWithGit( rootPath: string, excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; maxResults?: number } = {} ): Promise { - const { signal } = options + const { signal, maxResults } = options if (signal?.aborted) { return Promise.reject(fileListingCancellationError(signal)) } const gitPaths = new Set() const directoryPaths = new Set() + const directFileCandidates = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ReturnType isDone: () => boolean reject: (error: Error) => void + resolve: () => void }[] = [] const runGitLsFiles = (args: string[]): Promise => { @@ -53,15 +62,32 @@ export function listFilesWithGit( let buf = '' let done = false - const processPath = (path: string): void => { + const processPath = (path: string): boolean => { if (!path) { - return + return false } if (path.endsWith('/')) { directoryPaths.add(path) } else { gitPaths.add(path) + if (maxResults !== undefined) { + // Why: this duplicate classification exists only to stop bounded + // scans; unbounded SSH scans must not retain another full listing. + const parsed = parseQuickOpenGitLsFilesEntry(path) + const relPath = parsed.path.replace(/\/+$/, '') + if ( + !parsed.isGitlink && + !parsed.isUntrackedDir && + shouldIncludeQuickOpenPath(relPath) && + !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) + ) { + directFileCandidates.add(relPath) + } + } } + // Why: placeholders need IO classification and can disappear; only + // guaranteed final files are allowed to stop the remote Git processes. + return maxResults !== undefined && directFileCandidates.size >= maxResults } const child = spawn('git', ['ls-files', ...args], { @@ -100,7 +126,8 @@ export function listFilesWithGit( children.push({ child, isDone: () => done, - reject: rejectPass + reject: rejectPass, + resolve: resolvePass }) function handleStdoutData(chunk: string): void { @@ -108,7 +135,11 @@ export function listFilesWithGit( let start = 0 let idx = buf.indexOf('\0', start) while (idx !== -1) { - processPath(buf.substring(start, idx)) + if (processPath(buf.substring(start, idx))) { + buf = '' + finishAtLimit() + return + } start = idx + 1 idx = buf.indexOf('\0', start) } @@ -131,8 +162,10 @@ export function listFilesWithGit( rejectPass(new Error(`git ls-files killed by ${signal}`)) return } - if (buf) { - processPath(buf) + if (buf && processPath(buf)) { + buf = '' + finishAtLimit() + return } if (code === 0) { resolvePass() @@ -170,19 +203,27 @@ export function listFilesWithGit( } } + function finishAtLimit(): void { + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.resolve() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + } + } + // Why: a cancelled scan (workspace switch, superseded request) must stop // its git children right away instead of streaming a huge tree the caller // has already abandoned over the shared SSH channel. const onAbort = (): void => killSurvivors('git ls-files cancelled') signal?.addEventListener('abort', onAbort, { once: true }) - return Promise.all([ - runGitLsFiles(primary), + const runIgnoredPass = () => // Why: ignored files are supplementary — a failed or timed-out ignored - // pass must not discard the primary listing the user actually needs - // (#7719 root cause: the all-or-nothing failure showed zero files). - // Entries streamed before the failure are kept; a cancelled scan still - // rejects via the primary pass or the expansion's cancellation check. + // pass must not discard the primary listing the user actually needs. runGitLsFiles(ignoredPass).catch((err: Error) => { if (!signal?.aborted) { console.warn( @@ -191,18 +232,26 @@ export function listFilesWithGit( ) } }) - ]) + const passes = + maxResults === undefined + ? Promise.all([runGitLsFiles(primary), runIgnoredPass()]) + : runGitLsFiles(primary).then(() => + directFileCandidates.size < maxResults ? runIgnoredPass() : Promise.resolve() + ) + + return passes .then(async () => { const files = await expandQuickOpenGitFileListing({ rootPath, gitPaths, directoryPaths, excludePathPrefixes, - signal + signal, + maxResults }) // Why: directory placeholders are expanded after Git exits; restore // Git's path order for empty queries and fuzzy-score ties over SSH. - return files.sort() + return files.sort().slice(0, maxResults) }) .catch((err) => { killSurvivors('git ls-files canceled after sibling failure') diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index d76fe40b517..1ca5c2f5327 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -97,6 +97,24 @@ describe('relay quick open ignored file listing', () => { expect(ignoredArgs).toContain('!packages/other/**') }) + it('stops after the primary relay rg pass fills the result budget', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) + + const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) + ;(primaryProc.stdout as unknown as EventEmitter).emit( + 'data', + 'src/one.ts\nsrc/two.ts\nsrc/three.ts\n' + ) + + await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) + expect(primaryProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + it('git fallback ignored pass includes ignored non-env files', async () => { const root = await makeTempRoot() await writeRel(root, 'dist/generated.js') @@ -146,6 +164,35 @@ describe('relay quick open ignored file listing', () => { expect(ignoredArgs).toContain(':(exclude,glob)packages/other/**') }) + it('stops after primary relay Git files fill the result budget', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) + + const promise = listFilesWithGit('/remote/root', [], { maxResults: 2 }) + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/one.ts\0src/two.ts') + primaryProc.emit('close', 0, null) + await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) + expect(primaryProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + + it('does not let a discarded relay Git placeholder consume the result budget', async () => { + const primaryProc = createMockProcess() + spawnMock.mockReturnValue(primaryProc) + + const promise = listFilesWithGit('/remote/root', [], { maxResults: 1 }) + ;(primaryProc.stdout as unknown as EventEmitter).emit( + 'data', + `discarded/\0${staged('100644', 'src/kept.ts')}\0` + ) + + await expect(promise).resolves.toEqual(['src/kept.ts']) + expect(primaryProc.kill).toHaveBeenCalled() + }) + it('git fallback fills nested git repos returned as root-relative placeholders', async () => { const root = await makeTempRoot() await writeRel(root, 'README.md') diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index bbe7912ee61..c63a352786e 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -29,9 +29,9 @@ export const LIST_FILES_TIMEOUT_MS = 25_000 export function listFilesWithRg( rootPath: string, excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; maxResults?: number } = {} ): Promise { - const { signal } = options + const { signal, maxResults } = options if (signal?.aborted) { return Promise.reject(fileListingCancellationError(signal)) } @@ -67,6 +67,9 @@ export function listFilesWithRg( return true } files.add(relPath) + if (maxResults !== undefined && files.size >= maxResults) { + finishAtLimit() + } return true } @@ -134,6 +137,9 @@ export function listFilesWithRg( if (processLine(passBuf.substring(start, idx))) { passFileCount++ } + if (done) { + return + } start = idx + 1 idx = passBuf.indexOf('\n', start) } @@ -199,6 +205,16 @@ export function listFilesWithRg( } } + function finishAtLimit(): void { + if (done) { + return + } + done = true + signal?.removeEventListener('abort', onAbort) + killSurvivors('rg list reached bounded result limit') + resolve(Array.from(files).slice(0, maxResults)) + } + // Why: a cancelled scan (workspace switch, superseded request) must stop // its rg children immediately instead of letting them walk the tree to // completion and flood the relay with stdout it will only discard. @@ -212,7 +228,16 @@ export function listFilesWithRg( } signal?.addEventListener('abort', onAbort, { once: true }) - Promise.all([runPass(primary), runPass(ignoredPass)]) + const passes = + maxResults === undefined + ? Promise.all([runPass(primary), runPass(ignoredPass)]) + : // Why: deterministic primary-first budgeting prevents a large ignored + // tree from starving ordinary source paths on a remote host. + runPass(primary).then(() => + files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() + ) + + passes .then(() => { if (done) { return diff --git a/src/relay/fs-handler-readdir-fallback.test.ts b/src/relay/fs-handler-readdir-fallback.test.ts new file mode 100644 index 00000000000..d3e5591ab32 --- /dev/null +++ b/src/relay/fs-handler-readdir-fallback.test.ts @@ -0,0 +1,26 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { listFilesWithReaddir } from './fs-handler-readdir-fallback' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('relay readdir file-list fallback', () => { + it('returns a bounded prefix instead of rejecting at the requested result limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-relay-readdir-')) + roots.push(root) + await mkdir(join(root, 'src')) + await writeFile(join(root, 'src', 'a.ts'), 'a') + await writeFile(join(root, 'src', 'b.ts'), 'b') + + const files = await listFilesWithReaddir(root, [], { maxResults: 1 }) + + expect(files).toHaveLength(1) + expect(['src/a.ts', 'src/b.ts']).toContain(files[0]) + }) +}) diff --git a/src/relay/fs-handler-readdir-fallback.ts b/src/relay/fs-handler-readdir-fallback.ts index d7c9172183b..25006788f1b 100644 --- a/src/relay/fs-handler-readdir-fallback.ts +++ b/src/relay/fs-handler-readdir-fallback.ts @@ -24,11 +24,12 @@ import { export async function listFilesWithReaddir( rootPath: string, excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; maxResults?: number } = {} ): Promise { return listQuickOpenFilesWithReaddir(rootPath, { excludePathPrefixes, budget: createQuickOpenReaddirBudget(), + maxResults: options.maxResults, signal: options.signal }) } diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 63e399072e9..23dd28c35cb 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -61,6 +61,7 @@ function createMockDispatcher() { notify: vi.fn((method: string, params?: Record) => { notifications.push({ method, params }) }), + notifyClient: vi.fn(), onClientDetached: vi.fn((listener: (clientId: number) => void) => { detachListeners.add(listener) return () => detachListeners.delete(listener) @@ -153,6 +154,7 @@ describe('FsHandler', () => { expect(methods).toContain('fs.listFiles') expect(methods).toContain('fs.workspaceSpaceScan') expect(methods).toContain('fs.watch') + expect(methods).toContain('fs.unwatchAndWait') const notifMethods = Array.from(dispatcher._notificationHandlers.keys()) expect(notifMethods).toContain('fs.unwatch') @@ -493,6 +495,28 @@ describe('FsHandler', () => { await expect(fs.access(filePath)).rejects.toThrow() }) + it('holds the relay watcher fence through recursive directory deletion', async () => { + const directoryPath = path.join(tmpDir, 'watched-orphan') + mkdirSync(directoryPath) + const unsubscribe = vi.fn() + mockSubscribe.mockResolvedValue({ unsubscribe }) + await dispatcher.callRequest( + 'fs.watch', + { rootPath: directoryPath, watchId: 77 }, + { clientId: 3, isStale: () => false } + ) + + await dispatcher.callRequest('fs.deletePath', { targetPath: directoryPath, recursive: true }) + + expect(unsubscribe).toHaveBeenCalledTimes(1) + expect(dispatcher.notifyClient).toHaveBeenCalledWith(3, 'fs.watchFailed', { + rootPath: directoryPath, + watchId: 77, + message: 'Remote worktree is being removed' + }) + await expect(fs.access(directoryPath)).rejects.toThrow() + }) + it('createFile creates an empty file with parent dirs', async () => { const filePath = path.join(tmpDir, 'deep', 'nested', 'file.txt') await dispatcher.callRequest('fs.createFile', { filePath }) @@ -734,6 +758,129 @@ describe('FsHandler', () => { expect(unsubscribe).toHaveBeenCalledTimes(1) }) + it('settles acknowledged unwatch only after native unsubscribe completes', async () => { + let resolveUnsubscribe: () => void = () => {} + const unsubscribe = vi.fn( + () => + new Promise((resolve) => { + resolveUnsubscribe = resolve + }) + ) + mockSubscribe.mockResolvedValue({ unsubscribe }) + await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }) + + let settled = false + const unwatch = dispatcher.callRequest('fs.unwatchAndWait', { rootPath: tmpDir }).then(() => { + settled = true + }) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1)) + expect(settled).toBe(false) + + resolveUnsubscribe() + await unwatch + expect(settled).toBe(true) + }) + + it('waits for in-flight native setup before acknowledging teardown', async () => { + handler.dispose() + let resolveSubscribe: (value: { unsubscribe: () => Promise }) => void = () => {} + const unsubscribe = vi.fn(async () => undefined) + const subscribe = vi.fn( + () => + new Promise<{ unsubscribe: () => Promise }>((resolve) => { + resolveSubscribe = resolve + }) + ) + handler = new FsHandler(dispatcher as unknown as RelayDispatcher, new RelayContext(), { + dispose: vi.fn(), + forgetRoot: vi.fn(), + subscribe + }) + + const watch = dispatcher.callRequest('fs.watch', { rootPath: tmpDir }) + let unwatchSettled = false + const unwatch = dispatcher.callRequest('fs.unwatchAndWait', { rootPath: tmpDir }).then(() => { + unwatchSettled = true + }) + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1)) + expect(unwatchSettled).toBe(false) + + resolveSubscribe({ unsubscribe }) + await Promise.all([watch, unwatch]) + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('joins a physical unsubscribe already started by the notification path', async () => { + let resolveUnsubscribe: () => void = () => {} + const unsubscribe = vi.fn( + () => + new Promise((resolve) => { + resolveUnsubscribe = resolve + }) + ) + mockSubscribe.mockResolvedValue({ unsubscribe }) + await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }) + dispatcher.callNotification('fs.unwatch', { rootPath: tmpDir }) + + let settled = false + const joined = dispatcher.callRequest('fs.unwatchAndWait', { rootPath: tmpDir }).then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + resolveUnsubscribe() + await joined + }) + + it('blocks replacement watches behind physical unsubscribe and counts the pending slot', async () => { + let resolveUnsubscribe: () => void = () => {} + const unsubscribe = vi.fn( + () => + new Promise((resolve) => { + resolveUnsubscribe = resolve + }) + ) + mockSubscribe.mockResolvedValue({ unsubscribe }) + await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }) + dispatcher.callNotification('fs.unwatch', { rootPath: tmpDir }) + + const replacement = dispatcher.callRequest('fs.watch', { rootPath: tmpDir }) + for (let index = 0; index < 19; index += 1) { + await dispatcher.callRequest('fs.watch', { + rootPath: path.join(tmpDir, `pending-cap-${index}`) + }) + } + await expect( + dispatcher.callRequest('fs.watch', { rootPath: path.join(tmpDir, 'over-pending-cap') }) + ).rejects.toThrow('Maximum number of file watchers reached') + expect(mockSubscribe).toHaveBeenCalledTimes(20) + + resolveUnsubscribe() + await replacement + expect(mockSubscribe).toHaveBeenCalledTimes(21) + }) + + it('retains a failed native unsubscribe slot until acknowledged retry succeeds', async () => { + const unsubscribe = vi + .fn() + .mockRejectedValueOnce(new Error('native handle still active')) + .mockResolvedValueOnce(undefined) + mockSubscribe.mockResolvedValue({ unsubscribe }) + await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }) + + await expect(dispatcher.callRequest('fs.unwatchAndWait', { rootPath: tmpDir })).rejects.toThrow( + 'native handle still active' + ) + await expect( + dispatcher.callRequest('fs.unwatchAndWait', { rootPath: tmpDir }) + ).resolves.toBeUndefined() + expect(unsubscribe).toHaveBeenCalledTimes(2) + + await expect(dispatcher.callRequest('fs.watch', { rootPath: tmpDir })).resolves.toBeUndefined() + expect(mockSubscribe).toHaveBeenCalledTimes(2) + }) + it('allows a shared watch attach even when the root watch cap is full', async () => { mockSubscribe.mockResolvedValue({ unsubscribe: vi.fn() }) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 38f0dec9b5d..9c146af1450 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -96,6 +96,10 @@ export class FsHandler { }) } + getWatchRegistry(): RelayFilesystemWatchRegistry { + return this.watchRegistry + } + private registerHandlers(): void { this.dispatcher.onRequest('fs.readDir', (p) => this.readDir(p)) this.dispatcher.onRequest('fs.readFile', (p) => this.readFile(p)) @@ -118,7 +122,14 @@ export class FsHandler { 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.watchRegistry.watch(expandTilde(p.rootPath as string), context) + this.watchRegistry.watch( + expandTilde(p.rootPath as string), + context, + typeof p.watchId === 'number' && Number.isSafeInteger(p.watchId) ? p.watchId : undefined + ) + ) + this.dispatcher.onRequest('fs.unwatchAndWait', (p, context) => + this.watchRegistry.unwatchAndWait(expandTilde(p.rootPath as string), context) ) this.dispatcher.onNotification('fs.unwatch', (p, context) => this.watchRegistry.unwatch(expandTilde(p.rootPath as string), context) @@ -247,7 +258,14 @@ export class FsHandler { if (stats.isDirectory() && !recursive) { throw new Error('Cannot delete directory without recursive flag') } - await rm(targetPath, { recursive: !!recursive, force: true }) + const remove = () => rm(targetPath, { recursive: !!recursive, force: true }) + if (stats.isDirectory()) { + // Why: forced orphan cleanup bypasses git.removeWorktree but must hold + // the same relay-wide watcher fence through recursive deletion. + await this.watchRegistry.runWithRemovalFence(targetPath, remove) + return + } + await remove() } private async createFile(params: Record) { @@ -338,6 +356,12 @@ export class FsHandler { private listFiles(params: Record, context?: RequestContext): Promise { const rootPath = expandTilde(params.rootPath as string) + const maxResults = + typeof params.maxResults === 'number' && + Number.isInteger(params.maxResults) && + params.maxResults > 0 + ? Math.min(params.maxResults, 20_001) + : undefined // Why: the main-to-relay RPC adds excludePaths so nested linked worktrees // don't get double-scanned. The shared helper validates the shape and // normalizes into root-relative prefixes; malformed input yields [] so @@ -348,21 +372,22 @@ export class FsHandler { // aborting a stale scan when the workspace changes or the host cancels. return this.listFilesScans.run({ clientId: context?.clientId ?? 0, - key: JSON.stringify([rootPath, excludePathPrefixes]), + key: JSON.stringify([rootPath, excludePathPrefixes, maxResults]), signal: context?.signal, - start: (signal) => this.runListFilesScan(rootPath, excludePathPrefixes, signal) + start: (signal) => this.runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults) }) } private async runListFilesScan( rootPath: string, excludePathPrefixes: string[], - signal: AbortSignal + signal: AbortSignal, + maxResults?: number ): Promise { const rgAvailable = await checkRgAvailable() throwIfFileListingCancelled(signal) if (rgAvailable) { - return listFilesWithRg(rootPath, excludePathPrefixes, { signal }) + return listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults }) } // Why: git ls-files only works inside git repos. Use rev-parse to detect // git ancestry — unlike checking for a local .git entry, this works from @@ -383,7 +408,7 @@ export class FsHandler { // budget errors into install-rg guidance; genuine git failures keep // their own messages. try { - return await listFilesWithGit(rootPath, excludePathPrefixes, { signal }) + return await listFilesWithGit(rootPath, excludePathPrefixes, { signal, maxResults }) } catch (err) { if (isQuickOpenReaddirBudgetError(err)) { throw new Error(await buildInstallRgMessage(err)) @@ -397,7 +422,7 @@ export class FsHandler { // problem, so translate the opaque cap error into actionable guidance // the user can act on directly from the error toast. try { - return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal }) + return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal, maxResults }) } catch (err) { // Why: a cancelled scan is not an rg-availability problem; wrapping it // in install-rg guidance would surface bogus advice on the client. diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 0bb862fbcea..395e31badb3 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -16,7 +16,13 @@ import { readWorkingDiffFile } from './git-working-file-read' export type GitExec = ( args: string[], cwd: string, - opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; stdin?: string; timeout?: number } + opts?: { + maxBuffer?: number + disableOptionalLocks?: boolean + signal?: AbortSignal + stdin?: string + timeout?: number + } ) => Promise<{ stdout: string; stderr: string }> export type GitBufferExec = (args: string[], cwd: string) => Promise diff --git a/src/relay/git-handler-status-ops.test.ts b/src/relay/git-handler-status-ops.test.ts index 044fa82bf6a..4a7494d0956 100644 --- a/src/relay/git-handler-status-ops.test.ts +++ b/src/relay/git-handler-status-ops.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GitExec } from './git-handler-ops' import { getStatusOp } from './git-handler-status-ops' import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache' +import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' const LARGE_STATUS_ENTRY_COUNT = 150_000 @@ -26,6 +27,7 @@ describe('getStatusOp', () => { beforeEach(() => { clearNoEffectiveUpstreamStatusCache() + clearGitStatusLineStatsCache() tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-status-')) }) @@ -79,6 +81,51 @@ describe('getStatusOp', () => { expect(result.entries).toHaveLength(5) }) + it('reuses unchanged line stats only for hinted safety reads', async () => { + const statusOutput = `${buildBranchStatusOutput('head-1', '(detached)')}\n1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts` + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: statusOutput, stderr: '' } + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }) + const reused = await getStatusOp(git, { worktreePath: tmpDir, reuseLineStats: true }) + await getStatusOp(git, { worktreePath: tmpDir }) + + expect(reused.entries).toContainEqual( + expect.objectContaining({ path: 'src/a.ts', added: 3, removed: 2 }) + ) + expect(git.mock.calls.filter(([args]) => args.includes('diff'))).toHaveLength(2) + }) + + it('forwards the request abort signal to status and numstat subprocesses', async () => { + const controller = new AbortController() + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { + stdout: '1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts', + stderr: '' + } + } + if (args.includes('diff')) { + return { stdout: '1\t0\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }, { signal: controller.signal }) + + expect(git.mock.calls).not.toHaveLength(0) + for (const [, , options] of git.mock.calls) { + expect(options?.signal).toBe(controller.signal) + } + }) + it('caches no-effective-upstream probes across status polls for the same head', async () => { const git = vi.fn(async (args) => { if (args.includes('status')) { diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 4f48ec9b6da..81d0a4eb83b 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -20,6 +20,11 @@ import { type GitLineStats } from '../shared/git-uncommitted-line-stats' import { DEFAULT_GIT_STATUS_LIMIT } from '../shared/git-status-limit' +import { + beginGitStatusLineStatsCacheWrite, + clearGitStatusLineStatsCacheKey, + reuseOrRecomputeGitStatusLineStats +} from '../shared/git-status-line-stats-cache' export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') @@ -58,7 +63,8 @@ export async function detectConflictOperation(worktreePath: string): Promise + params: Record, + options: { signal?: AbortSignal } = {} ): Promise<{ entries: Record[] conflictOperation: string @@ -70,6 +76,8 @@ export async function getStatusOp( statusLength?: number }> { const worktreePath = params.worktreePath as string + const lineStatsCacheKey = `relay\0${worktreePath}` + const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) const includeIgnored = params.includeIgnored === true // Why: reject non-finite/negative limits so the cap guard stays reliable // (NaN would silently disable capping; negatives would over-truncate). @@ -106,7 +114,8 @@ export async function getStatusOp( const { stdout } = await git(statusArgs, worktreePath, { // Why: status polling is read-like; avoid refreshing the index and racing // terminal Git commands on `.git/worktrees/*/index.lock`. - disableOptionalLocks: true + disableOptionalLocks: true, + signal: options.signal }) const parsed = parseStatusOutput(stdout) head = parsed.head @@ -134,6 +143,9 @@ export async function getStatusOp( const branchName = getShortBranchName(branch) if (branchName) { try { + // Why: this probe coalesces across concurrent status reads, so one + // request's abort must not reject the shared in-flight promise for + // the others; the probe is small and its cached result stays useful. upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus( { worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName }, (args) => git(args, worktreePath), @@ -155,7 +167,12 @@ export async function getStatusOp( } } } - } catch { + } catch (error) { + // Why: an aborted scan must reject, not resolve — swallowing here would let + // a cancelled request be mistaken for a completed (empty) status result. + if (options.signal?.aborted) { + throw error + } // not a git repo or git not available } @@ -165,7 +182,25 @@ export async function getStatusOp( // running numstat over a huge change set would reintroduce the cost the limit // exists to avoid. if (!didHitLimit) { - await attachLineStats(git, worktreePath, entries) + await reuseOrRecomputeGitStatusLineStats({ + cacheKey: lineStatsCacheKey, + head, + entries, + writeToken: lineStatsWriteToken, + reuse: params.reuseLineStats === true, + isAborted: () => options.signal?.aborted === true, + recompute: () => attachLineStats(git, worktreePath, entries, options.signal) + }) + } else { + clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) + } + + // Why: abort after the porcelain read (e.g. during unmerged/upstream/line-stats + // work) must still reject — never resolve a cancelled scan as completed. + if (options.signal?.aborted) { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + throw error } return { @@ -182,29 +217,39 @@ export async function getStatusOp( async function runNumstat( git: GitExec, worktreePath: string, - cached: boolean -): Promise> { + cached: boolean, + signal?: AbortSignal +): Promise | null> { try { const { stdout } = await git( ['-c', 'core.quotePath=false', 'diff', ...(cached ? ['--cached'] : []), '--numstat', '-M'], worktreePath, - { disableOptionalLocks: true } + { disableOptionalLocks: true, signal } ) return parseNumstat(stdout) - } catch { + } catch (error) { + // Why: an aborted pass must reject so a cancelled scan is never treated as + // a completed one; only a genuine (non-abort) numstat failure degrades to + // uncounted rows below. + if (signal?.aborted) { + throw error + } // Why: a numstat failure should leave rows without counts rather than break - // the whole status refresh. - return new Map() + // the whole status refresh. Null (vs an empty map) tells the caller the + // pass is incomplete and must not be cached. + return null } } +/** Returns false when a numstat pass failed, so callers skip caching it. */ async function attachLineStats( git: GitExec, worktreePath: string, - entries: Record[] -): Promise { + entries: Record[], + signal?: AbortSignal +): Promise { if (entries.length === 0) { - return + return true } const hasStaged = entries.some((entry) => entry.area === 'staged') const hasUnstaged = entries.some((entry) => entry.area === 'unstaged') @@ -213,21 +258,22 @@ async function attachLineStats( .map((entry) => entry.path as string) const emptyStats = new Map() const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ - hasStaged ? runNumstat(git, worktreePath, true) : Promise.resolve(emptyStats), - hasUnstaged ? runNumstat(git, worktreePath, false) : Promise.resolve(emptyStats), - collectUntrackedAdditions(worktreePath, untrackedPaths) + hasStaged ? runNumstat(git, worktreePath, true, signal) : Promise.resolve(emptyStats), + hasUnstaged ? runNumstat(git, worktreePath, false, signal) : Promise.resolve(emptyStats), + collectUntrackedAdditions(worktreePath, untrackedPaths, signal) ]) for (const entry of entries) { const filePath = entry.path as string applyLineStats( entry as { added?: number; removed?: number }, entry.area === 'staged' - ? stagedStats.get(filePath) + ? (stagedStats ?? emptyStats).get(filePath) : entry.area === 'unstaged' - ? unstagedStats.get(filePath) + ? (unstagedStats ?? emptyStats).get(filePath) : untrackedStats.get(filePath) ) } + return stagedStats !== null && unstagedStats !== null } function getShortBranchName(branch: string | undefined): string | null { diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts index 211e4b205f4..e1c18a78d33 100644 --- a/src/relay/git-handler-utils.test.ts +++ b/src/relay/git-handler-utils.test.ts @@ -43,6 +43,38 @@ describe('parseWorktreeList', () => { lockReason: '"literal\\nquote"' }) }) + + it('preserves a prunable marker and its reason', () => { + expect( + parseWorktreeList( + 'worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /stale\nHEAD def\nbranch refs/heads/feature\nprunable gitdir file points to non-existent location\n' + )[1] + ).toMatchObject({ + path: '/stale', + prunable: true, + prunableReason: 'gitdir file points to non-existent location' + }) + }) + + it('preserves a NUL-delimited prunable marker', () => { + const output = [ + 'worktree /repo', + 'HEAD abc', + 'branch refs/heads/main', + '', + 'worktree /stale', + 'HEAD def', + 'branch refs/heads/feature', + 'prunable gitdir file points to non-existent location', + '' + ].join('\0') + + expect(parseWorktreeList(output, { nulDelimited: true })[1]).toMatchObject({ + path: '/stale', + prunable: true, + prunableReason: 'gitdir file points to non-existent location' + }) + }) }) describe('isUnsupportedWorktreeListZError', () => { diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index 6fbb8ec4a7d..ab3bb61faba 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -152,6 +152,8 @@ export function parseWorktreeList( let isBare = false let locked = false let lockReason = '' + let prunable = false + let prunableReason = '' for (const line of lines) { if (line.startsWith('worktree ')) { @@ -166,6 +168,12 @@ export function parseWorktreeList( locked = true const rawReason = line.slice('locked'.length).trim() lockReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason) + } else if (line === 'prunable' || line.startsWith('prunable ')) { + // Why: Git ≥ 2.36 flags registrations whose directory is gone; ignoring + // it surfaces the stale worktree as a live workspace (issue #8389). + prunable = true + const rawReason = line.slice('prunable'.length).trim() + prunableReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason) } } @@ -177,6 +185,8 @@ export function parseWorktreeList( isBare, ...(locked ? { locked: true } : {}), ...(lockReason ? { lockReason } : {}), + ...(prunable ? { prunable: true } : {}), + ...(prunableReason ? { prunableReason } : {}), isMainWorktree: worktrees.length === 0 }) } diff --git a/src/relay/git-handler-worktree-list.test.ts b/src/relay/git-handler-worktree-list.test.ts new file mode 100644 index 00000000000..2596938179a --- /dev/null +++ b/src/relay/git-handler-worktree-list.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { annotatePrunableWorktreesByExistence } from './git-handler-worktree-list' + +const tempRoots: string[] = [] + +async function createTempDir(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'orca-relay-prunable-')) + tempRoots.push(root) + return root +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('annotatePrunableWorktreesByExistence', () => { + it('marks linked worktrees with missing directories as prunable', async () => { + const liveDir = await createTempDir() + const missingDir = path.join(liveDir, 'deleted-worktree') + + const annotated = await annotatePrunableWorktreesByExistence([ + { path: liveDir, isMainWorktree: true }, + { path: path.join(liveDir, 'also-missing-main'), isMainWorktree: true }, + { path: liveDir, isMainWorktree: false }, + { path: missingDir, isMainWorktree: false } + ]) + + expect(annotated[0]?.prunable).toBeUndefined() + // Git never marks the main worktree prunable; repo-level handling owns it. + expect(annotated[1]?.prunable).toBeUndefined() + expect(annotated[2]?.prunable).toBeUndefined() + expect(annotated[3]).toMatchObject({ path: missingDir, prunable: true }) + }) + + it('shields locked registrations, mirroring git prunable semantics', async () => { + const liveDir = await createTempDir() + const missingDir = path.join(liveDir, 'deleted-locked-worktree') + + const annotated = await annotatePrunableWorktreesByExistence([ + { path: liveDir, isMainWorktree: true }, + { path: missingDir, isMainWorktree: false, locked: true, lockReason: 'agent session' } + ]) + + expect(annotated[1]?.prunable).toBeUndefined() + }) +}) diff --git a/src/relay/git-handler-worktree-list.ts b/src/relay/git-handler-worktree-list.ts index 72bec899ad3..ac565d2481b 100644 --- a/src/relay/git-handler-worktree-list.ts +++ b/src/relay/git-handler-worktree-list.ts @@ -1,3 +1,4 @@ +import { stat } from 'node:fs/promises' import type { GitCapabilityCache } from '../shared/git-capability-cache' import type { GitExec } from './git-handler-ops' import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils' @@ -30,6 +31,55 @@ export async function readRelayWorktreeList( ) } +const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8 + +/** Why: Git <2.31 does not emit the `prunable` porcelain annotation, so probe + * each linked worktree path directly instead of treating a stale registration + * as a live workspace (issue #8389). Runs on the `-z`-unsupported fallback + * (Git <2.36); on Git 2.31–2.35 the annotation is already parsed, so this is a + * harmless backstop. The relay owns the filesystem, so a plain stat is + * authoritative. */ +export async function annotatePrunableWorktreesByExistence( + worktrees: Record[] +): Promise[]> { + const annotated = [...worktrees] + let nextIndex = 0 + + async function probeNext(): Promise { + while (nextIndex < worktrees.length) { + const index = nextIndex + nextIndex += 1 + const worktree = worktrees[index] + const worktreePath = typeof worktree?.path === 'string' ? worktree.path : '' + // Git only marks linked worktrees prunable, and never locked ones (a + // lock shields the registration even when the directory is missing). The + // `locked` annotation is only parsed on Git >=2.31, so on older Git a + // locked+missing worktree cannot be shielded here. A missing main + // worktree is surfaced by the repo-level failure paths. + if ( + !worktreePath || + worktree.isMainWorktree === true || + worktree.isBare === true || + worktree.locked === true || + worktree.prunable === true + ) { + continue + } + try { + await stat(worktreePath) + } catch (err) { + if ((err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') { + annotated[index] = { ...worktree, prunable: true } + } + } + } + } + + const workerCount = Math.min(PRUNABLE_EXISTENCE_PROBE_CONCURRENCY, worktrees.length) + await Promise.all(Array.from({ length: workerCount }, () => probeNext())) + return annotated +} + function normalizeRelayWorktrees(worktrees: Record[]): RelayWorktreeInfo[] { return worktrees .map((worktree) => ({ diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index d674af9949e..ca787ac1e1b 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -139,6 +139,22 @@ describe('GitHandler', () => { expect(methods).toContain('git.isGitRepo') }) + it('runs remote worktree deletion inside the relay watcher fence', async () => { + const removalError = new Error('fenced before Git') + const runWithRemovalFence = vi.fn(async () => { + throw removalError + }) + handler.dispose() + handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext(), { + runWithRemovalFence + }) + + await expect( + dispatcher.callRequest('git.removeWorktree', { worktreePath: '/repo-feature' }) + ).rejects.toBe(removalError) + expect(runWithRemovalFence).toHaveBeenCalledWith('/repo-feature', expect.any(Function)) + }) + describe('abortMerge', () => { it('aborts an in-progress merge', async () => { gitInit(tmpDir) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 9a9a5d7cae6..66b5eb2198c 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -38,6 +38,7 @@ import { removeWorktreeOp, worktreeIsCleanOp } from './git-handler-worktree-ops' +import { annotatePrunableWorktreesByExistence } from './git-handler-worktree-list' import { forceDeletePreservedRelayBranch } from './git-handler-branch-cleanup' import { refreshLocalBaseRefForWorktreeCreateOp } from './git-handler-local-base-ref-refresh' import { gitExecMutatesRepository } from '../shared/git-exec-mutation' @@ -69,6 +70,7 @@ import { syncForkDefaultBranch, validateGitForkSyncExpectedUpstream } from '../s import { InFlightPromiseDedupe, stableInFlightKey } from '../shared/in-flight-promise-dedupe' import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../shared/git-fetch-auto-maintenance' import { GitCapabilityCache } from '../shared/git-capability-cache' +import type { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import { hasUnsupportedRevParsePathFormatEcho, isUnsupportedRevParsePathFormatError @@ -76,6 +78,7 @@ import { import { GitResponseStreamRegistry } from './git-response-stream' import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' import { endSubprocessStdin } from '../shared/subprocess-stdin-write' +import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -178,7 +181,11 @@ export class GitHandler { // Why: RelayContext is accepted for protocol back-compat (see // docs/relay-fs-allowlist-removal.md) but no longer consulted on git ops. - constructor(dispatcher: RelayDispatcher, _context: RelayContext) { + constructor( + dispatcher: RelayDispatcher, + _context: RelayContext, + private readonly watcherRegistry?: Pick + ) { this.dispatcher = dispatcher this.registerHandlers() // Why: a detached client's git.responseAck frames will never arrive; wake @@ -192,7 +199,7 @@ export class GitHandler { } private registerHandlers(): void { - this.dispatcher.onRequest('git.status', (p) => this.getStatus(p)) + this.dispatcher.onRequest('git.status', (p, context) => this.getStatus(p, context)) this.dispatcher.onRequest('git.submoduleStatus', (p) => this.getSubmoduleStatus(p)) this.dispatcher.onRequest('git.checkIgnored', (p) => this.checkIgnored(p)) this.dispatcher.onRequest('git.history', (p) => this.history(p)) @@ -281,6 +288,7 @@ export class GitHandler { private clearGitMutationReadCaches(): void { this.gitDiffReadDedupe.clear() + clearGitStatusLineStatsCache() clearSubmodulePathsCache(this.submodulePathsCache) } @@ -336,9 +344,9 @@ export class GitHandler { return stdout } - private async getStatus(params: Record) { + private async getStatus(params: Record, context: RequestContext) { this.gitDiffReadDedupe.clear() - return getStatusOp(this.git.bind(this), params) + return getStatusOp(this.git.bind(this), params, { signal: context.signal }) } // Why: the parent status only lists a single gitlink row per submodule. The @@ -1369,7 +1377,16 @@ export class GitHandler { const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { signal: context?.signal }) - return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) + const normalized = await this.normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout) + ) + // Why: this `-z`-unsupported fallback (Git <2.36) also serves Git + // <2.31, which emits no `prunable` annotation; probe each linked + // worktree path instead of treating stale registrations as live. + // On Git 2.31–2.35 the annotation is already parsed, so the probe + // is a harmless backstop (issue #8389). + return annotatePrunableWorktreesByExistence(normalized) } catch { return [] } @@ -1384,9 +1401,14 @@ export class GitHandler { } private async removeWorktree(params: Record) { - return this.runWithGitReadCacheClear(() => - removeWorktreeOp(this.git.bind(this), params, this.gitCapabilities) - ) + const remove = () => + this.runWithGitReadCacheClear(() => + removeWorktreeOp(this.git.bind(this), params, this.gitCapabilities) + ) + const worktreePath = params.worktreePath + return this.watcherRegistry && typeof worktreePath === 'string' + ? this.watcherRegistry.runWithRemovalFence(expandTilde(worktreePath), remove) + : remove() } private async worktreeIsClean(params: Record) { diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 289b05d6971..efab6bce51f 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -31,7 +31,12 @@ vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) -import { PtyHandler, attachIdentityMismatches } from './pty-handler' +import { + IMMEDIATE_PTY_EXIT_TIMEOUT_MS, + MAX_RELAY_PTY_SESSIONS, + PtyHandler, + attachIdentityMismatches +} from './pty-handler' import type { RelayDispatcher } from './dispatcher' function createMockDispatcher() { @@ -107,8 +112,10 @@ describe('PtyHandler', () => { handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) }) - afterEach(() => { - handler.dispose() + afterEach(async () => { + const cleanup = handler.dispose({ waitForPhysicalExit: false }) + await vi.runAllTimersAsync() + await cleanup.catch(() => {}) vi.useRealTimers() }) @@ -174,6 +181,37 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it('atomically caps concurrent PTY spawn admission', async () => { + const results = await Promise.allSettled( + Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, () => + dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + ) + ) + + expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength( + MAX_RELAY_PTY_SESSIONS + ) + expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1) + expect(results.find(({ status }) => status === 'rejected')).toMatchObject({ + reason: expect.objectContaining({ message: 'Maximum number of PTY sessions reached (50)' }) + }) + expect(mockPtySpawn).toHaveBeenCalledTimes(MAX_RELAY_PTY_SESSIONS) + expect(handler.activePtyCount).toBe(MAX_RELAY_PTY_SESSIONS) + }) + + it('spawns a PTY without post-Node-18 array copy methods', async () => { + const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'toReversed') + Reflect.deleteProperty(Array.prototype, 'toReversed') + try { + await expect(dispatcher.callRequest('pty.spawn', {})).resolves.toMatchObject({ id: 'pty-1' }) + expect(handler.activePtyCount).toBe(1) + } finally { + if (descriptor) { + Object.defineProperty(Array.prototype, 'toReversed', descriptor) + } + } + }) + it('guards SSH agent terminals after merging the relay inherited Git config', async () => { const gitConfigKeys = [ 'GIT_CONFIG_COUNT', @@ -741,6 +779,35 @@ describe('PtyHandler', () => { ).rejects.toThrow('PTY "pty-1" not found') }) + it('settles concurrent immediate shutdown when attach proves the shell exited', async () => { + const mockKill = vi.fn() + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + onData: vi.fn(), + onExit: vi.fn() + }) + await dispatcher.callRequest('pty.spawn', {}) + + let shutdown: Promise | undefined + const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockImplementation(() => { + shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + return false + }) + try { + await expect(dispatcher.callRequest('pty.attach', { id: 'pty-1' })).rejects.toThrow( + 'PTY "pty-1" not found' + ) + await expect(shutdown).resolves.toBeUndefined() + } finally { + aliveSpy.mockRestore() + } + + expect(mockKill).toHaveBeenCalledWith('SIGKILL') + expect(handler.activePtyCount).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + it('replays for a reattach whose backing shell is still alive', async () => { // Guard the other direction: a live pid must never be reaped, so a quiet // but live session still returns its buffered replay on reattach. @@ -769,8 +836,16 @@ describe('PtyHandler', () => { }) it('terminates spawned PTY when request becomes stale before response', async () => { + let onExitCb: ((evt: { exitCode: number }) => void) | undefined const killSpy = vi.fn() - const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() } + const term = { + ...mockPtyInstance, + kill: killSpy, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + } mockPtySpawn.mockReturnValue(term) await dispatcher.callRequest('pty.spawn', {}, { isStale: () => true }) @@ -783,6 +858,10 @@ describe('PtyHandler', () => { expect(killSpy).toHaveBeenCalledWith('SIGTERM') vi.advanceTimersByTime(5000) expect(killSpy).toHaveBeenCalledWith('SIGKILL') + expect(handler.activePtyCount).toBe(1) + + onExitCb?.({ exitCode: 137 }) + expect(handler.activePtyCount).toBe(0) }) it('does not submit provider-delivered commands for stale spawn responses', async () => { @@ -803,8 +882,16 @@ describe('PtyHandler', () => { }) it('releases pending provider-delivered commands on shutdown before delivery', async () => { + let onExitCb: ((evt: { exitCode: number }) => void) | undefined const killSpy = vi.fn() - const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() } + const term = { + ...mockPtyInstance, + kill: killSpy, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + } mockPtySpawn.mockReturnValue(term) await dispatcher.callRequest('pty.spawn', { @@ -813,7 +900,9 @@ describe('PtyHandler', () => { }) expect(handler.retainedStartupCommandCount).toBe(1) - await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + const shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + onExitCb!({ exitCode: 137 }) + await shutdown vi.advanceTimersByTime(50) expect(handler.retainedStartupCommandCount).toBe(0) @@ -1153,46 +1242,96 @@ describe('PtyHandler', () => { it('on immediate shutdown', async () => { await withWindowsPlatform(async () => { - const mockKill = mockKillablePty() + let onExitCb: ((evt: { exitCode: number }) => void) | undefined + const mockKill = vi.fn() + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + }) await dispatcher.callRequest('pty.spawn', {}) - await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + const shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + onExitCb!({ exitCode: 137 }) + await shutdown expectBareKills(mockKill, 1) }) }) - it('on stale-spawn cleanup including the SIGKILL fallback', async () => { + it('does not double-kill when immediate cleanup joins graceful shutdown', async () => { + await withWindowsPlatform(async () => { + let onExitCb: ((evt: { exitCode: number }) => void) | undefined + const mockKill = vi.fn() + const destroy = vi.fn() + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + destroy, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + }) + await dispatcher.callRequest('pty.spawn', {}) + await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false }) + const immediate = dispatcher.callRequest('pty.shutdown', { + id: 'pty-1', + immediate: true + }) + + expectBareKills(mockKill, 1) + onExitCb!({ exitCode: 137 }) + await immediate + expectBareKills(mockKill, 1) + expect(destroy).not.toHaveBeenCalled() + }) + }) + + it('does not retry stale-spawn cleanup after the Windows kill deadline', async () => { await withWindowsPlatform(async () => { const mockKill = mockKillablePty() await dispatcher.callRequest('pty.spawn', {}, { isStale: () => true }) expectBareKills(mockKill, 1) vi.advanceTimersByTime(5000) - expectBareKills(mockKill, 2) + expectBareKills(mockKill, 1) }) }) - it('on graceful-shutdown SIGKILL fallback', async () => { + it('does not retry graceful shutdown after the Windows kill deadline', async () => { await withWindowsPlatform(async () => { const mockKill = mockKillablePty() await dispatcher.callRequest('pty.spawn', {}) await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false }) expectBareKills(mockKill, 1) vi.advanceTimersByTime(5000) - expectBareKills(mockKill, 2) + expectBareKills(mockKill, 1) }) }) it('on dispose', async () => { await withWindowsPlatform(async () => { - const mockKill = mockKillablePty() + const mockKill = vi.fn() + const destroy = vi.fn() + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + destroy, + onData: vi.fn(), + onExit: vi.fn() + }) await dispatcher.callRequest('pty.spawn', {}) - handler.dispose() + await handler.dispose({ waitForPhysicalExit: false }) expectBareKills(mockKill, 1) + expect(destroy).not.toHaveBeenCalled() }) }) }) it('flushes pending PTY output before immediate shutdown cleanup', async () => { let dataCallback: ((data: string) => void) | undefined + let onExitCb: ((evt: { exitCode: number }) => void) | undefined const mockKill = vi.fn() mockPtySpawn.mockReturnValue({ ...mockPtyInstance, @@ -1200,12 +1339,16 @@ describe('PtyHandler', () => { onData: vi.fn((cb: (data: string) => void) => { dataCallback = cb }), - onExit: vi.fn() + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) }) await dispatcher.callRequest('pty.spawn', {}) dataCallback!('last words') - await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + const shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + onExitCb!({ exitCode: 137 }) + await shutdown expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'pty.data', { id: 'pty-1', @@ -1231,26 +1374,69 @@ describe('PtyHandler', () => { await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-fallback:0' } }) await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false }) vi.advanceTimersByTime(5000) + + expect(handler.activePtyCount).toBe(1) + expect(exits).toEqual([]) + expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.exit', expect.anything()) onExitCb!({ exitCode: 137 }) expect(mockKill).toHaveBeenCalledWith('SIGTERM') expect(mockKill).toHaveBeenCalledWith('SIGKILL') - expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { id: 'pty-1', code: -1 }) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { id: 'pty-1', code: 137 }) expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-fallback:0' }]) expect(handler.activePtyCount).toBe(0) }) + it('retries a rejected graceful SIGKILL fallback while retaining ownership', async () => { + let onExitCb: ((evt: { exitCode: number }) => void) | undefined + let forceAttempts = 0 + const mockKill = vi.fn((signal: string) => { + if (signal === 'SIGKILL' && forceAttempts++ === 0) { + throw new Error('transient kill failure') + } + }) + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + }) + + await dispatcher.callRequest('pty.spawn', {}) + await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false }) + vi.advanceTimersByTime(5000) + + expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) + expect(handler.activePtyCount).toBe(1) + expect(vi.getTimerCount()).toBe(1) + + vi.runOnlyPendingTimers() + expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGKILL']]) + expect(handler.activePtyCount).toBe(1) + + onExitCb!({ exitCode: 137 }) + expect(handler.activePtyCount).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + it('kills PTY on shutdown with SIGKILL when immediate', async () => { + let onExitCb: ((evt: { exitCode: number }) => void) | undefined const mockKill = vi.fn() mockPtySpawn.mockReturnValue({ ...mockPtyInstance, kill: mockKill, onData: vi.fn(), - onExit: vi.fn() + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) }) await dispatcher.callRequest('pty.spawn', {}) - await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + const shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + onExitCb!({ exitCode: 137 }) + await shutdown expect(mockKill).toHaveBeenCalledWith('SIGKILL') }) @@ -1535,6 +1721,43 @@ describe('PtyHandler', () => { expect(spawnEnv.env.TERM).toBe('xterm-256color') }) + it('admits default-cwd spawns through the worktree removal coordinator', async () => { + const finishCreation = vi.fn() + const beginWorktreePtySpawn = vi.fn((_operationPath: string) => finishCreation) + handler.setWorktreeRemovalCoordinator({ beginWorktreePtySpawn }) + + await dispatcher.callRequest('pty.spawn', {}) + + expect(beginWorktreePtySpawn).toHaveBeenCalledWith(expect.any(String)) + expect(beginWorktreePtySpawn.mock.calls[0][0]).not.toBe('') + expect(finishCreation).toHaveBeenCalledTimes(1) + }) + + it('fences both sibling worktree identity and removing cwd with rollback', async () => { + const finishSiblingAdmission = vi.fn() + const beginWorktreePtySpawn = vi.fn((operationPath: string) => { + if (operationPath === '/repo/removing/nested') { + throw new Error('Remote worktree deletion already in progress') + } + return finishSiblingAdmission + }) + handler.setWorktreeRemovalCoordinator({ beginWorktreePtySpawn }) + + await expect( + dispatcher.callRequest('pty.spawn', { + cwd: '/repo/removing/nested', + env: { ORCA_WORKTREE_ID: 'repo-id::/repo/sibling' } + }) + ).rejects.toThrow('Remote worktree deletion already in progress') + + expect(beginWorktreePtySpawn.mock.calls.map(([operationPath]) => operationPath)).toEqual([ + '/repo/sibling', + '/repo/removing/nested' + ]) + expect(finishSiblingAdmission).toHaveBeenCalledOnce() + expect(mockPtySpawn).not.toHaveBeenCalled() + }) + it('lets relay env augmenters resolve the original sequenced startup command hint', async () => { handler.addEnvAugmenter((ctx) => ({ SEEN_LAUNCH_COMMAND_HINT: resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) ?? '' @@ -1620,7 +1843,7 @@ describe('PtyHandler', () => { }) const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string - handler.dispose() + await handler.dispose({ waitForPhysicalExit: false }) mockPtySpawn.mockClear() dispatcher = createMockDispatcher() handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) @@ -1646,6 +1869,91 @@ describe('PtyHandler', () => { expect(callArgs.env.TERM_PROGRAM).toBe('Orca') }) + it('fences both revived worktree identity and cwd with rollback', async () => { + const finishSiblingAdmission = vi.fn() + const beginWorktreePtySpawn = vi.fn((operationPath: string) => { + if (operationPath === '/repo/removing/nested') { + throw new Error('Remote worktree deletion already in progress') + } + return finishSiblingAdmission + }) + handler.setWorktreeRemovalCoordinator({ beginWorktreePtySpawn }) + const state = JSON.stringify([ + { + id: 'pty-7', + pid: process.pid, + cols: 80, + rows: 24, + cwd: '/repo/removing/nested', + worktreeId: 'repo-id::/repo/sibling' + } + ]) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await expect(dispatcher.callRequest('pty.revive', { state })).rejects.toThrow( + 'Remote worktree deletion already in progress' + ) + } finally { + killSpy.mockRestore() + } + + expect(beginWorktreePtySpawn.mock.calls.map(([operationPath]) => operationPath)).toEqual([ + '/repo/sibling', + '/repo/removing/nested' + ]) + expect(finishSiblingAdmission).toHaveBeenCalledOnce() + expect(mockPtySpawn).not.toHaveBeenCalled() + }) + + it('applies the physical PTY cap to untrusted revive state', async () => { + const state = JSON.stringify( + Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, (_, index) => ({ + id: `pty-${index + 1}`, + pid: process.pid, + cols: 80, + rows: 24, + cwd: '/repo', + worktreeId: 'repo-id::/repo' + })) + ) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await expect(dispatcher.callRequest('pty.revive', { state })).rejects.toThrow( + 'Maximum number of PTY sessions reached (50)' + ) + } finally { + killSpy.mockRestore() + } + + expect(mockPtySpawn).toHaveBeenCalledTimes(MAX_RELAY_PTY_SESSIONS) + expect(handler.activePtyCount).toBe(MAX_RELAY_PTY_SESSIONS) + }) + + it('deduplicates concurrent revive requests for the same physical PTY id', async () => { + const state = JSON.stringify([ + { + id: 'pty-7', + pid: process.pid, + cols: 80, + rows: 24, + cwd: '/repo', + worktreeId: 'repo-id::/repo' + } + ]) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await Promise.all([ + dispatcher.callRequest('pty.revive', { state }), + dispatcher.callRequest('pty.revive', { state }) + ]) + } finally { + killSpy.mockRestore() + } + + expect(mockPtySpawn).toHaveBeenCalledTimes(1) + expect(handler.activePtyCount).toBe(1) + }) + it('revive preserves the credential guard chosen for an SSH agent terminal', async () => { await dispatcher.callRequest('pty.spawn', { command: 'claude' @@ -1728,7 +2036,7 @@ describe('PtyHandler', () => { expect(serialized.explicitTerm).toBeUndefined() expect(serialized.envToDelete).toEqual(['ORCA_ATTRIBUTION_SHIM_DIR']) - handler.dispose() + await handler.dispose({ waitForPhysicalExit: false }) mockPtySpawn.mockClear() dispatcher = createMockDispatcher() handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) @@ -1801,7 +2109,7 @@ describe('PtyHandler', () => { const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) try { - handler.dispose() + await handler.dispose({ waitForPhysicalExit: false }) mockPtySpawn.mockClear() dispatcher = createMockDispatcher() handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) @@ -1825,7 +2133,7 @@ describe('PtyHandler', () => { } ]) - handler.dispose() + await handler.dispose({ waitForPhysicalExit: false }) mockPtySpawn.mockClear() dispatcher = createMockDispatcher() handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) @@ -1899,7 +2207,7 @@ describe('PtyHandler', () => { } const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string - handler.dispose() + await handler.dispose({ waitForPhysicalExit: false }) mockPtySpawn.mockClear() dispatcher = createMockDispatcher() handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) @@ -1957,7 +2265,7 @@ describe('PtyHandler', () => { expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-2:1' }]) }) - it('immediate shutdown invokes the exit listener once even if onExit arrives later', async () => { + it('keeps immediate shutdown pending until onExit and invokes the exit listener once', async () => { let onExitCb: ((evt: { exitCode: number }) => void) | undefined const mockKill = vi.fn() mockPtySpawn.mockReturnValue({ @@ -1974,21 +2282,219 @@ describe('PtyHandler', () => { await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-shutdown:0' } }) - await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + let settled = false + const shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + void shutdown.then(() => { + settled = true + }) + await Promise.resolve() + + expect(settled).toBe(false) + expect(exits).toEqual([]) + expect(handler.activePtyCount).toBe(1) onExitCb!({ exitCode: 0 }) + await shutdown expect(mockKill).toHaveBeenCalledWith('SIGKILL') expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-shutdown:0' }]) expect(handler.activePtyCount).toBe(0) }) - it('dispose kills all PTYs with SIGKILL and invokes exit listeners', async () => { + it('physically stops only matching worktree PTYs before relay deletion', async () => { + let firstExit: ((evt: { exitCode: number }) => void) | undefined + let secondExit: ((evt: { exitCode: number }) => void) | undefined + const firstKill = vi.fn() + const secondKill = vi.fn() + mockPtySpawn + .mockReturnValueOnce({ + ...mockPtyInstance, + kill: firstKill, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + firstExit = cb + }) + }) + .mockReturnValueOnce({ + ...mockPtyInstance, + kill: secondKill, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + secondExit = cb + }) + }) + + await dispatcher.callRequest('pty.spawn', { + cwd: '/repo', + env: { ORCA_WORKTREE_ID: 'repo-id::/repo' } + }) + await dispatcher.callRequest('pty.spawn', { + cwd: '/sibling', + env: { ORCA_WORKTREE_ID: 'repo-id::/sibling' } + }) + + let settled = false + const shutdown = handler.shutdownForWorktreePath('/repo').finally(() => { + settled = true + }) + await Promise.resolve() + expect(firstKill).toHaveBeenCalledWith('SIGKILL') + expect(secondKill).not.toHaveBeenCalled() + expect(settled).toBe(false) + expect(handler.activePtyCount).toBe(2) + + firstExit?.({ exitCode: 137 }) + await shutdown + expect(secondExit).toBeDefined() + expect(handler.activePtyCount).toBe(1) + }) + + it('rejects timed-out immediate shutdown while retaining the physical owner', async () => { + let onExitCb: ((evt: { exitCode: number }) => void) | undefined const mockKill = vi.fn() mockPtySpawn.mockReturnValue({ ...mockPtyInstance, kill: mockKill, onData: vi.fn(), - onExit: vi.fn() + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + }) + + await dispatcher.callRequest('pty.spawn', {}) + const shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + const rejected = expect(shutdown).rejects.toThrow('Timed out waiting for PTY process exit') + await vi.advanceTimersByTimeAsync(IMMEDIATE_PTY_EXIT_TIMEOUT_MS) + await rejected + + expect(mockKill).toHaveBeenCalledTimes(1) + expect(handler.activePtyCount).toBe(1) + const retry = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + expect(mockKill).toHaveBeenCalledTimes(1) + onExitCb!({ exitCode: 137 }) + await retry + expect(handler.activePtyCount).toBe(0) + }) + + it('fences late creation and drains an admitted spawn before the disposal snapshot', async () => { + let onExitCb: ((evt: { exitCode: number }) => void) | undefined + const mockKill = vi.fn() + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + }) + + const admittedSpawn = dispatcher.callRequest('pty.spawn', {}) + const dispose = handler.dispose() + await admittedSpawn + + expect(mockKill).toHaveBeenCalledWith('SIGKILL') + expect(handler.activePtyCount).toBe(1) + await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow( + 'PTY handler is shutting down' + ) + const aliveSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await expect( + dispatcher.callRequest('pty.revive', { + state: JSON.stringify([ + { id: 'pty-late', pid: process.pid, cols: 80, rows: 24, cwd: '/tmp' } + ]) + }) + ).rejects.toThrow('PTY handler is shutting down') + } finally { + aliveSpy.mockRestore() + } + + onExitCb!({ exitCode: 137 }) + await dispose + expect(handler.activePtyCount).toBe(0) + expect(handler.dispose()).toBe(dispose) + }) + + it('retries a rejected force kill during dispose and waits for physical exit', async () => { + let onExitCb: ((evt: { exitCode: number }) => void) | undefined + let forceAttempts = 0 + const mockKill = vi.fn((signal: string) => { + if (signal === 'SIGKILL' && forceAttempts++ === 0) { + throw new Error('transient dispose kill failure') + } + }) + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + }) + + await dispatcher.callRequest('pty.spawn', {}) + const dispose = handler.dispose() + await Promise.resolve() + + expect(mockKill.mock.calls).toEqual([['SIGKILL']]) + expect(handler.activePtyCount).toBe(1) + await vi.advanceTimersByTimeAsync(250) + expect(mockKill.mock.calls).toEqual([['SIGKILL'], ['SIGKILL']]) + expect(handler.activePtyCount).toBe(1) + + onExitCb!({ exitCode: 137 }) + await dispose + expect(handler.activePtyCount).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('takes ownership when dispose overlaps a queued graceful force-kill retry', async () => { + let onExitCb: ((evt: { exitCode: number }) => void) | undefined + let forceAttempts = 0 + const mockKill = vi.fn((signal: string) => { + if (signal === 'SIGKILL' && forceAttempts++ < 2) { + throw new Error('transient overlapping kill failure') + } + }) + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + }) + + await dispatcher.callRequest('pty.spawn', {}) + await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false }) + vi.advanceTimersByTime(5000) + expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) + expect(vi.getTimerCount()).toBe(1) + + const dispose = handler.dispose() + await Promise.resolve() + expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGKILL']]) + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(250) + expect(mockKill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGKILL'], ['SIGKILL']]) + expect(handler.activePtyCount).toBe(1) + + onExitCb!({ exitCode: 137 }) + await dispose + expect(handler.activePtyCount).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('dispose kills all PTYs with SIGKILL and invokes exit listeners', async () => { + const mockKill = vi.fn() + const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = [] + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + kill: mockKill, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCallbacks.push(cb) + }) }) const exits: { id: string; paneKey?: string }[] = [] handler.setExitListener((evt) => exits.push(evt)) @@ -1997,12 +2503,18 @@ describe('PtyHandler', () => { await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dispose:1' } }) expect(handler.activePtyCount).toBe(2) - handler.dispose() + const dispose = handler.dispose() + await Promise.resolve() // Why: dispose uses SIGKILL (not SIGTERM) because the relay process is // exiting. A SIGTERM-ignoring remote shell (editor with unsaved buffers, // wedged process, uninterruptible sleep) would survive SIGTERM + immediate // destroy() as an orphan on the remote host. SIGKILL is not ignorable. expect(mockKill).toHaveBeenCalledWith('SIGKILL') + expect(handler.activePtyCount).toBe(2) + for (const onExit of onExitCallbacks) { + onExit({ exitCode: 137 }) + } + await dispose expect(exits).toEqual([ { id: 'pty-1', paneKey: 'tab-dispose:0' }, { id: 'pty-2', paneKey: 'tab-dispose:1' } diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 09c62462fa9..c1de6809c15 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -18,6 +18,12 @@ import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery' import { buildStartupCommandSubmission } from '../shared/startup-command-submission' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison +} from '../shared/cross-platform-path' +import { splitWorktreeId } from '../shared/worktree-id' +import { PhysicalExitTracker } from '../shared/physical-exit-tracker' import { createShellReadyScanState, drainShellReadyHeldBytes, @@ -30,6 +36,7 @@ import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env' import { isTuiAgent } from '../shared/tui-agent-config' +import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' // Why: node-pty is a native addon that may not be installed on the remote. // Dynamic import keeps the require() lazy so loadPty() returns null gracefully @@ -60,9 +67,7 @@ type ManagedPty = { * entry-point calls into a clean "not found" error instead of a silent no-op * (POSIX proc.kill is neutralized inside disposeManagedPty). */ disposed?: boolean - /** True once external cleanup observers have been notified. Forced cleanup - * paths can run before node-pty emits onExit; this prevents duplicate - * overlay/cache cleanup if onExit arrives later. */ + /** True once external cleanup observers have been notified. */ exitListenerNotified?: boolean /** Renderer-supplied paneKey from spawn env (ORCA_PANE_KEY). Captured so * external observers (the relay-hook-server cache) can evict per-pane @@ -78,6 +83,9 @@ type ManagedPty = { envToDelete: string[] gitCredentialPromptGuarded: boolean startupCommand?: ManagedStartupCommand + physicalExit?: PhysicalExitTracker + forceKillSent?: boolean + gracefulKillSent?: boolean } type PendingPtyOutput = { @@ -101,9 +109,20 @@ function killPtyProcess(pty: IPty, signal: string): void { pty.kill() return } + if (signal === 'SIGKILL') { + forceKillPosixPtyProcessGroups(pty.pid, () => pty.kill(signal)) + return + } pty.kill(signal) } +function finishPtyCreationOperations(operations: readonly (() => void)[]): void { + // Why: the relay still targets Node 18, which lacks Array.prototype.toReversed. + for (let index = operations.length - 1; index >= 0; index--) { + operations[index]() + } +} + function disposeManagedPty(managed: ManagedPty): void { if (managed.disposed) { return @@ -126,6 +145,10 @@ function disposeManagedPty(managed: ManagedPty): void { // IS a kill() call via _deferNoArgs — neutralizing it leaks the ConPTY agent. if (process.platform !== 'win32') { ;(managed.pty as unknown as { kill: (sig?: string) => void }).kill = () => {} + } else if (managed.gracefulKillSent || managed.forceKillSent) { + // Why: WindowsTerminal.destroy() calls kill() internally; any prior bare + // kill already closed ConPTY, so destroy would double-close the handle. + return } try { ;(managed.pty as unknown as { destroy?: () => void }).destroy?.() @@ -134,6 +157,8 @@ function disposeManagedPty(managed: ManagedPty): void { } } const DEFAULT_GRACE_TIME_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 +export const IMMEDIATE_PTY_EXIT_TIMEOUT_MS = 8_000 +export const MAX_RELAY_PTY_SESSIONS = 50 export const REPLAY_BUFFER_MAX = 100 * 1024 const PTY_OUTPUT_BATCH_INTERVAL_MS = 8 const PTY_OUTPUT_DRAIN_CONTINUE_MS = 1 @@ -145,6 +170,8 @@ const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 const STARTUP_COMMAND_WRITE_DELAY_MS = 50 const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500 +const PTY_FORCE_KILL_RETRY_DELAY_MS = 250 +const PTY_FORCE_KILL_MAX_ATTEMPTS = 2 const ALLOWED_SIGNALS = new Set([ 'SIGINT', 'SIGTERM', @@ -187,6 +214,7 @@ type PtyProcessSummary = { id: string cwd: string title: string + worktreeId?: string terminalHandle?: string } @@ -248,6 +276,10 @@ export type PtyEnvAugmenter = (ctx: { command?: string }) => Record +export type RelayPtyWorktreeRemovalCoordinator = { + beginWorktreePtySpawn(operationPath: string): () => void +} + export class PtyHandler { private ptys = new Map() private nextId = 1 @@ -258,6 +290,12 @@ export class PtyHandler { private pendingOutputByPty = new Map() private lastInputAtByPty = new Map() private interactiveOutputCharsByPty = new Map() + private pendingSpawnCount = 0 + private pendingReviveIds = new Set() + private creationFenced = false + private pendingCreationDrainResolvers = new Set<() => void>() + private worktreeRemovalCoordinator: RelayPtyWorktreeRemovalCoordinator | null = null + private disposePromise: Promise | null = null // Why: external observers need to drop per-pane state when a PTY exits. // Today the relay composes multiple consumers (hook-server cache eviction // and plugin-overlay dir cleanup) into a single callback at the call site @@ -282,6 +320,25 @@ export class PtyHandler { this.graceTimeMs = Math.max(0, Math.floor(graceTimeMs)) } + setWorktreeRemovalCoordinator(coordinator: RelayPtyWorktreeRemovalCoordinator | null): void { + this.worktreeRemovalCoordinator = coordinator + } + + async shutdownForWorktreePath(rootPath: string): Promise { + const matchingIds = [...this.ptys.values()] + .filter((managed) => { + const ownedPath = managed.worktreeId + ? splitWorktreeId(managed.worktreeId)?.worktreePath + : undefined + return ( + (ownedPath !== undefined && isPathInsideOrEqual(rootPath, ownedPath)) || + isPathInsideOrEqual(rootPath, managed.initialCwd) + ) + }) + .map((managed) => managed.id) + await Promise.all(matchingIds.map((id) => this.shutdown({ id, immediate: true }))) + } + get configuredGraceTimeMs(): number { return this.graceTimeMs } @@ -422,6 +479,7 @@ export class PtyHandler { /** Wire onData/onExit listeners for a managed PTY and store it. */ private wireAndStore(managed: ManagedPty): void { + managed.physicalExit = new PhysicalExitTracker() this.ptys.set(managed.id, managed) managed.pty.onData((data: string) => { const startup = managed.startupCommand @@ -436,6 +494,7 @@ export class PtyHandler { this.enqueuePtyOutput(managed.id, data) }) managed.pty.onExit(({ exitCode }: { exitCode: number }) => { + managed.physicalExit?.markExited() if (managed.disposed) { return } @@ -475,9 +534,7 @@ export class PtyHandler { } managed.exitListenerNotified = true // Why: external observers own relay-hook cache eviction and plugin-overlay - // cleanup. Natural exits, immediate shutdown, SIGKILL fallback, and relay - // process disposal all need the same cleanup even when node-pty never - // delivers onExit. + // cleanup. Physical exits and whole-relay disposal both need it exactly once. if (this.exitListener) { try { this.exitListener({ id: managed.id, paneKey: managed.paneKey }) @@ -609,13 +666,81 @@ export class PtyHandler { this.clearOutputFlushTimerIfIdle() } + private beginPtyCreation(operationPaths: readonly (string | undefined)[]): () => void { + if (this.creationFenced) { + throw new Error('PTY handler is shutting down') + } + const distinctPaths = new Map() + for (const operationPath of operationPaths) { + if (operationPath) { + distinctPaths.set(normalizeRuntimePathForComparison(operationPath), operationPath) + } + } + const finishRemovalOperations: (() => void)[] = [] + try { + if (this.worktreeRemovalCoordinator) { + for (const operationPath of distinctPaths.values()) { + finishRemovalOperations.push( + this.worktreeRemovalCoordinator.beginWorktreePtySpawn(operationPath) + ) + } + } + if (this.ptys.size + this.pendingSpawnCount >= MAX_RELAY_PTY_SESSIONS) { + throw new Error('Maximum number of PTY sessions reached (50)') + } + } catch (error) { + // Why: worktree identity and cwd can belong to different roots. A later + // rejection must release every earlier admission before propagating. + finishPtyCreationOperations(finishRemovalOperations) + throw error + } + this.pendingSpawnCount++ + let finished = false + return () => { + if (finished) { + return + } + finished = true + this.pendingSpawnCount-- + if (this.pendingSpawnCount === 0) { + for (const resolve of this.pendingCreationDrainResolvers) { + resolve() + } + this.pendingCreationDrainResolvers.clear() + } + finishPtyCreationOperations(finishRemovalOperations) + } + } + + private waitForPendingPtyCreations(): Promise { + if (this.pendingSpawnCount === 0) { + return Promise.resolve() + } + return new Promise((resolve) => { + this.pendingCreationDrainResolvers.add(resolve) + }) + } + private async spawn( params: Record, context?: RequestContext ): Promise<{ id: string }> { - if (this.ptys.size >= 50) { - throw new Error('Maximum number of PTY sessions reached (50)') + const env = params.env as Record | undefined + const worktreeId = env?.ORCA_WORKTREE_ID + const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined + const cwd = typeof params.cwd === 'string' ? params.cwd : resolveDefaultCwd() + const finishCreation = this.beginPtyCreation([worktreePath, cwd]) + try { + return await this.spawnAfterAdmission(params, context) + } finally { + finishCreation() } + } + + private async spawnAfterAdmission( + params: Record, + context?: RequestContext + ): Promise<{ id: string }> { const pty = await loadPty() if (!pty) { throw new Error('node-pty is not available on this remote host') @@ -638,7 +763,10 @@ export class PtyHandler { typeof params.shellOverride === 'string' ? params.shellOverride.trim() : '' const resolvedShellOverride = resolvePtyShellOverride(shellOverride) const shell = resolvedShellOverride || resolveDefaultShell() - const id = `pty-${this.nextId++}` + let id: string + do { + id = `pty-${this.nextId++}` + } while (this.ptys.has(id) || this.pendingReviveIds.has(id)) // Why: server-side augmenter values (ORCA_AGENT_HOOK_* and plugin overlay // dirs) override renderer-supplied env so live remote paths and hook coords @@ -742,20 +870,7 @@ export class PtyHandler { // response is discarded and no renderer can own this PTY. Shut it down // immediately so it does not linger as an unreachable remote shell. this.releaseStartupCommand(managed) - killPtyProcess(term, 'SIGTERM') - managed.killTimer = setTimeout(() => { - const still = this.ptys.get(id) - if (still && !still.disposed) { - killPtyProcess(still.pty, 'SIGKILL') - // Why: stale-spawn cleanup has no client who will ever attach. If - // SIGKILL's onExit is missed (kernel edge case, uninterruptible - // sleep), the managed entry + ptmx fd would leak forever. Dispose - // synchronously so the entry is gone regardless of onExit timing. - this.notifyExitListener(still) - disposeManagedPty(still) - this.ptys.delete(id) - } - }, 5000) + this.requestGracefulKill(managed, 'terminate stale') } else if (managed.startupCommand) { this.scheduleStartupCommandDelivery( managed, @@ -787,6 +902,7 @@ export class PtyHandler { // drops the dead lease and spawns fresh — the same recovery path an expired // grace window already takes. if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) { + managed.physicalExit?.markExited() this.notifyExitListener(managed) disposeManagedPty(managed) this.ptys.delete(id) @@ -871,58 +987,14 @@ export class PtyHandler { if (immediate) { this.releaseStartupCommand(managed) this.flushPtyOutput(id) - killPtyProcess(managed.pty, 'SIGKILL') - // Why: SIGKILL has already reaped the child; release the ptmx fd on the - // same tick. Deferring to onExit leaves a window where the fd is live - // with a dead child. Idempotent via the disposed guard — if onExit fires - // later and also calls disposeManagedPty, the second call is a no-op. - disposeManagedPty(managed) - // Why: mirror the graceful-shutdown killTimer cleanup. If SIGKILL's - // onExit never fires (kernel edge case: uninterruptible sleep, - // D-state child on a bad NFS mount), the disposed managed entry would - // linger in this.ptys forever. Each stranded entry consumes a slot in - // the 50-PTY cap and is returned by listProcesses/serialize. Deleting - // here makes the map hygiene a hard guarantee, not "hopefully onExit - // runs". If onExit DOES fire later, its own `this.ptys.delete(id)` is - // a no-op. - this.notifyExitListener(managed) - this.ptys.delete(id) - this.clearPtyFlowState(id) + this.requestForceKill(managed) + // Why: remote Git deletion must not race native handles still owned by + // an uninterruptible child. Timeout rejects but deliberately keeps the + // map entry so a later onExit or retry retains the physical owner. + await this.waitForPhysicalExit(managed, IMMEDIATE_PTY_EXIT_TIMEOUT_MS) } else { this.releaseStartupCommand(managed) - killPtyProcess(managed.pty, 'SIGTERM') - - // Why: Some processes ignore SIGTERM (e.g. a hung child, a custom signal - // handler). Without a SIGKILL fallback the PTY process would leak and the - // managed entry would never be cleaned up. The 5-second window gives - // well-behaved processes time to flush and exit gracefully. The timer is - // cleared in the onExit handler if the process terminates on its own. - // Do NOT call disposeManagedPty here: destroy()-right-after-SIGTERM - // collapses the graceful-shutdown window and risks interrupting shell - // EXIT traps. Fd release happens via onExit (natural exit) or via the - // killTimer → SIGKILL → disposeManagedPty chain below. - managed.killTimer = setTimeout(() => { - const still = this.ptys.get(id) - if (still && !still.disposed) { - killPtyProcess(still.pty, 'SIGKILL') - this.flushPtyOutput(id) - // Why: emit pty.exit BEFORE disposeManagedPty sets disposed=true. - // The natural onExit short-circuits on `managed.disposed`, so - // without this notify the renderer never learns the pane is dead - // when the SIGKILL fallback fires for a SIGTERM-ignoring child. - this.dispatcher.notify('pty.exit', { id, code: -1 }) - // Why: if SIGKILL's onExit never fires (kernel edge case, - // uninterruptible sleep, child wedged on a bad NFS mount), the - // fd and map entry would leak forever. Dispose synchronously so - // graceful-shutdown's SIGKILL fallback is a hard guarantee, not - // "hopefully onExit will run". The disposed guard inside - // disposeManagedPty makes a later onExit's dispose a no-op. - this.notifyExitListener(still) - disposeManagedPty(still) - this.ptys.delete(id) - this.clearPtyFlowState(id) - } - }, 5000) + this.requestGracefulKill(managed, 'force-kill') } } @@ -942,6 +1014,90 @@ export class PtyHandler { managed.pty.kill(signal) } + private waitForPhysicalExit(managed: ManagedPty, timeoutMs: number): Promise { + const physicalExit = managed.physicalExit + if (!physicalExit) { + return Promise.reject(new Error(`PTY "${managed.id}" exit tracking unavailable`)) + } + return physicalExit.waitForExit( + timeoutMs, + () => new Error(`Timed out waiting for PTY process exit: ${managed.id}`) + ) + } + + private requestGracefulKill( + managed: ManagedPty, + fallbackAction: 'terminate stale' | 'force-kill' + ): void { + if (managed.gracefulKillSent) { + return + } + managed.gracefulKillSent = true + if (process.platform === 'win32') { + // Why: bare node-pty kill is already force-final on ConPTY, so no later + // fallback, immediate cleanup, or destroy may close the handle again. + managed.forceKillSent = true + } + try { + killPtyProcess(managed.pty, 'SIGTERM') + } catch (error) { + managed.gracefulKillSent = false + managed.forceKillSent = false + throw error + } + if (process.platform === 'win32') { + return + } + // Why: POSIX children may ignore SIGTERM; only onExit releases ownership + // after the bounded SIGKILL fallback. + this.armForceKillFallback(managed, fallbackAction, 5000, PTY_FORCE_KILL_MAX_ATTEMPTS) + } + + private armForceKillFallback( + managed: ManagedPty, + fallbackAction: 'terminate stale' | 'force-kill', + delayMs: number, + attemptsRemaining: number + ): void { + managed.killTimer = setTimeout(() => { + managed.killTimer = undefined + const still = this.ptys.get(managed.id) + if (!still || still.disposed) { + return + } + try { + this.requestForceKill(still) + } catch (error) { + process.stderr.write( + `[pty-handler] failed to ${fallbackAction} PTY ${managed.id}: ${error instanceof Error ? error.message : String(error)}\n` + ) + // Why: a transient native SIGKILL failure must not strand an + // unreachable remote shell after the only cleanup owner returned. + if (attemptsRemaining > 1 && this.ptys.get(still.id) === still && !still.disposed) { + this.armForceKillFallback( + still, + fallbackAction, + PTY_FORCE_KILL_RETRY_DELAY_MS, + attemptsRemaining - 1 + ) + } + } + }, delayMs) + } + + private requestForceKill(managed: ManagedPty): void { + if (managed.forceKillSent || (process.platform === 'win32' && managed.gracefulKillSent)) { + return + } + managed.forceKillSent = true + try { + killPtyProcess(managed.pty, 'SIGKILL') + } catch (error) { + managed.forceKillSent = false + throw error + } + } + private async getCwd(params: Record): Promise { const id = params.id as string const managed = this.ptys.get(id) @@ -995,6 +1151,7 @@ export class PtyHandler { id, cwd: managed.initialCwd, title, + ...(managed.worktreeId ? { worktreeId: managed.worktreeId } : {}), ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } @@ -1034,7 +1191,7 @@ export class PtyHandler { const entries = JSON.parse(state) as SerializedPtyEntry[] for (const entry of entries) { - if (this.ptys.has(entry.id)) { + if (this.ptys.has(entry.id) || this.pendingReviveIds.has(entry.id)) { continue } // Only re-attach if the original process is still alive @@ -1043,97 +1200,91 @@ export class PtyHandler { } catch { continue } - const ptyMod = await loadPty() - if (!ptyMod) { - continue + const ownedPath = entry.worktreeId + ? splitWorktreeId(entry.worktreeId)?.worktreePath + : undefined + const finishCreation = this.beginPtyCreation([ownedPath, entry.cwd]) + this.pendingReviveIds.add(entry.id) + try { + await this.reviveEntry(entry) + } finally { + this.pendingReviveIds.delete(entry.id) + finishCreation() } - // Why: revive must apply the same hook env as spawn(). The hook-server - // coords come from augmenters, while pane identity comes from the - // serialized PTY entry because managed hook scripts exit without - // ORCA_PANE_KEY. - const revivedEnv: Record = {} - if (entry.paneKey) { - revivedEnv.ORCA_PANE_KEY = entry.paneKey - } - if (entry.tabId) { - revivedEnv.ORCA_TAB_ID = entry.tabId - } - if (entry.worktreeId) { - revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId - } - if (entry.terminalHandle) { - revivedEnv.ORCA_TERMINAL_HANDLE = entry.terminalHandle - } - const explicitTerm = - typeof entry.explicitTerm === 'string' && entry.explicitTerm.length > 0 - ? entry.explicitTerm - : undefined - if (explicitTerm !== undefined) { - revivedEnv.TERM = explicitTerm - } - // Why: serialized state can come from an older or untrusted client, so - // revive reapplies the same bounds as a fresh spawn before retaining it. - const envToDelete = sanitizeEnvToDelete(entry.envToDelete) - const shell = resolveDefaultShell() - // Why: `command` is intentionally absent from this revive path because - // SerializedPtyEntry (see line 99) does not persist it — ManagedPty - // never stored the renderer-chosen launch command. The Pi/OMP extension - // installer in src/relay/relay.ts therefore sees `ctx.command === - // undefined` for revived PTYs and prepares the Pi default plus OMP's - // typed-command wrapper. Plumbing `command` through serialization is a - // separate, larger change. - const spawnEnv = this.buildSpawnEnv( - revivedEnv, - { - id: entry.id, - paneKey: entry.paneKey, - shell - }, - envToDelete - ) - // Why: revive lacks the original launch command, so preserve the guard - // decision made at fresh spawn. Legacy state remains an ordinary shell. - const gitCredentialPromptGuarded = entry.gitCredentialPromptGuarded === true - if (gitCredentialPromptGuarded) { - Object.assign(spawnEnv, gitCredentialPromptGuardEnv(spawnEnv, process.platform)) - } - const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv) - const term = ptyMod.spawn(shell, shellLaunch.args, { - // Why: revive must preserve the same terminal identity as fresh spawn. - name: spawnEnv.TERM ?? 'xterm-256color', - cols: entry.cols, - rows: entry.rows, - cwd: entry.cwd, - // Why: revived shells should not inherit an ambient shell-ready marker - // because no provider-delivered startup command is waiting on it. - env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } - }) - this.wireAndStore({ - id: entry.id, - pty: term, - initialCwd: entry.cwd, - buffered: '', - paneKey: entry.paneKey, - tabId: entry.tabId, - attachIdentity: entry.attachIdentity, - worktreeId: entry.worktreeId, - ...(explicitTerm !== undefined ? { explicitTerm } : {}), - envToDelete, - gitCredentialPromptGuarded, - ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) - }) + } + } - // Why: nextId starts at 1 and is only incremented by spawn(). Revived - // PTYs carry their original IDs (e.g. "pty-3"), so without this bump the - // next spawn() would generate an ID that collides with an already-active - // revived PTY. - const match = entry.id.match(/^pty-(\d+)$/) - if (match) { - const revivedNum = Number.parseInt(match[1], 10) - if (revivedNum >= this.nextId) { - this.nextId = revivedNum + 1 - } - } + private async reviveEntry(entry: SerializedPtyEntry): Promise { + const ptyMod = await loadPty() + if (!ptyMod) { + return + } + // Why: revive must apply the same hook env as spawn(). The hook-server + // coords come from augmenters, while pane identity comes from the + // serialized PTY entry because managed hook scripts exit without + // ORCA_PANE_KEY. + const revivedEnv: Record = {} + if (entry.paneKey) { + revivedEnv.ORCA_PANE_KEY = entry.paneKey + } + if (entry.tabId) { + revivedEnv.ORCA_TAB_ID = entry.tabId + } + if (entry.worktreeId) { + revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId + } + if (entry.terminalHandle) { + revivedEnv.ORCA_TERMINAL_HANDLE = entry.terminalHandle + } + const explicitTerm = + typeof entry.explicitTerm === 'string' && entry.explicitTerm.length > 0 + ? entry.explicitTerm + : undefined + if (explicitTerm !== undefined) { + revivedEnv.TERM = explicitTerm + } + // Why: serialized state can come from an older or untrusted client, so + // revive reapplies the same bounds as a fresh spawn before retaining it. + const envToDelete = sanitizeEnvToDelete(entry.envToDelete) + const shell = resolveDefaultShell() + const spawnEnv = this.buildSpawnEnv( + revivedEnv, + { id: entry.id, paneKey: entry.paneKey, shell }, + envToDelete + ) + // Why: revive lacks the original launch command, so preserve the guard + // decision made at fresh spawn. Legacy state remains an ordinary shell. + const gitCredentialPromptGuarded = entry.gitCredentialPromptGuarded === true + if (gitCredentialPromptGuarded) { + Object.assign(spawnEnv, gitCredentialPromptGuardEnv(spawnEnv, process.platform)) + } + const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv) + const term = ptyMod.spawn(shell, shellLaunch.args, { + name: spawnEnv.TERM ?? 'xterm-256color', + cols: entry.cols, + rows: entry.rows, + cwd: entry.cwd, + // Why: no provider-delivered command is waiting for a ready marker. + env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } + }) + this.wireAndStore({ + id: entry.id, + pty: term, + initialCwd: entry.cwd, + buffered: '', + paneKey: entry.paneKey, + tabId: entry.tabId, + attachIdentity: entry.attachIdentity, + worktreeId: entry.worktreeId, + ...(explicitTerm !== undefined ? { explicitTerm } : {}), + envToDelete, + gitCredentialPromptGuarded, + ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) + }) + + const match = entry.id.match(/^pty-(\d+)$/) + if (match) { + this.nextId = Math.max(this.nextId, Number.parseInt(match[1], 10) + 1) } } @@ -1157,8 +1308,28 @@ export class PtyHandler { } } - dispose(): void { + dispose(options: { waitForPhysicalExit?: boolean } = {}): Promise { + // Why: fence creation synchronously before the first await so a spawn or + // revive cannot appear after the disposal snapshot and escape process exit. + this.creationFenced = true + if (this.disposePromise) { + return this.disposePromise + } + const disposePromise = this.disposePtys(options.waitForPhysicalExit !== false) + this.disposePromise = disposePromise + void disposePromise.catch(() => { + // Why: a rejected native kill retains ownership so a later shutdown + // signal can retry instead of joining a permanently rejected promise. + if (this.disposePromise === disposePromise) { + this.disposePromise = null + } + }) + return disposePromise + } + + private async disposePtys(waitForPhysicalExit: boolean): Promise { this.cancelGraceTimer() + await this.waitForPendingPtyCreations() if (this.outputFlushTimer !== null) { clearTimeout(this.outputFlushTimer) this.outputFlushTimer = null @@ -1166,28 +1337,76 @@ export class PtyHandler { this.pendingOutputByPty.clear() this.lastInputAtByPty.clear() this.interactiveOutputCharsByPty.clear() - for (const [, managed] of this.ptys) { - if (managed.killTimer) { - clearTimeout(managed.killTimer) - managed.killTimer = undefined - } - this.clearStartupCommandTimer(managed) - // Why: SIGKILL (not SIGTERM) before destroy. The relay process is - // exiting; any SIGTERM-ignoring remote shell (editor with unsaved - // buffers, a hung child with a bad handler, a process in - // uninterruptible sleep) would survive SIGTERM + immediate destroy() - // as an orphan on the remote host. SIGKILL is not ignorable and the - // ptmx fd release via disposeManagedPty is synchronous, so there is - // no graceful-shutdown window to preserve at this point. + const results = await Promise.allSettled( + [...this.ptys.values()].map((managed) => + this.disposePtyForRelayShutdown(managed, waitForPhysicalExit) + ) + ) + const rejected = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + if (rejected) { + throw rejected.reason + } + } + + private async disposePtyForRelayShutdown( + managed: ManagedPty, + waitForPhysicalExit: boolean + ): Promise { + if (managed.killTimer) { + clearTimeout(managed.killTimer) + managed.killTimer = undefined + } + this.clearStartupCommandTimer(managed) + // Why: relay exit must retain the native owner until SIGKILL is accepted + // (with one bounded retry) or onExit proves the process is already gone. + await this.requestForceKillForRelayShutdown(managed) + if (waitForPhysicalExit && this.ptys.get(managed.id) === managed && !managed.disposed) { try { - killPtyProcess(managed.pty, 'SIGKILL') + await this.waitForPhysicalExit(managed, IMMEDIATE_PTY_EXIT_TIMEOUT_MS) } catch { - /* child may already be dead */ + // An accepted SIGKILL is the bounded final boundary when a child is + // uninterruptible and cannot report exit before relay shutdown. } + } + if (this.ptys.get(managed.id) === managed && !managed.disposed) { this.notifyExitListener(managed) disposeManagedPty(managed) + this.ptys.delete(managed.id) + this.clearPtyFlowState(managed.id) } - this.ptys.clear() + } + + private async requestForceKillForRelayShutdown(managed: ManagedPty): Promise { + let lastError: unknown + for (let attempt = 0; attempt < PTY_FORCE_KILL_MAX_ATTEMPTS; attempt++) { + if (this.ptys.get(managed.id) !== managed || managed.disposed) { + return + } + try { + this.requestForceKill(managed) + return + } catch (error) { + lastError = error + } + if (attempt + 1 < PTY_FORCE_KILL_MAX_ATTEMPTS) { + const tracker = managed.physicalExit + if (!tracker) { + throw lastError + } + try { + await tracker.waitForExit( + PTY_FORCE_KILL_RETRY_DELAY_MS, + () => new Error(`Retrying force-kill for PTY ${managed.id}`) + ) + return + } catch { + // The bounded waiter detached; retry the still-owned native handle. + } + } + } + throw lastError } get activePtyCount(): number { diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index 7c505ae652d..9a21fb9eace 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -13,12 +13,13 @@ import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import { createRelayWatcherProcessPool } from './relay-watcher-process-pool' type InstalledWatch = { + rootPath: string callback: WatcherProcessCallback hooks: WatcherProcessHooks unsubscribe: ReturnType Promise>> } -type InstalledSupervisorWatch = InstalledWatch & { dir: string } +type InstalledSupervisorWatch = Omit & { dir: string } function stubRelayWatcherSupervisors() { const installed = new Map() @@ -47,13 +48,13 @@ class FakeWatcherPool { readonly forgetRoot = vi.fn() async subscribe( - _rootPath: string, + rootPath: string, callback: WatcherProcessCallback, _options: object, hooks: WatcherProcessHooks ): Promise { const unsubscribe = vi.fn(async () => undefined) - this.installed.push({ callback, hooks, unsubscribe }) + this.installed.push({ rootPath, callback, hooks, unsubscribe }) return { unsubscribe } } } @@ -62,6 +63,7 @@ function createDispatcher() { const detached = new Set<(clientId: number) => void>() return { notify: vi.fn(), + notifyClient: vi.fn(), onClientDetached: vi.fn((listener: (clientId: number) => void) => { detached.add(listener) return () => detached.delete(listener) @@ -127,6 +129,33 @@ describe('RelayFilesystemWatchRegistry', () => { expect(pool.installed[1].unsubscribe).toHaveBeenCalledTimes(1) }) + it('notifies each owning client when bounded recovery terminates a live watch', async () => { + await registry.watch('/repo', context(1), 101) + await registry.watch('/repo', context(2), 202) + const first = pool.installed[0] + vi.spyOn(pool, 'subscribe').mockRejectedValueOnce(new Error('quarantine recovery failed')) + + first.hooks.onTerminalError?.( + new WatcherProcessFailure( + 'file watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ) + ) + + await vi.waitFor(() => expect(dispatcher.notifyClient).toHaveBeenCalledTimes(2)) + expect(dispatcher.notifyClient).toHaveBeenNthCalledWith(1, 1, 'fs.watchFailed', { + rootPath: '/repo', + watchId: 101, + message: 'quarantine recovery failed' + }) + expect(dispatcher.notifyClient).toHaveBeenNthCalledWith(2, 2, 'fs.watchFailed', { + rootPath: '/repo', + watchId: 202, + message: 'quarantine recovery failed' + }) + }) + it('aborts a pending crawl only after the last same-root client leaves', async () => { let sharedSignal: AbortSignal | undefined let rejectSubscribe: ((error: Error) => void) | undefined @@ -169,6 +198,149 @@ describe('RelayFilesystemWatchRegistry', () => { expect(sharedSignal?.aborted).toBe(true) expect(rejectSubscribe).toBeDefined() }) + + it('propagates unexpected non-Error setup failures', async () => { + vi.spyOn(pool, 'subscribe').mockRejectedValueOnce('native setup failed') + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + try { + await expect(registry.watch('/repo', context(1))).rejects.toBe('native setup failed') + } finally { + stderr.mockRestore() + } + }) + + it('rejects acknowledged teardown while another client still owns the watch', async () => { + await registry.watch('/repo', context(1)) + await registry.watch('/repo', context(2)) + const unsubscribe = pool.installed[0].unsubscribe + + await expect(registry.unwatchAndWait('/repo', context(1))).rejects.toThrow( + 'still watched by another client' + ) + expect(unsubscribe).not.toHaveBeenCalled() + + registry.unwatch('/repo', context(1)) + expect(unsubscribe).not.toHaveBeenCalled() + registry.unwatch('/repo', context(2)) + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('notifies old clients and fences new ones while removal owns the root', async () => { + await registry.watch('/repo', context(1), 101) + let failRemoval: (error: Error) => void = () => undefined + const removal = registry.runWithRemovalFence( + '/repo', + () => + new Promise((_resolve, reject) => { + failRemoval = reject + }) + ) + await vi.waitFor(() => expect(pool.installed[0].unsubscribe).toHaveBeenCalledTimes(1)) + expect(dispatcher.notifyClient).toHaveBeenCalledWith(1, 'fs.watchFailed', { + rootPath: '/repo', + watchId: 101, + message: 'Remote worktree is being removed' + }) + + await expect(registry.watch('/repo', context(2))).rejects.toThrow( + 'deletion already in progress' + ) + failRemoval(new Error('Git removal failed')) + await expect(removal).rejects.toThrow('Git removal failed') + + await expect(registry.watch('/repo', context(2))).resolves.toBeUndefined() + }) + + it('waits admitted PTY creation, rejects late creation, then tears down before removal', async () => { + const finishPtyCreation = registry.beginWorktreePtySpawn('/repo/nested') + const order: string[] = [] + registry.setWorktreePtyTeardown(async (rootPath) => { + order.push(`pty:${rootPath}`) + }) + + const removal = registry.runWithRemovalFence('/repo', async () => { + order.push('remove') + }) + await Promise.resolve() + expect(order).toEqual([]) + expect(() => registry.beginWorktreePtySpawn('/repo/late')).toThrow( + 'deletion already in progress' + ) + + finishPtyCreation() + await removal + expect(order).toEqual(['pty:/repo', 'remove']) + }) + + it.each([ + ['drive spelling', 'C:\\Repo', 'c:/repo/'], + ['UNC spelling', '\\\\Server\\Share\\Repo', '//server/share/repo/'] + ])('shares one Windows root across equivalent %s', async (_label, firstPath, secondPath) => { + await registry.watch(firstPath, context(1)) + await registry.watch(secondPath, context(2)) + + expect(pool.installed).toHaveLength(1) + expect(pool.installed[0].rootPath).toBe(firstPath) + await expect(registry.unwatchAndWait(secondPath, context(1))).rejects.toThrow( + 'still watched by another client' + ) + + registry.unwatch(secondPath, context(1)) + await registry.unwatchAndWait(firstPath, context(2)) + + expect(pool.installed[0].unsubscribe).toHaveBeenCalledTimes(1) + expect(pool.forgetRoot).toHaveBeenCalledWith(firstPath) + }) + + it('keeps literal POSIX backslash roots physically distinct', async () => { + await registry.watch('/srv/team\\repo', context(1)) + await registry.watch('/srv/team/repo', context(2)) + + expect(pool.installed.map(({ rootPath }) => rootPath)).toEqual([ + '/srv/team\\repo', + '/srv/team/repo' + ]) + + await registry.unwatchAndWait('/srv/team\\repo', context(1)) + await registry.unwatchAndWait('/srv/team/repo', context(2)) + }) + + it('retains pending-setup teardown failure until the child physically exits', async () => { + let resolveSubscribe: (subscription: WatcherProcessSubscription) => void = () => undefined + let resolvePhysicalExit: () => void = () => undefined + const physicalExit = new Promise((resolve) => { + resolvePhysicalExit = resolve + }) + const terminationError = new WatcherProcessFailure( + 'file watcher process did not exit after termination deadline', + 'supervisor', + 'process_unavailable', + physicalExit + ) + const unsubscribe = vi.fn().mockRejectedValue(terminationError) + vi.spyOn(pool, 'subscribe').mockImplementation( + () => + new Promise((resolve) => { + resolveSubscribe = resolve + }) + ) + + const watch = registry.watch('/repo', context(1)) + const close = registry.unwatchAndWait('/repo', context(1)) + resolveSubscribe({ unsubscribe }) + + await expect(watch).rejects.toBe(terminationError) + await expect(close).rejects.toBe(terminationError) + await expect(registry.unwatchAndWait('/repo', context(1))).rejects.toBe(terminationError) + expect(pool.forgetRoot).not.toHaveBeenCalled() + + resolvePhysicalExit() + await physicalExit + await Promise.resolve() + + await expect(registry.unwatchAndWait('/repo', context(1))).resolves.toBeUndefined() + expect(pool.forgetRoot).toHaveBeenCalledWith('/repo') + }) }) describe('createRelayWatcherProcessPool', () => { diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts index a1ccda05826..4acb786fa23 100644 --- a/src/relay/relay-filesystem-watch-registry.ts +++ b/src/relay/relay-filesystem-watch-registry.ts @@ -1,10 +1,6 @@ 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 @@ -13,118 +9,204 @@ import { createRelayWatcherProcessPool, type RelayWatcherProcessPool } from './relay-watcher-process-pool' +import { emitRelayWatcherEvents, emitRelayWatcherOverflow } from './relay-watcher-event-emitter' +import { awaitRelayWatcherSetup, shouldRetryInitialRelayWatch } from './relay-watcher-setup-wait' +import { + RelayWatcherTeardownTracker, + type RelayWatcherTeardownState +} from './relay-watcher-teardown-tracker' +import { emitRelayWatcherTerminalFailure } from './relay-watcher-terminal-notifier' +import { assertRelayWatcherRootCapacity } from './relay-watcher-root-capacity' +import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' +import { + trackRelayWatcherSetup, + type RelayWatcherPendingSetup +} from './relay-watcher-setup-tracking' +import { RelayWatcherRemovalFence } from './relay-watcher-removal-fence' +import { releaseStaleRelayWatches } from './relay-watcher-stale-client-release' +import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' +import { joinRelayWatcherPendingSetup } from './relay-watcher-pending-setup-join' +import { createRelayWatcherState } from './relay-watcher-state' -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() + private readonly watches = new Map() + private readonly pendingSetups = new Map() + private readonly teardownTracker: RelayWatcherTeardownTracker + private readonly removalFence: RelayWatcherRemovalFence constructor( private readonly dispatcher: RelayDispatcher, private readonly watcherPool: RelayWatcherProcessPool = createRelayWatcherProcessPool() ) { + this.teardownTracker = new RelayWatcherTeardownTracker((rootPath) => + this.watcherPool.forgetRoot(rootPath) + ) + this.removalFence = new RelayWatcherRemovalFence( + this.watches, + this.pendingSetups, + this.teardownTracker, + this.dispatcher + ) this.dispatcher.onClientDetached?.((clientId) => this.releaseClientWatches(clientId)) } - async watch(rootPath: string, context?: RequestContext): Promise { - this.releaseStaleWatches() + watch(rootPath: string, context?: RequestContext, watchId?: number): Promise { + const rootKey = normalizeRuntimePathForComparison(rootPath) + if (this.removalFence.isActive(rootKey)) { + return Promise.reject(new Error('Remote worktree deletion already in progress')) + } + const existing = this.watches.get(rootKey) + if (existing) { + const hasLiveClient = [...existing.clients.values()].some((isStale) => !isStale()) + if (hasLiveClient) { + const clientId = context?.clientId ?? 0 + existing.clients.set(clientId, context?.isStale ?? (() => false)) + if (watchId !== undefined) { + existing.clientWatchIds.set(clientId, watchId) + } + return this.awaitSetupForClient(existing, clientId, context) + } + void this.closeWatch(existing).catch(() => {}) + } + const pendingSetup = this.pendingSetups.get(rootKey) + if (pendingSetup) { + const retry = (): Promise => this.watch(rootPath, context, watchId) + return joinRelayWatcherPendingSetup(pendingSetup, context?.signal, retry) + } + const setup = this.installWatch(rootKey, rootPath, context, watchId) + return trackRelayWatcherSetup(this.pendingSetups, rootKey, setup) + } + + private async installWatch( + rootKey: string, + rootPath: string, + context?: RequestContext, + watchId?: number + ): Promise { + const staleTeardown = releaseStaleRelayWatches(this.watches.values(), (state) => + this.closeWatch(state) + ) + if (staleTeardown) { + await staleTeardown + } + const rootTeardown = this.teardownTracker.join(rootKey) + if (rootTeardown) { + await rootTeardown + } const clientId = context?.clientId ?? 0 const isStale = context?.isStale ?? (() => false) - const existing = this.watches.get(rootPath) + const existing = this.watches.get(rootKey) if (existing) { existing.clients.set(clientId, isStale) + if (watchId !== undefined) { + existing.clientWatchIds.set(clientId, watchId) + } await this.awaitSetupForClient(existing, clientId, context) return } - if (this.watches.size >= MAX_RELAY_WATCH_ROOTS) { - throw new Error('Maximum number of file watchers reached') - } + assertRelayWatcherRootCapacity( + this.watches.keys(), + this.pendingSetups.keys(), + this.teardownTracker.rootPaths(), + rootKey + ) - 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) + const state = createRelayWatcherState(rootKey, rootPath, clientId, isStale, watchId) + this.watches.set(rootKey, state) + state.setupWaiters = new PromiseSettlementWaiters(this.startInitialWatch(state)) await this.awaitSetupForClient(state, clientId, context) } unwatch(rootPath: string, context?: RequestContext): void { - const state = this.watches.get(rootPath) + const rootKey = normalizeRuntimePathForComparison(rootPath) + const state = this.watches.get(rootKey) if (state) { this.releaseWatchClient(state, context?.clientId ?? 0) + return } + const setup = this.pendingSetups.get(rootKey) + if (setup) { + // Why: notification teardown must not disappear before async setup publishes ownership. + void setup.promise.then(() => this.unwatch(rootPath, context)).catch(() => {}) + } + } + + async unwatchAndWait(rootPath: string, context?: RequestContext): Promise { + const rootKey = normalizeRuntimePathForComparison(rootPath) + let state = this.watches.get(rootKey) + if (!state) { + await this.pendingSetups.get(rootKey)?.promise.catch(() => undefined) + state = this.watches.get(rootKey) + } + if (!state) { + const failed = this.teardownTracker.failedState(rootKey) + if (failed) { + await this.closeWatch(failed) + return + } + await this.teardownTracker.join(rootKey) + return + } + const clientId = context?.clientId ?? 0 + for (const [registeredClientId, isStale] of state.clients) { + if (registeredClientId !== clientId && isStale()) { + state.clients.delete(registeredClientId) + state.clientWatchIds.delete(registeredClientId) + } + } + if ([...state.clients.keys()].some((registeredClientId) => registeredClientId !== clientId)) { + // Why: destructive cleanup cannot acknowledge while another client owns the handle. + throw new Error('Remote path is still watched by another client') + } + state.clients.delete(clientId) + state.clientWatchIds.delete(clientId) + await this.closeWatch(state) + } + + async runWithRemovalFence(rootPath: string, operation: () => Promise): Promise { + return this.removalFence.run(normalizeRuntimePathForComparison(rootPath), operation) + } + + beginWorktreePtySpawn(path: string): () => void { + return this.removalFence.beginOperation(normalizeRuntimePathForComparison(path)) + } + + setWorktreePtyTeardown(teardown: (rootPath: string) => Promise): void { + this.removalFence.setBeforeRemove(teardown) } dispose(): void { - for (const state of Array.from(this.watches.values())) { - this.closeWatch(state) - } + this.watches.forEach((state) => void this.closeWatch(state).catch(() => {})) this.watcherPool.dispose() } - private async startInitialWatch(state: RelayWatchState): Promise { + private async startInitialWatch(state: RelayWatcherTeardownState): Promise { try { await this.subscribeState(state) } catch (firstError) { - if (!state.closed && shouldRetryInitialWatch(firstError)) { + if (!state.closed && shouldRetryInitialRelayWatch(firstError)) { try { await this.subscribeState(state) - this.emitOverflow(state) + emitRelayWatcherOverflow(this.dispatcher, state.rootPath, state.closed) return } catch (quarantineError) { - this.closeWatch(state) + void this.closeWatch(state).catch(() => {}) throw quarantineError } } - this.closeWatch(state) + void this.closeWatch(state).catch(() => {}) throw firstError } } - private subscribeState(state: RelayWatchState): Promise { + private subscribeState(state: RelayWatcherTeardownState): Promise { const generation = ++state.generation const emitOverflow = (): void => { if (state.generation === generation) { - this.emitOverflow(state) + emitRelayWatcherOverflow(this.dispatcher, state.rootPath, state.closed) } } return this.watcherPool @@ -141,7 +223,7 @@ export class RelayFilesystemWatchRegistry { emitOverflow() return } - this.emitEvents(state, events) + emitRelayWatcherEvents(this.dispatcher, state.closed, events) }, RELAY_WATCH_OPTIONS, { @@ -150,14 +232,14 @@ export class RelayFilesystemWatchRegistry { onOverflow: emitOverflow, onTerminalError: (error) => this.recoverWatch(state, generation, error), signal: state.abortController.signal, - subscribeTimeoutMs: RELAY_WATCH_CRAWL_TIMEOUT_MS + subscribeTimeoutMs: 60_000 } ) .then(async (subscription) => { if ( state.closed || state.generation !== generation || - this.watches.get(state.rootPath) !== state + this.watches.get(state.rootKey) !== state ) { await subscription.unsubscribe() return @@ -166,147 +248,71 @@ export class RelayFilesystemWatchRegistry { }) } - private recoverWatch(state: RelayWatchState, failedGeneration: number, error: Error): void { + private recoverWatch( + state: RelayWatcherTeardownState, + failedGeneration: number, + error: Error + ): void { if (state.closed || state.generation !== failedGeneration) { return } state.subscription = null - this.emitOverflow(state) + emitRelayWatcherOverflow(this.dispatcher, state.rootPath, state.closed) const recovery = this.subscribeState(state) - state.setupPromise = recovery + state.setupWaiters = new PromiseSettlementWaiters(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) + emitRelayWatcherTerminalFailure(this.dispatcher, state, message) + void this.closeWatch(state).catch(() => {}) } }) } - 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, + state: RelayWatcherTeardownState, clientId: number, context?: RequestContext ): Promise { try { - await this.awaitSetupWithAbort(state.setupPromise, context?.signal) + await awaitRelayWatcherSetup(state.setupWaiters, 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 + if (expectedAbort) { + return } - return + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`[relay] File watcher not available for ${state.rootPath}: ${message}\n`) + throw error } if (context?.isStale()) { this.releaseWatchClient(state, clientId) } } - private awaitSetupWithAbort(setupPromise: Promise, signal?: AbortSignal): Promise { - if (!signal) { - return setupPromise + private releaseClientWatches(clientId: number): void { + this.watches.forEach((state) => this.releaseWatchClient(state, clientId)) + } + + private releaseWatchClient(state: RelayWatcherTeardownState, clientId: number): void { + state.clients.delete(clientId) + state.clientWatchIds.delete(clientId) + if (!state.closed && state.clients.size === 0) { + void this.closeWatch(state).catch(() => {}) } - if (signal.aborted) { - return Promise.reject(createWatchAbortError()) - } - return new Promise((resolve, reject) => { - const onAbort = (): void => { - cleanup() - reject(createWatchAbortError()) + } + + private closeWatch(state: RelayWatcherTeardownState): Promise { + return this.teardownTracker.close(state, () => { + if (this.watches.get(state.rootKey) === state) { + this.watches.delete(state.rootKey) } - 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-event-emitter.ts b/src/relay/relay-watcher-event-emitter.ts new file mode 100644 index 00000000000..46515e67959 --- /dev/null +++ b/src/relay/relay-watcher-event-emitter.ts @@ -0,0 +1,31 @@ +import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' +import type { RelayDispatcher } from './dispatcher' + +export function emitRelayWatcherEvents( + dispatcher: RelayDispatcher, + closed: boolean, + events: readonly WatcherProcessEvent[] +): void { + if (closed || events.length === 0) { + return + } + dispatcher.notify('fs.changed', { + events: events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) + })) + }) +} + +export function emitRelayWatcherOverflow( + dispatcher: RelayDispatcher, + rootPath: string, + closed: boolean +): void { + if (!closed) { + dispatcher.notify('fs.changed', { + events: [{ kind: 'overflow', absolutePath: rootPath }] + }) + } +} diff --git a/src/relay/relay-watcher-parent-removal.test.ts b/src/relay/relay-watcher-parent-removal.test.ts new file mode 100644 index 00000000000..892759204e0 --- /dev/null +++ b/src/relay/relay-watcher-parent-removal.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + WatcherProcessHooks, + WatcherProcessSubscription +} from '../main/ipc/parcel-watcher-process-subscription' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' + +class ParentRemovalPool { + readonly subscriptions: { rootPath: string; unsubscribe: ReturnType }[] = [] + readonly dispose = vi.fn() + readonly forgetRoot = vi.fn() + + async subscribe( + rootPath: string, + _callback: unknown, + _options: unknown, + _hooks: WatcherProcessHooks + ): Promise { + const unsubscribe = vi.fn(async () => undefined) + this.subscriptions.push({ rootPath, unsubscribe }) + return { unsubscribe } + } +} + +function context(clientId: number): RequestContext { + return { clientId, isStale: () => false } +} + +describe('RelayFilesystemWatchRegistry parent removal', () => { + it.each([ + ['/repo', '/repo/nested', '/repo-sibling'], + ['C:\\Repo', 'c:\\repo\\nested', 'C:\\Repo-Sibling'] + ])( + 'closes descendant watches before deleting parent %s and preserves siblings', + async (parent, descendant, sibling) => { + const pool = new ParentRemovalPool() + const dispatcher = { notify: vi.fn(), notifyClient: vi.fn(), onClientDetached: vi.fn() } + const registry = new RelayFilesystemWatchRegistry( + dispatcher as unknown as RelayDispatcher, + pool + ) + await registry.watch(descendant, context(1), 101) + await registry.watch(sibling, context(2), 202) + const remove = vi.fn(async () => { + expect(pool.subscriptions[0].unsubscribe).toHaveBeenCalledOnce() + expect(pool.subscriptions[1].unsubscribe).not.toHaveBeenCalled() + }) + + await registry.runWithRemovalFence(parent, remove) + + expect(remove).toHaveBeenCalledOnce() + expect(dispatcher.notifyClient).toHaveBeenCalledWith(1, 'fs.watchFailed', { + rootPath: descendant, + watchId: 101, + message: 'Remote worktree is being removed' + }) + } + ) + + it('waits for a descendant setup and closes its published subscription before removal', async () => { + const pool = new ParentRemovalPool() + const dispatcher = { notify: vi.fn(), notifyClient: vi.fn(), onClientDetached: vi.fn() } + const registry = new RelayFilesystemWatchRegistry( + dispatcher as unknown as RelayDispatcher, + pool + ) + let releaseSetup: () => void = () => undefined + const setupGate = new Promise((resolve) => { + releaseSetup = resolve + }) + const subscribe = pool.subscribe.bind(pool) + vi.spyOn(pool, 'subscribe').mockImplementation(async (...args) => { + await setupGate + return subscribe(...args) + }) + + const watch = registry.watch('/repo/nested', context(1), 101) + const remove = vi.fn(async () => { + expect(pool.subscriptions[0].unsubscribe).toHaveBeenCalledOnce() + }) + const removal = registry.runWithRemovalFence('/repo', remove) + await Promise.resolve() + expect(remove).not.toHaveBeenCalled() + + releaseSetup() + await Promise.all([watch, removal]) + expect(remove).toHaveBeenCalledOnce() + }) + + it('waits for an already-retiring descendant before removal', async () => { + const pool = new ParentRemovalPool() + const dispatcher = { notify: vi.fn(), notifyClient: vi.fn(), onClientDetached: vi.fn() } + const registry = new RelayFilesystemWatchRegistry( + dispatcher as unknown as RelayDispatcher, + pool + ) + await registry.watch('/repo/nested', context(1), 101) + let releaseTeardown: () => void = () => undefined + pool.subscriptions[0].unsubscribe.mockImplementation( + () => + new Promise((resolve) => { + releaseTeardown = resolve + }) + ) + const teardown = registry.unwatchAndWait('/repo/nested', context(1)) + const remove = vi.fn(async () => undefined) + const removal = registry.runWithRemovalFence('/repo', remove) + await Promise.resolve() + expect(remove).not.toHaveBeenCalled() + + releaseTeardown() + await Promise.all([teardown, removal]) + expect(remove).toHaveBeenCalledOnce() + }) +}) diff --git a/src/relay/relay-watcher-pending-setup-join.ts b/src/relay/relay-watcher-pending-setup-join.ts new file mode 100644 index 00000000000..17c7cb5427a --- /dev/null +++ b/src/relay/relay-watcher-pending-setup-join.ts @@ -0,0 +1,21 @@ +import type { RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' +import { awaitRelayWatcherSetup } from './relay-watcher-setup-wait' + +export async function joinRelayWatcherPendingSetup( + pendingSetup: RelayWatcherPendingSetup, + signal: AbortSignal | undefined, + retry: () => Promise +): Promise { + try { + await awaitRelayWatcherSetup(pendingSetup, signal) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return + } + // Why: the previous caller's failure must not poison a later same-root + // attempt once its single serialized setup generation has released. + } + if (!signal?.aborted) { + await retry() + } +} diff --git a/src/relay/relay-watcher-pending-setup-waiters.test.ts b/src/relay/relay-watcher-pending-setup-waiters.test.ts new file mode 100644 index 00000000000..f3e6f8f897d --- /dev/null +++ b/src/relay/relay-watcher-pending-setup-waiters.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WatcherProcessSubscription } from '../main/ipc/parcel-watcher-process-subscription' +import type { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' + +class PendingSetupPool { + readonly installed: { unsubscribe: () => Promise }[] = [] + readonly dispose = vi.fn() + readonly forgetRoot = vi.fn() + + async subscribe(): Promise { + const subscription = { unsubscribe: vi.fn(async () => undefined) } + this.installed.push(subscription) + return subscription + } +} + +function context(clientId: number, signal?: AbortSignal): RequestContext { + return { clientId, isStale: () => false, signal } +} + +describe('RelayFilesystemWatchRegistry pending setup waiters', () => { + it('removes ten thousand aborted callers queued behind one teardown anchor', async () => { + const pool = new PendingSetupPool() + const dispatcher = { notify: vi.fn(), onClientDetached: vi.fn() } + const registry = new RelayFilesystemWatchRegistry( + dispatcher as unknown as RelayDispatcher, + pool + ) + await registry.watch('/repo', context(1)) + let releaseTeardown: () => void = () => undefined + pool.installed[0].unsubscribe = vi.fn( + () => + new Promise((resolve) => { + releaseTeardown = resolve + }) + ) + const teardown = registry.unwatchAndWait('/repo', context(1)) + const leader = registry.watch('/repo', context(2)) + const anchorController = new AbortController() + const anchor = registry.watch('/repo', context(3, anchorController.signal)) + const controllers = Array.from({ length: 10_000 }, () => new AbortController()) + const cancelled = controllers.map((controller, index) => + registry.watch('/repo', context(index + 4, controller.signal)) + ) + + for (const controller of controllers) { + controller.abort() + } + await Promise.all(cancelled) + + const pendingSetups = ( + registry as unknown as { pendingSetups: Map> } + ).pendingSetups + expect(pendingSetups.values().next().value?.waiterCount).toBe(1) + expect(pool.installed).toHaveLength(1) + releaseTeardown() + await Promise.all([teardown, leader, anchor]) + expect(pool.installed).toHaveLength(2) + expect(pendingSetups.size).toBe(0) + }) +}) diff --git a/src/relay/relay-watcher-removal-fence.ts b/src/relay/relay-watcher-removal-fence.ts new file mode 100644 index 00000000000..9b0b62ab780 --- /dev/null +++ b/src/relay/relay-watcher-removal-fence.ts @@ -0,0 +1,131 @@ +import type { + RelayWatcherTeardownState, + RelayWatcherTeardownTracker +} from './relay-watcher-teardown-tracker' +import type { RelayDispatcher } from './dispatcher' +import { emitRelayWatcherTerminalFailure } from './relay-watcher-terminal-notifier' +import { isPathInsideOrEqual } from '../shared/cross-platform-path' +import type { RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' + +export class RelayWatcherRemovalFence { + private readonly roots = new Set() + private readonly operations = new Map() + private readonly operationWaiters = new Map void>>() + private beforeRemove: ((rootKey: string) => Promise) | null = null + + constructor( + private readonly watches: Map, + private readonly pendingSetups: Map, + private readonly teardownTracker: RelayWatcherTeardownTracker, + private readonly dispatcher: RelayDispatcher + ) {} + + isActive(rootKey: string): boolean { + return [...this.roots].some((activeRoot) => isPathInsideOrEqual(activeRoot, rootKey)) + } + + setBeforeRemove(beforeRemove: (rootKey: string) => Promise): void { + this.beforeRemove = beforeRemove + } + + beginOperation(operationPath: string): () => void { + if ([...this.roots].some((rootKey) => isPathInsideOrEqual(rootKey, operationPath))) { + throw new Error('Remote worktree deletion already in progress') + } + this.operations.set(operationPath, (this.operations.get(operationPath) ?? 0) + 1) + let finished = false + return () => { + if (finished) { + return + } + finished = true + const remaining = (this.operations.get(operationPath) ?? 1) - 1 + if (remaining > 0) { + this.operations.set(operationPath, remaining) + } else { + this.operations.delete(operationPath) + } + this.resolveOperationWaiters() + } + } + + async run(rootKey: string, operation: () => Promise): Promise { + if ( + [...this.roots].some( + (activeRoot) => + isPathInsideOrEqual(activeRoot, rootKey) || isPathInsideOrEqual(rootKey, activeRoot) + ) + ) { + throw new Error('Remote worktree deletion already in progress') + } + this.roots.add(rootKey) + try { + await this.waitForOperations(rootKey) + await this.closeRoot(rootKey) + await this.beforeRemove?.(rootKey) + return await operation() + } finally { + this.roots.delete(rootKey) + } + } + + private hasOperationInside(rootKey: string): boolean { + return [...this.operations.keys()].some((operationPath) => + isPathInsideOrEqual(rootKey, operationPath) + ) + } + + private waitForOperations(rootKey: string): Promise { + if (!this.hasOperationInside(rootKey)) { + return Promise.resolve() + } + return new Promise((resolve) => { + const waiters = this.operationWaiters.get(rootKey) ?? new Set<() => void>() + waiters.add(resolve) + this.operationWaiters.set(rootKey, waiters) + }) + } + + private resolveOperationWaiters(): void { + for (const [rootKey, waiters] of this.operationWaiters) { + if (this.hasOperationInside(rootKey)) { + continue + } + this.operationWaiters.delete(rootKey) + for (const resolve of waiters) { + resolve() + } + } + } + + private async closeRoot(rootKey: string): Promise { + const pending = [...this.pendingSetups.entries()].filter(([setupRoot]) => + isPathInsideOrEqual(rootKey, setupRoot) + ) + await Promise.all(pending.map(([, setup]) => setup.promise.catch(() => undefined))) + const states = [...this.watches.entries()] + .filter(([watchRoot]) => isPathInsideOrEqual(rootKey, watchRoot)) + .map(([, state]) => state) + for (const state of states) { + emitRelayWatcherTerminalFailure(this.dispatcher, state, 'Remote worktree is being removed') + state.clients.clear() + state.clientWatchIds.clear() + } + await Promise.all(states.map((state) => this.closeWatch(state))) + const trackedRoots = this.teardownTracker + .rootPaths() + .filter((trackedRoot) => isPathInsideOrEqual(rootKey, trackedRoot)) + for (const trackedRoot of trackedRoots) { + const failed = this.teardownTracker.failedState(trackedRoot) + await (failed ? this.closeWatch(failed) : this.teardownTracker.join(trackedRoot)) + } + } + + private closeWatch(state: RelayWatcherTeardownState): Promise { + return this.teardownTracker.close(state, () => { + if (this.watches.get(state.rootKey) === state) { + this.watches.delete(state.rootKey) + } + }) + } +} diff --git a/src/relay/relay-watcher-root-capacity.ts b/src/relay/relay-watcher-root-capacity.ts new file mode 100644 index 00000000000..025462e7bd3 --- /dev/null +++ b/src/relay/relay-watcher-root-capacity.ts @@ -0,0 +1,14 @@ +const MAX_RELAY_WATCH_ROOTS = 20 + +export function assertRelayWatcherRootCapacity( + activeRoots: Iterable, + pendingRoots: Iterable, + teardownRoots: Iterable, + prospectiveRoot: string +): void { + const physicalRoots = new Set([...activeRoots, ...pendingRoots, ...teardownRoots]) + physicalRoots.add(prospectiveRoot) + if (physicalRoots.size > MAX_RELAY_WATCH_ROOTS) { + throw new Error('Maximum number of file watchers reached') + } +} diff --git a/src/relay/relay-watcher-setup-tracking.ts b/src/relay/relay-watcher-setup-tracking.ts new file mode 100644 index 00000000000..edf3d3cb976 --- /dev/null +++ b/src/relay/relay-watcher-setup-tracking.ts @@ -0,0 +1,19 @@ +import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' + +export type RelayWatcherPendingSetup = PromiseSettlementWaiters + +export function trackRelayWatcherSetup( + pendingSetups: Map, + rootKey: string, + setup: Promise +): Promise { + let tracked!: RelayWatcherPendingSetup + const trackedPromise = setup.finally(() => { + if (pendingSetups.get(rootKey) === tracked) { + pendingSetups.delete(rootKey) + } + }) + tracked = new PromiseSettlementWaiters(trackedPromise) + pendingSetups.set(rootKey, tracked) + return tracked.promise +} diff --git a/src/relay/relay-watcher-setup-wait.test.ts b/src/relay/relay-watcher-setup-wait.test.ts new file mode 100644 index 00000000000..69f31a4e59c --- /dev/null +++ b/src/relay/relay-watcher-setup-wait.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest' +import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' +import { awaitRelayWatcherSetup } from './relay-watcher-setup-wait' + +describe('awaitRelayWatcherSetup', () => { + it('removes ten thousand cancelled relay callers while one anchor remains', async () => { + let resolveSetup: () => void = () => undefined + const setupPromise = new Promise((resolve) => { + resolveSetup = resolve + }) + const thenSpy = vi.spyOn(setupPromise, 'then') + const setupWaiters = new PromiseSettlementWaiters(setupPromise) + const anchor = awaitRelayWatcherSetup(setupWaiters) + const controllers = Array.from({ length: 10_000 }, () => new AbortController()) + const cancelled = controllers.map((controller) => + awaitRelayWatcherSetup(setupWaiters, controller.signal).catch((error) => error) + ) + + for (const controller of controllers) { + controller.abort() + } + await Promise.all(cancelled) + + expect(setupWaiters.waiterCount).toBe(1) + expect(thenSpy).toHaveBeenCalledOnce() + resolveSetup() + await expect(anchor).resolves.toBeUndefined() + expect(setupWaiters.waiterCount).toBe(0) + }) +}) diff --git a/src/relay/relay-watcher-setup-wait.ts b/src/relay/relay-watcher-setup-wait.ts new file mode 100644 index 00000000000..a4c7b348b02 --- /dev/null +++ b/src/relay/relay-watcher-setup-wait.ts @@ -0,0 +1,28 @@ +import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' +import type { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' + +export function shouldRetryInitialRelayWatch(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 function awaitRelayWatcherSetup( + setupWaiters: PromiseSettlementWaiters, + signal?: AbortSignal +): Promise { + return setupWaiters.wait({ + signal, + createAbortError: createRelayWatchAbortError + }) +} + +function createRelayWatchAbortError(): Error { + const error = new Error('Request "fs.watch" was cancelled') + error.name = 'AbortError' + return error +} diff --git a/src/relay/relay-watcher-stale-client-release.ts b/src/relay/relay-watcher-stale-client-release.ts new file mode 100644 index 00000000000..959866d0447 --- /dev/null +++ b/src/relay/relay-watcher-stale-client-release.ts @@ -0,0 +1,20 @@ +import type { RelayWatcherTeardownState } from './relay-watcher-teardown-tracker' + +export function releaseStaleRelayWatches( + states: Iterable, + closeWatch: (state: RelayWatcherTeardownState) => Promise +): Promise | undefined { + const teardowns: Promise[] = [] + for (const state of Array.from(states)) { + for (const [clientId, isStale] of state.clients) { + if (isStale()) { + state.clients.delete(clientId) + state.clientWatchIds.delete(clientId) + } + } + if (state.clients.size === 0) { + teardowns.push(closeWatch(state)) + } + } + return teardowns.length > 0 ? Promise.all(teardowns).then(() => undefined) : undefined +} diff --git a/src/relay/relay-watcher-state.ts b/src/relay/relay-watcher-state.ts new file mode 100644 index 00000000000..90d6b978a8d --- /dev/null +++ b/src/relay/relay-watcher-state.ts @@ -0,0 +1,22 @@ +import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' +import type { RelayWatcherTeardownState } from './relay-watcher-teardown-tracker' + +export function createRelayWatcherState( + rootKey: string, + rootPath: string, + clientId: number, + isStale: () => boolean, + watchId?: number +): RelayWatcherTeardownState { + return { + clientWatchIds: watchId === undefined ? new Map() : new Map([[clientId, watchId]]), + rootKey, + rootPath, + clients: new Map([[clientId, isStale]]), + setupWaiters: new PromiseSettlementWaiters(Promise.resolve()), + subscription: null, + abortController: new AbortController(), + generation: 0, + closed: false + } +} diff --git a/src/relay/relay-watcher-teardown-tracker.ts b/src/relay/relay-watcher-teardown-tracker.ts new file mode 100644 index 00000000000..91b1460054d --- /dev/null +++ b/src/relay/relay-watcher-teardown-tracker.ts @@ -0,0 +1,109 @@ +import type { WatcherProcessSubscription } from '../main/ipc/parcel-watcher-process' +import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' +import type { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' + +export type RelayWatcherTeardownState = { + rootKey: string + rootPath: string + clients: Map boolean> + clientWatchIds: Map + setupWaiters: PromiseSettlementWaiters + subscription: WatcherProcessSubscription | null + abortController: AbortController + generation: number + closed: boolean +} + +export class RelayWatcherTeardownTracker { + private readonly pending = new Map>() + private readonly failed = new Map() + + constructor(private readonly forgetRoot: (rootPath: string) => void) {} + + close(state: RelayWatcherTeardownState, removeActiveState: () => void): Promise { + const pending = this.pending.get(state.rootKey) + if (pending) { + return pending + } + if (!state.closed) { + state.closed = true + state.generation++ + state.abortController.abort() + state.clients.clear() + state.clientWatchIds.clear() + removeActiveState() + } + const subscription = state.subscription + const teardown = subscription + ? callUnsubscribe(subscription) + : state.setupWaiters.promise.then(() => undefined) + let tracked: Promise + tracked = teardown + .then( + () => { + state.subscription = null + this.failed.delete(state.rootKey) + this.forgetRoot(state.rootPath) + }, + (error) => { + const physicalExit = isWatcherProcessFailure(error) ? error.physicalExit : undefined + if (!subscription && !physicalExit) { + this.failed.delete(state.rootKey) + this.forgetRoot(state.rootPath) + return + } + // Why: failed physical teardown must remain retryable and consume capacity. + this.failed.set(state.rootKey, { state, error }) + if (physicalExit) { + // Why: an unkillable child still owns native handles until its later + // physical exit, even when teardown began before setup published. + void physicalExit.then(() => { + const failed = this.failed.get(state.rootKey) + if (failed?.state === state && failed.error === error) { + this.failed.delete(state.rootKey) + this.forgetRoot(state.rootPath) + } + }) + } + throw error + } + ) + .finally(() => { + if (this.pending.get(state.rootKey) === tracked) { + this.pending.delete(state.rootKey) + } + }) + this.pending.set(state.rootKey, tracked) + return tracked + } + + join(rootKey: string): Promise | undefined { + const pending = this.pending.get(rootKey) + const failed = this.failed.get(rootKey) + if (!pending) { + return failed ? Promise.reject(failed.error) : undefined + } + return pending.then(() => { + const settledFailure = this.failed.get(rootKey) + if (settledFailure) { + throw settledFailure.error + } + }) + } + + failedState(rootKey: string): RelayWatcherTeardownState | undefined { + return this.failed.get(rootKey)?.state + } + + rootPaths(): string[] { + return [...this.pending.keys(), ...this.failed.keys()] + } +} + +function callUnsubscribe(subscription: WatcherProcessSubscription): Promise { + try { + return Promise.resolve(subscription.unsubscribe()) + } catch (error) { + return Promise.reject(error) + } +} diff --git a/src/relay/relay-watcher-terminal-notifier.ts b/src/relay/relay-watcher-terminal-notifier.ts new file mode 100644 index 00000000000..add044f4460 --- /dev/null +++ b/src/relay/relay-watcher-terminal-notifier.ts @@ -0,0 +1,32 @@ +import type { RelayDispatcher } from './dispatcher' + +type RelayTerminalWatchOwners = { + rootPath: string + clients: Map boolean> + clientWatchIds: Map +} + +export function emitRelayWatcherTerminalFailure( + dispatcher: RelayDispatcher, + state: RelayTerminalWatchOwners, + message: string +): void { + for (const [clientId, isStale] of state.clients) { + if (isStale()) { + state.clients.delete(clientId) + state.clientWatchIds.delete(clientId) + continue + } + const watchId = state.clientWatchIds.get(clientId) + if (watchId === undefined) { + continue + } + // Why: a shared relay may serve multiple Orca clients; only owners of + // this exact logical watch should invalidate their provider state. + dispatcher.notifyClient(clientId, 'fs.watchFailed', { + rootPath: state.rootPath, + watchId, + message + }) + } +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index d47c2019582..debef3b85e8 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -467,7 +467,10 @@ async function main(): Promise { const ptyHandler = new PtyHandler(dispatcher, graceTimeMs) const fsHandler = new FsHandler(dispatcher, context) - const gitHandler = new GitHandler(dispatcher, context) + const watchRegistry = fsHandler.getWatchRegistry() + ptyHandler.setWorktreeRemovalCoordinator(watchRegistry) + watchRegistry.setWorktreePtyTeardown((rootPath) => ptyHandler.shutdownForWorktreePath(rootPath)) + const gitHandler = new GitHandler(dispatcher, context, watchRegistry) const _preflightHandler = new PreflightHandler(dispatcher) const _externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) @@ -1014,25 +1017,41 @@ async function main(): Promise { }) } + let shutdownInFlight = false function shutdown(): void { + if (shutdownInFlight) { + return + } + shutdownInFlight = true relayLogLine( `[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}` ) graceDeadlineAt = null graceReason = null - dispatcher.dispose() - ptyHandler.dispose() - fsHandler.dispose() - gitHandler.dispose() - hookServer.stop() - // Why: Node's Unix server.close() can unlink the listen path. If the path - // was externally removed and rebound by a newer relay, closing this older - // server would strand the newer daemon behind a missing socket. - if (socketServer && ownsCurrentSocketPath()) { - socketServer.close() - } - cleanupOwnedSocket() - process.exit(0) + void ptyHandler + .dispose() + .then(() => { + dispatcher.dispose() + fsHandler.dispose() + gitHandler.dispose() + hookServer.stop() + // Why: Node's Unix server.close() can unlink the listen path. If the path + // was externally removed and rebound by a newer relay, closing this older + // server would strand the newer daemon behind a missing socket. + if (socketServer && ownsCurrentSocketPath()) { + socketServer.close() + } + cleanupOwnedSocket() + process.exit(0) + }) + .catch((error) => { + // Why: keep owning a PTY whose native kill was rejected; exiting here + // would turn a transient signal failure into an orphan remote shell. + shutdownInFlight = false + relayLogLine( + `[relay] Shutdown deferred: ${error instanceof Error ? error.message : String(error)}` + ) + }) } process.on('SIGTERM', shutdown) diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index 219addc02a9..ee20461dd68 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -99,6 +99,10 @@ describe('Subprocess: Relay entry point', () => { await relay.sentinelReceived }, 10_000) + it('keeps the Node-18 relay bundle free of unsupported array copy methods', () => { + expect(readFileSync(relayEntry, 'utf8')).not.toContain('.toReversed(') + }) + it('responds to fs.stat over stdin/stdout', async () => { tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) writeFileSync(path.join(tmpDir, 'test.txt'), 'hello') diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 014e3950e42..ab19d2e244a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -59,6 +59,8 @@ import RightSidebar from './components/right-sidebar' import { StarNagCard } from './components/StarNagCard' import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver' import { StarNagToastHost } from './components/star-nag/StarNagToastHost' +import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' +import { SkillFreshnessUpdateDialog } from './components/skills/SkillFreshnessUpdateDialog' import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from './components/ZoomOverlay' import { onOnboardingReopened } from './components/onboarding/show-onboarding-event' @@ -164,7 +166,11 @@ import { type KeybindingContext, type PhysicalModifierToken } from '../../shared/keybindings' -import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + isRuntimeOwnedSshTargetId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' import { ModifierDoubleTapDetector, toModifierDoubleTapEvent @@ -1001,7 +1007,13 @@ function App(): React.JSX.Element { // tabs through pty.attach on the relay. Passphrase-protected targets // are deferred to tab focus to avoid stacking credential dialogs at // startup before the user has context. - const connectionIds = sessionRead.session.activeConnectionIdsAtShutdown ?? [] + // Why: runtime-owned (ephemeral-VM) targets must never be dialed from + // the renderer — ssh.connect would dispose the runtime layer's live + // relay session. Main's windowless-promotion path can persist such + // ids into this list, so filter at the consumption boundary too. + const connectionIds = (sessionRead.session.activeConnectionIdsAtShutdown ?? []).filter( + (targetId) => !isRuntimeOwnedSshTargetId(targetId) + ) if (connectionIds.length > 0) { try { const SSH_RECONNECT_TIMEOUT_MS = 15_000 @@ -2818,10 +2830,20 @@ function App(): React.JSX.Element { > + {/* Why: the dialog hosts a live terminal pane, which requires the + link-routing preference context; mounting outside crashes it. */} + + + + {/* Why: rendered last so it sits after all -webkit-app-region:drag elements in DOM order. Electron's hit-test for drag regions is DOM-order-based and diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 99fc42d7111..af47d26736d 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -27,7 +27,6 @@ import { GitPullRequestDraft, List, LoaderCircle, - Lock, Minus, Plus, RefreshCw, @@ -90,6 +89,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' +import { JiraConnectDialog } from '@/components/jira-connect-dialog' import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' import { LinearScopeSelector } from '@/components/linear-scope-selector' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' @@ -205,7 +205,11 @@ import { type TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility' -import { accumulateWorkItemPages } from '@/components/task-page-work-item-pagination' +import { + getTaskPagePerRepoLimit, + taskPageToGitHubApiPage +} from '@/components/task-page-work-item-pagination' +import { sortWorkItemsByNumber } from '../../../shared/work-items' import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns' import { resolveLinearIssueAttributeFilterPrimaryTeam } from '@/components/linear-issue-attribute-filter-primary-team' import { @@ -3114,7 +3118,6 @@ export default function TaskPage(): React.JSX.Element { const jiraStatus = useAppStore((s) => s.jiraStatus) const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked) const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey) - const connectJira = useAppStore((s) => s.connectJira) const selectJiraSite = useAppStore((s) => s.selectJiraSite) const searchJiraIssues = useAppStore((s) => s.searchJiraIssues) const listJiraIssues = useAppStore((s) => s.listJiraIssues) @@ -3801,15 +3804,23 @@ export default function TaskPage(): React.JSX.Element { // once, but the result is reconciled into existing rows to avoid a full // table shuffle when only status/key fields changed. const landingGitHubRefreshKeysRef = useRef>(new Set()) - // Why: pages holds all fetched pages of work items. Page 0 is seeded from - // cache for instant first paint; subsequent pages are loaded via date cursors. - const [pages, setPages] = useState(() => { + // Why: divide the display budget between repos so one provider page maps to + // one UI page without truncating rows that later provider pages cannot return. + const githubPerRepoPageLimit = getTaskPagePerRepoLimit( + selectedRepos.length, + PER_REPO_FETCH_LIMIT, + CROSS_REPO_DISPLAY_LIMIT + ) + const githubPageSize = githubPerRepoPageLimit * Math.max(1, selectedRepos.length) + // Why: null entries are pages not fetched yet. Numbered provider pages let a + // high-page click load that page directly without rate-limiting intermediate reads. + const [pages, setPages] = useState<(GitHubWorkItem[] | null)[]>(() => { const trimmed = initialTaskQuery.trim() const merged: GitHubWorkItem[] = [] for (const r of selectedRepos) { const cached = getCachedWorkItems( r.id, - PER_REPO_FETCH_LIMIT, + githubPerRepoPageLimit, trimmed, r.path, getTaskPageRepoSourceContext(r, 'github') @@ -3821,15 +3832,13 @@ export default function TaskPage(): React.JSX.Element { if (merged.length === 0) { return [[]] } - const page0 = [...merged] - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) - .slice(0, CROSS_REPO_DISPLAY_LIMIT) + const page0 = sortWorkItemsByNumber(merged).slice(0, githubPageSize) return [page0] }) const [currentPage, setCurrentPage] = useState(0) const [paginationLoading, setPaginationLoading] = useState(false) const [loadingTargetPage, setLoadingTargetPage] = useState(null) - const [totalItemCount, setTotalItemCount] = useState(null) + const [countedTotalPages, setCountedTotalPages] = useState(null) const fetchWorkItemsNextPage = useAppStore((s) => s.fetchWorkItemsNextPage) const countWorkItemsAcrossRepos = useAppStore((s) => s.countWorkItemsAcrossRepos) @@ -3859,7 +3868,7 @@ export default function TaskPage(): React.JSX.Element { selectTaskPageWorkItemsCacheEntries( s.workItemsCache, selectedRepos.map(getTaskPageRepoCacheInput), - PER_REPO_FETCH_LIMIT, + githubPerRepoPageLimit, appliedWorkItemsCacheQuery ) ) @@ -3982,6 +3991,9 @@ export default function TaskPage(): React.JSX.Element { setPages((current) => { let changed = false const nextPages = current.map((page) => { + if (!page) { + return page + } let pageChanged = false const nextPage = page.map((item) => { if (item.id !== itemKey.id || item.repoId !== itemKey.repoId) { @@ -5836,6 +5848,7 @@ export default function TaskPage(): React.JSX.Element { }, [newLinearStates.data, newLinearIssueStateId]) const [linearConnectOpen, setLinearConnectOpen] = useState(false) + const [jiraConnectOpen, setJiraConnectOpen] = useState(false) useContextualTour( 'tasks', !dialogWorkItem && @@ -5845,6 +5858,7 @@ export default function TaskPage(): React.JSX.Element { !newLinearProjectOpen && !newLinearIssueOpen && !linearConnectOpen && + !jiraConnectOpen && activeModal === 'none', 'tasks_open' ) @@ -5884,12 +5898,6 @@ export default function TaskPage(): React.JSX.Element { const [newJiraIssueCustomFieldValues, setNewJiraIssueCustomFieldValues] = useState< Record >({}) - const [jiraConnectOpen, setJiraConnectOpen] = useState(false) - const [jiraSiteUrlDraft, setJiraSiteUrlDraft] = useState('') - const [jiraEmailDraft, setJiraEmailDraft] = useState('') - const [jiraApiTokenDraft, setJiraApiTokenDraft] = useState('') - const [jiraConnectState, setJiraConnectState] = useState<'idle' | 'connecting' | 'error'>('idle') - const [jiraConnectError, setJiraConnectError] = useState(null) const includeJiraSiteNameInProjectLabel = selectedJiraSiteId === 'all' const previousProviderRuntimeContextKeyRef = useRef(providerRuntimeContextKey) @@ -6156,6 +6164,9 @@ export default function TaskPage(): React.JSX.Element { const seen = new Set() const logins: string[] = [] for (const page of pages) { + if (!page) { + continue + } for (const item of page) { if ( !item.author || @@ -6229,45 +6240,31 @@ export default function TaskPage(): React.JSX.Element { } }, [ensurePRChecksLoaded, filteredWorkItems, githubMode, showPRManagementColumns, taskSource]) - // Why: each page is bounded by both the per-repo fetch budget (so a single - // repo can yield at most PER_REPO_FETCH_LIMIT rows per page) and the - // post-merge display cap. Dividing the total by CROSS_REPO_DISPLAY_LIMIT - // alone under-counts pages when the per-repo budget is the tighter bound — - // e.g. one repo with 60 open PRs yielded 36 rows on page 0 and ceil(60/100) - // = 1 total pages, hiding the pager entirely. - const effectivePageSize = Math.max( - 1, - Math.min(PER_REPO_FETCH_LIMIT * Math.max(1, selectedRepos.length), CROSS_REPO_DISPLAY_LIMIT) - ) - // Why: if the search-API count is unavailable (rate-limited, transient - // failure — `countWorkItemsAcrossRepos` swallows errors and returns 0), - // infer there's at least one more page whenever the last loaded page - // filled to the per-page capacity. Otherwise pagination would silently - // hide and trap the user on page 0 with no way to advance. - const lastPageFull = (pages.at(-1)?.length ?? 0) >= effectivePageSize + let lastLoadedPageIndex = 0 + for (let index = 0; index < pages.length; index += 1) { + if (pages[index] !== null) { + lastLoadedPageIndex = index + } + } + // Why: when counts fail, a full loaded page is enough evidence to expose one + // more page without pretending unfetched sparse entries are empty results. + const lastLoadedPageFull = + (pages[lastLoadedPageIndex]?.length ?? 0) >= Math.max(1, githubPageSize) + const fallbackTotalPages = lastLoadedPageFull + ? Math.max(pages.length, lastLoadedPageIndex + 2) + : Math.max(1, pages.length) const totalPages = - totalItemCount && totalItemCount > 0 - ? Math.max(pages.length, Math.ceil(totalItemCount / effectivePageSize)) - : lastPageFull - ? pages.length + 1 - : pages.length + countedTotalPages && countedTotalPages > 0 + ? Math.max(pages.length, countedTotalPages) + : fallbackTotalPages - // Why: loads the next page using the oldest item's updatedAt as a cursor. - // When targetPage is provided (from clicking a numbered page beyond loaded - // pages), it chains fetches until that page is loaded. + // Why: numbered provider pages support random access. Load only the clicked + // page so a high-page jump does not exhaust GitHub's Search API rate bucket. const handleLoadNextPage = useCallback( async (targetPage?: number) => { if (paginationLoading || selectedRepos.length === 0) { return } - const lastPage = pages.at(-1) - if (!lastPage || lastPage.length === 0) { - return - } - const oldestItem = lastPage.at(-1) - if (!oldestItem?.updatedAt) { - return - } const q = stripRepoQualifiers(appliedTaskSearch.trim()) const repoArgs = selectedRepos.map((r) => ({ repoId: r.id, @@ -6277,37 +6274,32 @@ export default function TaskPage(): React.JSX.Element { })) const requestGeneration = paginationGenerationRef.current - const target = targetPage ?? pages.length + const target = targetPage ?? currentPage + 1 setPaginationLoading(true) setLoadingTargetPage(target) try { - const result = await accumulateWorkItemPages({ - existingPages: pages, - initialCursor: oldestItem.updatedAt, - targetPage: target, - // Why: uniform page size keeps deduped pages from shrinking below the - // size `totalPages` (count ÷ effectivePageSize) assumes — otherwise - // the per-page overlap loss would strand the tail items. - pageSize: effectivePageSize, - isCancelled: () => paginationGenerationRef.current !== requestGeneration, - fetchPage: async (cursor) => { - const { items } = await fetchWorkItemsNextPage( - repoArgs, - PER_REPO_FETCH_LIMIT, - CROSS_REPO_DISPLAY_LIMIT, - q, - cursor - ) - return { items } - } - }) - if (result.cancelled) { + const { items } = await fetchWorkItemsNextPage( + repoArgs, + githubPerRepoPageLimit, + githubPageSize, + q, + taskPageToGitHubApiPage(target) + ) + if (paginationGenerationRef.current !== requestGeneration) { return } - if (result.newPages.length > 0) { - setPages((prev) => [...prev, ...result.newPages]) - setCurrentPage(target < result.loadedPages ? target : result.loadedPages - 1) + if (items.length === 0) { + return } + setPages((previous) => { + const next = [...previous] + while (next.length <= target) { + next.push(null) + } + next[target] = items + return next + }) + setCurrentPage(target) } catch (err) { console.error('Failed to load next page:', err) } finally { @@ -6320,10 +6312,11 @@ export default function TaskPage(): React.JSX.Element { [ paginationLoading, selectedRepos, - pages, + currentPage, appliedTaskSearch, fetchWorkItemsNextPage, - effectivePageSize + githubPageSize, + githubPerRepoPageLimit ] ) @@ -6399,7 +6392,7 @@ export default function TaskPage(): React.JSX.Element { for (const r of selectedRepos) { const cached = getCachedWorkItems( r.id, - PER_REPO_FETCH_LIMIT, + githubPerRepoPageLimit, q, r.path, getTaskPageRepoSourceContext(r, 'github') @@ -6415,14 +6408,10 @@ export default function TaskPage(): React.JSX.Element { // no repo has a cache entry for it), we clear the previous query's rows // rather than leaving them on screen under the spinner. const page0 = - preMerged.length > 0 - ? [...preMerged] - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) - .slice(0, CROSS_REPO_DISPLAY_LIMIT) - : [] + preMerged.length > 0 ? sortWorkItemsByNumber(preMerged).slice(0, githubPageSize) : [] setPages([page0]) setCurrentPage(0) - setTotalItemCount(null) + setCountedTotalPages(null) setTasksError(null) setFailedCount(0) // reset so a prior failure banner doesn't linger setTasksLoading(anyUncached) @@ -6464,7 +6453,7 @@ export default function TaskPage(): React.JSX.Element { // newer retry's source from the set. Clearing only the keys captured // when this effect dispatched preserves later additions. const dispatchedRetrySourceKeys = retryingSourceKeys - void fetchWorkItemsAcrossRepos(repoArgs, PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT, q, { + void fetchWorkItemsAcrossRepos(repoArgs, githubPerRepoPageLimit, githubPageSize, q, { ...deriveTaskPageGitHubWorkItemsFetchOptions(forcedFetch, shouldProbeOnLanding) }) .then(({ items, failedCount: failed }) => { @@ -6542,10 +6531,11 @@ export default function TaskPage(): React.JSX.Element { executionHostId: r.executionHostId, sourceContext: getTaskPageRepoSourceContext(r, 'github') })), - q - ).then((count) => { + q, + githubPerRepoPageLimit + ).then(({ totalPages: countedPages }) => { if (!cancelled) { - setTotalItemCount(count) + setCountedTotalPages(countedPages) } }) @@ -8119,33 +8109,6 @@ export default function TaskPage(): React.JSX.Element { [openComposerForJiraItem] ) - const handleJiraConnect = useCallback(async (): Promise => { - const siteUrl = jiraSiteUrlDraft.trim() - const email = jiraEmailDraft.trim() - const apiToken = jiraApiTokenDraft.trim() - if (!siteUrl || !email || !apiToken) { - return - } - setJiraConnectState('connecting') - setJiraConnectError(null) - try { - const result = await connectJira({ siteUrl, email, apiToken }) - if (result.ok) { - setJiraSiteUrlDraft('') - setJiraEmailDraft('') - setJiraApiTokenDraft('') - setJiraConnectState('idle') - setJiraConnectOpen(false) - } else { - setJiraConnectState('error') - setJiraConnectError(result.error) - } - } catch (error) { - setJiraConnectState('error') - setJiraConnectError(error instanceof Error ? error.message : 'Connection failed') - } - }, [connectJira, jiraApiTokenDraft, jiraEmailDraft, jiraSiteUrlDraft]) - const taskPageListChromeHidden = shouldHideTaskPageListChrome({ taskSource, hasGitHubDetail: Boolean(dialogWorkItem), @@ -9834,7 +9797,7 @@ export default function TaskPage(): React.JSX.Element { totalPages={totalPages} loadingTarget={loadingTargetPage} onPageChange={(page) => { - if (page < pages.length) { + if (pages[page] !== null && pages[page] !== undefined) { setCurrentPage(page) } else { void handleLoadNextPage(page) @@ -10090,16 +10053,7 @@ export default function TaskPage(): React.JSX.Element { )}

- - . -

-

- - {translate( - 'auto.components.TaskPage.2abe22ef76', - 'Your token is encrypted via the OS keychain and stored locally.' - )} -

-
- - - - - - + ) } diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 2890e483435..f75bbaf951e 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1826,20 +1826,13 @@ function Terminal(): React.JSX.Element | null { } } - // Cmd/Ctrl+Shift+T — reopen closed browser tab when browser is active, - // otherwise reopen the most recently closed editor tab. + // Cmd/Ctrl+Shift+T — reopen the most recently closed tab of any kind + // (terminal, browser, or editor), Chrome/Ghostty-style. Repeated presses + // walk back through the close history. if (!e.repeat && matchShortcut('tab.reopenClosed')) { e.preventDefault() notifyTerminalCapture('tab.reopenClosed') - const state = useAppStore.getState() - if (state.activeTabType === 'browser') { - const restored = state.reopenClosedBrowserTab(activeWorktreeId) - if (restored === null) { - state.reopenClosedEditorTab(activeWorktreeId) - } - } else { - state.reopenClosedEditorTab(activeWorktreeId) - } + useAppStore.getState().reopenClosedTab(activeWorktreeId) return } diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 9b6fcef5b29..e1bb431c76e 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -105,16 +105,14 @@ import { CREATE_WORKSPACE_QUICK_ACTION_ID } from '@/components/cmd-j/quick-actions' import { buildWorktreeChecksReviewIndex } from '@/components/cmd-j/worktree-checks-review-index' +import { resolvePaletteFocusRestoreTarget } from '@/components/cmd-j/palette-focus-restore-target' import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { getComposerEligibleRepos, resolveComposerGitRepoId } from '@/lib/new-workspace-composer-repo' -import { - lookupGitHubWorkItemByOwnerRepoForSource, - lookupGitHubWorkItemForSource -} from '@/lib/github-work-item-source-lookup' +import { lookupGitHubWorkItemForSource } from '@/lib/github-work-item-source-lookup' import type { SettingsNavTarget } from '@/lib/settings-navigation-types' import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' @@ -433,6 +431,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) const previousBrowserPageIdRef = useRef(null) const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview') + // Why: the exact element focused before Cmd+J opened (e.g. the terminal + // textarea the user was typing in) so Escape restores it precisely instead + // of the first matching surface in the DOM, which may be a background + // worktree's mounted-but-hidden terminal. + const previousFocusElementRef = useRef(null) const activeGroupSnapshotRef = useRef(null) const wasVisibleRef = useRef(false) const skipRestoreFocusRef = useRef(false) @@ -1177,6 +1180,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { document.activeElement.closest('[data-orca-browser-address-bar="true"]') ? 'address-bar' : 'webview' + // Why: same timing constraint — capture the pre-dialog focus target now + // so Escape can return focus to the exact input the user left (excluding + // document.body, which isn't a meaningful restore target). + previousFocusElementRef.current = + document.activeElement instanceof HTMLElement && document.activeElement !== document.body + ? document.activeElement + : null skipRestoreFocusRef.current = false setQuery('') setSelectedItemId('') @@ -1243,24 +1253,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { useEffect(() => cancelFallbackFocusFrames, [cancelFallbackFocusFrames]) - const focusFallbackSurface = useCallback(() => { - cancelFallbackFocusFrames() - fallbackFocusOuterFrameRef.current = requestAnimationFrame(() => { - fallbackFocusOuterFrameRef.current = null - fallbackFocusInnerFrameRef.current = requestAnimationFrame(() => { - fallbackFocusInnerFrameRef.current = null - const xterm = document.querySelector('.xterm-helper-textarea') as HTMLElement | null - if (xterm) { - xterm.focus() - return - } - const monaco = document.querySelector('.monaco-editor textarea') as HTMLElement | null - if (monaco) { - monaco.focus() - } + const focusFallbackSurface = useCallback( + (preferredTarget?: HTMLElement | null) => { + cancelFallbackFocusFrames() + fallbackFocusOuterFrameRef.current = requestAnimationFrame(() => { + fallbackFocusOuterFrameRef.current = null + fallbackFocusInnerFrameRef.current = requestAnimationFrame(() => { + fallbackFocusInnerFrameRef.current = null + resolvePaletteFocusRestoreTarget(preferredTarget ?? null)?.focus({ preventScroll: true }) + }) }) - }) - }, [cancelFallbackFocusFrames]) + }, + [cancelFallbackFocusFrames] + ) const requestBrowserFocus = useCallback( (detail: { pageId: string; target: 'webview' | 'address-bar' }) => { @@ -1294,7 +1299,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return } if (previousWorktreeIdRef.current) { - focusFallbackSurface() + // Why: dismissing Cmd+J should return to whatever the user was doing — + // restore the exact previously-focused surface (e.g. the terminal they + // were typing in) rather than an arbitrary first match. + focusFallbackSurface(previousFocusElementRef.current) } }, [closeModal, focusFallbackSurface, requestBrowserFocus] @@ -1535,13 +1543,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // Case 1: user pasted a GH issue/PR URL. if (ghLink) { - const { slug, number } = ghLink + const { number } = ghLink const state = useAppStore.getState() // Why: the existing-worktree check only needs the issue/PR number, which - // is repo-agnostic on the worktree meta side. We don't currently cache a - // repo-slug map, so slug-matching against a specific repo happens - // implicitly when we pick a repo for the `gh workItem` lookup below. + // is repo-agnostic on the worktree meta side. const matches = allWorktrees.filter( (w) => !w.isArchived && (w.linkedIssue === number || w.linkedPR === number) ) @@ -1553,73 +1559,21 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return } - // Resolve via gh.workItem: prefer the active repo, else the first eligible. + // Why: hand the raw URL to the composer's name field so it runs the same + // cross-project detection as Cmd+N — surfacing the "Switch project?" + // dialog when the URL targets a different project. Pre-resolving here + // against an arbitrary repo silently linked cross-project items to the + // wrong project and skipped that prompt. Seed the active repo so the + // field compares the URL against the project the user is currently in. const eligibleRepos = state.repos.filter((r) => isGitRepoKind(r)) const repoForLookup = (state.activeRepoId && eligibleRepos.find((r) => r.id === state.activeRepoId)) || eligibleRepos[0] - if (!repoForLookup) { - openComposer({ prefilledName: trimmed }) - return - } - - prefetchCreateWorkspaceBaseForComposer(repoForLookup.id) - const sourceContext = buildTaskSourceContextFromRepo({ - provider: 'github', - projectId: repoForLookup.id, - repo: repoForLookup - }) - // Why: awaiting inside the user gesture would leave the palette open - // indefinitely on slow networks. Close immediately and populate the - // composer once the lookup returns. - const lookupToken = createLookupGuard.start() - preserveCreateLookupOnCloseRef.current = true - recordFeatureInteraction('cmd-j-create-workspace') - closeModal() - void lookupGitHubWorkItemByOwnerRepoForSource({ - repoPath: repoForLookup.path, - repoId: repoForLookup.id, - sourceContext, - owner: slug.owner, - repo: slug.repo, - number, - type: ghLink.type - }) - .then((item) => { - if (!createLookupGuard.isCurrent(lookupToken)) { - return - } - const data: Record = { initialRepoId: repoForLookup.id } - if (item) { - const linkedWorkItem: LinkedWorkItemSummary = { - type: item.type, - number: item.number, - title: item.title, - url: item.url - } - data.linkedWorkItem = linkedWorkItem - data.prefilledName = - getLinkedWorkItemWorkspaceName(linkedWorkItem)?.seedName ?? - getLinkedWorkItemSuggestedName({ title: item.title }) - } else { - // Fallback: we couldn't resolve the URL, just seed the name. - data.prefilledName = `${slug.owner}-${slug.repo}-${number}` - } - queueMicrotask(() => - openModal('new-workspace-composer', { ...data, telemetrySource: 'command_palette' }) - ) - }) - .catch(() => { - if (!createLookupGuard.isCurrent(lookupToken)) { - return - } - queueMicrotask(() => - openModal('new-workspace-composer', { - initialRepoId: repoForLookup.id, - telemetrySource: 'command_palette' - }) - ) - }) + openComposer( + repoForLookup + ? { prefilledName: trimmed, initialRepoId: repoForLookup.id } + : { prefilledName: trimmed } + ) return } diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 0e786d64544..20cde69e56f 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -11,6 +11,7 @@ import { } from 'react' import { createPortal } from 'react-dom' import { cn } from '@/lib/utils' +import { createBrowserUuid } from '@/lib/browser-uuid' import { getConnectionId } from '@/lib/connection-context' import { detectLanguage } from '@/lib/language-detect' import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links' @@ -2874,11 +2875,7 @@ function BrowserPagePane({ }) const isActiveRef = useRef(isActive) isActiveRef.current = isActive - const annotationViewportBridgeTokenRef = useRef( - typeof crypto.randomUUID === 'function' - ? crypto.randomUUID().replaceAll('-', '') - : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}` - ) + const annotationViewportBridgeTokenRef = useRef(createBrowserUuid().replaceAll('-', '')) const browserAnnotations = useAppStore( (s) => s.browserAnnotationsByPageId[browserTab.id] ?? EMPTY_BROWSER_ANNOTATIONS ) diff --git a/src/renderer/src/components/browser-pane/markup/useMarkupEditor.test.ts b/src/renderer/src/components/browser-pane/markup/useMarkupEditor.test.ts new file mode 100644 index 00000000000..6e6ba294028 --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/useMarkupEditor.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMarkupEditor } from './useMarkupEditor' + +const realCrypto = globalThis.crypto + +function pointerDownEvent(clientX: number, clientY: number): React.PointerEvent { + return { + button: 0, + clientX, + clientY, + currentTarget: { setPointerCapture: vi.fn() }, + pointerId: 1, + preventDefault: vi.fn() + } as unknown as React.PointerEvent +} + +describe('useMarkupEditor.commitPendingText in a non-secure browser context', () => { + beforeEach(() => { + // Match a non-secure browser context (LAN web client over plain HTTP): + // getRandomValues stays, randomUUID is undefined. + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { getRandomValues: realCrypto.getRandomValues.bind(realCrypto) } + }) + }) + + afterEach(() => { + Object.defineProperty(globalThis, 'crypto', { configurable: true, value: realCrypto }) + }) + + it('commits a text shape with a valid id instead of throwing', () => { + const { result } = renderHook(() => useMarkupEditor(false, vi.fn())) + + act(() => result.current.setTool('text')) + act(() => result.current.onPointerDown(pointerDownEvent(5, 5))) + expect(result.current.pendingText).not.toBeNull() + + expect(() => act(() => result.current.commitPendingText('hello'))).not.toThrow() + + const shape = result.current.shapes.at(-1) + expect(shape?.kind).toBe('text') + expect(shape?.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/) + }) +}) diff --git a/src/renderer/src/components/browser-pane/markup/useMarkupEditor.ts b/src/renderer/src/components/browser-pane/markup/useMarkupEditor.ts index 242d17993cb..d2bfd1810fc 100644 --- a/src/renderer/src/components/browser-pane/markup/useMarkupEditor.ts +++ b/src/renderer/src/components/browser-pane/markup/useMarkupEditor.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { createBrowserUuid } from '@/lib/browser-uuid' import { blitMarkupScene, renderCommittedLayer } from './markup-canvas-render' import { useMarkupKeyboardShortcuts, type PendingText } from './useMarkupKeyboardShortcuts' import { useMarkupPointerHandlers } from './useMarkupPointerHandlers' @@ -139,7 +140,7 @@ export function useMarkupEditor(busy: boolean, onCancel: () => void) { } setDoc((document) => commitShape(document, { - id: crypto.randomUUID(), + id: createBrowserUuid(), kind: 'text', color, at, diff --git a/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.test.ts b/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.test.ts new file mode 100644 index 00000000000..3792a0f0b7b --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMarkupPointerHandlers, type MarkupPointerParams } from './useMarkupPointerHandlers' +import type { MarkupShape } from './markup-drawing-model' + +const realCrypto = globalThis.crypto + +function pointerDownEvent(clientX: number, clientY: number): React.PointerEvent { + return { + button: 0, + clientX, + clientY, + currentTarget: { setPointerCapture: vi.fn() }, + pointerId: 1 + } as unknown as React.PointerEvent +} + +function baseParams(overrides: Partial = {}): MarkupPointerParams { + return { + busy: false, + tool: 'pen', + color: '#ef4444', + width: 4, + pendingText: null, + canvasRef: { + current: { + getBoundingClientRect: () => ({ left: 0, top: 0 }) + } as unknown as HTMLCanvasElement + }, + setInProgress: vi.fn(), + setPendingText: vi.fn(), + setDoc: vi.fn(), + ...overrides + } +} + +describe('useMarkupPointerHandlers in a non-secure browser context', () => { + beforeEach(() => { + // Match a non-secure browser context (LAN web client over plain HTTP): + // getRandomValues stays, randomUUID is undefined. + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { getRandomValues: realCrypto.getRandomValues.bind(realCrypto) } + }) + }) + + afterEach(() => { + Object.defineProperty(globalThis, 'crypto', { configurable: true, value: realCrypto }) + }) + + it('starts a pen stroke with a valid id instead of throwing', () => { + const setInProgress = vi.fn() + const { result } = renderHook(() => useMarkupPointerHandlers(baseParams({ setInProgress }))) + + expect(() => act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))).not.toThrow() + + expect(setInProgress).toHaveBeenCalledTimes(1) + const shape = setInProgress.mock.calls[0][0] as MarkupShape + expect(shape.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/) + }) +}) diff --git a/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts b/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts index 3ddf41fd458..6ca25b3cabf 100644 --- a/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts +++ b/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react' import type React from 'react' +import { createBrowserUuid } from '@/lib/browser-uuid' import type { PendingText } from './useMarkupKeyboardShortcuts' import { commitShape, @@ -24,8 +25,17 @@ export type MarkupPointerParams = { // Canvas pointer interactions: draw a new shape, or place text. Split out of // useMarkupEditor to keep that hook focused. export function useMarkupPointerHandlers(params: MarkupPointerParams) { - const { busy, tool, color, width, pendingText, canvasRef, setInProgress, setPendingText, setDoc } = - params + const { + busy, + tool, + color, + width, + pendingText, + canvasRef, + setInProgress, + setPendingText, + setDoc + } = params const pointFromEvent = useCallback( (event: { clientX: number; clientY: number }): MarkupPoint => { @@ -57,7 +67,7 @@ export function useMarkupPointerHandlers(params: MarkupPointerParams) { return } event.currentTarget.setPointerCapture(event.pointerId) - const id = crypto.randomUUID() + const id = createBrowserUuid() if (tool === 'pen' || tool === 'highlight') { setInProgress({ id, kind: tool, color, width, points: [point] }) } else { diff --git a/src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts b/src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts new file mode 100644 index 00000000000..76b4f28eb77 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it } from 'vitest' +import { resolvePaletteFocusRestoreTarget } from './palette-focus-restore-target' + +afterEach(() => { + document.body.innerHTML = '' +}) + +function addTerminal(id: string): HTMLTextAreaElement { + const textarea = document.createElement('textarea') + textarea.className = 'xterm-helper-textarea' + textarea.dataset.terminal = id + document.body.appendChild(textarea) + return textarea +} + +describe('resolvePaletteFocusRestoreTarget', () => { + it('returns the exact previously-focused element when it is still connected', () => { + // Two terminals mounted (e.g. a background worktree comes first in the DOM); + // the user was typing in the second one before opening Cmd+J. + addTerminal('background') + const active = addTerminal('active') + + expect(resolvePaletteFocusRestoreTarget(active)).toBe(active) + }) + + it('falls back to the first terminal when the previous element is gone', () => { + const first = addTerminal('first') + const detached = document.createElement('textarea') + detached.className = 'xterm-helper-textarea' + // Never appended → not connected (e.g. its pane unmounted while Cmd+J was open). + + expect(detached.isConnected).toBe(false) + expect(resolvePaletteFocusRestoreTarget(detached)).toBe(first) + }) + + it('falls back to the editor textarea when no preferred target and no terminal exist', () => { + const editor = document.createElement('div') + editor.className = 'monaco-editor' + const textarea = document.createElement('textarea') + editor.appendChild(textarea) + document.body.appendChild(editor) + + expect(resolvePaletteFocusRestoreTarget(null)).toBe(textarea) + }) + + it('prefers the terminal over the editor when both are present and nothing was captured', () => { + const terminal = addTerminal('only') + const editor = document.createElement('div') + editor.className = 'monaco-editor' + editor.appendChild(document.createElement('textarea')) + document.body.appendChild(editor) + + expect(resolvePaletteFocusRestoreTarget(null)).toBe(terminal) + }) + + it('returns null when nothing is focusable', () => { + expect(resolvePaletteFocusRestoreTarget(null)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-focus-restore-target.ts b/src/renderer/src/components/cmd-j/palette-focus-restore-target.ts new file mode 100644 index 00000000000..3d739d8b022 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-focus-restore-target.ts @@ -0,0 +1,19 @@ +// Why: when Cmd+J closes it must hand focus back to whatever the user was +// doing. Prefer the exact element focused before the palette opened (e.g. the +// specific terminal textarea they were typing in); the querySelector fallbacks +// grab the first match in the DOM, which can be a background worktree's +// mounted-but-hidden terminal rather than the visible one. +export function resolvePaletteFocusRestoreTarget( + preferredTarget: HTMLElement | null, + doc: Document = document +): HTMLElement | null { + if (preferredTarget && preferredTarget.isConnected) { + return preferredTarget + } + const xterm = doc.querySelector('.xterm-helper-textarea') + if (xterm instanceof HTMLElement) { + return xterm + } + const monaco = doc.querySelector('.monaco-editor textarea') + return monaco instanceof HTMLElement ? monaco : null +} diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 1c0cb2734d2..fdbaee0dfcb 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -17,6 +17,8 @@ import { RichMarkdownEditorSurface } from './RichMarkdownEditorSurface' import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance' import { useRichMarkdownMenuController } from './useRichMarkdownMenuController' import { useRichMarkdownProgrammaticSync } from './useRichMarkdownProgrammaticSync' +import { useRichMarkdownReconcileRoundTrip } from './useRichMarkdownReconcileRoundTrip' +import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit' import { useRichMarkdownReviewController } from './useRichMarkdownReviewController' import { useRichMarkdownReviewEditorEffects } from './useRichMarkdownReviewEditorEffects' import { @@ -99,6 +101,11 @@ export default function RichMarkdownEditor({ const menu = useRichMarkdownMenuController({ markdownDocuments }) const isMac = navigator.userAgent.includes('Mac') const lastCommittedMarkdownRef = useRef(content) + // Why: three-way source-preserving reconciliation baseline — the raw on-disk + // bytes and their canonical serialization — so edits patch onto the original + // style rather than re-canonicalizing untouched regions (#6080). + const originalSourceRef = useRef(content) + const baseCanonicalRef = useRef('') const onContentChangeRef = useRef(onContentChange) const onDirtyStateHintRef = useRef(onDirtyStateHint) const onSaveRef = useRef(onSave) @@ -159,6 +166,13 @@ export default function RichMarkdownEditor({ onSaveRef.current = onSave onOpenDocLinkRef.current = onOpenDocLink isEditingLinkRef.current = isEditingLink + const reconcileRoundTripRef = useRichMarkdownReconcileRoundTrip({ + htmlSuperscriptLinkContext, + filePath, + runtimeEnvironmentId, + worktreeId, + worktreeRoot + }) const flushPendingSerialization = useCallback(() => { if (serializeTimerRef.current === null) { @@ -167,16 +181,19 @@ export default function RichMarkdownEditor({ window.clearTimeout(serializeTimerRef.current) serializeTimerRef.current = null try { - const markdown = editorRef.current?.getMarkdown() - if (markdown !== undefined) { - lastCommittedMarkdownRef.current = markdown + const { markdown, didSerialize } = commitRichMarkdownSerialization( + editorRef.current, + { originalSourceRef, baseCanonicalRef, lastCommittedMarkdownRef }, + reconcileRoundTripRef.current + ) + if (didSerialize) { onContentChangeRef.current(markdown) } } catch { // Why: save/restart flows should never crash the UI just because the // editor was torn down between scheduling and flushing a debounced sync. } - }, []) + }, [reconcileRoundTripRef]) useEffect(() => { // Why: autosave/restart paths live outside the editor component tree, so a @@ -216,6 +233,9 @@ export default function RichMarkdownEditor({ rootRef, editorRef, lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, + reconcileRoundTripRef, onContentChangeRef, onDirtyStateHintRef, onSaveRef, @@ -288,6 +308,8 @@ export default function RichMarkdownEditor({ filePath, isApplyingProgrammaticUpdateRef, lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, markdownDocuments, rootRef, runtimeEnvironmentId, diff --git a/src/renderer/src/components/editor/rich-markdown-commands.test.ts b/src/renderer/src/components/editor/rich-markdown-commands.test.ts index d04b1de08f6..5caffa90e7a 100644 --- a/src/renderer/src/components/editor/rich-markdown-commands.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-commands.test.ts @@ -40,14 +40,14 @@ describe('rich markdown slash commands', () => { it('orders commands under section headers', () => { expect(slashCommands.map((command) => `${command.group}:${command.id}`)).toEqual([ 'Headings:heading-1', - 'Headings:toggle-h1', 'Headings:heading-2', - 'Headings:toggle-h2', 'Headings:heading-3', - 'Headings:toggle-h3', 'Headings:heading-4', - 'Headings:toggle-h4', 'Headings:heading-5', + 'Toggle headings:toggle-h1', + 'Toggle headings:toggle-h2', + 'Toggle headings:toggle-h3', + 'Toggle headings:toggle-h4', 'Basic blocks:blockquote', 'Basic blocks:ordered-list', 'Basic blocks:bullet-list', diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts index 45f43e77ef1..8573ddea973 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts @@ -48,6 +48,9 @@ function createConfigParams(overrides: Partial = {}): Editor rootRef: ref(null), editorRef: ref(null), lastCommittedMarkdownRef: ref(''), + originalSourceRef: ref(''), + baseCanonicalRef: ref(''), + reconcileRoundTripRef: ref<(markdown: string) => string | null>(() => null), onContentChangeRef: ref(vi.fn()), onDirtyStateHintRef: ref(vi.fn()), onSaveRef: ref(vi.fn()), diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index 12ec6dc92d9..ec94eda94d4 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -22,6 +22,7 @@ import { type RichMarkdownRuntimeSettings } from './rich-markdown-editor-click-routing' import { createRichMarkdownKeyHandler } from './rich-markdown-key-handler' +import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit' import { createRichMarkdownImageResolverContext, setRichMarkdownImageResolverContext @@ -51,6 +52,9 @@ export type EditorConfigParams = { rootRef: MutableRefObject editorRef: MutableRefObject lastCommittedMarkdownRef: MutableRefObject + originalSourceRef: MutableRefObject + baseCanonicalRef: MutableRefObject + reconcileRoundTripRef: MutableRefObject<(markdown: string) => string | null> onContentChangeRef: MutableRefObject<(content: string) => void> onDirtyStateHintRef: MutableRefObject<(dirty: boolean) => void> onSaveRef: MutableRefObject<(content: string) => void> @@ -100,6 +104,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE rootRef, editorRef, lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, + reconcileRoundTripRef, onContentChangeRef, onDirtyStateHintRef, onSaveRef, @@ -170,6 +177,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE editorRef, rootRef, lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, + reconcileRoundTripRef, onContentChangeRef, onSaveRef, isEditingLinkRef, @@ -236,6 +246,11 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE // split on load. normalizeEmptyListItems(nextEditor) lastCommittedMarkdownRef.current = content + // Why: seed the source-preserving reconciliation baseline — the raw loaded + // bytes and their canonical serialization — so the first edit patches onto + // the original style instead of re-canonicalizing the whole file. + originalSourceRef.current = content + baseCanonicalRef.current = nextEditor.getMarkdown() isInitializingRef.current = false cancelAutoFocusRef.current?.() cancelAutoFocusRef.current = autoFocusRichEditor(nextEditor, rootRef.current) @@ -268,9 +283,14 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE serializeTimerRef.current = window.setTimeout(() => { serializeTimerRef.current = null try { - const markdown = nextEditor.getMarkdown() - lastCommittedMarkdownRef.current = markdown - onContentChangeRef.current(markdown) + const { markdown, didSerialize } = commitRichMarkdownSerialization( + nextEditor, + { originalSourceRef, baseCanonicalRef, lastCommittedMarkdownRef }, + reconcileRoundTripRef.current + ) + if (didSerialize) { + onContentChangeRef.current(markdown) + } } catch { // Why: save/restart flows should never crash the UI just because // the editor was torn down between scheduling and serializing. diff --git a/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx b/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx index b71b79f1e25..16d256c5f0f 100644 --- a/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx +++ b/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx @@ -25,27 +25,6 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 1 }).run() } }, - { - id: 'toggle-h1', - get label() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.41482b15ce', - 'Toggle Heading 1' - ) - }, - aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'], - icon: icon(ChevronRight), - group: 'Headings', - get description() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.3294a2c0cc', - 'Create a collapsible section with a large heading summary.' - ) - }, - run: (editor) => { - insertToggle(editor, 'heading-1') - } - }, { id: 'heading-2', get label() { @@ -68,27 +47,6 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 2 }).run() } }, - { - id: 'toggle-h2', - get label() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.7a2c1f9b04', - 'Toggle Heading 2' - ) - }, - aliases: ['toggle-h2'], - icon: icon(ChevronRight), - group: 'Headings', - get description() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.b3e5d8a1c6', - 'Create a collapsible section with a medium heading summary.' - ) - }, - run: (editor) => { - insertToggle(editor, 'heading-2') - } - }, { id: 'heading-3', get label() { @@ -111,27 +69,6 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 3 }).run() } }, - { - id: 'toggle-h3', - get label() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.2f9d6b4e10', - 'Toggle Heading 3' - ) - }, - aliases: ['toggle-h3'], - icon: icon(ChevronRight), - group: 'Headings', - get description() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.8c1a3e7d52', - 'Create a collapsible section with a small heading summary.' - ) - }, - run: (editor) => { - insertToggle(editor, 'heading-3') - } - }, { id: 'heading-4', get label() { @@ -154,27 +91,6 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 4 }).run() } }, - { - id: 'toggle-h4', - get label() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.5e0b9c2a71', - 'Toggle Heading 4' - ) - }, - aliases: ['toggle-h4'], - icon: icon(ChevronRight), - group: 'Headings', - get description() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.d4f16a8b39', - 'Create a collapsible section with a nested heading summary.' - ) - }, - run: (editor) => { - insertToggle(editor, 'heading-4') - } - }, { id: 'heading-5', get label() { @@ -196,5 +112,91 @@ export const headingSlashCommands: SlashCommand[] = [ // Use setHeading (not toggleHeading) so "/h5" is idempotent. editor.chain().focus().setHeading({ level: 5 }).run() } + }, + // Grouped separately (Notion-style) so plain headings and collapsible + // toggle headings are easy to scan as distinct families in the slash menu. + { + id: 'toggle-h1', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.41482b15ce', + 'Toggle H1' + ) + }, + aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'], + icon: icon(ChevronRight), + group: 'Toggle headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.3294a2c0cc', + 'Create a collapsible section with a large heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-1') + } + }, + { + id: 'toggle-h2', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.7a2c1f9b04', + 'Toggle H2' + ) + }, + aliases: ['toggle-h2'], + icon: icon(ChevronRight), + group: 'Toggle headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.b3e5d8a1c6', + 'Create a collapsible section with a medium heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-2') + } + }, + { + id: 'toggle-h3', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.2f9d6b4e10', + 'Toggle H3' + ) + }, + aliases: ['toggle-h3'], + icon: icon(ChevronRight), + group: 'Toggle headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.8c1a3e7d52', + 'Create a collapsible section with a small heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-3') + } + }, + { + id: 'toggle-h4', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.5e0b9c2a71', + 'Toggle H4' + ) + }, + aliases: ['toggle-h4'], + icon: icon(ChevronRight), + group: 'Toggle headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.d4f16a8b39', + 'Create a collapsible section with a nested heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-4') + } } ] diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts index ba9aaea314e..02d53e1b626 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts @@ -53,6 +53,9 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext editorRef: { current: editor }, rootRef: { current: null }, lastCommittedMarkdownRef: { current: '' }, + originalSourceRef: { current: '' }, + baseCanonicalRef: { current: '' }, + reconcileRoundTripRef: { current: () => null }, onContentChangeRef: { current: vi.fn() }, onSaveRef: { current: vi.fn() }, isEditingLinkRef: { current: false }, diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index 2805141d1aa..d8995f3c913 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -3,7 +3,6 @@ import type { Editor } from '@tiptap/react' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' import { isMarkdownPreviewFindShortcut } from './markdown-preview-search' -import { editorShortcutMatches } from './editor-shortcuts' import type { LinkBubbleState } from './RichMarkdownLinkBubble' import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands' import { @@ -21,12 +20,16 @@ import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-de import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard' import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut' +import { handleRichMarkdownSaveShortcut } from './rich-markdown-save-shortcut' export type KeyHandlerContext = { isMac: boolean editorRef: MutableRefObject rootRef: MutableRefObject lastCommittedMarkdownRef: MutableRefObject + originalSourceRef: MutableRefObject + baseCanonicalRef: MutableRefObject + reconcileRoundTripRef: MutableRefObject<(markdown: string) => string | null> onContentChangeRef: MutableRefObject<(content: string) => void> onSaveRef: MutableRefObject<(content: string) => void> isEditingLinkRef: MutableRefObject @@ -127,15 +130,7 @@ export function createRichMarkdownKeyHandler( ctx.openSearchRef.current() return true } - if (editorShortcutMatches('editor.save', event)) { - event.preventDefault() - // Why: flush any pending debounced serialization so the save - // captures the very latest editor content, not a stale snapshot. - ctx.flushPendingSerialization() - const markdown = ctx.editorRef.current?.getMarkdown() ?? ctx.lastCommittedMarkdownRef.current - ctx.lastCommittedMarkdownRef.current = markdown - ctx.onContentChangeRef.current(markdown) - ctx.onSaveRef.current(markdown) + if (handleRichMarkdownSaveShortcut(ctx, event)) { return true } diff --git a/src/renderer/src/components/editor/rich-markdown-reconcile-serializer.ts b/src/renderer/src/components/editor/rich-markdown-reconcile-serializer.ts new file mode 100644 index 00000000000..64c83fe5cd7 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-reconcile-serializer.ts @@ -0,0 +1,61 @@ +import { Editor } from '@tiptap/core' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { + createRichMarkdownHtmlSuperscriptLinkContext, + type RichMarkdownHtmlSuperscriptLinkContext +} from './rich-markdown-html-superscript-link-context' +import { + setRichMarkdownImageResolverContext, + type RichMarkdownImageResolverContext +} from './rich-markdown-image-context' +import { normalizeEmptyListItems } from './rich-markdown-normalize' + +export type RichMarkdownReconcileSerializerContext = { + htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext + imageResolverContext: RichMarkdownImageResolverContext +} + +/** + * Re-serializes markdown through a throwaway editor that REPLICATES the mounted + * editor's post-load state: same extension set, the file's own superscript-link + * classification context, its image resolver context, and normalizeEmptyListItems. + * Reconciliation's safety re-parse must match the live getMarkdown() output, or + * docs with empty list items / local images / superscript links would spuriously + * mismatch and silently fall back to canonical. Returns null if serialization throws. + */ +export function serializeRichMarkdownForReconcile( + content: string, + { htmlSuperscriptLinkContext, imageResolverContext }: RichMarkdownReconcileSerializerContext +): string | null { + try { + const codec = createRichMarkdownEditorCodec() + // Why: mirror the live editor's link classification (sourceOwner/paths drive + // whether a link serializes as a superscript link) via a fresh context built + // from the live snapshot, without subscribing to the live editor's context. + const { version: _version, ...snapshot } = htmlSuperscriptLinkContext.getSnapshot() + const context = createRichMarkdownHtmlSuperscriptLinkContext(snapshot) + const editor = new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ + codec, + htmlSuperscriptLinks: true, + htmlSuperscriptLinkContext: context + }), + content: encodeRawMarkdownHtmlForRichEditor(content, codec, { htmlSuperscriptLinks: true }), + contentType: 'markdown', + onBeforeCreate: ({ editor: nextEditor }) => { + setRichMarkdownImageResolverContext(nextEditor, imageResolverContext) + } + }) + try { + normalizeEmptyListItems(editor) + return editor.getMarkdown() + } finally { + editor.destroy() + } + } catch { + return null + } +} diff --git a/src/renderer/src/components/editor/rich-markdown-save-shortcut.ts b/src/renderer/src/components/editor/rich-markdown-save-shortcut.ts new file mode 100644 index 00000000000..14c641a954a --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-save-shortcut.ts @@ -0,0 +1,31 @@ +import type { KeyHandlerContext } from './rich-markdown-key-handler' +import { editorShortcutMatches } from './editor-shortcuts' +import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit' + +/** + * Cmd/Ctrl+S: flush the debounced serialization, then reconcile toward the + * original source style before saving so untouched regions keep their bytes. + */ +export function handleRichMarkdownSaveShortcut( + ctx: KeyHandlerContext, + event: KeyboardEvent +): boolean { + if (!editorShortcutMatches('editor.save', event)) { + return false + } + event.preventDefault() + // Why: flush pending debounced serialization so the save captures the very + // latest editor content, not a stale snapshot. + ctx.flushPendingSerialization() + // Why: the flush already reconciled + updated refs, so this re-serialize is + // idempotent (edited === baseCanonical → returns the reconciled bytes). On a + // torn-down editor it falls back to the last committed bytes without patching. + const { markdown } = commitRichMarkdownSerialization( + ctx.editorRef.current, + ctx, + ctx.reconcileRoundTripRef.current + ) + ctx.onContentChangeRef.current(markdown) + ctx.onSaveRef.current(markdown) + return true +} diff --git a/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts b/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts new file mode 100644 index 00000000000..e08b7a08bfb --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Editor } from '@tiptap/react' +import { + commitRichMarkdownSerialization, + type RichMarkdownReconcileRefs +} from './rich-markdown-serialization-commit' +import { handleRichMarkdownSaveShortcut } from './rich-markdown-save-shortcut' +import type { KeyHandlerContext } from './rich-markdown-key-handler' + +// Style-only canonicalizer mirroring the #6080 rewrites; stands in for the live +// editor's getMarkdown so these tests need no DOM. Strips the trailing newline +// like the real getMarkdown, so the branch-6 exact re-parse comparison behaves as +// in production (the reconcile roundTrip and the edited getMarkdown agree on EOF). +function fakeCanonicalize(md: string): string { + return md + .replace(/__([^_]+)__/g, '**$1**') + .replace(/_([^_]+)_/g, '*$1*') + .replace(/\n+$/, '') +} +const roundTrip = (md: string): string => fakeCanonicalize(md) + +function refs(originalSource: string, baseCanonical: string): RichMarkdownReconcileRefs { + return { + originalSourceRef: { current: originalSource }, + baseCanonicalRef: { current: baseCanonical }, + lastCommittedMarkdownRef: { current: '' } + } +} + +function fakeEditor(getMarkdown: () => string): Editor { + return { getMarkdown } as unknown as Editor +} + +describe('commitRichMarkdownSerialization (shared disk-bound serialize chokepoint)', () => { + it('persists SOURCE-PRESERVING bytes, not raw getMarkdown (regression gate for #6080)', () => { + const r = refs('# Title\n\n_word_\n', '# Title\n\n*word*') + const editor = fakeEditor(() => '# Title!\n\n*word*') // canonical edit + + const { markdown, didSerialize } = commitRichMarkdownSerialization(editor, r, roundTrip) + + // Fails if this site reverted to raw getMarkdown() (would emit *word*). + expect(markdown).toBe('# Title!\n\n_word_\n') + expect(didSerialize).toBe(true) + }) + + it('advances all three refs so the next incremental edit patches onto the reconciled source', () => { + const r = refs('# Title\n\n_word_\n', '# Title\n\n*word*') + const editor = fakeEditor(() => '# Title!\n\n*word*') + + commitRichMarkdownSerialization(editor, r, roundTrip) + + expect(r.originalSourceRef.current).toBe('# Title!\n\n_word_\n') // reconciled bytes + expect(r.baseCanonicalRef.current).toBe('# Title!\n\n*word*') // canonical of reconciled + expect(r.lastCommittedMarkdownRef.current).toBe('# Title!\n\n_word_\n') // exact disk bytes + }) + + it('falls back to the last committed bytes when the editor is torn down (null)', () => { + const r = refs('# Title\n\n_word_\n', '# Title\n\n*word*') + r.lastCommittedMarkdownRef.current = '# Title\n\n_word_\n' + + const { markdown, didSerialize } = commitRichMarkdownSerialization(null, r, roundTrip) + + expect(didSerialize).toBe(false) + expect(markdown).toBe('# Title\n\n_word_\n') + // Refs untouched on a torn-down editor. + expect(r.originalSourceRef.current).toBe('# Title\n\n_word_\n') + }) + + it('does not crash when getMarkdown throws (editor destroyed mid-flush)', () => { + const r = refs('src', 'src') + r.lastCommittedMarkdownRef.current = 'safe' + const editor = fakeEditor(() => { + throw new Error('editor destroyed') + }) + + const { markdown, didSerialize } = commitRichMarkdownSerialization(editor, r, roundTrip) + + expect(didSerialize).toBe(false) + expect(markdown).toBe('safe') + }) +}) + +describe('handleRichMarkdownSaveShortcut (Cmd/Ctrl+S persistence site)', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + function saveEvent(): KeyboardEvent & { preventDefault: ReturnType } { + return { + key: 's', + code: 'KeyS', + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + isComposing: false, + preventDefault: vi.fn() + } as unknown as KeyboardEvent & { preventDefault: ReturnType } + } + + function saveContext(editor: Editor | null): { + ctx: KeyHandlerContext + onSave: ReturnType + onContentChange: ReturnType + flush: ReturnType + } { + const onSave = vi.fn() + const onContentChange = vi.fn() + const flush = vi.fn() + const ctx = { + editorRef: { current: editor }, + originalSourceRef: { current: '# Title\n\n_word_\n' }, + baseCanonicalRef: { current: '# Title\n\n*word*' }, + lastCommittedMarkdownRef: { current: '' }, + reconcileRoundTripRef: { current: roundTrip }, + onContentChangeRef: { current: onContentChange }, + onSaveRef: { current: onSave }, + flushPendingSerialization: flush + } as unknown as KeyHandlerContext + return { ctx, onSave, onContentChange, flush } + } + + it('flushes then saves SOURCE-PRESERVING bytes on Cmd+S (mac)', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const editor = fakeEditor(() => '# Title!\n\n*word*') + const { ctx, onSave, onContentChange, flush } = saveContext(editor) + const event = saveEvent() + + expect(handleRichMarkdownSaveShortcut(ctx, event)).toBe(true) + expect(event.preventDefault).toHaveBeenCalled() + expect(flush).toHaveBeenCalledTimes(1) + // onSave/onContentChange receive reconciled bytes, not raw *word*. + expect(onSave).toHaveBeenCalledWith('# Title!\n\n_word_\n') + expect(onContentChange).toHaveBeenCalledWith('# Title!\n\n_word_\n') + }) + + it('ignores non-save keystrokes and touches nothing', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const editor = fakeEditor(() => '# Title!\n\n*word*') + const { ctx, onSave, flush } = saveContext(editor) + const event = { ...saveEvent(), key: 'a', code: 'KeyA' } as KeyboardEvent & { + preventDefault: ReturnType + } + + expect(handleRichMarkdownSaveShortcut(ctx, event)).toBe(false) + expect(flush).not.toHaveBeenCalled() + expect(onSave).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts b/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts new file mode 100644 index 00000000000..d35e9bde42b --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts @@ -0,0 +1,59 @@ +import type { MutableRefObject } from 'react' +import type { Editor } from '@tiptap/react' +import { reconcileSerializedMarkdown } from './rich-markdown-source-reconcile' + +export type RichMarkdownReconcileRefs = { + /** Current on-disk source bytes; updated to the reconciled output each commit. */ + originalSourceRef: MutableRefObject + /** Canonical serialization of `originalSourceRef` (getMarkdown of the unedited doc). */ + baseCanonicalRef: MutableRefObject + /** Exact bytes last handed to disk; gates the external-change reload. */ + lastCommittedMarkdownRef: MutableRefObject +} + +export type RichMarkdownSerializationCommit = { + /** Bytes to persist to disk (reconciled, or last committed on a torn-down editor). */ + markdown: string + /** False when the editor was torn down before serializing (refs left untouched). */ + didSerialize: boolean +} + +/** + * Single place where reconciliation and ref updates happen for every disk-bound + * serialize site. Computes edited=getMarkdown(), reconciles toward the original + * source style, and advances the refs for the next incremental edit. + */ +export function commitRichMarkdownSerialization( + editor: Editor | null, + refs: RichMarkdownReconcileRefs, + roundTrip: (markdown: string) => string | null +): RichMarkdownSerializationCommit { + let edited: string | undefined + try { + edited = editor?.getMarkdown() + } catch { + // Why: the editor can be destroyed between scheduling and serializing; a + // save/restart flush must never crash here. + edited = undefined + } + if (edited === undefined) { + // Torn-down fallback: reuse the already-reconciled bytes without patching. + return { markdown: refs.lastCommittedMarkdownRef.current, didSerialize: false } + } + + const reconciled = reconcileSerializedMarkdown({ + originalSource: refs.originalSourceRef.current, + baseCanonical: refs.baseCanonicalRef.current, + edited, + roundTrip + }) + + refs.originalSourceRef.current = reconciled + // Why: reconciled ≡ edited semantically, so its canonical form is `edited` + // (also correct in every fallback branch, which returns `edited` verbatim). + refs.baseCanonicalRef.current = edited + // Why: the external-change guard short-circuits on lastCommittedMarkdownRef, so + // it must hold the exact reconciled bytes that reach disk, not the canonical form. + refs.lastCommittedMarkdownRef.current = reconciled + return { markdown: reconciled, didSerialize: true } +} diff --git a/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts b/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts index 447a7c08a53..3f982d9bba9 100644 --- a/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts +++ b/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts @@ -40,7 +40,13 @@ export type SlashCommandIcon = | { kind: 'component'; component: React.ComponentType<{ className?: string }> } | { kind: 'text'; value: string } -export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others' +export type SlashCommandGroup = + | 'Headings' + | 'Toggle headings' + | 'Basic blocks' + | 'Advanced' + | 'Media' + | 'Others' export type SlashCommand = { id: SlashCommandId diff --git a/src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts b/src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts new file mode 100644 index 00000000000..9d221d40427 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts @@ -0,0 +1,516 @@ +import { describe, expect, it, vi } from 'vitest' +import { reconcileSerializedMarkdown } from './rich-markdown-source-reconcile' +import { serializeRichMarkdownForReconcile } from './rich-markdown-reconcile-serializer' +import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' +import type { RichMarkdownImageResolverContext } from './rich-markdown-image-context' + +// A deterministic stand-in for the live editor's canonicalization, covering the +// exact style rewrites #6080 reports. Used both to derive baseCanonical/edited +// and as the injected safety `roundTrip`, so the fake stays self-consistent. +function fakeCanonicalize(md: string): string { + return md + .split('\n') + .map((line) => line.replace(/^(\s*)\* /, '$1- ')) // `* bullet` -> `- bullet` + .join('\n') + .replace(/__([^_]+)__/g, '**$1**') // `__strong__` -> `**strong**` (before emphasis) + .replace(/_([^_]+)_/g, '*$1*') // `_emphasis_` -> `*emphasis*` +} + +const roundTrip = (md: string): string => fakeCanonicalize(md) + +/** Reconcile using the fake canonicalizer for both the base and the safety re-parse. */ +function reconcileWithFake(originalSource: string, edited: string): string { + return reconcileSerializedMarkdown({ + originalSource, + baseCanonical: fakeCanonicalize(originalSource), + edited, + roundTrip + }) +} + +describe('reconcileSerializedMarkdown', () => { + it('preserves untouched non-canonical regions on a 1-char edit', () => { + const originalSource = '# Title\n\n_emphasis_ and __strong__\n\n* one\n* two\n' + const baseCanonical = fakeCanonicalize(originalSource) + const edited = baseCanonical.replace('# Title', '# Title!') + + const reconciled = reconcileWithFake(originalSource, edited) + + // Untouched markup keeps its original bytes. + expect(reconciled).toContain('_emphasis_') + expect(reconciled).toContain('__strong__') + expect(reconciled).toContain('* one') + expect(reconciled).toContain('* two') + // The edited region reflects the change. + expect(reconciled).toContain('# Title!') + // Never re-canonicalized. + expect(reconciled).not.toContain('*emphasis*') + expect(reconciled).not.toContain('- one') + }) + + it('preserves the trailing newline of the original source', () => { + const originalSource = '# H\n\n_word_\n' + const edited = fakeCanonicalize(originalSource).replace('# H', '# H!') + + const reconciled = reconcileWithFake(originalSource, edited) + + expect(reconciled.endsWith('\n')).toBe(true) + expect(reconciled).toContain('_word_') + }) + + it('keeps newly-typed content canonical while preserving the original style', () => { + const originalSource = '_old_\n' + const edited = '*old* and *new*\n' + + const reconciled = reconcileWithFake(originalSource, edited) + + expect(reconciled).toContain('_old_') // original preserved + expect(reconciled).toContain('*new*') // new content stays canonical + }) + + it('returns edited when the source is already canonical (branch 2)', () => { + const originalSource = '*word* here\n' + const edited = '*word* there\n' + + // baseCanonical === originalSource, so there is nothing to preserve. + expect(reconcileWithFake(originalSource, edited)).toBe(edited) + }) + + it('returns the source verbatim when there is no semantic change (branch 1)', () => { + const originalSource = '_word_ and * bullet\n' + const baseCanonical = fakeCanonicalize(originalSource) + + // edited === baseCanonical: e.g. cursor moved, selection changed, no edit. + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited: baseCanonical, + roundTrip + }) + + // Exact bytes, including trailing newline — zero disk churn. + expect(reconciled).toBe(originalSource) + }) + + it('falls back to canonical when either string exceeds the size cap (branch 3)', () => { + const big = 'line of text\n'.repeat(9000) // ~117 KB, above the size cap + const originalSource = `${big}_x_\n` + const edited = `${big}*x* y\n` + + // Non-canonical source + real semantic change, but oversize → canonical fallback. + expect(reconcileWithFake(originalSource, edited)).toBe(edited) + }) + + it('bounds diff work for replacement-heavy edits instead of using the 1s library default', () => { + let now = 0 + const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => { + now += 10 + return now + }) + const baseCanonical = 'a'.repeat(20_000) + const edited = 'b'.repeat(20_000) + + try { + const reconciled = reconcileSerializedMarkdown({ + originalSource: `_${baseCanonical.slice(1)}`, + baseCanonical, + edited, + roundTrip: (markdown) => markdown + }) + + expect(reconciled).toBe(edited) + // The 10ms budget expires almost immediately; the dependency's default + // one-second budget takes ~102 reads with this deterministic clock. + expect(dateNow.mock.calls.length).toBeLessThan(10) + } finally { + dateNow.mockRestore() + } + }) + + it('skips the dependency half-match path when its long seed repeats', () => { + const dateNow = vi.spyOn(Date, 'now') + const baseCanonical = 'ab'.repeat(24_500) + const edited = 'ba'.repeat(24_500) + + try { + const reconciled = reconcileSerializedMarkdown({ + originalSource: `_${baseCanonical.slice(1)}`, + baseCanonical, + edited, + roundTrip: (markdown) => markdown + }) + + expect(reconciled).toBe(edited) + // The dependency reads the clock when diffing; zero reads proves the + // repetitive-input preflight returned before its unbounded half-match scan. + expect(dateNow).not.toHaveBeenCalled() + } finally { + dateNow.mockRestore() + } + }) + + it('still preserves a small edit inside a highly repetitive document', () => { + const dateNow = vi.spyOn(Date, 'now') + const baseCanonical = 'ab'.repeat(24_500) + const editIndex = Math.floor(baseCanonical.length / 2) + const edited = `${baseCanonical.slice(0, editIndex)}X${baseCanonical.slice(editIndex + 1)}` + + try { + const reconciled = reconcileSerializedMarkdown({ + originalSource: `_${baseCanonical.slice(1)}`, + baseCanonical, + edited, + roundTrip: (markdown) => `a${markdown.slice(1)}` + }) + + expect(reconciled).toBe(`_${edited.slice(1)}`) + expect(dateNow).toHaveBeenCalled() + } finally { + dateNow.mockRestore() + } + }) + + it('falls back to canonical when a hunk fails to apply (branch 5)', () => { + const baseCanonical = 'The quick brown fox jumps over the lazy dog every morning.\n' + const edited = 'The quick brown fox LEAPS over the lazy dog every morning.\n' + const originalSource = 'Zzz totally unrelated content sharing nothing at all here.\n' + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip + }) + + expect(reconciled).toBe(edited) + }) + + it('falls back to canonical when the safety re-parse mismatches (branch 6)', () => { + const reconciled = reconcileSerializedMarkdown({ + originalSource: '_a_\n', + baseCanonical: '*a*\n', + edited: '*a* b\n', + // Simulates a fuzzy misplacement whose render differs from `edited`. + roundTrip: () => 'something entirely different' + }) + + expect(reconciled).toBe('*a* b\n') + }) + + it('falls back to canonical when the safety serializer returns null (branch 6)', () => { + const reconciled = reconcileSerializedMarkdown({ + originalSource: '_a_\n', + baseCanonical: '*a*\n', + edited: '*a* b\n', + roundTrip: () => null + }) + + expect(reconciled).toBe('*a* b\n') + }) + + it('preserves non-canonical regions across an incremental two-edit ref chain', () => { + const originalSource = '# Title\n\n_emphasis_\n\n* item\n' + const base = fakeCanonicalize(originalSource) + const edited1 = base.replace('# Title', '# Title A') + + const reconciled1 = reconcileWithFake(originalSource, edited1) + expect(reconciled1).toContain('_emphasis_') + expect(reconciled1).toContain('* item') + + // Simulate the commit helper advancing the refs: originalSource := reconciled1, + // baseCanonical := edited1. The second edit must still preserve the markup. + const edited2 = edited1.replace('# Title A', '# Title AB') + const reconciled2 = reconcileSerializedMarkdown({ + originalSource: reconciled1, + baseCanonical: edited1, + edited: edited2, + roundTrip + }) + + expect(reconciled2).toContain('_emphasis_') + expect(reconciled2).toContain('* item') + expect(reconciled2).toContain('# Title AB') + }) + + it('skips the safety re-parse for a canonical LF file with a trailing newline (branch 2)', () => { + // The common case: file is already canonical but ends in \n, so getMarkdown + // (which strips the trailing newline) never equals it byte-for-byte. This + // must NOT pay the expensive re-parse — the whole point of branch 2. + const originalSource = '# Title\n\n*word*\n\n- one\n- two\n' + const baseCanonical = '# Title\n\n*word*\n\n- one\n- two' // getMarkdown: no trailing \n + const edited = '# Title!\n\n*word*\n\n- one\n- two' + const roundTrip = vi.fn(() => edited) + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip + }) + + expect(roundTrip).not.toHaveBeenCalled() + // Edit applied, and the source's trailing newline is preserved. + expect(reconciled).toBe('# Title!\n\n*word*\n\n- one\n- two\n') + }) + + it('preserves CRLF + trailing newline for a canonical CRLF file without re-parsing (branch 2)', () => { + const originalSource = '# H\r\n\r\n*word*\r\n' + const baseCanonical = '# H\n\n*word*' // getMarkdown: LF, no trailing + const edited = '# H!\n\n*word*' + const roundTrip = vi.fn(() => edited) + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip + }) + + expect(roundTrip).not.toHaveBeenCalled() + expect(reconciled).toBe('# H!\r\n\r\n*word*\r\n') + // No mixed endings. + expect(reconciled.replace(/\r\n/g, '')).not.toContain('\n') + }) + + it('restores CRLF on the oversize fallback so a uniform-CRLF file never flips to LF (branch 3)', () => { + const big = 'line of text\r\n'.repeat(9000) // ~130 KB, above the cap, all CRLF + const originalSource = `${big}_x_\r\n` // non-canonical emphasis, oversize + const editedLf = `${big.replace(/\r\n/g, '\n')}*x* y\n` + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical: `${big.replace(/\r\n/g, '\n')}*x*\n`, + edited: editedLf, + roundTrip: () => null + }) + + // Canonical fallback content, but the source's CRLF endings are kept. + expect(reconciled).toContain('\r\n') + expect(reconciled.replace(/\r\n/g, '')).not.toContain('\n') + }) + + it('restores CRLF on the hunk-apply fallback (branch 5)', () => { + const originalSource = 'Zzz unrelated content sharing nothing here.\r\n' + const baseCanonical = 'The quick brown fox jumps over the lazy dog.\n' + const edited = 'The quick brown fox LEAPS over the lazy dog.\n' + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip + }) + + // Falls back to canonical content, but in the source's CRLF ending. + expect(reconciled).toBe('The quick brown fox LEAPS over the lazy dog.\r\n') + }) + + it('preserves CRLF endings on disk without mixing (branch 4 EOL restore)', () => { + const originalSource = '# H\r\n\r\n_word_\r\n\r\n* one\r\n' + // getMarkdown always emits LF, so base/edited are LF. + const baseCanonical = '# H\n\n*word*\n\n- one\n' + const edited = '# H!\n\n*word*\n\n- one\n' + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip + }) + + expect(reconciled).toContain('\r\n') + expect(reconciled).toContain('_word_') + expect(reconciled).toContain('# H!') + // No mixed endings: stripping CRLF leaves no lone LF. + expect(reconciled.replace(/\r\n/g, '')).not.toContain('\n') + }) + + it('lands a repeated-substring edit on the correct occurrence or falls back', () => { + const originalSource = ['- _alpha_', '- _beta_', '', '- _alpha_', '- _beta_', ''].join('\n') + const baseCanonical = fakeCanonicalize(originalSource) + // Edit only the FIRST "alpha". + const edited = baseCanonical.replace('*alpha*', '*alpha edited*') + + const reconciled = reconcileWithFake(originalSource, edited) + + // Invariant: the reconciled bytes render exactly to `edited` — the change is + // never silently relocated to the wrong occurrence or dropped. + expect(fakeCanonicalize(reconciled).trimEnd()).toBe(edited.trimEnd()) + // Either the source-preserving path landed it, or it cleanly fell back. + const landedInStyle = + reconciled.includes('_alpha edited_') && reconciled.split('_beta_').length === 3 + expect(landedInStyle || reconciled === edited).toBe(true) + }) +}) + +describe('serializeRichMarkdownForReconcile (real editor pipeline)', () => { + const serializerContext = { + htmlSuperscriptLinkContext: createRichMarkdownHtmlSuperscriptLinkContext({ + sourceFilePath: '', + worktreeId: '', + worktreeRoot: null, + sourceOwner: { kind: 'unknown' as const } + }), + imageResolverContext: { + filePath: '', + runtimeContext: undefined + } satisfies RichMarkdownImageResolverContext + } + + const serialize = (md: string): string | null => + serializeRichMarkdownForReconcile(md, serializerContext) + + it('applies normalizeEmptyListItems so empty list items round-trip stably', () => { + // `3. ` immediately before a heading parses as an empty list item; without the + // normalize step the safety re-parse would spuriously mismatch and no-op. + const doc = '3. \n# Heading\n' + const once = serialize(doc) + expect(once).not.toBeNull() + // Idempotent: re-serializing the output is stable (the live editor's steady state). + expect(serialize(once!)?.trimEnd()).toBe(once!.trimEnd()) + }) + + it('reconciles a non-canonical doc end-to-end with the real serializer, preserving style', () => { + const originalSource = '# Title\n\n_emphasis_ text\n' + const baseCanonical = serialize(originalSource)! + const edited = baseCanonical.replace('# Title', '# Title!') + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip: (md) => serialize(md) + }) + + expect(reconciled).toContain('_emphasis_') // original style preserved + expect(reconciled).toContain('# Title!') // edit applied + // Safety invariant: reconciled renders exactly to the editor's canonical output. + expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd()) + }) + + it('minimizes the diff for the exact #6080 repro (real serializer)', () => { + // The reported case: a 1-char H1 edit must not rewrite untouched markup. + const originalSource = + '# Title\n\n_emphasis_ and __strong__\n\n* one\n* two\n* three\n\n_more emphasis_\n' + const baseCanonical = serialize(originalSource)! + const edited = baseCanonical.replace('# Title', '# Title!') + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip: (md) => serialize(md) + }) + + // Every untouched non-canonical construct keeps its original bytes. + for (const preserved of [ + '_emphasis_', + '__strong__', + '* one', + '* two', + '* three', + '_more emphasis_' + ]) { + expect(reconciled).toContain(preserved) + } + // The edit landed, and nothing else was re-canonicalized. + expect(reconciled).toContain('# Title!') + expect(reconciled).not.toContain('*emphasis*') + expect(reconciled).not.toContain('- one') + // Safety invariant: reconciled renders exactly to the editor's canonical output. + expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd()) + }) + + it('branch-2 fast path output still renders to the canonical edit (real serializer)', () => { + // Guards the re-parse-skipping fast path: even though branch 2 does NOT run + // the safety re-parse, its output must satisfy the same invariant the full + // path proves — reconciled renders exactly to the editor's canonical output. + const originalSource = '# Title\n\n*emphasis* text\n\n- one\n- two\n' // canonical + trailing \n + const baseCanonical = serialize(originalSource)! + expect(originalSource).not.toBe(baseCanonical) // differs only by the trailing \n + const edited = baseCanonical.replace('# Title', '# Title!') + let roundTripCalls = 0 + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip: (md) => { + roundTripCalls += 1 + return serialize(md) + } + }) + + expect(roundTripCalls).toBe(0) // took the cheap path + expect(reconciled).toBe('# Title!\n\n*emphasis* text\n\n- one\n- two\n') + expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd()) + }) + + it('does not drop a user-added trailing empty paragraph (branch 2 edited-side guard)', () => { + // getMarkdown keeps `\n\n` for a real trailing empty paragraph, so the fast + // path must NOT strip it: a canonical doc with a single trailing newline + // (branch-2 eligible on the source side) edited to add a trailing empty + // paragraph must persist that block, not silently drop it on save. + const originalSource = '# H\n\ntext\n' + const baseCanonical = serialize(originalSource)! // '# H\n\ntext' + const edited = '# H\n\ntext\n\n' // user pressed Enter at EOF + let roundTripCalls = 0 + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip: (md) => { + roundTripCalls += 1 + return serialize(md) + } + }) + + expect(roundTripCalls).toBeGreaterThan(0) // deferred to the safety-verified path + // Exact reload equality (NOT trimEnd): the empty paragraph survives. + expect(serialize(reconciled)).toBe(serialize(edited)) + }) + + it('does not introduce a spurious   when a trailing-blank-line doc is edited (branch 2 guard)', () => { + // A canonical heading-ending doc with 3 trailing blank lines: the fast path + // must NOT preserve that long trailing run while skipping the re-parse, or a + // reload materializes a literal   paragraph (a content change). The + // >1-trailing-newline guard defers this to the safety-checked path. + const originalSource = '# Notes\n\nSome text here.\n\n## TODO\n\n\n\n' + const baseCanonical = serialize(originalSource)! + const edited = baseCanonical.replace('## TODO', '## TODO\n\nA new note.') + let roundTripCalls = 0 + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip: (md) => { + roundTripCalls += 1 + return serialize(md) + } + }) + + expect(roundTripCalls).toBeGreaterThan(0) // took the safety-verified path + expect(reconciled).not.toContain(' ') + // Safety invariant: reconciled renders exactly to the editor's canonical edit. + expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd()) + }) + + it('falls back cleanly when an empty-list doc cannot be source-preserved', () => { + // Combines an empty list item with non-canonical emphasis; whatever the fuzzy + // patch does, the output must render to `edited` (no corruption). + const originalSource = '_lead_\n\n3. \n# Heading\n' + const baseCanonical = serialize(originalSource)! + const edited = baseCanonical.replace('Heading', 'Heading!') + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip: (md) => serialize(md) + }) + + expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd()) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts new file mode 100644 index 00000000000..a1bdf3997a9 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts @@ -0,0 +1,195 @@ +import { + applyPatches, + cleanupEfficiency, + cleanupSemantic, + makeDiff, + makePatches +} from '@sanity/diff-match-patch' + +// Why: bound the safety re-parse cost — it builds a throwaway TipTap editor per +// commit, whose parse time scales with document length (UTF-16 code units, the +// unit `.length` returns and the closest cheap proxy for parse cost — not UTF-8 +// bytes). Measured ~50-67ms at the cap on fast HW (higher on low-end/SSH), under +// the 300ms serialize debounce; a higher cap risks a main-thread stall on +// slow/SSH hosts. Above the cap we use today's canonical output (no regression). +const RECONCILE_SIZE_CAP_CODE_UNITS = 50_000 + +// Why: diff-match-patch defaults to a one-second search, which freezes the +// renderer on replacement-heavy paste/edit paths. A timed-out coarse diff is +// still safe because the round-trip proof below rejects any bad placement. +const RECONCILE_DIFF_TIMEOUT_SECONDS = 0.01 + +export type ReconcileSerializedMarkdownParams = { + /** Current on-disk source bytes (possibly non-canonical, possibly CRLF). */ + originalSource: string + /** Canonical serialization of `originalSource` (what getMarkdown returns unedited). */ + baseCanonical: string + /** Canonical serialization after the user's edit (getMarkdown, always LF). */ + edited: string + /** + * Re-serializes reconciled bytes through the live editor's pipeline. Injected + * so the reconcile logic is unit-testable without a DOM. Returns null when the + * throwaway serializer fails (treated as a safety mismatch → canonical fallback). + */ + roundTrip: (markdown: string) => string | null +} + +/** + * Carries the user's edit into the original source style so untouched regions + * keep their non-canonical bytes. Falls back to the canonical `edited` output + * (today's behavior) whenever the source-preserving transform cannot be proven + * render-equivalent — so it can never corrupt or relocate content. + */ +export function reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip +}: ReconcileSerializedMarkdownParams): string { + // Branch 1: no semantic change vs the unedited doc → preserve the original + // bytes verbatim (incl. trailing newline / EOL); zero disk churn. + if (edited === baseCanonical) { + return originalSource + } + + // Work in LF space: getMarkdown emits LF while `originalSource` may be CRLF, so + // patching LF-context hunks against raw CRLF would fuzzy-match poorly and mix + // endings. The detected EOL is restored on EVERY non-verbatim return below so a + // uniform-CRLF file never silently flips to LF, even on a canonical fallback. + const eol = detectDominantEol(originalSource) + const originalSourceLf = toLf(originalSource) + const baseLf = toLf(baseCanonical) + const editedLf = toLf(edited) + + // Branch 2: the source body equals the canonical `edited` body apart from its + // EOL/trailing newlines, so nothing non-canonical remains to preserve. Skip the + // expensive safety re-parse and carry the source's EOL + trailing newline onto + // the edit. Two guards keep the re-parse skip provably drift-free: + // - source trailing run ≤ 1 newline: a longer trailing-blank run can + // materialize a spurious ` ` empty paragraph on reload when the edit + // turns the EOF block into a paragraph; + // - `edited` has no trailing newline: getMarkdown keeps a trailing `\n\n` for a + // real trailing empty paragraph, so stripping it here would silently drop a + // block the user added. + // Both cases (rare) defer to the branch-6-verified path below instead. + const originalTrailingNewlines = originalSourceLf.match(/\n+$/)?.[0] ?? '' + if ( + originalTrailingNewlines.length <= 1 && + !editedLf.endsWith('\n') && + stripTrailingNewlines(originalSourceLf) === stripTrailingNewlines(baseLf) + ) { + return restoreEol(editedLf + originalTrailingNewlines, eol) + } + + // Branch 3: oversize → bounded-cost canonical fallback (today's behavior). + if ( + Math.max(originalSource.length, baseCanonical.length, edited.length) > + RECONCILE_SIZE_CAP_CODE_UNITS + ) { + return restoreEol(editedLf, eol) + } + + // Branch 4: run the divergent-base patch entirely in LF space. + // Why: the dependency's half-match accelerator ignores its diff deadline and + // can spend 100ms+ scanning repeated long seeds. Returning canonical is safer + // than entering that unbounded path for highly repetitive replacements. + if (hasRepeatedHalfMatchSeed(baseLf, editedLf)) { + return restoreEol(editedLf, eol) + } + let diffs = makeDiff(baseLf, editedLf, { + checkLines: true, + timeout: RECONCILE_DIFF_TIMEOUT_SECONDS + }) + // Match makePatches(textA, textB)'s cleanup behavior while supplying the + // bounded diff ourselves instead of inheriting the library's 1s timeout. + if (diffs.length > 2) { + diffs = cleanupSemantic(diffs) + diffs = cleanupEfficiency(diffs) + } + const patches = makePatches(baseLf, diffs) + const [reconciledLf, results] = applyPatches(patches, originalSourceLf) + + // Branch 5: a hunk that failed to locate in the non-canonical source → the + // fuzzy match is unreliable here, so fall back to canonical. + if (results.some((applied) => !applied)) { + return restoreEol(editedLf, eol) + } + + // Branch 6: prove the reconciled bytes render-equal the editor's document. + // Any fuzzy misplacement (e.g. onto a repeated substring) changes canonical + // output and is caught here → canonical fallback. Compared under norm so only + // style/EOL/EOF differences are tolerated. + const reparsed = roundTrip(reconciledLf) + if (reparsed === null || normalizeForSafety(reparsed) !== normalizeForSafety(editedLf)) { + return restoreEol(editedLf, eol) + } + + // Restore the detected EOL as the final step so reconciled CRLF stays CRLF. + return restoreEol(reconciledLf, eol) +} + +function stripTrailingNewlines(lfText: string): string { + return lfText.replace(/\n+$/, '') +} + +function detectDominantEol(text: string): '\n' | '\r\n' { + const totalLf = (text.match(/\n/g) ?? []).length + const crlf = (text.match(/\r\n/g) ?? []).length + const lfOnly = totalLf - crlf + return crlf > 0 && crlf >= lfOnly ? '\r\n' : '\n' +} + +function toLf(text: string): string { + return text.replace(/\r\n/g, '\n') +} + +function restoreEol(lfText: string, eol: '\n' | '\r\n'): string { + // lfText is pure LF, so a blind LF→CRLF replace produces no mixed endings. + return eol === '\r\n' ? lfText.replace(/\n/g, '\r\n') : lfText +} + +function normalizeForSafety(text: string): string { + // Why: both operands are canonical getMarkdown outputs, so an equal-render + // reconciliation is byte-identical here apart from EOL. Compare exactly (only + // CRLF-normalized) — a trailing `\n\n` empty paragraph is semantic, so a lenient + // trimEnd would mask exactly the trailing-block drift branch 6 must catch. + return text.replace(/\r\n/g, '\n') +} + +function hasRepeatedHalfMatchSeed(textA: string, textB: string): boolean { + // Match diff-match-patch's own prefix/suffix trimming so a small edit in a + // repetitive document is not mistaken for a repetitive replacement. + const minimumLength = Math.min(textA.length, textB.length) + let prefixLength = 0 + while ( + prefixLength < minimumLength && + textA.charCodeAt(prefixLength) === textB.charCodeAt(prefixLength) + ) { + prefixLength += 1 + } + let suffixLength = 0 + while ( + suffixLength < minimumLength - prefixLength && + textA.charCodeAt(textA.length - suffixLength - 1) === + textB.charCodeAt(textB.length - suffixLength - 1) + ) { + suffixLength += 1 + } + const middleA = textA.slice(prefixLength, textA.length - suffixLength) + const middleB = textB.slice(prefixLength, textB.length - suffixLength) + const longText = middleA.length > middleB.length ? middleA : middleB + const shortText = middleA.length > middleB.length ? middleB : middleA + if (longText.length < 4 || shortText.length * 2 < longText.length) { + return false + } + + const seedLength = Math.floor(longText.length / 4) + for (const start of [Math.ceil(longText.length / 4), Math.ceil(longText.length / 2)]) { + const seed = longText.slice(start, start + seedLength) + const firstMatch = shortText.indexOf(seed) + if (firstMatch !== -1 && shortText.includes(seed, firstMatch + 1)) { + return true + } + } + return false +} diff --git a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts index 40822dc7bb3..b9675d5e385 100644 --- a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts @@ -60,6 +60,9 @@ function createContext(editor: Editor): KeyHandlerContext { editorRef: { current: editor }, rootRef: { current: null }, lastCommittedMarkdownRef: { current: '' }, + originalSourceRef: { current: '' }, + baseCanonicalRef: { current: '' }, + reconcileRoundTripRef: { current: () => null }, onContentChangeRef: { current: vi.fn() }, onSaveRef: { current: vi.fn() }, isEditingLinkRef: { current: false }, diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts new file mode 100644 index 00000000000..c136095b8b0 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import { Editor } from '@tiptap/core' +import { useRichMarkdownProgrammaticSync } from './useRichMarkdownProgrammaticSync' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { + createRichMarkdownEditorCodec, + type RichMarkdownEditorCodec +} from './rich-markdown-source-transport' +import { + createRichMarkdownHtmlSuperscriptLinkContext, + type RichMarkdownHtmlSuperscriptLinkContext +} from './rich-markdown-html-superscript-link-context' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' + +function buildEditor( + codec: RichMarkdownEditorCodec, + htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext, + markdown: string +): Editor { + return new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ + codec, + htmlSuperscriptLinks: true, + htmlSuperscriptLinkContext + }), + content: encodeRawMarkdownHtmlForRichEditor(markdown, codec, { htmlSuperscriptLinks: true }), + contentType: 'markdown' + }) +} + +describe('useRichMarkdownProgrammaticSync external-reload baseline adoption (#6080)', () => { + it('adopts externally-canonicalized bytes as the reconciliation baseline without a reload', () => { + const codec = createRichMarkdownEditorCodec() + const htmlSuperscriptLinkContext = createRichMarkdownHtmlSuperscriptLinkContext({ + sourceFilePath: '', + worktreeId: '', + worktreeRoot: null, + sourceOwner: { kind: 'unknown' as const } + }) + // The editor currently shows emphasis loaded from the original `_old_` source; + // its canonical (getMarkdown) form is `*old*` with no trailing newline. + const canonical = '# T\n\n*old*' + const editor = buildEditor(codec, htmlSuperscriptLinkContext, canonical) + expect(editor.getMarkdown()).toBe(canonical) + + // Refs still reflect the ORIGINAL non-canonical source bytes on disk. + const lastCommittedMarkdownRef = { current: '# T\n\n_old_' } + const originalSourceRef = { current: '# T\n\n_old_' } + const baseCanonicalRef = { current: canonical } + const isApplyingProgrammaticUpdateRef = { current: false } + + try { + // An external tool rewrites disk to the canonical bytes (same semantics, + // new byte-level style). The doc already renders them, so no reload — but + // the reconciliation baseline must still be refreshed to the new bytes. + renderHook(() => + useRichMarkdownProgrammaticSync({ + codec, + content: canonical, + docLinkMenuSetter: vi.fn(), + editor, + fileId: 'f1', + filePath: '/repo/README.md', + isApplyingProgrammaticUpdateRef, + lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, + markdownDocuments: undefined, + rootRef: { current: null }, + runtimeEnvironmentId: null, + settings: null, + slashMenuSetter: vi.fn(), + worktreeId: 'w1', + worktreeRoot: null + }) + ) + + // Without the fix these stay stale at `_old_`, and the next edit would + // rewrite the file back to `_old_`, silently undoing the external change. + expect(lastCommittedMarkdownRef.current).toBe(canonical) + expect(originalSourceRef.current).toBe(canonical) + expect(baseCanonicalRef.current).toBe(canonical) + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts index 2086a39f2d3..92d1c4308b5 100644 --- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts @@ -22,6 +22,8 @@ type RichMarkdownProgrammaticSyncOptions = { filePath: string isApplyingProgrammaticUpdateRef: MutableRefObject lastCommittedMarkdownRef: MutableRefObject + originalSourceRef: MutableRefObject + baseCanonicalRef: MutableRefObject markdownDocuments?: MarkdownDocument[] rootRef: MutableRefObject runtimeEnvironmentId?: string | null @@ -46,6 +48,8 @@ export function useRichMarkdownProgrammaticSync({ filePath, isApplyingProgrammaticUpdateRef, lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, markdownDocuments, rootRef, runtimeEnvironmentId, @@ -101,12 +105,29 @@ export function useRichMarkdownProgrammaticSync({ if (!editor) { return } - if (content === lastCommittedMarkdownRef.current || editor.getMarkdown() === content) { + if (content === lastCommittedMarkdownRef.current) { + return + } + if (editor.getMarkdown() === content) { + // Why: disk bytes changed but already render-equal to the current doc (e.g. + // an external tool canonicalized byte-level style). Skip the disruptive + // reload, but adopt the new bytes as the reconciliation baseline so the next + // edit patches onto the fresh source, not the stale pre-change source. + lastCommittedMarkdownRef.current = content + originalSourceRef.current = content + baseCanonicalRef.current = content return } isApplyingProgrammaticUpdateRef.current = true try { - applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef, codec) + applyExternalRichMarkdownContent( + editor, + content, + lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, + codec + ) } finally { isApplyingProgrammaticUpdateRef.current = false } @@ -120,6 +141,8 @@ export function useRichMarkdownProgrammaticSync({ fileId, isApplyingProgrammaticUpdateRef, lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, rootRef, slashMenuSetter ]) @@ -129,6 +152,8 @@ function applyExternalRichMarkdownContent( editor: Editor, content: string, lastCommittedMarkdownRef: MutableRefObject, + originalSourceRef: MutableRefObject, + baseCanonicalRef: MutableRefObject, codec: RichMarkdownEditorCodec ): void { try { @@ -145,6 +170,10 @@ function applyExternalRichMarkdownContent( // external content, matching onCreate's single-paragraph reflow behavior. normalizeEmptyListItems(editor) lastCommittedMarkdownRef.current = content + // Why: reset the reconciliation baseline to the freshly loaded external bytes + // so subsequent edits preserve the new source style, not the pre-reload one. + originalSourceRef.current = content + baseCanonicalRef.current = editor.getMarkdown() if (hadFocus) { const docSize = editor.state.doc.content.size editor diff --git a/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts b/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts new file mode 100644 index 00000000000..556759a7241 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts @@ -0,0 +1,42 @@ +import { useRef, type MutableRefObject } from 'react' +import { useAppStore } from '@/store' +import { serializeRichMarkdownForReconcile } from './rich-markdown-reconcile-serializer' +import { createRichMarkdownImageResolverContext } from './rich-markdown-image-context' +import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' + +type ReconcileRoundTripParams = { + htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext + filePath: string + runtimeEnvironmentId?: string | null + worktreeId: string + worktreeRoot: string | null +} + +/** + * Exposes the reconciliation safety serializer as a render-updated ref. It + * mirrors the live editor's codec/link/image context so the step-6 re-parse + * matches getMarkdown(), and only runs on commit — so rebuilding the closure + * each render is cheap and always reflects the latest context. + */ +export function useRichMarkdownReconcileRoundTrip({ + htmlSuperscriptLinkContext, + filePath, + runtimeEnvironmentId, + worktreeId, + worktreeRoot +}: ReconcileRoundTripParams): MutableRefObject<(markdown: string) => string | null> { + const settings = useAppStore((s) => s.settings) + const ref = useRef<(markdown: string) => string | null>(() => null) + ref.current = (markdown) => + serializeRichMarkdownForReconcile(markdown, { + htmlSuperscriptLinkContext, + imageResolverContext: createRichMarkdownImageResolverContext({ + filePath, + runtimeEnvironmentId, + settings, + worktreeId, + worktreeRoot + }) + }) + return ref +} diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index 79ea48d8929..ad7a1197323 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -205,8 +205,11 @@ describe('feature interaction writer boundaries', () => { const taskPageSource = componentSource('TaskPage.tsx') const jiraWriter = "recordFeatureInteraction('jira-tasks')" + // End boundary is the declaration after the handler: the Jira connect flow + // now lives in the shared JiraConnectDialog, so handleJiraConnect (the prior + // marker) no longer exists in TaskPage. expect( - sourceBetween(taskPageSource, 'const handleUseJiraItem', 'const handleJiraConnect') + sourceBetween(taskPageSource, 'const handleUseJiraItem', 'const taskPageListChromeHidden') ).toContain(jiraWriter) }) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index 822c7207091..dfcf8337e3a 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -1955,7 +1955,7 @@ describe('FloatingTerminalPanel close behavior', () => { target }) - expect(mocks.closeTab).toHaveBeenCalledWith('tab-1') + expect(mocks.closeTab).toHaveBeenCalledWith('tab-1', { reason: 'cleanup' }) expect(panelElement.focus).toHaveBeenCalledWith({ preventScroll: true }) }) @@ -2002,7 +2002,7 @@ describe('FloatingTerminalPanel close behavior', () => { }) attachRef(panel.props.ref, null) - expect(mocks.closeTab).toHaveBeenCalledWith('tab-1') + expect(mocks.closeTab).toHaveBeenCalledWith('tab-1', { reason: 'cleanup' }) expect(cancelAnimationFrame).toHaveBeenCalledWith(42) expect(panelElement.focus).not.toHaveBeenCalled() }) @@ -2046,7 +2046,7 @@ describe('FloatingTerminalPanel close behavior', () => { target }) - expect(mocks.closeTab).toHaveBeenCalledWith('tab-1') + expect(mocks.closeTab).toHaveBeenCalledWith('tab-1', { reason: 'cleanup' }) expect(panelElement.focus).not.toHaveBeenCalled() }) @@ -2197,7 +2197,7 @@ describe('FloatingTerminalPanel close behavior', () => { const tabBar = findByTypeName(element, 'TabBar') ;(tabBar.props.onClose as (tabId: string) => void)('tab-1') - expect(mocks.closeTab).toHaveBeenCalledWith('tab-1') + expect(mocks.closeTab).toHaveBeenCalledWith('tab-1', { reason: 'cleanup' }) expect(onOpenChange).not.toHaveBeenCalled() }) @@ -2212,7 +2212,7 @@ describe('FloatingTerminalPanel close behavior', () => { const tabBar = findByTypeName(element, 'TabBar') ;(tabBar.props.onClose as (tabId: string) => void)('tab-2') - expect(mocks.closeTab).toHaveBeenCalledWith('tab-2') + expect(mocks.closeTab).toHaveBeenCalledWith('tab-2', { reason: 'cleanup' }) expect(onOpenChange).not.toHaveBeenCalled() }) @@ -2232,7 +2232,7 @@ describe('FloatingTerminalPanel close behavior', () => { mocks.closeTab.mockClear() ;(terminalPane.props.onCloseTab as () => void)() - expect(mocks.closeTab).toHaveBeenCalledWith('tab-1') + expect(mocks.closeTab).toHaveBeenCalledWith('tab-1', { reason: 'cleanup' }) expect(onOpenChange).not.toHaveBeenCalled() }) @@ -2331,7 +2331,7 @@ describe('FloatingTerminalPanel close behavior', () => { ;(tabBar.props.onClose as (tabId: string) => void)('tab-1') expect(mocks.closeWebRuntimeSessionTab).not.toHaveBeenCalled() - expect(mocks.closeTab).toHaveBeenCalledWith('tab-1') + expect(mocks.closeTab).toHaveBeenCalledWith('tab-1', { reason: 'cleanup' }) expect(onOpenChange).not.toHaveBeenCalled() }) @@ -2384,14 +2384,14 @@ describe('FloatingTerminalPanel close behavior', () => { ]) ;(tabBar.props.onCloseOthers as (tabId: string) => void)('new-keep') - expect(mocks.closeTab).toHaveBeenCalledWith('new-left') - expect(mocks.closeTab).toHaveBeenCalledWith('new-right') + expect(mocks.closeTab).toHaveBeenCalledWith('new-left', { reason: 'cleanup' }) + expect(mocks.closeTab).toHaveBeenCalledWith('new-right', { reason: 'cleanup' }) expect(mocks.closeTab).not.toHaveBeenCalledWith('old-left') mocks.closeTab.mockClear() ;(tabBar.props.onCloseToRight as (tabId: string) => void)('new-left') - expect(mocks.closeTab).toHaveBeenCalledWith('new-keep') - expect(mocks.closeTab).toHaveBeenCalledWith('new-right') + expect(mocks.closeTab).toHaveBeenCalledWith('new-keep', { reason: 'cleanup' }) + expect(mocks.closeTab).toHaveBeenCalledWith('new-right', { reason: 'cleanup' }) expect(mocks.closeTab).not.toHaveBeenCalledWith('old-keep') }) @@ -2412,8 +2412,8 @@ describe('FloatingTerminalPanel close behavior', () => { const tabBar = findByTypeName(element, 'TabBar') ;(tabBar.props.onCloseToRight as (tabId: string) => void)('tab-c') - expect(mocks.closeTab).toHaveBeenCalledWith('tab-a') - expect(mocks.closeTab).toHaveBeenCalledWith('tab-b') + expect(mocks.closeTab).toHaveBeenCalledWith('tab-a', { reason: 'cleanup' }) + expect(mocks.closeTab).toHaveBeenCalledWith('tab-b', { reason: 'cleanup' }) expect(mocks.closeTab).not.toHaveBeenCalledWith('tab-c') }) }) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index ee85d577804..4f0b65ea3dc 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -748,7 +748,7 @@ export function FloatingTerminalPanel({ const dirtyEditorFileIds: string[] = [] for (const item of items) { if (item.contentType === 'terminal') { - closeTab(item.entityId) + closeTab(item.entityId, { reason: 'cleanup' }) } else if (item.contentType === 'browser') { destroyWorkspaceWebviews(state.browserPagesByWorkspace, item.entityId) closeBrowserTab(item.entityId) diff --git a/src/renderer/src/components/jira-connect-dialog.tsx b/src/renderer/src/components/jira-connect-dialog.tsx index eff9f0cde24..0de9093185c 100644 --- a/src/renderer/src/components/jira-connect-dialog.tsx +++ b/src/renderer/src/components/jira-connect-dialog.tsx @@ -1,4 +1,4 @@ -import { useId, useState } from 'react' +import { useId, useLayoutEffect, useState } from 'react' import { LoaderCircle, Lock } from 'lucide-react' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' @@ -13,6 +13,7 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { cn } from '@/lib/utils' import { hasRemoteProviderRuntime } from '@/lib/provider-runtime-context' import { translate } from '@/i18n/i18n' @@ -26,6 +27,10 @@ type JiraConnectDialogProps = { } type ConnectState = 'idle' | 'connecting' | 'error' +type JiraInstanceType = 'cloud' | 'server' +// Self-hosted Jira accepts either a personal access token (Bearer) or classic +// username + password (Basic); older Server/DC instances predate PATs. +type ServerAuthMethod = 'pat' | 'basic' // Why: mirrors the inline Jira connect dialog in TaskPage so the onboarding // "Connect integrations" step can reuse the same site URL + email + API token @@ -45,15 +50,39 @@ export function JiraConnectDialog({ const tokenId = useId() const errorId = useId() + const [instanceType, setInstanceType] = useState('cloud') + const [serverAuthMethod, setServerAuthMethod] = useState('pat') const [siteUrl, setSiteUrl] = useState('') const [email, setEmail] = useState('') const [apiToken, setApiToken] = useState('') const [connectState, setConnectState] = useState('idle') const [connectError, setConnectError] = useState(null) + // Start every open with a clean slate so a previously-typed secret, stale + // instance/auth-method selection, or old error can't linger across reopens. + // Runs before paint so a stale credential never renders for a frame. + useLayoutEffect(() => { + if (!open) { + return + } + setInstanceType('cloud') + setServerAuthMethod('pat') + setSiteUrl('') + setEmail('') + setApiToken('') + setConnectState('idle') + setConnectError(null) + }, [open]) + + const isServer = instanceType === 'server' + // `needsIdentity` folds "Cloud Atlassian email" and "self-hosted Basic + // username" — the identity slot that keys/labels the stored site. PAT auth + // uses no identity, so the email field is hidden and left empty. + const isServerBasic = isServer && serverAuthMethod === 'basic' + const needsIdentity = !isServer || isServerBasic const canSubmit = Boolean(siteUrl.trim()) && - Boolean(email.trim()) && + (!needsIdentity || Boolean(email.trim())) && Boolean(apiToken.trim()) && connectState !== 'connecting' const credentialStorageCopy = hasRemoteProviderRuntime(settings) @@ -67,6 +96,16 @@ export function JiraConnectDialog({ } } + // A Cloud email, a Server username, a PAT, and an account password are + // different secrets; drop the credential fields when the deployment or auth + // method changes so one can't be submitted as another (e.g. a password + // silently riding along as a Bearer PAT). + const clearCredentialsOnModeSwitch = (): void => { + setEmail('') + setApiToken('') + clearErrorOnEdit() + } + const handleOpenChange = (nextOpen: boolean): void => { if (connectState !== 'connecting') { onOpenChange(nextOpen) @@ -77,7 +116,12 @@ export function JiraConnectDialog({ const trimmedSite = siteUrl.trim() const trimmedEmail = email.trim() const trimmedToken = apiToken.trim() - if (!trimmedSite || !trimmedEmail || !trimmedToken || connectState === 'connecting') { + if ( + !trimmedSite || + (needsIdentity && !trimmedEmail) || + !trimmedToken || + connectState === 'connecting' + ) { return } setConnectState('connecting') @@ -85,8 +129,11 @@ export function JiraConnectDialog({ try { const result = await connectJira({ siteUrl: trimmedSite, - email: trimmedEmail, - apiToken: trimmedToken + // Cloud sends the Atlassian email; self-hosted Basic sends the username; + // PAT sends nothing, so a stale email can't key/label the stored site. + email: needsIdentity ? trimmedEmail : '', + apiToken: trimmedToken, + authType: instanceType }) if (!mountedRef.current) { return @@ -95,6 +142,8 @@ export function JiraConnectDialog({ setSiteUrl('') setEmail('') setApiToken('') + setInstanceType('cloud') + setServerAuthMethod('pat') setConnectState('idle') onOpenChange(false) onConnected?.() @@ -121,31 +170,110 @@ export function JiraConnectDialog({ {translate('auto.components.jira.connect.dialog.8388bdea2b', 'Connect Jira site')} - {translate( - 'auto.components.jira.connect.dialog.d785c42b8b', - 'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.' - )} + {!isServer + ? translate( + 'auto.components.jira.connect.dialog.d785c42b8b', + 'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.' + ) + : isServerBasic + ? translate( + 'auto.components.jira.connect.dialog.1d947a07ab', + 'Use a self-hosted Jira base URL, username, and password to browse issues.' + ) + : translate( + 'auto.components.jira.connect.dialog.2e2b69e48e', + 'Use a self-hosted Jira base URL and a personal access token to browse issues.' + )}
{ event.preventDefault() void handleConnect() }} >
+ { + if (!value || connectState === 'connecting') { + return + } + setInstanceType(value as JiraInstanceType) + clearCredentialsOnModeSwitch() + }} + aria-label={translate( + 'auto.components.jira.connect.dialog.b67e919bd5', + 'Jira instance type' + )} + > + + {translate('auto.components.jira.connect.dialog.17787d6e4b', 'Atlassian Cloud')} + + + {translate('auto.components.jira.connect.dialog.bc7a831773', 'Self-hosted')} + + + {isServer ? ( + { + if (!value || connectState === 'connecting') { + return + } + setServerAuthMethod(value as ServerAuthMethod) + clearCredentialsOnModeSwitch() + }} + aria-label={translate( + 'auto.components.jira.connect.dialog.f49708c369', + 'Jira authentication method' + )} + > + + {translate( + 'auto.components.jira.connect.dialog.730d973bae', + 'Personal access token' + )} + + + {translate( + 'auto.components.jira.connect.dialog.84a810dd0e', + 'Username & password' + )} + + + ) : null}
{ setSiteUrl(event.target.value) @@ -154,36 +282,66 @@ export function JiraConnectDialog({ disabled={connectState === 'connecting'} />
-
- - { - setEmail(event.target.value) - clearErrorOnEdit() - }} - disabled={connectState === 'connecting'} - /> -
+ {needsIdentity ? ( +
+ + { + setEmail(event.target.value) + clearErrorOnEdit() + }} + disabled={connectState === 'connecting'} + /> +
+ ) : null}
{ setApiToken(event.target.value) @@ -199,24 +357,40 @@ export function JiraConnectDialog({ {connectError}

) : null} -

- {translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '} - - . -

+

+ ) : isServer ? ( +

+ {translate( + 'auto.components.jira.connect.dialog.ccfb086d3e', + 'Create a personal access token in your Jira profile under Personal Access Tokens.' + )} +

+ ) : ( +

+ {translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '} + + . +

+ )}

{credentialStorageCopy} diff --git a/src/renderer/src/components/linear-issue-workspace-text.test.ts b/src/renderer/src/components/linear-issue-workspace-text.test.ts new file mode 100644 index 00000000000..eecc60bddf5 --- /dev/null +++ b/src/renderer/src/components/linear-issue-workspace-text.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import type { LinearIssue } from '../../../shared/types' +import { buildLinearIssueBranchName } from './linear-issue-workspace-text' + +function makeIssue(branchName?: string): LinearIssue { + return { + id: 'issue-1', + identifier: 'ENG-123', + title: 'Fix launch context handoff', + url: 'https://linear.app/acme/issue/ENG-123/fix-launch-context-handoff', + branchName, + state: { name: 'Todo', type: 'unstarted', color: '#999999' }, + team: { id: 'team-1', name: 'Engineering', key: 'ENG' }, + labels: [], + labelIds: [], + priority: 3, + estimate: null, + updatedAt: '2026-05-29T12:00:00.000Z' + } +} + +describe('buildLinearIssueBranchName', () => { + it('prefers Linear’s branch name', () => { + expect(buildLinearIssueBranchName(makeIssue(' team/eng-123-fix-launch-context '))).toBe( + 'team/eng-123-fix-launch-context' + ) + }) + + it('falls back to Orca’s generated workspace slug', () => { + expect(buildLinearIssueBranchName(makeIssue())).toBe('eng-123-fix-launch-context-handoff') + expect(buildLinearIssueBranchName(makeIssue(' '))).toBe('eng-123-fix-launch-context-handoff') + }) +}) diff --git a/src/renderer/src/components/linear-issue-workspace-text.ts b/src/renderer/src/components/linear-issue-workspace-text.ts index 0d95641d5ec..b9a9a518e79 100644 --- a/src/renderer/src/components/linear-issue-workspace-text.ts +++ b/src/renderer/src/components/linear-issue-workspace-text.ts @@ -1,5 +1,6 @@ import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import type { LinearIssue } from '../../../shared/types' +import { getUsableLinearBranchName } from '../../../shared/new-workspace/workspace-source' const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) @@ -22,7 +23,7 @@ export function formatLinearIssueRelativeTime(input: string): string { } export function buildLinearIssueBranchName(issue: LinearIssue): string { - return getLinearIssueWorkspaceName(issue) + return getUsableLinearBranchName(issue.branchName) ?? getLinearIssueWorkspaceName(issue) } export function buildLinearIssuePrompt(issue: LinearIssue): string { diff --git a/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx b/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx index f28c516df9d..e19b527fc10 100644 --- a/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx +++ b/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx @@ -2,7 +2,7 @@ import '@testing-library/jest-dom/vitest' import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { WindowsFirewallNotice } from './WindowsFirewallNotice' @@ -28,6 +28,7 @@ describe('WindowsFirewallNotice', () => { supported: true, port: 6768, ruleAllowed: false, + blockingRuleDetected: false, privateFirewallEnabled: true, networkCategory: 'private', inspectionAvailable: true @@ -45,15 +46,28 @@ describe('WindowsFirewallNotice', () => { it('repairs only after explicit user action and hides after success', async () => { const repairWindowsFirewall = vi.fn().mockResolvedValue({ ok: true }) - setMobileApi({ - getWindowsFirewallStatus: vi.fn().mockResolvedValue({ + const getWindowsFirewallStatus = vi + .fn() + .mockResolvedValueOnce({ supported: true, port: 6768, ruleAllowed: false, + blockingRuleDetected: false, privateFirewallEnabled: true, networkCategory: 'private', inspectionAvailable: true - }), + }) + .mockResolvedValueOnce({ + supported: true, + port: 6768, + ruleAllowed: true, + blockingRuleDetected: false, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + }) + setMobileApi({ + getWindowsFirewallStatus, repairWindowsFirewall }) const user = userEvent.setup() @@ -61,11 +75,77 @@ describe('WindowsFirewallNotice', () => { await user.click(await screen.findByRole('button', { name: /allow phone connections/i })) expect(repairWindowsFirewall).toHaveBeenCalledTimes(1) + await waitFor(() => expect(getWindowsFirewallStatus).toHaveBeenCalledTimes(2)) await waitFor(() => expect(screen.queryByText(/allow phone connections through/i)).not.toBeInTheDocument() ) }) + it('reports an overriding Block rule and stays actionable if repair cannot clear it', async () => { + const blockedStatus = { + supported: true, + port: 6768, + ruleAllowed: false, + blockingRuleDetected: true, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + } + const getWindowsFirewallStatus = vi.fn().mockResolvedValue(blockedStatus) + const repairWindowsFirewall = vi.fn().mockResolvedValue({ ok: true }) + setMobileApi({ getWindowsFirewallStatus, repairWindowsFirewall }) + const user = userEvent.setup() + render() + + expect(await screen.findByText(/Windows may be blocking Orca Mobile/i)).toBeInTheDocument() + expect(screen.getByText(/Block rule can override/i)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: /repair firewall access/i })) + + await waitFor(() => expect(getWindowsFirewallStatus).toHaveBeenCalledTimes(2)) + expect(screen.getByText(/Windows may be blocking Orca Mobile/i)).toBeInTheDocument() + }) + + it('ignores a stale inspection that resolves after a newer result', async () => { + const blockedStatus = { + supported: true, + port: 6768, + ruleAllowed: false, + blockingRuleDetected: true, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + } + const clearedStatus = { ...blockedStatus, ruleAllowed: true, blockingRuleDetected: false } + let resolveStale: (status: typeof blockedStatus) => void = () => {} + const getWindowsFirewallStatus = vi + .fn() + .mockResolvedValueOnce(blockedStatus) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStale = resolve + }) + ) + .mockResolvedValueOnce(clearedStatus) + setMobileApi({ getWindowsFirewallStatus }) + render() + expect(await screen.findByText(/Windows may be blocking Orca Mobile/i)).toBeInTheDocument() + + // Why: UAC elevation bounces window focus, so an older in-flight + // inspection can resolve after a newer one and must not win. + window.dispatchEvent(new Event('focus')) + window.dispatchEvent(new Event('focus')) + await waitFor(() => expect(getWindowsFirewallStatus).toHaveBeenCalledTimes(3)) + await waitFor(() => + expect(screen.queryByText(/Windows may be blocking Orca Mobile/i)).not.toBeInTheDocument() + ) + + await act(async () => { + resolveStale(blockedStatus) + }) + expect(screen.queryByText(/Windows may be blocking Orca Mobile/i)).not.toBeInTheDocument() + }) + it('does not offer a firewall rule while the selected network is public', async () => { const openWindowsNetworkSettings = vi.fn().mockResolvedValue(true) setMobileApi({ @@ -73,6 +153,7 @@ describe('WindowsFirewallNotice', () => { supported: true, port: 6768, ruleAllowed: true, + blockingRuleDetected: false, privateFirewallEnabled: false, networkCategory: 'public', inspectionAvailable: true @@ -95,6 +176,7 @@ describe('WindowsFirewallNotice', () => { supported: true, port: 6768, ruleAllowed: false, + blockingRuleDetected: false, privateFirewallEnabled: true, networkCategory: 'domain', inspectionAvailable: true diff --git a/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx index 9fd06428246..fb9d1827673 100644 --- a/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx +++ b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { CircleAlert, Loader2, ShieldCheck } from 'lucide-react' import { toast } from 'sonner' import type { WindowsMobileFirewallStatus } from '../../../../shared/windows-mobile-firewall' @@ -21,23 +21,29 @@ export function WindowsFirewallNotice({ const [status, setStatus] = useState(null) const [repairing, setRepairing] = useState(false) const mountedRef = useMountedRef() + const inspectIdRef = useRef(0) - const inspect = useCallback(async () => { + const inspect = useCallback(async (): Promise => { + // Why: UAC elevation steals and returns window focus, so a focus-triggered + // inspection can race the post-repair one; only the latest result may win. + const inspectId = ++inspectIdRef.current if (!pairingReady) { setStatus(null) - return + return null } try { const next = await window.api.mobile.getWindowsFirewallStatus( address ? { address } : undefined ) - if (mountedRef.current) { + if (mountedRef.current && inspectIdRef.current === inspectId) { setStatus(next) } + return next } catch { - if (mountedRef.current) { + if (mountedRef.current && inspectIdRef.current === inspectId) { setStatus(null) } + return null } }, [address, mountedRef, pairingReady]) @@ -52,11 +58,16 @@ export function WindowsFirewallNotice({ } const firewallStatus = status const networkIsPublic = firewallStatus.networkCategory === 'public' + const blockingRuleDetected = firewallStatus.blockingRuleDetected // 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)) { + if ( + !networkIsPublic && + (!firewallStatus.privateFirewallEnabled || + (firewallStatus.ruleAllowed && !blockingRuleDetected)) + ) { return null } @@ -68,11 +79,32 @@ export function WindowsFirewallNotice({ return } if (result.ok) { - setStatus({ ...firewallStatus, ruleAllowed: true }) - toast.success( + // Why: elevation success only confirms the script ran; managed policy + // can still leave an overriding Block rule in effect. + const next = await inspect() + if (!mountedRef.current) { + return + } + if ( + next?.supported && + (!next.privateFirewallEnabled || + (next.ruleAllowed && !next.blockingRuleDetected && next.inspectionAvailable)) + ) { + toast.success( + translate( + 'auto.components.mobile.WindowsFirewallNotice.repair-success', + 'Windows Firewall now allows Orca Mobile on private networks' + ) + ) + return + } + if (!next) { + setStatus(firewallStatus) + } + toast.error( translate( - 'auto.components.mobile.WindowsFirewallNotice.repair-success', - 'Windows Firewall now allows Orca Mobile on private networks' + 'auto.components.mobile.WindowsFirewallNotice.repair-unverified', + 'Windows Firewall access could not be verified' ) ) return @@ -81,7 +113,7 @@ export function WindowsFirewallNotice({ toast.error( translate( 'auto.components.mobile.WindowsFirewallNotice.repair-failed', - 'Could not add the Windows Firewall rule' + 'Could not update the Windows Firewall rules' ) ) } @@ -90,7 +122,7 @@ export function WindowsFirewallNotice({ toast.error( translate( 'auto.components.mobile.WindowsFirewallNotice.repair-failed', - 'Could not add the Windows Firewall rule' + 'Could not update the Windows Firewall rules' ) ) } @@ -113,10 +145,15 @@ export function WindowsFirewallNotice({ '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' - )} + : blockingRuleDetected + ? translate( + 'auto.components.mobile.WindowsFirewallNotice.blocked-title', + 'Windows may be blocking Orca Mobile' + ) + : translate( + 'auto.components.mobile.WindowsFirewallNotice.missing-title', + 'Allow phone connections through Windows Firewall' + )}

{networkIsPublic @@ -124,11 +161,17 @@ export function WindowsFirewallNotice({ '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 } - )} + : blockingRuleDetected + ? translate( + 'auto.components.mobile.WindowsFirewallNotice.blocked-description', + 'An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.', + { port: firewallStatus.port } + ) + : 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 ? ( @@ -155,10 +198,15 @@ export function WindowsFirewallNotice({ 'auto.components.mobile.WindowsFirewallNotice.waiting', 'Waiting for Windows…' ) - : translate( - 'auto.components.mobile.WindowsFirewallNotice.allow', - 'Allow phone connections' - )} + : blockingRuleDetected + ? translate( + 'auto.components.mobile.WindowsFirewallNotice.repair', + 'Repair firewall access' + ) + : translate( + 'auto.components.mobile.WindowsFirewallNotice.allow', + 'Allow phone connections' + )} )}
diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx index 8ddb4a46c9b..f7c20ef51f4 100644 --- a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx @@ -1,6 +1,7 @@ import { MessageSquare, TriangleAlert } from 'lucide-react' import { translate } from '@/i18n/i18n' import { formatAgentTypeLabel } from '@/lib/agent-status' +import { NATIVE_CHAT_EMPTY_STATE_COPY } from '../../../../shared/native-chat-empty-state' import type { NativeChatSession } from '../../../../shared/native-chat-types' export function NativeChatEmptyState({ @@ -44,28 +45,37 @@ function emptyStateCopy( switch (kind) { case 'loading': return { - title: translate('components.native-chat.state.loading.title', 'Loading conversation…'), + title: translate( + 'components.native-chat.state.loading.title', + NATIVE_CHAT_EMPTY_STATE_COPY.loading.title + ), subtitle: translate( 'components.native-chat.state.loading.subtitle', - 'Reading the agent transcript.' + NATIVE_CHAT_EMPTY_STATE_COPY.loading.subtitle ) } case 'error': return { - title: translate('components.native-chat.state.error.title', 'Could not load conversation'), + title: translate( + 'components.native-chat.state.error.title', + NATIVE_CHAT_EMPTY_STATE_COPY.error.title + ), subtitle: message ?? translate( 'components.native-chat.state.error.subtitle', - 'The transcript could not be read. Toggle back to the terminal to keep working.' + NATIVE_CHAT_EMPTY_STATE_COPY.error.subtitle ) } case 'not-agent': return { - title: translate('components.native-chat.state.notAgent.title', 'No conversation here'), + title: translate( + 'components.native-chat.state.notAgent.title', + NATIVE_CHAT_EMPTY_STATE_COPY.notAgent.title + ), subtitle: translate( 'components.native-chat.state.notAgent.subtitle', - 'This terminal is not running a recognized coding agent.' + NATIVE_CHAT_EMPTY_STATE_COPY.notAgent.subtitle ) } case 'empty': { @@ -73,12 +83,12 @@ function emptyStateCopy( return { title: translate( 'components.native-chat.state.empty.title', - 'Start a chat with {{value0}}', + NATIVE_CHAT_EMPTY_STATE_COPY.empty.title, { value0: agentName } ), subtitle: translate( 'components.native-chat.state.empty.subtitle', - 'Ask {{value0}} to inspect code, explain output, or make a change.', + NATIVE_CHAT_EMPTY_STATE_COPY.empty.subtitle, { value0: agentName } ) } diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx new file mode 100644 index 00000000000..37f5e21db08 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send' + +const INITIAL_PROMPT = JSON.stringify({ + questions: [ + { + question: 'Tabs or spaces?', + multiSelect: false, + options: [{ label: 'Tabs' }, { label: 'Spaces' }] + } + ] +}) + +const storeState = { + agentStatusByPaneKey: { + 'tab-1:leaf-1': { + interactivePrompt: INITIAL_PROMPT, + toolName: 'AskUserQuestion' + } + } +} + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState) +})) + +import { NativeChatInteractiveCard } from './NativeChatInteractiveCard' + +const mocks = { + sendAnswer: vi.fn(), + sendRaw: vi.fn(), + cancelPending: vi.fn(), + cancel: vi.fn() +} + +function renderCard(canSend = true): ReturnType { + return render(cardElement(canSend)) +} + +function cardElement(canSend = true): React.JSX.Element { + return ( + + ) +} + +function chooseSpacesAndSubmit(): void { + fireEvent.click(screen.getByRole('button', { name: /Spaces/ })) + fireEvent.click(screen.getByRole('button', { name: 'Send answer' })) +} + +describe('NativeChatInteractiveCard answer lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + storeState.agentStatusByPaneKey['tab-1:leaf-1'].interactivePrompt = INITIAL_PROMPT + }) + + afterEach(() => { + cleanup() + }) + + it('keeps the card retryable when no PTY answer was sent', () => { + mocks.sendAnswer.mockReturnValue(0) + renderCard() + + chooseSpacesAndSubmit() + expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Send answer' })) + expect(mocks.sendAnswer).toHaveBeenCalledTimes(2) + }) + + it('cancels delayed PTY writes when the owning card unmounts', () => { + mocks.sendAnswer.mockReturnValue(5_000) + const rendered = renderCard() + + chooseSpacesAndSubmit() + expect(mocks.cancelPending).not.toHaveBeenCalled() + + rendered.unmount() + expect(mocks.cancelPending).toHaveBeenCalledOnce() + }) + + it('cancels delayed PTY writes when desktop send authority is lost', () => { + mocks.sendAnswer.mockReturnValue(5_000) + const rendered = renderCard() + + chooseSpacesAndSubmit() + rendered.rerender(cardElement(false)) + + expect(mocks.cancelPending).toHaveBeenCalledOnce() + }) + + it('shows the paced send as busy and freezes the snapshotted answer', () => { + mocks.sendAnswer.mockReturnValue(5_000) + renderCard() + + chooseSpacesAndSubmit() + + expect(screen.getByRole('button', { name: 'Sending…' })).toBeDisabled() + expect(screen.getByRole('button', { name: /Spaces/ })).toBeDisabled() + expect(screen.getByRole('textbox')).toBeDisabled() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled() + }) + + it('cancels the old answer sequence when a replacement prompt arrives', () => { + mocks.sendAnswer.mockReturnValue(5_000) + const rendered = renderCard() + chooseSpacesAndSubmit() + + storeState.agentStatusByPaneKey['tab-1:leaf-1'].interactivePrompt = JSON.stringify({ + questions: [ + { + question: 'Choose a shell?', + multiSelect: false, + options: [{ label: 'zsh' }, { label: 'bash' }] + } + ] + }) + rendered.rerender(cardElement()) + + expect(mocks.cancelPending).toHaveBeenCalledOnce() + expect(screen.getByText('Choose a shell?')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx index 947d5f65c3e..de2d1aa1a98 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '../../store' import { parseInteractivePrompt } from './native-chat-interactive-prompt' import { nativeChatCardDismissKey } from './native-chat-dismiss-key' @@ -11,8 +11,8 @@ import type { NativeChatInteractiveSend } from './use-native-chat-interactive-se * `interactivePrompt` is present: a question wizard (precedence) or a tool * approval. Cleared by the host once the agent moves on, so it disappears * automatically. Sends through the composer's verified runtime path (R8/R6): - * answers as bracketed-paste + Enter; cancel/deny as ESC. Guarded by `canSend` - * so a mobile presence-lock blocks desktop sends the same way it guards xterm. + * answers via agent-specific paste or selector keystrokes; cancel/deny as ESC. + * Guarded by `canSend` so a mobile presence-lock blocks desktop sends too. * * Dismiss-on-answer (mobile parity): the live status lingers after answering — * the agent emits a post-tool event carrying the same prompt — so we track the @@ -43,7 +43,7 @@ export function NativeChatInteractiveCard({ // Thread the sibling `toolName` from the same status entry so the question // parser can dispatch through the tool's registered parser (mobile parity). const interactiveToolName = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.toolName ?? null) - const { sendAnswer, sendRaw, cancel } = send + const { sendAnswer, sendRaw, cancelPending, cancel } = send const card = useMemo( () => parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined), @@ -56,17 +56,24 @@ export function NativeChatInteractiveCard({ // vanish mid-send. `submitting` also gates a second submit racing the first. const dismissTimerRef = useRef | null>(null) const submittingRef = useRef(false) - const clearDismissTimer = (): void => { + const [submitting, setSubmitting] = useState(false) + const clearDismissTimer = useCallback((): void => { if (dismissTimerRef.current) { clearTimeout(dismissTimerRef.current) dismissTimerRef.current = null } submittingRef.current = false - } - // Keyed on cardKey (and unmount): a new prompt can replace the current one - // before the settle timer fires, and a stale `submitting` gate would swallow - // the new card's first answer. - useEffect(() => clearDismissTimer, [cardKey]) + setSubmitting(false) + }, []) + // A replacement prompt, ownership loss, or unmount must stop both timers and + // PTY writes during commit, before an old answer can type into the new prompt. + useLayoutEffect( + () => () => { + clearDismissTimer() + cancelPending() + }, + [canSend, cardKey, cancelPending, clearDismissTimer] + ) // Forget the dismissal once the prompt clears so a fresh prompt can show. const present = card != null @@ -75,7 +82,7 @@ export function NativeChatInteractiveCard({ setDismissedKey(null) clearDismissTimer() } - }, [present]) + }, [present, clearDismissTimer]) // Tell the view when a question card is up so it can hide the composer (this // card supplies its own input). Reset on unmount so the composer comes back. @@ -93,18 +100,28 @@ export function NativeChatInteractiveCard({ { + onAnswer={(selections) => { if (submittingRef.current) { return } submittingRef.current = true - const settleMs = sendAnswer(text) + const settleMs = sendAnswer(card.prompt, selections) + if (settleMs <= 0) { + // Keep the actionable card visible when its PTY disappeared between + // render and submit; the next live target update can make it retryable. + submittingRef.current = false + return + } + setSubmitting(true) // Hold the card until the paced write finishes, then mark it answered // (which hides it and restores the composer). dismissTimerRef.current = setTimeout(() => { + cancelPending() setDismissedKey(cardKey) submittingRef.current = false + setSubmitting(false) dismissTimerRef.current = null }, settleMs) }} diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx new file mode 100644 index 00000000000..3e2a8b27716 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NativeChatQuestionCard } from './NativeChatQuestionCard' +import type { AskAnswerSelection, AskPrompt } from './native-chat-interactive-prompt' + +// The card resolves its own label-keyed selection state into the index-based +// answer the delivery layer needs. These tests pin that resolution — the exact +// seam of STA-1860 (a non-first pick must surface as its option INDEX, not the +// first option / the raw label). + +let container: HTMLDivElement +let root: ReturnType + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function render(prompt: AskPrompt, onAnswer: (s: AskAnswerSelection[]) => void): void { + act(() => { + root.render( {}} />) + }) +} + +function click(button: Element | undefined, describe: string): void { + if (!button) { + throw new Error(`button not found: ${describe}`) + } + act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))) +} + +// Option rows carry a badge number + label, so match them by the label they +// contain among the aria-pressed selectable rows. +function clickOption(label: string): void { + const row = [...container.querySelectorAll('button[aria-pressed]')].find((b) => + b.textContent?.includes(label) + ) + click(row, `option ${label}`) +} + +function clickOptionAt(index: number): void { + click(container.querySelectorAll('button[aria-pressed]')[index], `option index ${index}`) +} + +function clickAction(text: string): void { + const button = [...container.querySelectorAll('button')].find( + (b) => b.textContent?.trim() === text + ) + click(button, text) +} + +const tabsOrSpaces: AskPrompt = { + questions: [ + { + question: 'Do you prefer tabs or spaces?', + header: 'Indent', + multiSelect: false, + options: [{ label: 'Tabs' }, { label: 'Spaces' }] + } + ] +} + +describe('NativeChatQuestionCard', () => { + it('delivers the SECOND option as index 1, not the default (STA-1860)', () => { + const onAnswer = vi.fn() + render(tabsOrSpaces, onAnswer) + + clickOption('Spaces') + clickAction('Send answer') + + expect(onAnswer).toHaveBeenCalledWith([{ indices: [1], other: '' }]) + }) + + it('delivers a multi-select pick as its option indices', () => { + const onAnswer = vi.fn() + render( + { + questions: [ + { + question: 'Which fruits?', + multiSelect: true, + options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }] + } + ] + }, + onAnswer + ) + + clickOption('Cherry') + clickOption('Apple') + clickAction('Send answer') + + expect(onAnswer).toHaveBeenCalledWith([{ indices: [0, 2], other: '' }]) + }) + + it('keeps duplicate labels distinct by their numbered row', () => { + const onAnswer = vi.fn() + render( + { + questions: [ + { + question: 'Which duplicate row?', + multiSelect: false, + options: [{ label: 'Same' }, { label: 'Same' }] + } + ] + }, + onAnswer + ) + + clickOptionAt(1) + clickAction('Send answer') + + expect(onAnswer).toHaveBeenCalledWith([{ indices: [1], other: '' }]) + }) + + it('carries free text through as the other answer', () => { + const onAnswer = vi.fn() + render(tabsOrSpaces, onAnswer) + + const input = container.querySelector('input')! + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + setter.call(input, 'four spaces') + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + clickAction('Send answer') + + expect(onAnswer).toHaveBeenCalledWith([{ indices: [], other: 'four spaces' }]) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index 503f3111034..db068b5c773 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -2,12 +2,14 @@ import { useState, type RefObject } from 'react' import { Check, Pencil, X } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import { formatAskAnswer, type AskPrompt } from './native-chat-interactive-prompt' +import type { AskAnswerSelection, AskPrompt } from './native-chat-interactive-prompt' export type NativeChatQuestionCardProps = { prompt: AskPrompt - /** Send the formatted answer text to the agent. */ - onAnswer: (text: string) => void + /** Whether the snapshotted answer is still being delivered to the agent. */ + isSubmitting?: boolean + /** Deliver the chosen answer (per-question option indices + free text). */ + onAnswer: (selections: AskAnswerSelection[]) => void /** Dismiss the prompt (sends Escape to the agent). */ onCancel: () => void /** Exposes the free-text row so pane-level Paste can target it while the @@ -24,12 +26,15 @@ export type NativeChatQuestionCardProps = { */ export function NativeChatQuestionCard({ prompt, + isSubmitting = false, onAnswer, onCancel, answerInputRef }: NativeChatQuestionCardProps): React.JSX.Element { const [index, setIndex] = useState(0) - const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) + // Keep option identity by index: labels are display text and are not guaranteed + // unique, while Claude's selector commits the numbered row (STA-1860). + const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) const [otherText, setOtherText] = useState(() => prompt.questions.map(() => '')) const total = prompt.questions.length @@ -46,29 +51,30 @@ export function NativeChatQuestionCard({ // The resolved answer for a question: picked labels plus any typed free-text. const answerFor = (qi: number, sel = selections, oth = otherText): string => { - const picked = sel[qi] ?? [] + const question = prompt.questions[qi] + const picked = (sel[qi] ?? []) + .map((optionIndex) => question?.options[optionIndex]?.label ?? '') + .filter((label) => label.length > 0) const other = (oth[qi] ?? '').trim() return [...picked, ...(other ? [other] : [])].join(', ') } const currentAnswered = answerFor(index).length > 0 - const submitAll = (sel: string[][], oth: string[]): void => { - const resolved = prompt.questions.map((_, i) => { - const picked = sel[i] ?? [] - const other = (oth[i] ?? '').trim() - return [...picked, ...(other ? [other] : [])] + const submitAll = (sel: number[][], oth: string[]): void => { + const resolved: AskAnswerSelection[] = prompt.questions.map((_, i) => { + return { indices: [...(sel[i] ?? [])], other: (oth[i] ?? '').trim() } }) - const text = formatAskAnswer(prompt, resolved) - if (text.length > 0) { - onAnswer(text) + const anyAnswered = resolved.some((s) => s.indices.length > 0 || (s.other ?? '').length > 0) + if (anyAnswered) { + onAnswer(resolved) } } // Advance to the next question, or submit on the last one — always from an // explicit snapshot so a just-committed single-select pick isn't lost to the // async setState. - const advanceOrSubmit = (sel: string[][], oth: string[]): void => { + const advanceOrSubmit = (sel: number[][], oth: string[]): void => { if (isLast) { submitAll(sel, oth) } else { @@ -79,14 +85,16 @@ export function NativeChatQuestionCard({ // Selecting only highlights the row; submitting is an explicit step via the // trailing Send/Next button. (Auto-submitting on the first click dismissed the // card before the user saw any feedback, which read as "nothing happened".) - const pickOption = (label: string): void => { + const pickOption = (optionIndex: number): void => { setSelections((prev) => { const next = prev.map((s) => [...s]) const cur = next[index] ?? [] if (q.multiSelect) { - next[index] = cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label] + next[index] = cur.includes(optionIndex) + ? cur.filter((pickedIndex) => pickedIndex !== optionIndex) + : [...cur, optionIndex].sort((a, b) => a - b) } else { - next[index] = cur.includes(label) ? [] : [label] + next[index] = cur.includes(optionIndex) ? [] : [optionIndex] } return next }) @@ -115,7 +123,7 @@ export function NativeChatQuestionCard({ // Part of the composer: docked in the bottom input region, matching the // composer's width and padding, rendered as the "ask" dialog card directly // above the text input. Its free-text row is the answer input. -
+
{total > 1 ? (
@@ -123,9 +131,10 @@ export function NativeChatQuestionCard({
@@ -231,23 +245,26 @@ function OptionRow({ label, description, selected, + disabled, onSelect }: { badge: string label: string description?: string selected: boolean + disabled: boolean onSelect: () => void }): React.JSX.Element { return (
) : null} diff --git a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx index 270e1785aef..0ffc0a2b526 100644 --- a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx @@ -79,6 +79,9 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshComputerUseSkill} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : COMPUTER_USE_SKILL_NAME + } /> ) } diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index 85b27f12ed7..f1a34a0396f 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -163,6 +163,9 @@ export function EphemeralVmsPane(): React.JSX.Element { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshSkill} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : EPHEMERAL_VMS_SKILL_NAME + } />
diff --git a/src/renderer/src/components/settings/GrokAccountsSection.test.tsx b/src/renderer/src/components/settings/GrokAccountsSection.test.tsx new file mode 100644 index 00000000000..cd418597719 --- /dev/null +++ b/src/renderer/src/components/settings/GrokAccountsSection.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + refreshGrokRateLimits: vi.fn() +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => React.createElement('span', { 'data-testid': 'grok-icon' }) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + refreshGrokRateLimits: mocks.refreshGrokRateLimits, + rateLimits: { grok: null } + }) +})) + +import { GrokAccountsSection } from './GrokAccountsSection' + +describe('GrokAccountsSection', () => { + beforeEach(() => { + mocks.getStatus.mockResolvedValue({ + signedIn: true, + email: 'dev@example.com', + teamId: null, + tokenFresh: false, + error: null + }) + mocks.refreshGrokRateLimits.mockResolvedValue(undefined) + Object.defineProperty(window, 'api', { + configurable: true, + value: { grokAccounts: { getStatus: mocks.getStatus } } + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('explains the host-scoped Grok recovery flow without requiring a chat message', async () => { + render() + + expect( + await screen.findByText( + 'Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed.' + ) + ).toBeInTheDocument() + expect(screen.queryByText(/grok login/i)).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/settings/GrokAccountsSection.tsx b/src/renderer/src/components/settings/GrokAccountsSection.tsx index 474c4c1ee66..8df194dbd2d 100644 --- a/src/renderer/src/components/settings/GrokAccountsSection.tsx +++ b/src/renderer/src/components/settings/GrokAccountsSection.tsx @@ -109,12 +109,12 @@ export function GrokAccountsSection(): React.JSX.Element {

{tokenFresh ? translate( - 'auto.components.settings.GrokAccountsSection.c3d4e5f6a7', - 'Signed in. Orca only reads that file on disk — run grok login again if usage fails.' + 'auto.components.settings.GrokAccountsSection.b36fa2c908', + 'Signed in. Orca reads the Grok CLI session stored on disk.' ) : translate( - 'auto.components.settings.GrokAccountsSection.d4e5f6a7b8', - 'Session expired — run grok login in a terminal to refresh.' + 'auto.components.settings.GrokAccountsSection.f08c41de73', + 'Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed.' )}

diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index b757261bba6..d597895d580 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -7,6 +7,7 @@ import { ORCHESTRATION_SKILL_INSTALL_COMMAND, ORCHESTRATION_SKILL_UPDATE_COMMAND } from '@/lib/orchestration-install-command' +import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' @@ -75,6 +76,9 @@ export function OrchestrationSetupCard(props: { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={skill.refresh} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : ORCHESTRATION_SKILL_NAME + } /> ) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 2e0d2baa003..0d4c2d5d537 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -100,6 +100,7 @@ import { useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { useSkillFreshness } from '@/hooks/useSkillFreshness' import { deriveNeededSectionIds, getInitialMountedSectionIds } from './settings-load-performance' import { translate } from '@/i18n/i18n' import { getProjectHostSetupProjectionFromState } from '../../store/selectors' @@ -207,11 +208,15 @@ function getSettingsNavGroupDefinitionsForSearch( function getSkillNavInstallStatus(skill: { installed: boolean loading: boolean + updateAvailable?: boolean }): SettingsNavInstallStatus { if (skill.loading) { return 'checking' } - return skill.installed ? 'installed' : 'install' + if (!skill.installed) { + return 'install' + } + return skill.updateAvailable ? 'update-available' : 'installed' } function hasReadyVoiceModel( @@ -346,6 +351,10 @@ function Settings(): React.JSX.Element { discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) + // Why: mirror the setup cards — freshness only speaks for the validated global + // rail, which doesn't run under WSL, so the nav pill stays presence-only there. + const { inventory: skillFreshnessInventory } = useSkillFreshness() + const skillFreshnessApplies = activeSkillRuntime.agentRuntime?.runtime !== 'wsl' const [voiceModelStatesLoading, setVoiceModelStatesLoading] = useState(showDesktopOnlySettings) // Why: the Terminal settings section shares one search index with the // sidebar. We trim platform-only entries on other platforms so search never @@ -724,13 +733,16 @@ function Settings(): React.JSX.Element { orchestrationSkill const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } = computerUseSkill + const eligibleUpdateSkillNames = skillFreshnessInventory?.eligibleUpdateNames const capabilityInstallStatusBySectionId = useMemo(() => { + const eligibleUpdates = new Set(skillFreshnessApplies ? (eligibleUpdateSkillNames ?? []) : []) const next = new Map([ [ 'orchestration', getSkillNavInstallStatus({ installed: orchestrationSkillInstalled, - loading: orchestrationSkillLoading + loading: orchestrationSkillLoading, + updateAvailable: eligibleUpdates.has(ORCHESTRATION_SKILL_NAME) }) ] ]) @@ -739,7 +751,8 @@ function Settings(): React.JSX.Element { 'computer-use', getSkillNavInstallStatus({ installed: computerUseSkillInstalled, - loading: computerUseSkillLoading + loading: computerUseSkillLoading, + updateAvailable: eligibleUpdates.has(COMPUTER_USE_SKILL_NAME) }) ) if (settings) { @@ -757,11 +770,13 @@ function Settings(): React.JSX.Element { }, [ computerUseSkillInstalled, computerUseSkillLoading, + eligibleUpdateSkillNames, modelStates, orchestrationSkillInstalled, orchestrationSkillLoading, settings, showDesktopOnlySettings, + skillFreshnessApplies, voiceModelStatesLoading ]) const navSections = useMemo( diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index 7bbcea65371..8029eabda1a 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -155,6 +155,11 @@ export function SettingsSidebar({ ) case 'installed': return translate('auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', 'Installed') + case 'update-available': + return translate( + 'auto.components.skills.SkillFreshnessStatusPill.updateAvailable', + 'Update available' + ) case 'checking': return translate('auto.components.settings.AgentSkillSetupPanel.68a468752e', 'Checking...') } @@ -164,9 +169,11 @@ export function SettingsSidebar({ 'ml-auto shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-medium leading-none', status === 'installed' ? 'border-status-success-border bg-status-success-background text-status-success' - : status === 'install' - ? 'border-foreground/15 bg-foreground/10 text-foreground' - : 'border-border/50 bg-muted/30 text-muted-foreground' + : status === 'update-available' + ? 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300' + : status === 'install' + ? 'border-foreground/15 bg-foreground/10 text-foreground' + : 'border-border/50 bg-muted/30 text-muted-foreground' ) return ( diff --git a/src/renderer/src/components/settings/SshPassphraseDialog.tsx b/src/renderer/src/components/settings/SshPassphraseDialog.tsx index 3f25f5d91e2..7cfb4dbd0b8 100644 --- a/src/renderer/src/components/settings/SshPassphraseDialog.tsx +++ b/src/renderer/src/components/settings/SshPassphraseDialog.tsx @@ -110,7 +110,14 @@ export function SshPassphraseDialog(): React.JSX.Element | null { return ( !isOpen && void handleCancel()}> - + {/* Why: a credential prompt is a preemptive modal that must sit above any + open popover/menu (popover z-60, menus z-70) — the default dialog z-50 + would let a still-open picker cover the focused input. */} + {isPassword diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index 3ceaf412d13..3e15d266221 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -2,15 +2,19 @@ import type { SettingsSearchEntry } from './settings-search' import { getTerminalAppearanceSearchEntries } from './terminal-search' import { getLeftSidebarAppearanceEntry, getSidebarEntries } from './appearance-sidebar-search' import { createLocalizedCatalog } from '@/i18n/localized-catalog' -import { getRendererAppPlatform } from '@/lib/renderer-app-platform' -import { isWebClientLocation } from '@/lib/web-client-location' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { SHOW_UI_LANGUAGE_SETTING } from '@/i18n/supported-languages' import { getStatusBarToggles } from './appearance-status-bar-search' import { getUsagePercentageDisplayEntry } from './appearance-usage-percentage-search' +import { getMenuBarIconEntries, getSystemTrayEntries } from './appearance-system-presence-search' -export { getStatusBarToggles, getUsagePercentageDisplayEntry } +export { + getMenuBarIconEntries, + getStatusBarToggles, + getSystemTrayEntries, + getUsagePercentageDisplayEntry +} export const getThemeEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { @@ -188,65 +192,6 @@ export const getAppIconEntries = createLocalizedCatalog((): SettingsSearchEntry[ } ]) -const getSystemTrayEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [ - { - title: translate( - 'auto.components.settings.appearance.search.9a115966d3', - 'Minimize to Tray on Close' - ), - description: translate( - 'auto.components.settings.appearance.search.4d5b9427b5', - 'When enabled, closing the window keeps Orca running in the system tray instead of quitting.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', { - englishOnly: true - }), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.system', - 'system tray', - { englishOnly: true } - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.minimize', - 'minimize', - { englishOnly: true } - ), - ...translateSearchKeyword('auto.components.settings.appearance.search.tray.close', 'close', { - englishOnly: true - }), - ...translateSearchKeyword('auto.components.settings.appearance.search.e5bc35d59e', 'window'), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.notification', - 'notification area', - { englishOnly: true } - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.background', - 'background', - { englishOnly: true } - ) - ] - } -]) - -type SystemTraySearchOptions = { - showSystemTray?: boolean -} - -function shouldShowSystemTrayEntries(options: SystemTraySearchOptions): boolean { - return ( - options.showSystemTray ?? - // Why: this setting controls Electron's Windows tray only. A Windows web - // browser can report win32, but it has no local tray to affect. - (getRendererAppPlatform() === 'win32' && !isWebClientLocation()) - ) -} - -export function getSystemTrayEntries(options: SystemTraySearchOptions = {}): SettingsSearchEntry[] { - return shouldShowSystemTrayEntries(options) ? getSystemTrayEntryCatalog() : [] -} - const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { title: translate('auto.components.settings.AppearancePane.interfaceTitle', 'Interface') @@ -269,6 +214,7 @@ const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEnt type AppearancePaneSearchOptions = { showWarpImport?: boolean showSystemTray?: boolean + showMenuBarIcon?: boolean } function buildAppearancePaneSearchEntries( @@ -286,7 +232,8 @@ function buildAppearancePaneSearchEntries( ...getStatusBarEntries(), ...getSidebarEntries(), ...getAppIconEntries(), - ...getSystemTrayEntries(options) + ...getSystemTrayEntries(options), + ...getMenuBarIconEntries(options) ] } @@ -295,6 +242,7 @@ export function getAppearancePaneSearchEntries( ): SettingsSearchEntry[] { return buildAppearancePaneSearchEntries({ showWarpImport: options.showWarpImport ?? true, - showSystemTray: options.showSystemTray + showSystemTray: options.showSystemTray, + showMenuBarIcon: options.showMenuBarIcon }) } diff --git a/src/renderer/src/components/settings/appearance-system-presence-search.ts b/src/renderer/src/components/settings/appearance-system-presence-search.ts new file mode 100644 index 00000000000..f392da6c271 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-system-presence-search.ts @@ -0,0 +1,103 @@ +import { translate } from '@/i18n/i18n' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { getRendererAppPlatform } from '@/lib/renderer-app-platform' +import { isWebClientLocation } from '@/lib/web-client-location' +import type { SettingsSearchEntry } from './settings-search' +import { translateSearchKeyword } from './settings-search-keywords' + +const getSystemTrayEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [ + { + title: translate( + 'auto.components.settings.appearance.search.9a115966d3', + 'Minimize to Tray on Close' + ), + description: translate( + 'auto.components.settings.appearance.search.4d5b9427b5', + 'When enabled, closing the window keeps Orca running in the system tray instead of quitting.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', { + englishOnly: true + }), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.system', + 'system tray', + { englishOnly: true } + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.minimize', + 'minimize', + { englishOnly: true } + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.close', 'close', { + englishOnly: true + }), + ...translateSearchKeyword('auto.components.settings.appearance.search.e5bc35d59e', 'window'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.notification', + 'notification area', + { englishOnly: true } + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.background', + 'background', + { englishOnly: true } + ) + ] + } +]) + +const getMenuBarIconEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [ + { + title: translate('settings.appearance.menuBarIcon.title', 'Show Menu Bar Icon'), + description: translate( + 'settings.appearance.menuBarIcon.description', + 'Keep an Orca shortcut and activity indicator in the macOS menu bar.' + ), + keywords: [ + ...translateSearchKeyword('settings.appearance.menuBarIcon.keyword.menuBar', 'menu bar', { + englishOnly: true + }), + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', { + englishOnly: true + }), + ...translateSearchKeyword('auto.components.settings.appearance.search.1f2880a9d5', 'orca'), + ...translateSearchKeyword( + 'settings.appearance.menuBarIcon.keyword.statusItem', + 'status item', + { englishOnly: true } + ), + ...translateSearchKeyword('settings.appearance.menuBarIcon.keyword.activity', 'activity', { + englishOnly: true + }), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.background', + 'background', + { englishOnly: true } + ) + ] + } +]) + +type SystemPresenceSearchOptions = { + showMenuBarIcon?: boolean + showSystemTray?: boolean +} + +export function getSystemTrayEntries( + options: SystemPresenceSearchOptions = {} +): SettingsSearchEntry[] { + const show = + options.showSystemTray ?? + // Why: a Windows web client can report win32, but it has no local tray. + (getRendererAppPlatform() === 'win32' && !isWebClientLocation()) + return show ? getSystemTrayEntryCatalog() : [] +} + +export function getMenuBarIconEntries( + options: SystemPresenceSearchOptions = {} +): SettingsSearchEntry[] { + const show = + options.showMenuBarIcon ?? (getRendererAppPlatform() === 'darwin' && !isWebClientLocation()) + return show ? getMenuBarIconEntryCatalog() : [] +} diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index 0501d05faf1..8427e6f4cf7 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -38,8 +38,14 @@ export function JiraIntegrationCard(): React.JSX.Element { const siteCount = sites.length || (connected ? 1 : 0) const accountScope = getProviderAccountScope(settings) const credentialCopy = hasRemoteProviderRuntime(settings) - ? 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.' - : 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are stored locally and encrypted when local runtime storage supports it.' + ? translate( + 'auto.components.settings.task.tracker.integration.cards.2d60ec7921', + 'Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.' + ) + : translate( + 'auto.components.settings.task.tracker.integration.cards.977e360b71', + 'Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it.' + ) const subordinateRowClass = useIntegrationSubordinateRowClass('flex items-center gap-3') const accountScopeRowClass = useIntegrationSubordinateRowClass('text-xs') diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index 5811f5dcf02..bf642e83735 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -172,6 +172,15 @@ describe('getTerminalPaneSearchEntries', () => { expect(matchesSettingsSearch('tray', webEntries)).toBe(false) }) + it('includes the macOS menu bar entry only when its desktop control is shown', () => { + const macEntries = getAppearancePaneSearchEntries({ showMenuBarIcon: true }) + const otherEntries = getAppearancePaneSearchEntries({ showMenuBarIcon: false }) + + expect(macEntries.some((entry) => entry.title === 'Show Menu Bar Icon')).toBe(true) + expect(otherEntries.some((entry) => entry.title === 'Show Menu Bar Icon')).toBe(false) + expect(matchesSettingsSearch('status item', macEntries)).toBe(true) + }) + it('keeps sidebar shortcut restore settings in the Appearance search index', () => { const automationsEntry = getSidebarEntries().find( (entry) => entry.title === 'Show Automations Button' diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 73726d09ae0..81671c20616 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -142,7 +142,10 @@ import { type ScrollToCurrentWorkspaceRevealRequestDetail } from '@/lib/scroll-to-current-workspace-status' import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag' -import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop' +import { + getLogicalRepoOrderRankById, + getSidebarOrderedRepoHeaderIdsByBucket +} from './project-header-drop' import { useProjectGroupHeaderDrag } from './project-group-header-drag' import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from './project-group-header-drop' import { @@ -269,7 +272,7 @@ import { buildSidebarHostOptions } from './sidebar-host-options' import { HostSectionHeaderMenu } from './HostSectionHeaderMenu' import { ProjectHeaderActions } from './ProjectHeaderActions' import { translate } from '@/i18n/i18n' -import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { folderWorkspaceKey, getActiveSidebarWorkspaceId } from '../../../../shared/workspace-scope' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { isConfirmedStaleFolderPathStatus, @@ -407,22 +410,6 @@ function getWorktreeOptionId(rowKey: string): string { return `worktree-list-option-${encodeURIComponent(rowKey)}` } -// Why: folder workspaces are tracked by the scoped active key, while older -// worktree-only paths still read activeWorktreeId. -function getActiveSidebarWorkspaceId( - activeWorkspaceKey: string | null, - activeWorktreeId: string | null -): string | null { - const scope = activeWorkspaceKey ? parseWorkspaceKey(activeWorkspaceKey) : null - if (scope?.type === 'folder') { - return folderWorkspaceKey(scope.folderWorkspaceId) - } - if (scope?.type === 'worktree') { - return scope.worktreeId - } - return activeWorktreeId -} - function getMountedWorktreeOptions(worktreeId: string, root?: ParentNode | null): HTMLElement[] { const scope = root ?? document const result: HTMLElement[] = [] @@ -5686,9 +5673,7 @@ const WorktreeList = React.memo(function WorktreeList({ }) }, [defaultHostId, folderWorkspaces, projectGroups, visibleHostIdSet]) const repoOrder = useMemo(() => { - const map = new Map() - repos.forEach((r, i) => map.set(r.id, i)) - return map + return getLogicalRepoOrderRankById(repos.map((repo) => repo.id)) }, [repos]) const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState< Map diff --git a/src/renderer/src/components/sidebar/project-group-header-drag.ts b/src/renderer/src/components/sidebar/project-group-header-drag.ts index 63c2a723d45..e2a879aebdc 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drag.ts @@ -67,7 +67,8 @@ export function useProjectGroupHeaderDrag({ containerTop: containerRect.top, scrollTop: container.scrollTop, rects: session.headerRects, - sidebarProjectGroupHeaderIds: session.sidebarProjectGroupHeaderIds + sidebarProjectGroupHeaderIds: session.sidebarProjectGroupHeaderIds, + contentBottom: container.scrollHeight }) }, [] diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.test.ts b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts index 58bd015df4a..8fa20c47b49 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drop.test.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts @@ -137,7 +137,9 @@ describe('computeProjectGroupHeaderDropPreview', () => { expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: 96 }) }) - it('does not create a drop slot inside an expanded Project Group section', () => { + it('snaps a drop inside the last expanded Project Group section to its bottom boundary', () => { + const INDICATOR_GAP = 4 + const sectionBottom = 380 const preview = computeProjectGroupHeaderDropPreview({ pointerY: 350, containerTop: 0, @@ -150,14 +152,83 @@ describe('computeProjectGroupHeaderDropPreview', () => { headerIndex: 2, top: 300, bottom: 328, - sectionBottom: 380 + sectionBottom } ] }) + // Only boundary available is 'c's section bottom → drop after 'c' (slot 3). + expect(preview).toEqual({ dropIndex: 3, dropIndicatorY: sectionBottom + INDICATOR_GAP }) + }) + + it('rejects a drop below the measured content when the estimated section overshoots', () => { + const preview = computeProjectGroupHeaderDropPreview({ + pointerY: 360, + containerTop: 0, + scrollTop: 0, + sidebarProjectGroupHeaderIds: ['a', 'b', 'c'], + rects: [ + { + groupId: 'c', + bucketKey: 'root', + headerIndex: 2, + top: 300, + bottom: 328, + sectionBottom: 380 + } + ], + // Estimate ends at 380 but the list renders to 340; 360 is below content. + contentBottom: 340 + }) + expect(preview).toBeNull() }) + it('rejects an edge-zone final drop below measured content when the estimate overshoots', () => { + const preview = computeProjectGroupHeaderDropPreview({ + pointerY: 389, + containerTop: 0, + scrollTop: 0, + sidebarProjectGroupHeaderIds: ['a', 'b', 'c'], + rects: [ + { + groupId: 'c', + bucketKey: 'root', + headerIndex: 2, + top: 300, + bottom: 328, + sectionBottom: 380 + } + ], + // 389 crosses the estimated final boundary but remains below real content. + contentBottom: 350 + }) + + expect(preview).toBeNull() + }) + + it('snaps a within-content drop even when the estimated section overshoots', () => { + const preview = computeProjectGroupHeaderDropPreview({ + pointerY: 335, + containerTop: 0, + scrollTop: 0, + sidebarProjectGroupHeaderIds: ['a', 'b', 'c'], + rects: [ + { + groupId: 'c', + bucketKey: 'root', + headerIndex: 2, + top: 300, + bottom: 328, + sectionBottom: 380 + } + ], + contentBottom: 340 + }) + + expect(preview).toEqual({ dropIndex: 3, dropIndicatorY: 384 }) + }) + it('uses the whole Project Group section for the final boundary slot', () => { const preview = computeProjectGroupHeaderDropPreview({ pointerY: 400, diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.ts b/src/renderer/src/components/sidebar/project-group-header-drop.ts index 512b636717f..81382372784 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drop.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drop.ts @@ -184,6 +184,7 @@ export function computeProjectGroupHeaderDropPreview(args: { scrollTop: number rects: readonly ProjectGroupHeaderDragRect[] sidebarProjectGroupHeaderIds: readonly string[] + contentBottom?: number }): ProjectGroupHeaderDropPreview | null { const { rects, sidebarProjectGroupHeaderIds } = args return computeWorktreeSidebarHeaderDropPreview({ @@ -192,6 +193,7 @@ export function computeProjectGroupHeaderDropPreview(args: { scrollTop: args.scrollTop, rects, headerCount: sidebarProjectGroupHeaderIds.length, - getId: (rect) => rect.groupId + getId: (rect) => rect.groupId, + contentBottom: args.contentBottom }) } diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts index 70a40d6d250..7b5a72def55 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts @@ -53,6 +53,42 @@ describe('commitProjectHeaderDragDrop', () => { expect(onCommitRepoOrder).toHaveBeenCalledWith(['c', 'a', 'b']) }) + it('moves a merged paired-host header upward as one stable block', () => { + const onCommitRepoOrder = vi.fn() + const repos = [makeRepo('b'), makeRepo('same'), makeRepo('c')] + const repoById = new Map(repos.map((repo) => [repo.id, repo])) + + commitProjectHeaderDragDrop({ + session: makeSession('same', ['b', 'same', 'c']), + sidebarDropIndex: 0, + orderedRepoIds: ['b', 'same', 'c', 'same'], + repoById, + usesProjectGroupOrdering: false, + onCommitRepoOrder, + onCommitProjectGroupOrder: vi.fn() + }) + + expect(onCommitRepoOrder).toHaveBeenCalledWith(['same', 'same', 'b', 'c']) + }) + + it('does not reorder host occurrences when a merged header stays in place', () => { + const onCommitRepoOrder = vi.fn() + const repos = [makeRepo('b'), makeRepo('same'), makeRepo('c')] + const repoById = new Map(repos.map((repo) => [repo.id, repo])) + + commitProjectHeaderDragDrop({ + session: makeSession('same', ['b', 'same', 'c']), + sidebarDropIndex: 2, + orderedRepoIds: ['b', 'same', 'c', 'same'], + repoById, + usesProjectGroupOrdering: false, + onCommitRepoOrder, + onCommitProjectGroupOrder: vi.fn() + }) + + expect(onCommitRepoOrder).not.toHaveBeenCalled() + }) + it('commits projectGroupOrder when project groups are present', () => { const onCommitProjectGroupOrder = vi.fn() const repos = [ diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.ts index 43c513905a6..2ab63663d71 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.ts @@ -1,5 +1,6 @@ import { applyAllRepoInsertAt, + getLogicalRepoOrderRankById, getProjectGroupOrderForSidebarDrop, mapSidebarProjectHeaderDropIndexToSiblingInsertIndex, mapSidebarRepoDropIndexToAllRepoInsertAt @@ -23,7 +24,13 @@ export function commitProjectHeaderDragDrop(args: { const sidebarRepoHeaderIds = args.session.sidebarRepoHeaderIds const sourceIndex = sidebarRepoHeaderIds.indexOf(args.session.repoId) - if (args.sidebarDropIndex === sourceIndex) { + // Why: both slots bordering the dragged header are visual no-ops. In + // particular, do not compact paired-host occurrences on an unchanged drop. + if ( + sourceIndex === -1 || + args.sidebarDropIndex === sourceIndex || + args.sidebarDropIndex === sourceIndex + 1 + ) { return } @@ -46,9 +53,7 @@ export function commitProjectHeaderDragDrop(args: { if (siblingDropIndex === sourceIndexInSiblings) { return } - const repoOrderRankById = new Map( - args.orderedRepoIds.map((repoId, index) => [repoId, index] as const) - ) + const repoOrderRankById = getLogicalRepoOrderRankById(args.orderedRepoIds) const order = getProjectGroupOrderForSidebarDrop({ siblings, dropIndex: siblingDropIndex, diff --git a/src/renderer/src/components/sidebar/project-header-drag.ts b/src/renderer/src/components/sidebar/project-header-drag.ts index e4fccc57c9f..4d13ddfa0af 100644 --- a/src/renderer/src/components/sidebar/project-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.ts @@ -72,13 +72,13 @@ export function useRepoHeaderDrag({ if (!session || !container) { return null } - const containerRect = container.getBoundingClientRect() return computeProjectHeaderDropPreview({ pointerY, - containerTop: containerRect.top, + containerTop: container.getBoundingClientRect().top, scrollTop: container.scrollTop, rects: session.headerRects, - sidebarRepoHeaderIds: session.sidebarRepoHeaderIds + sidebarRepoHeaderIds: session.sidebarRepoHeaderIds, + contentBottom: container.scrollHeight }) }, [] diff --git a/src/renderer/src/components/sidebar/project-header-drop.test.ts b/src/renderer/src/components/sidebar/project-header-drop.test.ts index c943ca97594..c0382a5f162 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { applyAllRepoInsertAt, computeProjectHeaderDropPreview, + getLogicalRepoOrderRankById, getProjectGroupOrderForSidebarDrop, getProjectHeaderDragBucketKey, getSidebarOrderedRepoHeaderIdsByBucket, @@ -53,6 +54,20 @@ describe('getSidebarOrderedRepoHeaderIdsByBucket', () => { }) }) +describe('getLogicalRepoOrderRankById', () => { + it('anchors a merged paired-host header to its first persisted occurrence', () => { + const rankById = getLogicalRepoOrderRankById(['b', 'same', 'c', 'same']) + + expect(rankById).toEqual( + new Map([ + ['b', 0], + ['same', 1], + ['c', 2] + ]) + ) + }) +}) + describe('mapSidebarRepoDropIndexToAllRepoInsertAt', () => { const sidebar = ['a', 'b', 'c'] @@ -139,7 +154,9 @@ describe('computeProjectHeaderDropPreview', () => { expect(preview).toEqual({ dropIndex: 3, dropIndicatorY: 383 }) }) - it('does not create a drop slot inside an expanded project section', () => { + it('snaps a drop inside the last expanded project section to its bottom boundary', () => { + const INDICATOR_GAP = 4 + const sectionBottom = 380 const preview = computeProjectHeaderDropPreview({ pointerY: 350, containerTop: 0, @@ -152,15 +169,18 @@ describe('computeProjectHeaderDropPreview', () => { headerIndex: 2, top: 300, bottom: 328, - sectionBottom: 380 + sectionBottom } ] }) - expect(preview).toBeNull() + // Only boundary available is 'c's section bottom → drop after 'c' (slot 3). + expect(preview).toEqual({ dropIndex: 3, dropIndicatorY: sectionBottom + INDICATOR_GAP }) }) - it('does not create a drop slot in the contents between sibling project headers', () => { + it('snaps a drop between sibling project headers to the nearer boundary', () => { + const INDICATOR_GAP = 4 + const nextHeaderTop = 220 const preview = computeProjectHeaderDropPreview({ pointerY: 150, containerTop: 0, @@ -173,13 +193,135 @@ describe('computeProjectHeaderDropPreview', () => { headerIndex: 0, top: 100, bottom: 128, - sectionBottom: 220 + sectionBottom: nextHeaderTop }, - { repoId: 'b', bucketKey: 'ungrouped', headerIndex: 1, top: 220, bottom: 248 } + { repoId: 'b', bucketKey: 'ungrouped', headerIndex: 1, top: nextHeaderTop, bottom: 248 } ] }) - expect(preview).toBeNull() + // pointerY 150 sits in 'a's body; nearer boundary is 'b's top (216 vs 224). + expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: nextHeaderTop - INDICATOR_GAP }) + }) + + describe('nearest-boundary choice across an interior gap', () => { + // The gap models estimate-vs-actual drift: sectionBottom is an estimated + // row offset (worktree-header-section-boundaries.ts, no virtualizer gap:6 or + // measured sizes) while the next header's top is actual vItem.start geometry, + // so they diverge in tall sections. Near the real boundary the pointer snaps + // to actual geometry (beforeNext); the estimate governs only deep-body drops. + const INDICATOR_GAP = 4 + const prevSectionBottom = 200 + const nextHeaderTop = 240 + const sectionBottomSlotY = prevSectionBottom + INDICATOR_GAP // 204 + const nextHeaderSlotY = nextHeaderTop - INDICATOR_GAP // 236 + const midpointY = (sectionBottomSlotY + nextHeaderSlotY) / 2 // 220 + const gapRects = [ + { + repoId: 'a', + bucketKey: 'ungrouped', + headerIndex: 0, + top: 100, + bottom: 128, + sectionBottom: prevSectionBottom + }, + { + repoId: 'b', + bucketKey: 'ungrouped', + headerIndex: 1, + top: nextHeaderTop, + bottom: 268, + sectionBottom: 340 + } + ] as const + + it('snaps to the previous section bottom when the pointer is nearer to it', () => { + const preview = computeProjectHeaderDropPreview({ + pointerY: sectionBottomSlotY + 1, // 205, closer to 204 than 236 + containerTop: 0, + scrollTop: 0, + sidebarRepoHeaderIds: ['a', 'b'], + rects: gapRects.map((rect) => ({ ...rect })) + }) + + expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: sectionBottomSlotY }) + }) + + it('snaps to the next header top when the pointer is nearer to it', () => { + const preview = computeProjectHeaderDropPreview({ + pointerY: nextHeaderSlotY - 1, // 235, closer to 236 than 204 + containerTop: 0, + scrollTop: 0, + sidebarRepoHeaderIds: ['a', 'b'], + rects: gapRects.map((rect) => ({ ...rect })) + }) + + expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: nextHeaderSlotY }) + }) + + it('breaks the midpoint tie toward the next header boundary', () => { + const preview = computeProjectHeaderDropPreview({ + pointerY: midpointY, // 220, equidistant → next header wins + containerTop: 0, + scrollTop: 0, + sidebarRepoHeaderIds: ['a', 'b'], + rects: gapRects.map((rect) => ({ ...rect })) + }) + + expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: nextHeaderSlotY }) + }) + }) + + describe('content bound for the last section', () => { + const INDICATOR_GAP = 4 + const estimatedSectionBottom = 380 + const lastRects = [ + { + repoId: 'c', + bucketKey: 'ungrouped', + headerIndex: 2, + top: 300, + bottom: 328, + sectionBottom: estimatedSectionBottom + } + ] as const + const lastPreview = (pointerY: number, contentBottom: number) => + computeProjectHeaderDropPreview({ + pointerY, + containerTop: 0, + scrollTop: 0, + sidebarRepoHeaderIds: ['a', 'b', 'c'], + rects: lastRects.map((rect) => ({ ...rect })), + contentBottom + }) + + it('rejects a drop below the measured content when the estimate overshoots', () => { + // Estimate says the section ends at 380, but the list actually renders to + // 340; a pointer at 360 is below real content → no fabricated final slot. + expect(lastPreview(360, 340)).toBeNull() + }) + + it('rejects an edge-zone final drop below measured content when the estimate overshoots', () => { + // 389 crosses the estimated final boundary (380 + 8px padding) while still + // sitting below the measured list end, so the content bound must win. + expect(lastPreview(389, 350)).toBeNull() + }) + + it('still snaps within the measured content when the estimate overshoots', () => { + // 335 is inside the real last section (ends at 340) → drop after 'c'. + expect(lastPreview(335, 340)).toEqual({ + dropIndex: 3, + dropIndicatorY: estimatedSectionBottom + INDICATOR_GAP + }) + }) + + it('snaps within the measured content when actual content undershoots the estimate', () => { + // Real content taller than the estimate (420 > 380): a 360 drop is well + // inside the section → still snaps to the final slot. + expect(lastPreview(360, 420)).toEqual({ + dropIndex: 3, + dropIndicatorY: estimatedSectionBottom + INDICATOR_GAP + }) + }) }) }) @@ -193,6 +335,15 @@ describe('applyAllRepoInsertAt', () => { ]) }) + it('moves duplicate host occurrences as one stable logical-project block', () => { + expect(applyAllRepoInsertAt(['b', 'same', 'c', 'same'], 'same', 0)).toEqual([ + 'same', + 'same', + 'b', + 'c' + ]) + }) + it('returns null for no-op reorders', () => { expect(applyAllRepoInsertAt(['a', 'b', 'c'], 'b', 2)).toBeNull() }) diff --git a/src/renderer/src/components/sidebar/project-header-drop.ts b/src/renderer/src/components/sidebar/project-header-drop.ts index 478b2b1b7f8..be012eb72ce 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.ts @@ -54,6 +54,20 @@ export function getSidebarOrderedRepoHeaderIdsByBucket( return buckets } +export function getLogicalRepoOrderRankById( + orderedRepoIds: readonly string[] +): Map { + const rankById = new Map() + orderedRepoIds.forEach((repoId, index) => { + // Why: paired hosts can contribute the same logical project ID; its merged + // header must anchor to the first occurrence instead of whichever host loaded last. + if (!rankById.has(repoId)) { + rankById.set(repoId, index) + } + }) + return rankById +} + export function getProjectGroupOrderForSidebarDrop(args: { siblings: readonly Repo[] dropIndex: number @@ -184,6 +198,7 @@ export function computeProjectHeaderDropPreview(args: { scrollTop: number rects: readonly ProjectHeaderDragRect[] sidebarRepoHeaderIds: readonly string[] + contentBottom?: number }): ProjectHeaderDropPreview | null { const { rects, sidebarRepoHeaderIds } = args return computeWorktreeSidebarHeaderDropPreview({ @@ -192,7 +207,8 @@ export function computeProjectHeaderDropPreview(args: { scrollTop: args.scrollTop, rects, headerCount: sidebarRepoHeaderIds.length, - getId: (rect) => rect.repoId + getId: (rect) => rect.repoId, + contentBottom: args.contentBottom }) } @@ -201,16 +217,20 @@ export function applyAllRepoInsertAt( draggedRepoId: string, insertAt: number ): string[] | null { - const fromIndex = allRepoIds.indexOf(draggedRepoId) - if (fromIndex === -1 || insertAt < 0 || insertAt > allRepoIds.length) { + if (!allRepoIds.includes(draggedRepoId) || insertAt < 0 || insertAt > allRepoIds.length) { return null } - const next = allRepoIds.slice() - next.splice(fromIndex, 1) - const adjustedInsertAt = insertAt > fromIndex ? insertAt - 1 : insertAt - if (adjustedInsertAt === fromIndex) { + // Why: one merged header controls every host-qualified occurrence; moving + // them together prevents a drag from persisting a cross-host split project. + const draggedBlock = allRepoIds.filter((repoId) => repoId === draggedRepoId) + const removedBeforeInsert = allRepoIds + .slice(0, insertAt) + .filter((repoId) => repoId === draggedRepoId).length + const adjustedInsertAt = insertAt - removedBeforeInsert + const next = allRepoIds.filter((repoId) => repoId !== draggedRepoId) + next.splice(adjustedInsertAt, 0, ...draggedBlock) + if (next.every((repoId, index) => repoId === allRepoIds[index])) { return null } - next.splice(adjustedInsertAt, 0, draggedRepoId) return next } diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-header-drop-preview.ts b/src/renderer/src/components/sidebar/worktree-sidebar-header-drop-preview.ts index 386a3e317a5..00dca75e41a 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-header-drop-preview.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-header-drop-preview.ts @@ -23,12 +23,22 @@ export function computeWorktreeSidebarHeaderDropPreview< rects: readonly TRect[] headerCount: number getId: (rect: TRect) => string + // Measured scroll-content height (same coordinate space as localY and the + // virtualizer-derived rect tops). Bounds the interior snap so it cannot + // fabricate a slot below the real list end. Optional: geometry-only unit + // tests omit it; live drags always pass container.scrollHeight. + contentBottom?: number }): WorktreeSidebarHeaderDropPreview | null { if (args.rects.length === 0 || args.headerCount === 0) { return null } const localY = args.pointerY - args.containerTop + args.scrollTop + // Why: every preview branch, including estimated edge slots, must stay + // inside the measured list content rather than fabricate a reorder below it. + if (args.contentBottom !== undefined && localY > args.contentBottom) { + return null + } const first = args.rects[0]! const last = args.rects.at(-1)! const lastBoundaryBottom = Math.max(last.bottom, last.sectionBottom ?? last.bottom) @@ -59,21 +69,74 @@ export function computeWorktreeSidebarHeaderDropPreview< } const hoveredRect = args.rects.find((rect) => localY >= rect.top && localY <= rect.bottom) - if (!hoveredRect) { + if (hoveredRect) { + const mid = (hoveredRect.top + hoveredRect.bottom) / 2 + const dropIndex = localY < mid ? hoveredRect.headerIndex : hoveredRect.headerIndex + 1 + const nextRect = + localY < mid ? hoveredRect : args.rects.find((rect) => rect.headerIndex >= dropIndex) + const indicatorY = nextRect + ? Math.max(0, nextRect.top - INDICATOR_GAP_PX) + : Math.max(hoveredRect.bottom, hoveredRect.sectionBottom ?? hoveredRect.bottom) + + INDICATOR_GAP_PX + + return { + dropIndex, + dropIndicatorY: Math.max(args.scrollTop, indicatorY) + } + } + + // localY is in a section body or interior gap, not a header band. Snap to the + // nearer boundary slot instead of returning null: this interior dead zone was + // accidental scope of 22d5989ed (#6609 only required correct reorder indices), + // and vanishing here makes the drop a silent no-op. + const boundary = pickNearestHeaderBoundarySlot(args.rects, localY) + if (!boundary) { return null } - - const mid = (hoveredRect.top + hoveredRect.bottom) / 2 - const dropIndex = localY < mid ? hoveredRect.headerIndex : hoveredRect.headerIndex + 1 - const nextRect = - localY < mid ? hoveredRect : args.rects.find((rect) => rect.headerIndex >= dropIndex) - const indicatorY = nextRect - ? Math.max(0, nextRect.top - INDICATOR_GAP_PX) - : Math.max(hoveredRect.bottom, hoveredRect.sectionBottom ?? hoveredRect.bottom) + - INDICATOR_GAP_PX - return { - dropIndex, - dropIndicatorY: Math.max(args.scrollTop, indicatorY) + dropIndex: boundary.dropIndex, + dropIndicatorY: Math.max(args.scrollTop, boundary.indicatorY) } } + +type WorktreeSidebarHeaderBoundarySlot = { + dropIndex: number + indicatorY: number +} + +function pickNearestHeaderBoundarySlot( + rects: readonly WorktreeSidebarHeaderDragRect[], + localY: number +): WorktreeSidebarHeaderBoundarySlot | null { + let prevRect: WorktreeSidebarHeaderDragRect | undefined + let nextRect: WorktreeSidebarHeaderDragRect | undefined + for (const rect of rects) { + if (rect.top <= localY) { + prevRect = rect + } else if (nextRect === undefined) { + nextRect = rect + } + } + + const afterPrev: WorktreeSidebarHeaderBoundarySlot | null = prevRect + ? { + dropIndex: prevRect.headerIndex + 1, + indicatorY: + Math.max(prevRect.bottom, prevRect.sectionBottom ?? prevRect.bottom) + INDICATOR_GAP_PX + } + : null + const beforeNext: WorktreeSidebarHeaderBoundarySlot | null = nextRect + ? { dropIndex: nextRect.headerIndex, indicatorY: Math.max(0, nextRect.top - INDICATOR_GAP_PX) } + : null + + if (!afterPrev) { + return beforeNext + } + if (!beforeNext) { + return afterPrev + } + // Ties (localY at the span midpoint) resolve to the next header's boundary. + return Math.abs(localY - beforeNext.indicatorY) <= Math.abs(localY - afterPrev.indicatorY) + ? beforeNext + : afterPrev +} diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx new file mode 100644 index 00000000000..ca0090a6c68 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx @@ -0,0 +1,230 @@ +// @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 type { + SkillFreshnessInstallation, + SkillFreshnessInventory +} from '../../../../shared/skill-freshness' +import { SkillFreshnessNudge } from './SkillFreshnessNudge' + +const mocks = vi.hoisted(() => ({ + dismissed: [] as string[], + updateSettings: vi.fn(), + toastInfo: vi.fn(), + toastDismiss: vi.fn(), + requestDialog: vi.fn(), + settingsLoaded: true, + inventory: null as SkillFreshnessInventory | null, + error: null as string | null +})) + +function placement( + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: 'orca-cli', + name: 'orca-cli', + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: '/home/.agents/skills/orca-cli', + resolvedPath: '/home/.agents/skills/orca-cli', + physicalIdentity: 'physical-orca-cli', + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +function eligibleInventory(): SkillFreshnessInventory { + return { + schemaVersion: 1, + installations: [placement()], + eligibleUpdateNames: ['orca-cli'], + scannedAt: 1 + } +} + +vi.mock('@/hooks/useSkillFreshness', () => ({ + useSkillFreshness: () => ({ + inventory: mocks.inventory, + loading: false, + error: mocks.error, + refresh: vi.fn() + }) +})) + +vi.mock('sonner', () => ({ + toast: { info: mocks.toastInfo, dismiss: mocks.toastDismiss } +})) + +vi.mock('./skill-freshness-update-dialog', () => ({ + requestSkillFreshnessUpdateDialog: mocks.requestDialog +})) + +vi.mock('@/store', () => { + const state = () => ({ + settings: mocks.settingsLoaded ? { dismissedSkillFreshnessNudges: mocks.dismissed } : null, + updateSettings: mocks.updateSettings + }) + const useAppStore = (selector: (value: ReturnType) => unknown) => selector(state()) + useAppStore.getState = state + return { useAppStore } +}) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderNudge(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +} + +async function rerenderNudge(): Promise { + await act(async () => { + root?.render() + }) +} + +const DISMISSAL_KEY = ['physical-orca-cli', 'orca-cli', '2'].join('\0') + +describe('SkillFreshnessNudge', () => { + beforeEach(() => { + mocks.dismissed = [] + mocks.settingsLoaded = true + mocks.inventory = eligibleInventory() + mocks.error = null + mocks.updateSettings.mockReset() + mocks.updateSettings.mockResolvedValue(undefined) + mocks.toastInfo.mockReset() + mocks.toastInfo.mockReturnValue('freshness-toast') + mocks.toastDismiss.mockReset() + mocks.requestDialog.mockReset() + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('lingers without an auto-close timer or auto-close dismissal write', async () => { + await renderNudge() + + expect(mocks.toastInfo).toHaveBeenCalledTimes(1) + const options = mocks.toastInfo.mock.calls[0]?.[1] + expect(options.duration).toBe(Number.POSITIVE_INFINITY) + expect(options.onAutoClose).toBeUndefined() + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('opens the update dialog on action click without persisting a dismissal', async () => { + await renderNudge() + + const options = mocks.toastInfo.mock.calls[0]?.[1] + options.action.onClick() + options.onDismiss() + + expect(mocks.requestDialog).toHaveBeenCalledTimes(1) + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('retracts a resolved nudge without recording a dismissal', async () => { + await renderNudge() + const options = mocks.toastInfo.mock.calls[0]?.[1] + + mocks.inventory = { + schemaVersion: 1, + installations: [placement({ status: 'current', observedPackageDigest: 'current' })], + eligibleUpdateNames: [], + scannedAt: 2 + } + await rerenderNudge() + // Sonner invokes onDismiss for programmatic dismissals on its next render. + options.onDismiss() + + expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast') + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('retracts a stale nudge when re-inventory fails', async () => { + await renderNudge() + const options = mocks.toastInfo.mock.calls[0]?.[1] + + mocks.inventory = null + mocks.error = 'scan failed' + await rerenderNudge() + options.onDismiss() + + expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast') + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('persists the exact placement/revision key once on explicit dismissal', async () => { + await renderNudge() + + const options = mocks.toastInfo.mock.calls[0]?.[1] + options.onDismiss() + options.onDismiss() + + expect(mocks.updateSettings).toHaveBeenCalledTimes(1) + expect(mocks.updateSettings).toHaveBeenCalledWith({ + dismissedSkillFreshnessNudges: [DISMISSAL_KEY] + }) + }) + + it('does not repeat the same nudge within a session', async () => { + await renderNudge() + // A fresh inventory object with identical content re-runs the effect. + mocks.inventory = eligibleInventory() + await rerenderNudge() + + expect(mocks.toastInfo).toHaveBeenCalledTimes(1) + }) + + it('does not repeat a nudge for an already dismissed exact tuple', async () => { + mocks.dismissed = [DISMISSAL_KEY] + + await renderNudge() + + expect(mocks.toastInfo).not.toHaveBeenCalled() + }) + + it('does not nudge for a poisoned name with no eligible update', async () => { + mocks.inventory = { + schemaVersion: 1, + installations: [placement(), placement({ id: 'repo-copy', topology: 'repo-scope' })], + eligibleUpdateNames: [], + scannedAt: 1 + } + + await renderNudge() + + expect(mocks.toastInfo).not.toHaveBeenCalled() + }) + + it('waits for persisted settings before deciding whether to nudge', async () => { + mocks.settingsLoaded = false + + await renderNudge() + + expect(mocks.toastInfo).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx new file mode 100644 index 00000000000..135d860656b --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef } from 'react' +import { Terminal } from 'lucide-react' +import { toast } from 'sonner' +import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' + +const MAX_DISMISSED_FRESHNESS_NUDGES = 512 +const NO_DISMISSED_FRESHNESS_NUDGES: string[] = [] + +type ActiveFreshnessNudge = { + id: string | number + fingerprint: string + persistDismissal: boolean +} + +function candidateKey(args: { + physicalIdentity: string + name: string + currentReleaseRevision: number +}): string { + return [args.physicalIdentity, args.name, args.currentReleaseRevision].join('\0') +} + +export function SkillFreshnessNudge(): null { + const state = useSkillFreshness() + const settingsLoaded = useAppStore((store) => store.settings !== null) + const dismissed = useAppStore( + (store) => store.settings?.dismissedSkillFreshnessNudges ?? NO_DISMISSED_FRESHNESS_NUDGES + ) + const updateSettings = useAppStore((store) => store.updateSettings) + const shownFingerprints = useRef(new Set()) + const persistedFingerprints = useRef(new Set()) + const activeNudgeRef = useRef(null) + + useEffect(() => { + const inventory = state.inventory + if (!settingsLoaded) { + return + } + if (!inventory) { + const active = activeNudgeRef.current + if (state.error && active) { + // Why: a failed re-check cannot keep advertising authority derived from + // old bytes; retract without turning the scan failure into a dismissal. + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } + return + } + const eligibleNames = new Set(inventory.eligibleUpdateNames) + const candidates = inventory.installations.flatMap((installation) => + installation.status === 'outdated' && + eligibleNames.has(installation.name) && + installation.physicalIdentity + ? [ + { + key: candidateKey({ + physicalIdentity: installation.physicalIdentity, + name: installation.name, + currentReleaseRevision: installation.currentReleaseRevision + }), + name: installation.name + } + ] + : [] + ) + const dismissedKeys = new Set(dismissed) + const unseen = candidates.filter((candidate) => !dismissedKeys.has(candidate.key)) + if (unseen.length === 0) { + const active = activeNudgeRef.current + if (active) { + // Why: a resolved/replaced nudge is stale presentation, not an explicit + // user dismissal, so retract it without persisting its tuple keys. + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } + return + } + const fingerprint = unseen + .map((candidate) => candidate.key) + .sort((left, right) => left.localeCompare(right, 'en')) + .join('\n') + const active = activeNudgeRef.current + if (active?.fingerprint === fingerprint) { + return + } + if (active) { + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } + if (shownFingerprints.current.has(fingerprint)) { + return + } + shownFingerprints.current.add(fingerprint) + + const persistDismissal = (): void => { + if (persistedFingerprints.current.has(fingerprint)) { + return + } + persistedFingerprints.current.add(fingerprint) + const current = useAppStore.getState().settings?.dismissedSkillFreshnessNudges ?? [] + const next = [...new Set([...current, ...unseen.map((candidate) => candidate.key)])].slice( + -MAX_DISMISSED_FRESHNESS_NUDGES + ) + void updateSettings({ dismissedSkillFreshnessNudges: next }).catch(() => { + persistedFingerprints.current.delete(fingerprint) + }) + } + const names = new Set(unseen.map((candidate) => candidate.name)) + // Why: name the outdated skills so the nudge is actionable without opening + // the modal; the sentence is translatable but the identifiers interpolate as-is. + const outdatedNames = [...names] + .sort((left, right) => left.localeCompare(right, 'en')) + .join(', ') + const nextActive: ActiveFreshnessNudge = { + id: '', + fingerprint, + persistDismissal: true + } + nextActive.id = toast.info( + names.size === 1 + ? translate( + 'auto.components.skills.SkillFreshnessNudge.titleOne', + 'An installed Orca skill is out of date' + ) + : translate( + 'auto.components.skills.SkillFreshnessNudge.titleMany', + '{{value0}} installed Orca skills are out of date', + { value0: names.size } + ), + { + description: translate( + 'auto.components.skills.SkillFreshnessNudge.description', + 'Update {{value0}} so agents follow the current instructions for this version of Orca.', + { value0: outdatedNames } + ), + // Why: the nudge lingers until the user acts. Ignoring it (app quit) + // records nothing, so a still-outdated skill may prompt once next launch. + duration: Number.POSITIVE_INFINITY, + // Why: only an explicit dismissal (the close button) records the keys; + // opening the review dialog is engagement, not a decision to hide it. + onDismiss: () => { + if (nextActive.persistDismissal) { + persistDismissal() + } + if (activeNudgeRef.current === nextActive) { + activeNudgeRef.current = null + } + }, + action: { + label: ( + + + {names.size === 1 + ? translate('auto.components.skills.SkillFreshnessNudge.updateOne', 'Update skill') + : translate( + 'auto.components.skills.SkillFreshnessNudge.updateMany', + 'Update skills' + )} + + ), + onClick: () => { + // Sonner closes action toasts without onDismiss; clear ownership so + // a later inventory cannot treat the already-closed toast as active. + nextActive.persistDismissal = false + if (activeNudgeRef.current === nextActive) { + activeNudgeRef.current = null + } + requestSkillFreshnessUpdateDialog() + } + } + } + ) + activeNudgeRef.current = nextActive + }, [dismissed, settingsLoaded, state.error, state.inventory, updateSettings]) + + return null +} diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx new file mode 100644 index 00000000000..785b67aaf07 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx @@ -0,0 +1,107 @@ +// @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 type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import { SkillFreshnessStatusPill } from './SkillFreshnessStatusPill' + +const mocks = vi.hoisted(() => ({ + inventory: null as SkillFreshnessInventory | null +})) + +vi.mock('@/hooks/useSkillFreshness', () => ({ + useSkillFreshness: () => ({ + inventory: mocks.inventory, + loading: false, + error: null, + refresh: vi.fn() + }) +})) + +function inventory( + entries: { name: string; status: 'current' | 'outdated' | 'unrecognized' }[], + eligibleUpdateNames: string[] +): SkillFreshnessInventory { + return { + schemaVersion: 1, + installations: entries.map((entry, index) => ({ + id: `${entry.name}-${index}`, + name: entry.name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${entry.name}`, + resolvedPath: `/home/.agents/skills/${entry.name}`, + physicalIdentity: `physical-${entry.name}-${index}`, + topology: 'canonical-copy', + status: entry.status, + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null + })), + eligibleUpdateNames, + scannedAt: 1 + } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderPill(skillName: string): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) + return container +} + +describe('SkillFreshnessStatusPill', () => { + beforeEach(() => { + mocks.inventory = null + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('shows Update available for an eligible outdated skill', async () => { + mocks.inventory = inventory([{ name: 'orca-cli', status: 'outdated' }], ['orca-cli']) + + expect((await renderPill('orca-cli')).textContent).toBe('Update available') + }) + + it('shows Up to date when every placement is current', async () => { + mocks.inventory = inventory([{ name: 'orca-cli', status: 'current' }], []) + + expect((await renderPill('orca-cli')).textContent).toBe('Up to date') + }) + + it('falls back to Installed for a blocked outdated placement', async () => { + mocks.inventory = inventory( + [ + { name: 'orca-cli', status: 'outdated' }, + { name: 'orca-cli', status: 'unrecognized' } + ], + [] + ) + + expect((await renderPill('orca-cli')).textContent).toBe('Installed') + }) + + it('falls back to Installed before the inventory loads', async () => { + expect((await renderPill('orca-cli')).textContent).toBe('Installed') + }) +}) diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx new file mode 100644 index 00000000000..3c858686591 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx @@ -0,0 +1,40 @@ +import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { translate } from '@/i18n/i18n' +import { IntegrationStatusPill } from '@/components/integration-status-pill' + +// Why: the setup rails' Installed pill is presence-only; when freshness knows a +// safe update exists (or that every copy is current) the pill should say so. +// Falls back to plain Installed for blocked/unrecognized copies so an unsafe +// placement is never advertised as updatable here. +export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element { + const { inventory } = useSkillFreshness() + if (inventory?.eligibleUpdateNames.includes(skillName)) { + return ( + + {translate( + 'auto.components.skills.SkillFreshnessStatusPill.updateAvailable', + 'Update available' + )} + + ) + } + const placements = inventory?.installations.filter( + (installation) => installation.name === skillName + ) + if ( + placements && + placements.length > 0 && + placements.every((installation) => installation.status === 'current') + ) { + return ( + + {translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date')} + + ) + } + return ( + + {translate('auto.components.skills.SkillFreshnessStatusPill.installed', 'Installed')} + + ) +} diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx new file mode 100644 index 00000000000..a810ae06cc6 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -0,0 +1,371 @@ +// @vitest-environment happy-dom + +import { act, useState, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + SkillFreshnessInstallation, + SkillFreshnessInventory +} from '../../../../shared/skill-freshness' +import { SkillFreshnessUpdateDialog } from './SkillFreshnessUpdateDialog' +import { + consumeSkillFreshnessUpdateDialogRequest, + requestSkillFreshnessUpdateDialog +} from './skill-freshness-update-dialog' + +const mocks = vi.hoisted(() => ({ + inventory: null as SkillFreshnessInventory | null, + loading: false, + error: null as string | null, + refresh: vi.fn(), + terminalProps: [] as { + command: string + description: string + onInteracted?: (method: 'keyboard' | 'pointer', event?: { key?: string }) => void + onTerminalExit?: () => void + }[], + notifyChanged: vi.fn() +})) + +vi.mock('@/hooks/useSkillFreshness', () => ({ + useSkillFreshness: () => ({ + inventory: mocks.inventory, + loading: mocks.loading, + error: mocks.error, + refresh: mocks.refresh + }) +})) + +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + notifyInstalledAgentSkillsChanged: mocks.notifyChanged +})) + +vi.mock('@/components/onboarding/OnboardingInlineCommandTerminal', () => ({ + OnboardingInlineCommandTerminal: (props: (typeof mocks.terminalProps)[number]) => { + mocks.terminalProps.push(props) + return
{props.command}
+ } +})) + +// Radix Dialog/Collapsible internals (portal, focus-scope) are exercised in +// Electron QA; here the content logic is what matters, so use plain wrappers. +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children?: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children?: ReactNode }) =>

{children}

+})) + +vi.mock('@/components/ui/collapsible', () => ({ + Collapsible: ({ + children, + defaultOpen = false + }: { + children?: ReactNode + defaultOpen?: boolean + }) => { + // Model Radix's uncontrolled default: changing defaultOpen only matters + // when the keyed disclosure remounts. + const [open] = useState(defaultOpen) + return
{children}
+ }, + CollapsibleTrigger: ({ children }: { children?: ReactNode }) =>
{children}
, + CollapsibleContent: ({ children }: { children?: ReactNode }) =>
{children}
+})) + +function placement( + name: string, + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: `${name}-${overrides.rootId ?? 'home-agents'}`, + name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${name}`, + resolvedPath: `/home/.agents/skills/${name}`, + physicalIdentity: `physical-${name}`, + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +function eligibleInventory(): SkillFreshnessInventory { + return { + schemaVersion: 1, + installations: [placement('orca-cli')], + eligibleUpdateNames: ['orca-cli'], + scannedAt: 1 + } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderDialog(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +} + +async function rerender(): Promise { + await act(async () => { + root?.render() + }) +} + +async function openViaRequest(): Promise { + await act(async () => { + requestSkillFreshnessUpdateDialog() + }) +} + +async function clickButton(label: string): Promise { + const button = Array.from(container?.querySelectorAll('button') ?? []).find( + (candidate) => candidate.textContent?.trim() === label + ) + expect(button).toBeDefined() + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +describe('SkillFreshnessUpdateDialog', () => { + beforeEach(() => { + consumeSkillFreshnessUpdateDialogRequest() + mocks.inventory = eligibleInventory() + mocks.loading = false + mocks.error = null + mocks.refresh.mockReset() + mocks.notifyChanged.mockReset() + mocks.terminalProps.length = 0 + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('stays closed until an open request arrives', async () => { + await renderDialog() + expect(container?.querySelector('[data-dialog-open]')).toBeNull() + }) + + it('shows the eligible summary and the exact pre-filled draft command', async () => { + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain('Update skills') + expect(container?.textContent).toContain('1 skill can be updated safely') + expect(mocks.terminalProps.at(-1)).toMatchObject({ + command: 'npx skills update orca-cli --global', + description: 'Review the pre-filled command, then press Enter to run it.' + }) + }) + + it('resolves a request made before inventory loads once a safe command arrives', async () => { + mocks.inventory = null + await openViaRequest() + await renderDialog() + + expect(container?.querySelector('[data-dialog-open]')).not.toBeNull() + expect(mocks.terminalProps).toEqual([]) + + mocks.inventory = eligibleInventory() + await rerender() + + expect(mocks.terminalProps.at(-1)?.command).toBe('npx skills update orca-cli --global') + }) + + it('shows the up-to-date state once every installation is current', async () => { + await renderDialog() + await openViaRequest() + + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('orca-cli', { status: 'current', observedPackageDigest: 'current' }) + ], + eligibleUpdateNames: [], + scannedAt: 2 + } + await rerender() + + expect(container?.textContent).toContain('All installed Orca skills are up to date.') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('auto-expands Details when a rescan changes the result to blocked', async () => { + await renderDialog() + await openViaRequest() + expect(container?.querySelector('[data-collapsible-open="false"]')).not.toBeNull() + + mocks.inventory = { + schemaVersion: 1, + installations: [placement('orca-cli', { topology: 'read-only' })], + eligibleUpdateNames: [], + scannedAt: 2 + } + await rerender() + + expect(container?.querySelector('[data-collapsible-open="true"]')).not.toBeNull() + }) + + it('does not replace or tear down a submitted command during a rescan', async () => { + await renderDialog() + await openViaRequest() + + await act(async () => { + mocks.terminalProps.at(-1)?.onInteracted?.('keyboard', { key: 'Enter' }) + }) + mocks.inventory = null + mocks.loading = true + await rerender() + + expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe( + 'npx skills update orca-cli --global' + ) + + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('orca-cli', { status: 'current', observedPackageDigest: 'current' }) + ], + eligibleUpdateNames: [], + scannedAt: 2 + } + mocks.loading = false + await rerender() + + expect(container?.textContent).toContain('All installed Orca skills are up to date.') + expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe( + 'npx skills update orca-cli --global' + ) + }) + + it('removes an unsubmitted draft as soon as its inventory is invalidated', async () => { + await renderDialog() + await openViaRequest() + expect(container?.querySelector('[data-testid="update-terminal"]')).not.toBeNull() + + mocks.inventory = null + mocks.loading = true + await rerender() + + expect(container?.textContent).toContain('Checking installed Orca skills') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('shows a failed scan as an error instead of indefinite progress', async () => { + mocks.inventory = null + mocks.error = 'Could not inspect installed skills.' + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain('Could not inspect installed skills.') + expect(container?.textContent).not.toContain('Checking installed Orca skills') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('creates a fresh draft after a terminal exits and the rescan still finds an update', async () => { + await renderDialog() + await openViaRequest() + const firstTerminal = mocks.terminalProps.at(-1) + + await act(async () => { + firstTerminal?.onTerminalExit?.() + }) + expect(mocks.notifyChanged).toHaveBeenCalledTimes(1) + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + + mocks.inventory = { ...eligibleInventory(), scannedAt: 2 } + await rerender() + + expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe( + 'npx skills update orca-cli --global' + ) + expect(mocks.terminalProps.at(-1)).not.toBe(firstTerminal) + }) + + it('explains a poisoned sibling in Details without offering a command', async () => { + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('orca-cli'), + placement('orca-cli', { + id: 'repo-copy', + rootId: 'repo', + sourceKind: 'repo', + topology: 'repo-scope', + status: 'unrecognized', + unresolvedPath: '/repo/.agents/skills/orca-cli' + }) + ], + eligibleUpdateNames: [], + scannedAt: 1 + } + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain( + 'it may be modified, or a different skill with the same name' + ) + expect(container?.textContent).toContain('Unrecognized') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('explains a self-blocked read-only placement without blaming a sibling', async () => { + mocks.inventory = { + schemaVersion: 1, + installations: [placement('orca-cli', { topology: 'read-only' })], + eligibleUpdateNames: [], + scannedAt: 1 + } + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain( + 'read-only location, so Orca left it out of the update' + ) + expect(container?.textContent).toContain('Read only') + }) + + it('re-inventories installed skills when the dialog closes', async () => { + await renderDialog() + await openViaRequest() + + await clickButton('Close') + + expect(mocks.notifyChanged).toHaveBeenCalledTimes(1) + expect(container?.querySelector('[data-dialog-open]')).toBeNull() + }) + + it('forces a re-check from the Re-check affordance', async () => { + await renderDialog() + await openViaRequest() + + await clickButton('Re-check') + + expect(mocks.refresh).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx new file mode 100644 index 00000000000..0a0e342c467 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx @@ -0,0 +1,310 @@ +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type KeyboardEvent +} from 'react' +import { AlertTriangle, CheckCircle2, ChevronDown, Loader2, RefreshCw } from 'lucide-react' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import { buildTargetedSkillUpdateCommand } from '../../../../shared/skill-freshness' +import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { OnboardingInlineCommandTerminal } from '@/components/onboarding/OnboardingInlineCommandTerminal' +import { TooltipProvider } from '@/components/ui/tooltip' +import { groupSkillFreshness } from './skill-freshness-grouping' +import { SkillFreshnessGroup } from './skill-freshness-group' +import { + consumeSkillFreshnessUpdateDialogRequest, + getSkillFreshnessUpdateDialogRequest, + subscribeSkillFreshnessUpdateDialog +} from './skill-freshness-update-dialog' + +type FreshnessSummaryKind = 'loading' | 'empty' | 'eligible' | 'current' | 'attention' + +function summarizeInventory( + inventory: SkillFreshnessInventory | null, + hasBlockedGroup: boolean +): FreshnessSummaryKind { + if (!inventory) { + return 'loading' + } + if (inventory.installations.length === 0) { + return 'empty' + } + if (inventory.eligibleUpdateNames.length > 0) { + return 'eligible' + } + // Why: with nothing eligible, the modal is either genuinely all-clear or has + // out-of-date skills it can't safely update; the group filter already dropped + // the up-to-date and unrecognized-only noise, so a blocked group is the signal. + return hasBlockedGroup ? 'attention' : 'current' +} + +function SummaryHeadline({ + kind, + eligibleCount +}: { + kind: FreshnessSummaryKind + eligibleCount: number +}): React.JSX.Element { + if (kind === 'loading') { + return ( +
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.checking', + 'Checking installed Orca skills…' + )} +
+ ) + } + if (kind === 'empty') { + return ( +

+ {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.none', + 'No installed Orca skills found.' + )} +

+ ) + } + if (kind === 'current') { + return ( +
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.success', + 'All installed Orca skills are up to date.' + )} +
+ ) + } + if (kind === 'attention') { + return ( +
+
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.attention', + 'Some installed Orca skills were left out of the update.' + )} +
+

+ {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.attentionDescription', + 'Open Update details to see why each one was skipped.' + )} +

+
+ ) + } + return ( +
+

+ {eligibleCount === 1 + ? translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.updateOne', + '1 skill can be updated safely' + ) + : translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.updateMany', + '{{value0}} skills can be updated safely', + { value0: eligibleCount } + )} +

+
+ ) +} + +export function SkillFreshnessUpdateDialog(): React.JSX.Element { + const state = useSkillFreshness() + const open = useSyncExternalStore( + subscribeSkillFreshnessUpdateDialog, + getSkillFreshnessUpdateDialogRequest, + getSkillFreshnessUpdateDialogRequest + ) + const [terminalCommand, setTerminalCommand] = useState(null) + const [awaitingExitRefresh, setAwaitingExitRefresh] = useState(false) + const terminalSubmittedRef = useRef(false) + const inventoryAtTerminalExitRef = useRef(null) + const inventory = state.inventory + const eligibleNames = useMemo(() => inventory?.eligibleUpdateNames ?? [], [inventory]) + const groups = useMemo( + () => + inventory ? groupSkillFreshness(inventory.installations, inventory.eligibleUpdateNames) : [], + [inventory] + ) + const hasBlockedGroup = groups.some((group) => group.status === 'cannot-update') + const updateCommand = buildTargetedSkillUpdateCommand(eligibleNames) + const summaryKind = summarizeInventory(inventory, hasBlockedGroup) + + useEffect(() => { + if (!open) { + return + } + if (state.loading || state.error || !inventory) { + // Why: a scan invalidates the authorization behind an unsubmitted draft. + // A running command keeps its PTY until exit, but stale drafts fail closed. + if (!terminalSubmittedRef.current && terminalCommand !== null) { + setTerminalCommand(null) + } + return + } + if (awaitingExitRefresh) { + if (inventory === inventoryAtTerminalExitRef.current) { + return + } + inventoryAtTerminalExitRef.current = null + setAwaitingExitRefresh(false) + return + } + if (terminalSubmittedRef.current || terminalCommand === updateCommand) { + return + } + // Why: changing the shared onboarding terminal's command pastes it again. + // Replace only an unsubmitted draft; a submitted command owns its PTY until exit. + setTerminalCommand(updateCommand) + }, [ + awaitingExitRefresh, + inventory, + open, + state.error, + state.loading, + terminalCommand, + updateCommand + ]) + + const handleOpenChange = (next: boolean): void => { + // Why: closing is the natural point to re-observe bytes so a completed update + // clears the state and the lingering nudge does not fire again. + if (!next) { + consumeSkillFreshnessUpdateDialogRequest() + terminalSubmittedRef.current = false + inventoryAtTerminalExitRef.current = null + setAwaitingExitRefresh(false) + setTerminalCommand(null) + notifyInstalledAgentSkillsChanged() + } + } + + const handleTerminalInteraction = ( + method: 'keyboard' | 'pointer', + event?: KeyboardEvent + ): void => { + if (method === 'keyboard' && event?.key === 'Enter') { + terminalSubmittedRef.current = true + } + } + + const handleTerminalExit = (): void => { + terminalSubmittedRef.current = false + inventoryAtTerminalExitRef.current = inventory + setAwaitingExitRefresh(true) + setTerminalCommand(null) + notifyInstalledAgentSkillsChanged() + } + + const hasVisibleGroups = groups.length > 0 + + return ( + + + + + {translate('auto.components.skills.SkillFreshnessUpdateDialog.title', 'Update skills')} + + + + {state.error ? ( +

{state.error}

+ ) : ( + + )} + + {terminalCommand ? ( + + ) : null} + + {/* Why: Radix reads defaultOpen only on mount. Remount when a scan + becomes blocked so the required diagnostic details actually open. */} + {hasVisibleGroups ? ( + + + + + + + {groups.map((group) => ( + + ))} + + + + ) : null} + + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/skills/skill-freshness-group.tsx b/src/renderer/src/components/skills/skill-freshness-group.tsx new file mode 100644 index 00000000000..c6dac96479b --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-group.tsx @@ -0,0 +1,203 @@ +import type { + SkillFreshnessGroupModel, + SkillLocationChip, + SkillLocationRow +} from './skill-freshness-grouping' +import { translate } from '@/i18n/i18n' +import { Badge } from '@/components/ui/badge' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' + +function chipLabel(chip: SkillLocationChip): string { + switch (chip) { + case 'current': + return translate('auto.components.skills.SkillFreshnessRow.chipCurrent', 'Current') + case 'unrecognized': + return translate('auto.components.skills.SkillFreshnessRow.chipUnrecognized', 'Unrecognized') + case 'inaccessible': + return translate('auto.components.skills.SkillFreshnessRow.chipInaccessible', 'Inaccessible') + case 'duplicate': + return translate('auto.components.skills.SkillFreshnessRow.chipDuplicate', 'Duplicate') + case 'external-link': + return translate('auto.components.skills.SkillFreshnessRow.chipExternalLink', 'External link') + case 'broken-link': + return translate('auto.components.skills.SkillFreshnessRow.chipBrokenLink', 'Broken link') + case 'read-only': + return translate('auto.components.skills.SkillFreshnessRow.chipReadOnly', 'Read only') + case 'in-a-repo': + return translate('auto.components.skills.SkillFreshnessRow.chipInRepo', 'In a repo') + case 'plugin-cache': + return translate('auto.components.skills.SkillFreshnessRow.chipPluginCache', 'Plugin cache') + } +} + +// Why: chips describe only what a location *is*; the effect on the update +// command lives in the per-skill sentence, so the two never say it twice. +function chipTooltip(chip: SkillLocationChip): string { + switch (chip) { + case 'current': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipCurrent', + 'This copy matches the current official version.' + ) + case 'unrecognized': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipUnrecognized', + 'This copy doesn’t match any official version — it may be modified, or a different skill with the same name.' + ) + case 'inaccessible': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipInaccessible', + 'Orca couldn’t read this copy (a permissions or file error).' + ) + case 'duplicate': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipDuplicate', + 'A separate copy of this skill, installed apart from the main one.' + ) + case 'external-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipExternalLink', + 'A shortcut pointing outside Orca’s skill folders.' + ) + case 'broken-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipBrokenLink', + 'A shortcut to something that no longer exists.' + ) + case 'read-only': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipReadOnly', + 'This copy is in a read-only location.' + ) + case 'in-a-repo': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipInRepo', + 'This copy lives inside a project, not your global skills.' + ) + case 'plugin-cache': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipPluginCache', + 'This copy is managed by a plugin.' + ) + } +} + +// Why: a skill is skipped for one concrete reason; lead with the highest-priority +// blocking placement so the sentence explains the real cause (an edited copy is +// more useful to surface than a downstream symptom). +const SKIPPED_REASON_PRIORITY: SkillLocationChip[] = [ + 'unrecognized', + 'read-only', + 'inaccessible', + 'in-a-repo', + 'plugin-cache', + 'external-link', + 'broken-link' +] + +function skippedReason(locations: readonly SkillLocationRow[]): string { + const present = new Set(locations.map((location) => location.chip)) + const chip = SKIPPED_REASON_PRIORITY.find((candidate) => present.has(candidate)) + switch (chip) { + case 'unrecognized': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonUnrecognized', + 'The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.' + ) + case 'read-only': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonReadOnly', + 'This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.' + ) + case 'inaccessible': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonInaccessible', + 'Orca couldn’t read this copy, so it left the skill out of the update.' + ) + case 'in-a-repo': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonInRepo', + 'This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.' + ) + case 'plugin-cache': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonPluginCache', + 'A plugin manages this skill, so Orca left it out of the update — update the plugin instead.' + ) + case 'external-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonExternalLink', + 'This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.' + ) + case 'broken-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonBrokenLink', + 'This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.' + ) + default: + return translate( + 'auto.components.skills.SkillFreshnessRow.cantUpdateReason', + 'Orca left this skill out of the update command.' + ) + } +} + +export function SkillFreshnessGroup({ + group +}: { + group: SkillFreshnessGroupModel +}): React.JSX.Element { + const isBlocked = group.status === 'cannot-update' + return ( +
+
+ {group.name} + {isBlocked ? ( + + {translate('auto.components.skills.SkillFreshnessRow.statusCantUpdate', 'Skipped')} + + ) : ( + + {translate( + 'auto.components.skills.SkillFreshnessRow.statusUpdateAvailable', + 'Update available' + )} + + )} +
+ {isBlocked ? ( +

{skippedReason(group.locations)}

+ ) : null} +
+ {group.locations.map((location) => ( +
+ + {location.path} + + {location.chip ? ( + + + + {chipLabel(location.chip)} + + + + {chipTooltip(location.chip)} + + + ) : null} +
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/skills/skill-freshness-grouping.test.ts b/src/renderer/src/components/skills/skill-freshness-grouping.test.ts new file mode 100644 index 00000000000..03368ec07bf --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-grouping.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import type { SkillFreshnessInstallation } from '../../../../shared/skill-freshness' +import { groupSkillFreshness } from './skill-freshness-grouping' + +function placement( + name: string, + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: `${name}-${overrides.rootId ?? 'home-agents'}-${overrides.unresolvedPath ?? 'a'}`, + name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${name}`, + resolvedPath: `/home/.agents/skills/${name}`, + physicalIdentity: `physical-${name}`, + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +describe('groupSkillFreshness', () => { + it('marks an eligible outdated skill as update-available with one location', () => { + const groups = groupSkillFreshness([placement('orca-cli')], ['orca-cli']) + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ name: 'orca-cli', status: 'update-available' }) + expect(groups[0]?.locations).toEqual([ + { id: expect.any(String), path: '/home/.agents/skills/orca-cli', chip: null } + ]) + }) + + it('hides skills with nothing out of date (current, unrecognized-only, unreadable-only)', () => { + const groups = groupSkillFreshness( + [ + placement('orca-cli', { status: 'current' }), + placement('dataviz', { status: 'unrecognized', topology: 'independent-copy' }), + placement('linear-tickets', { status: 'inaccessible' }) + ], + [] + ) + expect(groups).toEqual([]) + }) + + it('groups a blocked skill and flags the culprit location, not the main copy', () => { + const groups = groupSkillFreshness( + [ + placement('orchestration'), + placement('orchestration', { + rootId: 'home-claude', + unresolvedPath: '/home/.claude/skills/orchestration', + status: 'unrecognized', + topology: 'independent-copy' + }) + ], + [] + ) + expect(groups).toHaveLength(1) + expect(groups[0]?.status).toBe('cannot-update') + // Why: the out-of-date main copy is bare; only the poisoning copy carries a chip. + expect(groups[0]?.locations).toEqual([ + { id: expect.any(String), path: '/home/.agents/skills/orchestration', chip: null }, + { id: expect.any(String), path: '/home/.claude/skills/orchestration', chip: 'unrecognized' } + ]) + }) + + it('prefers a location status over its topology and maps every topology to a chip', () => { + const chipFor = (overrides: Partial): string | null => + groupSkillFreshness( + [placement('s', { status: 'outdated' }), placement('s', overrides)], + ['s'] + )[0]?.locations.find((location) => location.path.includes('culprit'))?.chip ?? null + const at = (path: string, rest: Partial) => ({ + unresolvedPath: `/culprit/${path}`, + ...rest + }) + expect(chipFor(at('a', { status: 'unrecognized', topology: 'independent-copy' }))).toBe( + 'unrecognized' + ) + expect(chipFor(at('b', { status: 'inaccessible', topology: 'read-only' }))).toBe('inaccessible') + expect(chipFor(at('c', { topology: 'independent-copy' }))).toBe('duplicate') + expect(chipFor(at('d', { topology: 'external-link' }))).toBe('external-link') + expect(chipFor(at('e', { topology: 'broken-link' }))).toBe('broken-link') + expect(chipFor(at('f', { topology: 'read-only' }))).toBe('read-only') + expect(chipFor(at('g', { topology: 'repo-scope' }))).toBe('in-a-repo') + expect(chipFor(at('h', { topology: 'plugin-cache' }))).toBe('plugin-cache') + expect(chipFor(at('i', { status: 'current', topology: 'provider-alias' }))).toBe('current') + }) +}) diff --git a/src/renderer/src/components/skills/skill-freshness-grouping.ts b/src/renderer/src/components/skills/skill-freshness-grouping.ts new file mode 100644 index 00000000000..db76800fdfb --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-grouping.ts @@ -0,0 +1,90 @@ +import type { SkillFreshnessInstallation } from '../../../../shared/skill-freshness' + +export type SkillGroupStatus = 'update-available' | 'cannot-update' + +export type SkillLocationChip = + | 'current' + | 'unrecognized' + | 'inaccessible' + | 'duplicate' + | 'external-link' + | 'broken-link' + | 'read-only' + | 'in-a-repo' + | 'plugin-cache' + +export type SkillLocationRow = { + id: string + path: string + chip: SkillLocationChip | null +} + +export type SkillFreshnessGroupModel = { + name: string + status: SkillGroupStatus + locations: SkillLocationRow[] +} + +function locationChip(installation: SkillFreshnessInstallation): SkillLocationChip | null { + // Why: a location's own status wins over its topology — "the contents don't + // match" is more useful to the user than "it's a duplicate". + if (installation.status === 'unrecognized') { + return 'unrecognized' + } + if (installation.status === 'inaccessible') { + return 'inaccessible' + } + switch (installation.topology) { + case 'independent-copy': + return 'duplicate' + case 'external-link': + return 'external-link' + case 'broken-link': + return 'broken-link' + case 'read-only': + return 'read-only' + case 'repo-scope': + return 'in-a-repo' + case 'plugin-cache': + return 'plugin-cache' + case 'canonical-copy': + case 'provider-alias': + // Why: a supported location only needs a chip when it's already up to date, + // to explain why the update won't touch it; the out-of-date main copy is bare. + return installation.status === 'current' ? 'current' : null + } +} + +/** + * Groups installations by skill for the update modal and derives each skill's + * update disposition. Only skills with an out-of-date official copy are returned — + * up-to-date, unrecognized-only, and unreadable-only skills have nothing to change + * here, so they are omitted entirely. + */ +export function groupSkillFreshness( + installations: readonly SkillFreshnessInstallation[], + eligibleUpdateNames: readonly string[] +): SkillFreshnessGroupModel[] { + const eligible = new Set(eligibleUpdateNames) + const byName = new Map() + for (const installation of installations) { + const entries = byName.get(installation.name) ?? [] + entries.push(installation) + byName.set(installation.name, entries) + } + const groups: SkillFreshnessGroupModel[] = [] + for (const [name, entries] of byName) { + if (!entries.some((entry) => entry.status === 'outdated')) { + continue + } + const locations = entries + .map((entry) => ({ id: entry.id, path: entry.unresolvedPath, chip: locationChip(entry) })) + .sort((left, right) => left.path.localeCompare(right.path, 'en')) + groups.push({ + name, + status: eligible.has(name) ? 'update-available' : 'cannot-update', + locations + }) + } + return groups.sort((left, right) => left.name.localeCompare(right.name, 'en')) +} diff --git a/src/renderer/src/components/skills/skill-freshness-update-dialog.ts b/src/renderer/src/components/skills/skill-freshness-update-dialog.ts new file mode 100644 index 00000000000..14a84fa4ac8 --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-update-dialog.ts @@ -0,0 +1,31 @@ +let pendingOpen = false +const listeners = new Set<() => void>() + +// Why: the nudge action can fire before the dialog subscribes. Keeping the +// request as an external snapshot prevents mount ordering from losing it. +export function requestSkillFreshnessUpdateDialog(): void { + pendingOpen = true + for (const listener of listeners) { + listener() + } +} + +export function consumeSkillFreshnessUpdateDialogRequest(): boolean { + const requested = pendingOpen + pendingOpen = false + if (requested) { + for (const listener of listeners) { + listener() + } + } + return requested +} + +export function getSkillFreshnessUpdateDialogRequest(): boolean { + return pendingOpen +} + +export function subscribeSkillFreshnessUpdateDialog(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 7a43e13f225..a1db1b6e492 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -87,6 +87,7 @@ import { } from '@/runtime/runtime-provider-accounts-client' import { translate } from '@/i18n/i18n' import { + getDisplayedUsagePercentage, normalizeUsagePercentageDisplay, type UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' @@ -957,6 +958,27 @@ export function ClaudeSwitcherMenu({ ) } +// --------------------------------------------------------------------------- +// Mini progress bar (follows the selected usage percentage meaning, grey) +// --------------------------------------------------------------------------- + +function MiniBar({ + usedPct, + display +}: { + usedPct: number + display: UsagePercentageDisplay +}): React.JSX.Element { + return ( +
+
+
+ ) +} + // --------------------------------------------------------------------------- // Inline usage bars (compact bars for inactive accounts in the switcher) // --------------------------------------------------------------------------- @@ -971,8 +993,6 @@ export function InlineUsageBars({ const display = normalizeUsagePercentageDisplay( useAppStore((state) => state.usagePercentageDisplay) ) - // Why: the preference changes copy, while bar fill stays consumption-based - // so empty/green and full/red keep the meter semantics introduced in #8167. const usageWindows = [ limits.session ? { @@ -1007,9 +1027,10 @@ export function InlineUsageBars({ {usageWindows.map((window) => (
+ {/* Why: fill follows the selected percentage; color still signals consumption urgency. */}
@@ -1270,6 +1291,9 @@ export function ProviderSegment({ return ( + {p.session && !compact && ( + + )} {visibleWindows.map((window, index) => ( {index > 0 ? · : null} diff --git a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx index 404caa344dc..00d27391e62 100644 --- a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx +++ b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx @@ -71,7 +71,7 @@ describe('InlineUsageBars', () => { expect(markup).toContain('42% used Fable') }) - it('shows remaining copy without reversing consumption meter fill', async () => { + it('shows remaining copy and remaining meter fill', async () => { mocks.usagePercentageDisplay = 'remaining' const { InlineUsageBars } = await import('./StatusBar') @@ -82,6 +82,9 @@ describe('InlineUsageBars', () => { expect(markup).toContain('68% left 5h') expect(markup).toContain('84% left wk') expect(markup).toContain('58% left Fable') - expect(markup).toContain('width:32%') + expect(markup).toContain('width:68%') + expect(markup).toContain('width:84%') + expect(markup).toContain('width:58%') + expect(markup).not.toContain('width:32%') }) }) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 6ff705f2e61..2dc23cf100a 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -135,18 +135,36 @@ describe('provider usage error copy', () => { ) }) - it('keeps the reworded Grok expired-token error classified as an auth failure (#8497)', () => { - // Why: the fix (grok-fetcher.ts) dropped the "run grok login" wording that - // used to trigger auth classification; this pins that the new copy still - // resolves to the softer refresh message instead of leaking the raw string. + it('shows the exact Grok CLI recovery flow for an expired refreshable session (#8497)', () => { const grok = provider({ provider: 'grok', - error: 'Grok access token expired — Grok CLI will refresh it on next use' + error: + 'Grok sign-in expired — run grok on the computer running Orca; sign in if prompted. No chat message is needed.', + usageMetadata: { + failureKind: 'delegated-refresh-required', + source: 'oauth' + } }) - expect(getProviderUsageStatusLabel(grok)).toBe('Refresh failed') + expect(getProviderUsageStatusLabel(grok)).toBe('Run Grok to refresh') expect(getProviderUsageErrorMessage(grok)).toBe( - 'Grok usage could not be refreshed. Agent sessions may still be signed in.' + 'Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.' + ) + }) + + it('shows the exact Kimi CLI recovery flow for an expired read-only session', () => { + const kimi = provider({ + provider: 'kimi', + error: 'Kimi session expired — run kimi on the computer running Orca, then retry usage.', + usageMetadata: { + failureKind: 'delegated-refresh-required', + source: 'oauth' + } + }) + + expect(getProviderUsageStatusLabel(kimi)).toBe('Run Kimi to refresh') + expect(getProviderUsageErrorMessage(kimi)).toBe( + 'Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage.' ) }) @@ -514,26 +532,24 @@ describe('ProviderPanel reset rendering', () => { expect(markup).not.toContain('140%') }) - it.each(PROVIDER_IDS)( - 'applies remaining copy to %s while retaining consumption bar direction', - (providerId) => { - const p = provider({ - provider: providerId, - status: 'ok', - session: { - usedPercent: 25, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - } - }) + it.each(PROVIDER_IDS)('applies remaining copy and meter fill to %s', (providerId) => { + const p = provider({ + provider: providerId, + status: 'ok', + session: { + usedPercent: 25, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + } + }) - const markup = renderToStaticMarkup(ProviderPanel({ p, usagePercentageDisplay: 'remaining' })) + const markup = renderToStaticMarkup(ProviderPanel({ p, usagePercentageDisplay: 'remaining' })) - expect(markup).toContain('75% left') - expect(markup).toContain('width:25%') - } - ) + expect(markup).toContain('75% left') + expect(markup).toContain('width:75%') + expect(markup).not.toContain('width:25%') + }) }) describe('clampUsedPercent', () => { diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 72bd6b73df2..fecb07d0d48 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -13,6 +13,7 @@ import { } from './usage-error-copy' import { clampUsedPercent, + getDisplayedUsagePercentage, type UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' import { formatUsagePercentageLabel } from './usage-percentage-label' @@ -184,11 +185,8 @@ export function getWindowSections( // `text-background` for primary text and `text-background/50` for secondary // to stay readable inside the inverted tooltip container. -// Why: color-coded by consumption so users can quickly gauge urgency. -// Matches common harness usage meters (Claude/Codex): bars fill with % used. -// Neutral = comfortable (<60% used), yellow = caution (60-80%), red = critical (≥80%). -// Why: the <60 band is neutral (not green) so the always-visible meter stays quiet -// until a limit nears — color = state (STYLEGUIDE) — and matches MiniBar's grey fill. +// Why: urgency color tracks % used even when fill represents % remaining; +// low usage stays neutral so persistent chrome stays quiet. export function barColor(usedPct: number): string { if (usedPct < 60) { return 'bg-muted-foreground/40' @@ -280,18 +278,18 @@ export function ProviderPanel({ if (!w) { return null } - // Why: preference changes the copy only; consumption-based bar direction - // preserves the empty/green to full/red meter convention from #8167. const usedPct = clampUsedPercent(w.usedPercent) + const displayedPct = getDisplayedUsagePercentage(usedPct, usagePercentageDisplay) const resetLabel = w.resetsAt ? formatResetCountdown(w.resetsAt - Date.now()) : null return (
{label}
+ {/* Why: fill follows the selected percentage; color still signals consumption urgency. */}
diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 0b0c3bc2eb6..39032ab5e2c 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -64,7 +64,25 @@ function isUsageAuthError(message: string | null): boolean { return Boolean(message && USAGE_AUTH_ERROR_PATTERNS.some((pattern) => pattern.test(message))) } +function getDelegatedCliRefreshProvider( + p: ProviderRateLimits +): Extract | null { + if (p.usageMetadata?.failureKind !== 'delegated-refresh-required') { + return null + } + // Why: only these providers require a user-run CLI to rotate the read-only + // session Orca consumes; Claude handles the same failure kind in-app. + return p.provider === 'grok' || p.provider === 'kimi' ? p.provider : null +} + export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { + const delegatedCliProvider = getDelegatedCliRefreshProvider(p) + if (delegatedCliProvider === 'grok') { + return translate('auto.components.status.bar.tooltip.e2c6a4f917', 'Run Grok to refresh') + } + if (delegatedCliProvider === 'kimi') { + return translate('auto.components.status.bar.tooltip.f90b3d7a16', 'Run Kimi to refresh') + } if (p.provider === 'claude') { switch (p.usageMetadata?.failureKind) { case 'deferred-by-live-session': @@ -107,6 +125,19 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { if (!p.error) { return fallback } + const delegatedCliProvider = getDelegatedCliRefreshProvider(p) + if (delegatedCliProvider === 'grok') { + return translate( + 'auto.components.status.bar.tooltip.d1b7f509ac', + 'Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.' + ) + } + if (delegatedCliProvider === 'kimi') { + return translate( + 'auto.components.status.bar.tooltip.a37e8c15d4', + 'Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage.' + ) + } if (p.provider === 'claude') { switch (p.usageMetadata?.failureKind) { case 'deferred-by-live-session': diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index 98703dd2339..71b85b8adae 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -146,8 +146,8 @@ describe('task page cache selectors', () => { entry([patched]) ]) - expect(nextPages[0][0]).toBe(patched) - expect(nextPages[0][1]).toBe(otherRepoSameId) + expect(nextPages[0]?.[0]).toBe(patched) + expect(nextPages[0]?.[1]).toBe(otherRepoSameId) }) it('merges landing refresh status changes without reordering GitHub rows', () => { diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index 490db04d916..67b3a7c1c8c 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -37,6 +37,7 @@ type WorkItemsCache = Record> type LinearIssueCache = Record> type LinearSearchCache = Record> type LinearListCache = Record>> +export type TaskPageWorkItemPages = readonly (GitHubWorkItem[] | null)[] export function deriveTaskPageGitHubWorkItemsFetchOptions( forcedFetch: boolean, @@ -83,9 +84,9 @@ function taskPageWorkItemCacheKey(item: GitHubWorkItem): string { } export function reconcileTaskPagePagesWithWorkItemsCache( - pages: readonly GitHubWorkItem[][], + pages: TaskPageWorkItemPages, entries: readonly (CacheEntry | undefined)[] -): GitHubWorkItem[][] { +): (GitHubWorkItem[] | null)[] { const cachedItems = new Map() for (const entry of entries) { for (const item of entry?.data ?? []) { @@ -95,6 +96,9 @@ export function reconcileTaskPagePagesWithWorkItemsCache( let changed = false const nextPages = pages.map((page) => { + if (!page) { + return null + } let pageChanged = false const nextPage = page.map((item) => { const cached = cachedItems.get(taskPageWorkItemCacheKey(item)) @@ -108,7 +112,7 @@ export function reconcileTaskPagePagesWithWorkItemsCache( return pageChanged ? nextPage : page }) - return changed ? nextPages : (pages as GitHubWorkItem[][]) + return changed ? nextPages : (pages as (GitHubWorkItem[] | null)[]) } function taskPageWorkItemKey(item: GitHubWorkItem): string { @@ -221,16 +225,16 @@ export function shouldResetTaskPagePaginationAfterLandingRefresh( } export function reconcileTaskPagePagesAfterLandingRefresh( - pages: readonly GitHubWorkItem[][], + pages: TaskPageWorkItemPages, refreshedItems: readonly GitHubWorkItem[] -): GitHubWorkItem[][] { +): (GitHubWorkItem[] | null)[] { const firstPage = pages[0] ?? [] if (shouldResetTaskPagePaginationAfterLandingRefresh(firstPage, refreshedItems)) { return [[...refreshedItems]] } const nextFirstPage = reconcileTaskPageItemsAfterLandingRefresh(firstPage, refreshedItems) if (nextFirstPage === firstPage) { - return pages as GitHubWorkItem[][] + return pages as (GitHubWorkItem[] | null)[] } return [nextFirstPage, ...pages.slice(1)] } diff --git a/src/renderer/src/components/task-page-work-item-pagination.test.ts b/src/renderer/src/components/task-page-work-item-pagination.test.ts index b3bc3301d94..22ad6bf6cde 100644 --- a/src/renderer/src/components/task-page-work-item-pagination.test.ts +++ b/src/renderer/src/components/task-page-work-item-pagination.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import type { GitHubWorkItem } from '../../../shared/types' -import { accumulateWorkItemPages, workItemIdentity } from './task-page-work-item-pagination' +import { + accumulateWorkItemPages, + getTaskPagePerRepoLimit, + taskPageToGitHubApiPage, + workItemIdentity +} from './task-page-work-item-pagination' function item(repoId: string, id: string, updatedAt: string): GitHubWorkItem { return { @@ -39,6 +44,21 @@ describe('workItemIdentity', () => { }) }) +describe('numbered GitHub pagination', () => { + it('converts zero-indexed task pages to one-indexed API pages', () => { + expect(taskPageToGitHubApiPage(0)).toBe(1) + expect(taskPageToGitHubApiPage(1)).toBe(2) + expect(taskPageToGitHubApiPage(15)).toBe(16) + }) + + it('divides the display budget before per-repo fetches can overflow it', () => { + expect(getTaskPagePerRepoLimit(1, 36, 100)).toBe(36) + expect(getTaskPagePerRepoLimit(2, 36, 100)).toBe(36) + expect(getTaskPagePerRepoLimit(3, 36, 100)).toBe(33) + expect(getTaskPagePerRepoLimit(90, 36, 100)).toBe(1) + }) +}) + describe('accumulateWorkItemPages', () => { it('drops the re-fetched boundary row that shares the previous page cursor', async () => { const boundary = item('r', 'issue:2', '2026-07-02') diff --git a/src/renderer/src/components/task-page-work-item-pagination.ts b/src/renderer/src/components/task-page-work-item-pagination.ts index a332fe675e2..e8f0a8749e3 100644 --- a/src/renderer/src/components/task-page-work-item-pagination.ts +++ b/src/renderer/src/components/task-page-work-item-pagination.ts @@ -31,6 +31,21 @@ export function workItemIdentity(item: Pick): s return `${item.repoId}:${item.id}` } +export function taskPageToGitHubApiPage(taskPage: number): number { + return Math.max(0, Math.floor(taskPage)) + 1 +} + +// Why: provider pages cannot spill truncated rows into the next page. Divide +// the display budget up front so every fetched row remains reachable. +export function getTaskPagePerRepoLimit( + repoCount: number, + maxPerRepo: number, + displayLimit: number +): number { + const normalizedRepoCount = Math.max(1, Math.floor(repoCount)) + return Math.max(1, Math.min(maxPerRepo, Math.floor(displayLimit / normalizedRepoCount))) +} + export type WorkItemPageFetchResult = { items: GitHubWorkItem[] } export type AccumulateWorkItemPagesArgs = { diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index f9a4e1fc40f..975aad10f94 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -107,7 +107,9 @@ import { } from '../native-chat/native-chat-leaf-routing' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' -import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' +import { applyDesktopFitFallbackAfterReplay } from './desktop-fit-fallback' +import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture' import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes' import { @@ -517,15 +519,12 @@ export default function TerminalPane({ if (rect.width === 0 || rect.height === 0) { continue } - safeFit(pane) - const stuckAtMobile = - event.priorCols != null && - event.priorRows != null && - pane.terminal.cols === event.priorCols && - pane.terminal.rows === event.priorRows - if (stuckAtMobile && event.cols > 0 && event.rows > 0) { - pane.terminal.resize(event.cols, event.rows) - } + applyDesktopFitFallbackAfterReplay(pane, { + ...event, + // Why: the timeout/replay queue can outlive this pane binding; + // never apply old server dimensions to a replacement PTY. + shouldApply: () => getAffectedPanes().includes(pane) + }) } }) } @@ -1130,7 +1129,7 @@ export default function TerminalPane({ const clearPaneScrollback = useCallback( (pane: ManagedPane): void => { clearedScrollbackLeafIdsRef.current.add(pane.leafId) - pane.terminal.clear() + clearTerminalScrollbackAndFollowOutput(pane.terminal) // Why: also clear the host buffer for remote-server panes, or the next // host snapshot replays the scrollback we just cleared locally. const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null @@ -1884,26 +1883,27 @@ export default function TerminalPane({ return } for (const pane of manager.getPanes()) { - safeFit(pane) - const transport = paneTransportsRef.current.get(pane.id) - if (!transport?.isConnected()) { - continue - } - const ptyId = transport.getPtyId() - if (!ptyId) { - continue - } - // Why: match pty-connection resize guards so web refit retries do not - // forward SIGWINCH while mobile-lock or phone-fit overrides are active. - if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) { - continue - } - // Why: skip forwarding a stale near-zero fit to the host PTY while the - // overlay is still settling after a worktree switch. - if (pane.terminal.cols < 8 || pane.terminal.rows < 4) { - continue - } - transport.resize(pane.terminal.cols, pane.terminal.rows) + safeFitAndThen(pane, 'web-client-pty-resize', () => { + const transport = paneTransportsRef.current.get(pane.id) + if (!transport?.isConnected()) { + return + } + const ptyId = transport.getPtyId() + if (!ptyId) { + return + } + // Why: match pty-connection resize guards so web refit retries do not + // forward SIGWINCH while mobile-lock or phone-fit overrides are active. + if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) { + return + } + // Why: skip forwarding a stale near-zero fit to the host PTY while the + // overlay is still settling after a worktree switch. + if (pane.terminal.cols < 8 || pane.terminal.rows < 4) { + return + } + transport.resize(pane.terminal.cols, pane.terminal.rows) + }) } } const scheduleFrame = (): void => { diff --git a/src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts new file mode 100644 index 00000000000..3fef752e5ea --- /dev/null +++ b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyDesktopFitFallbackAfterReplay } from './desktop-fit-fallback' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from '@/lib/pane-manager/terminal-scroll-intent-rebuild' + +function createPane() { + const terminal = { + cols: 49, + rows: 20, + buffer: { active: { type: 'normal', viewportY: 0, baseY: 0 } }, + resize: vi.fn((cols: number, rows: number) => { + terminal.cols = cols + terminal.rows = rows + }) + } + return { + terminal, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: 800, height: 600 }) + }, + fitAddon: { + proposeDimensions: vi.fn(() => null), + fit: vi.fn() + } + } +} + +describe('desktop fit fallback', () => { + it('waits until structural replay completes before direct resize', async () => { + const pane = createPane() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + applyDesktopFitFallbackAfterReplay(pane as never, { + cols: 120, + rows: 40, + priorCols: 49, + priorRows: 20 + }) + expect(pane.terminal.resize).not.toHaveBeenCalled() + + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + expect(pane.terminal.resize).toHaveBeenCalledWith(120, 40) + }) + + it('drops deferred dimensions when the pane binding becomes stale', async () => { + const pane = createPane() + let isCurrent = true + beginTerminalScrollIntentBufferRebuild(pane.terminal) + applyDesktopFitFallbackAfterReplay(pane as never, { + cols: 120, + rows: 40, + priorCols: 49, + priorRows: 20, + shouldApply: () => isCurrent + }) + + isCurrent = false + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + expect(pane.terminal.resize).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/desktop-fit-fallback.ts b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.ts new file mode 100644 index 00000000000..7bb5da4f44d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.ts @@ -0,0 +1,42 @@ +import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types' +import { safeFit } from '@/lib/pane-manager/pane-fit' +import { deferTerminalGeometryMutationDuringRebuild } from '@/lib/pane-manager/terminal-scroll-intent-rebuild' + +type DesktopFitFallbackDimensions = { + cols: number + rows: number + priorCols?: number | null + priorRows?: number | null + shouldApply?: () => boolean +} + +export function applyDesktopFitFallbackAfterReplay( + pane: ManagedPane, + dimensions: DesktopFitFallbackDimensions +): void { + const applyFallback = (): void => { + if (dimensions.shouldApply?.() === false) { + return + } + safeFit(pane) + const stuckAtPriorGrid = + dimensions.priorCols != null && + dimensions.priorRows != null && + pane.terminal.cols === dimensions.priorCols && + pane.terminal.rows === dimensions.priorRows + if (stuckAtPriorGrid && dimensions.cols > 0 && dimensions.rows > 0) { + pane.terminal.resize(dimensions.cols, dimensions.rows) + } + } + // Why: the server dimensions are only a fallback; source-dimension replay + // must parse and restore its viewport before this can reflow xterm. + if ( + !deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + 'desktop-fit-fallback', + applyFallback + ) + ) { + applyFallback() + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts index 2907e2b3341..436982b061b 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts @@ -78,4 +78,25 @@ describe('pty buffer serializer registry', () => { expect(window.api.pty.sendSerializedBuffer).toHaveBeenCalledWith('request-1', null) }) + + it('preserves the renderer-parsed output sequence in the response', async () => { + const { registerPtySerializer } = await import('./pty-buffer-serializer') + registerPtySerializer('pty-sequenced', () => ({ + data: 'parsed output', + cols: 80, + rows: 24, + seq: 42 + })) + const serializeRequestHandler = vi.mocked(window.api.pty.onSerializeBufferRequest).mock + .calls[0]?.[0] + + serializeRequestHandler?.({ requestId: 'request-sequenced', ptyId: 'pty-sequenced' }) + await Promise.resolve() + await Promise.resolve() + + expect(window.api.pty.sendSerializedBuffer).toHaveBeenCalledWith( + 'request-sequenced', + expect.objectContaining({ seq: 42 }) + ) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts index 2958b5aa08f..c32ea0aafaf 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts @@ -14,6 +14,7 @@ export type SerializedBuffer = { data: string cols: number rows: number + seq?: number lastTitle?: string } @@ -148,6 +149,9 @@ function ensureSerializerListener(): void { cols: result.cols, rows: result.rows } + if (result.seq !== undefined) { + payload.seq = result.seq + } if (lastTitle !== undefined) { payload.lastTitle = lastTitle } else if (result.lastTitle !== undefined) { 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 6819d32d204..18e99dac5a7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -225,6 +225,7 @@ function resolveMockPaneWindowsShiftEnterEncoding( } type ConnectCallbacks = { + onConnect?: () => void onData?: ( data: string, meta?: { seq?: number; rawLength?: number; background?: boolean; droppedOutput?: boolean } @@ -417,57 +418,65 @@ function createPane(paneId: number) { cursorY: 0, cursorX: 0 } + const terminal = { + cols: 120, + rows: 40, + element: {}, + buffer: { + active: activeBuffer + }, + modes: { + bracketedPasteMode: false, + sendFocusMode: false + }, + options: { + scrollback: 5_000, + ignoreBracketedPasteMode: false, + theme: { + foreground: '#eeeeee', + background: '#111111' + } + }, + write: vi.fn<(data: string, callback?: () => void) => void>(function write(...args): void { + const [data, callback] = args + if (data === '' || callback?.name === 'runParsedSteps') { + callback?.() + } + }), + resize: vi.fn(), + clear: vi.fn(), + scrollToBottom: vi.fn(() => { + activeBuffer.viewportY = activeBuffer.baseY + }), + scrollToLine: vi.fn((line: number) => { + activeBuffer.viewportY = line + }), + scrollLines: vi.fn((amount: number) => { + activeBuffer.viewportY = Math.max( + 0, + Math.min(activeBuffer.baseY, activeBuffer.viewportY + amount) + ) + }), + paste: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), + onResize: vi.fn(() => ({ dispose: vi.fn() })), + onRender: vi.fn((_listener: () => void) => ({ dispose: vi.fn() })), + onTitleChange: vi.fn(() => ({ dispose: vi.fn() })), + hasSelection: vi.fn(() => false), + parser: { + registerCsiHandler: vi.fn(() => ({ dispose: vi.fn() })), + registerOscHandler: vi.fn(() => ({ dispose: vi.fn() })) + } + } return { id: paneId, leafId, stablePaneId: leafId, - terminal: { - cols: 120, - rows: 40, - element: {}, - buffer: { - active: activeBuffer - }, - modes: { - bracketedPasteMode: false, - sendFocusMode: false - }, - options: { - scrollback: 5_000, - ignoreBracketedPasteMode: false, - theme: { - foreground: '#eeeeee', - background: '#111111' - } - }, - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - scrollToBottom: vi.fn(() => { - activeBuffer.viewportY = activeBuffer.baseY - }), - scrollToLine: vi.fn((line: number) => { - activeBuffer.viewportY = line - }), - scrollLines: vi.fn((amount: number) => { - activeBuffer.viewportY = Math.max( - 0, - Math.min(activeBuffer.baseY, activeBuffer.viewportY + amount) - ) - }), - paste: vi.fn(), - onData: vi.fn(() => ({ dispose: vi.fn() })), - onResize: vi.fn(() => ({ dispose: vi.fn() })), - onTitleChange: vi.fn(() => ({ dispose: vi.fn() })), - hasSelection: vi.fn(() => false), - parser: { - registerCsiHandler: vi.fn(() => ({ dispose: vi.fn() })), - registerOscHandler: vi.fn(() => ({ dispose: vi.fn() })) - } - }, + terminal, container: createPaneContainer(), fitAddon: { - fit: vi.fn() + fit: vi.fn(), + proposeDimensions: vi.fn(() => ({ cols: terminal.cols, rows: terminal.rows })) } } } @@ -866,7 +875,8 @@ describe('connectPanePty', () => { sendSerializedBuffer: vi.fn(), declarePendingPaneSerializer: vi.fn().mockResolvedValue(1), settlePaneSerializer: vi.fn().mockResolvedValue(undefined), - clearPendingPaneSerializer: vi.fn().mockResolvedValue(undefined) + clearPendingPaneSerializer: vi.fn().mockResolvedValue(undefined), + reportRendererSerializerReady: vi.fn().mockResolvedValue(undefined) }, platform: { get: vi.fn(() => ({ platform: 'win32', osRelease: '10.0.26100' })) @@ -4932,7 +4942,10 @@ describe('connectPanePty', () => { const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } const transport = createMockTransport() + let currentPtyId: string | null = null + vi.mocked(transport.getPtyId).mockImplementation(() => currentPtyId) transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + currentPtyId = 'pty-local-1' capturedDataCallback.current = callbacks.onData ?? null return 'pty-local-1' }) @@ -4960,23 +4973,33 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - await flushAsyncTicks(1) + await flushAsyncTicks() expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() }) it('clears pre-hook launch config when an Orca-started command exits', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } const transport = createMockTransport() + let currentPtyId: string | null = null + vi.mocked(transport.getPtyId).mockImplementation(() => currentPtyId) transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + currentPtyId = 'pty-local-1' capturedDataCallback.current = callbacks.onData ?? null return 'pty-local-1' }) transportFactoryQueue.push(transport) const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } as StoreState connectPanePty( createPane(1) as never, @@ -4989,7 +5012,8 @@ describe('connectPanePty', () => { agentEnv: {} }, launchAgent: 'codex' - } + }, + restoredPtyIdByLeafId: {} }) as never ) @@ -5002,7 +5026,9 @@ describe('connectPanePty', () => { expect.objectContaining({ agentType: 'codex' }) ) capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - await flushAsyncTicks(1) + await flushAsyncTicks() + await vi.advanceTimersByTimeAsync(350) + await flushAsyncTicks() expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() @@ -5082,6 +5108,134 @@ describe('connectPanePty', () => { expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') }) + it('confirms an Orca-launched Droid fresh spawn in a no-OSC shell (Git Bash)', async () => { + // Why: PowerShell gets an OSC 133 command-start that triggers the confirming + // foreground read, but Git Bash / cmd.exe emit no command boundary and the + // launch command is injected programmatically (no typed inference). Without a + // fresh-spawn sample the pane never earns routing trust, so Shift+Enter falls + // back to Esc+CR and Droid submits instead of inserting a newline (#7620). + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('droid') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + const pane = createPane(1) + const ptyId = 'pty-launched-droid-no-osc' + const tabId = 'tab-launched-droid-no-osc' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + tabId, + startup: { command: 'droid', launchAgent: 'droid' } + }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + // The transport reports the fresh spawn; no OSC 133, no typed inference. + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as ((id: string) => void) | undefined + expect(onPtySpawn).toBeTypeOf('function') + onPtySpawn?.(ptyId) + // Span the bounded confirmation retry ladder while Droid boots. + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + + it('trusts a launched Droid whose no-OSC boot only becomes foreground after retries', async () => { + // Why: the confirmation ladder must span Droid's boot — the shell is still + // foreground on the first read(s) before Droid takes over. + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const foregroundResults = ['bash.exe', 'bash.exe', 'droid'] + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation( + async () => foregroundResults.shift() ?? 'droid' + ) + const pane = createPane(1) + const ptyId = 'pty-launched-droid-slow-boot' + const tabId = 'tab-launched-droid-slow-boot' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, startup: { command: 'droid', launchAgent: 'droid' } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as ((id: string) => void) | undefined + onPtySpawn?.(ptyId) + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + + it('stays recoverable when a launched Droid outlasts the fresh-spawn confirmation window', async () => { + // Why: if the bounded ladder misses (pathologically slow boot), the miss must + // NOT latch a known-agent shell-confirm — that would clear launch identity and + // block every later focus/reveal re-sample, permanently poisoning Shift+Enter + // for the session. A benign miss leaves the pane recoverable, so a later focus + // sample (once Droid is finally foreground) still earns routing trust. + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + let foreground = 'bash.exe' + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) + const pane = createPane(1) + const ptyId = 'pty-launched-droid-slow' + const tabId = 'tab-launched-droid-slow' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, startup: { command: 'droid', launchAgent: 'droid' } }) as never + ) as unknown as { sampleForegroundAgentOnFocus: () => void } + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as ((id: string) => void) | undefined + onPtySpawn?.(ptyId) + // Whole ladder elapses while the shell is still foreground (Droid not up yet). + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + // Benign miss: shell never latched as foreground; encoding still safe fallback. + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + // Droid finally boots and a focus event re-samples: trust is recoverable. + foreground = 'droid' + binding.sampleForegroundAgentOnFocus() + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + it('revokes trusted Droid after accepted no-OSC exit input until shell confirmation', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') @@ -6021,7 +6175,7 @@ describe('connectPanePty', () => { it('resets reattach renderer state after daemon snapshot replay without applying the full mode reset', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004hrestored snapshot' } @@ -6071,7 +6225,7 @@ describe('connectPanePty', () => { // the snapshot bytes, so a remote pane whose size drifted from the daemon's // grid still repaints the exact host layout. const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6119,13 +6273,301 @@ describe('connectPanePty', () => { expect(resizeToSnapshotCall as number).toBeLessThan(snapshotWriteCall as number) }) + it('waits for a recovered destination fit before forwarding its grid or live output', async () => { + const { connectPanePty } = await import('./pty-connection') + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const transport = createMockTransport('tab-pty') + let deliverLiveData = (_data: string): void => {} + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (callbacks?.onData) { + deliverLiveData = callbacks.onData + } + return sessionId + ? { + id: sessionId, + snapshot: 'source-grid snapshot', + snapshotCols: 80, + snapshotRows: 24 + } + : null + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) + const signalPty = window.api.pty.signal as unknown as ReturnType + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + transport.resize.mockClear() + signalPty.mockClear() + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + + expect(transport.resize).not.toHaveBeenCalled() + expect(signalPty).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + + deliverLiveData('live-after-snapshot') + await flushAsyncTicks(4) + expect(writes.join('')).not.toContain('live-after-snapshot') + + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(12) + + expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(signalPty).toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + expect(writes.join('')).toContain('live-after-snapshot') + }) + + it('restores a pinned viewport only after a same-size reattach snapshot finishes parsing', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'same-size authoritative snapshot', + snapshotCols: 120, + snapshotRows: 40 + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + pane.terminal.buffer.active.baseY = 100 + pane.terminal.buffer.active.viewportY = 80 + markTerminalPinnedViewport(pane.terminal) + const { parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + + // Model xterm's native pinned state after clear + replay: the old line + // number is no longer meaningful, but the old distance from bottom is. + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 0 + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(180) + markTerminalFollowOutput(pane.terminal) + }) + + it('does not apply a queued reattach snapshot after the PTY is replaced', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + const reattachResult = createDeferred<{ + id: string + snapshot: string + }>() + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return null + } + replayCallback.current = callbacks?.onReplayData ?? null + return reattachResult.promise + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + replayCallback.current?.('blocking replay') + await flushAsyncTicks(12) + expect(writes).toEqual(['\x1b[2J\x1b[3J\x1b[H']) + reattachResult.resolve({ id: 'tab-pty', snapshot: 'stale authoritative snapshot' }) + await flushAsyncTicks(12) + const resizeCallsBeforeReplacement = transport.resize.mock.calls.length + vi.mocked(transport.getPtyId).mockReturnValue('replacement-pty') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('stale authoritative snapshot') + expect(transport.resize).toHaveBeenCalledTimes(resizeCallsBeforeReplacement) + expect(window.api.pty.signal).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + }) + + it('does not apply a queued reattach snapshot after its PTY exits', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + const reattachResult = createDeferred<{ id: string; snapshot: string }>() + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + replayCallback.current = callbacks?.onReplayData ?? null + return reattachResult.promise + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + replayCallback.current?.('blocking replay') + await flushAsyncTicks(12) + reattachResult.resolve({ id: 'tab-pty', snapshot: 'dead authoritative snapshot' }) + await flushAsyncTicks(12) + const resizeCallsBeforeExit = transport.resize.mock.calls.length + vi.mocked(transport.getPtyId).mockReturnValue(null) + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('dead authoritative snapshot') + expect(transport.resize).toHaveBeenCalledTimes(resizeCallsBeforeExit) + expect(window.api.pty.signal).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + }) + + it('drops stale callbacks but delivers fresh replacement output after an old replay clear', async () => { + const { connectPanePty } = await import('./pty-connection') + const { getTerminalScrollIntentKind, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + const oldResult = createDeferred() + const oldCallbacks: { current: ConnectCallbacks | null } = { current: null } + const replacementCallbacks: { current: ConnectCallbacks | null } = { current: null } + let connectCount = 0 + transport.connect.mockImplementation( + async ({ callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + connectCount += 1 + if (connectCount === 1) { + oldCallbacks.current = callbacks ?? null + return oldResult.promise + } + vi.mocked(transport.getPtyId).mockReturnValue('replacement-pty') + replacementCallbacks.current = callbacks ?? null + return 'replacement-pty' + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 80 + pane.terminal.buffer.active.baseY = 100 + markTerminalPinnedViewport(pane.terminal) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + oldCallbacks.current?.onReplayData?.('old replay') + await flushAsyncTicks(8) + parseCallbacks.shift()?.() + await flushAsyncTicks(8) + oldCallbacks.current?.onError?.('SSH_SESSION_EXPIRED: tab-pty') + oldResult.resolve(undefined) + await flushAsyncTicks(20) + expect(replacementCallbacks.current).not.toBeNull() + + replacementCallbacks.current?.onData?.('B-PROMPT') + oldCallbacks.current?.onData?.('STALE-A') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(20) + + expect(writes).toContain('B-PROMPT') + expect(writes).not.toContain('STALE-A') + expect(pane.terminal.scrollToBottom).toHaveBeenCalled() + expect(getTerminalScrollIntentKind(pane.terminal)).toBe('followOutput') + }) + + it('retries a fresh-spawn native follow reset when renderer dimensions return', async () => { + const { connectPanePty } = await import('./pty-connection') + const { getTerminalScrollIntentKind } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('fresh-pty') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] } + } as StoreState + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 40 + pane.terminal.buffer.active.baseY = 100 + const renderListener: { current: (() => void) | null } = { current: null } + const renderDisposable = { dispose: vi.fn() } + pane.terminal.onRender = vi.fn((listener: () => void) => { + renderListener.current = listener + return renderDisposable + }) + vi.mocked(pane.terminal.scrollToBottom) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementation(() => { + pane.terminal.buffer.active.viewportY = pane.terminal.buffer.active.baseY + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(pane.terminal.buffer.active.viewportY).toBe(40) + expect(renderListener.current).not.toBeNull() + + renderListener.current?.() + expect(pane.terminal.buffer.active.viewportY).toBe(100) + expect(pane.terminal.scrollToBottom).toHaveBeenCalledTimes(2) + expect(getTerminalScrollIntentKind(pane.terminal)).toBe('followOutput') + expect(renderDisposable.dispose).toHaveBeenCalledTimes(1) + }) + it('writes the daemon pendingEscapeTailAnsi after the reset on local reattach (#7329)', async () => { // Why: the mid-escape tail must be re-armed LAST — after the reattach reset, // whose ESC would abort it — so the racing live continuation completes it // instead of rendering literally. Covers the local daemon reattach path, // which previously dropped the field the remote path already honored. const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6267,7 +6709,7 @@ describe('connectPanePty', () => { it('preserves live modes and injects focus-in after focused agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } @@ -6309,7 +6751,7 @@ describe('connectPanePty', () => { it('keeps ?25h in the live agent reattach reset when the snapshot leaves the cursor visible', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { // A mid-frame snapshot cut after the TUI re-showed its cursor. @@ -6342,7 +6784,7 @@ describe('connectPanePty', () => { it('does not inject focus-in after reattach when the terminal does not own DOM focus', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } @@ -6377,7 +6819,7 @@ describe('connectPanePty', () => { it('resets stale focus and cursor modes for a focused non-agent shell reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } @@ -6414,7 +6856,7 @@ describe('connectPanePty', () => { it('does not treat persisted tab launchAgent metadata as a live agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } @@ -6462,7 +6904,7 @@ describe('connectPanePty', () => { it('does not treat an agent-name token in a shell title as a live agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } @@ -6501,7 +6943,7 @@ describe('connectPanePty', () => { it('does not treat ordinary shell scrollback mentioning Cursor Agent as a live agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6594,7 +7036,7 @@ describe('connectPanePty', () => { // authoritative source and wins by precedence. it('paints only the daemon snapshot when reattach result includes both snapshot and replay', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6629,7 +7071,7 @@ describe('connectPanePty', () => { it('paints only relay replay when reattach result has replay and coldRestore but no snapshot', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -7870,7 +8312,7 @@ describe('connectPanePty', () => { ) transportFactoryQueue.push(transport) const pane = createPane(1) - const { writes } = captureCallbackTerminalWrites(pane) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) const deps = createDeps({ restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } @@ -7880,8 +8322,14 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) const snapshotIndex = writes.indexOf('authoritative-snapshot') - const liveIndex = writes.indexOf('post-snapshot-live') expect(snapshotIndex).toBeGreaterThanOrEqual(0) + expect(writes).not.toContain('post-snapshot-live') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + const liveIndex = writes.indexOf('post-snapshot-live') expect(liveIndex).toBeGreaterThan(snapshotIndex) }) @@ -8057,6 +8505,49 @@ describe('connectPanePty', () => { ) }) + it('holds post-snapshot live bytes until a hidden restore can fit the destination grid', async () => { + enableMainAuthority() + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, transport, dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'source-grid hidden snapshot\r\n', + cols: 80, + rows: 24, + seq: 64 + }) + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + transport.resize.mockClear() + + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(transport.resize).not.toHaveBeenCalled() + dataCallback('live-after-hidden', { seq: 81, rawLength: 17 }) + await flushAsyncTicks(6) + expect(pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')).not.toContain( + 'live-after-hidden' + ) + + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(20) + + expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')).toContain( + 'live-after-hidden' + ) + }) + it('kicks pane recovery when reveal finds the write pipeline certified dead', async () => { // The 2026-07-13 fossil-pane incident shape: bytes drop while hidden, // the pipeline is certified dead, and certification's own recovery @@ -8486,6 +8977,17 @@ describe('connectPanePty', () => { it('abandons the restore on queue overflow, writes the stream through, and repaints once', async () => { const { pane, dataCallback, getMainBufferSnapshot, resolveFirstSnapshot } = await startInFlightRestore() + const { markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const parseCallbacks: (() => void)[] = [] + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { + if (callback) { + parseCallbacks.push(callback) + } + }) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + markTerminalPinnedViewport(pane.terminal) // Flood while the snapshot is in flight: overflows the 512KB restore // queue — the live stream is outrunning snapshot fetch+replay. @@ -8499,6 +9001,15 @@ describe('connectPanePty', () => { // Cut 1: the overflow abandons the restore instead of re-fetching. expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + pane.terminal.buffer.active.viewportY = 200 + pane.terminal.buffer.active.baseY = 200 + for (const callback of parseCallbacks.splice(0)) { + callback() + } + // The post-replay fit is part of the transaction now; let its promise + // settle before asserting that overflow abandonment owns later bytes. + await flushAsyncTicks(20) + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(142) // Cut 2: drop sentinels and seq-gap chunks during the flood window // must not re-arm restores — the post-gap bytes write through. @@ -10313,7 +10824,11 @@ describe('connectPanePty', () => { const pane = createPane(1) pane.terminal.cols = 80 pane.terminal.rows = 8 - const { writes } = captureCallbackTerminalWrites(pane) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) const manager = createManager(1) const deps = createDeps({ isVisibleRef: { current: false }, @@ -10399,7 +10914,11 @@ describe('connectPanePty', () => { const pane = createPane(1) pane.terminal.cols = 80 pane.terminal.rows = 8 - const { writes } = captureCallbackTerminalWrites(pane) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) const manager = createManager(1) const deps = createDeps({ isVisibleRef: { current: false }, @@ -10475,7 +10994,11 @@ describe('connectPanePty', () => { const pane = createPane(1) pane.terminal.cols = 80 pane.terminal.rows = 8 - const { writes } = captureCallbackTerminalWrites(pane) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) const manager = createManager(1) const deps = createDeps({ isVisibleRef: { current: true }, @@ -11733,6 +12256,120 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('cancels a delayed snapshot scroll restore when the pane binding is disposed', async () => { + const { connectPanePty } = await import('./pty-connection') + const { isTerminalScrollIntentRebuildInFlight } = + await import('@/lib/pane-manager/terminal-scroll-intent-rebuild') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(writes).toContain('snapshot-state\r\n') + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(true) + pane.terminal.scrollToLine.mockClear() + disposable.dispose() + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(true) + + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 200 + for (const callback of parseCallbacks) { + callback() + } + await flushAsyncTicks() + + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(false) + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + }) + + it('does not apply a delayed snapshot restore after newer user intent', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(writes).toContain('snapshot-state\r\n') + pane.terminal.buffer.active.viewportY = 200 + pane.terminal.buffer.active.baseY = 200 + markTerminalFollowOutput(pane.terminal) + pane.terminal.scrollToLine.mockClear() + for (const callback of parseCallbacks) { + callback() + } + await flushAsyncTicks() + + // Why: replay completion must not overwrite scroll intent recorded while + // xterm was still parsing the restored snapshot. + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + disposable.dispose() + }) + it('does not signal SIGWINCH after hidden-backlog snapshot replay when dimensions are unchanged', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -12345,7 +12982,7 @@ describe('connectPanePty', () => { capturedReplayCallback.current?.('first replay') capturedReplayCallback.current?.('second replay') - await flushAsyncTicks(2) + await flushAsyncTicks(6) expect(pane.terminal.write).toHaveBeenCalledTimes(1) expect(pane.terminal.write).toHaveBeenNthCalledWith( @@ -12366,6 +13003,116 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('holds newer live bytes until a later replay frame has fully parsed', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-live-order') + const callbacksRef: { + replay: ((data: string) => void) | null + data: ((data: string) => void) | null + } = { replay: null, data: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + callbacksRef.replay = callbacks.onReplayData ?? null + callbacksRef.data = callbacks.onData ?? null + return 'remote:env-1@@terminal-live-order' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(8) + + callbacksRef.replay?.('authoritative replay') + await flushAsyncTicks(8) + expect(writes).toEqual(['\x1b[2J\x1b[3J\x1b[H']) + + callbacksRef.data?.('NEWER-LIVE\r\n') + expect(writes).not.toContain('NEWER-LIVE\r\n') + for (let index = 0; index < 12 && parseCallbacks.length > 0; index += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(8) + + const replayIndex = writes.indexOf('authoritative replay') + const resetIndex = writes.indexOf(POST_REPLAY_REATTACH_RESET) + const liveIndex = writes.indexOf('NEWER-LIVE\r\n') + expect(replayIndex).toBeGreaterThan(0) + expect(resetIndex).toBeGreaterThan(replayIndex) + expect(liveIndex).toBeGreaterThan(resetIndex) + binding.dispose() + }) + + it('drops a queued relay replay instead of retagging it for a replacement PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-old') + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + replayCallback.current = callbacks.onReplayData ?? null + return 'remote:env-1@@terminal-old' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(8) + + replayCallback.current?.('blocking replay') + await flushAsyncTicks(8) + replayCallback.current?.('stale queued replay') + vi.mocked(transport.getPtyId).mockReturnValue('remote:env-1@@terminal-replacement') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('blocking replay') + expect(writes).not.toContain('stale queued replay') + binding.dispose() + }) + + it('requests snapshot recovery for one oversized live frame deferred by replay', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-large-live') + const callbacksRef: { + replay: ((data: string) => void) | null + data: ((data: string) => void) | null + } = { replay: null, data: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + callbacksRef.replay = callbacks.onReplayData ?? null + callbacksRef.data = callbacks.onData ?? null + return 'pty-large-live' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(8) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue(null) + getMainBufferSnapshot.mockClear() + + callbacksRef.replay?.('authoritative replay') + await flushAsyncTicks(8) + const oversizedLiveFrame = 'L'.repeat(512 * 1024 + 1) + callbacksRef.data?.(oversizedLiveFrame) + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-large-live', { + scrollbackRows: 5000 + }) + expect(writes.some((write) => write.startsWith('L'))).toBe(false) + binding.dispose() + }) + it('does not switch renderers for Arabic output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() @@ -13565,8 +14312,10 @@ 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.attach.mockImplementation(({ callbacks }: { callbacks?: ConnectCallbacks }) => { transport.getPtyId.mockReturnValue('remote:env-1@@terminal-1') + callbacks?.onReplayData?.('restored remote prompt $ ') + callbacks?.onConnect?.() }) transportFactoryQueue.push(transport) @@ -13582,10 +14331,12 @@ describe('connectPanePty', () => { } as StoreState const pane = createPane(2) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => callback?.()) const manager = createManager(2) const deps = createDeps() connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() expect(transport.connect).not.toHaveBeenCalled() expect(transport.attach).toHaveBeenCalledWith( @@ -13593,6 +14344,58 @@ describe('connectPanePty', () => { ) expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1') + await vi.waitFor(() => + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith( + 'remote:env-1@@terminal-1' + ) + ) + }) + + it('reports remote renderer readiness only after delayed restore output is parsed', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + let remoteCallbacks: ConnectCallbacks | undefined + transport.attach.mockImplementation(({ callbacks }: { callbacks?: ConnectCallbacks }) => { + transport.getPtyId.mockReturnValue('remote:env-1@@terminal-delayed') + remoteCallbacks = callbacks + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'remote:terminal-delayed' }] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-1' + } + } as StoreState + const pane = createPane(2) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty(pane as never, createManager(2) as never, createDeps() as never) + await flushAsyncTicks() + expect(window.api.pty.reportRendererSerializerReady).not.toHaveBeenCalled() + + remoteCallbacks?.onReplayData?.('delayed restored prompt $ ') + remoteCallbacks?.onConnect?.() + await vi.waitFor(() => expect(parseCallbacks.length).toBeGreaterThan(0)) + expect(window.api.pty.reportRendererSerializerReady).not.toHaveBeenCalled() + + for (let step = 0; step < 10; step += 1) { + await vi.waitFor(() => expect(parseCallbacks.length).toBeGreaterThan(0)) + parseCallbacks.shift()?.() + await flushAsyncTicks() + if (vi.mocked(window.api.pty.reportRendererSerializerReady!).mock.calls.length > 0) { + break + } + } + expect(writes.join('')).toContain('delayed restored prompt $ ') + await vi.waitFor(() => + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith( + 'remote:env-1@@terminal-delayed' + ) + ) }) it('cold-spawns slept remote runtime PTYs instead of reattaching the preserved handle', async () => { @@ -13612,6 +14415,9 @@ describe('connectPanePty', () => { | ((ptyId: string) => void) | undefined onPtySpawn?.(freshPtyId) + const connectCallbacks = callbacks as ConnectCallbacks | undefined + connectCallbacks?.onReplayData?.('shell ready\r\n') + connectCallbacks?.onConnect?.() return freshPtyId } ) @@ -13646,6 +14452,7 @@ describe('connectPanePty', () => { } as StoreState const pane = createPane(1) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => callback?.()) const manager = createManager(1) const deps = createDeps({ restoredLeafId: LEAF_1, @@ -13685,6 +14492,7 @@ describe('connectPanePty', () => { expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', restoredPtyId) expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, freshPtyId) expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', freshPtyId) + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith(freshPtyId) expect(transport.sendInput).not.toHaveBeenCalled() expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) @@ -15811,9 +16619,11 @@ describe('connectPanePty', () => { it('replays attach buffer for deferred SSH reattach and clears stale tab session metadata', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('leaf-session') transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { - return { id: opts.sessionId ?? 'pty-new', replay: 'restored-ssh-output' } + const id = opts.sessionId ?? 'pty-new' + transport.getPtyId.mockReturnValue(id) + return { id, replay: 'restored-ssh-output' } }) transportFactoryQueue.push(transport) @@ -15826,6 +16636,7 @@ describe('connectPanePty', () => { } const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) const manager = createManager(1) const deps = createDeps({ restoredLeafId: LEAF_1, @@ -15835,6 +16646,15 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(20) + const settlePaneSerializer = vi.mocked(window.api.pty.settlePaneSerializer) + expect(parseCallbacks.length).toBeGreaterThan(0) + expect(settlePaneSerializer).not.toHaveBeenCalled() + for (let step = 0; step < 20 && settlePaneSerializer.mock.calls.length === 0; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks() + } + expect(settlePaneSerializer).toHaveBeenCalledWith(expect.any(String), 1) + const api = ( globalThis as unknown as { window: { @@ -15855,12 +16675,9 @@ describe('connectPanePty', () => { // Why: the relay's replay buffer holds the full terminal history, so the // client clears xterm before writing to prevent duplication with any // content already in the terminal from a prior session. - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', expect.any(Function)) - expect(pane.terminal.write).toHaveBeenCalledWith('restored-ssh-output', expect.any(Function)) - expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_REATTACH_RESET, - expect.any(Function) - ) + expect(writes).toContain('\x1b[2J\x1b[3J\x1b[H') + expect(writes).toContain('restored-ssh-output') + expect(writes).toContain(POST_REPLAY_REATTACH_RESET) expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH') }) @@ -16646,8 +17463,21 @@ describe('connectPanePty', () => { titleHandler('Codex working', 'Codex working') await vi.advanceTimersByTimeAsync(2_500) + const inspectionsBeforeIdle = getForegroundProcess.mock.calls.length getForegroundProcess.mockResolvedValue(null) - await vi.advanceTimersByTimeAsync(800) + // Why: active process polling is jittered, so a fixed window can consume + // both null samples before the replacement hook row is installed. + for ( + let attempts = 0; + getForegroundProcess.mock.calls.length === inspectionsBeforeIdle; + attempts += 1 + ) { + if (attempts >= 10) { + throw new Error('Expected the first idle process inspection') + } + await vi.advanceTimersToNextTimerAsync() + } + expect(getForegroundProcess).toHaveBeenCalledTimes(inspectionsBeforeIdle + 1) mockStoreState.agentStatusByPaneKey[paneKey] = { state: 'working', @@ -18533,7 +19363,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() // xterm is 120x40 (createPane default), PTY reports 80x24 → re-assert. - expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) }) it('does not fit during visibility-resume reassertion', async () => { @@ -18558,7 +19388,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(fit).not.toHaveBeenCalled() - expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) }) it('re-asserts after observed pane geometry changes while visible', async () => { @@ -18776,6 +19606,61 @@ describe('connectPanePty', () => { } }) + it('defers a remote-desktop viewport claim until structural replay completes', async () => { + const originalDocument = globalThis.document + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + hasFocus: vi.fn(() => true) + } as unknown as Document + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + queueMicrotask(() => callback(0)) + return 1 + }) + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const { beginTerminalScrollIntentBufferRebuild, endTerminalScrollIntentBufferRebuild } = + await import('@/lib/pane-manager/terminal-scroll-intent-rebuild') + const pane = createPane(2) + const observer = installObservedPane(pane) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + let proposedGrid = { cols: 120, rows: 40 } + pane.fitAddon = { + ...pane.fitAddon, + proposeDimensions: vi.fn(() => proposedGrid) + } as never + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + observer.trigger() + await flushAsyncTicks() + setFitOverride('pty-pane-2', 'remote-desktop-fit', 80, 24) + proposedGrid = { cols: 70, rows: 30 } + vi.mocked(pane.terminal.resize).mockClear() + transport.resize.mockClear() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + observer.trigger() + await flushAsyncTicks() + expect(pane.terminal.resize).not.toHaveBeenCalled() + expect(transport.resize).not.toHaveBeenCalled() + + endTerminalScrollIntentBufferRebuild(pane.terminal) + await flushAsyncTicks() + expect(pane.terminal.resize).toHaveBeenCalledWith(70, 30) + expect(transport.resize).toHaveBeenCalledWith(70, 30, { claim: true }) + } finally { + setFitOverride('pty-pane-2', 'desktop-fit', 0, 0) + observer.restore() + globalThis.document = originalDocument + } + }) + it('skips observed desktop reassertion while mobile owns the PTY without a fit override', async () => { const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') const pane = createPane(2) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8382693987f..859a71bba1a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -41,7 +41,12 @@ import { type HasPty } from './terminal-dead-session-reconcile' import type { PtyConnectionDeps } from './pty-connection-types' -import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { + cancelPendingSafeFitContinuations, + safeFit, + safeFitAndThen, + type SafeFitContinuationHandle +} from '@/lib/pane-manager/pane-tree-ops' import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' @@ -49,7 +54,12 @@ import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' import { getAppliedSizeReadE2eDelayMs } from './pty-applied-size-read-e2e-delay' import { createPtySizeReassertion } from './pty-size-reassertion' -import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard' +import { + isPaneReplaying, + replayIntoTerminal, + replayIntoTerminalAsync, + waitForTerminalReplayWritesParsed +} from './replay-guard' import { isTerminalWritePipelineCertifiedDead, registerUndeliverableWriteHandler @@ -98,7 +108,11 @@ import { } from '../../../../shared/terminal-color-scheme-protocol' import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' import { subscribeToTerminalUserInput } from './terminal-user-input-signal' -import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer' +import { + hasPtySerializer, + registerPtySerializer, + registerPtyTitleSource +} from './pty-buffer-serializer' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' import { @@ -115,10 +129,16 @@ import { } from '@/lib/pane-manager/windows-pty-compatibility' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' +import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { - captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent + getTerminalScrollIntentKind, + markTerminalFollowOutput } from '@/lib/pane-manager/terminal-scroll-intent' +import { + cancelTerminalScrollIntentBufferRebuildCompletions, + deferTerminalGeometryMutationDuringRebuild +} from '@/lib/pane-manager/terminal-scroll-intent-rebuild' +import { createTerminalStructuralReplayCoordinator } from '@/lib/pane-manager/terminal-structural-replay-coordinator' import { createBrowserUuid } from '@/lib/browser-uuid' import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id' import { @@ -959,6 +979,7 @@ export function connectPanePty( const terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(deps.tabId) exposeE2eTerminalPtyOutputDebug() let disposed = false + const structuralReplayCoordinator = createTerminalStructuralReplayCoordinator(pane.terminal) let connectFrame: number | null = null let connectFallbackTimer: ReturnType | null = null let startupGridSettleHandle: TerminalStartupGridSettleHandle | null = null @@ -966,6 +987,10 @@ export function connectPanePty( let connectStarted = false let unregisterBacklogRecovery: (() => void) | null = null let unregisterDocumentVisibilityRecovery: (() => void) | null = null + let cancelHiddenOutputSnapshotScrollRestore = (): void => {} + let pendingHiddenSnapshotFit: SafeFitContinuationHandle | null = null + let pendingReattachFit: SafeFitContinuationHandle | null = null + let cancelFreshSpawnFollowReset = (): void => {} let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} let cleanupHiddenOutputRestoreFloodRepaint = (): void => {} @@ -1867,6 +1892,21 @@ export function connectPanePty( (isTuiAgent(registeredLaunchAgent) ? registeredLaunchAgent : undefined) ) } + // Why: the concrete TUI agent a fresh spawn is expected to launch, used to seed + // a command-start confirmation on no-OSC shells (Git Bash/cmd) that never emit + // one. Returns null when the expectation isn't a recognized TUI agent. + const resolveExpectedLaunchTuiAgent = (): TuiAgent | null => { + const state = useAppStore.getState() + const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (candidate) => candidate.id === deps.tabId + ) + const candidate = + tab?.launchAgent ?? + paneStartup?.launchAgent ?? + paneStartup?.initialAgentStatus?.agent ?? + state.agentLaunchConfigByPaneKey[cacheKey]?.identity.agentType + return isTuiAgent(candidate) ? candidate : null + } // Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a // full-screen agent's leaked nested-shell 133;D can't clear its tab identity, // even on a restore where no command-start read has recorded evidence yet. @@ -2709,10 +2749,25 @@ export function connectPanePty( // frame-level sync often runs before that async result arrives. scheduleRuntimeGraphSync() agentCompletionCoordinator.startProcessTracking() - // Why: fresh spawns receive future OSC command-start events; only adopted or - // restored PTYs may already be inside Codex with no new foreground signal. + // Why: fresh spawns normally rely on a future OSC 133 command-start read to + // identify the launched agent; only adopted or restored PTYs may already be + // inside Codex with no new foreground signal. But no-OSC shells (Git Bash, + // cmd.exe) never emit a command-start, so an expected-agent pane spawned into + // one would never gain the fresh process evidence that authorizes Droid's + // Windows Shift+Enter CSI-u routing — leaving Shift+Enter to submit (#7620). + // Seed the SAME command-start confirmation the manually-typed launch path + // uses (onCommandStarted): its bounded retry ladder spans agent boot, and a + // miss publishes shellForeground:false — recoverable by later focus/reveal + // samples. A visible-pty sample would instead let a slow boot latch a + // known-agent shell-confirm that clears launch identity and blocks recovery. + // A real OSC 133;C, if it arrives, simply supersedes this. if (options.sampleVisibleForegroundAgent === true) { sampleVisiblePaneForegroundAgent() + } else if (options.seedInitialAgentStatus === true) { + const freshSpawnLaunchAgent = resolveExpectedLaunchTuiAgent() + if (freshSpawnLaunchAgent) { + paneForegroundAgentTracker.onCommandStarted(freshSpawnLaunchAgent) + } } } @@ -3162,8 +3217,13 @@ export function connectPanePty( const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 let lastTerminalInputAt = Number.NEGATIVE_INFINITY let hasReceivedPtyOutput = false - let deferredReattachLiveData: { data: string; meta?: PtyDataMeta }[] | null = null + let deferredReattachLiveData: + | { data: string; ptyId: string | null; streamGeneration: number; meta?: PtyDataMeta }[] + | null = null let deferredReattachLiveDataChars = 0 + let reattachLiveDataDeferralDepth = 0 + let deferredReattachLiveDataOwners = new Map() + let transportStreamGeneration = 0 const MAX_DEFERRED_REATTACH_LIVE_CHARS = 512 * 1024 const MAX_DEFERRED_REATTACH_LIVE_CHUNKS = 1_024 const markTerminalInputSent = (): void => { @@ -3629,7 +3689,7 @@ export function connectPanePty( getPtyId: () => transport.getPtyId(), isRemotePtyId: isRemoteRuntimePtyId, shouldSuppressDesktopResize: () => shouldSuppressDesktopPtyResize(), - fit: () => safeFit(pane), + fitAndRun: (continuation) => safeFitAndThen(pane, 'pty-size-reassertion', continuation), getTerminalDimensions: () => ({ cols: pane.terminal.cols, rows: pane.terminal.rows }), getAppliedSize: async (ptyId) => { // Why: e2e seam — delays the read past the reveal fit to reproduce the @@ -3712,6 +3772,18 @@ export function connectPanePty( let pendingPaneGeometryChanged = false const handleObservedPaneGeometry = (): void => { pendingGeometryReportRaf = null + if (disposed) { + return + } + if ( + deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + 'observed-pane-geometry', + handleObservedPaneGeometry + ) + ) { + return + } const paneGeometryChanged = pendingPaneGeometryChanged pendingPaneGeometryChanged = false const currentPtyId = transport.getPtyId() @@ -3842,7 +3914,9 @@ export function connectPanePty( // the grid stabilizes. While hidden it keeps watching for a late settle. isAuthoritative: () => isRendererPtyResizeAuthoritative(), measure: () => { - safeFit(pane) + if (!safeFit(pane)) { + return null + } const cols = pane.terminal.cols const rows = pane.terminal.rows return cols > 0 && rows > 0 ? { cols, rows } : null @@ -3879,7 +3953,9 @@ export function connectPanePty( } } const measureStartupGrid = (): { cols: number; rows: number } | null => { - safeFit(pane) + if (!safeFit(pane)) { + return null + } const cols = pane.terminal.cols const rows = pane.terminal.rows return cols > 0 && rows > 0 ? { cols, rows } : null @@ -4020,7 +4096,10 @@ export function connectPanePty( return { data, cols: pane.terminal.cols, - rows: pane.terminal.rows + rows: pane.terminal.rows, + ...(rendererOrderedPtyId === ptyId && rendererOrderedSeq !== null + ? { seq: rendererOrderedSeq } + : {}) } } catch { return null @@ -4029,7 +4108,7 @@ export function connectPanePty( () => { clearHiddenOutputRestoreState() discardTerminalOutput(pane.terminal) - pane.terminal.clear() + clearTerminalScrollbackAndFollowOutput(pane.terminal) } ) const unregisterTitleSource = registerPtyTitleSource(ptyId, (handler) => @@ -4043,6 +4122,47 @@ export function connectPanePty( } } + let replayWriteQueue = Promise.resolve() + const settlePaneSerializerAfterReplay = async ( + ptyId: string, + generation: number + ): Promise => { + try { + await replayWriteQueue + if (disposed || transport.getPtyId() !== ptyId) { + await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {}) + return + } + await waitForTerminalOutputParsed(pane.terminal) + if (!disposed && transport.getPtyId() === ptyId) { + await window.api.pty.settlePaneSerializer(cacheKey, generation) + return + } + } catch { + // Clear below so a failed parser/replay cannot leave the pane generation pending. + } + await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {}) + } + const reportRemoteRendererSerializerReady = (): void => { + const ptyId = transport.getPtyId() + if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { + return + } + if (!hasPtySerializer(ptyId)) { + registerPaneSerializerFor(ptyId) + } + // Why: onSubscribed follows the snapshot callback, but replay drains + // asynchronously; join it and xterm's parser before reporting readiness. + void replayWriteQueue + .then(() => waitForTerminalOutputParsed(pane.terminal)) + .then(() => { + if (!disposed && transport.getPtyId() === ptyId) { + void window.api.pty.reportRendererSerializerReady?.(ptyId) + } + }) + .catch(() => {}) + } + // Why: for ordinary local startup commands, the local PTY provider already // writes via the shell-ready barrier. terminal-paste and SSH startup // commands stay renderer-delivered so xterm/relay can apply their handling. @@ -4416,12 +4536,60 @@ export function connectPanePty( }, 50) } + let freshSpawnFollowResetDisposables: IDisposable[] = [] + cancelFreshSpawnFollowReset = (): void => { + for (const disposable of freshSpawnFollowResetDisposables) { + disposable.dispose() + } + freshSpawnFollowResetDisposables = [] + } + const resetFreshSpawnFollowOutput = (): void => { + cancelFreshSpawnFollowReset() + markTerminalFollowOutput(pane.terminal) + let nativeFollowResetComplete = false + const tryResetNativeFollow = (): void => { + if ( + disposed || + getTerminalScrollIntentKind(pane.terminal) !== 'followOutput' || + deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + 'fresh-spawn-follow-reset', + tryResetNativeFollow + ) + ) { + return + } + try { + pane.terminal.scrollToBottom() + nativeFollowResetComplete = true + cancelFreshSpawnFollowReset() + } catch (err) { + if (!(err instanceof TypeError && /dimensions/.test(err.message))) { + cancelFreshSpawnFollowReset() + throw err + } + } + } + tryResetNativeFollow() + if (!nativeFollowResetComplete) { + // Why: xterm's browser viewport can reject scrolling while its renderer + // is detached; the first render/resize is the earliest safe native retry. + freshSpawnFollowResetDisposables = [ + pane.terminal.onRender(tryResetNativeFollow), + pane.terminal.onResize(tryResetNativeFollow) + ] + } + } + const startFreshSpawn = ( startupOverride?: PendingStartupCommand | null, options: FreshSpawnOptions = {} ): Promise => { clearPaneMode2031State() clearHiddenOutputRestoreState() + // Why: a canceled old replay clear can preserve xterm's native + // isUserScrolling flag. A replacement shell must start in follow mode. + resetFreshSpawnFollowOutput() // Why: a fresh spawn is a new process with kitty keyboard flags at // zero. The exit-handler reset alone is not enough: a late exit from a // replaced PTY takes the stale-transport early return and skips it, so @@ -4449,6 +4617,7 @@ export function connectPanePty( : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) transportConnectInFlightSince = Date.now() + const outputCallbacks = captureTransportOutputCallbacks(reportError) const spawnedRaw = transport.connect({ url: '', cols, @@ -4461,11 +4630,7 @@ export function connectPanePty( ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: { - onData: dataCallback, - onReplayData: replayDataCallback, - onError: reportError - } + callbacks: outputCallbacks.callbacks }) void Promise.resolve(spawnedRaw) @@ -4475,6 +4640,13 @@ export function connectPanePty( }) const trackedPromise: Promise = Promise.resolve(spawnedRaw) .then(async (spawnedPtyId) => { + if (outputCallbacks.generation !== transportStreamGeneration) { + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) + } + return null + } const resolvedPtyId = spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId ? spawnedPtyId.id @@ -4529,9 +4701,11 @@ export function connectPanePty( reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows) } const gen = await preSignalPromise - if (typeof gen === 'number' && resolvedPtyId) { - if (!isRemoteRuntimePtyId(resolvedPtyId)) { + if (resolvedPtyId && (typeof gen === 'number' || isRemoteRuntimePtyId(resolvedPtyId))) { + if (!isRemoteRuntimePtyId(resolvedPtyId) || !hasPtySerializer(resolvedPtyId)) { registerPaneSerializerFor(resolvedPtyId) + } + if (typeof gen === 'number') { void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) } } else if (typeof gen === 'number') { @@ -4736,10 +4910,18 @@ export function connectPanePty( writeFreshShellViewportBlanking() } - const sendFocusedReattachFocusInAfterReplay = (): void => { + const sendFocusedReattachFocusInAfterReplay = ( + expectedPtyId: string | null = transport.getPtyId(), + expectedStreamGeneration = transportStreamGeneration + ): void => { const scheduledGeneration = reattachReplayPayloadSignalGeneration void waitForTerminalOutputParsed(pane.terminal).then(() => { - if (disposed) { + const currentPtyId = transport.getPtyId() + if ( + disposed || + expectedStreamGeneration !== transportStreamGeneration || + currentPtyId !== expectedPtyId + ) { return } // Why: a newer replay frame owns the judgment; its own post-parse @@ -4778,24 +4960,56 @@ export function connectPanePty( }) } - let replayWriteQueue = Promise.resolve() type PendingReplayData = { data: string clearBeforeReplay: boolean + ptyId: string | null + generation: number + streamGeneration: number pendingEscapeTailAnsi?: string } let pendingReplayData: PendingReplayData | null = null + let replayPayloadGeneration = 0 let replayDrainQueued = false - const drainReplayDataQueue = async (): Promise => { + const drainReplayDataQueue = async ( + expectedPtyId: string | null, + expectedStreamGeneration: number + ): Promise => { + let appliedCurrentPayload = false while (pendingReplayData !== null) { - const { data, clearBeforeReplay, pendingEscapeTailAnsi } = pendingReplayData + if ( + pendingReplayData.ptyId !== expectedPtyId || + pendingReplayData.streamGeneration !== expectedStreamGeneration + ) { + return false + } + if ( + transport.getPtyId() !== expectedPtyId || + transportStreamGeneration !== expectedStreamGeneration + ) { + pendingReplayData = null + return false + } + const payload = pendingReplayData + const { data, clearBeforeReplay, pendingEscapeTailAnsi } = payload pendingReplayData = null + const isCurrentPayload = (): boolean => + !disposed && + payload.generation === replayPayloadGeneration && + payload.streamGeneration === transportStreamGeneration && + transport.getPtyId() === payload.ptyId + if (!isCurrentPayload()) { + continue + } // Relay replay buffers may overlap with content already rendered in // xterm. Local eager replay decides this earlier so metadata-only frames // can keep restored scrollback while still using the replay guard. if (clearBeforeReplay) { await writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H') + if (!isCurrentPayload()) { + continue + } } if (clearBeforeReplay || data.length > 0) { // Why: an empty clearing frame is still an authoritative repaint and @@ -4808,9 +5022,15 @@ export function connectPanePty( // apply as sets to keep the mirrored stack from accumulating frames. kittyKeyboardModes.scanReplay(data) await writeReplayDataAsync(data) + if (!isCurrentPayload()) { + continue + } if (clearBeforeReplay || data.length > 0) { await writeReplayDataAsync(reattachReplayResetSequence(data)) - sendFocusedReattachFocusInAfterReplay() + if (!isCurrentPayload()) { + continue + } + sendFocusedReattachFocusInAfterReplay(payload.ptyId, payload.streamGeneration) } // Why: the daemon could not serialize a PTY read that ended mid-escape, // so the emulator shipped the dangling partial separately. Write it LAST @@ -4820,43 +5040,113 @@ export function connectPanePty( if (pendingEscapeTailAnsi) { await writeReplayDataAsync(pendingEscapeTailAnsi) } - if (disposed) { - pendingReplayData = null - return + if (!isCurrentPayload()) { + continue } // Why: remote-runtime snapshots can arrive after WebGL attached to an // empty buffer; rebuilding after replay parses seeds the glyph atlas // from the now-populated xterm state. manager.rebuildPaneWebgl(pane.id) + appliedCurrentPayload = true } + return appliedCurrentPayload + } + const scheduleReplayDataDrain = (): void => { + if (replayDrainQueued) { + return + } + const scheduledPtyId = pendingReplayData?.ptyId ?? null + replayDrainQueued = true + // Why: live bytes are newer than the authoritative replay frame. Hold + // them until clear + replay + reset have all parsed, or replay can erase them. + const scheduledStreamGeneration = + pendingReplayData?.streamGeneration ?? transportStreamGeneration + beginReattachLiveDataDeferral(scheduledStreamGeneration) + let replayCompleted = false + replayWriteQueue = replayWriteQueue + .catch(() => undefined) + .then(() => + structuralReplayCoordinator.run( + async () => { + replayCompleted = await drainReplayDataQueue( + scheduledPtyId, + scheduledStreamGeneration + ) + }, + { + shouldRestore: () => + !disposed && + transport.getPtyId() === scheduledPtyId && + transportStreamGeneration === scheduledStreamGeneration + } + ) + ) + .then(() => { + replayCompleted &&= !disposed && transport.getPtyId() === scheduledPtyId + }) + .finally(() => { + replayDrainQueued = false + if (pendingReplayData !== null) { + // Why: preserve the PTY identity captured when the callback fired; + // re-reading it here could retag stale bytes for a replacement PTY. + scheduleReplayDataDrain() + } + finishReattachLiveDataDeferral(replayCompleted, scheduledStreamGeneration) + }) } const replayDataCallback = ( data: string, - meta: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } = {} + meta: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } = {}, + streamGeneration = transportStreamGeneration ): void => { pendingReplayData = { data, clearBeforeReplay: meta.clearBeforeReplay !== false, + ptyId: transport.getPtyId(), + generation: (replayPayloadGeneration += 1), + streamGeneration, ...(meta.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi } : {}) } - if (replayDrainQueued) { - return - } - replayDrainQueued = true - replayWriteQueue = replayWriteQueue - .catch(() => undefined) - .then(drainReplayDataQueue) - .finally(() => { - replayDrainQueued = false - if (pendingReplayData !== null) { - replayDataCallback(pendingReplayData.data, { - clearBeforeReplay: pendingReplayData.clearBeforeReplay, - ...(pendingReplayData.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: pendingReplayData.pendingEscapeTailAnsi } - : {}) - }) + scheduleReplayDataDrain() + } + + const captureTransportOutputCallbacks = (onError: (message: string) => void) => { + // Why: a new stream generation cannot inherit an old replay's pending + // destination-grid fit or keep its live-data waiter open. + pendingHiddenSnapshotFit?.cancel() + pendingHiddenSnapshotFit = null + pendingReattachFit?.cancel() + pendingReattachFit = null + const generation = (transportStreamGeneration += 1) + const isCurrent = (): boolean => !disposed && generation === transportStreamGeneration + return { + generation, + callbacks: { + onConnect: (): void => { + if (isCurrent()) { + reportRemoteRendererSerializerReady() + } + }, + onData: (data: string, meta?: PtyDataMeta): void => { + if (isCurrent()) { + dataCallback(data, meta, generation) + } + }, + onReplayData: ( + data: string, + meta?: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } + ): void => { + if (isCurrent()) { + replayDataCallback(data, meta, generation) + } + }, + onError: (message: string): void => { + if (isCurrent()) { + onError(message) + } } - }) + } + } } type PendingHiddenOutputRestoreChunk = { @@ -4876,6 +5166,12 @@ export function connectPanePty( let hiddenOutputRestoreDeferredRetryTimer: ReturnType | null = null let hiddenOutputRestoreForegroundDeadlineTimer: ReturnType | null = null let hiddenOutputRestoreDeferredRetryAttempts = 0 + let hiddenOutputSnapshotScrollRestore: { + ptyId: string | null + generation: number + valid: boolean + started: boolean + } | null = null // Why: hidden recovery state belongs to one PTY stream. Reattach/restart // can reuse the pane object for a different session before visibility. let hiddenOutputRestorePtyId: string | null = null @@ -5982,6 +6278,14 @@ export function connectPanePty( : hiddenOutputRestorePendingChunks.slice() const hadPendingOverflow = hiddenOutputRestorePendingOverflow hiddenOutputRestoreGeneration += 1 + if ( + hiddenOutputSnapshotScrollRestore?.valid && + hiddenOutputSnapshotScrollRestore.ptyId === expectedPtyId + ) { + // Why: same-PTY flood abandonment stops recovery bookkeeping, but its + // already-queued replay must keep the rebuild bracket and final pin. + hiddenOutputSnapshotScrollRestore.generation = hiddenOutputRestoreGeneration + } hiddenOutputRestoreInFlight = null hiddenOutputRestoreNeeded = false hiddenOutputRestorePtyId = null @@ -6046,6 +6350,7 @@ export function connectPanePty( } function clearHiddenOutputRestoreState(): void { + cancelSnapshotScrollRestore() clearPendingLiveChunksDuringRestore() hiddenStartupRendererQueryPending = '' hiddenRendererStateDirty = false @@ -6055,6 +6360,23 @@ export function connectPanePty( hiddenOutputRestoreGeneration += 1 } + function cancelSnapshotScrollRestore(): void { + pendingHiddenSnapshotFit?.cancel() + pendingHiddenSnapshotFit = null + const scrollRestore = hiddenOutputSnapshotScrollRestore + if (!scrollRestore) { + return + } + scrollRestore.valid = false + hiddenOutputSnapshotScrollRestore = null + if (scrollRestore.started) { + cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) + } + // Why: invalidation suppresses restoration, but queued bytes still own + // the bracket until their FIFO sentinels prove parsing has finished. + } + cancelHiddenOutputSnapshotScrollRestore = cancelSnapshotScrollRestore + function clearPaneMode2031State(): void { deps.paneMode2031Ref.current.delete(pane.id) deps.paneLastThemeModeRef.current.delete(pane.id) @@ -6121,7 +6443,7 @@ export function connectPanePty( }) } - function applyMainBufferSnapshot(snapshot: { + async function applyMainBufferSnapshot(snapshot: { data: string cols: number rows: number @@ -6129,8 +6451,19 @@ export function connectPanePty( alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string - }): void { - const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) + }): Promise { + const restorePtyId = transport.getPtyId() + const restoreGeneration = hiddenOutputRestoreGeneration + if (hiddenOutputSnapshotScrollRestore) { + cancelSnapshotScrollRestore() + } + const scrollRestore = { + ptyId: restorePtyId, + generation: restoreGeneration, + valid: true, + started: false + } + hiddenOutputSnapshotScrollRestore = scrollRestore const colsBeforeReplay = pane.terminal.cols const rowsBeforeReplay = pane.terminal.rows const hasSnapshotDimensions = @@ -6138,83 +6471,135 @@ export function connectPanePty( Number.isFinite(snapshot.rows) && snapshot.cols > 0 && snapshot.rows > 0 - discardTerminalOutput(pane.terminal) - if ( - hasSnapshotDimensions && - (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) - ) { - // Why: serialized terminal snapshots encode layout at their source - // dimensions. Replay at those dimensions first, then fit back below. - // This xterm-only resize must not SIGWINCH the live TUI. - suppressSnapshotReplayPtyResize = true - try { - pane.terminal.resize(snapshot.cols, snapshot.rows) - } finally { - suppressSnapshotReplayPtyResize = false - } - } - if (!snapshot.alternateScreen) { - // Why: this clear (incl. \x1b[3J) wipes xterm's scrollback. Alt-screen - // TUIs (Claude Code, vim) keep their scroll history in xterm, so - // clearing on restore loses scroll-up after a hidden->visible return. - // Mirrors the attach-time guard in pty-transport.ts. - writeReplayData('\x1b[2J\x1b[3J\x1b[H') - } else if (snapshot.scrollbackAnsi !== undefined) { - // Why: SerializeAddon captures normal and alternate buffers together. - // Rebuild normal while it is active, then return to a clean alt frame. - writeReplayData('\x1b[?1049l\x1b[2J\x1b[3J\x1b[H') - writeReplayData(snapshot.scrollbackAnsi) - writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') - } else { - // Why: the snapshot's own ?1049h is a no-op when the pane is already on - // the alternate screen, and the serialized frame skips blank cells — so - // without clearing the alt screen the pre-hide frame bleeds through - // every cell the final frame leaves blank. \x1b[2J on the alt buffer - // does not touch the normal buffer's scrollback the TUI returns to. - writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') - } - writeReplayData(snapshot.data) - // Why: status/title-corroborated live agents own ?25l/?1004h (a forced - // ?1004l here would silence focus events until the agent restarts, since - // agents only enable focus reporting at startup). - writeReplayData( - hasLiveAgentReattachStatusOrTitleSignal() - ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET - : POST_REPLAY_LIVE_SNAPSHOT_RESET - ) - if (snapshot.pendingEscapeTailAnsi) { - // Why last: the snapshot was taken with main's emulator mid-escape; - // re-arming the dangling sequence must be the FINAL replay write (any - // later ESC — including the reset above — aborts it) so the racing - // live tail's continuation completes it exactly as live, instead of - // rendering literally (Bug E fix / #7329). - writeReplayData(snapshot.pendingEscapeTailAnsi) - } - hiddenRendererStateDirty = false - recordRendererOrderedSeq(snapshot) - resetHiddenRendererRiskState() - recordTerminalOutput(pane.terminal) - const currentPtyId = transport.getPtyId() - if (currentPtyId && !getFitOverrideForPty(currentPtyId)) { - safeFit(pane) - const replayChangedDimensions = hasSnapshotDimensions - ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows - : pane.terminal.cols !== colsBeforeReplay || pane.terminal.rows !== rowsBeforeReplay - if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { - transport.resize(pane.terminal.cols, pane.terminal.rows) - if (!isRemoteRuntimePtyId(currentPtyId)) { - // Why: redundant SIGWINCH can make alternate-screen TUIs rebuild - // their internal scroll viewport to the top on tab return. - window.api.pty.signal(currentPtyId, 'SIGWINCH') + try { + await structuralReplayCoordinator.run( + async () => { + if ( + !scrollRestore.valid || + disposed || + transport.getPtyId() !== scrollRestore.ptyId || + hiddenOutputRestoreGeneration !== scrollRestore.generation + ) { + return + } + scrollRestore.started = true + discardTerminalOutput(pane.terminal) + if ( + hasSnapshotDimensions && + (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) + ) { + // Why: xterm parses writes later. Keep snapshot dimensions until + // the FIFO sentinel completes so serialized wraps stay exact. + suppressSnapshotReplayPtyResize = true + try { + pane.terminal.resize(snapshot.cols, snapshot.rows) + } finally { + suppressSnapshotReplayPtyResize = false + } + } + if (!snapshot.alternateScreen) { + // Why: this clear (incl. \x1b[3J) wipes xterm's scrollback. Alt-screen + // TUIs (Claude Code, vim) keep their scroll history in xterm, so + // clearing on restore loses scroll-up after a hidden->visible return. + // Mirrors the attach-time guard in pty-transport.ts. + writeReplayData('\x1b[2J\x1b[3J\x1b[H') + } else if (snapshot.scrollbackAnsi !== undefined) { + // Why: SerializeAddon captures normal and alternate buffers together. + // Rebuild normal while it is active, then return to a clean alt frame. + writeReplayData('\x1b[?1049l\x1b[2J\x1b[3J\x1b[H') + writeReplayData(snapshot.scrollbackAnsi) + writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') + } else { + // Why: the snapshot's own ?1049h is a no-op when the pane is already on + // the alternate screen, and the serialized frame skips blank cells — so + // without clearing the alt screen the pre-hide frame bleeds through + // every cell the final frame leaves blank. \x1b[2J on the alt buffer + // does not touch the normal buffer's scrollback the TUI returns to. + writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') + } + writeReplayData(snapshot.data) + // Why: status/title-corroborated live agents own ?25l/?1004h (a forced + // ?1004l here would silence focus events until the agent restarts, since + // agents only enable focus reporting at startup). + writeReplayData( + hasLiveAgentReattachStatusOrTitleSignal() + ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET + : POST_REPLAY_LIVE_SNAPSHOT_RESET + ) + if (snapshot.pendingEscapeTailAnsi) { + // Why last: the snapshot was taken with main's emulator mid-escape; + // re-arming the dangling sequence must be the FINAL replay write (any + // later ESC — including the reset above — aborts it) so the racing + // live tail's continuation completes it exactly as live, instead of + // rendering literally (Bug E fix / #7329). + writeReplayData(snapshot.pendingEscapeTailAnsi) + } + hiddenRendererStateDirty = false + recordRendererOrderedSeq(snapshot) + resetHiddenRendererRiskState() + recordTerminalOutput(pane.terminal) + await waitForTerminalReplayWritesParsed(pane.terminal) + }, + { + shouldRestore: () => + scrollRestore.valid && + !disposed && + transport.getPtyId() === scrollRestore.ptyId && + hiddenOutputRestoreGeneration === scrollRestore.generation, + afterRestore: async () => { + const isCurrentRestore = (): boolean => + scrollRestore.valid && + !disposed && + transport.getPtyId() === scrollRestore.ptyId && + hiddenOutputRestoreGeneration === scrollRestore.generation + if (!isCurrentRestore()) { + return + } + const currentPtyId = transport.getPtyId() + if (!currentPtyId || getFitOverrideForPty(currentPtyId)) { + return + } + const fit = safeFitAndThen( + pane, + 'hidden-snapshot-pty-resize', + () => { + if (!isCurrentRestore() || transport.getPtyId() !== currentPtyId) { + return + } + const replayChangedDimensions = hasSnapshotDimensions + ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows + : pane.terminal.cols !== colsBeforeReplay || + pane.terminal.rows !== rowsBeforeReplay + if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { + transport.resize(pane.terminal.cols, pane.terminal.rows) + if (!isRemoteRuntimePtyId(currentPtyId)) { + // Why: redundant SIGWINCH can make alternate-screen TUIs rebuild + // their internal scroll viewport to the top on tab return. + window.api.pty.signal(currentPtyId, 'SIGWINCH') + } + } + }, + { shouldContinue: isCurrentRestore } + ) + pendingHiddenSnapshotFit = fit + try { + await fit.completion + } finally { + if (pendingHiddenSnapshotFit === fit) { + pendingHiddenSnapshotFit = null + } + } + if (isCurrentRestore()) { + scheduleReattachIdleAgentCursorReset() + } + } } + ) + } finally { + if (hiddenOutputSnapshotScrollRestore === scrollRestore) { + hiddenOutputSnapshotScrollRestore = null } - scheduleReattachIdleAgentCursorReset() } - // Why: snapshot replay clears and rebuilds xterm state; re-apply the - // user's scroll intent once so hidden catch-up cannot repin the viewport. - // Restore by bottom offset — the rebuilt buffer renumbers every row, so - // the pre-replay absolute viewport line points at arbitrary content. - enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent, { restoreBy: 'bottomOffset' }) } function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean { @@ -6351,7 +6736,15 @@ export function connectPanePty( } hiddenOutputRestoreDeferredRetryAttempts = 0 restoreIterations += 1 - applyMainBufferSnapshot(snapshot) + await applyMainBufferSnapshot(snapshot) + if ( + disposed || + hiddenOutputRestoreGeneration !== restoreGeneration || + hiddenOutputRestorePtyId !== currentPtyId || + transport.getPtyId() !== currentPtyId + ) { + return + } // Why: everything at or before snapshot.seq is now painted; chunks // still draining from main's ACK backlog below that point are // duplicates the dataCallback reconciliation must suppress. @@ -6456,14 +6849,41 @@ export function connectPanePty( } } - const dataCallback = (data: string, meta?: PtyDataMeta): void => { + const dataCallback = ( + data: string, + meta?: PtyDataMeta, + streamGeneration = transportStreamGeneration + ): void => { + if (streamGeneration !== transportStreamGeneration) { + return + } if (deferredReattachLiveData !== null) { - deferredReattachLiveData.push({ data, ...(meta ? { meta } : {}) }) - deferredReattachLiveDataChars += data.length - let dropped = false + // Why: a replacement stream must not inherit either bytes or a gap + // marker from the replay owner it superseded. + deferredReattachLiveData = deferredReattachLiveData.filter( + (chunk) => chunk.streamGeneration === streamGeneration + ) + deferredReattachLiveDataChars = deferredReattachLiveData.reduce( + (total, chunk) => total + chunk.data.length, + 0 + ) + const oversized = data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS + const deferredData = oversized ? data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : data + deferredReattachLiveData.push({ + data: deferredData, + ptyId: transport.getPtyId(), + streamGeneration, + ...(meta ? { meta } : {}) + }) + deferredReattachLiveDataChars += deferredData.length + // Why: retaining one arbitrarily large IPC frame would bypass this + // queue's memory bound. Mark it as a stream gap so snapshot recovery + // replaces it instead of feeding a partial ANSI frame to xterm. + let dropped = oversized while ( - deferredReattachLiveData.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || - deferredReattachLiveDataChars > MAX_DEFERRED_REATTACH_LIVE_CHARS + deferredReattachLiveData.length > 1 && + (deferredReattachLiveData.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || + deferredReattachLiveDataChars > MAX_DEFERRED_REATTACH_LIVE_CHARS) ) { const removed = deferredReattachLiveData.shift() deferredReattachLiveDataChars -= removed?.data.length ?? 0 @@ -6666,34 +7086,72 @@ export function connectPanePty( } }) - const beginReattachLiveDataDeferral = (): void => { - deferredReattachLiveData = [] - deferredReattachLiveDataChars = 0 + const beginReattachLiveDataDeferral = (ownerGeneration = transportStreamGeneration): void => { + reattachLiveDataDeferralDepth += 1 + if (reattachLiveDataDeferralDepth === 1) { + deferredReattachLiveData = [] + deferredReattachLiveDataChars = 0 + deferredReattachLiveDataOwners = new Map() + } + if (!deferredReattachLiveDataOwners.has(ownerGeneration)) { + deferredReattachLiveDataOwners.set(ownerGeneration, { failed: false }) + } } - const finishReattachLiveDataDeferral = (deliver: boolean): void => { + const finishReattachLiveDataDeferral = ( + deliver: boolean, + acceptedGeneration = transportStreamGeneration + ): void => { + if (reattachLiveDataDeferralDepth <= 0) { + return + } + if (!deliver) { + const owner = deferredReattachLiveDataOwners.get(acceptedGeneration) + if (owner) { + owner.failed = true + } + } + reattachLiveDataDeferralDepth -= 1 + if (reattachLiveDataDeferralDepth > 0) { + return + } const chunks = deferredReattachLiveData deferredReattachLiveData = null deferredReattachLiveDataChars = 0 - if (!deliver || disposed || !chunks) { + const currentPtyId = transport.getPtyId() + const currentGeneration = transportStreamGeneration + const currentOwner = deferredReattachLiveDataOwners.get(currentGeneration) + deferredReattachLiveDataOwners = new Map() + if (disposed || !chunks) { return } // Why: createOrAttach snapshots precede bytes emitted before its IPC // reply. Paint the authoritative replay first, then admit those live // chunks so the replay clear cannot erase newer output. for (const chunk of chunks) { - dataCallback(chunk.data, chunk.meta) + if ( + chunk.ptyId !== currentPtyId || + chunk.streamGeneration !== currentGeneration || + currentOwner?.failed === true + ) { + continue + } + dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) } } - const handleReattachResult = ( + const handleReattachResult = async ( result: PtyConnectResult | string | void, staleSessionId?: string | null, - coldRestoreStartup?: ColdRestoreAgentResumeStartup | null - ): boolean => { + coldRestoreStartup?: ColdRestoreAgentResumeStartup | null, + attemptGeneration = transportStreamGeneration + ): Promise => { if (disposed) { return false } + if (attemptGeneration !== transportStreamGeneration) { + return false + } const connectResult = result && typeof result === 'object' && 'id' in result ? (result as PtyConnectResult) : null @@ -6754,6 +7212,15 @@ export function connectPanePty( }) return false } + const isCurrentReattachPayload = (): boolean => { + const currentPtyId = transport.getPtyId() + return ( + !disposed && attemptGeneration === transportStreamGeneration && currentPtyId === ptyId + ) + } + if (!isCurrentReattachPayload()) { + return false + } setPanePtyFitBinding(ptyId) reportPanePtyVisibility(ptyId, deps.isVisibleRef.current) registerSideEffectFactConsumerForPty(ptyId) @@ -6779,125 +7246,179 @@ export function connectPanePty( // newer than disk-recorded scrollback. If we ever return all three, // the daemon and relay are by definition tracking the same session // and only the freshest source belongs on screen. - if (connectResult?.snapshot) { - rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) - // Why: the daemon serializes its grid with soft-wrapped lines as - // continuous text. Replaying that at a different column count rewraps - // rows one cell early/late (bug #7279). Replay at the snapshot's own - // dimensions first; safeFit below fits the pane back and resizes the - // remote PTY. Suppress the xterm->PTY forward so this layout-only - // resize does not SIGWINCH the live remote TUI. Mirrors - // applyMainBufferSnapshot. - const snapshotCols = connectResult.snapshotCols - const snapshotRows = connectResult.snapshotRows - const hasSnapshotDimensions = - typeof snapshotCols === 'number' && - typeof snapshotRows === 'number' && - Number.isFinite(snapshotCols) && - Number.isFinite(snapshotRows) && - snapshotCols > 0 && - snapshotRows > 0 - if ( - hasSnapshotDimensions && - (pane.terminal.cols !== snapshotCols || pane.terminal.rows !== snapshotRows) - ) { - suppressSnapshotReplayPtyResize = true + const hasStructuralReplay = Boolean( + connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore + ) + let reattachPayloadApplied = !hasStructuralReplay + const applyReattachPayload = async (): Promise => { + if (!isCurrentReattachPayload()) { + return + } + if (connectResult?.snapshot) { + rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) + // Why: the daemon serializes its grid with soft-wrapped lines as + // continuous text. Replaying that at a different column count rewraps + // rows one cell early/late (bug #7279). Replay at the snapshot's own + // dimensions first; safeFit below fits the pane back and resizes the + // remote PTY. Suppress the xterm->PTY forward so this layout-only + // resize does not SIGWINCH the live remote TUI. Mirrors + // applyMainBufferSnapshot. + const snapshotCols = connectResult.snapshotCols + const snapshotRows = connectResult.snapshotRows + const hasSnapshotDimensions = + typeof snapshotCols === 'number' && + typeof snapshotRows === 'number' && + Number.isFinite(snapshotCols) && + Number.isFinite(snapshotRows) && + snapshotCols > 0 && + snapshotRows > 0 + if ( + hasSnapshotDimensions && + (pane.terminal.cols !== snapshotCols || pane.terminal.rows !== snapshotRows) + ) { + suppressSnapshotReplayPtyResize = true + try { + pane.terminal.resize(snapshotCols, snapshotRows) + } finally { + suppressSnapshotReplayPtyResize = false + } + } + writeReplayData('\x1b[2J\x1b[3J\x1b[H') + // Why: the daemon snapshot's rehydrate preamble carries the live + // session's kitty keyboard flags; re-arm the mirror from it so Option + // chords keep their kitty encoding after a window reload. + kittyKeyboardModes.scanReplay(connectResult.snapshot) + writeReplayData(connectResult.snapshot) + // Snapshot reattach keeps a live session, so avoid the broader mode + // reset. We only drop renderer-owned state that should not leak from + // replay bytes into the restored renderer terminal. + writeReplayData(reattachReplayResetSequence(connectResult.snapshot)) + if (connectResult.pendingEscapeTailAnsi) { + // Why last: re-arm the daemon's dangling mid-escape sequence AFTER the + // reset (whose ESC would abort it) so the racing live continuation + // completes it instead of rendering literally (#7329). + writeReplayData(connectResult.pendingEscapeTailAnsi) + } + sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) + if (connectResult.coldRestore) { + // Snapshot superseded the cold-restore payload — ack it so the + // daemon does not redeliver it on the next reattach. + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } + } + } else if (connectResult?.replay) { + rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) + // Relay replay holds the last 100 KB of raw output. The xterm may + // already hold pre-disconnect content; clear first to avoid + // duplication. The reattach reset clears renderer-owned state without + // tearing down the still-running TUI's live modes. + writeReplayData('\x1b[2J\x1b[3J\x1b[H') + // Why: raw relay replay contains the application's own kitty pushes + // when they fall inside the retained window; re-arm the mirror with + // replay (set) semantics so redelivery cannot grow the stack. + kittyKeyboardModes.scanReplay(connectResult.replay) + writeReplayData(connectResult.replay) + writeReplayData(reattachReplayResetSequence(connectResult.replay)) + sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) + if (connectResult.coldRestore) { + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } + } + } else if (connectResult?.coldRestore) { + // replayIntoTerminal: the recorded scrollback is raw PTY output that + // may contain query sequences the previous agent CLI emitted; + // writing them through xterm.write would trigger auto-replies that + // land in the new shell's stdin. See replay-guard.ts. + writeReplayData(connectResult.coldRestore.scrollback) + const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() + const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) + if (didPrepareResume) { + if (preparedStartup?.hasSleepingRecord) { + showSessionRestoredBanner() + } + clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) + } + // Cold-restore means the daemon lost the session and spawned a + // fresh shell — no TUI is consuming the mode-setting bytes that a + // crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so + // reset them to match the fresh shell's expectations. + writeReplayData(POST_REPLAY_MODE_RESET) + // Why: the dead run's scrollback was never scanned, and any kitty + // flags it pushed died with it — the fresh shell starts at zero. + kittyKeyboardModes.reset() + consumeRestoredViewportBlankingMarker() + writeFreshShellViewportBlanking() + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } + if (didPrepareResume && !coldRestoreStartup) { + schedulePendingStartupCommandDelivery() + } + } + if (hasStructuralReplay) { + await waitForTerminalReplayWritesParsed(pane.terminal) + if (!isCurrentReattachPayload()) { + return + } + reattachPayloadApplied = true + } + } + + const fitAfterReattachRestore = async (): Promise => { + if (!isCurrentReattachPayload()) { + return + } + const reattachPtyId = transport.getPtyId() + if (!reattachPtyId) { + return + } + if (!getFitOverrideForPty(reattachPtyId)) { + const fit = safeFitAndThen( + pane, + 'reattach-pty-resize', + () => { + if (!isCurrentReattachPayload() || transport.getPtyId() !== reattachPtyId) { + return + } + const reattachCols = pane.terminal.cols + const reattachRows = pane.terminal.rows + if (reattachCols > 0 && reattachRows > 0) { + transport.resize(reattachCols, reattachRows) + } + // Why: POSIX only delivers SIGWINCH when terminal dimensions actually + // change. Sending it explicitly guarantees restored TUIs repaint at + // the correct cursor position after snapshot replay. + if (!isRemoteRuntimePtyId(reattachPtyId)) { + window.api.pty.signal(reattachPtyId, 'SIGWINCH') + } + }, + { shouldContinue: isCurrentReattachPayload } + ) + pendingReattachFit = fit try { - pane.terminal.resize(snapshotCols, snapshotRows) + await fit.completion } finally { - suppressSnapshotReplayPtyResize = false + if (pendingReattachFit === fit) { + pendingReattachFit = null + } } - } - writeReplayData('\x1b[2J\x1b[3J\x1b[H') - // Why: the daemon snapshot's rehydrate preamble carries the live - // session's kitty keyboard flags; re-arm the mirror from it so Option - // chords keep their kitty encoding after a window reload. - kittyKeyboardModes.scanReplay(connectResult.snapshot) - writeReplayData(connectResult.snapshot) - // Snapshot reattach keeps a live session, so avoid the broader mode - // reset. We only drop renderer-owned state that should not leak from - // replay bytes into the restored renderer terminal. - writeReplayData(reattachReplayResetSequence(connectResult.snapshot)) - if (connectResult.pendingEscapeTailAnsi) { - // Why last: re-arm the daemon's dangling mid-escape sequence AFTER the - // reset (whose ESC would abort it) so the racing live continuation - // completes it instead of rendering literally (#7329). - writeReplayData(connectResult.pendingEscapeTailAnsi) - } - sendFocusedReattachFocusInAfterReplay() - if (connectResult.coldRestore) { - // Snapshot superseded the cold-restore payload — ack it so the - // daemon does not redeliver it on the next reattach. - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } - } else if (connectResult?.replay) { - rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) - // Relay replay holds the last 100 KB of raw output. The xterm may - // already hold pre-disconnect content; clear first to avoid - // duplication. The reattach reset clears renderer-owned state without - // tearing down the still-running TUI's live modes. - writeReplayData('\x1b[2J\x1b[3J\x1b[H') - // Why: raw relay replay contains the application's own kitty pushes - // when they fall inside the retained window; re-arm the mirror with - // replay (set) semantics so redelivery cannot grow the stack. - kittyKeyboardModes.scanReplay(connectResult.replay) - writeReplayData(connectResult.replay) - writeReplayData(reattachReplayResetSequence(connectResult.replay)) - sendFocusedReattachFocusInAfterReplay() - if (connectResult.coldRestore) { - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } - } else if (connectResult?.coldRestore) { - // replayIntoTerminal: the recorded scrollback is raw PTY output that - // may contain query sequences the previous agent CLI emitted; - // writing them through xterm.write would trigger auto-replies that - // land in the new shell's stdin. See replay-guard.ts. - writeReplayData(connectResult.coldRestore.scrollback) - const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() - const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) - if (didPrepareResume) { - if (preparedStartup?.hasSleepingRecord) { - showSessionRestoredBanner() - } - clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) - } - // Cold-restore means the daemon lost the session and spawned a - // fresh shell — no TUI is consuming the mode-setting bytes that a - // crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so - // reset them to match the fresh shell's expectations. - writeReplayData(POST_REPLAY_MODE_RESET) - // Why: the dead run's scrollback was never scanned, and any kitty - // flags it pushed died with it — the fresh shell starts at zero. - kittyKeyboardModes.reset() - consumeRestoredViewportBlankingMarker() - writeFreshShellViewportBlanking() - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - if (didPrepareResume && !coldRestoreStartup) { - schedulePendingStartupCommandDelivery() + } else if (isCurrentReattachPayload() && !isRemoteRuntimePtyId(reattachPtyId)) { + window.api.pty.signal(reattachPtyId, 'SIGWINCH') } } - // Why: when a mobile-fit override is active, skip sending desktop dims - // to the PTY — the PTY is already at phone dimensions and must stay there. - const reattachPtyId = transport.getPtyId() - if (!reattachPtyId || !getFitOverrideForPty(reattachPtyId)) { - safeFit(pane) - const reattachCols = pane.terminal.cols - const reattachRows = pane.terminal.rows - if (reattachCols > 0 && reattachRows > 0) { - transport.resize(reattachCols, reattachRows) - } + if (hasStructuralReplay) { + await structuralReplayCoordinator.run(applyReattachPayload, { + shouldRestore: isCurrentReattachPayload, + afterRestore: fitAfterReattachRestore + }) + } else { + await applyReattachPayload() + await fitAfterReattachRestore() } - // Why: POSIX only delivers SIGWINCH when terminal dimensions actually - // change. Sending it explicitly guarantees restored TUIs repaint at - // the correct cursor position after snapshot replay. - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.signal(ptyId, 'SIGWINCH') + if (!isCurrentReattachPayload() || !reattachPayloadApplied) { + return false } scheduleReattachIdleAgentCursorReset() @@ -7097,7 +7618,14 @@ export function connectPanePty( const coldRestoreStartup = buildColdRestoreAgentResumeStartup() clearPaneMode2031State() clearHiddenOutputRestoreState() - beginReattachLiveDataDeferral() + const outputCallbacks = captureTransportOutputCallbacks((message) => { + if (isSshSessionExpiredError(message)) { + expiredReattachError = true + return + } + reportError(message) + }) + beginReattachLiveDataDeferral(outputCallbacks.generation) transportConnectInFlightSince = Date.now() const reattachPromise = transport.connect({ url: '', @@ -7116,17 +7644,7 @@ export function connectPanePty( : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: { - onData: dataCallback, - onReplayData: replayDataCallback, - onError: (message) => { - if (isSshSessionExpiredError(message)) { - expiredReattachError = true - return - } - reportError(message) - } - } + callbacks: outputCallbacks.callbacks }) void Promise.resolve(reattachPromise) .catch(() => null) @@ -7135,6 +7653,14 @@ export function connectPanePty( }) void Promise.resolve(reattachPromise) .then(async (result) => { + if (outputCallbacks.generation !== transportStreamGeneration) { + finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) + } + return + } console.warn( `[pty-connection] Reattach result for tab=${deps.tabId}:`, result @@ -7145,7 +7671,7 @@ export function connectPanePty( : 'undefined' ) if (!result && expiredReattachError) { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) @@ -7160,23 +7686,38 @@ export function connectPanePty( }) return } - const accepted = handleReattachResult(result, pendingSessionId, coldRestoreStartup) - finishReattachLiveDataDeferral(accepted) + const accepted = await handleReattachResult( + result, + pendingSessionId, + coldRestoreStartup, + outputCallbacks.generation + ) + finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { if (!isRemoteRuntimePtyId(pendingSessionId)) { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + const settledPtyId = + result && typeof result === 'object' && 'id' in result + ? result.id + : (transport.getPtyId() ?? pendingSessionId) + const hasRestorePayload = + result && + typeof result === 'object' && + ('snapshot' in result || 'replay' in result || 'coldRestore' in result) + await (hasRestorePayload + ? settlePaneSerializerAfterReplay(settledPtyId, gen) + : window.api.pty.settlePaneSerializer(cacheKey, gen)) } } }) .catch(async (err) => { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) } console.warn(`[pty-connection] Reattach FAILED for tab=${deps.tabId}:`, err) - if (disposed) { + if (disposed || outputCallbacks.generation !== transportStreamGeneration) { return } if (isSshSessionExpiredError(err)) { @@ -7293,7 +7834,14 @@ export function connectPanePty( let expiredReattachError = false const coldRestoreStartup = buildColdRestoreAgentResumeStartup() - beginReattachLiveDataDeferral() + const outputCallbacks = captureTransportOutputCallbacks((message) => { + if (isSshSessionExpiredError(message)) { + expiredReattachError = true + return + } + reportError(message) + }) + beginReattachLiveDataDeferral(outputCallbacks.generation) transportConnectInFlightSince = Date.now() const reattachPromise = transport.connect({ url: '', @@ -7310,17 +7858,7 @@ export function connectPanePty( ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: { - onData: dataCallback, - onReplayData: replayDataCallback, - onError: (message) => { - if (isSshSessionExpiredError(message)) { - expiredReattachError = true - return - } - reportError(message) - } - } + callbacks: outputCallbacks.callbacks }) void Promise.resolve(reattachPromise) @@ -7330,8 +7868,16 @@ export function connectPanePty( }) void Promise.resolve(reattachPromise) .then(async (result) => { + if (outputCallbacks.generation !== transportStreamGeneration) { + finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) + } + return + } if (!result && expiredReattachError) { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) @@ -7346,26 +7892,40 @@ export function connectPanePty( }) return } - const accepted = handleReattachResult( + const accepted = await handleReattachResult( result, deferredReattachSessionId, - coldRestoreStartup + coldRestoreStartup, + outputCallbacks.generation ) - finishReattachLiveDataDeferral(accepted) + finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { if (!isRemoteRuntimePtyId(deferredReattachSessionId)) { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + const settledPtyId = + result && typeof result === 'object' && 'id' in result + ? result.id + : (transport.getPtyId() ?? deferredReattachSessionId) + const hasRestorePayload = + result && + typeof result === 'object' && + ('snapshot' in result || 'replay' in result || 'coldRestore' in result) + await (hasRestorePayload + ? settlePaneSerializerAfterReplay(settledPtyId, gen) + : window.api.pty.settlePaneSerializer(cacheKey, gen)) } } }) .catch(async (err) => { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) } const message = err instanceof Error ? err.message : String(err) + if (outputCallbacks.generation !== transportStreamGeneration) { + return + } warnTerminalLifecycleAnomaly('restored PTY reattach threw', { tabId: deps.tabId, worktreeId: deps.worktreeId, @@ -7409,22 +7969,19 @@ export function connectPanePty( try { clearPaneMode2031State() clearHiddenOutputRestoreState() + const outputCallbacks = captureTransportOutputCallbacks(reportError) transport.attach({ existingPtyId: attachPtyId, cols, rows, - callbacks: { - onData: dataCallback, - onReplayData: replayDataCallback, - onError: reportError - } + callbacks: outputCallbacks.callbacks }) const attachedPtyId = transport.getPtyId() ?? attachPtyId bindActivePanePty(attachedPtyId, { updateTabPtyId: 'if-missing', sampleVisibleForegroundAgent: true }) - if (attachPtyId === eagerLivePtyId) { + if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) { registerPaneSerializerFor(attachedPtyId) } } catch (err) { @@ -7465,15 +8022,12 @@ export function connectPanePty( } clearPaneMode2031State() clearHiddenOutputRestoreState() + const outputCallbacks = captureTransportOutputCallbacks(reportError) transport.attach({ existingPtyId: spawnedPtyId, cols, rows, - callbacks: { - onData: dataCallback, - onReplayData: replayDataCallback, - onError: reportError - } + callbacks: outputCallbacks.callbacks }) const attachedPtyId = transport.getPtyId() ?? spawnedPtyId // Why: this path reuses a PTY spawned by an earlier mount, so no @@ -7661,10 +8215,16 @@ export function connectPanePty( reconcileIfSessionMissing, dispose() { disposed = true + cancelPendingSafeFitContinuations(pane) + pendingHiddenSnapshotFit = null + pendingReattachFit = null // A normal park/reconnect/remount does not advance the recovery epoch; // invalidate this concrete xterm so its delayed retry cannot hit the next. terminalRecoveryInstance.unregister() unregisterUndeliverableWriteHandler() + cancelHiddenOutputSnapshotScrollRestore() + structuralReplayCoordinator.dispose() + cancelFreshSpawnFollowReset() // Why: the post-spawn reconcile polls across frames; cancel its pending // rAF so a torn-down pane cannot keep fitting/resizing after disposal. ptySizeReconcileHandle?.cancel() diff --git a/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts b/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts index 3b22b2d9acf..59286263364 100644 --- a/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts @@ -15,7 +15,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 120, rows: 30 })), forwardResize @@ -34,7 +34,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 82, rows: 30 })), forwardResize @@ -53,8 +53,9 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(() => { + fitAndRun: vi.fn((continuation) => { calls.push('fit') + continuation() }), getTerminalDimensions: vi.fn(() => { calls.push('measure') @@ -81,8 +82,9 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(() => { + fitAndRun: vi.fn((continuation) => { forwardResize(82, 30) + continuation() }), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 82, rows: 30 })), @@ -97,14 +99,14 @@ describe('createPtySizeReassertion', () => { }) it('can verify current dimensions without fitting again', async () => { - const fit = vi.fn() + const fitAndRun = vi.fn((continuation: () => void) => continuation()) const forwardResize = vi.fn() const reassertion = createPtySizeReassertion({ isDisposed: () => false, getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit, + fitAndRun, getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 120, rows: 30 })), forwardResize @@ -113,7 +115,7 @@ describe('createPtySizeReassertion', () => { reassertion.request({ fit: false }) await flushAsyncTicks() - expect(fit).not.toHaveBeenCalled() + expect(fitAndRun).not.toHaveBeenCalled() expect(forwardResize).toHaveBeenCalledWith(82, 30) }) @@ -124,7 +126,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'remote:terminal-1', isRemotePtyId: () => true, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize, forwardResize: vi.fn() @@ -134,7 +136,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => true, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize, forwardResize: vi.fn() @@ -164,7 +166,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize, forwardResize @@ -201,7 +203,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: targetCols, rows: 40 }), getAppliedSize, forwardResize @@ -240,7 +242,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -273,7 +275,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -313,7 +315,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -346,7 +348,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -369,7 +371,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => { throw new Error('unavailable') diff --git a/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts b/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts index 7f127b67083..6c5db87d04c 100644 --- a/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts +++ b/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts @@ -5,7 +5,7 @@ export type PtySizeReassertionOptions = { getPtyId: () => string | null isRemotePtyId: (ptyId: string) => boolean shouldSuppressDesktopResize: () => boolean - fit: () => void + fitAndRun: (continuation: () => void) => void getTerminalDimensions: () => PtySizeReassertionDimensions getAppliedSize: (ptyId: string) => Promise forwardResize: (cols: number, rows: number) => void @@ -46,7 +46,8 @@ export function createPtySizeReassertion(options: PtySizeReassertionOptions): Pt return } if (shouldFit) { - options.fit() + options.fitAndRun(() => run(false)) + return } const target = options.getTerminalDimensions() if (!dimensionsAreUsable(target)) { diff --git a/src/renderer/src/components/terminal-pane/replay-guard.test.ts b/src/renderer/src/components/terminal-pane/replay-guard.test.ts index 8f5d99ecbcb..beddf35e1a1 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.test.ts @@ -4,6 +4,7 @@ import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync, + waitForTerminalReplayWritesParsed, type ReplayingPanesRef } from './replay-guard' import { configureLazyArabicShapingJoiner } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' @@ -418,6 +419,27 @@ describe('replay-guard', () => { }) describe('replay-guard stall handling (probe-certified release)', () => { + it('waits for the FIFO replay sentinel without releasing on elapsed time', async () => { + vi.useFakeTimers() + const { terminal } = makeFakePane(1) + let resolved = false + + void waitForTerminalReplayWritesParsed(terminal, { stallCheckMs: 1_000 }).then(() => { + resolved = true + }) + expect(terminal.lastData).toEqual(['']) + + vi.advanceTimersByTime(1_000) + expect(terminal.lastData).toEqual(['', '']) + expect(resolved).toBe(false) + vi.advanceTimersByTime(60_000) + expect(resolved).toBe(false) + + terminal.flush() + await Promise.resolve() + expect(resolved).toBe(true) + }) + it('HOLDS the guard while a slow replay is still parsing — a probe is queued, never a blind release', () => { // Why this is the load-bearing safety test: a time-based release here // would leak xterm auto-replies into the shell (and a leaked ESC into an diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index 1e1042de088..6711390d851 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -247,3 +247,49 @@ export function replayIntoTerminalAsync( }) }) } + +/** Resolves after every replay write already queued on this terminal has + * parsed. A delayed FIFO probe covers a lost sentinel callback without ever + * treating elapsed time alone as proof that parsing finished. */ +export function waitForTerminalReplayWritesParsed( + terminal: ReplayGuardWriteTarget, + options: Pick = {} +): Promise { + return new Promise((resolve) => { + let finished = false + let stallTimer: ReturnType | null = null + const finish = (): void => { + if (finished) { + return + } + finished = true + if (stallTimer !== null) { + clearTimeout(stallTimer) + stallTimer = null + } + resolve() + } + const queueProbe = (): void => { + if (finished) { + return + } + try { + // Why: an empty write is FIFO with earlier replay bytes. Its callback + // can recover a lost sentinel callback without changing parser state. + terminal.write('', finish) + } catch { + // A disposed terminal cannot parse any remaining replay bytes. + finish() + } + } + stallTimer = setTimeout(queueProbe, options.stallCheckMs ?? REPLAY_GUARD_STALL_CHECK_MS) + try { + // Why empty: pendingEscapeTailAnsi must remain the final replay bytes; + // xterm still orders this completion after every earlier write. + terminal.write('', finish) + } catch { + // A disposed terminal cannot parse any remaining replay bytes. + finish() + } + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index d1af2c9f1af..cf92bca5c65 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -10,7 +10,7 @@ import { } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' import { guardParserHandler } from './terminal-parser-handler-guard' -import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' import { normalizeTerminalFastScrollSensitivity, normalizeTerminalScrollSensitivity, @@ -300,21 +300,26 @@ export function applyTerminalAppearance( // separate hook and lets live toggles (settings change, font swap) // land immediately. manager.setPaneLigaturesEnabled(pane.id, ligaturesEnabled) - try { - const state = captureScrollState(pane.terminal) - safeFit(pane) - restoreScrollState(pane.terminal, state) - } catch { - /* ignore */ - } const transport = paneTransports.get(pane.id) // Why: skip PTY resize when a mobile-fit override is active — the PTY // is already at the correct phone dimensions and must not be resized // back to desktop dimensions by an appearance change. const appearancePtyId = transport?.getPtyId() if (transport?.isConnected() && (!appearancePtyId || !getFitOverrideForPty(appearancePtyId))) { - transport.resize(pane.terminal.cols, pane.terminal.rows) maybePushMode2031Flip(pane.id, appearance.mode, transport, paneMode2031, paneLastThemeMode) + safeFitAndThen(pane, 'appearance-pty-resize', () => { + const currentTransport = paneTransports.get(pane.id) + if ( + currentTransport !== transport || + !transport.isConnected() || + transport.getPtyId() !== appearancePtyId + ) { + return + } + transport.resize(pane.terminal.cols, pane.terminal.rows) + }) + } else { + safeFit(pane) } } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index dbd2057e309..578f225ac3b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -1232,6 +1232,16 @@ describe('createFilePathLinkProvider range bounds', () => { expect(shellPathExists).toHaveBeenCalled() }) + it('does not invoke the xterm callback twice when the callback throws', async () => { + const { provider } = createProviderSetup([makeBufferLine('CLAUDE.md')]) + const callback = vi.fn(() => { + throw new Error('terminal was disposed') + }) + + provider.provideLinks(1, callback) + await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)) + }) + it('shows switch and external-open hint for known worktree root hover', async () => { setPlatform('Macintosh') storeState.worktreesByRepo = { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 8ca9fe0bef1..78e50b97940 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -215,29 +215,33 @@ export function createFilePathLinkProvider( ) ) ) - .then((resolvedLinks) => { - const latestFingerprints = new Set( - buildCandidateLogicalLinesForBufferPosition(buffer, bufferLineNumber).map( - (logicalLine) => logicalLine.fingerprint + .then( + (resolvedLinks) => { + const latestFingerprints = new Set( + buildCandidateLogicalLinesForBufferPosition(buffer, bufferLineNumber).map( + (logicalLine) => logicalLine.fingerprint + ) ) - ) - const providedLinks = resolvedLinks.filter( - (link): link is ProvidedFileLink => link !== null - ) - const links = preferLongestNonOverlappingLinks(providedLinks) - .filter(({ logicalLine }) => latestFingerprints.has(logicalLine.fingerprint)) - .map(({ link }) => link) - if (providedLinks.length > 0 && links.length === 0) { - return + const providedLinks = resolvedLinks.filter( + (link): link is ProvidedFileLink => link !== null + ) + const links = preferLongestNonOverlappingLinks(providedLinks) + .filter(({ logicalLine }) => latestFingerprints.has(logicalLine.fingerprint)) + .map(({ link }) => link) + if (providedLinks.length > 0 && links.length === 0) { + return + } + callback(links.length > 0 ? links : undefined) + }, + () => { + // Why: remote probes reject during SSH teardown; using the rejection + // arm avoids treating a consumer callback failure as a probe failure. + callback(undefined) } - callback(links.length > 0 ? links : undefined) - }) + ) .catch(() => { - // Why: remote path-existence probes reject with "Remote connection - // dropped/reconnecting" during SSH teardown. Without a catch the - // rejected Promise.all is unhandled and the crash-breadcrumb buffer - // retains it, growing the renderer heap until it crashes (#8260). - callback(undefined) + // Link discovery is best-effort; a stale xterm callback must not + // recreate the unhandled rejection this path is meant to contain. }) } } diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index 2377b5e78db..00c73ae9be1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -51,7 +51,11 @@ vi.mock('./pty-pre-handler-buffer', () => ({ discardPreHandlerPtyState: (ptyId: string) => consumePreHandlerPtyState(ptyId) })) -type CloseTerminalTabOptions = { onClosed?: () => void; onCancel?: () => void } +type CloseTerminalTabOptions = { + captureRecentlyClosed?: boolean + onClosed?: () => void + onCancel?: () => void +} const closeTerminalTab = vi.fn() vi.mock('../terminal/terminal-tab-actions', () => ({ closeTerminalTab: (tabId: string, options?: CloseTerminalTabOptions) => @@ -276,6 +280,7 @@ describe('terminal-parked-tab-watchers', () => { expect(consumePreHandlerPtyState).not.toHaveBeenCalled() expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) const options = closeTerminalTab.mock.calls[0]?.[1] as CloseTerminalTabOptions + expect(options.captureRecentlyClosed).toBe(false) options.onClosed?.() expect(consumePreHandlerPtyState).toHaveBeenCalledWith(PTY_ID) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index a797ed06b78..5926cc8c153 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -196,6 +196,9 @@ function startParkedTabWatchers( disposersByPtyId.get(ptyId)?.() disposersByPtyId.delete(ptyId) closeTerminalTab(tab.id, { + // Why: this autonomous PTY exit still needs the parked pinned-tab + // confirmation flow, but it must not become user reopen history. + captureRecentlyClosed: false, onClosed: () => { discardPreHandlerPtyState(ptyId) const entry = parkedWatchersByTabId.get(tab.id) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index b42304cb851..29d9f27d287 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -13,7 +13,8 @@ vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ requestTerminalBacklogRecovery: vi.fn() })) vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ - enforceTerminalCurrentScrollIntent: vi.fn() + enforceTerminalCurrentScrollIntent: vi.fn(), + syncTerminalScrollIntentFromViewport: vi.fn() })) vi.mock('./pane-helpers', () => ({ fitAndFocusPanes: vi.fn(), @@ -26,6 +27,11 @@ vi.mock('./terminal-webgl-atlas-recovery', () => ({ // the terminal-output debounce (which a background stream could otherwise defer). scheduleTabRevealWebglAtlasRecovery: () => scheduleTabRevealWebglAtlasRecovery() })) +const resetTerminalLinkifierHoverState = vi.fn() +vi.mock('@/lib/pane-manager/terminal-linkifier-hover-reset', () => ({ + resetTerminalLinkifierHoverState: (terminal: unknown) => + resetTerminalLinkifierHoverState(terminal) +})) type FakeManager = { getPanes: ReturnType @@ -73,6 +79,34 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) + it('captures native trim movement before enforcing viewport intent', async () => { + const terminal = { name: 'trimmed-terminal' } + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal }]) + const { enforceTerminalCurrentScrollIntent, syncTerminalScrollIntentFromViewport } = vi.mocked( + await import('@/lib/pane-manager/terminal-scroll-intent') + ) + + resumeTerminalVisibility(resumeArgs(manager, true)) + + expect(syncTerminalScrollIntentFromViewport).toHaveBeenCalledWith(terminal) + expect(syncTerminalScrollIntentFromViewport.mock.invocationCallOrder[0]).toBeLessThan( + enforceTerminalCurrentScrollIntent.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + + it('resets each pane linkifier hover cache on reveal so links recover without a scroll', () => { + const first = { name: 'pane-a' } + const second = { name: 'pane-b' } + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal: first }, { terminal: second }]) + + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(resetTerminalLinkifierHoverState).toHaveBeenCalledWith(first) + expect(resetTerminalLinkifierHoverState).toHaveBeenCalledWith(second) + }) + it('schedules the repaint after rendering resumes on a heavy reveal', () => { const order: string[] = [] const manager = createManager(order) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index d1a0cc9e20f..c330efb37e4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -5,7 +5,11 @@ import { flushTerminalOutput, requestTerminalBacklogRecovery } from '@/lib/pane-manager/pane-terminal-output-scheduler' -import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent' +import { + enforceTerminalCurrentScrollIntent, + syncTerminalScrollIntentFromViewport +} from '@/lib/pane-manager/terminal-scroll-intent' +import { resetTerminalLinkifierHoverState } from '@/lib/pane-manager/terminal-linkifier-hover-reset' import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' @@ -51,6 +55,13 @@ export function resumeTerminalVisibility({ captureViewportPositions, withSuppressedScrollTracking }: ResumeTerminalVisibilityArgs): void { + // Why: hiding the surface fired mouseleave, which cleared xterm's current + // link but left its hover cell cache; without this reset a link stays dead + // until a scroll when the pointer returns to the same cell on reveal. + for (const pane of manager.getPanes()) { + resetTerminalLinkifierHoverState(pane.terminal) + } + syncTerminalViewportIntents(manager) // Why: WebGL resume can disturb xterm's viewport bookkeeping before the // post-resume fit runs. Capture numeric viewport positions first; the // restore path avoids content matching so duplicate agent log lines do @@ -133,6 +144,7 @@ export function recoverVisibleTerminalWindowWake({ requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS }) } + syncTerminalViewportIntents(manager) manager.resumeRendering() if (isActive) { fitAndFocusPanes(manager) @@ -170,6 +182,7 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) } + syncTerminalViewportIntents(manager) // Resume WebGL immediately so the terminal shows its last-known state // on the first painted frame. macOS context creation is ~5 ms; on // Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume @@ -190,3 +203,11 @@ function enforceTerminalViewportIntents(manager: PaneManager): void { enforceTerminalCurrentScrollIntent(pane.terminal) } } + +function syncTerminalViewportIntents(manager: PaneManager): void { + for (const pane of manager.getPanes()) { + // Why: native scrollback trimming moves a pinned viewport content-stably. + // Capture that live position before resume/fit can disturb it. + syncTerminalScrollIntentFromViewport(pane.terminal) + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 4587aac9354..de537af3d91 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ getTerminalOutputEpoch: vi.fn(() => 0), handleTerminalFileDrop: vi.fn(), enforceTerminalCurrentScrollIntent: vi.fn(), + syncTerminalScrollIntentFromViewport: vi.fn(), pasteTerminalText: vi.fn(), recordTerminalUserInputForLeaf: vi.fn(), requestTerminalBacklogRecovery: vi.fn(), @@ -79,7 +80,8 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({ })) vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ - enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent + enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent, + syncTerminalScrollIntentFromViewport: mocks.syncTerminalScrollIntentFromViewport })) vi.mock('./terminal-drop-handler', () => ({ 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 c2c355a8e9b..e1fe139d445 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 @@ -106,10 +106,8 @@ import { getConnectionId } from '@/lib/connection-context' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' -import { - markTerminalPinnedViewport, - syncTerminalScrollIntentSoon -} from '@/lib/pane-manager/terminal-scroll-intent' +import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent' +import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers' import { e2eConfig } from '@/lib/e2e-config' @@ -1024,11 +1022,20 @@ export function useTerminalPaneLifecycle({ } if (e.type === 'keydown') { + const shouldSyncCurrentTerminal = (): boolean => + managerRef.current + ?.getPanes() + .some((candidate) => candidate.terminal === pane.terminal) === true if (e.key === 'PageUp' || e.key === 'Home') { markTerminalPinnedViewport(pane.terminal) - syncTerminalScrollIntentSoon(pane.terminal, { preservePinnedAtBottom: true }) + syncTerminalScrollIntentSoon(pane.terminal, { + preservePinnedAtBottom: true, + shouldSync: shouldSyncCurrentTerminal + }) } else if (e.key === 'PageDown' || e.key === 'End') { - syncTerminalScrollIntentSoon(pane.terminal) + syncTerminalScrollIntentSoon(pane.terminal, { + shouldSync: shouldSyncCurrentTerminal + }) } } diff --git a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts index da714e9bfe4..1d2bd6c22a0 100644 --- a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts +++ b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { dispatchZoomLevelChanged } from '@/lib/zoom-events' -import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { getPaneOwnedActiveHelperTextarea } from './regular-terminal-focus-ownership' type FontZoomDeps = { @@ -57,13 +57,7 @@ export function useTerminalFontZoom({ } pane.terminal.options.fontSize = nextSize - try { - const state = captureScrollState(pane.terminal) - safeFit(pane) - restoreScrollState(pane.terminal, state) - } catch { - /* ignore */ - } + safeFit(pane) const percent = Math.round((nextSize / globalSize) * 100) dispatchZoomLevelChanged('terminal', percent) diff --git a/src/renderer/src/components/terminal/close-local-terminal-tab-state.ts b/src/renderer/src/components/terminal/close-local-terminal-tab-state.ts index 80cb239a57b..3dd4aecc89e 100644 --- a/src/renderer/src/components/terminal/close-local-terminal-tab-state.ts +++ b/src/renderer/src/components/terminal/close-local-terminal-tab-state.ts @@ -8,6 +8,7 @@ export function closeLocalTerminalTabState( terminalTabId: string, options?: { reason?: TerminalTabCloseReason + captureRecentlyClosed?: boolean remoteCloseOwnedByHost?: boolean localPtyTeardownOwnedExternally?: boolean precomputedRetirementPlan?: TerminalTabRetirementPlan @@ -20,6 +21,7 @@ export function closeLocalTerminalTabState( ) { if ( options?.reason || + options?.captureRecentlyClosed !== undefined || options?.remoteCloseOwnedByHost || options?.localPtyTeardownOwnedExternally || options?.precomputedRetirementPlan diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index 718db6be2c1..7c7bb511efc 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -42,9 +42,9 @@ vi.mock('@/runtime/web-session-tabs-sync', () => ({ import { closeOtherTerminalTabs, closeTerminalTab, - closeTerminalTabsToRight, - createNewTerminalTab + closeTerminalTabsToRight } from './terminal-tab-actions' +import { createNewTerminalTab } from './terminal-tab-create' describe('createNewTerminalTab', () => { beforeEach(() => { @@ -442,6 +442,27 @@ describe('closeTerminalTab', () => { expect(onCancel).toHaveBeenCalledTimes(1) }) + it('rejects a pinned background lifecycle close without opening a confirmation modal', () => { + const requestPinnedTabCloseConfirm = vi.fn() + const closeUnifiedTab = vi.fn() + const onClosed = vi.fn() + const onCancel = vi.fn() + getStateMock.mockReturnValue( + makePinnedTabState({ + confirmClosePinnedTab: true, + requestPinnedTabCloseConfirm, + closeUnifiedTab + }) + ) + + closeTerminalTab('pinned-entity-1', { rejectPinned: true, onClosed, onCancel }) + + expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled() + expect(closeUnifiedTab).not.toHaveBeenCalled() + expect(onClosed).not.toHaveBeenCalled() + expect(onCancel).toHaveBeenCalledTimes(1) + }) + it('guards a pinned tab closed by its unified id (workspace overlay path)', () => { const requestPinnedTabCloseConfirm = vi.fn() const closeUnifiedTab = vi.fn() @@ -480,6 +501,69 @@ describe('closeTerminalTab', () => { expect(closeTab).toHaveBeenCalledWith('pinned-entity-1', { reason: undefined }) expect(closeUnifiedTab).not.toHaveBeenCalled() }) + + it('threads the PTY-exit reason through to closeTab', () => { + const closeTab = vi.fn() + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1' }, { id: 'tab-2' }] + }, + unifiedTabsByWorktree: {}, + activeWorktreeId: 'wt-1', + activeTabId: 'tab-2', + openFiles: [], + browserTabsByWorktree: {}, + closeTab, + setActiveTab: vi.fn() + }) + + // Why: the legacy no-layout surface routes pty exits through + // closeTerminalTab; a self-exited shell must not join the reopen stack. + closeTerminalTab('tab-1', { reason: 'pty-exit' }) + + expect(closeTab).toHaveBeenCalledWith('tab-1', { reason: 'pty-exit' }) + }) + + it('threads parked-exit history suppression through to closeTab', () => { + const closeTab = vi.fn() + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }, { id: 'tab-2' }] }, + unifiedTabsByWorktree: {}, + activeWorktreeId: 'wt-1', + activeTabId: 'tab-2', + openFiles: [], + browserTabsByWorktree: {}, + closeTab, + setActiveTab: vi.fn() + }) + + closeTerminalTab('tab-1', { captureRecentlyClosed: false }) + + expect(closeTab).toHaveBeenCalledWith('tab-1', { captureRecentlyClosed: false }) + }) + + it('keeps the plain user-close call shape when no close options are given', () => { + const closeTab = vi.fn() + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1' }, { id: 'tab-2' }] + }, + unifiedTabsByWorktree: {}, + activeWorktreeId: 'wt-1', + activeTabId: 'tab-2', + openFiles: [], + browserTabsByWorktree: {}, + closeTab, + setActiveTab: vi.fn() + }) + + closeTerminalTab('tab-1') + + expect(closeTab).toHaveBeenCalledWith('tab-1') + }) }) describe('closeOtherTerminalTabs', () => { diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index c8ef7d96abd..ebd861e6952 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -5,7 +5,6 @@ import { reconcileTabOrder } from '../tab-bar/reconcile-order' import { activateWebRuntimeSessionTab, closeWebRuntimeSessionTab, - createWebRuntimeSessionTerminal, isWebRuntimeSessionActive, toHostSessionTabId } from '@/runtime/web-runtime-session' @@ -46,61 +45,13 @@ function isPinnedVisibleTab( ) } -export function createNewTerminalTab( - activeWorktreeId: string | null, - shellOverride?: string, - options?: { startupCwd?: string } -): void { - if (!activeWorktreeId) { - return - } - const state = useAppStore.getState() - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId) - if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { - // Why: paired web clients receive host-owned terminal tabs through - // session.tabs. Creating a local tab first races the host snapshot and can - // leave stale remote handles in the web store. - void createWebRuntimeSessionTerminal({ - worktreeId: activeWorktreeId, - environmentId: runtimeEnvironmentId, - command: shellOverride, - ...(options?.startupCwd ? { cwd: options.startupCwd } : {}), - activate: true - }) - return - } - const newTab = state.createTab( - activeWorktreeId, - undefined, - shellOverride, - options?.startupCwd ? { startupCwd: options.startupCwd } : undefined - ) - state.setActiveTabType('terminal') - // Why: persist the tab bar order with the new terminal at the end of the - // current visual order. Without this, reconcileTabOrder falls back to - // terminals-first when tabBarOrderByWorktree is unset, causing a new - // terminal to jump to index 0 instead of appending after editor tabs. - const freshState = useAppStore.getState() - const termIds = (freshState.tabsByWorktree[activeWorktreeId] ?? []).map((t) => t.id) - const editorIds = freshState.openFiles - .filter((f) => f.worktreeId === activeWorktreeId) - .map((f) => f.id) - const base = reconcileTabOrder( - freshState.tabBarOrderByWorktree[activeWorktreeId], - termIds, - editorIds - ) - // The new tab is already in base via termIds; move it to the end - const order = base.filter((id) => id !== newTab.id) - order.push(newTab.id) - state.setTabBarOrder(activeWorktreeId, order) -} - export function closeTerminalTab( tabId: string, options?: { force?: boolean + rejectPinned?: boolean reason?: TerminalTabCloseReason + captureRecentlyClosed?: boolean localPtyTeardownOwnedExternally?: boolean precomputedRetirementPlan?: TerminalTabRetirementPlan precomputedCloseState?: PrecomputedTerminalCloseState @@ -128,6 +79,12 @@ export function closeTerminalTab( !options?.force && isPinnedVisibleTab(state, owningWorktreeId, terminalTabId) ) { + // Why: background lifecycle callers cannot safely wait on a modal whose + // owner may be unattended; reject pinned tabs without bypassing the guard. + if (options?.rejectPinned) { + options.onCancel?.() + return + } guardPinnedTabClose({ isPinned: true, tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId), @@ -158,6 +115,9 @@ export function closeTerminalTab( // host session snapshot catches up. closeLocalTerminalTabState(terminalTabId, { reason: options?.reason, + ...(options?.captureRecentlyClosed !== undefined + ? { captureRecentlyClosed: options.captureRecentlyClosed } + : {}), remoteCloseOwnedByHost: true, ...(options?.localPtyTeardownOwnedExternally ? { localPtyTeardownOwnedExternally: true } @@ -183,6 +143,9 @@ export function closeTerminalTab( if (terminalCountBeforeClose <= 1) { closeLocalTerminalTabState(terminalTabId, { reason: options?.reason, + ...(options?.captureRecentlyClosed !== undefined + ? { captureRecentlyClosed: options.captureRecentlyClosed } + : {}), ...(options?.localPtyTeardownOwnedExternally ? { localPtyTeardownOwnedExternally: true } : {}), @@ -224,6 +187,9 @@ export function closeTerminalTab( closeLocalTerminalTabState(terminalTabId, { reason: options?.reason, + ...(options?.captureRecentlyClosed !== undefined + ? { captureRecentlyClosed: options.captureRecentlyClosed } + : {}), ...(options?.localPtyTeardownOwnedExternally ? { localPtyTeardownOwnedExternally: true } : {}), ...(options?.precomputedRetirementPlan ? { precomputedRetirementPlan: options.precomputedRetirementPlan } diff --git a/src/renderer/src/components/terminal/terminal-tab-create.ts b/src/renderer/src/components/terminal/terminal-tab-create.ts new file mode 100644 index 00000000000..ed54255b3fc --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-tab-create.ts @@ -0,0 +1,57 @@ +import { useAppStore } from '@/store' +import { reconcileTabOrder } from '../tab-bar/reconcile-order' +import { + createWebRuntimeSessionTerminal, + isWebRuntimeSessionActive +} from '@/runtime/web-runtime-session' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' + +export function createNewTerminalTab( + activeWorktreeId: string | null, + shellOverride?: string, + options?: { startupCwd?: string } +): void { + if (!activeWorktreeId) { + return + } + const state = useAppStore.getState() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId) + if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { + // Why: paired web clients receive host-owned terminal tabs through + // session.tabs. Creating a local tab first races the host snapshot and can + // leave stale remote handles in the web store. + void createWebRuntimeSessionTerminal({ + worktreeId: activeWorktreeId, + environmentId: runtimeEnvironmentId, + command: shellOverride, + ...(options?.startupCwd ? { cwd: options.startupCwd } : {}), + activate: true + }) + return + } + const newTab = state.createTab( + activeWorktreeId, + undefined, + shellOverride, + options?.startupCwd ? { startupCwd: options.startupCwd } : undefined + ) + state.setActiveTabType('terminal') + // Why: persist the tab bar order with the new terminal at the end of the + // current visual order. Without this, reconcileTabOrder falls back to + // terminals-first when tabBarOrderByWorktree is unset, causing a new + // terminal to jump to index 0 instead of appending after editor tabs. + const freshState = useAppStore.getState() + const termIds = (freshState.tabsByWorktree[activeWorktreeId] ?? []).map((t) => t.id) + const editorIds = freshState.openFiles + .filter((f) => f.worktreeId === activeWorktreeId) + .map((f) => f.id) + const base = reconcileTabOrder( + freshState.tabBarOrderByWorktree[activeWorktreeId], + termIds, + editorIds + ) + // The new tab is already in base via termIds; move it to the end + const order = base.filter((id) => id !== newTab.id) + order.push(newTab.id) + state.setTabBarOrder(activeWorktreeId, order) +} diff --git a/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts b/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts index ac60d86d2eb..31bdaf65d5e 100644 --- a/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts +++ b/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts @@ -13,14 +13,21 @@ function sourceBetween(startPattern: string, endPattern: string): string { } describe('WorktreeJumpPalette source-context boundaries', () => { - it('resolves typed GitHub issue/PR entries through the lookup repo source host', () => { - expect(source).toContain('buildTaskSourceContextFromRepo') - + it('defers pasted GitHub URL resolution to the composer so cross-project detection runs', () => { + // Why: pasting a cross-project URL must surface the same "Switch project?" + // prompt as Cmd+N. The palette hands the raw URL to the composer's name + // field instead of pre-resolving it against an arbitrary repo, which + // silently linked cross-project items to the wrong project. const githubLinkSection = sourceBetween( - 'void lookupGitHubWorkItemByOwnerRepoForSource({', + '// Case 1: user pasted a GH issue/PR URL.', '// Case 2: user typed a raw issue number.' ) - expect(githubLinkSection).toContain('sourceContext') + expect(githubLinkSection).toContain('prefilledName: trimmed') + expect(githubLinkSection).not.toContain('lookupGitHubWorkItemByOwnerRepoForSource') + }) + + it('resolves typed raw issue/PR numbers through the lookup repo source host', () => { + expect(source).toContain('buildTaskSourceContextFromRepo') const rawNumberSection = sourceBetween( 'void lookupGitHubWorkItemForSource({', diff --git a/src/renderer/src/hooks/installed-agent-skills-change-event.ts b/src/renderer/src/hooks/installed-agent-skills-change-event.ts new file mode 100644 index 00000000000..2e11a17ff03 --- /dev/null +++ b/src/renderer/src/hooks/installed-agent-skills-change-event.ts @@ -0,0 +1 @@ +export const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 51b552f3436..c46bd3ea5c2 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -411,6 +411,36 @@ describe('useComposerState host-context boundaries', () => { ) }) + it('keeps a Linear branch override when its workspace-scoped issue survives a repo change', () => { + const section = sourceBetween( + HOOK_SOURCE, + 'const handleRepoChange = useCallback', + 'const handleFolderSourceRepoChange = useCallback' + ) + + expect(section).toContain('const preservedLinearBranchName = preserveLinearLinkedWorkItem') + expect(section).toContain('getLinearLinkedWorkItemBranchName(linkedWorkItem)') + expect(section).toContain('setBranchNameOverride(preservedLinearBranchName)') + expect(section).toContain( + 'setBranchNameOverridePreservesNameEdits(Boolean(preservedLinearBranchName))' + ) + expect(section).toContain("branchAutoNameRef.current = preservedLinearBranchName ?? ''") + }) + + it('clears a Linear branch override when its linked issue is removed', () => { + const section = sourceBetween( + HOOK_SOURCE, + 'const handleRemoveLinkedWorkItem = useCallback', + 'const handleNameValueChange = useCallback' + ) + + expect(section).toContain('const removedLinearItem = isLinearLinkedWorkItem(linkedWorkItem)') + expect(section).toContain('if (removedLinearItem)') + expect(section).toContain('setBranchNameOverride(undefined)') + expect(section).toContain('setBranchNameOverridePreservesNameEdits(false)') + expect(section).toContain("branchAutoNameRef.current = ''") + }) + it('selects a project by its own host instead of pinning the current host', () => { // Regression: passing the current host as a hard `hostId` made picking a // project set up only on a different host a silent no-op. The current host @@ -445,6 +475,8 @@ describe('useComposerState host-context boundaries', () => { ) expect(githubApply).toContain('setLinkedGitLabIssue(null)') expect(githubApply).toContain('setLinkedGitLabMR(null)') + expect(githubApply).toContain('setBranchNameOverridePreservesNameEdits(false)') + expect(githubApply).toContain("branchAutoNameRef.current = ''") const gitlabApply = sourceBetween( HOOK_SOURCE, @@ -453,6 +485,8 @@ describe('useComposerState host-context boundaries', () => { ) expect(gitlabApply).toContain("setLinkedIssue('')") expect(gitlabApply).toContain('setLinkedPR(null)') + expect(gitlabApply).toContain('setBranchNameOverridePreservesNameEdits(false)') + expect(gitlabApply).toContain("branchAutoNameRef.current = ''") const projectGroupSmartHandlers = sourceBetween( HOOK_SOURCE, diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index eecc7a6c974..aaad1b6a622 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -78,6 +78,7 @@ import { import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { buildLinearIssueLinkedWorkItem, + getLinearLinkedWorkItemBranchName, isLinearLinkedWorkItem } from '@/lib/linear-linked-work-item' import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' @@ -960,6 +961,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [linkedWorkItem, setLinkedWorkItem] = useState( () => linkedWorkItemSeed ) + const initialLinearBranchName = getLinearLinkedWorkItemBranchName(linkedWorkItemSeed) const taskSourceContext = useMemo(() => { if ( persistDraft && @@ -1082,9 +1084,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [compareBaseRef, setCompareBaseRef] = useState( persistDraft ? newWorkspaceDraft?.compareBaseRef : undefined ) - const [branchNameOverride, setBranchNameOverride] = useState(undefined) - const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] = - useState(false) + const [branchNameOverride, setBranchNameOverride] = useState( + initialLinearBranchName + ) + const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] = useState( + Boolean(initialLinearBranchName) + ) const [smartNameMode, setSmartNameMode] = useState('smart') // Why (#5181): when the user picks an existing LOCAL branch, let them reuse it // (check it out) instead of creating a new branch from it. `reuseEligibleBranch` @@ -2084,6 +2089,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } if (!options.preserveBranchNameOverride) { setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + branchAutoNameRef.current = '' } }, [name] @@ -2321,6 +2328,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS lastAutoNameRef.current = nextName } setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + branchAutoNameRef.current = '' }, [name] ) @@ -2348,6 +2357,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const handleRemoveLinkedWorkItem = useCallback((): void => { smartGitHubPrStartPointSelectionRef.current = null + const removedLinearItem = isLinearLinkedWorkItem(linkedWorkItem) setLinkedWorkItem(null) setLinkedIssue('') setLinkedPR(null) @@ -2355,7 +2365,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (name === lastAutoNameRef.current) { lastAutoNameRef.current = '' } - }, [name]) + if (removedLinearItem) { + // Why: a Linear branch override belongs to its linked issue; unlinking + // must not leave provider metadata driving a later worktree create. + setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + branchAutoNameRef.current = '' + } + }, [linkedWorkItem, name]) const handleNameValueChange = useCallback( (nextName: string): void => { @@ -2646,6 +2663,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } } const preserveLinearLinkedWorkItem = isLinearLinkedWorkItem(linkedWorkItem) + const preservedLinearBranchName = preserveLinearLinkedWorkItem + ? getLinearLinkedWorkItemBranchName(linkedWorkItem) + : undefined setRepoId(value) if (!options.preserveStartFrom) { smartGitHubPrStartPointSelectionRef.current = null @@ -2672,10 +2692,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setBaseBranch(undefined) setCompareBaseRef(undefined) setPushTarget(undefined) - setBranchNameOverride(undefined) + // Why: Linear sources are workspace-scoped, so their canonical branch + // survives choosing a different implementation repo with the issue. + setBranchNameOverride(preservedLinearBranchName) + setBranchNameOverridePreservesNameEdits(Boolean(preservedLinearBranchName)) + branchAutoNameRef.current = preservedLinearBranchName ?? '' // Why (#5181): reuse state is branch-scoped, so a repo switch must clear - // it alongside the branch override (matches the other reset paths). - setBranchNameOverridePreservesNameEdits(false) + // it even when a workspace-scoped Linear override is restored. setReuseEligibleBranch(null) setReuseSelectedBranch(false) setForkPushWarning(null) @@ -2929,6 +2952,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } setStartFromResetHint(null) setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) setForkPushWarning(null) branchAutoNameRef.current = '' smartGitHubPrStartPointSelectionRef.current = null @@ -3037,6 +3061,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS applyLinkedGitLabWorkItem(item) setStartFromResetHint(null) setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) setForkPushWarning(null) branchAutoNameRef.current = '' // Why: MR metadata can be sourced from one host/account while the @@ -3208,7 +3233,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setLinkedPR(null) setLinkedGitLabIssue(null) setLinkedGitLabMR(null) - setLinkedWorkItem(buildLinearIssueLinkedWorkItem(issue)) + const linkedLinearIssue = buildLinearIssueLinkedWorkItem(issue) + setLinkedWorkItem(linkedLinearIssue) const suggestedName = getLinearIssueWorkspaceName(issue) // Why: same lookup-text rule as applyLinkedWorkItem, plus the typed // Linear identifier ("STA-123") that matched this issue. @@ -3222,9 +3248,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setName(suggestedName) lastAutoNameRef.current = suggestedName } - setBranchNameOverride(undefined) + const linearBranchName = getLinearLinkedWorkItemBranchName(linkedLinearIssue) + setBranchNameOverride(linearBranchName) + setBranchNameOverridePreservesNameEdits(Boolean(linearBranchName)) setForkPushWarning(null) - branchAutoNameRef.current = '' + branchAutoNameRef.current = linearBranchName ?? '' // Why: match the GitHub issue/PR flow by drafting linked context for // review instead of auto-submitting. Auto-filling the note here would // turn a source selection into user-authored instructions. diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts index e06a8a2a2ee..e19bac655b1 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -7,9 +7,9 @@ import type { } from '../../../shared/skills' import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import { markOrchestrationSetupComplete } from '@/lib/orchestration-setup-state' +import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' import { useMountedRef } from './useMountedRef' -const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' export const GLOBAL_AGENT_SKILL_SOURCE_KINDS = [ 'home' ] as const satisfies readonly SkillSourceKind[] diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index bc0ca7fec92..16f5deaddd8 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -313,6 +313,7 @@ describe('useIpcEvents zoom routing', () => { }, agentStatus: { onSet: () => () => {} }, ui: makeEvents({ + consumePendingOpenSettings: () => Promise.resolve(false), onTerminalZoom: (listener: (direction: 'in' | 'out' | 'reset') => void) => { terminalZoomListenerRef.current = listener return () => {} @@ -455,6 +456,7 @@ describe('useIpcEvents zoom routing', () => { }, agentStatus: { onSet: () => () => {} }, ui: makeEvents({ + consumePendingOpenSettings: () => Promise.resolve(false), onTerminalZoom: (listener: (direction: 'in' | 'out' | 'reset') => void) => { terminalZoomListenerRef.current = listener return () => {} @@ -666,6 +668,7 @@ describe('useIpcEvents rate-limit hydration', () => { }, agentStatus: { onSet: () => () => {} }, ui: makeEvents({ + consumePendingOpenSettings: () => Promise.resolve(false), getZoomLevel: vi.fn(() => 0) }) } @@ -954,6 +957,7 @@ describe('useIpcEvents browser tab create routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -969,6 +973,7 @@ describe('useIpcEvents browser tab create routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -1175,6 +1180,7 @@ describe('useIpcEvents updater integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -1190,6 +1196,7 @@ describe('useIpcEvents updater integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -1293,6 +1300,130 @@ describe('useIpcEvents updater integration', () => { expect(removeSshCredentialRequest).toHaveBeenCalledWith('req-1') }) + it('opens Settings from a Settings intent queued before the listener attached', async () => { + const openSettingsPage = vi.fn() + let onOpenSettingsRegistered = false + + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + effect() + } + } + }) + + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => ({ + openSettingsPage, + fetchRepos: vi.fn(), + fetchWorktrees: vi.fn(), + setActiveView: vi.fn(), + activeModal: null, + closeModal: vi.fn(), + openModal: vi.fn(), + activeWorktreeId: 'wt-1', + activeView: 'terminal', + setActiveRepo: vi.fn(), + setActiveWorktree: vi.fn(), + revealWorktreeInSidebar: vi.fn(), + setIsFullScreen: vi.fn(), + setUpdateStatus: vi.fn(), + setRateLimitsFromPush: vi.fn(), + settings: { terminalFontSize: 13 } + }) + } + })) + + vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom: vi.fn() })) + vi.doMock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn() + })) + vi.doMock('@/components/sidebar/visible-worktrees', () => ({ + getVisibleWorktreeIds: () => [] + })) + vi.doMock('@/lib/editor-font-zoom', () => ({ + nextEditorFontZoomLevel: vi.fn(() => 0), + computeEditorFontSize: vi.fn(() => 13) + })) + vi.doMock('@/components/settings/SettingsConstants', () => ({ + zoomLevelToPercent: vi.fn(() => 100), + ZOOM_MIN: -3, + ZOOM_MAX: 3 + })) + vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() })) + + const makeEvents = (target: Record = {}): Record => + new Proxy(target, { + get: (namespace, prop) => + prop in namespace ? Reflect.get(namespace, prop) : () => () => {} + }) + + vi.stubGlobal('window', { + api: { + repos: makeEvents(), + worktrees: makeEvents(), + keybindings: makeEvents(), + settings: makeEvents(), + browser: makeEvents(), + updater: { + getStatus: () => Promise.resolve({ state: 'idle' }), + onStatus: () => () => {}, + onClearDismissal: () => () => {} + }, + rateLimits: { + get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), + onUpdate: () => () => {} + }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + getTerminalDrivers: () => Promise.resolve([]), + getBrowserDrivers: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {}, + onTerminalDriverChanged: () => () => {}, + onBrowserDriverChanged: () => () => {} + }, + ssh: { + listTargets: () => Promise.resolve([]), + listPortForwards: () => Promise.resolve([]), + listDetectedPorts: () => Promise.resolve([]), + getState: () => Promise.resolve(null), + onStateChanged: () => () => {}, + onCredentialRequest: () => () => {}, + onCredentialResolved: () => () => {}, + onPortForwardsChanged: () => () => {}, + onDetectedPortsChanged: () => () => {} + }, + agentStatus: { onSet: () => () => {} }, + ui: makeEvents({ + onOpenSettings: () => { + onOpenSettingsRegistered = true + return () => {} + }, + // Why: exercise the positive branch — an intent queued before mount is + // pulled once the renderer's onOpenSettings listener is attached. + consumePendingOpenSettings: () => Promise.resolve(true), + getZoomLevel: () => 0, + set: vi.fn() + }) + } + }) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + // Why: the pending-intent pull is a Promise; flush microtasks before asserting. + await Promise.resolve() + await Promise.resolve() + + expect(onOpenSettingsRegistered).toBe(true) + expect(openSettingsPage).toHaveBeenCalledTimes(1) + }) + it('clears stale remote PTYs when an SSH connection fully disconnects', async () => { const clearTabPtyId = vi.fn() const setSshConnectionState = vi.fn() @@ -1419,6 +1550,7 @@ describe('useIpcEvents updater integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -1434,6 +1566,7 @@ describe('useIpcEvents updater integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -1859,6 +1992,7 @@ describe('useIpcEvents updater integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -1918,6 +2052,7 @@ describe('useIpcEvents updater integration', () => { requestTerminalCreateListenerRef.current = listener return () => {} }, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -2684,6 +2819,7 @@ describe('useIpcEvents browser tab close routing', () => { type CloseActiveTabListener = () => void type CloseTerminalListener = (data: { tabId: string; paneRuntimeId?: number | null }) => void type CloseSessionTabListener = (data: { tabId: string; worktreeId: string }) => void + type TerminalTabCloseRequestListener = (data: { requestId: string; tabId: string }) => void async function useIpcEventsForCloseRouting({ closeActiveTabListenerRef, @@ -2691,7 +2827,10 @@ describe('useIpcEvents browser tab close routing', () => { closeTerminalListenerRef, getState, requestTabCloseListenerRef, - replyTabClose = vi.fn() + replyTabClose = vi.fn(), + terminalTabCloseRequestListenerRef, + respondTerminalTabClose = vi.fn(), + persistWorkspaceSession = vi.fn().mockResolvedValue(undefined) }: { closeActiveTabListenerRef?: { current: CloseActiveTabListener | null } closeSessionTabListenerRef?: { current: CloseSessionTabListener | null } @@ -2699,6 +2838,9 @@ describe('useIpcEvents browser tab close routing', () => { getState: () => Record requestTabCloseListenerRef?: { current: RequestTabCloseListener | null } replyTabClose?: ReturnType + terminalTabCloseRequestListenerRef?: { current: TerminalTabCloseRequestListener | null } + respondTerminalTabClose?: ReturnType + persistWorkspaceSession?: ReturnType }): Promise { vi.doMock('react', async () => { const actual = await vi.importActual('react') @@ -2779,6 +2921,12 @@ describe('useIpcEvents browser tab close routing', () => { vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() })) + vi.doMock('@/lib/workspace-session-host-persistence', () => ({ + persistWorkspaceSessionByHost: persistWorkspaceSession + })) + vi.doMock('@/lib/workspace-session', () => ({ + buildWorkspaceSessionPayload: vi.fn(() => ({})) + })) vi.stubGlobal('window', { dispatchEvent: vi.fn(), @@ -2792,6 +2940,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -2807,6 +2956,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -2827,6 +2977,13 @@ describe('useIpcEvents browser tab close routing', () => { } return () => {} }, + onTerminalTabCloseRequest: (listener: TerminalTabCloseRequestListener) => { + if (terminalTabCloseRequestListenerRef) { + terminalTabCloseRequestListenerRef.current = listener + } + return () => {} + }, + respondTerminalTabClose, onSleepWorktree: () => () => {}, onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, @@ -2974,6 +3131,65 @@ describe('useIpcEvents browser tab close routing', () => { expect(closeTerminalTabMock).toHaveBeenCalledWith('terminal-1') }) + it('acknowledges whole-tab close only after the fresh session is durably persisted', async () => { + const listenerRef: { current: TerminalTabCloseRequestListener | null } = { current: null } + let finishPersist!: () => void + const persistWorkspaceSession = vi.fn( + () => + new Promise((resolve) => { + finishPersist = resolve + }) + ) + const respondTerminalTabClose = vi.fn() + closeTerminalTabMock.mockImplementation((_tabId: string, options: { onClosed?: () => void }) => + options.onClosed?.() + ) + await useIpcEventsForCloseRouting({ + getState: () => ({}), + terminalTabCloseRequestListenerRef: listenerRef, + respondTerminalTabClose, + persistWorkspaceSession + }) + + listenerRef.current?.({ requestId: 'close-1', tabId: 'terminal-1' }) + await Promise.resolve() + + expect(closeTerminalTabMock).toHaveBeenCalledWith( + 'terminal-1', + expect.objectContaining({ rejectPinned: true }) + ) + expect(persistWorkspaceSession).toHaveBeenCalledTimes(1) + expect(respondTerminalTabClose).not.toHaveBeenCalled() + + finishPersist() + await vi.waitFor(() => + expect(respondTerminalTabClose).toHaveBeenCalledWith({ requestId: 'close-1' }) + ) + }) + + it('rejects a pinned whole-tab close without persisting or reporting success', async () => { + const listenerRef: { current: TerminalTabCloseRequestListener | null } = { current: null } + const persistWorkspaceSession = vi.fn().mockResolvedValue(undefined) + const respondTerminalTabClose = vi.fn() + closeTerminalTabMock.mockImplementation((_tabId: string, options: { onCancel?: () => void }) => + options.onCancel?.() + ) + await useIpcEventsForCloseRouting({ + getState: () => ({}), + terminalTabCloseRequestListenerRef: listenerRef, + respondTerminalTabClose, + persistWorkspaceSession + }) + + listenerRef.current?.({ requestId: 'close-pinned', tabId: 'terminal-pinned' }) + + expect(persistWorkspaceSession).not.toHaveBeenCalled() + expect(respondTerminalTabClose).toHaveBeenCalledWith({ + requestId: 'close-pinned', + error: 'terminal_tab_pinned' + }) + }) + it('confirms before closing a pinned active browser tab from the native close event', async () => { const closeActiveTabListenerRef: { current: CloseActiveTabListener | null } = { current: null } const closeBrowserTab = vi.fn() @@ -3281,6 +3497,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3296,6 +3513,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3498,6 +3716,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3513,6 +3732,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3710,6 +3930,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3725,6 +3946,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3940,6 +4162,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3964,6 +4187,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { }, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -4196,6 +4420,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -4211,6 +4436,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -4429,6 +4655,7 @@ describe('useIpcEvents agent status snapshot integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -4444,6 +4671,7 @@ describe('useIpcEvents agent status snapshot integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index efec4bae01c..838cf50720e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -14,6 +14,8 @@ import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' +import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' +import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' @@ -96,6 +98,7 @@ import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-seriali import { track } from '@/lib/telemetry' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import type { RuntimeClientEvent } from '../../../shared/runtime-client-events' import type { AppState } from '../store/types' @@ -1212,6 +1215,18 @@ export function useIpcEvents(): void { }) ) + // Why: a tray/menu-bar "Settings…" click can fire before this listener + // attaches on a fresh window; consume any intent queued for us. Guarded + // with `?.` so a stale preload bundle doesn't crash the listener set. + void window.api.ui + .consumePendingOpenSettings?.() + .then((open) => { + if (open) { + useAppStore.getState().openSettingsPage() + } + }) + .catch(() => {}) + unsubs.push( window.api.ui.onOpenSetupGuide?.(() => { useAppStore.getState().openModal('setup-guide', { telemetrySource: 'help_menu' }) @@ -1666,6 +1681,32 @@ export function useIpcEvents(): void { ) ) + // Why: background-mounting a mobile-subscribed tab attaches a PTY that this + // renderer never mounted, without navigating the desktop (STA-1840). + unsubs.push( + window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => { + if (!worktreeId) { + return + } + // Why: synthetic pty handles need persisted-tab resolution, but a miss + // must not mount every saved terminal in a large hidden worktree. + const mount = planMobileTerminalTabMount( + useAppStore.getState(), + { + worktreeId, + ...(tabId ? { tabId } : {}), + ...(ptyId ? { ptyId } : {}) + }, + { + isTabMounted: hasRegisteredRuntimeTerminalTab + } + ) + if (mount) { + requestBackgroundTerminalWorktreeMount(mount) + } + }) + ) + // Why: CLI-driven terminal creation sends a request and waits for the // tabId reply so it can resolve a handle the caller can use immediately. // This mirrors the browser's onRequestTabCreate/replyTabCreate pattern. @@ -1981,6 +2022,40 @@ export function useIpcEvents(): void { }) ) + // Why: during an in-place renderer reload, an older preload can briefly + // remain installed. Keep the new request listener additive at that seam. + if (window.api.ui.onTerminalTabCloseRequest) { + unsubs.push( + window.api.ui.onTerminalTabCloseRequest(({ requestId, tabId }) => { + let responded = false + const respond = (error?: string): void => { + if (responded) { + return + } + responded = true + window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) }) + } + closeTerminalTab(tabId, { + rejectPinned: true, + onCancel: () => respond('terminal_tab_pinned'), + onClosed: () => { + void (async () => { + const state = useAppStore.getState() + await persistWorkspaceSessionByHost( + window.api.session, + buildWorkspaceSessionPayload(state), + state + ) + respond() + })().catch((error: unknown) => { + respond(error instanceof Error ? error.message : 'terminal_tab_close_failed') + }) + } + }) + }) + ) + } + unsubs.push( window.api.ui.onSleepWorktree(({ worktreeId }) => { void runSleepWorktree(worktreeId) @@ -2100,10 +2175,8 @@ export function useIpcEvents(): void { if (getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) { return } - // Why: the guest process can request "open this link in Orca", but it - // does not own Orca's worktree/tab model. Resolve the source page's - // worktree and create a new outer browser tab so the link opens as a - // separate tab in the outer Orca tab bar. + // Why: only the renderer owns Orca's tab model. Creating the tab with + // the default activation behavior brings the clicked link forward. store.createBrowserTab(sourcePage.worktreeId, url, { title: url }) }) ) diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index e39c75db2bf..60ab54ac4c4 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -382,7 +382,8 @@ export function buildSettingsNavigationMetadata({ icon: Palette, searchEntries: getAppearancePaneSearchEntries({ showWarpImport: showDesktopOnlySettings, - showSystemTray: showDesktopOnlySettings && isWindows + showSystemTray: showDesktopOnlySettings && isWindows, + showMenuBarIcon: showDesktopOnlySettings && isMac }), group: 'interface' }, diff --git a/src/renderer/src/hooks/useSkillFreshness.test.tsx b/src/renderer/src/hooks/useSkillFreshness.test.tsx new file mode 100644 index 00000000000..812ad72ffe8 --- /dev/null +++ b/src/renderer/src/hooks/useSkillFreshness.test.tsx @@ -0,0 +1,238 @@ +// @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 type { SkillFreshnessInventory } from '../../../shared/skill-freshness' +import { + _skillFreshnessCacheForTests, + type SkillFreshnessState, + useSkillFreshness +} from './useSkillFreshness' + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (cause: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (cause: unknown) => void + const promise = new Promise((complete, fail) => { + resolve = complete + reject = fail + }) + return { promise, resolve, reject } +} + +function inventory(scannedAt: number, eligibleUpdateNames: string[] = []): SkillFreshnessInventory { + return { schemaVersion: 1, installations: [], eligibleUpdateNames, scannedAt } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null +let state: SkillFreshnessState | null = null +const states = new Map() + +function Probe({ id = 'default' }: { id?: string }): null { + state = useSkillFreshness() + states.set(id, state) + return null +} + +describe('useSkillFreshness', () => { + beforeEach(() => { + _skillFreshnessCacheForTests.reset() + state = null + states.clear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + vi.useRealTimers() + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('runs a follow-up scan when invalidated during an in-flight request', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('focus'))) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + + await act(async () => second.resolve(inventory(2))) + expect(state?.inventory?.scannedAt).toBe(2) + }) + + it('skips focus rescans inside the cooldown but honors install-change events', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + await act(async () => second.resolve(inventory(2))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + expect(state?.inventory?.scannedAt).toBe(2) + }) + + it('retracts stale update authority during the cooldown and runs one trailing focus scan', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) + const second = deferred() + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(1, ['orca-cli'])) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + expect(state?.inventory?.eligibleUpdateNames).toEqual(['orca-cli']) + + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + expect(state?.inventory).toBeNull() + expect(state?.loading).toBe(true) + + await act(async () => vi.advanceTimersByTimeAsync(15_000)) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + await act(async () => second.resolve(inventory(2))) + expect(state?.inventory?.scannedAt).toBe(2) + }) + + it('coalesces multiple consumers into one rescan per invalidation event', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValue(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => + root?.render( + <> + + + + ) + ) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + await act(async () => second.resolve(inventory(2))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + }) + + it('publishes a manual refresh to every consumer', async () => { + const second = deferred() + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(1)) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => + root?.render( + <> + + + + ) + ) + expect(states.get('one')?.inventory?.scannedAt).toBe(1) + expect(states.get('two')?.inventory?.scannedAt).toBe(1) + + let refresh: Promise | undefined + await act(async () => { + refresh = states.get('one')?.refresh() + await Promise.resolve() + }) + expect(states.get('one')?.inventory).toBeNull() + expect(states.get('two')?.inventory).toBeNull() + + await act(async () => second.resolve(inventory(2))) + await refresh + expect(states.get('one')?.inventory?.scannedAt).toBe(2) + expect(states.get('two')?.inventory?.scannedAt).toBe(2) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + }) + + it('fails closed when an invalidation scan rejects', async () => { + const second = deferred() + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(1)) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + expect(state?.inventory?.scannedAt).toBe(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + expect(state?.inventory).toBeNull() + expect(state?.loading).toBe(true) + + await act(async () => second.reject(new Error('scan failed'))) + expect(state?.inventory).toBeNull() + expect(state?.loading).toBe(false) + expect(state?.error).toBe('scan failed') + }) + + it('installs one event-listener pair for multiple consumers and cleans it up', async () => { + const addEventListener = vi.spyOn(window, 'addEventListener') + const removeEventListener = vi.spyOn(window, 'removeEventListener') + window.api = { + skills: { freshnessInventory: vi.fn().mockResolvedValue(inventory(1)) } + } as never + + await act(async () => + root?.render( + <> + + + + ) + ) + + expect(addEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1) + expect( + addEventListener.mock.calls.filter(([name]) => name === 'orca:installed-agent-skills-changed') + ).toHaveLength(1) + + await act(async () => root?.unmount()) + root = null + expect(removeEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1) + expect( + removeEventListener.mock.calls.filter( + ([name]) => name === 'orca:installed-agent-skills-changed' + ) + ).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/hooks/useSkillFreshness.ts b/src/renderer/src/hooks/useSkillFreshness.ts new file mode 100644 index 00000000000..91ea0c4bdc2 --- /dev/null +++ b/src/renderer/src/hooks/useSkillFreshness.ts @@ -0,0 +1,178 @@ +import { useEffect, useSyncExternalStore } from 'react' +import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' +import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' + +// Why: window focus fires on every alt-tab, and each scan re-reads and re-hashes +// every installed package; a just-completed scan stays authoritative briefly. +const FOCUS_RESCAN_COOLDOWN_MS = 15_000 +let cachedInventory: SkillFreshnessInventory | null = null +let pendingInventory: Promise | null = null +let invalidationRevision = 0 +let completedRevision = -1 +let lastCompletedScanAt = 0 +let refreshSequence = 0 +let scheduledFocusRescan: number | null = null + +type SkillFreshnessSnapshot = { + inventory: SkillFreshnessInventory | null + loading: boolean + error: string | null +} + +let snapshot: SkillFreshnessSnapshot = { + inventory: null, + loading: false, + error: null +} +const subscribers = new Set<() => void>() + +function publishSnapshot(next: SkillFreshnessSnapshot): void { + if ( + snapshot.inventory === next.inventory && + snapshot.loading === next.loading && + snapshot.error === next.error + ) { + return + } + snapshot = next + for (const subscriber of subscribers) { + subscriber() + } +} + +async function loadInventory(force: boolean): Promise { + if (force) { + invalidationRevision += 1 + } + const targetRevision = invalidationRevision + for (;;) { + if (cachedInventory && completedRevision >= targetRevision) { + return cachedInventory + } + if (!pendingInventory) { + const requestRevision = invalidationRevision + const request = window.api.skills + .freshnessInventory() + .then((inventory) => { + cachedInventory = inventory + completedRevision = Math.max(completedRevision, requestRevision) + lastCompletedScanAt = Date.now() + return inventory + }) + .finally(() => { + if (pendingInventory === request) { + pendingInventory = null + } + }) + pendingInventory = request + } + await pendingInventory + } +} + +async function refreshSkillFreshness(force = true): Promise { + if (scheduledFocusRescan !== null) { + window.clearTimeout(scheduledFocusRescan) + scheduledFocusRescan = null + } + const sequence = ++refreshSequence + // Why: eligibility is write authority for the draft command. Once invalidated, + // stale bytes must stop authorizing UI even if the replacement scan fails. + publishSnapshot({ inventory: null, loading: true, error: null }) + try { + const inventory = await loadInventory(force) + if (sequence === refreshSequence) { + publishSnapshot({ inventory, loading: false, error: null }) + } + } catch (cause) { + if (sequence === refreshSequence) { + publishSnapshot({ + inventory: null, + loading: false, + error: cause instanceof Error ? cause.message : 'Could not inspect Orca skills.' + }) + } + } +} + +function onWindowFocus(): void { + const cooldownRemaining = FOCUS_RESCAN_COOLDOWN_MS - (Date.now() - lastCompletedScanAt) + if (cooldownRemaining <= 0) { + void refreshSkillFreshness(true) + return + } + if (!snapshot.inventory?.eligibleUpdateNames.length || scheduledFocusRescan !== null) { + return + } + // Why: a focus event can follow an external edit. Retract stale update + // authority immediately, but keep rapid alt-tabs to one trailing disk scan. + publishSnapshot({ inventory: null, loading: true, error: null }) + scheduledFocusRescan = window.setTimeout( + () => { + scheduledFocusRescan = null + void refreshSkillFreshness(true) + }, + Math.min(cooldownRemaining, FOCUS_RESCAN_COOLDOWN_MS) + ) +} + +function onInstalledSkillsChanged(): void { + void refreshSkillFreshness(true) +} + +function subscribe(subscriber: () => void): () => void { + subscribers.add(subscriber) + if (subscribers.size === 1) { + // Why: every consumer reads one external snapshot, so focus/install events + // install one listener and trigger one shared IPC scan regardless of UI count. + window.addEventListener('focus', onWindowFocus) + window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) + } + return () => { + subscribers.delete(subscriber) + if (subscribers.size === 0) { + window.removeEventListener('focus', onWindowFocus) + window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) + } + } +} + +function getSnapshot(): SkillFreshnessSnapshot { + return snapshot +} + +function ensureInventoryLoaded(): void { + if (!snapshot.inventory && !snapshot.loading) { + void refreshSkillFreshness(false) + } +} + +export type SkillFreshnessState = SkillFreshnessSnapshot & { + refresh: () => Promise +} + +export function useSkillFreshness(): SkillFreshnessState { + const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + + useEffect(() => { + ensureInventoryLoaded() + }, []) + + return { ...current, refresh: refreshSkillFreshness } +} + +export const _skillFreshnessCacheForTests = { + reset(): void { + cachedInventory = null + pendingInventory = null + invalidationRevision = 0 + completedRevision = -1 + lastCompletedScanAt = 0 + refreshSequence = 0 + if (scheduledFocusRescan !== null) { + window.clearTimeout(scheduledFocusRescan) + scheduledFocusRescan = null + } + snapshot = { inventory: null, loading: false, error: null } + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b59bbd3bf4f..38ec85ea123 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -31,6 +31,10 @@ "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.", "antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.", "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI." + }, + "menuBarIcon": { + "title": "Show Menu Bar Icon", + "description": "Keep an Orca shortcut and activity indicator in the macOS menu bar." } } }, @@ -67,7 +71,8 @@ "quit": "Quit", "minimizeNotice": { "body": "Orca is still running in the system tray" - } + }, + "activityWaiting": "Orca - activity waiting" }, "worktreeJumpPalette": { "matchLabel": { @@ -3219,7 +3224,11 @@ "42fdd4da1d": "Claude sign-in is being refreshed. Agent sessions may still be signed in.", "c06c1d215d": "Claude usage could not be refreshed because the network request failed.", "cabdc2a9e0": "Claude sign-in credentials could not be read.", - "a7517cccb6": "Claude usage is unavailable right now." + "a7517cccb6": "Claude usage is unavailable right now.", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH Host" @@ -3606,6 +3615,64 @@ "cd7893fbc1": "Scanning skills", "35b9a724a0": "Available", "0c74e7ff34": "Local" + }, + "SkillFreshnessNudge": { + "titleOne": "An installed Orca skill is out of date", + "titleMany": "{{value0}} installed Orca skills are out of date", + "description": "Update {{value0}} so agents follow the current instructions for this version of Orca.", + "updateOne": "Update skill", + "updateMany": "Update skills" + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "Update available", + "statusCantUpdate": "Skipped", + "cantUpdateReason": "Orca left this skill out of the update command.", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.", + "chipCurrent": "Current", + "chipUnrecognized": "Unrecognized", + "chipInaccessible": "Inaccessible", + "chipDuplicate": "Duplicate", + "chipExternalLink": "External link", + "chipBrokenLink": "Broken link", + "chipReadOnly": "Read only", + "chipInRepo": "In a repo", + "chipPluginCache": "Plugin cache", + "tipCurrent": "This copy matches the current official version.", + "tipUnrecognized": "This copy doesn’t match any official version — it may be modified, or a different skill with the same name.", + "tipInaccessible": "Orca couldn’t read this copy (a permissions or file error).", + "tipDuplicate": "A separate copy of this skill, installed apart from the main one.", + "tipExternalLink": "A shortcut pointing outside Orca’s skill folders.", + "tipBrokenLink": "A shortcut to something that no longer exists.", + "tipReadOnly": "This copy is in a read-only location.", + "tipInRepo": "This copy lives inside a project, not your global skills.", + "tipPluginCache": "This copy is managed by a plugin." + }, + "SkillFreshnessUpdateDialog": { + "title": "Update skills", + "checking": "Checking installed Orca skills…", + "none": "No installed Orca skills found.", + "updateOne": "1 skill can be updated safely", + "updateMany": "{{value0}} skills can be updated safely", + "success": "All installed Orca skills are up to date.", + "attention": "Some installed Orca skills were left out of the update.", + "attentionDescription": "Open Update details to see why each one was skipped.", + "details": "Update details", + "terminalTitle": "Update Orca skills", + "terminalDescription": "Review the pre-filled command, then press Enter to run it.", + "terminalAria": "Orca skill update terminal", + "checkNow": "Re-check", + "close": "Close" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "Update available", + "upToDate": "Up to date", + "installed": "Installed" } }, "sidebar": { @@ -8688,7 +8755,9 @@ "fe9231215b": "Checking Linear access before showing setup actions.", "e1f5e6424c": "{{value0}} workspace{{value1}} connected", "disconnect_all": "Disconnect", - "account_scope_prefix": "Account scope" + "account_scope_prefix": "Account scope", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -9040,7 +9109,9 @@ "b7e2d9f0a3": "Same weekly credit % as the grok /usage screen in the terminal.", "c6d1a8f4e2": "Resets {{when}}", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Used", @@ -10713,14 +10784,18 @@ }, "WindowsFirewallNotice": { "repair-success": "Windows Firewall now allows Orca Mobile on private networks", - "repair-failed": "Could not add the Windows Firewall rule", + "repair-failed": "Could not update the Windows Firewall rules", "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" + "allow": "Allow phone connections", + "repair-unverified": "Windows Firewall access could not be verified", + "blocked-title": "Windows may be blocking Orca Mobile", + "blocked-description": "An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.", + "repair": "Repair firewall access" } }, "gitlab": { @@ -11851,18 +11926,18 @@ "45cf7ceb3f": "Medium section heading.", "c209a116b7": "Heading 2", "3294a2c0cc": "Create a collapsible section with a large heading summary.", - "41482b15ce": "Toggle Heading 1", + "41482b15ce": "Toggle H1", "570611864e": "Large section heading.", "e66e7f04c6": "Heading 1", "5f9a0ed7c4": "Heading 4", "01a71dbbdd": "Nested section heading.", "8440fa4acf": "Heading 5", "b287b93c66": "Deep section heading.", - "7a2c1f9b04": "Toggle Heading 2", + "7a2c1f9b04": "Toggle H2", "b3e5d8a1c6": "Create a collapsible section with a medium heading summary.", - "2f9d6b4e10": "Toggle Heading 3", + "2f9d6b4e10": "Toggle H3", "8c1a3e7d52": "Create a collapsible section with a small heading summary.", - "5e0b9c2a71": "Toggle Heading 4", + "5e0b9c2a71": "Toggle H4", "d4f16a8b39": "Create a collapsible section with a nested heading summary." } } @@ -12698,7 +12773,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud site URL", "d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.", - "8388bdea2b": "Connect Jira site" + "8388bdea2b": "Connect Jira site", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, @@ -13125,6 +13217,7 @@ "title": "Could not load conversation", "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working." }, + "pairHost": "Pair a host to view agent chat history.", "notAgent": { "title": "No conversation here", "subtitle": "This terminal is not running a recognized coding agent." @@ -13149,7 +13242,8 @@ "cancel": "Cancel", "send": "Send answer", "next": "Next", - "skip": "Skip" + "skip": "Skip", + "sending": "Sending…" }, "approval": { "title": "Allow {{value0}}?", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c5e3d9b0398..4ebb7ed8d8d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -31,6 +31,10 @@ "portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos.", "antigravityToggleDescription": "Muestra el uso de suscripción de Antigravity para el espacio de trabajo activo.", "grokToggleDescription": "Muestra el uso de créditos de suscripción de Grok cuando has iniciado sesión con Grok CLI." + }, + "menuBarIcon": { + "title": "Mostrar icono en la barra de menús", + "description": "Mantén un acceso directo de Orca e indicador de actividad en la barra de menús de macOS." } } }, @@ -67,7 +71,8 @@ "quit": "Salir", "minimizeNotice": { "body": "Orca sigue ejecutándose en la bandeja del sistema" - } + }, + "activityWaiting": "Orca: actividad en espera" }, "worktreeJumpPalette": { "matchLabel": { @@ -3219,7 +3224,11 @@ "42fdd4da1d": "Se está actualizando el inicio de sesión de Claude. Las sesiones de agentes pueden seguir iniciadas.", "c06c1d215d": "No se pudo actualizar el uso de Claude porque falló la solicitud de red.", "cabdc2a9e0": "No se pudieron leer las credenciales de inicio de sesión de Claude.", - "a7517cccb6": "El uso de Claude no está disponible ahora." + "a7517cccb6": "El uso de Claude no está disponible ahora.", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "Host SSH" @@ -3606,6 +3615,64 @@ "cd7893fbc1": "Escaneando skills", "35b9a724a0": "Disponible", "0c74e7ff34": "Local" + }, + "SkillFreshnessNudge": { + "titleOne": "Una skill de Orca instalada está desactualizada", + "titleMany": "{{value0}} skills de Orca instaladas están desactualizadas", + "updateOne": "Actualizar skill", + "updateMany": "Actualizar skills", + "description": "Actualiza {{value0}} para que los Agents sigan las instrucciones actuales de esta versión de Orca." + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "Actualización disponible", + "statusCantUpdate": "No se puede actualizar", + "cantUpdateReason": "Esta skill está instalada en un lugar que Orca no puede actualizar de forma segura, así que el comando npx skills update la deja sin cambios.", + "chipCurrent": "Actual", + "chipUnrecognized": "No reconocida", + "chipInaccessible": "Inaccesible", + "chipDuplicate": "Duplicada", + "chipExternalLink": "Enlace externo", + "chipBrokenLink": "Enlace roto", + "chipReadOnly": "Solo lectura", + "chipInRepo": "En un repo", + "chipPluginCache": "Caché de plugin", + "tipCurrent": "La skill aquí ya está actualizada; la actualización no la cambiará.", + "tipUnrecognized": "El contenido de la skill aquí no coincide con ninguna versión oficial, así que Orca no puede actualizarla de forma segura. Elimina o reemplaza lo que hay aquí para permitir las actualizaciones.", + "tipInaccessible": "Orca no pudo leer la skill aquí (un error de permisos o de archivo), así que no puede comprobarla ni actualizarla.", + "tipDuplicate": "La skill también está instalada aquí, aparte de la principal, así que el comando npx skills update no puede alcanzarla. Elimínala para permitir las actualizaciones.", + "tipExternalLink": "Es un acceso directo que apunta fuera de las carpetas de skills de Orca; la actualización no lo seguirá.", + "tipBrokenLink": "Es un acceso directo a algo que ya no existe; puedes eliminarlo sin problema.", + "tipReadOnly": "La skill aquí está en una ubicación de solo lectura, así que no se puede actualizar hasta que cambies sus permisos.", + "tipInRepo": "La skill aquí vive dentro de un proyecto, no en tus skills globales; Orca solo actualiza las globales.", + "tipPluginCache": "La skill aquí la gestiona un plugin; actualiza el plugin en su lugar.", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "Actualizar skills", + "checking": "Comprobando las skills de Orca instaladas…", + "none": "No se encontraron skills de Orca instaladas.", + "updateOne": "Se puede actualizar 1 skill de forma segura", + "updateMany": "Se pueden actualizar {{value0}} skills de forma segura", + "success": "Todas las skills de Orca instaladas están actualizadas.", + "attention": "Algunas skills de Orca instaladas no se pueden actualizar automáticamente.", + "attentionDescription": "Abre Detalles de la actualización para ver por qué no se puede actualizar cada una.", + "details": "Detalles de la actualización", + "terminalTitle": "Actualizar skills de Orca", + "terminalDescription": "Revisa el comando pre-rellenado y pulsa Intro para ejecutarlo.", + "terminalAria": "Terminal de actualización de skills de Orca", + "checkNow": "Comprobar ahora", + "close": "Cerrar" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "Actualización disponible", + "upToDate": "Actualizado", + "installed": "Instalado" } }, "sidebar": { @@ -8680,7 +8747,9 @@ "fe9231215b": "Comprobando el acceso a Linear antes de mostrar las acciones de configuración.", "e1f5e6424c": "Conexión completada para {{value0}} workspace{{value1}}", "disconnect_all": "Desconectar todo", - "account_scope_prefix": "Alcance de la cuenta" + "account_scope_prefix": "Alcance de la cuenta", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -9040,7 +9109,9 @@ "b7e2d9f0a3": "El mismo porcentaje de créditos semanales que la pantalla grok /usage en la terminal.", "c6d1a8f4e2": "Se restablece {{when}}", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Usado", @@ -10713,14 +10784,18 @@ }, "WindowsFirewallNotice": { "repair-success": "Windows Firewall now allows Orca Mobile on private networks", - "repair-failed": "Could not add the Windows Firewall rule", + "repair-failed": "Could not update the Windows Firewall rules", "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" + "allow": "Allow phone connections", + "repair-unverified": "Windows Firewall access could not be verified", + "blocked-title": "Windows may be blocking Orca Mobile", + "blocked-description": "An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.", + "repair": "Repair firewall access" } }, "gitlab": { @@ -12698,7 +12773,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "URL del sitio de Jira Cloud", "d785c42b8b": "Usa una URL de Jira Cloud, email de Atlassian y token API para explorar issues.", - "8388bdea2b": "Conectar sitio de Jira" + "8388bdea2b": "Conectar sitio de Jira", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, @@ -13125,6 +13217,7 @@ "title": "No se pudo cargar la conversación", "subtitle": "No se pudo leer la transcripción. Vuelva a la terminal para seguir trabajando." }, + "pairHost": "Empareje un host para ver el historial de chat del agente.", "notAgent": { "title": "No hay conversación aquí", "subtitle": "Este terminal no está ejecutando un agente de código reconocido." @@ -13149,7 +13242,8 @@ "cancel": "Cancelar", "send": "Enviar respuesta", "next": "Siguiente", - "skip": "Omitir" + "skip": "Omitir", + "sending": "Enviando…" }, "approval": { "title": "¿Permitir {{value0}}?", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e4997704343..d5d31aebd55 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -31,6 +31,10 @@ "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。", "antigravityToggleDescription": "アクティブなワークスペースの Antigravity サブスクリプション使用量を表示します。", "grokToggleDescription": "Grok CLI でサインインしている場合に Grok サブスクリプションのクレジット使用量を表示します。" + }, + "menuBarIcon": { + "title": "メニューバーアイコンを表示", + "description": "macOS メニューバーに Orca のショートカットとアクティビティインジケーターを表示します。" } } }, @@ -67,7 +71,8 @@ "quit": "終了", "minimizeNotice": { "body": "Orcaはシステムトレイで実行中です" - } + }, + "activityWaiting": "Orca - アクティビティ待機中" }, "worktreeJumpPalette": { "matchLabel": { @@ -3219,7 +3224,11 @@ "42fdd4da1d": "Claude サインインを更新中です。エージェントセッションはまだサインイン済みの場合があります。", "c06c1d215d": "ネットワークリクエストに失敗したため、Claude 使用量を更新できませんでした。", "cabdc2a9e0": "Claude サインイン認証情報を読み取れませんでした。", - "a7517cccb6": "Claude 使用量は現在利用できません。" + "a7517cccb6": "Claude 使用量は現在利用できません。", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH ホスト" @@ -3606,6 +3615,64 @@ "cd7893fbc1": "スキャンスキル", "35b9a724a0": "利用可能", "0c74e7ff34": "ローカル" + }, + "SkillFreshnessNudge": { + "titleOne": "インストール済みの Orca スキルが古くなっています", + "titleMany": "インストール済みの Orca スキル {{value0}} 件が古くなっています", + "updateOne": "スキルを更新", + "updateMany": "スキルを更新", + "description": "Agents がこのバージョンの Orca の最新の手順に従うように、{{value0}} を更新してください。" + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "更新あり", + "statusCantUpdate": "更新できません", + "cantUpdateReason": "このスキルは Orca が安全に更新できない場所にインストールされているため、npx skills update コマンドは変更しません。", + "chipCurrent": "最新", + "chipUnrecognized": "未認識", + "chipInaccessible": "アクセス不可", + "chipDuplicate": "重複", + "chipExternalLink": "外部リンク", + "chipBrokenLink": "リンク切れ", + "chipReadOnly": "読み取り専用", + "chipInRepo": "リポジトリ内", + "chipPluginCache": "プラグインキャッシュ", + "tipCurrent": "ここのスキルはすでに最新です。更新しても変更されません。", + "tipUnrecognized": "ここのスキルの内容が公式バージョンのいずれとも一致しないため、Orca は安全に更新できません。更新を許可するには、ここにあるものを削除または置き換えてください。", + "tipInaccessible": "Orca はここのスキルを読み取れなかったため(権限またはファイルのエラー)、確認も更新もできません。", + "tipDuplicate": "このスキルはメインとは別に、ここにもインストールされています。そのため npx skills update コマンドは到達できません。更新を許可するには削除してください。", + "tipExternalLink": "これは Orca のスキルフォルダーの外を指すショートカットです。更新では追跡されません。", + "tipBrokenLink": "これは存在しないものを指すショートカットです。削除しても問題ありません。", + "tipReadOnly": "ここのスキルは読み取り専用の場所にあるため、権限を変更するまで更新できません。", + "tipInRepo": "ここのスキルはグローバルスキルではなくプロジェクト内にあります。Orca はグローバルなものだけを更新します。", + "tipPluginCache": "ここのスキルはプラグインによって管理されています。代わりにプラグインを更新してください。", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "スキルを更新", + "checking": "インストール済みの Orca スキルを確認中…", + "none": "インストール済みの Orca スキルは見つかりませんでした。", + "updateOne": "1 件のスキルを安全に更新できます", + "updateMany": "{{value0}} 件のスキルを安全に更新できます", + "success": "インストール済みの Orca スキルはすべて最新です。", + "attention": "一部のインストール済み Orca スキルは自動的に更新できません。", + "attentionDescription": "更新できない理由は、更新の詳細を開いて確認してください。", + "details": "更新の詳細", + "terminalTitle": "Orca スキルを更新", + "terminalDescription": "入力済みのコマンドを確認し、Enter キーを押して実行してください。", + "terminalAria": "Orca スキル更新ターミナル", + "checkNow": "今すぐ確認", + "close": "閉じる" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "更新があります", + "upToDate": "最新です", + "installed": "インストール済み" } }, "sidebar": { @@ -8680,7 +8747,9 @@ "fe9231215b": "Checking Linear access before showing setup actions.", "e1f5e6424c": "{{value0}} workspace{{value1}} connected", "disconnect_all": "切断", - "account_scope_prefix": "Account scope" + "account_scope_prefix": "Account scope", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -9040,7 +9109,9 @@ "b7e2d9f0a3": "ターミナルの grok /usage 画面と同じ週次クレジット率です。", "c6d1a8f4e2": "{{when}} にリセット", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "使用済み", @@ -10713,14 +10784,18 @@ }, "WindowsFirewallNotice": { "repair-success": "Windows Firewall now allows Orca Mobile on private networks", - "repair-failed": "Could not add the Windows Firewall rule", + "repair-failed": "Could not update the Windows Firewall rules", "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" + "allow": "Allow phone connections", + "repair-unverified": "Windows Firewall access could not be verified", + "blocked-title": "Windows may be blocking Orca Mobile", + "blocked-description": "An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.", + "repair": "Repair firewall access" } }, "gitlab": { @@ -12698,7 +12773,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud site URL", "d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.", - "8388bdea2b": "Connect Jira site" + "8388bdea2b": "Connect Jira site", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, @@ -13125,6 +13217,7 @@ "title": "会話を読み込めませんでした", "subtitle": "トランスクリプトを読み取ることができませんでした。作業を続けるには、terminal に切り替えてください。" }, + "pairHost": "ホストをペアリングして、エージェントのチャット履歴を表示します。", "notAgent": { "title": "ここでは会話はありません", "subtitle": "この terminal は認識されたコーディング agent を実行していません。" @@ -13149,7 +13242,8 @@ "cancel": "キャンセル", "send": "回答を送信する", "next": "次へ", - "skip": "スキップ" + "skip": "スキップ", + "sending": "送信中…" }, "approval": { "title": "{{value0}} を許可しますか?", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 9ed973f8110..694d4c9a600 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -31,6 +31,10 @@ "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요.", "antigravityToggleDescription": "활성 워크스페이스에 대한 Antigravity 구독 사용량을 표시합니다.", "grokToggleDescription": "Grok CLI로 로그인한 경우 Grok 구독 크레딧 사용량을 표시합니다." + }, + "menuBarIcon": { + "title": "메뉴 바 아이콘 표시", + "description": "macOS 메뉴 바에 Orca 바로가기 및 활동 표시기를 유지합니다." } } }, @@ -67,7 +71,8 @@ "quit": "종료", "minimizeNotice": { "body": "Orca가 시스템 트레이에서 계속 실행 중입니다" - } + }, + "activityWaiting": "Orca - 활동 대기 중" }, "worktreeJumpPalette": { "matchLabel": { @@ -3219,7 +3224,11 @@ "42fdd4da1d": "Claude 로그인을 새로 고치는 중입니다. 에이전트 세션은 여전히 로그인되어 있을 수 있습니다.", "c06c1d215d": "네트워크 요청 실패로 Claude 사용량을 새로 고칠 수 없습니다.", "cabdc2a9e0": "Claude 로그인 자격 증명을 읽을 수 없습니다.", - "a7517cccb6": "현재 Claude 사용량을 사용할 수 없습니다." + "a7517cccb6": "현재 Claude 사용량을 사용할 수 없습니다.", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH 호스트" @@ -3606,6 +3615,64 @@ "cd7893fbc1": "스킬 스캔 중", "35b9a724a0": "사용 가능", "0c74e7ff34": "로컬" + }, + "SkillFreshnessNudge": { + "titleOne": "설치된 Orca 스킬이 오래되었습니다", + "titleMany": "설치된 Orca 스킬 {{value0}}개가 오래되었습니다", + "updateOne": "스킬 업데이트", + "updateMany": "스킬 업데이트", + "description": "Agents가 이 버전의 Orca에 대한 최신 지침을 따르도록 {{value0}}을(를) 업데이트하세요." + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "업데이트 있음", + "statusCantUpdate": "업데이트할 수 없음", + "cantUpdateReason": "이 스킬은 Orca가 안전하게 업데이트할 수 없는 위치에 설치되어 있어, npx skills update 명령이 변경하지 않습니다.", + "chipCurrent": "최신", + "chipUnrecognized": "인식 안 됨", + "chipInaccessible": "액세스 불가", + "chipDuplicate": "중복", + "chipExternalLink": "외부 링크", + "chipBrokenLink": "깨진 링크", + "chipReadOnly": "읽기 전용", + "chipInRepo": "저장소 내", + "chipPluginCache": "플러그인 캐시", + "tipCurrent": "여기 있는 스킬은 이미 최신이므로 업데이트해도 변경되지 않습니다.", + "tipUnrecognized": "여기 있는 스킬의 콘텐츠가 어떤 공식 버전과도 일치하지 않아 Orca가 안전하게 업데이트할 수 없습니다. 업데이트를 허용하려면 여기 있는 항목을 제거하거나 교체하세요.", + "tipInaccessible": "Orca가 여기 있는 스킬을 읽지 못해(권한 또는 파일 오류) 확인하거나 업데이트할 수 없습니다.", + "tipDuplicate": "이 스킬은 기본 위치와 별개로 여기에도 설치되어 있어 npx skills update 명령이 접근할 수 없습니다. 업데이트를 허용하려면 제거하세요.", + "tipExternalLink": "Orca의 스킬 폴더 바깥을 가리키는 바로 가기입니다. 업데이트가 이를 따라가지 않습니다.", + "tipBrokenLink": "더 이상 존재하지 않는 대상을 가리키는 바로 가기입니다. 삭제해도 됩니다.", + "tipReadOnly": "여기 있는 스킬은 읽기 전용 위치에 있어 권한을 변경해야 업데이트할 수 있습니다.", + "tipInRepo": "여기 있는 스킬은 전역 스킬이 아니라 프로젝트 안에 있습니다. Orca는 전역 스킬만 업데이트합니다.", + "tipPluginCache": "여기 있는 스킬은 플러그인이 관리합니다. 대신 플러그인을 업데이트하세요.", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "스킬 업데이트", + "checking": "설치된 Orca 스킬을 확인하는 중…", + "none": "설치된 Orca 스킬을 찾을 수 없습니다.", + "updateOne": "스킬 1개를 안전하게 업데이트할 수 있습니다", + "updateMany": "스킬 {{value0}}개를 안전하게 업데이트할 수 있습니다", + "success": "설치된 Orca 스킬이 모두 최신 상태입니다.", + "attention": "일부 설치된 Orca 스킬은 자동으로 업데이트할 수 없습니다.", + "attentionDescription": "각 항목을 업데이트할 수 없는 이유는 업데이트 세부 정보를 열어 확인하세요.", + "details": "업데이트 세부 정보", + "terminalTitle": "Orca 스킬 업데이트", + "terminalDescription": "미리 입력된 명령을 검토한 후 Enter 키를 눌러 실행하세요.", + "terminalAria": "Orca 스킬 업데이트 터미널", + "checkNow": "지금 확인", + "close": "닫기" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "업데이트 가능", + "upToDate": "최신 상태", + "installed": "설치됨" } }, "sidebar": { @@ -5396,10 +5463,10 @@ "0df2e4fd12": "자동 저장 파일", "d21136d9ef": "Orca가 파일 편집을 유지하는 방법을 구성합니다.", "45c6e85c4d": "편집기", - "8f1afdfbd8": "차이점 단어 줄 바꿈", + "8f1afdfbd8": "Diff 자동 줄 바꿈", "4aa4d9fb73": "가로 스크롤을 요구하는 대신 diff 편집기에서 긴 줄을 줄 바꿈합니다.", - "bf16ef0af2": "끄다", - "3f6892f307": "~에", + "bf16ef0af2": "사용 안 함", + "3f6892f307": "사용", "b82f86d7d2": "Rich Markdown Spellcheck", "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown.", "7ddd66fede": "편집기 자동 줄 바꿈", @@ -8680,7 +8747,9 @@ "fe9231215b": "설정 작업을 표시하기 전에 Linear 액세스를 확인하는 중입니다.", "e1f5e6424c": "{{value0}}개 워크스페이스{{value1}} 연결됨", "disconnect_all": "연결 해제", - "account_scope_prefix": "계정 범위" + "account_scope_prefix": "계정 범위", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -9040,7 +9109,9 @@ "b7e2d9f0a3": "터미널의 grok /usage 화면과 같은 주간 크레딧 비율입니다.", "c6d1a8f4e2": "{{when}}에 재설정", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "사용", @@ -10713,14 +10784,18 @@ }, "WindowsFirewallNotice": { "repair-success": "Windows Firewall now allows Orca Mobile on private networks", - "repair-failed": "Could not add the Windows Firewall rule", + "repair-failed": "Could not update the Windows Firewall rules", "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" + "allow": "Allow phone connections", + "repair-unverified": "Windows Firewall access could not be verified", + "blocked-title": "Windows may be blocking Orca Mobile", + "blocked-description": "An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.", + "repair": "Repair firewall access" } }, "gitlab": { @@ -12698,7 +12773,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud 사이트 URL", "d785c42b8b": "Jira Cloud 사이트 URL, Atlassian 이메일, API 토큰을 사용해 이슈를 탐색합니다.", - "8388bdea2b": "Jira 사이트 연결" + "8388bdea2b": "Jira 사이트 연결", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, @@ -13125,6 +13217,7 @@ "title": "대화를 로드할 수 없습니다.", "subtitle": "스크립트를 읽을 수 없습니다. 계속 작업하려면 terminal로 다시 전환하세요." }, + "pairHost": "호스트를 페어링하여 에이전트 채팅 기록을 확인하세요.", "notAgent": { "title": "여기에는 대화가 없습니다.", "subtitle": "이 terminal은 인식된 코딩 agent를 실행하고 있지 않습니다." @@ -13149,7 +13242,8 @@ "cancel": "취소", "send": "답변 보내기", "next": "다음", - "skip": "건너뛰기" + "skip": "건너뛰기", + "sending": "전송 중…" }, "approval": { "title": "{{value0}}을(를) 허용하시겠습니까?", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ff39313ef09..d6c41a8447a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -20,17 +20,21 @@ "spanish": "Español" }, "statusBar": { - "claudeToggleDescription": "显示当前工作区的 Claude token和成本使用情况。", - "codexToggleDescription": "显示当前工作区的 Codex token和成本使用情况。", - "geminiToggleDescription": "显示当前工作区的 Gemini token和成本使用情况。", - "opencodeGoToggleDescription": "显示当前工作区的 OpenCode Go token和成本使用情况。", + "claudeToggleDescription": "显示当前工作区的 Claude Token 与费用消耗情况。", + "codexToggleDescription": "显示当前工作区的 Codex Token 与费用消耗情况。", + "geminiToggleDescription": "显示当前工作区的 Gemini Token 与费用消耗情况。", + "opencodeGoToggleDescription": "显示当前工作区的 OpenCode Go Token 与费用消耗情况。", "kimiToggleDescription": "显示当前工作区的 Kimi 订阅使用情况。", "minimaxToggleDescription": "显示当前工作区的 MiniMax 订阅使用情况。", "sshToggleDescription": "当有可用的 SSH 和远程 Orca 主机时显示它们。", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", - "portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。", + "portsToggleDescription": "显示实时工作区端口。单击可查看工作区范围的端口和外部监听器。", "antigravityToggleDescription": "显示当前工作区的 Antigravity 订阅使用量。", "grokToggleDescription": "通过 Grok CLI 登录后显示 Grok 订阅额度使用量。" + }, + "menuBarIcon": { + "title": "显示菜单栏图标", + "description": "在 macOS 菜单栏中保留 Orca 快捷方式和活动指示器。" } } }, @@ -67,7 +71,8 @@ "quit": "退出", "minimizeNotice": { "body": "Orca 仍在系统托盘中运行" - } + }, + "activityWaiting": "Orca - 活动等待中" }, "worktreeJumpPalette": { "matchLabel": { @@ -1514,7 +1519,7 @@ "cc8795e07c": "无法加载 Linear 议题", "f362667d55": "已更新", "b1eaa18ace": "议题", - "37e7ee311e": "钥匙", + "37e7ee311e": "标识", "b7bae28b6a": "显示", "a26a48252e": "显示属性", "5d2d835467": "排序", @@ -2381,7 +2386,7 @@ "skippedAncestor": "由于无法删除嵌套工作区,已跳过。", "timedOut": "删除 {{value0}} 的时间比预期长。它将在后台继续运行。", "stillRemoving": "Still removing workspaces: {{value0}}", - "skippedPendingAncestor": "Skipped because a nested workspace has not finished removing." + "skippedPendingAncestor": "已跳过,因为嵌套的工作区尚未完成移除。" }, "candidateRow": { "gitLabel": "Git", @@ -2537,15 +2542,15 @@ }, "TerminalAgentSessionForkDialog": { "17fc841e59": "复制上下文", - "0c8a8629b1": "Fork 会显示为独立工作区,而非嵌套子项。新智能体会收到一份有界转录稿,可作为可编辑草稿。", - "620461df22": "顶级分叉", + "0c8a8629b1": "派生会话会显示为独立工作区,而非嵌套子项。新智能体会收到一份有界转录稿,可作为可编辑草稿。", + "620461df22": "顶级派生", "619b5a35d2": "创建一个顶级工作区分支并使用捕获的上下文启动一个新的智能体选项卡。", - "64e292e8e3": "分叉智能体会话", - "9d25de2920": "创建分叉", + "64e292e8e3": "派生智能体会话", + "9d25de2920": "创建派生", "2b10412cfc": "创建中..." }, "TerminalContextMenu": { - "b4cdd9314e": "清晰的屏幕", + "b4cdd9314e": "清屏", "8c17d6786d": "关闭窗格", "copyTerminalId": "复制终端 ID", "2cf85a6a55": "复制窗格 ID", @@ -2554,7 +2559,7 @@ "06c2b0f043": "均衡窗格大小", "98bccf4fa2": "向下拆分终端", "20e565d865": "向右拆分终端", - "8a7ddb8b8a": "分叉智能体会话...", + "8a7ddb8b8a": "派生智能体会话...", "0a82b0608c": "添加快捷命令...", "9528a65ef8": "没有快捷命令", "3ce594a4a0": "全局的", @@ -2570,7 +2575,7 @@ "e4aa243f8c": "重新启动守护进程", "a7e2fd2699": "提交议题", "5c8ce20be6": "如果这种情况持续存在,请", - "cc6d997c65": "从此处重新启动终端守护程序以清除过时的守护程序状态。" + "cc6d997c65": "从此处重新启动终端守护进程以清除失效的守护进程状态。" }, "TerminalPane": { "ac112e9036": "删除标题", @@ -2608,13 +2613,13 @@ "agent": { "session": { "fork": { - "2317900211": "无法复制 fork 上下文。", + "2317900211": "无法复制派生上下文。", "88e34d00eb": "顶级会话分支在新工作区中打开", - "fd3d12a1e1": "无法创建 fork 工作区。", - "38e41edc6e": "该工作区无法分叉为 git 工作树。", - "f867385bb5": "找不到此 fork 的源工作区。", - "046e8d853c": "没有要 fork 的终端上下文", - "c00421d320": "已复制分叉上下文。启动智能体并粘贴它以启动分叉。", + "fd3d12a1e1": "无法创建派生工作区。", + "38e41edc6e": "该工作区无法派生为 Git 工作树。", + "f867385bb5": "找不到此派生的源工作区。", + "046e8d853c": "没有要派生的终端上下文", + "c00421d320": "已复制派生上下文。启动智能体并粘贴它以开始派生。", "f62b40e2c7": "没有可复制的终端上下文", "373a3103e7": "已复制上下文", "3fc568a49d": "无法复制上下文。" @@ -3219,7 +3224,11 @@ "42fdd4da1d": "正在刷新 Claude 登录。智能体会话可能仍处于登录状态。", "c06c1d215d": "由于网络请求失败,无法刷新 Claude 用量。", "cabdc2a9e0": "无法读取 Claude 登录凭据。", - "a7517cccb6": "Claude 用量目前不可用。" + "a7517cccb6": "Claude 用量目前不可用。", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH 主机" @@ -3606,6 +3615,64 @@ "cd7893fbc1": "正在扫描技能", "35b9a724a0": "可用的", "0c74e7ff34": "当地的" + }, + "SkillFreshnessNudge": { + "titleOne": "已安装的 Orca 技能已过期", + "titleMany": "{{value0}} 个已安装的 Orca 技能已过期", + "updateOne": "更新技能", + "updateMany": "更新技能", + "description": "更新 {{value0}},让 Agents 遵循此版本 Orca 的最新说明。" + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "有更新", + "statusCantUpdate": "无法更新", + "cantUpdateReason": "此技能安装在 Orca 无法安全更新的位置,因此 npx skills update 命令不会更改它。", + "chipCurrent": "最新", + "chipUnrecognized": "无法识别", + "chipInaccessible": "无法访问", + "chipDuplicate": "重复", + "chipExternalLink": "外部链接", + "chipBrokenLink": "失效链接", + "chipReadOnly": "只读", + "chipInRepo": "在仓库中", + "chipPluginCache": "插件缓存", + "tipCurrent": "这里的技能已是最新,更新不会更改它。", + "tipUnrecognized": "这里的技能内容与任何官方版本都不匹配,因此 Orca 无法安全更新它。请移除或替换此处的内容以允许更新。", + "tipInaccessible": "Orca 无法读取这里的技能(权限或文件错误),因此无法检查或更新它。", + "tipDuplicate": "此技能除主副本外还安装在这里,因此 npx skills update 命令无法访问它。请移除它以允许更新。", + "tipExternalLink": "这是一个指向 Orca 技能文件夹之外的快捷方式;更新不会跟随它。", + "tipBrokenLink": "这是一个指向已不存在内容的快捷方式,可以安全删除。", + "tipReadOnly": "这里的技能位于只读位置,需更改其权限后才能更新。", + "tipInRepo": "这里的技能位于某个项目内,而非全局技能;Orca 只更新全局技能。", + "tipPluginCache": "这里的技能由插件管理;请改为更新插件。", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "更新技能", + "checking": "正在检查已安装的 Orca 技能…", + "none": "未找到已安装的 Orca 技能。", + "updateOne": "可以安全更新 1 个技能", + "updateMany": "可以安全更新 {{value0}} 个技能", + "success": "所有已安装的 Orca 技能均为最新。", + "attention": "部分已安装的 Orca 技能无法自动更新。", + "attentionDescription": "打开“更新详情”查看为何每一项都无法更新。", + "details": "更新详情", + "terminalTitle": "更新 Orca 技能", + "terminalDescription": "检查预填的命令,然后按 Enter 键运行。", + "terminalAria": "Orca 技能更新终端", + "checkNow": "立即检查", + "close": "关闭" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "有可用更新", + "upToDate": "已是最新", + "installed": "已安装" } }, "sidebar": { @@ -4701,7 +4768,7 @@ "28c8e53176": "这会强制退出所有工作区中每个正在运行的终端窗格。这些会话中所有未保存的工作都会丢失。守护进程本身保持运行,并且可以立即打开新终端。这无法撤销。", "1bbea41a77": "杀死所有终端会话?", "01d6b7c64e": "终止每个正在运行的终端窗格并重新启动守护进程。窗格显示“进程已退出”并且可以立即重新打开。先前应用程序版本的旧协议会话将被保留。这无法撤销。", - "922548bc66": "重新启动终端守护程序?", + "922548bc66": "重新启动终端守护进程?", "2b4efdc162": "无法终止会话。", "d18f3005c2": "{{value0}} 会话{{value1}} 拒绝退出。", "baad8cd651": "没有正在运行的会话。", @@ -5229,7 +5296,7 @@ "d8b6764d79": "可用时使用评审模板", "e001734396": "除非在编辑器中更改,否则将托管评审创建为草稿。", "6ba48f07a4": "默认草稿", - "15b60d54b2": "例如ollama 运行 llama3.1 {提示}", + "15b60d54b2": "例如 ollama run llama3.1 {prompt}", "3f1b26cc91": "将提示词输入作为参数传递;否则 Orca 会通过标准输入传递。", "4f722a5f53": "由选择自定义命令的 commit 消息、PR 和分支名称预设使用。使用", "47e45cbd5a": "自定义命令", @@ -5520,7 +5587,7 @@ "aa204f185f": "当前 GitHub CLI REST、搜索和 GraphQL 速率限制。", "612a440e57": "GitHub API 预算", "2cde9044a8": "graphql", - "36e3de3619": "通过与陈旧的历史进行比较。如果该分支有未 commits 的更改或仅限本地 commits,Orca 会跳过更新。", + "36e3de3619": "避免与过时的历史记录进行比较。如果该分支有未提交的更改或仅在本地的提交,Orca 会跳过更新。", "d072a12995": "git diff main...HEAD", "db3a127eb1": "。这使得命令像", "3ae3de8898": "master", @@ -6157,7 +6224,7 @@ "e56668c291": "使用全局", "fbb77e122a": "选择自定义命令的文本操作的 Repo 回退。", "ebffc5a28c": "自定义命令", - "f9941f0caf": "例如ollama 运行 llama3.1 {提示}" + "f9941f0caf": "例如 ollama run llama3.1 {prompt}" }, "RepositorySourceControlAiEnablement": { "84233d1bb3": "关", @@ -7342,7 +7409,7 @@ "a1414dcefb": "安装浏览器使用技能", "e56c7b55c9": "设置", "034c5e8d7f": "启用", - "7e0dcb257a": "壳", + "7e0dcb257a": "Shell", "3ffafc9b95": "命令", "30c74aaa1f": "路径", "ff05cbc344": "orca", @@ -7719,7 +7786,7 @@ "564942ffc5": "origin/main", "28192e3a63": "master", "e3e9adde59": "main", - "0e993bf00f": "当您创建工作区时,Orca 会刷新远程库并安全地快进匹配的本地分支,例如 main 或 master。这使得像 git diff main...HEAD 这样的命令无法与过时的历史记录进行比较。如果该分支有未 commits 的更改或仅限本地 commits,Orca 会跳过更新。", + "0e993bf00f": "当您创建工作区时,Orca 会刷新远程基准并安全地快进匹配的本地分支,例如 main 或 master。这可以防止像 git diff main...HEAD 这样的命令对比过时的历史记录。如果该分支有未提交的更改或仅在本地的提交,Orca 会跳过更新。", "f8bda25f29": "保持本地 main 最新", "769ddd7f81": "风俗", "1d2fae1fa2": "git 用户名", @@ -8206,7 +8273,7 @@ "afda131738": "Orca 优先", "0ecfc47434": "冲突", "0f8cb15582": "智能体", - "f1adebbe8c": "壳", + "f1adebbe8c": "Shell", "7f1b38f59a": "推", "7e3fc707aa": "终端", "0ecba9aa5f": "键盘", @@ -8340,7 +8407,7 @@ "d802a578bf": "会话", "9f2dda133c": "普蒂", "f35400f7e8": "守护进程", - "f72abc493c": "通过终止会话、清除保存的回滚或重新启动守护程序,从冻结的终端中恢复。", + "f72abc493c": "通过终止会话、清除保存的回滚缓冲区或重新启动守护进程,从冻结的终端中恢复。", "6f5d486a68": "管理会话", "10f9fb6fea": "设置", "2ade3ea490": "配置", @@ -8363,7 +8430,7 @@ "dd4f6cb541": "德语", "983d45cf4c": "撰写", "7ace5beec9": "元", - "38f1b4f4cb": "钥匙", + "38f1b4f4cb": "按键", "c4427dc5ff": "替代", "b37edfc65a": "选项", "1f8b00f5ce": "控制 macOS Option 键是发送 Alt/Esc 序列还是组合字符。反映 Ghostty 的 macos-option-as-alt。", @@ -8547,7 +8614,7 @@ "action": { "recipe": { "options": { - "commitMessage": "从分阶段更改生成 commit 消息。", + "commitMessage": "根据已暂存的更改生成提交信息。", "pullRequest": "生成托管评论标题和描述。", "branchName": "重命名 Orca 从初始智能体任务创建的分支。", "fixCommitFailure": "当 commit 挂钩或 git commit 失败时启动智能体。", @@ -8680,7 +8747,9 @@ "fe9231215b": "在显示设置操作前正在检查 Linear 访问权限。", "e1f5e6424c": "{{value0}} 个工作区{{value1}} 已连接", "disconnect_all": "断开连接", - "account_scope_prefix": "账户范围" + "account_scope_prefix": "账户范围", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -9040,29 +9109,31 @@ "b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。", "c6d1a8f4e2": "{{when}} 重置", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "已用", "usagePercentageDisplayRemaining": "剩余" }, "TerminalInteractionSection": { - "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", - "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + "567633ff50": "右键点击将剪贴板内容粘贴到终端。按住 Control 键点击以打开上下文菜单。", + "c64497148a": "右键点击粘贴剪贴板内容。按住 Control 键点击打开上下文菜单。" }, "MobileRelayStatusSection": { - "registered": "Registered", - "connecting": "Connecting", - "reconnecting": "Reconnecting", - "offline": "Offline", + "registered": "已注册", + "connecting": "正在连接", + "reconnecting": "正在重新连接", + "offline": "离线", "title": "Orca Relay", - "automatic": "Connect from anywhere when a phone is paired", - "signInPrompt": "Sign in on this desktop to connect from anywhere", - "directStillAvailable": "LAN and Tailscale connections remain available.", - "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", - "signIn": "Sign in", - "unavailable": "Unavailable", - "standby": "Standby — no relay devices" + "automatic": "手机配对后可从任何地方连接", + "signInPrompt": "在此桌面端登录以从任何地方连接", + "directStillAvailable": "局域网和 Tailscale 连接仍然可用。", + "directNeedsNoAccount": "无需账户即可使用局域网和 Tailscale 配对。", + "signIn": "登录", + "unavailable": "不可用", + "standby": "待机 — 无 Relay 设备" }, "MobilePairingConnectionOptions": { "ready": "已就绪", @@ -9235,17 +9306,17 @@ "c76693e456": "无法加载此工作区的文件:" }, "GitHistoryPanel": { - "cf7cad58d2": "还没有 commits", + "cf7cad58d2": "尚无提交", "781a8bcf7b": "正在加载图表...", - "d0fb0f4bf2": "刷新 commits", - "9f7535d22b": "引用是指向该确切 commit 的分支或标记名称。它们仅出现在 Git 具有 commit 的命名引用的地方。", + "d0fb0f4bf2": "刷新提交记录", + "9f7535d22b": "引用是指向该确切提交的分支或标签名称。它们仅出现在 Git 具有该提交的命名引用的位置。", "9289ba0cb9": "什么是参考文献?", "d836037d02": "提交", "8232c8b2f2": "打开 commit {{value0}}: {{value1}}", "9a8b85882d": "加载中", "62e685d5ec": "空闲", "111e1d0db4": "错误", - "e5e81e59a6": "调整 commits 大小", + "e5e81e59a6": "调整提交记录高度", "6d1e0a7c3b": "无法加载提交文件" }, "HostedReviewActions": { @@ -9391,22 +9462,22 @@ "3278b2767b": "前面", "11b5dd8e41": "比较", "783a808870": "关闭", - "a9bf7c171a": "Commit 失败", + "a9bf7c171a": "提交失败", "03d238218c": "细节", - "011f9713fc": "Commit 被阻止", - "cc199ccc5f": "更多 commit 和远程操作", + "011f9713fc": "提交被阻止", + "cc199ccc5f": "更多提交和远程操作", "4d6e1fd7f3": "更多操作", - "37a81f29ad": "生成 commit 消息。单击停止。", - "b94112eb9e": "Commit 消息", + "37a81f29ad": "正在生成提交信息。单击停止。", + "b94112eb9e": "提交信息", "0d0a8359d3": "信息", "15b7f210d7": "启动智能体前请查看并编辑完整提示词。", - "054ead86b1": "使用 AI 修复 Commit 失败", - "9e5ccd00aa": "Commit 失败上下文不可用", + "054ead86b1": "使用 AI 修复提交失败", + "9e5ccd00aa": "提交失败的上下文不可用", "f0a2dc9e46": "自定义启动...", - "ec7bfced55": "选择智能体来修复 commit 失败", - "dd43c47089": "选择针对此 commit 失败的智能体", - "30b8d4f181": "使用 AI 修复 commit 失败", - "4b37ae99b0": "启动默认 AI 智能体来修复此 commit 失败", + "ec7bfced55": "选择智能体来修复提交失败", + "dd43c47089": "选择针对此提交失败的智能体", + "30b8d4f181": "使用 AI 修复提交失败", + "4b37ae99b0": "启动默认 AI 智能体来修复此提交失败", "ae743199cd": "在创建 {{value0}} 之前,请选择其他基础分支。", "318e2a7f88": "请等待 AI 生成完成。", "f76307c1f7": "请选择基础分支。", @@ -9429,7 +9500,7 @@ "e1970d327d": "新建 {{value0}}", "f4c766f1ca": "为此运行选择智能体和提示词模板。", "1a6a6e0bc5": "生成托管评论详细信息", - "6b122529d4": "生成 Commit 消息", + "6b122529d4": "生成提交信息", "e48caaf0dd": "已启动 AI 智能体处理冲突。", "901140f47d": "启动智能体前请查看并编辑完整提示词。", "19652ddd76": "用 AI 解决冲突", @@ -9448,7 +9519,7 @@ "dc5a6465fc": "{{value0}}(例如 {{value1}}{{value2}})", "8eb3782a0c": "无法丢弃 {{value0}} 文件{{value1}}", "a5e5a11090": "放弃所有失败 - 无法在放弃之前取消暂存文件", - "8a5ba6a988": "无法加载 commit 差异", + "8a5ba6a988": "无法加载提交差异", "fe5bd1a610": "创建 {{value0}}...", "812cb992ee": "打开于 {{value0}}", "eef5446523": "{{value0}} #{{value1}} 已开放", @@ -9481,9 +9552,9 @@ "424ee0e5bf": "错误", "834cb3f23d": "用 AI 修复", "60bd988f0b": "AI 修复", - "461575b9bc": "使用 AI 生成 commit 消息", - "b16b8f0e4b": "AI commit 消息", - "ddc1fbd690": "停止生成 commit 消息", + "461575b9bc": "使用 AI 生成提交信息", + "b16b8f0e4b": "AI 提交信息", + "ddc1fbd690": "停止生成提交信息", "5acbcedc1a": "创建 {{value0}}", "aaf1451654": "创建草稿 {{value0}}", "26511c22b4": "创建中...", @@ -9537,20 +9608,20 @@ "04a5d7239b": "此仓库没有受支持的网页远程库", "15b6e834ac": "无法在浏览器中打开提交", "d37e68f61d": "正在准备分支以供评审…", - "8d8f5c6c94": "正在生成 commit 消息…", - "fda060d6ce": "请先检查 commit 消息,然后重试创建 PR。", + "8d8f5c6c94": "正在生成提交信息…", + "fda060d6ce": "请先检查提交信息,然后重试创建 PR。", "b75cb1fd0c": "正在提交更改…", "995c5e67ec": "评审设置需要处理。", "d7492cafce": "无法刷新 Source Control。请重试创建 PR。", "473f18758e": "Source Control AI 设置", - "createPrIntentConfigureAi": "请添加 commit 消息或配置 Source Control AI 设置。", - "createPrIntentGenerateFailed": "无法生成 commit 消息。请添加一条消息后重试。", + "createPrIntentConfigureAi": "请添加提交信息或配置源代码管理 AI 设置。", + "createPrIntentGenerateFailed": "无法生成提交信息。请添加一条信息后重试。", "createPrIntentCommitFailed": "无法提交更改。请修复问题后重试创建 PR。", "createPrIntentNeedsSync": "创建评审前请先同步此分支。", "createPrIntentBranchNotReady": "分支尚未准备好创建评审。", "createPrIntentPublishing": "正在发布分支…", "createPrIntentForcePushing": "正在使用 lease 强制推送…", - "createPrIntentPushing": "正在推送 commits…", + "createPrIntentPushing": "正在推送提交…", "createPrIntentRemoteFailed": "无法更新远程分支。请重试创建 PR。", "createPrIntentGeneratingDetails": "正在生成审查详情…", "createPrIntentBranchChangedDuringDetails": "生成审查详情时分支已更改。请重试创建 PR。", @@ -9644,7 +9715,7 @@ "checks": { "panel": { "content": { - "3916814392": "落后(基本 commit:", + "3916814392": "落后(基础提交:", "755be805f6": "暂无评论", "751f7c6e5c": "显示每个来源的前 100 条评论", "94557d68e2": "评论", @@ -9750,7 +9821,7 @@ "2bdd7aaf2d": "GitHub 状态无法刷新。现有的缓存数据被保留。", "5f478ab3d3": "无法刷新PR", "6ce9d4e069": "在创建 {{value0}} 之前推送您的分支。", - "76e15946a9": "分支有未推送的 commits", + "76e15946a9": "分支有未推送的提交", "f8543140cc": "在创建 {{value0}} 之前发布此分支。", "41252bc53f": "分支未发布", "05e4aec17b": "{{value0}} 检查将在操作完成后可用", @@ -9825,11 +9896,11 @@ "commit": { "failure": { "launch": { - "a8b97d2318": "针对 commit 失败启动了 AI 智能体。", + "a8b97d2318": "针对提交失败启动了 AI 智能体。", "5540ff50cc": "无法构建智能体启动命令。", "9bbd9077a2": "没有启用的 AI 智能体。在“设置”中配置智能体。", "d481ab22f9": "已保存的 AI 智能体不可用。使用自定义启动来选择另一个智能体。", - "f2b47026e8": "Commit 失败提示为空。更新源代码管理 AI 设置。", + "f2b47026e8": "提交失败的提示为空。请更新源代码管理 AI 设置。", "4f4e0418a0": "无法构建智能体提示符。", "216f762bd7": "无法解析工作区连接。" } @@ -9869,7 +9940,7 @@ "confirmation": { "2ae5a785b3": "放弃所有未暂存的更改?", "ddf36f291c": "这将取消暂存并恢复所有已暂存的更改。暂存的新文件将被删除。此操作无法撤销。", - "5ddd8cac7f": "放弃所有分阶段的更改?", + "5ddd8cac7f": "放弃所有已暂存的更改?", "1426c2efff": "这将恢复对此文件的所有更改。此操作无法撤销。", "d4df3a61df": "放弃对“{{value0}}”的更改?", "40e9357b2a": "这将从 HEAD 恢复文件并放弃删除。此操作无法撤销。", @@ -9893,7 +9964,7 @@ "7aad2c0240": "托管评审操作进行中…", "9e779995dd": "创建 {{value0}}", "226b85a3a7": "fetch", - "323bb614aa": "Commit 并同步", + "323bb614aa": "提交并同步", "2b8e6595fd": "提交", "04d709801d": "从远程获取但不合并" } @@ -9909,22 +9980,22 @@ "390abeab93": "强力推", "1884cf34af": "将此分支发布到 origin", "7b4d02e6b8": "发布分支", - "3d5dccef0b": "没有可 commit 的内容。PR 已合并。", + "3d5dccef0b": "没有可提交的内容。PR 已合并。", "41d4bcf157": "检查 PR 状态...", - "acce237921": "没有可 commit 的内容。分支没有可发布的更改。", - "fa3bd4f40c": "暂存至少一个要 commit 的文件", + "acce237921": "没有可提交的内容。分支没有可发布的更改。", + "fa3bd4f40c": "暂存至少一个要提交的文件", "5a477d80cb": "暂存所有更改", "18a0fca877": "暂存全部", - "f01f16d77f": "输入 commit 消息以 commit", - "ab41fb926b": "Commit 分阶段更改", + "f01f16d77f": "输入提交信息以进行提交", + "ab41fb926b": "提交已暂存的更改", "2d8f185fbc": "在提交部分暂存的文件之前暂存所有更改", "a6457b46a7": "提交前解决冲突", "484f45c439": "{{value0}} 正在进行中...", "6f7a8b9c0d": "远程操作进行中…", "7f8a9b0c1d": "远程操作进行中——请等它完成后重试", "74fc171e99": "强制推送正在进行中...", - "16aee3a5c1": "正在进行中……", - "e61b0d7a3c": "请先检出分支再发布 commits。", + "16aee3a5c1": "正在提交…", + "e61b0d7a3c": "请先检出分支再发布提交。", "1d47e850cf": "将更新推送到已关联的评审分支", "c39d0c75c3": "已关联的评审分支目标不可用。", "8c6d15a07d": "创建 PR", @@ -10053,7 +10124,7 @@ "projectScope": "项目", "currentProjectLower": "当前项目", "project": "项目", - "hostScopeAriaLabel": "Session History host: {{value0}}", + "hostScopeAriaLabel": "会话历史主机:{{value0}}", "host": "主机" }, "AiVaultSessionDetails": { @@ -10203,9 +10274,9 @@ "viewLog": "查看日志" }, "aiVaultSessionLogOpen": { - "workspaceGone": "Couldn't open log — workspace is no longer available.", - "notAuthorized": "Couldn't open log — path not authorized.", - "alreadyEditable": "Log is already open for editing." + "workspaceGone": "无法打开日志 — 工作区已不可用。", + "notAuthorized": "无法打开日志 — 路径未授权。", + "alreadyEditable": "日志已处于编辑状态。" } } }, @@ -10679,7 +10750,7 @@ "e0f98be657": "Orca/壮举-手机页面", "2defc05141": "开发@mac", "da121ba48d": "计划.md", - "e4befee569": "壳", + "e4befee569": "Shell", "606aa93192": "文件", "94febb0976": "源头控制", "8d6516312d": "2 个终端 · Claude 活跃", @@ -10712,15 +10783,19 @@ "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" + "repair-success": "Windows 防火墙现已允许 Orca Mobile 在专用网络上通信", + "repair-failed": "无法更新 Windows 防火墙规则", + "public-title": "Windows 将此网络标记为公用", + "missing-title": "允许手机连接通过 Windows 防火墙", + "public-description": "请先将此受信任的 Wi-Fi 网络更改为专用网络,然后再允许 Orca Mobile 连接。", + "missing-description": "Windows 可能会阻止配对服务器。请为此 Orca 应用和专用网络上的 TCP 端口 {{port}} 添加规则。", + "open-settings": "打开网络设置", + "waiting": "正在等待 Windows…", + "allow": "允许手机连接", + "repair-unverified": "无法验证 Windows 防火墙访问权限", + "blocked-title": "Windows 可能正在阻止 Orca Mobile", + "blocked-description": "现有的入站阻止规则可能会覆盖配对例外。修复将移除此 Orca 应用的冲突 TCP 规则,然后在专用网络上允许端口 {{port}}。", + "repair": "修复防火墙访问权限" } }, "gitlab": { @@ -10844,7 +10919,7 @@ "eb88125c6f": "✓ 已验证 — 免费试用仍然有效。", "051c97d15a": ".pp-card[data-card=\"starter\"] .pp-cta", "4fa59ca545": "✓ 更新", - "1bec24acc1": "@keyframes browserFlash { 0% { 不透明度:0; } 20% { 不透明度: 0.85; } 100% { 不透明度: 0; } } @keyframes browserTabIn { 来自 { 不透明度:0;变换:translateY(-2px); } 到 { 不透明度:1;变换:无; } } @keyframes browserViewIn { 来自 { 不透明度:0;变换:翻译Y(4px); } 到 { 不透明度:1;变换:无; } }", + "1bec24acc1": "@keyframes browserFlash { 0% { opacity: 0; } 20% { opacity: 0.85; } 100% { opacity: 0; } } @keyframes browserTabIn { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes browserViewIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }", "73bbb46073": "/定价", "f39be6ca14": "/报名" }, @@ -10885,7 +10960,7 @@ "5a55c00a81": "推出计划", "218503f9f3": "自动保存", "cda56c5915": "笔记/launch-plan.md", - "e16479c1c5": "[data-slash-menu] [data-slash-row].slash-active { 背景:rgba(24,24,27,0.07);框阴影:插入 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { 颜色:rgb(113 113 122);字体系列:ui-monospace、SFMono-Regular、Menlo、等宽字体;字体大小:12.5px; } [data-md-active-line][data-role=\"h1\"] { 颜色:继承;字体系列:继承;字体大小:18px;字体粗细:700;字母间距:-0.01em;行高:1.2;顶部边距:6px; } [data-md-caret] { 显示:内联块;宽度:1.5px;高度:1em;背景:当前颜色;垂直对齐:-2px;左边距:1px;动画: md-caret-blink 1.05s steps(1) 无限; } @keyframes md-caret-blink { 0%, 50% { 不透明度: 1 } 51%, 100% { 不透明度: 0 } } @keyframes md-block-in { 来自 { 不透明度: 0; }变换:translateY(-2px); } 到 { 不透明度:1;变换:无; } } @keyframes md-cursor-ripple { 0% { 变换:缩放(0.4);不透明度:0.9; } 100% { 变换:缩放(1.4);不透明度:0; [data-clicking=\"1\"] [data-cursor-ripple] { 动画:md-cursor-ripple 460ms 向前缓出; }" + "e16479c1c5": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, "FeatureTourPreview": { "1170621527": ">", @@ -11007,18 +11082,18 @@ "e4473d438f": "用 AI 生成", "c30cd930ff": "创建PR", "ea0100dd15": "查看全部", - "e725000cd7": "变化", + "e725000cd7": "更改", "a079083a6c": "提交", "7347fa5839": "信息", - "d1a7f15876": "使用 AI 生成 commit 消息", - "cd8a3a39d7": "提前 3 次 commits" + "d1a7f15876": "使用 AI 生成提交信息", + "cd8a3a39d7": "领先 3 个提交" }, "TasksAnimatedVisual": { "efba6f77eb": "阅读议题 #", "b68c92fbdc": "启动工作区", - "4331c4d0f8": "开放", + "4331c4d0f8": "打开", "b13375617e": "工作树选择器截断名称", - "72f9e516a3": "紧迫", + "72f9e516a3": "按下", "fe47c9c9e8": "工作区准备就绪", "61ffda7601": "创建工作区" }, @@ -11201,7 +11276,7 @@ "fa809e8ada": "已打开 macOS 隐私与安全性", "bfa3402305": "无法请求权限", "c566bca278": "完全磁盘访问权限", - "0d6efe9cf4": "当项目或 worktree 位于受保护文件夹时,建议在 macOS 上开启。", + "0d6efe9cf4": "当项目或工作树位于受保护文件夹时,建议在 macOS 上开启。", "dac08ec03e": "正在打开...", "6e3d62b816": "打开完全磁盘访问权限" } @@ -12062,14 +12137,14 @@ }, "submit": { "notice": { - "unknownError": "The crash report request failed before it returned a reason.", - "ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.", - "uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.", - "checkConnection": "Check your connection and try again, or copy the details.", - "notSent": "Crash report wasn't sent", + "unknownError": "崩溃报告请求在返回原因之前失败。", + "ticketUploaded": "诊断工单 {{value0}} 已上传但未关联。", + "uncheckDiagnostics": "取消勾选\"附加最近的诊断日志\"并重试,或复制详细信息。", + "checkConnection": "请检查网络连接并重试,或复制详细信息。", + "notSent": "崩溃报告未发送", "copyDetails": "Copy Details", - "sentWithoutDiagnostics": "Crash report sent without diagnostic logs", - "diagnosticsReason": "Diagnostic logs were not attached: {{value0}}" + "sentWithoutDiagnostics": "崩溃报告已发送,但未附带诊断日志", + "diagnosticsReason": "诊断日志未附加:{{value0}}" } }, "copy": { @@ -12698,7 +12773,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud 站点 URL", "d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API token 来浏览议题。", - "8388bdea2b": "连接 Jira 站点" + "8388bdea2b": "连接 Jira 站点", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, @@ -13101,7 +13193,7 @@ "removeAttachment": "移除附件", "pastedImageLabel": "粘贴的图片", "imagePasteFailed": "图片粘贴失败。", - "worktreeNotReady": "Worktree 未就绪 — 请稍后重试。", + "worktreeNotReady": "工作树未就绪 — 请稍后重试。", "uploadingAttachments": "正在上传 {{value0}} 个文件到远程…" }, "tool": { @@ -13125,6 +13217,7 @@ "title": "无法加载对话", "subtitle": "无法读取记录。请切回终端继续工作。" }, + "pairHost": "配对主机以查看智能体聊天记录。", "notAgent": { "title": "这里没有对话", "subtitle": "此终端未运行可识别的编码智能体。" @@ -13149,7 +13242,8 @@ "cancel": "取消", "send": "发送回答", "next": "下一步", - "skip": "跳过" + "skip": "跳过", + "sending": "正在发送…" }, "approval": { "title": "允许 {{value0}}?", diff --git a/src/renderer/src/lib/activity-thread-display.test.ts b/src/renderer/src/lib/activity-thread-display.test.ts index c553ceb02f3..1728c8ab5ca 100644 --- a/src/renderer/src/lib/activity-thread-display.test.ts +++ b/src/renderer/src/lib/activity-thread-display.test.ts @@ -112,7 +112,11 @@ describe('getActivityThreadTaskTitle', () => { }) it('keeps orchestration labels across terse follow-ups but yields to new work', () => { - const orchestration = { taskId: 'task-1', dispatchId: 'ctx-1', displayName: 'Fix checkout race' } + const orchestration = { + taskId: 'task-1', + dispatchId: 'ctx-1', + displayName: 'Fix checkout race' + } // Terse follow-up → still the same orchestration task. expect( getActivityThreadTaskTitle({ diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 141da5c2a3a..aaf3fc4795c 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -105,36 +105,9 @@ export function getWorkingAgentsPerWorktree({ return result } -const WELL_KNOWN_LABELS: Record = { - claude: 'Claude', - openclaude: 'OpenClaude', - codex: 'Codex', - gemini: 'Gemini', - antigravity: 'Antigravity', - amp: 'Amp', - copilot: 'GitHub Copilot', - opencode: 'OpenCode', - 'mimo-code': 'MiMo Code', - cursor: 'Cursor', - aider: 'Aider', - pi: 'Pi', - omp: 'OMP', - droid: 'Droid', - 'command-code': 'Command Code', - grok: 'Grok', - hermes: 'Hermes', - devin: 'Devin', - ante: 'Ante', - kimi: 'Kimi' -} - -export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { - if (!agentType || agentType === 'unknown') { - return 'Agent' - } - // Capitalize well-known names nicely; pass through custom names as-is - return WELL_KNOWN_LABELS[agentType] ?? agentType -} +// Re-exported from shared so mobile shows the same agent labels (one source of +// truth). Kept re-exported here so existing `@/lib/agent-status` importers work. +export { formatAgentTypeLabel } from '../../../shared/agent-type-label' // Why: AgentIcon expects a TuiAgent, but AgentType is a broader union // (WellKnownAgentType | (string & {})) that includes 'unknown' and arbitrary diff --git a/src/renderer/src/lib/crash-diagnostics.test.ts b/src/renderer/src/lib/crash-diagnostics.test.ts index d093c8faf61..4c95495abf4 100644 --- a/src/renderer/src/lib/crash-diagnostics.test.ts +++ b/src/renderer/src/lib/crash-diagnostics.test.ts @@ -125,11 +125,34 @@ describe('renderer crash diagnostics', () => { message: 'ResizeObserver loop completed with undelivered notifications.', preventDefault }) + listeners.get('error')?.[0]?.({ + message: 'ResizeObserver loop limit exceeded', + preventDefault + }) - expect(preventDefault).toHaveBeenCalledTimes(1) + expect(preventDefault).toHaveBeenCalledTimes(2) expect(recordBreadcrumbMock).not.toHaveBeenCalled() }) + it('records application errors that only mention a ResizeObserver loop', () => { + diagnostics.installRendererCrashDiagnostics() + recordBreadcrumbMock.mockClear() + + const preventDefault = vi.fn() + listeners.get('error')?.[0]?.({ + message: 'ResizeObserver loop failed while rendering the terminal', + preventDefault + }) + + expect(preventDefault).not.toHaveBeenCalled() + expect(recordBreadcrumbMock).toHaveBeenCalledWith({ + name: 'renderer_error', + data: expect.objectContaining({ + message: 'ResizeObserver loop failed while rendering the terminal' + }) + }) + }) + it('disposes global listeners and the memory interval', () => { diagnostics.installRendererCrashDiagnostics() diff --git a/src/renderer/src/lib/crash-diagnostics.ts b/src/renderer/src/lib/crash-diagnostics.ts index b718917d9ed..aedb6d4bf8c 100644 --- a/src/renderer/src/lib/crash-diagnostics.ts +++ b/src/renderer/src/lib/crash-diagnostics.ts @@ -67,7 +67,11 @@ function recordRendererError(event: ErrorEvent): void { // Why: "ResizeObserver loop completed" is a benign, self-resolving Chromium // quirk. Recording it fills the breadcrumb buffer and inflates the error // count without diagnostic value, contributing to renderer heap growth (#8260). - if (/ResizeObserver loop/i.test(event.message)) { + if ( + /^ResizeObserver loop (?:limit exceeded|completed with undelivered notifications)\.?$/i.test( + event.message + ) + ) { event.preventDefault() return } diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 1c24ae7683a..a636cd147c0 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -559,7 +559,10 @@ describe('launchAgentBackgroundSession', () => { }) ).rejects.toThrow('spawn failed') - expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { recordInteraction: false }) + expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { + recordInteraction: false, + reason: 'cleanup' + }) expect(mockUpdateTabPtyId).not.toHaveBeenCalled() }) @@ -875,7 +878,10 @@ describe('launchAgentBackgroundSession', () => { }) expect(state.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(expect.stringMatching(/^tab-1:/)) - expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { recordInteraction: false }) + expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { + recordInteraction: false, + reason: 'cleanup' + }) expect(mockDispatchEvent).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index bb2f102f681..10f96825407 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -326,7 +326,11 @@ export async function launchAgentBackgroundSession( if (ptyId) { await retireProvider({ ptyId, runtimeTarget, runtimeTerminalHandle }) } - runBestEffortAgentBackgroundCleanups(() => store.closeTab(tab.id, { recordInteraction: false })) + // Why: a launch-failure cleanup close is not a user close — keep it out of + // the Cmd+Shift+T reopen stack. + runBestEffortAgentBackgroundCleanups(() => + store.closeTab(tab.id, { recordInteraction: false, reason: 'cleanup' }) + ) throw error } } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 467ca7d07fe..c43f98ef969 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -307,7 +307,9 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() await Promise.resolve() expect(mockSetActiveTabType).toHaveBeenCalledWith('terminal') - expect(store.closeTab).toHaveBeenCalledWith('stale-agent-tab') + expect(store.closeTab).toHaveBeenCalledWith('stale-agent-tab', { + reason: 'cleanup' + }) }) it('forwards prompt launch env and captured config to paired web runtime hosts', async () => { diff --git a/src/renderer/src/lib/launch-agent-web-host-tab.ts b/src/renderer/src/lib/launch-agent-web-host-tab.ts index c685dea9968..d5275b53280 100644 --- a/src/renderer/src/lib/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -12,7 +12,9 @@ function removeStaleLocalAgentTabsForWebHostLaunch(worktreeId: string): void { const state = useAppStore.getState() for (const tab of state.tabsByWorktree[worktreeId] ?? []) { if (tab.launchAgent && !isWebTerminalSurfaceTabId(tab.id)) { - state.closeTab(tab.id) + // Why: pruning a stale local agent tab is a system close — keep it out of + // the Cmd+Shift+T reopen stack. + state.closeTab(tab.id, { reason: 'cleanup' }) } } } diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts index 10dce9d658a..a2c2218d2ad 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts @@ -269,7 +269,10 @@ describe('launchWorktreeBackgroundTerminals', () => { } }) - expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { recordInteraction: false }) + expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { + recordInteraction: false, + reason: 'cleanup' + }) expect(mockSpawn).toHaveBeenCalledTimes(3) expect(mockSpawn).toHaveBeenNthCalledWith( 3, diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts index 568f68e6f22..85a02598484 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts @@ -178,7 +178,7 @@ async function createBackgroundTab(args: { env: args.launch.env }) } catch (error) { - store.closeTab(tab.id, { recordInteraction: false }) + store.closeTab(tab.id, { recordInteraction: false, reason: 'cleanup' }) throw error } if ( diff --git a/src/renderer/src/lib/linear-linked-work-item.test.ts b/src/renderer/src/lib/linear-linked-work-item.test.ts index d6f3da3de84..10c11a9dde1 100644 --- a/src/renderer/src/lib/linear-linked-work-item.test.ts +++ b/src/renderer/src/lib/linear-linked-work-item.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import type { LinearIssue } from '../../../shared/types' -import { buildLinearIssueLinkedWorkItem, isLinearLinkedWorkItem } from './linear-linked-work-item' +import { + buildLinearIssueLinkedWorkItem, + getLinearLinkedWorkItemBranchName, + isLinearLinkedWorkItem +} from './linear-linked-work-item' function makeIssue(patch: Partial = {}): LinearIssue { return { @@ -42,6 +46,20 @@ describe('buildLinearIssueLinkedWorkItem', () => { expect(item.linearWorkspaceId).toBe('ws-1') }) + + it('carries a normalized usable Linear branch name', () => { + const item = buildLinearIssueLinkedWorkItem( + makeIssue({ branchName: ' team/eng-123-fix-launch-context ' }) + ) + + expect(item.linearBranchName).toBe('team/eng-123-fix-launch-context') + }) + + it('omits unusable Linear branch names', () => { + const item = buildLinearIssueLinkedWorkItem(makeIssue({ branchName: ' ' })) + + expect(item).not.toHaveProperty('linearBranchName') + }) }) describe('isLinearLinkedWorkItem', () => { @@ -52,4 +70,20 @@ describe('isLinearLinkedWorkItem', () => { expect(isLinearLinkedWorkItem({})).toBe(false) expect(isLinearLinkedWorkItem(null)).toBe(false) }) + + it('only resolves branch overrides from Linear-linked items', () => { + expect( + getLinearLinkedWorkItemBranchName({ + provider: 'linear', + linearIdentifier: 'ENG-123', + linearBranchName: ' team/eng-123-fix ' + }) + ).toBe('team/eng-123-fix') + expect( + getLinearLinkedWorkItemBranchName({ + provider: 'github', + linearBranchName: 'team/eng-123-fix' + }) + ).toBeUndefined() + }) }) diff --git a/src/renderer/src/lib/linear-linked-work-item.ts b/src/renderer/src/lib/linear-linked-work-item.ts index 5bb212f2b48..d1219bad8dc 100644 --- a/src/renderer/src/lib/linear-linked-work-item.ts +++ b/src/renderer/src/lib/linear-linked-work-item.ts @@ -1,6 +1,9 @@ import type { LinearIssue } from '../../../shared/types' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' -import { buildLinearWorkspaceSource } from '../../../shared/new-workspace/workspace-source' +import { + buildLinearWorkspaceSource, + getUsableLinearBranchName +} from '../../../shared/new-workspace/workspace-source' export function isLinearLinkedWorkItem( item: Pick | null | undefined @@ -8,6 +11,17 @@ export function isLinearLinkedWorkItem( return item?.provider === 'linear' || Boolean(item?.linearIdentifier?.trim()) } +export function getLinearLinkedWorkItemBranchName( + item: + | Pick + | null + | undefined +): string | undefined { + return isLinearLinkedWorkItem(item) + ? getUsableLinearBranchName(item?.linearBranchName) + : undefined +} + export function buildLinearIssueLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { return buildLinearWorkspaceSource(issue) } diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts new file mode 100644 index 00000000000..6529ebca17f --- /dev/null +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import { planMobileTerminalTabMount } from './mobile-terminal-tab-mount' + +type PlannerState = Pick + +function state(tabCount = 1): PlannerState { + return { + tabsByWorktree: { + wt: Array.from({ length: tabCount }, (_, index) => ({ + id: `tab-${index}`, + ptyId: `wt@@${index}` + })) + } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: {} + } +} + +describe('planMobileTerminalTabMount', () => { + it('keeps real-tab requests targeted to exactly one tab', () => { + expect(planMobileTerminalTabMount(state(), { worktreeId: 'wt', tabId: 'tab-0' })).toEqual({ + worktreeId: 'wt', + tabIds: ['tab-0'] + }) + }) + + it('resolves synthetic handles to exactly one owning tab at workspace scale', () => { + expect(planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@173' })).toEqual({ + worktreeId: 'wt', + tabIds: ['tab-173'] + }) + }) + + it('does not mount the whole worktree when a stale pty id has no owner', () => { + expect( + planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@missing' }) + ).toBeNull() + }) + + it('does not mount either tab when stale persistence has duplicate pty ownership', () => { + const s = state(200) + s.terminalLayoutsByTabId['tab-199'] = { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { leaf: 'wt@@173' } + } + + expect(planMobileTerminalTabMount(s, { worktreeId: 'wt', ptyId: 'wt@@173' })).toBeNull() + }) + + it('does not mount a hidden worktree for a stale direct tab id', () => { + const isTabMounted = vi.fn() + + expect( + planMobileTerminalTabMount( + state(200), + { worktreeId: 'wt', tabId: 'tab-missing' }, + { isTabMounted } + ) + ).toBeNull() + expect(isTabMounted).not.toHaveBeenCalled() + }) + + it('does not schedule hidden layout work for an already-mounted tab', () => { + const isTabMounted = vi.fn().mockReturnValue(true) + + expect( + planMobileTerminalTabMount( + state(200), + { worktreeId: 'wt', ptyId: 'wt@@173' }, + { isTabMounted } + ) + ).toBeNull() + expect(isTabMounted).toHaveBeenCalledTimes(1) + expect(isTabMounted).toHaveBeenCalledWith('tab-173') + }) +}) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.ts new file mode 100644 index 00000000000..28431cc5ad6 --- /dev/null +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.ts @@ -0,0 +1,41 @@ +import type { AppState } from '@/store/types' +import type { BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal' +import { resolveTerminalTabIdForPtyId } from './terminal-tab-for-pty-id' + +export type MobileTerminalTabMountRequest = { + worktreeId: string + tabId?: string + ptyId?: string +} + +type MobileTerminalTabMountOptions = { + isTabMounted?: (tabId: string) => boolean +} + +/** Why: exact-tab planning prevents a stale ptyId from mounting every saved xterm (#8597). */ +export function planMobileTerminalTabMount( + state: Pick, + request: MobileTerminalTabMountRequest, + options: MobileTerminalTabMountOptions = {} +): BackgroundMountTerminalWorktreeDetail | null { + if (!request.worktreeId) { + return null + } + const requestedTabExists = request.tabId + ? (state.tabsByWorktree[request.worktreeId] ?? []).some((tab) => tab.id === request.tabId) + : false + // Why: stale real-tab handles must fail closed like stale synthetic handles; + // otherwise they mount and measure a hidden worktree with no pane to recover. + const tabId = request.tabId + ? requestedTabExists + ? request.tabId + : null + : request.ptyId + ? resolveTerminalTabIdForPtyId(state, request.worktreeId, request.ptyId) + : null + // Why: replaying the background-mount event for a live pane restarts its + // three-second hidden measurement window on every mobile reconnect. + return tabId && !options.isTabMounted?.(tabId) + ? { worktreeId: request.worktreeId, tabIds: [tabId] } + : null +} diff --git a/src/renderer/src/lib/native-chat-supported-agent.ts b/src/renderer/src/lib/native-chat-supported-agent.ts index a376fe0d5ec..84135bba8f4 100644 --- a/src/renderer/src/lib/native-chat-supported-agent.ts +++ b/src/renderer/src/lib/native-chat-supported-agent.ts @@ -1,19 +1,4 @@ -import type { AgentType } from '../../../shared/agent-status-types' -import type { TuiAgent } from '../../../shared/types' - -/** Agents whose transcripts the native chat view can parse and render. */ -export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ - 'claude', - 'openclaude', - 'codex', - // Why: Grok writes `~/.grok/sessions///chat_history.jsonl` and - // Orca already reads that for hooks/AI Vault — native chat reuses the same - // path + a Grok JSONL decoder rather than leaving Grok TUI-only. - 'grok' -]) - -export function isNativeChatSupportedAgent( - agent: TuiAgent | AgentType | null | undefined -): boolean { - return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) -} +export { + isNativeChatSupportedAgent, + NATIVE_CHAT_SUPPORTED_AGENTS +} from '../../../shared/native-chat-agent-support' diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 0a7505762d0..4ca67db446e 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -59,6 +59,7 @@ export type LinkedWorkItemSummary = Omit provider?: FolderWorkspaceLinkedTask['provider'] linearWorkspaceId?: string linearOrganizationUrlKey?: string + linearBranchName?: string linkedContext?: LinkedWorkItemContext } diff --git a/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts b/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts new file mode 100644 index 00000000000..a863b503fe6 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDivider } from './pane-divider' + +type PaneElement = HTMLElement & { style: Record } + +type DividerDragHarness = { + divider: HTMLElement + dividerListeners: Map + windowListeners: Map + capturedPointerIds: Set + previousPane: PaneElement + nextPane: PaneElement + onLayoutChanged: ReturnType + flushAnimationFrames: () => void +} + +function createPaneElement(width: number): PaneElement { + return { + style: {}, + classList: { contains: vi.fn(() => false) }, + dispatchEvent: vi.fn(() => true), + getBoundingClientRect: vi.fn(() => ({ + left: 0, + top: 0, + right: width, + bottom: 200, + width, + height: 200 + })), + querySelectorAll: vi.fn(() => []) + } as unknown as PaneElement +} + +function createPointerEvent(args: Partial): PointerEvent { + return { + preventDefault: vi.fn(), + pointerId: 1, + clientX: 0, + clientY: 0, + ...args + } as unknown as PointerEvent +} + +function createDividerDragHarness(): DividerDragHarness { + const dividerListeners = new Map() + const windowListeners = new Map() + const capturedPointerIds = new Set() + const animationFrames = new Map() + const previousPane = createPaneElement(100) + const nextPane = createPaneElement(300) + const divider = { + style: { setProperty: vi.fn() }, + classList: { add: vi.fn(), remove: vi.fn() }, + addEventListener: vi.fn((event: string, listener: EventListener) => { + dividerListeners.set(event, listener) + }), + removeEventListener: vi.fn((event: string, listener: EventListener) => { + if (dividerListeners.get(event) === listener) { + dividerListeners.delete(event) + } + }), + setPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.add(pointerId)), + hasPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.has(pointerId)), + releasePointerCapture: vi.fn((pointerId: number) => capturedPointerIds.delete(pointerId)), + previousElementSibling: previousPane, + nextElementSibling: nextPane + } as unknown as HTMLElement + vi.stubGlobal('document', { createElement: vi.fn(() => divider) }) + vi.stubGlobal('window', { + addEventListener: vi.fn((event: string, listener: EventListener) => { + windowListeners.set(event, listener) + }), + removeEventListener: vi.fn((event: string, listener: EventListener) => { + if (windowListeners.get(event) === listener) { + windowListeners.delete(event) + } + }) + }) + let nextFrameId = 0 + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + nextFrameId += 1 + animationFrames.set(nextFrameId, callback) + return nextFrameId + }) + ) + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((frameId: number) => animationFrames.delete(frameId)) + ) + const onLayoutChanged = vi.fn() + createDivider(true, {}, { refitPanesUnder: vi.fn(), onLayoutChanged }) + + return { + divider, + dividerListeners, + windowListeners, + capturedPointerIds, + previousPane, + nextPane, + onLayoutChanged, + flushAnimationFrames: () => { + for (const [frameId, callback] of animationFrames) { + animationFrames.delete(frameId) + callback(16) + } + } + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('divider pointer capture loss', () => { + it('continues through window events and commits the final drag position', () => { + const harness = createDividerDragHarness() + harness.dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 9, clientX: 100 }) + ) + harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 180 })) + harness.flushAnimationFrames() + + expect(harness.previousPane.style.flex).toBe('180 1 0%') + expect(harness.dividerListeners.has('lostpointercapture')).toBe(false) + + harness.capturedPointerIds.delete(9) + harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 220 })) + harness.flushAnimationFrames() + harness.windowListeners.get('pointerup')?.(createPointerEvent({ pointerId: 9, clientX: 220 })) + + expect(harness.previousPane.style.flex).toBe('220 1 0%') + expect(harness.nextPane.style.flex).toBe('180 1 0%') + expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1) + }) + + it('still restores the original layout when the window loses focus', () => { + const harness = createDividerDragHarness() + harness.previousPane.style.flex = '2 1 0%' + harness.nextPane.style.flex = '3 1 0%' + harness.dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 9, clientX: 100 }) + ) + harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 180 })) + harness.flushAnimationFrames() + + harness.windowListeners.get('blur')?.({} as Event) + + expect(harness.previousPane.style.flex).toBe('2 1 0%') + expect(harness.nextPane.style.flex).toBe('3 1 0%') + expect(harness.onLayoutChanged).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts index 0b552ccc4ca..8fd5d3443ca 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts @@ -93,6 +93,8 @@ export function attachDividerDrag( if (windowListenersAttached || typeof window === 'undefined') { return } + // Why: Chromium can transiently lose capture while the button remains held, + // so window events keep ownership until pointerup, pointercancel, or blur. windowListenersAttached = true window.addEventListener('pointermove', onPointerMove, true) window.addEventListener('pointerup', onPointerUp, true) @@ -264,12 +266,6 @@ export function attachDividerDrag( } } - const onLostPointerCapture = (): void => { - if (dragging) { - finishActiveDrag(false) - } - } - const onWindowBlur = (): void => { finishActiveDrag(false) } @@ -278,7 +274,6 @@ export function attachDividerDrag( divider.addEventListener('pointermove', onPointerMove) divider.addEventListener('pointerup', onPointerUp) divider.addEventListener('pointercancel', onPointerCancel) - divider.addEventListener('lostpointercapture', onLostPointerCapture) divider.addEventListener('dblclick', onDoubleClick) dividerDragCleanups.set(divider, () => { finishActiveDrag(false) @@ -286,7 +281,6 @@ export function attachDividerDrag( divider.removeEventListener('pointermove', onPointerMove) divider.removeEventListener('pointerup', onPointerUp) divider.removeEventListener('pointercancel', onPointerCancel) - divider.removeEventListener('lostpointercapture', onLostPointerCapture) divider.removeEventListener('dblclick', onDoubleClick) }) } diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts index daf76998950..e9c891baabc 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts @@ -5,6 +5,11 @@ import { detachPaneFitResizeObserver, requestStablePaneFit } from './pane-fit-resize-observer' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' +import { safeFitAndThen } from './pane-tree-ops' type ResizeObserverCallbackLike = ConstructorParameters[0] @@ -174,6 +179,39 @@ describe('attachPaneFitResizeObserver', () => { expect(onSettled).toHaveBeenCalledTimes(1) }) + it('releases a hidden reattach continuation when the stable grid already matches', async () => { + const pane = createPane() + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue(undefined) + const continuation = vi.fn() + const pending = safeFitAndThen(pane, 'reattach-pty-resize', continuation) + + expect(continuation).not.toHaveBeenCalled() + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue({ cols: 79, rows: 24 }) + requestStablePaneFit(pane) + flushAnimationFrames() + + await expect(pending.completion).resolves.toBe(true) + expect(continuation).toHaveBeenCalledTimes(1) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + + it('does not notify settled callbacks until a replay-deferred fit completes', async () => { + const onSettled = vi.fn() + const pane = createPane() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + requestStablePaneFit(pane, onSettled) + flushAnimationFrames() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(onSettled).not.toHaveBeenCalled() + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(onSettled).toHaveBeenCalledTimes(1) + }) + it('skips observer fits while the terminal has no visible geometry', () => { const pane = createPane(() => ({ cols: 80, rows: 24 }), { rect: { width: 0, height: 0 } diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts index 867a2b5dd18..53d2f6b6f11 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts @@ -1,5 +1,5 @@ import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' -import { safeFit } from './pane-tree-ops' +import { cancelPendingSafeFitContinuations, safeFitAndThen } from './pane-tree-ops' type ProposedDimensions = { cols: number @@ -74,12 +74,11 @@ function flushStableFitCallbacks(pane: StableFitPane): void { } } -function finishStableFit(pane: StableFitPane, shouldFit: boolean): void { +function finishStableFit(pane: StableFitPane): void { setPendingObservedFitRafId(pane, null) - if (shouldFit) { - safeFit(pane) - } - flushStableFitCallbacks(pane) + // Why: an equal grid still proves a restored pane is measurable, so it must + // release reattach continuations that were parked while the tab was hidden. + safeFitAndThen(pane, 'stable-pane-fit', () => flushStableFitCallbacks(pane)) } export function requestStablePaneFit(pane: StableFitPane, onSettled?: () => void): void { @@ -112,23 +111,23 @@ export function requestStablePaneFit(pane: StableFitPane, onSettled?: () => void frameCount += 1 if (!next) { - finishStableFit(pane, true) + finishStableFit(pane) return } if (terminalDimensionsEqual(pane, next)) { - finishStableFit(pane, false) + finishStableFit(pane) return } if (dimensionsEqual(previous, next)) { - finishStableFit(pane, true) + finishStableFit(pane) return } previous = next if (frameCount >= MAX_STABILITY_FRAMES) { - finishStableFit(pane, true) + finishStableFit(pane) return } @@ -164,4 +163,5 @@ export function detachPaneFitResizeObserver(pane: ManagedPaneInternal): void { setPendingObservedFitRafId(pane, null) } stableFitCallbacks.delete(pane) + cancelPendingSafeFitContinuations(pane) } diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts new file mode 100644 index 00000000000..573ba6ab7ed --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -0,0 +1,257 @@ +import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' +import { getFitOverrideForPty } from './mobile-fit-overrides' +import { + captureTerminalStructuralScrollIntent, + isTerminalStructuralScrollIntentCurrent, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' +import { + captureScrollState, + releaseScrollStateMarker, + restoreScrollStateAfterFit, + resumePendingFitScrollRestoreAfterFit +} from './pane-scroll' +import { + deferTerminalGeometryMutationDuringRebuild, + isTerminalScrollIntentRebuildInFlight +} from './terminal-scroll-intent-rebuild' + +const MIN_PANE_FIT_WIDTH_PX = 48 +const MIN_PANE_FIT_HEIGHT_PX = 24 +const MIN_PANE_FIT_COLS = 8 +const MIN_PANE_FIT_ROWS = 4 + +export type SafeFitContinuationHandle = { + completion: Promise + cancel: () => void +} + +type PendingSafeFitContinuation = { + continuation: () => void + shouldContinue: () => boolean + resolve: (completed: boolean) => void +} + +const pendingSafeFitContinuations = new WeakMap< + ManagedPane, + Map +>() + +function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null { + try { + return pane.fitAddon.proposeDimensions() ?? null + } catch { + return null + } +} + +function canMeasurePaneForFit(pane: ManagedPane): boolean { + const measure = pane.container?.getBoundingClientRect + if (typeof measure === 'function') { + const rect = measure.call(pane.container) + if (rect.width < MIN_PANE_FIT_WIDTH_PX || rect.height < MIN_PANE_FIT_HEIGHT_PX) { + return false + } + } + const dims = getProposedDimensions(pane) + if (!dims) { + return false + } + // Why: worktree switches can briefly measure a near-zero overlay before + // fallback positioning lands. Fitting there pins the PTY at ~2 cols. + return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS +} + +function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { + // Why: split reparent has its own delayed restore; restoring here can fight that timer. + return !( + 'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState + ) +} + +function performSafeFit(pane: ManagedPane): boolean { + if (deferTerminalGeometryMutationDuringRebuild(pane.terminal, 'safe-fit', () => safeFit(pane))) { + return false + } + if (!canMeasurePaneForFit(pane)) { + return false + } + let scrollIntent = null as ReturnType + let pinnedScrollState: ScrollState | null = null + let shouldRestoreScroll = false + const captureScrollForFit = (): void => { + scrollIntent = captureTerminalStructuralScrollIntent(pane.terminal) + // Why: fit can reflow and renumber every buffer row; a marker tracks the + // pinned content itself, while a numeric line would point elsewhere after. + pinnedScrollState = + scrollIntent?.kind === 'pinnedViewport' ? captureScrollState(pane.terminal) : null + shouldRestoreScroll = true + } + try { + // Why: a mobile-owned PTY must stay at its phone grid on passive desktop panes. + const ptyId = pane.container?.dataset?.ptyId + const override = ptyId ? getFitOverrideForPty(ptyId) : null + if (override) { + if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) { + if (canPreserveScrollIntentForFit(pane)) { + captureScrollForFit() + } + pane.terminal.resize(override.cols, override.rows) + } else { + resumePendingFitScrollRestoreAfterFit(pane.terminal) + } + return true + } + + const dims = getProposedDimensions(pane) + if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { + // Why: divider drags often stay within one cell; avoid needless clear/refresh churn. + resumePendingFitScrollRestoreAfterFit(pane.terminal) + return true + } + if (canPreserveScrollIntentForFit(pane)) { + captureScrollForFit() + } + pane.fitAddon.fit() + return true + } catch { + // Container may not have dimensions yet. + return false + } finally { + if (shouldRestoreScroll) { + try { + if (resumePendingFitScrollRestoreAfterFit(pane.terminal)) { + } else if (pinnedScrollState) { + const state: ScrollState = pinnedScrollState + pinnedScrollState = null + restoreScrollStateAfterFit(pane.terminal, state, { + onRestored: () => { + // Why: do not replace a durable pre-replay pin with transient 0/0 geometry. + if (!state.wasAtBottom) { + markTerminalPinnedViewport(pane.terminal) + } + }, + shouldRestore: () => + !isTerminalScrollIntentRebuildInFlight(pane.terminal) && + isTerminalStructuralScrollIntentCurrent(pane.terminal, scrollIntent) + }) + } else { + restoreTerminalStructuralScrollIntent(pane.terminal, scrollIntent) + } + } catch { + // Why: SSH reattach can briefly expose xterm without renderer dimensions. + } finally { + if (pinnedScrollState) { + releaseScrollStateMarker(pinnedScrollState) + } + } + } + } +} + +function settlePendingSafeFitContinuation( + pane: ManagedPane, + operationKey: string, + pending: PendingSafeFitContinuation, + completed: boolean +): void { + const operations = pendingSafeFitContinuations.get(pane) + if (operations?.get(operationKey) !== pending) { + return + } + operations.delete(operationKey) + if (operations.size === 0) { + pendingSafeFitContinuations.delete(pane) + } + pending.resolve(completed) +} + +function flushPendingSafeFitContinuations(pane: ManagedPane): void { + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + for (const [operationKey, pending] of operations) { + if (!pending.shouldContinue()) { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + continue + } + try { + pending.continuation() + settlePendingSafeFitContinuation(pane, operationKey, pending, true) + } catch { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + } + } +} + +export function safeFit(pane: ManagedPane): boolean { + const completed = performSafeFit(pane) + if (completed) { + // Why: replay transactions may be waiting for renderer dimensions; any + // successful ordinary fit is the event that makes their PTY grid authoritative. + flushPendingSafeFitContinuations(pane) + } + return completed +} + +export function cancelPendingSafeFitContinuations(pane: ManagedPane): void { + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + pendingSafeFitContinuations.delete(pane) + for (const pending of operations.values()) { + pending.resolve(false) + } +} + +// Why: callers that forward xterm's grid to a PTY must wait for a measurable +// fit or explicit lifecycle cancellation instead of observing replay dimensions. +export function safeFitAndThen( + pane: ManagedPane, + operationKey: string, + continuation: () => void, + options: { shouldContinue?: () => boolean } = {} +): SafeFitContinuationHandle { + const operations = pendingSafeFitContinuations.get(pane) ?? new Map() + const replaced = operations.get(operationKey) + if (replaced) { + settlePendingSafeFitContinuation(pane, operationKey, replaced, false) + } + let resolveCompletion = (_completed: boolean): void => {} + const completion = new Promise((resolve) => { + resolveCompletion = resolve + }) + const pending: PendingSafeFitContinuation = { + continuation, + shouldContinue: options.shouldContinue ?? (() => true), + resolve: resolveCompletion + } + const currentOperations = pendingSafeFitContinuations.get(pane) ?? operations + currentOperations.set(operationKey, pending) + pendingSafeFitContinuations.set(pane, currentOperations) + const cancel = (): void => { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + } + if (!pending.shouldContinue()) { + cancel() + return { completion, cancel } + } + if ( + deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + `safe-fit-and-then:${operationKey}`, + () => { + if (pendingSafeFitContinuations.get(pane)?.get(operationKey) === pending) { + safeFit(pane) + } + } + ) + ) { + return { completion, cancel } + } + safeFit(pane) + return { completion, cancel } +} diff --git a/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts index 4d08013372f..81baa63f5c6 100644 --- a/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ManagedPaneInternal } from './pane-manager-types' import { disposePane } from './pane-lifecycle' +import { restoreScrollStateAfterFit } from './pane-scroll' function createPane(pendingInitialFitRafId: number | null): ManagedPaneInternal { const leafId = '11111111-1111-4111-8111-111111111111' as never @@ -54,4 +55,31 @@ describe('pane initial fit lifecycle', () => { expect(pane.pendingInitialFitRafId).toBeNull() expect(panes.has(pane.id)).toBe(false) }) + + it('cancels pending fit scroll restoration before terminal disposal', () => { + const cancelAnimationFrame = vi.fn() + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 23) + ) + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) + const pane = createPane(null) + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + + restoreScrollStateAfterFit( + pane.terminal, + { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + firstVisibleLineMarker: marker as never + }, + { onRestored: vi.fn(), shouldRestore: () => true } + ) + disposePane(pane, new Map([[pane.id, pane]])) + + expect(cancelAnimationFrame).toHaveBeenCalledWith(23) + expect(marker.dispose).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index 4b0fef324ac..a18e432d065 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -46,6 +46,7 @@ function createPane(): ManagedPaneInternal { loadAddon: vi.fn(), attachCustomWheelEventHandler: vi.fn(), refresh: vi.fn(), + cols: 80, rows: 24 } as never, container: {} as never, @@ -57,7 +58,8 @@ function createPane(): ManagedPaneInternal { webglDisabledAfterContextLoss: false, hasComplexScriptOutput: false, fitAddon: { - fit: vi.fn() + fit: vi.fn(), + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })) } as never, fitResizeObserver: null, pendingObservedFitRafId: null, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index adc43af01c1..ea938f9a351 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -5,9 +5,10 @@ import { detachPaneFitResizeObserver } from './pane-fit-resize-observer' import { clearPendingSplitScrollRestore } from './pane-split-scroll' +import { cancelDeferredScrollRestore } from './pane-scroll' import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider' import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' -import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent' +import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer' import { configureLazyArabicShapingJoiner } from './terminal-arabic-shaping-joiner' @@ -244,6 +245,13 @@ export function disposePane( } catch { /* ignore */ } + try { + // Why: fit retries own xterm markers and frame callbacks independently of + // split restoration; both must be released before terminal disposal. + cancelDeferredScrollRestore(pane.terminal) + } catch { + /* ignore */ + } try { pane.ligaturesAddon?.dispose() } catch { diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 8218de5e856..5f2c3d5fcdb 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -124,6 +124,8 @@ export type ScrollState = { viewportY: number baseY: number firstVisibleLineMarker?: IMarker + firstVisibleLogicalLineMarker?: IMarker + firstVisibleLogicalCellOffset?: number } export type ManagedPaneInternal = { diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.test.ts b/src/renderer/src/lib/pane-manager/pane-scroll.test.ts index 9d1317d8ed4..9378945e40d 100644 --- a/src/renderer/src/lib/pane-manager/pane-scroll.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-scroll.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { Terminal as HeadlessTerminal } from '@xterm/headless' import type { IMarker, Terminal } from '@xterm/xterm' import { captureScrollState, getTerminalOutputEpoch, recordTerminalOutput, restoreScrollState, + restoreScrollStateAfterFit, restoreScrollStateAfterLayout } from './pane-scroll' import type { ScrollState } from './pane-manager-types' @@ -59,6 +61,24 @@ function setMarkerLine(marker: IMarker, line: number): void { mutableMarker.line = line } +function writeHeadless(terminal: HeadlessTerminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +function findBufferLineContaining(terminal: HeadlessTerminal, text: string): number { + for (let lineY = 0; lineY < terminal.buffer.active.length; lineY += 1) { + if (terminal.buffer.active.getLine(lineY)?.translateToString(true).includes(text)) { + return lineY + } + } + return -1 +} + +function makeHeadlessRestorable(terminal: HeadlessTerminal): Terminal { + Object.defineProperty(terminal, 'element', { configurable: true, value: {} }) + return terminal as unknown as Terminal +} + describe('scroll state', () => { afterEach(() => { vi.useRealTimers() @@ -286,6 +306,61 @@ describe('scroll state', () => { expect(terminal.buffer.active.viewportY).toBe(30) }) + it('releases fit markers when restoration throws an unexpected error', () => { + const terminal = createTerminal({ viewportY: 10, baseY: 100 }) + const marker = createMarker(42) + vi.mocked(terminal.scrollToLine).mockImplementation(() => { + throw new Error('unexpected renderer failure') + }) + const state: ScrollState = { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + firstVisibleLineMarker: marker + } + + expect(() => + restoreScrollStateAfterFit(terminal, state, { + onRestored: vi.fn(), + shouldRestore: () => true + }) + ).toThrow('unexpected renderer failure') + expect(marker.isDisposed).toBe(true) + }) + + it('releases fit markers when an asynchronous retry throws', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const terminal = createTerminal({ viewportY: 10, baseY: 100 }) + const marker = createMarker(42) + vi.mocked(terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementationOnce(() => { + throw new Error('unexpected asynchronous renderer failure') + }) + + restoreScrollStateAfterFit( + terminal, + { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + firstVisibleLineMarker: marker + }, + { onRestored: vi.fn(), shouldRestore: () => true } + ) + + expect(() => frameCallbacks.shift()?.(0)).toThrow('unexpected asynchronous renderer failure') + expect(marker.isDisposed).toBe(true) + }) + it('scrolls to the current bottom when the pane was previously at bottom', () => { const terminal = createTerminal({ viewportY: 10, baseY: 250 }) const state: ScrollState = { @@ -316,4 +391,142 @@ describe('scroll state', () => { expect(terminal.scrollToLine).not.toHaveBeenCalled() expect(terminal.buffer.active.viewportY).toBe(10) }) + + it.each([ + { + fromCols: 10, + toCols: 20, + pinnedText: 'abcdefghij', + expectedTop: 'ABCDEFGHIJabcdefghij' + }, + { fromCols: 20, toCols: 7, pinnedText: 'KLMNOPQRSTuvwxyz', expectedTop: 'efghijK' } + ])( + 'restores the same logical cells through real xterm reflow ($fromCols->$toCols)', + async ({ fromCols, toCols, pinnedText, expectedTop }) => { + const headless = new HeadlessTerminal({ + cols: fromCols, + rows: 5, + scrollback: 1000, + allowProposedApi: true + }) + try { + await writeHeadless(headless, 'prefix\r\n') + await writeHeadless(headless, 'ABCDEFGHIJabcdefghijKLMNOPQRSTuvwxyz\r\n') + for (let index = 0; index < 10; index += 1) { + await writeHeadless(headless, `tail-${index}\r\n`) + } + const pinnedLine = findBufferLineContaining(headless, pinnedText) + expect(pinnedLine).toBeGreaterThan(0) + headless.scrollToLine(pinnedLine) + const terminal = makeHeadlessRestorable(headless) + const state = captureScrollState(terminal) + + headless.resize(toCols, 5) + expect(state.firstVisibleLogicalLineMarker?.isDisposed).toBe(false) + expect(restoreScrollState(terminal, state)).toBe(true) + + expect( + headless.buffer.active.getLine(headless.buffer.active.viewportY)?.translateToString(true) + ).toBe(expectedTop) + } finally { + headless.dispose() + } + } + ) + + it('uses a logical marker for backend-only ConPTY compatibility', async () => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 5, + scrollback: 1000, + allowProposedApi: true, + windowsPty: { backend: 'conpty' } + }) + try { + await writeHeadless(headless, 'prefix\r\n') + await writeHeadless(headless, 'ABCDEFGHIJabcdefghijKLMNOPQRSTuvwxyz\r\n') + for (let index = 0; index < 10; index += 1) { + await writeHeadless(headless, `tail-${index}\r\n`) + } + const pinnedLine = findBufferLineContaining(headless, 'abcdefghij') + headless.scrollToLine(pinnedLine) + const terminal = makeHeadlessRestorable(headless) + const state = captureScrollState(terminal) + + expect(state.firstVisibleLogicalLineMarker).toBeDefined() + headless.resize(20, 5) + expect(restoreScrollState(terminal, state)).toBe(true) + expect( + headless.buffer.active.getLine(headless.buffer.active.viewportY)?.translateToString(true) + ).toBe('ABCDEFGHIJabcdefghij') + } finally { + headless.dispose() + } + }) + + it('keeps a physical marker for the default non-reflowing cursor line', async () => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 3, + scrollback: 100, + allowProposedApi: true + }) + try { + await writeHeadless(headless, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') + const terminal = makeHeadlessRestorable(headless) + headless.scrollLines(-1) + const state = captureScrollState(terminal) + + expect(state.firstVisibleLineMarker).toBeDefined() + expect(state.firstVisibleLogicalLineMarker).toBeUndefined() + headless.resize(20, 3) + expect(restoreScrollState(terminal, state)).toBe(true) + } finally { + headless.dispose() + } + }) + + it('uses physical markers for legacy ConPTY and logical markers for modern ConPTY', async () => { + const captureForBuild = async (buildNumber: number): Promise => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 3, + scrollback: 100, + allowProposedApi: true, + windowsPty: { backend: 'conpty', buildNumber } + }) + await writeHeadless(headless, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\r\n') + await writeHeadless(headless, 'tail-1\r\ntail-2\r\ntail-3\r\n') + const pinnedLine = findBufferLineContaining(headless, 'KLMNOPQRST') + headless.scrollToLine(pinnedLine) + const state = captureScrollState(makeHeadlessRestorable(headless)) + headless.dispose() + return state + } + + const legacy = await captureForBuild(19045) + const modern = await captureForBuild(26100) + + expect(legacy.firstVisibleLogicalLineMarker).toBeUndefined() + expect(modern.firstVisibleLogicalLineMarker).toBeDefined() + }) + + it('counts a wide glyph wrap placeholder as zero logical cells', async () => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 3, + scrollback: 100, + allowProposedApi: true + }) + try { + await writeHeadless(headless, '123456789界abcdefghij\r\ntail-1\r\ntail-2\r\ntail-3\r\n') + const pinnedLine = findBufferLineContaining(headless, '界') + headless.scrollToLine(pinnedLine) + const state = captureScrollState(makeHeadlessRestorable(headless)) + + expect(state.firstVisibleLogicalCellOffset).toBe(9) + } finally { + headless.dispose() + } + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.ts b/src/renderer/src/lib/pane-manager/pane-scroll.ts index 083c41b4d31..bb79faa4556 100644 --- a/src/renderer/src/lib/pane-manager/pane-scroll.ts +++ b/src/renderer/src/lib/pane-manager/pane-scroll.ts @@ -1,9 +1,14 @@ import type { Terminal } from '@xterm/xterm' import type { ScrollState } from './pane-manager-types' +import { + captureLogicalLineAnchor, + resolveLogicalCellOffsetLine +} from './terminal-reflow-scroll-anchor' +import { forceTerminalViewportScrollbarSync } from './terminal-viewport-scrollbar-sync' const terminalOutputEpochs = new WeakMap() const deferredScrollRestores = new WeakMap< - Terminal, + object, { cancelled: boolean rafIds: number[] @@ -11,6 +16,19 @@ const deferredScrollRestores = new WeakMap< timeoutIds: ReturnType[] } >() +const pendingFitScrollRestores = new WeakMap< + object, + { + cancelled: boolean + rafId: number | null + retryAfterFit: () => boolean + shouldRestore: () => boolean + state: ScrollState + } +>() +const FIT_SCROLL_RESTORE_MAX_FRAMES = 2 + +type ScrollRestoreResult = 'restored' | 'retry' | 'skipped' export function recordTerminalOutput(terminal: Terminal): void { terminalOutputEpochs.set(terminal, getTerminalOutputEpoch(terminal) + 1) @@ -20,7 +38,8 @@ export function getTerminalOutputEpoch(terminal: Terminal): number { return terminalOutputEpochs.get(terminal) ?? 0 } -export function cancelDeferredScrollRestore(terminal: Terminal): void { +export function cancelDeferredScrollRestore(terminal: object): void { + cancelPendingFitScrollRestore(terminal) const pending = deferredScrollRestores.get(terminal) if (!pending) { return @@ -42,24 +61,138 @@ export function captureScrollState(terminal: Terminal): ScrollState { const buf = terminal.buffer.active const viewportY = buf.viewportY const wasAtBottom = viewportY >= buf.baseY + const logicalAnchor = + !wasAtBottom && buf.type === 'normal' + ? captureLogicalLineAnchor(terminal, viewportY) + : undefined + const firstVisibleLineMarker = + !wasAtBottom && buf.type === 'normal' + ? terminal.registerMarker?.(viewportY - (buf.baseY + buf.cursorY)) + : undefined return { bufferType: buf.type, wasAtBottom, viewportY, baseY: buf.baseY, - // Why: xterm markers track the same buffer line through resize reflow; - // a numeric viewport line alone can point at different content afterward. - firstVisibleLineMarker: - !wasAtBottom && buf.type === 'normal' - ? terminal.registerMarker?.(viewportY - (buf.baseY + buf.cursorY)) - : undefined + // Why: continuation-row markers can be deleted or drift during reflow. + // Keep the physical marker for no-reflow ConPTY/cursor-line cases, and + // anchor reflowing content at the logical line's stable first row. + firstVisibleLineMarker, + firstVisibleLogicalLineMarker: + logicalAnchor?.lineY === viewportY + ? firstVisibleLineMarker + : logicalAnchor + ? terminal.registerMarker?.(logicalAnchor.lineY - (buf.baseY + buf.cursorY)) + : undefined, + firstVisibleLogicalCellOffset: logicalAnchor?.cellOffset } } -export function restoreScrollState(terminal: Terminal, state: ScrollState): void { +export function restoreScrollState(terminal: Terminal, state: ScrollState): boolean { cancelDeferredScrollRestore(terminal) - restoreScrollStateNow(terminal, state) - releaseScrollStateMarker(state) + try { + return restoreScrollStateNow(terminal, state) === 'restored' + } finally { + releaseScrollStateMarker(state) + } +} + +export function restoreScrollStateAfterFit( + terminal: Terminal, + state: ScrollState, + options: { onRestored: () => void; shouldRestore: () => boolean } +): void { + cancelDeferredScrollRestore(terminal) + if (!options.shouldRestore()) { + releaseScrollStateMarker(state) + return + } + let initialResult: ScrollRestoreResult + try { + initialResult = restoreScrollStateNow(terminal, state) + } catch (error) { + releaseScrollStateMarker(state) + throw error + } + if (initialResult !== 'retry' || typeof requestAnimationFrame !== 'function') { + releaseScrollStateMarker(state) + if (initialResult === 'restored') { + options.onRestored() + } + return + } + + const pending = { + cancelled: false, + rafId: null as number | null, + retryAfterFit: (): boolean => false, + shouldRestore: options.shouldRestore, + state + } + let remainingFrames = FIT_SCROLL_RESTORE_MAX_FRAMES + const finish = (restored: boolean): void => { + if (pending.cancelled) { + return + } + pending.cancelled = true + pendingFitScrollRestores.delete(terminal) + releaseScrollStateMarker(state) + if (restored && options.shouldRestore()) { + options.onRestored() + } + } + const retry = (): boolean => { + pending.rafId = null + if (pending.cancelled || !options.shouldRestore()) { + finish(false) + return false + } + let result: ScrollRestoreResult + try { + result = restoreScrollStateNow(terminal, state) + } catch (error) { + finish(false) + throw error + } + if (result === 'restored') { + finish(true) + return true + } + remainingFrames -= 1 + if (result !== 'retry') { + finish(false) + return false + } + if (remainingFrames <= 0) { + // Why: background/WebGL teardown can outlast a bounded frame retry. + // Keep the content marker parked for the next real fit/reveal. + return true + } + pending.rafId = requestAnimationFrame(retry) + return true + } + pending.retryAfterFit = () => { + if (pending.rafId !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(pending.rafId) + pending.rafId = null + } + remainingFrames = FIT_SCROLL_RESTORE_MAX_FRAMES + 1 + return retry() + } + pendingFitScrollRestores.set(terminal, pending) + pending.rafId = requestAnimationFrame(retry) +} + +export function resumePendingFitScrollRestoreAfterFit(terminal: Terminal): boolean { + const pending = pendingFitScrollRestores.get(terminal) + if (!pending) { + return false + } + if (!pending.shouldRestore()) { + cancelPendingFitScrollRestore(terminal) + return false + } + return pending.retryAfterFit() } export function restoreScrollStateAfterLayout(terminal: Terminal, state: ScrollState): void { @@ -114,13 +247,13 @@ export function restoreScrollStateAfterLayout(terminal: Terminal, state: ScrollS deferredScrollRestores.set(terminal, pending) } -function restoreScrollStateNow(terminal: Terminal, state: ScrollState): void { +function restoreScrollStateNow(terminal: Terminal, state: ScrollState): ScrollRestoreResult { if (!terminal.element) { - return + return 'retry' } const buf = terminal.buffer.active if (state.bufferType === 'alternate' || buf.type !== state.bufferType) { - return + return 'skipped' } // Why: WebGL suspend disposes xterm's render service while leaving @@ -129,24 +262,42 @@ function restoreScrollStateNow(terminal: Terminal, state: ScrollState): void { // window quietly — the next visibility flip re-fits and re-restores. if (state.wasAtBottom) { if (safeScrollCall(() => terminal.scrollToBottom())) { - forceViewportScrollbarSync(terminal) + forceTerminalViewportScrollbarSync(terminal) + return 'restored' } - return + return 'retry' } + const logicalMarkerLine = + state.firstVisibleLogicalLineMarker && !state.firstVisibleLogicalLineMarker.isDisposed + ? state.firstVisibleLogicalLineMarker.line + : -1 const markerLine = state.firstVisibleLineMarker && !state.firstVisibleLineMarker.isDisposed ? state.firstVisibleLineMarker.line : -1 - const targetLine = Math.min(markerLine >= 0 ? markerLine : state.viewportY, buf.baseY) + const logicalTargetLine = + logicalMarkerLine >= 0 && state.firstVisibleLogicalCellOffset !== undefined + ? resolveLogicalCellOffsetLine( + terminal, + logicalMarkerLine, + state.firstVisibleLogicalCellOffset + ) + : null + const targetLine = Math.min( + logicalTargetLine ?? (markerLine >= 0 ? markerLine : state.viewportY), + buf.baseY + ) state.viewportY = targetLine // Why: deferred rAF/timeout restores re-invoke this function after xterm // reflow settles; keep the marker alive so each call consults the live // line. Callers (restoreScrollState, the timeout in // restoreScrollStateAfterLayout, cancelDeferredScrollRestore) own disposal. if (safeScrollCall(() => terminal.scrollToLine(targetLine))) { - forceViewportScrollbarSync(terminal) + forceTerminalViewportScrollbarSync(terminal) + return 'restored' } + return 'retry' } function safeScrollCall(fn: () => void): boolean { @@ -166,23 +317,21 @@ function safeScrollCall(fn: () => void): boolean { export function releaseScrollStateMarker(state: ScrollState): void { state.firstVisibleLineMarker?.dispose() - state.firstVisibleLineMarker = undefined + if (state.firstVisibleLogicalLineMarker !== state.firstVisibleLineMarker) { + state.firstVisibleLogicalLineMarker?.dispose() + } + state.firstVisibleLineMarker = state.firstVisibleLogicalLineMarker = undefined } -// Why: xterm 6 can leave its scrollbar thumb stale when ydisp is unchanged. -// A synchronous one-line jiggle updates the scrollbar without a visible paint. -function forceViewportScrollbarSync(terminal: Terminal): void { - const buf = terminal.buffer.active - if (buf.viewportY >= buf.baseY) { - // Why: jiggle-scrolling at bottom makes xterm stop following active output - // after split-pane resizes; scrollToBottom already places the thumb there. +function cancelPendingFitScrollRestore(terminal: object): void { + const pending = pendingFitScrollRestores.get(terminal) + if (!pending) { return } - if (buf.viewportY > 0) { - safeScrollCall(() => terminal.scrollLines(-1)) - safeScrollCall(() => terminal.scrollLines(1)) - } else if (buf.viewportY < buf.baseY) { - safeScrollCall(() => terminal.scrollLines(1)) - safeScrollCall(() => terminal.scrollLines(-1)) + pending.cancelled = true + if (pending.rafId !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(pending.rafId) } + releaseScrollStateMarker(pending.state) + pendingFitScrollRestores.delete(terminal) } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index cd5c046b226..c57175bb463 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -7,10 +7,6 @@ import { writeForegroundTerminalChunk, type ForegroundTerminalOutputTarget } from './pane-terminal-foreground-render-settle' -import { - captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent -} from './terminal-scroll-intent' import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import { @@ -833,6 +829,9 @@ function hasDrainableBacklog(): boolean { return false } +// Why no per-write scroll enforcement: xterm's BufferService.isUserScrolling +// natively owns live follow/pin semantics. App-side intent enforcement is +// limited to structural operations xterm cannot identify, such as replay. function writeBackgroundTerminalChunk( terminal: TerminalOutputTarget, data: string, @@ -848,29 +847,13 @@ function writeBackgroundTerminalChunk( const runOnWriteFailure = onWriteFailure ? (): void => runGuardedWriteCompletionStep('background-on-write-failure', onWriteFailure) : undefined - const scrollIntent = captureTerminalWriteScrollIntent(terminal) try { - if (!scrollIntent) { - if (!runOnParsed || terminal.write.length < 2) { - terminal.write(data) - runOnParsed?.() - return true - } - terminal.write(data, runOnParsed) - return true - } - const runScrollIntentThenParsed = (): void => { - runGuardedWriteCompletionStep('background-scroll-intent', () => - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - ) - runOnParsed?.() - } - if (terminal.write.length < 2) { + if (!runOnParsed || terminal.write.length < 2) { terminal.write(data) - runScrollIntentThenParsed() + runOnParsed?.() return true } - terminal.write(data, runScrollIntentThenParsed) + terminal.write(data, runOnParsed) return true } catch { runOnWriteFailure?.() @@ -878,32 +861,6 @@ function writeBackgroundTerminalChunk( } } -function writeForegroundTerminalChunkWithIntent( - terminal: TerminalOutputTarget, - data: string, - options: { - forceViewportRefresh: boolean - followupViewportRefresh: boolean - shouldRefreshViewportSynchronously: ForegroundRefreshSyncResolver - onParsed?: TerminalOutputParsedCallback - onWriteFailure?: () => void - } -): boolean { - const scrollIntent = captureTerminalWriteScrollIntent(terminal) - return writeForegroundTerminalChunk(terminal, data, { - forceViewportRefresh: options.forceViewportRefresh, - followupViewportRefresh: options.followupViewportRefresh, - shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously, - onParsed: () => { - // Why: recovery must repaint from the scrolled buffer state that xterm - // will keep, not from a pre-intent-restored viewport snapshot. - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - options.onParsed?.() - }, - onWriteFailure: options.onWriteFailure - }) -} - function takeNextDrainableEntry(): QueueEntry | null { let largeBacklogEntry: QueueEntry | null = null for (const entry of queuedByTerminal.values()) { @@ -1015,7 +972,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null try { queuedWrite.beforeWrite?.(queuedWrite.data) const writeAccepted = queuedWrite.foreground - ? writeForegroundTerminalChunkWithIntent( + ? writeForegroundTerminalChunk( entry.terminal, queuedWrite.stripTransientCursorShows ? removeTransientCursorShowSequences(queuedWrite.data) @@ -1052,8 +1009,8 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null return null } } catch { - // Why: beforeWrite or pre-write viewport capture can fail before xterm owns - // the bytes. Cancel the armed watch without claiming parser failure. + // Why: beforeWrite or write setup can fail before xterm owns the bytes. + // Cancel the armed watch without claiming parser failure. cancelTerminalWriteStallWatch(entry.terminal) ackCreditsParsed?.() fireQueuedAckCredits(entry) @@ -1305,7 +1262,7 @@ export function writeTerminalOutput( }) try { options.beforeWrite?.(data) - writeForegroundTerminalChunkWithIntent( + writeForegroundTerminalChunk( terminal, options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data, { @@ -1399,7 +1356,7 @@ export function flushTerminalOutput( try { queuedWrite.beforeWrite?.(queuedWrite.data) const writeAccepted = queuedWrite.foreground - ? writeForegroundTerminalChunkWithIntent( + ? writeForegroundTerminalChunk( terminal, queuedWrite.stripTransientCursorShows ? removeTransientCursorShowSequences(queuedWrite.data) @@ -1431,7 +1388,7 @@ export function flushTerminalOutput( return } } catch { - // Why: pre-write hooks/capture failed before xterm owned these bytes. + // Why: pre-write hooks/setup failed before xterm owned these bytes. // Cancel the watch; consumed + abandoned chunks still credit delivery. cancelTerminalWriteStallWatch(terminal) ackCreditsParsed?.() diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index 16a4e0092b6..66c3ed3a17e 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -1,7 +1,23 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { equalizePaneSplitSizes, safeFit } from './pane-tree-ops' +import { + cancelPendingSafeFitContinuations, + equalizePaneSplitSizes, + safeFit, + safeFitAndThen +} from './pane-tree-ops' import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' import { setFitOverride, hydrateOverrides } from './mobile-fit-overrides' +import { + captureTerminalStructuralScrollIntent, + enforceTerminalCurrentScrollIntent, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' +import { + beginTerminalScrollIntentBufferRebuild, + cancelTerminalScrollIntentBufferRebuildCompletions, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' class MockHTMLElement { classList: { contains: (cls: string) => boolean } @@ -21,6 +37,7 @@ beforeAll(() => { afterEach(() => { hydrateOverrides([]) + vi.unstubAllGlobals() }) function createPane({ @@ -178,6 +195,54 @@ describe('safeFit', () => { expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) }) + it('coalesces fits until replay parsing and scroll restoration complete', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number } + activeBuffer.viewportY = 80 + activeBuffer.baseY = 100 + markTerminalPinnedViewport(pane.terminal) + const intent = captureTerminalStructuralScrollIntent(pane.terminal) + beginTerminalScrollIntentBufferRebuild(pane.terminal) + activeBuffer.viewportY = 0 + activeBuffer.baseY = 0 + + safeFit(pane) + safeFit(pane) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + + activeBuffer.viewportY = 200 + activeBuffer.baseY = 200 + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + expect(activeBuffer.viewportY).toBe(180) + }) + endTerminalScrollIntentBufferRebuild(pane.terminal) + restoreTerminalStructuralScrollIntent(pane.terminal, intent, { restoreBy: 'bottomOffset' }) + await Promise.resolve() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + }) + + it('drops a deferred fit when a replay rebuild is canceled', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + beginTerminalScrollIntentBufferRebuild(pane.terminal) + safeFit(pane) + cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + it('restores the viewport if fit clobbers it during resize', () => { const pane = createPane({ proposedCols: 100, @@ -231,6 +296,66 @@ describe('safeFit', () => { expect(marker.dispose).toHaveBeenCalled() }) + it('records the restored post-reflow pin when widening lowers baseY', () => { + const pane = createPane({ + proposedCols: 160, + proposedRows: 32, + terminalCols: 80, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + markTerminalPinnedViewport(pane.terminal) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + marker.line = 30 + }) + + safeFit(pane) + activeBuffer.viewportY = 0 + vi.mocked(pane.terminal.scrollToLine).mockClear() + enforceTerminalCurrentScrollIntent(pane.terminal) + + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(30) + }) + + it('preserves a durable pin when the remounted fit buffer is still empty', () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + markTerminalPinnedViewport(pane.terminal) + + activeBuffer.viewportY = 0 + activeBuffer.baseY = 0 + safeFit(pane) + + activeBuffer.viewportY = 0 + activeBuffer.baseY = 80 + vi.mocked(pane.terminal.scrollToLine).mockClear() + enforceTerminalCurrentScrollIntent(pane.terminal) + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(22) + }) + it('keeps a follow-output pane at the bottom through fit', () => { const pane = createPane({ proposedCols: 100, @@ -251,22 +376,236 @@ describe('safeFit', () => { expect(pane.terminal.scrollToBottom).toHaveBeenCalled() }) - it('does not throw when xterm rejects scroll restoration during layout', () => { + it('retries a transient dimensions failure before recording the post-fit pin', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) const pane = createPane({ proposedCols: 100, proposedRows: 32, terminalCols: 120, terminalRows: 32 }) - const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number } + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } activeBuffer.viewportY = 42 activeBuffer.baseY = 100 - vi.mocked(pane.terminal.scrollToLine).mockImplementation(() => { - throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + activeBuffer.cursorY = 0 + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + markTerminalPinnedViewport(pane.terminal) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + marker.line = 30 }) + vi.mocked(pane.terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementation((line: number) => { + activeBuffer.viewportY = line + }) expect(() => safeFit(pane)).not.toThrow() expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(activeBuffer.viewportY).toBe(0) + expect(marker.dispose).not.toHaveBeenCalled() + + frameCallbacks.shift()?.(0) + expect(activeBuffer.viewportY).toBe(30) + expect(marker.dispose).toHaveBeenCalled() + + activeBuffer.viewportY = 0 + vi.mocked(pane.terminal.scrollToLine).mockClear() + enforceTerminalCurrentScrollIntent(pane.terminal) + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(30) + }) + + it('cancels a pending fit retry when snapshot replay starts', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 30, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + markTerminalPinnedViewport(pane.terminal) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine).mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + + safeFit(pane) + beginTerminalScrollIntentBufferRebuild(pane.terminal) + frameCallbacks.shift()?.(0) + + expect(pane.terminal.scrollToLine).toHaveBeenCalledTimes(1) + expect(marker.dispose).toHaveBeenCalledTimes(1) + endTerminalScrollIntentBufferRebuild(pane.terminal) + }) + + it('carries the original fit marker across another fit before retry', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const originalMarker = { line: 30, isDisposed: false, dispose: vi.fn() } + const replacementMarker = { line: 5, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi + .fn() + .mockReturnValueOnce(originalMarker) + .mockReturnValueOnce(replacementMarker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementation((line: number) => { + activeBuffer.viewportY = line + }) + markTerminalPinnedViewport(pane.terminal) + + safeFit(pane) + safeFit(pane) + + expect(activeBuffer.viewportY).toBe(30) + expect(originalMarker.dispose).toHaveBeenCalledTimes(1) + expect(replacementMarker.dispose).toHaveBeenCalledTimes(1) + expect(frameCallbacks).toHaveLength(1) + }) + + it('resumes an exhausted dimensions retry on a same-grid reveal fit', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 30, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine).mockImplementation(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + markTerminalPinnedViewport(pane.terminal) + + safeFit(pane) + frameCallbacks.shift()?.(0) + frameCallbacks.shift()?.(0) + expect(marker.dispose).not.toHaveBeenCalled() + + vi.mocked(pane.terminal.scrollToLine).mockImplementation((line: number) => { + activeBuffer.viewportY = line + }) + ;(pane.terminal as unknown as { cols: number }).cols = 100 + safeFit(pane) + expect(activeBuffer.viewportY).toBe(30) + expect(marker.dispose).toHaveBeenCalledTimes(1) + }) + + it('releases a replacement marker when a resumed retry throws', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const originalMarker = { line: 30, isDisposed: false, dispose: vi.fn() } + const replacementMarker = { line: 5, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi + .fn() + .mockReturnValueOnce(originalMarker) + .mockReturnValueOnce(replacementMarker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementationOnce(() => { + throw new Error('unexpected resumed restore failure') + }) + markTerminalPinnedViewport(pane.terminal) + + safeFit(pane) + expect(() => safeFit(pane)).not.toThrow() + + expect(originalMarker.dispose).toHaveBeenCalledTimes(1) + expect(replacementMarker.dispose).toHaveBeenCalledTimes(1) }) it('still refits when a split-scroll lock is active and the grid changed', () => { @@ -396,6 +735,73 @@ describe('safeFit', () => { expect(paneB.fitAddon.fit).toHaveBeenCalledTimes(1) expect(paneB.terminal.resize).not.toHaveBeenCalled() }) + + it('runs an authoritative continuation only after a deferred replay fit', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + pane.terminal.resize(100, 32) + }) + const observedDimensions: { cols: number; rows: number }[] = [] + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + safeFitAndThen(pane, 'pty-resize', () => { + observedDimensions.push({ cols: pane.terminal.cols, rows: pane.terminal.rows }) + }) + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(observedDimensions).toEqual([]) + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(observedDimensions).toEqual([{ cols: 100, rows: 32 }]) + }) + + it('retains an authoritative continuation until a later measurable fit succeeds', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue(undefined) + const continuation = vi.fn() + + const pending = safeFitAndThen(pane, 'pty-resize', continuation) + + expect(continuation).not.toHaveBeenCalled() + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue({ cols: 100, rows: 32 }) + safeFit(pane) + + await expect(pending.completion).resolves.toBe(true) + expect(continuation).toHaveBeenCalledTimes(1) + }) + + it('cancels an authoritative fit continuation disposed before its post-replay microtask', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + const continuation = vi.fn() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + const pending = safeFitAndThen(pane, 'pty-resize', continuation) + + endTerminalScrollIntentBufferRebuild(pane.terminal) + cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) + cancelPendingSafeFitContinuations(pane) + await Promise.resolve() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(continuation).not.toHaveBeenCalled() + await expect(pending.completion).resolves.toBe(false) + }) }) describe('equalizePaneSplitSizes', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index d069244cf4f..833eb1ff822 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -6,16 +6,15 @@ import type { PaneStyleOptions } from './pane-manager-types' import { createDivider, disposeDivider } from './pane-divider' -import { getFitOverrideForPty } from './mobile-fit-overrides' import { disposeWebgl, attachWebgl } from './pane-webgl-renderer' -import { - captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent, - syncTerminalScrollIntentFromViewport -} from './terminal-scroll-intent' -import { captureScrollState, releaseScrollStateMarker, restoreScrollState } from './pane-scroll' -import type { ScrollState } from './pane-manager-types' +import { safeFit } from './pane-fit' +export { + cancelPendingSafeFitContinuations, + safeFit, + safeFitAndThen, + type SafeFitContinuationHandle +} from './pane-fit' export { captureScrollState, restoreScrollState } from './pane-scroll' // --------------------------------------------------------------------------- @@ -33,111 +32,6 @@ type TreeOpsCallbacks = { requestPaneReparentFrame?: (callback: FrameRequestCallback) => void } -const MIN_PANE_FIT_WIDTH_PX = 48 -const MIN_PANE_FIT_HEIGHT_PX = 24 -const MIN_PANE_FIT_COLS = 8 -const MIN_PANE_FIT_ROWS = 4 - -function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null { - try { - return pane.fitAddon.proposeDimensions() ?? null - } catch { - return null - } -} - -function canMeasurePaneForFit(pane: ManagedPane): boolean { - const measure = pane.container.getBoundingClientRect - if (typeof measure === 'function') { - const rect = measure.call(pane.container) - if (rect.width < MIN_PANE_FIT_WIDTH_PX || rect.height < MIN_PANE_FIT_HEIGHT_PX) { - return false - } - } - const dims = getProposedDimensions(pane) - if (!dims) { - return false - } - // Why: worktree switches can briefly measure a near-zero overlay before - // fallback positioning lands. Fitting there pins the PTY at ~2 cols until - // the next user-driven resize. - return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS -} - -function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { - // Why: split reparent has its own delayed restore; restoring here can fight that timer. - return !( - 'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState - ) -} - -export function safeFit(pane: ManagedPane): void { - if (!canMeasurePaneForFit(pane)) { - return - } - let scrollIntent = null as ReturnType - let pinnedScrollState: ScrollState | null = null - let shouldRestoreScroll = false - const captureScrollForFit = (): void => { - scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) - // Why: fit can reflow and renumber every buffer row; a marker tracks the - // pinned content itself, while a numeric line would point elsewhere after. - pinnedScrollState = - scrollIntent?.kind === 'pinnedViewport' ? captureScrollState(pane.terminal) : null - shouldRestoreScroll = true - } - try { - // Why: when a mobile client has resized this PTY to phone dimensions, - // the desktop must keep xterm at those dimensions instead of fitting to - // the desktop pane geometry. This prevents desktop auto-fit from undoing - // the mobile resize. Uses data-pty-id (set by bindPanePtyId) to look up - // the override by ptyId directly, avoiding pane ID collisions across tabs. - const ptyId = pane.container.dataset.ptyId - const override = ptyId ? getFitOverrideForPty(ptyId) : null - if (override) { - if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) { - if (canPreserveScrollIntentForFit(pane)) { - captureScrollForFit() - } - pane.terminal.resize(override.cols, override.rows) - } - return - } - - const dims = getProposedDimensions(pane) - if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { - // Why: divider drags fire refits every frame, but most frames do not - // cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh() - // churn, which was causing visible terminal blinking while resizing. - return - } - if (canPreserveScrollIntentForFit(pane)) { - captureScrollForFit() - } - pane.fitAddon.fit() - } catch { - // Container may not have dimensions yet - } finally { - if (shouldRestoreScroll) { - try { - if (pinnedScrollState) { - restoreScrollState(pane.terminal, pinnedScrollState) - syncTerminalScrollIntentFromViewport(pane.terminal) - } else { - enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent) - } - } catch { - // Why: xterm can temporarily expose a terminal whose renderer has not - // initialized dimensions yet during SSH reattach/layout. Fit is best-effort. - } finally { - if (pinnedScrollState) { - releaseScrollStateMarker(pinnedScrollState) - } - } - } - } -} - export function fitAllPanesInternal(panes: Map): void { for (const pane of panes.values()) { safeFit(pane) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts index 91b95f2caf2..f2a07f8751f 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts @@ -3,6 +3,10 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { disposePane } from './pane-lifecycle' import { suspendPaneRendering } from './pane-rendering-control' import { disposeWebgl } from './pane-webgl-renderer' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' function createPane( overrides: Partial> = {} @@ -14,11 +18,17 @@ function createPane( stablePaneId: leafId, terminal: { element: null, + cols: 80, rows: 24, + buffer: { active: { type: 'normal', viewportY: 0, baseY: 0 } }, refresh: vi.fn(), + resize: vi.fn(), dispose: vi.fn() } as never, - container: {} as never, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: 800, height: 600 }) + } as never, xtermContainer: {} as never, linkTooltip: {} as never, terminalGpuAcceleration: 'off', @@ -28,6 +38,7 @@ function createPane( hasComplexScriptOutput: false, fitAddon: { fit: vi.fn(), + proposeDimensions: vi.fn(() => ({ cols: 100, rows: 24 })), dispose: vi.fn() } as never, fitResizeObserver: null, @@ -66,6 +77,26 @@ describe('pane WebGL refresh lifecycle', () => { expect(pane.pendingWebglRefreshRafId).toBe(29) }) + it('defers the DOM-renderer refit until structural replay completes', async () => { + const refreshFrame: { current: FrameRequestCallback | null } = { current: null } + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + refreshFrame.current = callback + return 29 + }) + const pane = createPane() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + disposeWebgl(pane, { refreshDimensions: true }) + refreshFrame.current?.(0) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(pane.terminal.refresh).toHaveBeenCalledTimes(1) + }) + it('actively releases the xterm WebGL context before disposing the addon', () => { const loseContext = vi.fn() const canvas = { width: 120, height: 40 } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index 7ae295b4a9d..790ce3a74d7 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -6,6 +6,7 @@ import { getTerminalWebglAutoDecision, resetTerminalWebglAutoDecision } from './terminal-webgl-auto-policy' +import { safeFitAndThen } from './pane-fit' export const ENABLE_WEBGL_RENDERER = true let suggestedRendererType: 'dom' | undefined @@ -90,8 +91,11 @@ export function disposeWebgl( pane.pendingWebglRefreshRafId = requestAnimationFrame(() => { pane.pendingWebglRefreshRafId = null try { - pane.fitAddon.fit() - pane.terminal.refresh(0, pane.terminal.rows - 1) + // Why: context loss can coincide with snapshot parsing; refresh only + // after the replay-aware fit has authoritative renderer dimensions. + safeFitAndThen(pane, 'webgl-fallback-refresh', () => { + pane.terminal.refresh(0, pane.terminal.rows - 1) + }) } catch { /* ignore — pane may have been disposed in the meantime */ } diff --git a/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts new file mode 100644 index 00000000000..aca9b7034d5 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import type { Terminal } from '@xterm/xterm' +import { resetTerminalLinkifierHoverState } from './terminal-linkifier-hover-reset' + +type FakeLinkifier = { _lastBufferCell?: unknown; _activeLine?: number } + +function createTerminal(linkifier: FakeLinkifier | null | undefined, hasCore = true): Terminal { + const core = hasCore ? { linkifier: linkifier ?? undefined } : undefined + return { _core: core } as unknown as Terminal +} + +describe('resetTerminalLinkifierHoverState', () => { + it('clears the hover cell cache and active line so the next mousemove re-linkifies', () => { + const linkifier: FakeLinkifier = { _lastBufferCell: { x: 5, y: 5 }, _activeLine: 5 } + resetTerminalLinkifierHoverState(createTerminal(linkifier)) + + expect(linkifier._lastBufferCell).toBeUndefined() + expect(linkifier._activeLine).toBe(-1) + }) + + it('is a no-op when the linkifier or core internals are unavailable', () => { + expect(() => resetTerminalLinkifierHoverState(createTerminal(null))).not.toThrow() + expect(() => resetTerminalLinkifierHoverState(createTerminal(undefined, false))).not.toThrow() + }) + + it('only touches fields that exist so a renamed xterm internal degrades safely', () => { + const linkifier: FakeLinkifier = {} + resetTerminalLinkifierHoverState(createTerminal(linkifier)) + + expect('_lastBufferCell' in linkifier).toBe(false) + expect('_activeLine' in linkifier).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.ts b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.ts new file mode 100644 index 00000000000..2c93f354ac0 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.ts @@ -0,0 +1,46 @@ +import type { Terminal } from '@xterm/xterm' + +type LinkifierHoverCache = { + _lastBufferCell?: unknown + _activeLine?: number +} + +type TerminalCoreWithLinkifier = { + _core?: { + linkifier?: LinkifierHoverCache + } +} + +/** + * Force xterm's linkifier to re-run link providers on the next mousemove. + * + * Why: when a terminal is hidden (worktree/tab switch) the browser fires + * `mouseleave`, which clears the linkifier's current link but leaves its + * `_lastBufferCell` cache set. On reveal the pointer usually returns to the + * same cell, so xterm's mousemove handler short-circuits (position unchanged) + * and never re-linkifies — the link (file path, URL, term_* handle, OSC-8) + * stays dead until a scroll shifts the buffer position. Clearing the cell/line + * cache makes the next mousemove re-evaluate providers so the link and its + * hover underline recover without a scroll. + * + * Reaches into xterm internals (`@xterm/xterm` 6.1.0-beta.287 `Linkifier`) + * because there is no public API to invalidate the hover cache. Guarded so a + * future xterm build that renames these fields degrades to the pre-fix + * behavior (link recovers on the next genuine cell change) instead of throwing. + */ +export function resetTerminalLinkifierHoverState(terminal: Terminal): void { + try { + const linkifier = (terminal as unknown as TerminalCoreWithLinkifier)._core?.linkifier + if (!linkifier) { + return + } + if ('_lastBufferCell' in linkifier) { + linkifier._lastBufferCell = undefined + } + if ('_activeLine' in linkifier) { + linkifier._activeLine = -1 + } + } catch { + /* linkifier internals unavailable — link recovers on the next cell change */ + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-reflow-scroll-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-reflow-scroll-anchor.ts new file mode 100644 index 00000000000..c7ed2bcce17 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-reflow-scroll-anchor.ts @@ -0,0 +1,137 @@ +import type { Terminal } from '@xterm/xterm' + +type ReflowLineReader = { + getCellMetrics: (lineY: number, column: number) => { code: number; width: number } | undefined + isWrapped: (lineY: number) => boolean +} + +type TerminalWithInternalBufferLines = Terminal & { + _core?: { + _bufferService?: { + buffer?: { + lines?: { + get: (lineY: number) => + | { + getCodePoint: (column: number) => number + getWidth: (column: number) => number + isWrapped: boolean + length: number + } + | undefined + } + } + } + } +} + +export function captureLogicalLineAnchor( + terminal: Terminal, + viewportY: number +): { cellOffset: number; lineY: number } | undefined { + const buf = terminal.buffer.active + if (typeof buf.getLine !== 'function' || shouldKeepPhysicalResizeAnchor(terminal)) { + return undefined + } + const lines = createReflowLineReader(terminal) + let lineY = viewportY + while (lineY > 0 && lines.isWrapped(lineY)) { + lineY -= 1 + } + const cursorLineY = buf.baseY + buf.cursorY + if (terminal.options?.reflowCursorLine !== true && lineContainsLine(lines, lineY, cursorLineY)) { + return undefined + } + let cellOffset = 0 + for (let currentLineY = lineY; currentLineY < viewportY; currentLineY += 1) { + cellOffset += readReflowedRowCellCount(terminal, lines, currentLineY) + } + return { cellOffset, lineY } +} + +function shouldKeepPhysicalResizeAnchor(terminal: Terminal): boolean { + const windowsPty = terminal.options?.windowsPty + if (!windowsPty?.buildNumber) { + return false + } + // Why: xterm disables reflow only when an explicit legacy build is present; + // Orca's backend-only fallback for an unknown Windows build still reflows. + return windowsPty.backend !== 'conpty' || windowsPty.buildNumber < 21376 +} + +function lineContainsLine( + lines: ReflowLineReader, + logicalStartY: number, + targetY: number +): boolean { + if (targetY < logicalStartY) { + return false + } + for (let lineY = logicalStartY + 1; lineY <= targetY; lineY += 1) { + if (!lines.isWrapped(lineY)) { + return false + } + } + return true +} + +export function resolveLogicalCellOffsetLine( + terminal: Terminal, + logicalStartY: number, + cellOffset: number +): number { + const buf = terminal.buffer.active + const lines = createReflowLineReader(terminal) + let lineY = logicalStartY + let remainingCells = cellOffset + while (lineY < buf.baseY && lines.isWrapped(lineY + 1)) { + const rowCells = readReflowedRowCellCount(terminal, lines, lineY) + if (remainingCells < rowCells) { + break + } + remainingCells -= rowCells + lineY += 1 + } + return lineY +} + +function readReflowedRowCellCount( + terminal: Terminal, + lines: ReflowLineReader, + lineY: number +): number { + const cols = Math.max(terminal.cols, 1) + const lastCell = lines.getCellMetrics(lineY, cols - 1) + const nextFirstCell = lines.getCellMetrics(lineY + 1, 0) + // Why: xterm wraps a width-2 glyph one cell early when only the last column + // remains. That placeholder is not part of the logical cell offset. + return lastCell?.code === 0 && lastCell.width === 1 && nextFirstCell?.width === 2 + ? cols - 1 + : cols +} + +function createReflowLineReader(terminal: Terminal): ReflowLineReader { + const internalLines = (terminal as TerminalWithInternalBufferLines)._core?._bufferService?.buffer + ?.lines + if (internalLines) { + // Why: public getLine/getCell allocate wrapper objects per row/cell. The + // pinned xterm core exposes the same active lines without resize-path GC. + return { + isWrapped: (lineY) => internalLines.get(lineY)?.isWrapped ?? false, + getCellMetrics: (lineY, column) => { + const line = internalLines.get(lineY) + if (!line || column < 0 || column >= line.length) { + return undefined + } + return { code: line.getCodePoint(column), width: line.getWidth(column) } + } + } + } + const buffer = terminal.buffer.active + return { + isWrapped: (lineY) => buffer.getLine(lineY)?.isWrapped ?? false, + getCellMetrics: (lineY, column) => { + const cell = buffer.getLine(lineY)?.getCell(column) + return cell ? { code: cell.getCode(), width: cell.getWidth() } : undefined + } + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-buffer-snapshot.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-buffer-snapshot.ts new file mode 100644 index 00000000000..730adc5a97b --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-buffer-snapshot.ts @@ -0,0 +1,53 @@ +export type TerminalScrollBufferType = 'normal' | 'alternate' + +export type TerminalScrollBufferTarget = { + buffer?: { + active?: { + type?: string + viewportY?: number + baseY?: number + } + } +} + +export type TerminalScrollBufferSnapshot = { + bufferType: TerminalScrollBufferType + viewportY: number + baseY: number +} + +export function readTerminalScrollBufferSnapshot( + terminal: TerminalScrollBufferTarget +): TerminalScrollBufferSnapshot | null { + const buffer = terminal.buffer?.active + const viewportY = buffer?.viewportY + const baseY = buffer?.baseY + if (typeof viewportY !== 'number' || typeof baseY !== 'number') { + return null + } + return { + bufferType: buffer?.type === 'alternate' ? 'alternate' : 'normal', + viewportY, + baseY + } +} + +export function isTerminalViewportAtBottom(viewportY: number, baseY: number): boolean { + return viewportY >= baseY +} + +export function clampTerminalViewportY(viewportY: number, baseY: number): number { + return Math.max(0, Math.min(viewportY, baseY)) +} + +export function safeTerminalScrollCall(scroll: () => void): boolean { + try { + scroll() + return true + } catch (err) { + if (err instanceof TypeError && /dimensions/.test(err.message)) { + return false + } + throw err + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts new file mode 100644 index 00000000000..9545615705c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts @@ -0,0 +1,254 @@ +import type { IDisposable } from '@xterm/xterm' +import { + bindTerminalScrollIntentKey, + enforceTerminalCurrentScrollIntent, + getTerminalScrollIntentKind, + isTerminalScrollIntentKeyBindingCurrent, + markTerminalPinnedViewport, + syncTerminalScrollIntentFromViewport +} from './terminal-scroll-intent' +import { syncTerminalScrollIntentSoon } from './terminal-scroll-intent-settle' +import type { TerminalScrollIntentKey, TerminalScrollIntentTarget } from './terminal-scroll-intent' +import { + isTerminalScrollIntentRebuildInFlight, + onTerminalScrollIntentBufferRebuildComplete +} from './terminal-scroll-intent-rebuild' + +const XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES = [ + 'xterm-viewport', + 'xterm-scrollbar', + 'xterm-slider' +] as const +const XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR = XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES.map( + (className) => `.${className}` +).join(',') + +function isTerminalScrollIntentPointerTarget(target: EventTarget | null): target is Element { + if (typeof Element === 'undefined' || !(target instanceof Element)) { + return false + } + // xterm's custom scrollbar uses separate thumb/track nodes from the viewport. + return target.closest(XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR) !== null +} + +type TerminalWithOnData = { + onData?: (listener: (data: string) => void) => { dispose?: unknown } | undefined + _core?: { + coreService?: { + onUserInput?: (listener: () => void) => { dispose?: unknown } | undefined + } + } +} + +// Mouse reports (SGR "\x1b[ boolean, + captureInteractionRevision: () => number, + resyncUserInput: (interactionRevision: number) => void +): { dispose: () => void } | null { + const terminalWithInput = terminal as TerminalWithOnData + const onData = terminalWithInput.onData + if (typeof onData !== 'function') { + return null + } + const onUserInput = terminalWithInput._core?.coreService?.onUserInput + let pendingUserInputRevision: number | null = null + try { + const dataSubscription = onData((data: string) => { + if (isMouseReportInput(data)) { + pendingUserInputRevision = null + if (isActive() && getTerminalScrollIntentKind(terminal) === 'pinnedViewport') { + // Why: xterm treats mouse reports as user input and scrolls bottom + // before onData. Restore the reading position before output follows. + enforceTerminalCurrentScrollIntent(terminal) + } + return + } + if (typeof onUserInput === 'function') { + const interactionRevision = pendingUserInputRevision + pendingUserInputRevision = null + if (interactionRevision !== null && isActive()) { + resyncUserInput(interactionRevision) + } + } else if (isActive()) { + // Compatibility fallback for test doubles or an unexpected xterm + // shape; pinned production xterm uses onUserInput below. + resyncUserInput(captureInteractionRevision()) + } + }) + const userInputSubscription = onUserInput?.(() => { + // Why: xterm emits onUserInput immediately before its matching onData. + // Reserve order here, then let onData classify typing versus mouse. + pendingUserInputRevision = captureInteractionRevision() + }) + return { + dispose: () => { + if (dataSubscription && typeof dataSubscription.dispose === 'function') { + dataSubscription.dispose() + } + if (userInputSubscription && typeof userInputSubscription.dispose === 'function') { + userInputSubscription.dispose() + } + } + } + } catch { + return null + } +} + +/** Wires the user-driven scroll signals (wheel, scrollbar pointer drags) that + * are allowed to change a terminal's scroll intent. Output-driven scroll + * events deliberately do not update intent (see terminal-scroll-intent.ts). */ +export function attachTerminalScrollIntentTracking( + terminal: TerminalScrollIntentTarget, + host: HTMLElement, + intentKey?: TerminalScrollIntentKey +): IDisposable { + if (!bindTerminalScrollIntentKey(terminal, intentKey)) { + syncTerminalScrollIntentFromViewport(terminal) + } + let disposed = false + const isActive = (): boolean => !disposed + let pointerScrollActive = false + let cancelPostRebuildSync: (() => void) | null = null + let nextInteractionRevision = 0 + let latestCommittedInteractionRevision = 0 + let postRebuildSync: { revision: number; mode: 'sample' | 'preservePinnedAtBottom' } | null = null + const captureInteractionRevision = (): number => (nextInteractionRevision += 1) + + const syncFromViewportOrAfterRebuild = ( + mode: 'sample' | 'preservePinnedAtBottom' = 'sample', + interactionRevision = captureInteractionRevision() + ): boolean => { + if (interactionRevision < latestCommittedInteractionRevision) { + return false + } + latestCommittedInteractionRevision = interactionRevision + if (!isTerminalScrollIntentRebuildInFlight(terminal)) { + syncTerminalScrollIntentFromViewport(terminal, { allowBufferShrink: true }) + return true + } + postRebuildSync = { revision: interactionRevision, mode } + if (!cancelPostRebuildSync) { + cancelPostRebuildSync = onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + cancelPostRebuildSync = null + const pendingSync = postRebuildSync + postRebuildSync = null + if ( + completed && + isActive() && + pendingSync && + pendingSync.revision === latestCommittedInteractionRevision + ) { + // Why: wheel/scrollbar movement during replay must be sampled from + // the completed buffer, never from its transient cleared rows. + const preservePinnedAtBottom = pendingSync.mode === 'preservePinnedAtBottom' + if ( + preservePinnedAtBottom && + getTerminalScrollIntentKind(terminal) !== 'pinnedViewport' + ) { + markTerminalPinnedViewport(terminal) + } + syncTerminalScrollIntentFromViewport(terminal, { + allowBufferShrink: true, + preservePinnedAtBottom + }) + if (preservePinnedAtBottom) { + // Why: an upward wheel or scrollbar gesture against the cleared 0/0 + // buffer must not erase the durable pin. Settle after restoration so + // a real move wins and a no-op gesture can still return to follow. + syncTerminalScrollIntentSoon(terminal, { + allowBufferShrink: true, + preservePinnedAtBottom: true, + shouldSync: isActive + }) + } + } + }) + } + return false + } + const userInputResync = subscribeScrollIntentUserInputResync( + terminal, + isActive, + captureInteractionRevision, + (interactionRevision) => syncFromViewportOrAfterRebuild('sample', interactionRevision) + ) + + const onWheel = (event: WheelEvent): void => { + if (!syncFromViewportOrAfterRebuild(event.deltaY < 0 ? 'preservePinnedAtBottom' : 'sample')) { + return + } + if (event.deltaY < 0) { + markTerminalPinnedViewport(terminal) + syncTerminalScrollIntentSoon(terminal, { + preservePinnedAtBottom: true, + shouldSync: isActive + }) + return + } + syncTerminalScrollIntentSoon(terminal, { shouldSync: isActive }) + } + + const onPointerDown = (event: PointerEvent): void => { + pointerScrollActive = isTerminalScrollIntentPointerTarget(event.target) + } + + const onPointerDone = (): void => { + if (!pointerScrollActive) { + return + } + pointerScrollActive = false + syncFromViewportOrAfterRebuild('preservePinnedAtBottom') + } + + const onScroll = (): void => { + if (pointerScrollActive) { + syncFromViewportOrAfterRebuild('preservePinnedAtBottom') + } + } + + host.addEventListener('wheel', onWheel, { capture: true, passive: true }) + host.addEventListener('pointerdown', onPointerDown, true) + host.addEventListener('scroll', onScroll, true) + globalThis.addEventListener?.('pointerup', onPointerDone, true) + globalThis.addEventListener?.('pointercancel', onPointerDone, true) + return { + dispose: () => { + // Why: native pinned output can grow baseY without a DOM scroll event; + // persist that geometry before remount, but never let an old instance + // overwrite a successor already bound to the same leaf key. + if (isTerminalScrollIntentKeyBindingCurrent(terminal)) { + syncTerminalScrollIntentFromViewport(terminal) + } + disposed = true + cancelPostRebuildSync?.() + cancelPostRebuildSync = null + postRebuildSync = null + userInputResync?.dispose() + host.removeEventListener('wheel', onWheel, true) + host.removeEventListener('pointerdown', onPointerDown, true) + host.removeEventListener('scroll', onScroll, true) + globalThis.removeEventListener?.('pointerup', onPointerDone, true) + globalThis.removeEventListener?.('pointercancel', onPointerDone, true) + } + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts new file mode 100644 index 00000000000..c3739b5c470 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking' +import { + captureTerminalStructuralScrollIntent, + enforceTerminalCurrentScrollIntent, + getTerminalScrollIntentKind, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' + +function createTerminal(viewportY: number, baseY: number) { + const terminal = { + buffer: { active: { type: 'normal' as const, viewportY, baseY } }, + scrollToBottom: vi.fn(() => { + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + }), + scrollToLine: vi.fn((line: number) => { + terminal.buffer.active.viewportY = line + }), + onData: undefined as + | ((listener: (data: string) => void) => { dispose: () => void }) + | undefined, + _core: undefined as + | { coreService: { onUserInput: (listener: () => void) => { dispose: () => void } } } + | undefined + } + return terminal +} + +class TestElement extends EventTarget { + parentElement: TestElement | null = null + readonly classList = { + contains: (className: string): boolean => this.className.split(/\s+/).includes(className) + } + + constructor(public className = '') { + super() + } + + closest(selector: string): TestElement | null { + for (const candidate of selector.split(',')) { + const trimmed = candidate.trim() + if (trimmed.startsWith('.') && this.classList.contains(trimmed.slice(1))) { + return this + } + } + return this.parentElement?.closest(selector) ?? null + } +} + +function createTerminalWithInputCapture(viewportY: number, baseY: number) { + const capturedInput: { listener: ((data: string) => void) | null } = { listener: null } + const capturedUserInput: { listener: (() => void) | null } = { listener: null } + const terminal = createTerminal(viewportY, baseY) + terminal.onData = (listener: (data: string) => void) => { + capturedInput.listener = listener + return { dispose: vi.fn() } + } + terminal._core = { + coreService: { + onUserInput: (listener: () => void) => { + capturedUserInput.listener = listener + return { dispose: vi.fn() } + } + } + } + return { terminal, capturedInput, capturedUserInput } +} + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('terminal scroll-intent input resync', () => { + it('heals a stale pin when typing scrolls the terminal to the bottom', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedUserInput.listener?.() + capturedInput.listener?.('a') + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('heals a stale pre-reflow pin when typing reaches a shorter buffer bottom', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.baseY = 70 + terminal.buffer.active.viewportY = 70 + capturedUserInput.listener?.() + capturedInput.listener?.('a') + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('keeps a real pin when app-consumed input does not move the viewport', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[5~') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42) + disposable.dispose() + }) + + it('does not reclassify a pin from mouse reports even when they scroll to bottom', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[<35;10;5M') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + expect(terminal.buffer.active.viewportY).toBe(42) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42) + disposable.dispose() + }) + + it('does not let a focus reply make a following mouse report reclassify the pin', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + capturedInput.listener?.('\x1b[I') + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[<0;10;5M') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('ignores a parser reply that has no matching user-input signal', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedInput.listener?.('\x1b[1;1R') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('does not apply input resync after tracking is disposed', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + capturedUserInput.listener?.() + disposable.dispose() + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedInput.listener?.('a') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + }) + + it('defers real typing intent until a snapshot rebuild completes', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.baseY = 5 + terminal.buffer.active.viewportY = 5 + capturedUserInput.listener?.() + capturedInput.listener?.('a') + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('lets later typing supersede a wheel-up pin during snapshot replay', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(80, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + capturedUserInput.listener?.() + capturedInput.listener?.('a') + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('ignores parser auto-replies while a snapshot rebuild is partial', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const intent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.baseY = 5 + terminal.buffer.active.viewportY = 5 + capturedInput.listener?.('\x1b[1;1R') + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, intent, { restoreBy: 'bottomOffset' }) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(142) + disposable.dispose() + }) + + it('supports bottom-offset restore for structural buffer rebuilds', () => { + const terminal = createTerminal(550, 600) + markTerminalPinnedViewport(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) + terminal.buffer.active.baseY = 80 + terminal.buffer.active.viewportY = 80 + + restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(30) + expect(terminal.buffer.active.viewportY).toBe(30) + }) + + it('retains the intended bottom-offset pin when renderer dimensions reject restore', () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + terminal.scrollToLine.mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + + restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + expect(terminal.buffer.active.viewportY).toBe(200) + + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts new file mode 100644 index 00000000000..3de8e79a9f1 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts @@ -0,0 +1,125 @@ +// Why: buffer rebuilds (snapshot replay clear + rewrite) parse asynchronously. +// Until the rebuild's bytes have parsed, viewportY/baseY describe a transient +// half-cleared buffer; any intent capture/enforce latched from it pins the +// terminal at line 0. Callers bracket the rebuild and re-apply intent once +// after parse (see terminal-scroll-intent.ts). +const terminalScrollIntentRebuilds = new WeakMap() +const terminalScrollIntentRebuildCompletions = new WeakMap< + object, + Set<(completed: boolean) => void> +>() +const deferredTerminalGeometryMutations = new WeakMap< + object, + { + mutations: Map void> + } +>() + +function notifyRebuildCompletions( + completions: Set<(completed: boolean) => void> | undefined, + completed: boolean +): void { + for (const completion of completions ?? []) { + try { + completion(completed) + } catch (error) { + // Why: one optional observer must not strand the rebuild or prevent the + // coordinator from restoring the authoritative viewport. + console.error('[terminal] scroll-intent rebuild completion failed', error) + } + } +} + +export function beginTerminalScrollIntentBufferRebuild(terminal: object): void { + terminalScrollIntentRebuilds.set(terminal, (terminalScrollIntentRebuilds.get(terminal) ?? 0) + 1) +} + +export function endTerminalScrollIntentBufferRebuild(terminal: object): void { + const count = terminalScrollIntentRebuilds.get(terminal) ?? 0 + if (count <= 1) { + terminalScrollIntentRebuilds.delete(terminal) + const completions = terminalScrollIntentRebuildCompletions.get(terminal) + terminalScrollIntentRebuildCompletions.delete(terminal) + notifyRebuildCompletions(completions, true) + return + } + terminalScrollIntentRebuilds.set(terminal, count - 1) +} + +export function isTerminalScrollIntentRebuildInFlight(terminal: object): boolean { + return (terminalScrollIntentRebuilds.get(terminal) ?? 0) > 0 +} + +export function onTerminalScrollIntentBufferRebuildComplete( + terminal: object, + completion: (completed: boolean) => void +): () => void { + if (!isTerminalScrollIntentRebuildInFlight(terminal)) { + completion(true) + return () => {} + } + let completions = terminalScrollIntentRebuildCompletions.get(terminal) + if (!completions) { + completions = new Set() + terminalScrollIntentRebuildCompletions.set(terminal, completions) + } + completions.add(completion) + return () => { + completions?.delete(completion) + if (completions?.size === 0) { + terminalScrollIntentRebuildCompletions.delete(terminal) + } + } +} + +// Why: source-dimension replay must finish and restore its viewport before +// unrelated fit/resize work is allowed to reflow the rebuilt buffer. +export function deferTerminalGeometryMutationDuringRebuild( + terminal: object, + operationKey: string, + mutation: () => void +): boolean { + if (!isTerminalScrollIntentRebuildInFlight(terminal)) { + return false + } + const existing = deferredTerminalGeometryMutations.get(terminal) + if (existing) { + existing.mutations.set(operationKey, mutation) + return true + } + const mutations = new Map([[operationKey, mutation]]) + const deferred = { mutations } + deferredTerminalGeometryMutations.set(terminal, deferred) + onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + if (deferredTerminalGeometryMutations.get(terminal) !== deferred) { + return + } + if (!completed) { + deferredTerminalGeometryMutations.delete(terminal) + return + } + // Why: rebuild completion listeners run before the coordinator restores + // intent; the microtask makes every geometry mutation post-restore. + queueMicrotask(() => { + if (deferredTerminalGeometryMutations.get(terminal) !== deferred) { + return + } + // Keep the entry cancellable until execution begins; disposal may land + // after rebuild completion but before this post-restore microtask. + deferredTerminalGeometryMutations.delete(terminal) + for (const [key, pendingMutation] of mutations) { + if (!deferTerminalGeometryMutationDuringRebuild(terminal, key, pendingMutation)) { + pendingMutation() + } + } + }) + }) + return true +} + +export function cancelTerminalScrollIntentBufferRebuildCompletions(terminal: object): void { + const completions = terminalScrollIntentRebuildCompletions.get(terminal) + terminalScrollIntentRebuildCompletions.delete(terminal) + notifyRebuildCompletions(completions, false) + deferredTerminalGeometryMutations.delete(terminal) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-settle.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-settle.ts new file mode 100644 index 00000000000..a4962dff8cf --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-settle.ts @@ -0,0 +1,33 @@ +import { + syncTerminalScrollIntentFromViewport, + type TerminalScrollIntentTarget +} from './terminal-scroll-intent' + +export function syncTerminalScrollIntentSoon( + terminal: TerminalScrollIntentTarget, + options: { + allowBufferShrink?: boolean + preservePinnedAtBottom?: boolean + shouldSync?: () => boolean + } = {} +): void { + const sync = (): void => { + if (options.shouldSync?.() === false) { + return + } + syncTerminalScrollIntentFromViewport(terminal, options) + } + queueMicrotask(sync) + requestAnimationFrame(sync) + requestAnimationFrame(() => requestAnimationFrame(sync)) + // Why: preservePinnedAtBottom only bridges xterm's async scroll application. + // The settle tick must reclassify from the real viewport, otherwise a wheel + // the viewport never followed latches a phantom pin at the bottom. + setTimeout(() => { + if (options.shouldSync?.() !== false) { + syncTerminalScrollIntentFromViewport(terminal, { + allowBufferShrink: options.allowBufferShrink + }) + } + }, 80) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts new file mode 100644 index 00000000000..4e6c2b66626 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import { + bindTerminalScrollIntentKey, + captureTerminalStructuralScrollIntent, + markTerminalFollowOutput, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' + +type BufferType = 'normal' | 'alternate' + +function createTerminal(viewportY: number, baseY: number, type: BufferType = 'normal') { + const terminal = { + buffer: { active: { type, viewportY, baseY } }, + scrollToBottom: vi.fn(() => { + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + }), + scrollToLine: vi.fn((line: number) => { + terminal.buffer.active.viewportY = line + }) + } + return terminal +} + +describe('terminal structural scroll-intent transitions', () => { + it.each([ + { + name: 'live pinned growth', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 76, baseY: 120, type: 'normal' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 120, bufferType: 'normal' } + }, + { + name: 'empty pinned remount', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 0, baseY: 0, type: 'normal' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' } + }, + { + name: 'shorter pinned remount', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 20, baseY: 30, type: 'normal' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' } + }, + { + name: 'alternate buffer entered from a normal-buffer pin', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 0, baseY: 0, type: 'alternate' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' } + }, + { + name: 'untracked return to bottom', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 100, baseY: 100, type: 'normal' as const }, + expected: { kind: 'followOutput', viewportY: 100, baseY: 100, bufferType: 'normal' } + }, + { + name: 'empty follow-output remount', + storedKind: 'followOutput' as const, + live: { viewportY: 0, baseY: 0, type: 'normal' as const }, + expected: { kind: 'followOutput', viewportY: 0, baseY: 0, bufferType: 'normal' } + } + ])('captures the authoritative coordinates for $name', ({ storedKind, live, expected }) => { + const key = `structural-${storedKind}-${live.type}-${live.viewportY}-${live.baseY}` + const original = createTerminal(76, 100) + bindTerminalScrollIntentKey(original, key) + if (storedKind === 'pinnedViewport') { + markTerminalPinnedViewport(original) + } else { + original.buffer.active.viewportY = original.buffer.active.baseY + markTerminalFollowOutput(original) + } + + const current = createTerminal(live.viewportY, live.baseY, live.type) + bindTerminalScrollIntentKey(current, key) + + expect(captureTerminalStructuralScrollIntent(current)).toMatchObject(expected) + }) + + it('restores a durable remount pin by bottom offset without overwriting newer intent', () => { + const original = createTerminal(76, 100) + bindTerminalScrollIntentKey(original, 'structural-remount-revision') + markTerminalPinnedViewport(original) + const remounted = createTerminal(0, 0) + bindTerminalScrollIntentKey(remounted, 'structural-remount-revision') + const staleIntent = captureTerminalStructuralScrollIntent(remounted) + + remounted.buffer.active.viewportY = 200 + remounted.buffer.active.baseY = 200 + markTerminalFollowOutput(remounted) + restoreTerminalStructuralScrollIntent(remounted, staleIntent, { restoreBy: 'bottomOffset' }) + + expect(remounted.scrollToLine).not.toHaveBeenCalled() + expect(remounted.buffer.active.viewportY).toBe(200) + }) + + it('keeps a normal-buffer pin dormant while replay restores an alternate buffer', () => { + const original = createTerminal(76, 100) + bindTerminalScrollIntentKey(original, 'structural-buffer-switch') + markTerminalPinnedViewport(original) + const remounted = createTerminal(0, 0, 'alternate') + bindTerminalScrollIntentKey(remounted, 'structural-buffer-switch') + const intent = captureTerminalStructuralScrollIntent(remounted) + + remounted.buffer.active.baseY = 40 + remounted.buffer.active.viewportY = 40 + restoreTerminalStructuralScrollIntent(remounted, intent, { restoreBy: 'bottomOffset' }) + expect(remounted.scrollToLine).not.toHaveBeenCalled() + + remounted.buffer.active.type = 'normal' + remounted.buffer.active.baseY = 140 + remounted.buffer.active.viewportY = 140 + restoreTerminalStructuralScrollIntent(remounted, intent, { restoreBy: 'bottomOffset' }) + expect(remounted.scrollToLine).toHaveBeenLastCalledWith(116) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts index 205bd8ecaa9..5d3c9f26aa9 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts @@ -1,15 +1,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { - attachTerminalScrollIntentTracking, - captureTerminalWriteScrollIntent, + bindTerminalScrollIntentKey, + captureTerminalStructuralScrollIntent, enforceTerminalCurrentScrollIntent, - enforceTerminalWriteScrollIntent, getTerminalScrollIntentKind, markTerminalFollowOutput, markTerminalPinnedViewport, syncTerminalScrollIntentFromViewport, - syncTerminalScrollIntentSoon + restoreTerminalStructuralScrollIntent } from './terminal-scroll-intent' +import { syncTerminalScrollIntentSoon } from './terminal-scroll-intent-settle' +import { clearTerminalScrollbackAndFollowOutput } from './terminal-scrollback-clear' +import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking' +import { + beginTerminalScrollIntentBufferRebuild, + cancelTerminalScrollIntentBufferRebuildCompletions, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' function createTerminal({ viewportY, @@ -99,14 +106,39 @@ describe('terminal scroll intent', () => { expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') }) + it('treats a viewport exactly one row above bottom as pinned', () => { + const terminal = createTerminal({ viewportY: 99, baseY: 100 }) + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + syncTerminalScrollIntentFromViewport(terminal) + expect(captureTerminalStructuralScrollIntent(terminal)?.kind).toBe('pinnedViewport') + }) + + it('clears a pinned scrollback into follow-output state', () => { + const terminal = { + ...createTerminal({ viewportY: 42, baseY: 100 }), + clear: vi.fn() + } + markTerminalPinnedViewport(terminal) + + clearTerminalScrollbackAndFollowOutput(terminal) + + expect(terminal.clear).toHaveBeenCalledOnce() + expect(terminal.scrollToBottom).toHaveBeenCalledOnce() + expect(terminal.clear.mock.invocationCallOrder[0]).toBeLessThan( + terminal.scrollToBottom.mock.invocationCallOrder[0] + ) + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + }) + it('preserves a pinned viewport after output moves xterm to bottom', () => { const terminal = createTerminal({ viewportY: 42, baseY: 100 }) markTerminalPinnedViewport(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) terminal.buffer.active.baseY = 125 terminal.buffer.active.viewportY = 125 - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.scrollToLine).toHaveBeenCalledWith(42) expect(terminal.buffer.active.viewportY).toBe(42) @@ -116,11 +148,11 @@ describe('terminal scroll intent', () => { it('follows output after output advances while following', () => { const terminal = createTerminal({ viewportY: 100, baseY: 100 }) markTerminalFollowOutput(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) terminal.buffer.active.baseY = 125 terminal.buffer.active.viewportY = 0 - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.scrollToBottom).toHaveBeenCalledTimes(1) expect(terminal.buffer.active.viewportY).toBe(125) @@ -129,16 +161,30 @@ describe('terminal scroll intent', () => { it('does not preserve across buffer type changes', () => { const terminal = createTerminal({ viewportY: 42, baseY: 100 }) markTerminalPinnedViewport(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) terminal.buffer.active.type = 'alternate' terminal.buffer.active.viewportY = 0 - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.scrollToLine).not.toHaveBeenCalled() expect(terminal.buffer.active.viewportY).toBe(0) }) + it('does not enforce a captured intent after newer user intent supersedes it', () => { + const terminal = createTerminal({ viewportY: 42, baseY: 100 }) + markTerminalPinnedViewport(terminal) + const staleSnapshot = captureTerminalStructuralScrollIntent(terminal) + + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + markTerminalFollowOutput(terminal) + terminal.buffer.active.baseY = 125 + restoreTerminalStructuralScrollIntent(terminal, staleSnapshot) + + expect(terminal.scrollToLine).not.toHaveBeenCalled() + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + }) + it('syncs intent from the current viewport after user scroll settles', () => { const terminal = createTerminal({ viewportY: 100, baseY: 100 }) @@ -148,6 +194,20 @@ describe('terminal scroll intent', () => { expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') }) + it('records xterm native scrollback-trim movement before structural enforcement', () => { + const terminal = createTerminal({ viewportY: 10, baseY: 20 }) + markTerminalPinnedViewport(terminal) + + // At scrollback capacity xterm keeps baseY fixed and walks viewportY up + // as old rows trim, preserving the visible content without app help. + terminal.buffer.active.viewportY = 5 + syncTerminalScrollIntentFromViewport(terminal) + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(5) + }) + it('tracks upward wheel immediately and records the settled viewport', async () => { const frameCallbacks: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { @@ -253,6 +313,131 @@ describe('terminal scroll intent', () => { remountedDisposable.dispose() }) + it('captures durable pinned coordinates before replaying into an empty remount', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 }) + const firstHost = new TestElement() as unknown as HTMLElement + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + firstHost, + 'leaf-remount-replay' + ) + markTerminalPinnedViewport(firstTerminal) + + const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 }) + const remountedHost = new TestElement() as unknown as HTMLElement + const remountedDisposable = attachTerminalScrollIntentTracking( + remountedTerminal, + remountedHost, + 'leaf-remount-replay' + ) + const intent = captureTerminalStructuralScrollIntent(remountedTerminal) + + expect(intent).toMatchObject({ + kind: 'pinnedViewport', + viewportY: 76, + baseY: 100 + }) + remountedTerminal.buffer.active.viewportY = 100 + remountedTerminal.buffer.active.baseY = 100 + restoreTerminalStructuralScrollIntent(remountedTerminal, intent, { + restoreBy: 'bottomOffset' + }) + + expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(76) + expect(remountedTerminal.buffer.active.viewportY).toBe(76) + firstDisposable.dispose() + remountedDisposable.dispose() + }) + + it('refreshes pinned base geometry before a keyed empty remount', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 10, baseY: 20 }) + const firstHost = new TestElement() as unknown as HTMLElement + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + firstHost, + 'leaf-growing-pin' + ) + markTerminalPinnedViewport(firstTerminal) + + firstTerminal.buffer.active.baseY = 30 + syncTerminalScrollIntentFromViewport(firstTerminal) + const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 }) + const remountedHost = new TestElement() as unknown as HTMLElement + const remountedDisposable = attachTerminalScrollIntentTracking( + remountedTerminal, + remountedHost, + 'leaf-growing-pin' + ) + const intent = captureTerminalStructuralScrollIntent(remountedTerminal) + remountedTerminal.buffer.active.viewportY = 30 + remountedTerminal.buffer.active.baseY = 30 + restoreTerminalStructuralScrollIntent(remountedTerminal, intent, { + restoreBy: 'bottomOffset' + }) + + expect(intent).toMatchObject({ viewportY: 10, baseY: 30 }) + expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(10) + firstDisposable.dispose() + remountedDisposable.dispose() + }) + + it('persists native pinned growth on disposal for the next keyed replay', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 }) + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-growth' + ) + markTerminalPinnedViewport(firstTerminal) + firstTerminal.buffer.active.baseY = 120 + + firstDisposable.dispose() + + const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 }) + const remountedDisposable = attachTerminalScrollIntentTracking( + remountedTerminal, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-growth' + ) + const intent = captureTerminalStructuralScrollIntent(remountedTerminal) + remountedTerminal.buffer.active.viewportY = 200 + remountedTerminal.buffer.active.baseY = 200 + restoreTerminalStructuralScrollIntent(remountedTerminal, intent, { + restoreBy: 'bottomOffset' + }) + + expect(intent).toMatchObject({ viewportY: 76, baseY: 120 }) + expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(156) + remountedDisposable.dispose() + }) + + it('does not let an old terminal disposal overwrite its keyed successor', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 }) + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-successor' + ) + markTerminalPinnedViewport(firstTerminal) + + const successor = createTerminal({ viewportY: 100, baseY: 100 }) + const successorDisposable = attachTerminalScrollIntentTracking( + successor, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-successor' + ) + markTerminalFollowOutput(successor) + firstTerminal.buffer.active.baseY = 150 + firstDisposable.dispose() + + expect(getTerminalScrollIntentKind(successor)).toBe('followOutput') + successorDisposable.dispose() + }) + it('tracks pointer-driven scrollbar scrolls without using output scroll as intent', () => { vi.stubGlobal('Element', TestElement) const terminal = createTerminal({ viewportY: 100, baseY: 100 }) @@ -358,6 +543,33 @@ describe('terminal scroll intent', () => { expect(terminal.scrollToLine).toHaveBeenLastCalledWith(75) }) + it('does not let a stale key-settle callback overwrite a remounted terminal', async () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.useFakeTimers({ toFake: ['setTimeout'] }) + let firstTerminalIsCurrent = true + const first = createTerminal({ viewportY: 100, baseY: 100 }) + bindTerminalScrollIntentKey(first, 'key-settle-remount') + markTerminalPinnedViewport(first) + first.buffer.active.viewportY = 50 + syncTerminalScrollIntentSoon(first, { shouldSync: () => firstTerminalIsCurrent }) + + const replacement = createTerminal({ viewportY: 100, baseY: 100 }) + bindTerminalScrollIntentKey(replacement, 'key-settle-remount') + markTerminalFollowOutput(replacement) + firstTerminalIsCurrent = false + await Promise.resolve() + while (frameCallbacks.length > 0) { + frameCallbacks.shift()?.(16) + } + vi.advanceTimersByTime(80) + + expect(getTerminalScrollIntentKind(replacement)).toBe('followOutput') + }) + it('enforces current intent once for visibility resume', () => { const terminal = createTerminal({ viewportY: 40, baseY: 100 }) markTerminalPinnedViewport(terminal) @@ -434,11 +646,11 @@ describe('terminal scroll intent', () => { markTerminalPinnedViewport(terminal) for (let batch = 1; batch <= 2; batch += 1) { - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) // xterm follows output during the write because the viewport was at bottom. terminal.buffer.active.baseY += 25 terminal.buffer.active.viewportY = terminal.buffer.active.baseY - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.buffer.active.viewportY).toBe(terminal.buffer.active.baseY) } expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') @@ -470,16 +682,205 @@ describe('terminal scroll intent', () => { expect(terminal.buffer.active.viewportY).toBe(150) }) - it('supports bottom-offset restore for buffer-rebuild write paths', () => { - const terminal = createTerminal({ viewportY: 550, baseY: 600 }) + it('does not re-latch a pinned intent from a transiently shorter rebuilt buffer', () => { + const terminal = createTerminal({ viewportY: 248, baseY: 254 }) markTerminalPinnedViewport(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) - terminal.buffer.active.baseY = 80 - terminal.buffer.active.viewportY = 80 - enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + // Snapshot replay cleared the buffer; enforcement races the async parse. + terminal.buffer.active.baseY = 0 + terminal.buffer.active.viewportY = 0 + restoreTerminalStructuralScrollIntent(terminal, snapshot) - expect(terminal.scrollToLine).toHaveBeenLastCalledWith(30) - expect(terminal.buffer.active.viewportY).toBe(30) + // The replay finishes parsing and the scrollback regrows past the pin. + terminal.buffer.active.baseY = 284 + terminal.buffer.active.viewportY = 284 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(248) + expect(terminal.buffer.active.viewportY).toBe(248) }) + + it('keeps a pinned intent when capture races a cleared unparsed buffer', () => { + const terminal = createTerminal({ viewportY: 248, baseY: 254 }) + markTerminalPinnedViewport(terminal) + + // A structural capture sees the rebuilt buffer while it is still empty; + // the at-bottom(0/0) reading is transient and must not convert the pin. + terminal.buffer.active.baseY = 0 + terminal.buffer.active.viewportY = 0 + const snapshot = captureTerminalStructuralScrollIntent(terminal) + expect(snapshot?.kind).toBe('pinnedViewport') + }) + + it('suspends intent capture and enforcement while a buffer rebuild is in flight', () => { + const terminal = createTerminal({ viewportY: 248, baseY: 254 }) + markTerminalPinnedViewport(terminal) + const preReplay = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.baseY = 0 + terminal.buffer.active.viewportY = 0 + expect(captureTerminalStructuralScrollIntent(terminal)).toBeNull() + enforceTerminalCurrentScrollIntent(terminal) + + // A live streaming batch lands while the replay is partially parsed. + terminal.buffer.active.baseY = 284 + terminal.buffer.active.viewportY = 0 + restoreTerminalStructuralScrollIntent(terminal, preReplay) + expect(terminal.scrollToLine).not.toHaveBeenCalled() + expect(terminal.scrollToBottom).not.toHaveBeenCalled() + + terminal.buffer.active.viewportY = 284 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, preReplay, { restoreBy: 'bottomOffset' }) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(278) + expect(terminal.buffer.active.viewportY).toBe(278) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + }) + + it.each([ + { deltaY: -10, finalViewportY: 150, expectedKind: 'pinnedViewport', expectedLine: 150 }, + { deltaY: 10, finalViewportY: 200, expectedKind: 'followOutput', expectedLine: null } + ])( + 'resyncs a $expectedKind wheel intent after a delayed rebuild', + async ({ deltaY, finalViewportY, expectedKind, expectedLine }) => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 42, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: deltaY }) + host.dispatchEvent(wheel) + terminal.buffer.active.viewportY = finalViewportY + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { + restoreBy: 'bottomOffset' + }) + + expect(getTerminalScrollIntentKind(terminal)).toBe(expectedKind) + terminal.buffer.active.viewportY = 0 + terminal.scrollToLine.mockClear() + enforceTerminalCurrentScrollIntent(terminal) + if (expectedLine === null) { + expect(terminal.scrollToBottom).toHaveBeenCalled() + } else { + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(expectedLine) + } + disposable.dispose() + } + ) + + it('preserves a durable pin when wheel-up lands on the cleared replay buffer', async () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 80, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + await Promise.resolve() + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180) + expect(terminal.buffer.active.viewportY).toBe(180) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('keeps rebuild wheel intent when xterm classifies the same event as mouse input', async () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture({ + viewportY: 80, + baseY: 100 + }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[<64;10;5M') + await Promise.resolve() + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + await Promise.resolve() + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('does not resync deferred wheel intent from a canceled partial rebuild', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 42, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + cancelTerminalScrollIntentBufferRebuildCompletions(terminal) + endTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 100 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42) + disposable.dispose() + }) + + function createTerminalWithInputCapture(args: { viewportY: number; baseY: number }) { + const capturedInput: { listener: ((data: string) => void) | null } = { listener: null } + const capturedUserInput: { listener: (() => void) | null } = { listener: null } + const terminal = createTerminal(args) as ReturnType & { + onData?: (listener: (data: string) => void) => { dispose: () => void } + _core?: { coreService: { onUserInput: (listener: () => void) => { dispose: () => void } } } + } + terminal.onData = (listener: (data: string) => void) => { + capturedInput.listener = listener + return { dispose: vi.fn() } + } + terminal._core = { + coreService: { + onUserInput: (listener: () => void) => { + capturedUserInput.listener = listener + return { dispose: vi.fn() } + } + } + } + return { terminal, capturedInput, capturedUserInput } + } }) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts index 88e70096a59..8fa96d62c69 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -1,35 +1,36 @@ -import type { IDisposable } from '@xterm/xterm' +import { isTerminalScrollIntentRebuildInFlight } from './terminal-scroll-intent-rebuild' +import { + clampTerminalViewportY, + isTerminalViewportAtBottom, + readTerminalScrollBufferSnapshot, + safeTerminalScrollCall, + type TerminalScrollBufferType +} from './terminal-scroll-buffer-snapshot' type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport' -type BufferType = 'normal' | 'alternate' - -type TerminalScrollIntentTarget = { - buffer?: { - active?: { - type?: string - viewportY?: number - baseY?: number - } - } +export type TerminalScrollIntentTarget = { + buffer?: Parameters[0]['buffer'] scrollToBottom?: () => void scrollToLine?: (line: number) => void } -type TerminalScrollIntentKey = string +export type TerminalScrollIntentKey = string type TerminalScrollIntent = { kind: TerminalScrollIntentKind - bufferType: BufferType + bufferType: TerminalScrollBufferType viewportY: number baseY: number + revision: number } -type TerminalScrollIntentWriteSnapshot = { +export type TerminalStructuralScrollIntentSnapshot = { kind: TerminalScrollIntentKind - bufferType: BufferType + bufferType: TerminalScrollBufferType viewportY: number baseY: number + revision: number } type TerminalScrollIntentEnforceOptions = { @@ -47,47 +48,31 @@ const terminalScrollIntentKeyByTerminal = new WeakMap< TerminalScrollIntentTarget, TerminalScrollIntentKey >() +const terminalScrollIntentKeyBindingByTerminal = new WeakMap() const terminalScrollIntentByKey = new Map() +const terminalScrollIntentBindingByKey = new Map() -const BOTTOM_TOLERANCE_ROWS = 1 -const XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES = [ - 'xterm-viewport', - 'xterm-scrollbar', - 'xterm-slider' -] as const -const XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR = XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES.map( - (className) => `.${className}` -).join(',') - -function readBufferSnapshot( - terminal: TerminalScrollIntentTarget -): { bufferType: BufferType; viewportY: number; baseY: number } | null { - const buffer = terminal.buffer?.active - const viewportY = buffer?.viewportY - const baseY = buffer?.baseY - if (typeof viewportY !== 'number' || typeof baseY !== 'number') { - return null - } - return { - bufferType: buffer?.type === 'alternate' ? 'alternate' : 'normal', - viewportY, - baseY - } -} - -function isAtBottom(viewportY: number, baseY: number): boolean { - return viewportY >= baseY - BOTTOM_TOLERANCE_ROWS -} +let nextTerminalScrollIntentRevision = 1 +let nextTerminalScrollIntentKeyBinding = 1 function writeIntent( terminal: TerminalScrollIntentTarget, kind: TerminalScrollIntentKind ): TerminalScrollIntent | null { - const snapshot = readBufferSnapshot(terminal) + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return null } - const intent = { kind, ...snapshot } + return writeIntentSnapshot(terminal, kind, snapshot) +} + +function writeIntentSnapshot( + terminal: TerminalScrollIntentTarget, + kind: TerminalScrollIntentKind, + snapshot: { bufferType: TerminalScrollBufferType; viewportY: number; baseY: number } +): TerminalScrollIntent { + const intent = { kind, ...snapshot, revision: nextTerminalScrollIntentRevision } + nextTerminalScrollIntentRevision += 1 terminalScrollIntentByTerminal.set(terminal, intent) const key = terminalScrollIntentKeyByTerminal.get(terminal) if (key) { @@ -105,7 +90,7 @@ function readStoredIntent(terminal: TerminalScrollIntentTarget): TerminalScrollI return key ? terminalScrollIntentByKey.get(key) : undefined } -function bindTerminalScrollIntentKey( +export function bindTerminalScrollIntentKey( terminal: TerminalScrollIntentTarget, key: TerminalScrollIntentKey | undefined ): TerminalScrollIntent | undefined { @@ -113,6 +98,10 @@ function bindTerminalScrollIntentKey( return terminalScrollIntentByTerminal.get(terminal) } terminalScrollIntentKeyByTerminal.set(terminal, key) + const binding = nextTerminalScrollIntentKeyBinding + nextTerminalScrollIntentKeyBinding += 1 + terminalScrollIntentKeyBindingByTerminal.set(terminal, binding) + terminalScrollIntentBindingByKey.set(key, binding) const existing = terminalScrollIntentByKey.get(key) if (existing) { terminalScrollIntentByTerminal.set(terminal, existing) @@ -120,28 +109,17 @@ function bindTerminalScrollIntentKey( return existing } -function clampViewportY(viewportY: number, baseY: number): number { - return Math.max(0, Math.min(viewportY, baseY)) -} - -function safeScrollCall(fn: () => void): boolean { - try { - fn() +export function isTerminalScrollIntentKeyBindingCurrent( + terminal: TerminalScrollIntentTarget +): boolean { + const key = terminalScrollIntentKeyByTerminal.get(terminal) + if (!key) { return true - } catch (err) { - if (err instanceof TypeError && /dimensions/.test(err.message)) { - return false - } - throw err } -} - -function isTerminalScrollIntentPointerTarget(target: EventTarget | null): target is Element { - if (typeof Element === 'undefined' || !(target instanceof Element)) { - return false - } - // xterm's custom scrollbar uses separate thumb/track nodes from the viewport. - return target.closest(XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR) !== null + return ( + terminalScrollIntentKeyBindingByTerminal.get(terminal) === + terminalScrollIntentBindingByKey.get(key) + ) } export function markTerminalFollowOutput(terminal: TerminalScrollIntentTarget): void { @@ -154,45 +132,53 @@ export function markTerminalPinnedViewport(terminal: TerminalScrollIntentTarget) export function syncTerminalScrollIntentFromViewport( terminal: TerminalScrollIntentTarget, - options: { preservePinnedAtBottom?: boolean } = {} + options: { allowBufferShrink?: boolean; preservePinnedAtBottom?: boolean } = {} ): void { - const snapshot = readBufferSnapshot(terminal) + if (isTerminalScrollIntentRebuildInFlight(terminal)) { + return + } + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return } const existing = readStoredIntent(terminal) // Why: a remounted/replayed terminal can briefly report an empty or shorter // scrollback. That transient state must not erase a durable pinned viewport. - if (existing?.kind === 'pinnedViewport' && snapshot.baseY < existing.baseY) { + if ( + !options.allowBufferShrink && + existing?.kind === 'pinnedViewport' && + snapshot.baseY < existing.baseY + ) { terminalScrollIntentByTerminal.set(terminal, existing) return } if ( options.preservePinnedAtBottom && existing?.kind === 'pinnedViewport' && - isAtBottom(snapshot.viewportY, snapshot.baseY) + isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) ) { return } - writeIntent( - terminal, - isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport' - ) -} - -export function syncTerminalScrollIntentSoon( - terminal: TerminalScrollIntentTarget, - options: { preservePinnedAtBottom?: boolean } = {} -): void { - const sync = (): void => syncTerminalScrollIntentFromViewport(terminal, options) - queueMicrotask(sync) - requestAnimationFrame(sync) - requestAnimationFrame(() => requestAnimationFrame(sync)) - // Why: preservePinnedAtBottom only bridges xterm's async scroll application. - // The settle tick must reclassify from the real viewport, otherwise a wheel - // the viewport never followed (sub-row delta, TUI-consumed mouse report, - // plain PageUp/Home sent to the app) latches a phantom pin at the bottom. - setTimeout(() => syncTerminalScrollIntentFromViewport(terminal), 80) + const kind = isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ? 'followOutput' + : 'pinnedViewport' + // Why: parser auto-replies and repeated wheel settle samples often observe + // no intent change. Avoid manufacturing revisions that can cancel a valid + // structural restore or amplify terminal-output bursts. + if ( + existing?.kind === kind && + existing.bufferType === snapshot.bufferType && + (kind === 'followOutput' || existing.viewportY === snapshot.viewportY) + ) { + if (kind === 'pinnedViewport' && existing.baseY !== snapshot.baseY) { + // Why: native pinned output can grow baseY without moving viewportY. + // Refresh geometry without creating a user-intent revision so a later + // keyed remount restores the same content, not the stale bottom offset. + Object.assign(existing, snapshot) + } + return + } + writeIntent(terminal, kind) } export function getTerminalScrollIntentKind( @@ -202,52 +188,82 @@ export function getTerminalScrollIntentKind( if (existing) { return existing.kind } - const snapshot = readBufferSnapshot(terminal) + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return 'followOutput' } - return isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport' + return isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ? 'followOutput' + : 'pinnedViewport' } -export function captureTerminalWriteScrollIntent( +export function captureTerminalStructuralScrollIntent( terminal: TerminalScrollIntentTarget -): TerminalScrollIntentWriteSnapshot | null { - const snapshot = readBufferSnapshot(terminal) +): TerminalStructuralScrollIntentSnapshot | null { + if (isTerminalScrollIntentRebuildInFlight(terminal)) { + return null + } + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return null } const existing = readStoredIntent(terminal) let kind = existing?.kind ?? - (isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport') + (isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ? 'followOutput' + : 'pinnedViewport') // Why: a pinned intent whose live viewport still sits at the bottom is a - // phantom pin (the user's scroll never detached the viewport). Enforcing it - // would freeze the terminal at the current line on every write batch. - if (kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) { + // phantom pin (the user's scroll never detached the viewport). Restoring it + // after a structural operation would freeze the terminal at a stale line. + // Only trust the at-bottom reading when the scrollback is at least as long + // as the pin's — a shorter one is a cleared buffer awaiting replay. + if ( + kind === 'pinnedViewport' && + isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) && + (!existing || snapshot.baseY >= existing.baseY) + ) { kind = 'followOutput' } + // Why: a keyed remount starts at 0/0 before replay. Preserve the durable + // pre-remount coordinates or a bottom-offset restore silently loses the pin. + const capturedCoordinates = + existing?.kind === 'pinnedViewport' && snapshot.baseY < existing.baseY ? existing : snapshot return { + ...capturedCoordinates, kind, - bufferType: snapshot.bufferType, - viewportY: snapshot.viewportY, - baseY: snapshot.baseY + revision: existing?.revision ?? 0 } } -export function enforceTerminalWriteScrollIntent( +export function isTerminalStructuralScrollIntentCurrent( terminal: TerminalScrollIntentTarget, - snapshot: TerminalScrollIntentWriteSnapshot | null, + snapshot: TerminalStructuralScrollIntentSnapshot | null +): boolean { + if (!snapshot) { + return false + } + return (readStoredIntent(terminal)?.revision ?? 0) === snapshot.revision +} + +export function restoreTerminalStructuralScrollIntent( + terminal: TerminalScrollIntentTarget, + snapshot: TerminalStructuralScrollIntentSnapshot | null, options: TerminalScrollIntentEnforceOptions = {} ): void { - if (!snapshot) { + if ( + !snapshot || + !isTerminalStructuralScrollIntentCurrent(terminal, snapshot) || + isTerminalScrollIntentRebuildInFlight(terminal) + ) { return } - const current = readBufferSnapshot(terminal) + const current = readTerminalScrollBufferSnapshot(terminal) if (!current || current.bufferType !== snapshot.bufferType) { return } if (snapshot.kind === 'followOutput') { - if (safeScrollCall(() => terminal.scrollToBottom?.())) { + if (safeTerminalScrollCall(() => terminal.scrollToBottom?.())) { writeIntent(terminal, 'followOutput') } return @@ -256,89 +272,60 @@ export function enforceTerminalWriteScrollIntent( options.restoreBy === 'bottomOffset' ? current.baseY - Math.max(0, snapshot.baseY - snapshot.viewportY) : snapshot.viewportY - const targetY = clampViewportY(requestedY, current.baseY) + const targetY = clampTerminalViewportY(requestedY, current.baseY) if (current.viewportY !== targetY) { - safeScrollCall(() => terminal.scrollToLine?.(targetY)) + if (!safeTerminalScrollCall(() => terminal.scrollToLine?.(targetY))) { + // Why: renderer teardown can reject the scroll before xterm changes its + // native viewport; retain the intended pin for the next fit/retry rather + // than latching the transient current bottom. + writeIntentSnapshot(terminal, 'pinnedViewport', { + bufferType: current.bufferType, + viewportY: targetY, + baseY: current.baseY + }) + return + } + } + const existing = readStoredIntent(terminal) + // Why: a scrollback shorter than the stored pin means the buffer is being + // rebuilt; re-latching from it would overwrite the durable line with the + // cleared buffer's line 0. + if (existing?.kind === 'pinnedViewport' && current.baseY < existing.baseY) { + return } writeIntent(terminal, 'pinnedViewport') } export function enforceTerminalCurrentScrollIntent(terminal: TerminalScrollIntentTarget): void { + if (isTerminalScrollIntentRebuildInFlight(terminal)) { + return + } const existing = readStoredIntent(terminal) if (!existing) { - enforceTerminalWriteScrollIntent(terminal, captureTerminalWriteScrollIntent(terminal)) + restoreTerminalStructuralScrollIntent(terminal, captureTerminalStructuralScrollIntent(terminal)) return } const snapshot = { kind: existing.kind, bufferType: existing.bufferType, viewportY: existing.viewportY, - baseY: existing.baseY + baseY: existing.baseY, + revision: existing.revision } - if (snapshot.kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) { + if ( + snapshot.kind === 'pinnedViewport' && + isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ) { // Why: a pin recorded at the bottom means the viewport never detached; // resuming must follow live output, not freeze at that stale line. snapshot.kind = 'followOutput' } - const current = readBufferSnapshot(terminal) + const current = readTerminalScrollBufferSnapshot(terminal) // Why: a shorter live buffer than the stored intent means the buffer was // rebuilt (snapshot replay/remount); absolute lines are renumbered there. const restoreBy = snapshot.kind === 'pinnedViewport' && current && current.baseY < snapshot.baseY ? 'bottomOffset' : 'viewportLine' - enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy }) -} - -export function attachTerminalScrollIntentTracking( - terminal: TerminalScrollIntentTarget, - host: HTMLElement, - intentKey?: TerminalScrollIntentKey -): IDisposable { - if (!bindTerminalScrollIntentKey(terminal, intentKey)) { - syncTerminalScrollIntentFromViewport(terminal) - } - let pointerScrollActive = false - - const onWheel = (event: WheelEvent): void => { - if (event.deltaY < 0) { - markTerminalPinnedViewport(terminal) - syncTerminalScrollIntentSoon(terminal, { preservePinnedAtBottom: true }) - return - } - syncTerminalScrollIntentSoon(terminal) - } - - const onPointerDown = (event: PointerEvent): void => { - pointerScrollActive = isTerminalScrollIntentPointerTarget(event.target) - } - - const onPointerDone = (): void => { - if (!pointerScrollActive) { - return - } - pointerScrollActive = false - syncTerminalScrollIntentFromViewport(terminal) - } - - const onScroll = (): void => { - if (pointerScrollActive) { - syncTerminalScrollIntentFromViewport(terminal) - } - } - - host.addEventListener('wheel', onWheel, { capture: true, passive: true }) - host.addEventListener('pointerdown', onPointerDown, true) - host.addEventListener('scroll', onScroll, true) - globalThis.addEventListener?.('pointerup', onPointerDone, true) - globalThis.addEventListener?.('pointercancel', onPointerDone, true) - return { - dispose: () => { - host.removeEventListener('wheel', onWheel, true) - host.removeEventListener('pointerdown', onPointerDown, true) - host.removeEventListener('scroll', onScroll, true) - globalThis.removeEventListener?.('pointerup', onPointerDone, true) - globalThis.removeEventListener?.('pointercancel', onPointerDone, true) - } - } + restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy }) } diff --git a/src/renderer/src/lib/pane-manager/terminal-scrollback-clear.ts b/src/renderer/src/lib/pane-manager/terminal-scrollback-clear.ts new file mode 100644 index 00000000000..84929536a83 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scrollback-clear.ts @@ -0,0 +1,16 @@ +import { markTerminalFollowOutput, type TerminalScrollIntentTarget } from './terminal-scroll-intent' + +type TerminalScrollbackClearTarget = TerminalScrollIntentTarget & { + clear: () => void + scrollToBottom: () => void +} + +export function clearTerminalScrollbackAndFollowOutput( + terminal: TerminalScrollbackClearTarget +): void { + terminal.clear() + // Why: xterm clear() leaves BufferService.isUserScrolling latched when the + // viewport was pinned, so a public zero-distance bottom scroll must reset it. + terminal.scrollToBottom() + markTerminalFollowOutput(terminal) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts new file mode 100644 index 00000000000..a4e2d86d429 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createTerminalStructuralReplayCoordinator } from './terminal-structural-replay-coordinator' +import { + markTerminalPinnedViewport, + syncTerminalScrollIntentFromViewport +} from './terminal-scroll-intent' +import { + isTerminalScrollIntentRebuildInFlight, + onTerminalScrollIntentBufferRebuildComplete +} from './terminal-scroll-intent-rebuild' +import { restoreScrollStateAfterFit } from './pane-scroll' +import type { ScrollState } from './pane-manager-types' + +function createTerminal(viewportY: number, baseY: number) { + const active = { type: 'normal', viewportY, baseY } + return { + buffer: { active }, + scrollToBottom: vi.fn(() => { + active.viewportY = active.baseY + }), + scrollToLine: vi.fn((line: number) => { + active.viewportY = line + }) + } +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {} + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('terminal structural replay coordinator', () => { + it('serializes rebuilds and restores a pinned bottom offset after each parse', async () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const firstParsed = deferred() + const secondParsed = deferred() + const starts: string[] = [] + + const first = coordinator.run(async () => { + starts.push('first') + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + await firstParsed.promise + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + }) + const second = coordinator.run(async () => { + starts.push('second') + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + await secondParsed.promise + terminal.buffer.active.viewportY = 300 + terminal.buffer.active.baseY = 300 + }) + + await Promise.resolve() + await Promise.resolve() + expect(starts).toEqual(['first']) + firstParsed.resolve() + await first + expect(terminal.buffer.active.viewportY).toBe(180) + await Promise.resolve() + expect(starts).toEqual(['first', 'second']) + secondParsed.resolve() + await second + expect(terminal.buffer.active.viewportY).toBe(280) + }) + + it('lets user viewport intent observed during replay supersede the old pin', async () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const parsed = deferred() + + const completion = coordinator.run(async () => { + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + if (completed) { + syncTerminalScrollIntentFromViewport(terminal, { allowBufferShrink: true }) + } + }) + await parsed.promise + terminal.buffer.active.viewportY = 190 + terminal.buffer.active.baseY = 200 + }) + + parsed.resolve() + await completion + expect(terminal.buffer.active.viewportY).toBe(190) + expect(terminal.scrollToLine).not.toHaveBeenCalled() + }) + + it('releases a rebuild on disposal without restoring a half-parsed buffer', async () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const neverParsed = new Promise(() => {}) + let postRebuildCompleted: boolean | null = null + + const completion = coordinator.run(async () => { + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + postRebuildCompleted = completed + }) + await neverParsed + }) + await Promise.resolve() + await Promise.resolve() + coordinator.dispose() + await completion + + expect(postRebuildCompleted).toBe(false) + expect(terminal.buffer.active.viewportY).toBe(0) + expect(terminal.scrollToLine).not.toHaveBeenCalled() + }) + + it('cancels a pre-existing fit restore before replay can make it stale', async () => { + const rafCallbacks: FrameRequestCallback[] = [] + const cancelAnimationFrame = vi.fn() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + rafCallbacks.push(callback) + return rafCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) + const terminal = createTerminal(80, 100) as ReturnType & { + element: object | null + } + terminal.element = null + const staleState: ScrollState = { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 20, + baseY: 100 + } + restoreScrollStateAfterFit(terminal as never, staleState, { + onRestored: vi.fn(), + shouldRestore: () => true + }) + expect(rafCallbacks).toHaveLength(1) + + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const parsed = deferred() + const completion = coordinator.run(async () => { + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + await parsed.promise + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + }) + await Promise.resolve() + await Promise.resolve() + expect(cancelAnimationFrame).toHaveBeenCalledWith(1) + + terminal.element = {} + parsed.resolve() + await completion + expect(terminal.buffer.active.viewportY).toBe(180) + rafCallbacks[0]?.(0) + expect(terminal.buffer.active.viewportY).toBe(180) + }) + + it('restores and releases replay when an optional completion listener throws', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const laterCompletion = vi.fn() + + await coordinator.run(() => { + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + onTerminalScrollIntentBufferRebuildComplete(terminal, () => { + throw new Error('optional listener failed') + }) + onTerminalScrollIntentBufferRebuildComplete(terminal, laterCompletion) + }) + + expect(laterCompletion).toHaveBeenCalledWith(true) + expect(terminal.buffer.active.viewportY).toBe(180) + expect(isTerminalScrollIntentRebuildInFlight(terminal)).toBe(false) + expect(consoleError).toHaveBeenCalledWith( + '[terminal] scroll-intent rebuild completion failed', + expect.any(Error) + ) + }) + + it('keeps later replay work serialized behind an asynchronous post-restore fit', async () => { + const terminal = createTerminal(80, 100) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const fitCompleted = deferred() + const fitStarted = deferred() + const events: string[] = [] + + const first = coordinator.run( + () => { + events.push('first-replay') + }, + { + afterRestore: async () => { + events.push('first-fit') + fitStarted.resolve() + await fitCompleted.promise + } + } + ) + const second = coordinator.run(() => { + events.push('second-replay') + }) + + await fitStarted.promise + expect(events).toEqual(['first-replay', 'first-fit']) + fitCompleted.resolve() + await first + await second + expect(events).toEqual(['first-replay', 'first-fit', 'second-replay']) + }) + + it('releases an asynchronous post-restore wait when the coordinator is disposed', async () => { + const terminal = createTerminal(80, 100) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const fitNeverCompletes = new Promise(() => {}) + const fitStarted = deferred() + const completion = coordinator.run(() => undefined, { + afterRestore: async () => { + fitStarted.resolve() + await fitNeverCompletes + } + }) + + await fitStarted.promise + coordinator.dispose() + + await expect(completion).resolves.toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.ts b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.ts new file mode 100644 index 00000000000..35aee56a5cd --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.ts @@ -0,0 +1,91 @@ +import { + captureTerminalStructuralScrollIntent, + restoreTerminalStructuralScrollIntent, + type TerminalScrollIntentTarget +} from './terminal-scroll-intent' +import { + beginTerminalScrollIntentBufferRebuild, + cancelTerminalScrollIntentBufferRebuildCompletions, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' +import { cancelDeferredScrollRestore } from './pane-scroll' + +type StructuralReplayTask = () => void | Promise + +type StructuralReplayOptions = { + shouldRestore?: () => boolean + afterRestore?: () => void | Promise +} + +export type TerminalStructuralReplayCoordinator = { + run: (task: StructuralReplayTask, options?: StructuralReplayOptions) => Promise + dispose: () => void +} + +// Why: clear-and-replay bytes parse later and can overlap. One pane-scoped +// queue prevents dimension changes and stale viewport restores from interleaving. +export function createTerminalStructuralReplayCoordinator( + terminal: TerminalScrollIntentTarget +): TerminalStructuralReplayCoordinator { + let disposed = false + let activeCancellation: (() => void) | null = null + let tail = Promise.resolve() + + const run = ( + task: StructuralReplayTask, + options: StructuralReplayOptions = {} + ): Promise => { + const completion = tail + .catch(() => undefined) + .then(async () => { + if (disposed) { + return + } + const intent = captureTerminalStructuralScrollIntent(terminal) + // Why: a pre-replay fit retry can otherwise run after this transaction + // and restore a stale marker over the authoritative replay viewport. + cancelDeferredScrollRestore(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + let cancelTask = (): void => {} + const cancellation = new Promise((resolve) => { + cancelTask = resolve + }) + activeCancellation = () => { + cancelTask() + } + try { + const taskCompletion = Promise.resolve(task()) + await Promise.race([taskCompletion, cancellation]) + } finally { + endTerminalScrollIntentBufferRebuild(terminal) + try { + const shouldRestore = !disposed && options.shouldRestore?.() !== false + if (shouldRestore) { + restoreTerminalStructuralScrollIntent(terminal, intent, { restoreBy: 'bottomOffset' }) + // Why: live bytes must remain serialized behind replay until any + // post-restore fit has produced the authoritative destination grid. + await Promise.race([Promise.resolve(options.afterRestore?.()), cancellation]) + } + } finally { + activeCancellation = null + } + } + }) + tail = completion + return completion + } + + return { + run, + dispose: () => { + if (disposed) { + return + } + disposed = true + // Why: a torn-down terminal may silently drop write callbacks. Release + // the rebuild without sampling its half-parsed buffer into the keyed pin. + cancelTerminalScrollIntentBufferRebuildCompletions(terminal) + activeCancellation?.() + } + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-viewport-scrollbar-sync.ts b/src/renderer/src/lib/pane-manager/terminal-viewport-scrollbar-sync.ts new file mode 100644 index 00000000000..b5d970e088d --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-viewport-scrollbar-sync.ts @@ -0,0 +1,29 @@ +import type { Terminal } from '@xterm/xterm' + +// Why: xterm 6 can leave its scrollbar thumb stale when ydisp is unchanged. +// A synchronous one-line jiggle updates the scrollbar without a visible paint. +export function forceTerminalViewportScrollbarSync(terminal: Terminal): void { + const buf = terminal.buffer.active + if (buf.viewportY >= buf.baseY) { + // Why: jiggle-scrolling at bottom makes xterm stop following active output + // after split-pane resizes; scrollToBottom already places the thumb there. + return + } + if (buf.viewportY > 0) { + safeScrollCall(() => terminal.scrollLines(-1)) + safeScrollCall(() => terminal.scrollLines(1)) + } else if (buf.viewportY < buf.baseY) { + safeScrollCall(() => terminal.scrollLines(1)) + safeScrollCall(() => terminal.scrollLines(-1)) + } +} + +function safeScrollCall(fn: () => void): void { + try { + fn() + } catch (error) { + if (!(error instanceof TypeError) || !/dimensions/.test(error.message)) { + throw error + } + } +} diff --git a/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts b/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts new file mode 100644 index 00000000000..2141e2bf187 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts @@ -0,0 +1,182 @@ +/** + * Contract test for xterm's native user-scrolling ownership (vendored + * 6.1.0-beta.287; @xterm/headless shares BufferService with @xterm/xterm). + * + * Orca's live PTY write path performs NO scroll-intent enforcement — it + * relies on xterm core keeping a scrolled-up viewport stable and following + * output at the bottom (BufferService.isUserScrolling, consumed atomically + * inside scroll()). App-side enforcement is scoped to structural operations + * (snapshot replay, remount, fit reflow) in terminal-scroll-intent.ts. + * + * If an xterm upgrade breaks any assertion here, the live write path loses + * its follow/pin semantics silently — fix the write path before bumping. + */ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import packageJson from '../../../../../package.json' +import { clearTerminalScrollbackAndFollowOutput } from './terminal-scrollback-clear' + +type TerminalWithBufferService = Terminal & { + _core?: { + _bufferService?: { isUserScrolling?: boolean } + coreService?: { onUserInput?: (listener: () => void) => { dispose: () => void } } + } +} + +function write(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +async function writeLines(term: Terminal, count: number, label: string): Promise { + for (let i = 0; i < count; i += 1) { + await write(term, `${label}${i}\r\n`) + } +} + +describe('xterm native user-scrolling contract (vendored 6.1.0-beta.287)', () => { + it('pins headless and renderer xterm to the same version', () => { + expect(packageJson.dependencies['@xterm/headless']).toBe( + packageJson.devDependencies['@xterm/xterm'] + ) + }) + + it('keeps a scrolled-up viewport stable while output is written', async () => { + const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true }) + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + expect(buffer.viewportY).toBe(buffer.baseY) + + term.scrollLines(-5) + const pinnedY = buffer.viewportY + expect(pinnedY).toBe(buffer.baseY - 5) + + await writeLines(term, 10, 'more') + expect(buffer.viewportY).toBe(pinnedY) + expect(buffer.baseY).toBe(pinnedY + 15) + }) + + it('treats a viewport one row above bottom as user-scrolling through output', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + + term.scrollLines(-1) + const pinnedY = buffer.viewportY + expect(pinnedY).toBe(buffer.baseY - 1) + expect(term._core?._bufferService?.isUserScrolling).toBe(true) + + await writeLines(term, 5, 'more') + expect(buffer.viewportY).toBe(pinnedY) + }) + + it('follows output at the bottom and re-follows after scrolling back down', async () => { + const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true }) + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + + await writeLines(term, 5, 'tail') + expect(buffer.viewportY).toBe(buffer.baseY) + + term.scrollLines(-5) + term.scrollToBottom() + await writeLines(term, 5, 'after') + expect(buffer.viewportY).toBe(buffer.baseY) + }) + + it('applies scrollOnUserInput before notifying onData listeners', async () => { + const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true }) + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + term.scrollLines(-5) + let viewportSeenByOnData = -1 + const subscription = term.onData(() => { + viewportSeenByOnData = buffer.viewportY + }) + + term.input('a', true) + + // Why: Orca resyncs typing intent synchronously from onData, so this + // xterm ordering is part of the pinned-version contract. + expect(viewportSeenByOnData).toBe(buffer.baseY) + subscription.dispose() + }) + + it('distinguishes real user input from parser auto-replies', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + allowProposedApi: true + }) as TerminalWithBufferService + expect(term._core?.coreService?.onUserInput).toBeTypeOf('function') + let userInputCount = 0 + const subscription = term._core?.coreService?.onUserInput?.(() => { + userInputCount += 1 + }) + + term.input('a', true) + await write(term, '\x1b[6n') + + expect(userInputCount).toBe(1) + subscription?.dispose() + }) + + it('walks a pinned viewport down content-stably when scrollback trims', async () => { + const term = new Terminal({ rows: 5, cols: 20, scrollback: 20, allowProposedApi: true }) + await writeLines(term, 30, 'x') + const buffer = term.buffer.active + term.scrollLines(-10) + const pinnedY = buffer.viewportY + const fullBaseY = buffer.baseY + + await writeLines(term, 10, 'trim') + // Buffer is at capacity: baseY stays put while each trimmed line shifts + // the pinned viewport up by one so the visible content does not move. + expect(buffer.baseY).toBe(fullBaseY) + expect(buffer.viewportY).toBe(Math.max(0, pinnedY - 10)) + }) + + it('exposes the isUserScrolling flag the structural restore paths depend on', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 30, 'line') + const bufferService = term._core?._bufferService + expect(typeof bufferService?.isUserScrolling).toBe('boolean') + + // scrollLines/scrollToBottom self-manage the flag, so Orca's programmatic + // scroll restores inherit xterm's native live-output ownership. + expect(bufferService?.isUserScrolling).toBe(false) + term.scrollLines(-5) + expect(bufferService?.isUserScrolling).toBe(true) + term.scrollToBottom() + expect(bufferService?.isUserScrolling).toBe(false) + }) + + it('resets native user-scrolling when a pinned scrollback is cleared', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 30, 'line') + term.scrollLines(-5) + expect(term._core?._bufferService?.isUserScrolling).toBe(true) + + clearTerminalScrollbackAndFollowOutput(term) + expect(term.buffer.active.viewportY).toBe(0) + expect(term.buffer.active.baseY).toBe(0) + expect(term._core?._bufferService?.isUserScrolling).toBe(false) + + await writeLines(term, 15, 'after-clear') + expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY) + }) +}) diff --git a/src/renderer/src/lib/session-write-subscriber.test.ts b/src/renderer/src/lib/session-write-subscriber.test.ts index 703c5405834..3c31296095a 100644 --- a/src/renderer/src/lib/session-write-subscriber.test.ts +++ b/src/renderer/src/lib/session-write-subscriber.test.ts @@ -601,6 +601,32 @@ describe('createSessionWriteSubscriber', () => { cleanup() }) + it('rebuilds a pending tab patch after close so it cannot resurrect the tab', () => { + const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() + const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) + + useAppStore.setState({ + workspaceSessionReady: true, + hydrationSucceeded: true, + ...makeTerminalSessionState('shell') + }) + vi.advanceTimersByTime(50) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + unifiedTabsByWorktree: { 'wt-1': [] }, + groupsByWorktree: { 'wt-1': [] }, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + activeTabId: null + }) + vi.advanceTimersByTime(200) + + expect(persist).toHaveBeenCalledTimes(1) + expect(persist.mock.calls[0][0].patch.tabsByWorktree?.['wt-1']).toEqual([]) + expect(persist.mock.calls[0][0].patch.unifiedTabs?.['wt-1']).toBeUndefined() + cleanup() + }) + it('cleanup unsubscribes and cancels a pending timer', () => { const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 765940ffe9e..3541c0e3f2a 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -3,7 +3,7 @@ import type { LucideProps } from 'lucide-react' import type { SettingsSearchEntry } from '@/components/settings/settings-search' export type SettingsNavIcon = ComponentType -export type SettingsNavInstallStatus = 'install' | 'installed' | 'checking' +export type SettingsNavInstallStatus = 'install' | 'installed' | 'update-available' | 'checking' export type SettingsNavTarget = | 'general' diff --git a/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts b/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts new file mode 100644 index 00000000000..4fdc698e79e --- /dev/null +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { resolveTerminalTabIdForPtyId } from './terminal-tab-for-pty-id' +import type { AppState } from '@/store/types' + +type ResolverState = Pick + +function state(partial: { + tabs?: Record + layouts?: Record }> +}): ResolverState { + return { + tabsByWorktree: (partial.tabs ?? {}) as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: (partial.layouts ?? {}) as unknown as AppState['terminalLayoutsByTabId'] + } +} + +describe('resolveTerminalTabIdForPtyId', () => { + it('matches a tab by its own ptyId', () => { + const s = state({ + tabs: { + wt: [ + { id: 'tab-a', ptyId: 'wt@@1' }, + { id: 'tab-b', ptyId: 'wt@@2' } + ] + } + }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@2')).toBe('tab-b') + }) + + it('matches a tab by a split leaf ptyId in its saved layout', () => { + const s = state({ + tabs: { wt: [{ id: 'tab-a', ptyId: null }] }, + layouts: { 'tab-a': { ptyIdsByLeafId: { leaf1: 'wt@@1', leaf2: 'wt@@9' } } } + }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@9')).toBe('tab-a') + }) + + it('returns null when no tab owns the ptyId', () => { + const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@nope')).toBeNull() + }) + + it('returns null when stale persistence binds the ptyId to multiple tabs', () => { + const s = state({ + tabs: { + wt: [ + { id: 'tab-a', ptyId: 'wt@@1' }, + { id: 'tab-b', ptyId: null } + ] + }, + layouts: { 'tab-b': { ptyIdsByLeafId: { leaf2: 'wt@@1' } } } + }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@1')).toBeNull() + }) + + it('returns null for an unknown worktree', () => { + const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } }) + expect(resolveTerminalTabIdForPtyId(s, 'other', 'wt@@1')).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/terminal-tab-for-pty-id.ts b/src/renderer/src/lib/terminal-tab-for-pty-id.ts new file mode 100644 index 00000000000..77ee3c29706 --- /dev/null +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.ts @@ -0,0 +1,27 @@ +import type { AppState } from '@/store/types' + +/** Resolve a synthetic mobile handle's ptyId through persisted tab and split bindings. */ +export function resolveTerminalTabIdForPtyId( + state: Pick, + worktreeId: string, + ptyId: string +): string | null { + const tabs = state.tabsByWorktree[worktreeId] ?? [] + let resolvedTabId: string | null = null + for (const tab of tabs) { + const ptyIdsByLeafId = state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId + const ownsPty = + tab.ptyId === ptyId || + (ptyIdsByLeafId !== undefined && Object.values(ptyIdsByLeafId).includes(ptyId)) + if (!ownsPty) { + continue + } + if (resolvedTabId && resolvedTabId !== tab.id) { + // Why: stale duplicate ownership must not attach whichever hidden tab + // happens to appear first in persisted order. + return null + } + resolvedTabId = tab.id + } + return resolvedTabId +} diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index d2bf7336a9e..6c6db493341 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -6,7 +6,8 @@ import { buildHostIdByWorktreeId, fetchWorkspaceSessionFromHosts, fetchWorkspaceSessionWithRuntimeHostOwners, - patchWorkspaceSessionByHost + patchWorkspaceSessionByHost, + persistWorkspaceSessionByHost } from './workspace-session-host-persistence' describe('fetchWorkspaceSessionFromHosts', () => { @@ -384,3 +385,69 @@ describe('fetchWorkspaceSessionFromHosts', () => { expect(owner(worktreeWorkspaceKey(worktreeId))).toBe('runtime:env-1') }) }) + +describe('persistWorkspaceSessionByHost', () => { + it('awaits every host write before crossing the durable flush boundary', async () => { + const localWorktreeId = 'local-repo::/src/local' + const remoteWorktreeId = 'remote-repo::/srv/remote' + const payload: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [localWorktreeId]: [ + { + id: 'local-tab', + ptyId: null, + worktreeId: localWorktreeId, + title: 'Local', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ], + [remoteWorktreeId]: [ + { + id: 'remote-tab', + ptyId: null, + worktreeId: remoteWorktreeId, + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + const set = vi.fn().mockResolvedValue(undefined) + const flush = vi.fn().mockResolvedValue(undefined) + + await persistWorkspaceSessionByHost( + { get: vi.fn(), set, patch: vi.fn(), flush, setSync: vi.fn() }, + payload, + { + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { + 'local-repo': [{ id: localWorktreeId, repoId: 'local-repo' }], + 'remote-repo': [{ id: remoteWorktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }] + } + } + ) + + expect(set).toHaveBeenCalledTimes(2) + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ tabsByWorktree: { [localWorktreeId]: expect.any(Array) } }) + ) + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + tabsByWorktree: { [remoteWorktreeId]: expect.any(Array) } + }), + 'runtime:env-1' + ) + expect(flush).toHaveBeenCalledTimes(1) + expect(set.mock.invocationCallOrder.at(-1)).toBeLessThan(flush.mock.invocationCallOrder[0]!) + }) +}) diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index f59a827b729..ab7b04c2fed 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -33,6 +33,11 @@ type SessionApi = { setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void } +type DurableSessionApi = SessionApi & { + set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise + flush: () => Promise +} + export type WorkspaceSessionHostRead = { session: WorkspaceSessionState runtimeHostIdByWorkspaceSessionKey: Record @@ -223,6 +228,23 @@ export function patchWorkspaceSessionByHost( return localWrite } +/** Persist a fresh full snapshot to every owning host partition, then force the + * main store to disk. Used by request/reply lifecycle operations whose success + * receipt is a durability boundary rather than a debounced UI update. */ +export async function persistWorkspaceSessionByHost( + api: DurableSessionApi, + payload: WorkspaceSessionState, + state: HostPersistenceState +): Promise { + const slices = splitWorkspaceSessionByHost(payload, buildHostIdByWorktreeId(state)) + const writes: Promise[] = [api.set(slices[LOCAL_EXECUTION_HOST_ID] ?? payload)] + for (const [hostId, slice] of nonLocalEntries(slices)) { + writes.push(api.set(slice, hostId)) + } + await Promise.all(writes) + await api.flush() +} + /** Synchronous full-session split for the beforeunload / quit paths. */ export function persistWorkspaceSessionByHostSync( api: SessionApi, diff --git a/src/renderer/src/lib/workspace-session-liveness.test.ts b/src/renderer/src/lib/workspace-session-liveness.test.ts index db06aaf0e6c..e46fa1b66d7 100644 --- a/src/renderer/src/lib/workspace-session-liveness.test.ts +++ b/src/renderer/src/lib/workspace-session-liveness.test.ts @@ -91,4 +91,137 @@ describe('workspace session live PTY persistence', () => { expect(payload.activeWorktreeIdsOnShutdown).toEqual([]) expect(payload.remoteSessionIdsByTabId).toBeUndefined() }) + + it('reconnects a persisted SSH PTY when shutdown observes a transient relay drop', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + tabsByWorktree: { + 'wt-ssh': [ + { + id: 'tab-ssh', + title: 'remote', + ptyId: null, + worktreeId: 'wt-ssh' + } as never + ] + }, + ptyIdsByTabId: { 'tab-ssh': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' }, + // Why 'reconnecting': an involuntary transport drop always lands in + // 'reconnecting' / 'reconnection-failed' / 'error' — never + // 'disconnected', which only an explicit user disconnect produces. + sshConnectionStates: new Map([['conn-1', { status: 'reconnecting' } as never]]), + repos: [ + { + id: 'repo-ssh', + path: '/repo-ssh', + displayName: 'SSH', + badgeColor: '#fff', + addedAt: 1, + connectionId: 'conn-1' + } + ], + worktreesByRepo: { + 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never] + } + }) + ) + + expect(payload.remoteSessionIdsByTabId).toEqual({ + 'tab-ssh': 'ssh:conn-1@@pty-42' + }) + expect(payload.activeConnectionIdsAtShutdown).toEqual(['conn-1']) + }) + + it('keeps explicitly disconnected hosts out of the startup reconnect list', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + tabsByWorktree: { + 'wt-ssh': [ + { + id: 'tab-ssh', + title: 'remote', + ptyId: null, + worktreeId: 'wt-ssh' + } as never + ] + }, + ptyIdsByTabId: { 'tab-ssh': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' }, + // 'disconnected' = the user chose to take this host offline. The + // session id must still persist (restore-on-focus), but startup must + // not auto-dial the host against the user's intent. + sshConnectionStates: new Map([['conn-1', { status: 'disconnected' } as never]]), + repos: [ + { + id: 'repo-ssh', + path: '/repo-ssh', + displayName: 'SSH', + badgeColor: '#fff', + addedAt: 1, + connectionId: 'conn-1' + } + ], + worktreesByRepo: { + 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never] + } + }) + ) + + expect(payload.remoteSessionIdsByTabId).toEqual({ + 'tab-ssh': 'ssh:conn-1@@pty-42' + }) + expect(payload.activeConnectionIdsAtShutdown).toBeUndefined() + }) + + it('never derives runtime-owned SSH targets into the startup reconnect list', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + tabsByWorktree: { + 'wt-vm': [ + { + id: 'tab-vm', + title: 'vm', + ptyId: null, + worktreeId: 'wt-vm' + } as never + ] + }, + ptyIdsByTabId: { 'tab-vm': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-vm': 'ssh:runtime-ssh-vm1@@pty-7' }, + // Why a status entry: without one the status gate already excludes the + // target and the runtime-owned check would be untested dead weight. + // A pane-driven optimistic write CAN stamp runtime-owned states + // (TerminalSshReconnectOverlay), so pin the exclusion independently — + // on both the session-id union path ('reconnecting') and the live + // connected-states path ('connected'). + sshConnectionStates: new Map([ + ['runtime-ssh-vm1', { status: 'reconnecting' } as never], + ['runtime-ssh-vm2', { status: 'connected' } as never] + ]), + repos: [ + { + id: 'repo-vm', + path: '/repo-vm', + displayName: 'VM', + badgeColor: '#fff', + addedAt: 1, + connectionId: 'runtime-ssh-vm1' + } + ], + worktreesByRepo: { + 'repo-vm': [{ id: 'wt-vm', repoId: 'repo-vm' } as never] + } + }) + ) + + // Why: the renderer must never drive startup ssh.connect for runtime-owned + // (ephemeral-VM) targets — their lifecycle belongs to the runtime layer, + // and ssh.listTargets() hides them so the connect would target a host the + // user cannot see or manage. + expect(payload.remoteSessionIdsByTabId).toEqual({ + 'tab-vm': 'ssh:runtime-ssh-vm1@@pty-7' + }) + expect(payload.activeConnectionIdsAtShutdown).toBeUndefined() + }) }) diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index ec4ea11e643..da578c1ff42 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -205,6 +205,10 @@ describe('buildWorkspaceSessionPatch', () => { expect(Object.keys(patch).sort()).toEqual( [ + // Why: the reconnect list derives from remote session ids, so any + // patch that rewrites remoteSessionIdsByTabId must carry it too — + // otherwise a crash between patches strands a stale target on disk. + 'activeConnectionIdsAtShutdown', 'activeWorktreeIdsOnShutdown', 'remoteSessionIdsByTabId', 'tabsByWorktree', @@ -225,6 +229,25 @@ describe('buildWorkspaceSessionPatch', () => { expect(patch.activeConnectionIdsAtShutdown).toBeUndefined() }) + it('derives reconnect targets from surviving relay session ids in terminal-field patches', () => { + const patch = buildWorkspaceSessionPatch( + createSnapshot({ + tabsByWorktree: { + 'wt-ssh': [{ id: 'tab-ssh', title: 'remote', ptyId: null, worktreeId: 'wt-ssh' } as never] + }, + ptyIdsByTabId: { 'tab-ssh': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' }, + sshConnectionStates: new Map([['conn-1', { status: 'reconnecting' } as never]]), + repos: [createRepo('repo-ssh', 'conn-1')], + worktreesByRepo: { 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never] } + }), + ['lastKnownRelayPtyIdByTabId'] + ) + + expect(patch.remoteSessionIdsByTabId).toEqual({ 'tab-ssh': 'ssh:conn-1@@pty-42' }) + expect(patch.activeConnectionIdsAtShutdown).toEqual(['conn-1']) + }) + it('patches tab chrome as a sanitized bundle when split groups change', () => { const patch = buildWorkspaceSessionPatch( createSnapshot({ diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index eff46edfa23..e88f476a8f6 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -72,10 +72,21 @@ export function buildWorkspaceSessionPatch( 'worktreesByRepo' ] as const) ) { - Object.assign(patch, buildTerminalSessionData(snapshot)) - } - if (changed.has('sshConnectionStates')) { - patch.activeConnectionIdsAtShutdown = buildActiveConnectionIdsAtShutdown(snapshot) + const terminalSessionData = buildTerminalSessionData(snapshot) + Object.assign(patch, terminalSessionData) + // Why: the reconnect list is derived from persisted remote session ids as + // well as live SSH state. Recompute it alongside remoteSessionIdsByTabId + // so a crash between patches cannot leave a target on disk whose sessions + // were all closed (or vice versa). + patch.activeConnectionIdsAtShutdown = buildActiveConnectionIdsAtShutdown( + snapshot, + terminalSessionData.remoteSessionIdsByTabId ?? null + ) + } else if (changed.has('sshConnectionStates')) { + patch.activeConnectionIdsAtShutdown = buildActiveConnectionIdsAtShutdown( + snapshot, + buildTerminalSessionData(snapshot).remoteSessionIdsByTabId ?? null + ) } if ( hasAnyChangedField(changed, [ diff --git a/src/renderer/src/lib/workspace-session-reconnect-targets.ts b/src/renderer/src/lib/workspace-session-reconnect-targets.ts new file mode 100644 index 00000000000..713e4388e53 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-reconnect-targets.ts @@ -0,0 +1,47 @@ +import type { WorkspaceSessionState } from '../../../shared/types' +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' +import type { WorkspaceSessionSnapshot } from './workspace-session' + +export function buildActiveConnectionIdsAtShutdown( + snapshot: WorkspaceSessionSnapshot, + // Why: required (null = none) rather than defaulted — every caller already + // has the terminal session data in hand, and a default initializer would + // re-run the full repo/worktree scan on the shutdown-critical path. + remoteSessionIdsByTabId: WorkspaceSessionState['remoteSessionIdsByTabId'] | null +): WorkspaceSessionState['activeConnectionIdsAtShutdown'] { + // Why: sshConnectionStates is a Map, not a plain + // object. Object.entries() on a Map returns [] — must use Array.from(). + // Runtime-owned states are normally never broadcast to the renderer, but a + // pane-level optimistic write could stamp one; exclude them here too. + const targetIds = new Set( + Array.from(snapshot.sshConnectionStates.entries()) + .filter( + ([targetId, state]) => state.status === 'connected' && !isRuntimeOwnedSshTargetId(targetId) + ) + .map(([targetId]) => targetId) + ) + + // Why: shutdown can observe SSH in a transient state (relay drop mid-quit, + // exhausted reconnect) after the socket closed but before the snapshot + // flushed. The durable PTY id still names the target Orca must reconnect to + // restore that surviving remote session. Two exclusions: + // 'disconnected'/'auth-failed'/never-observed usually mean an explicit user + // disconnect or a failed/cancelled connect — startup must not auto-dial a + // host the user left offline or stack credential dialogs (sessions still + // restore on tab focus via the deferred flow, so only eagerness is lost). + // Runtime-owned (ephemeral-VM) targets belong to the runtime layer; a + // renderer-driven ssh.connect would dispose the runtime's live relay session. + for (const sessionId of Object.values(remoteSessionIdsByTabId ?? {})) { + const connectionId = parseAppSshPtyId(sessionId)?.connectionId + if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) { + continue + } + const status = snapshot.sshConnectionStates.get(connectionId)?.status + if (status && status !== 'disconnected' && status !== 'auth-failed') { + targetIds.add(connectionId) + } + } + + return targetIds.size > 0 ? Array.from(targetIds) : undefined +} diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index f0acc22e620..23cc4da80e0 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -12,6 +12,9 @@ import type { OpenFile } from '../store/slices/editor' import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified-tabs' import { buildLastVisitedAtByWorktreeId } from './workspace-session-focus-recency' import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agents' +import { buildActiveConnectionIdsAtShutdown } from './workspace-session-reconnect-targets' + +export { buildActiveConnectionIdsAtShutdown } /** Why (issue #1158): the debounced + shutdown session writers share this * gate so a hydration failure cannot overwrite orca-data.json with the @@ -331,18 +334,6 @@ export function buildTerminalSessionData( } } -export function buildActiveConnectionIdsAtShutdown( - snapshot: WorkspaceSessionSnapshot -): WorkspaceSessionState['activeConnectionIdsAtShutdown'] { - // Why: sshConnectionStates is a Map, not a plain - // object. Object.entries() on a Map returns [] — must use Array.from(). - const connectedTargetIds = Array.from(snapshot.sshConnectionStates.entries()) - .filter(([, state]) => state.status === 'connected') - .map(([targetId]) => targetId) - - return connectedTargetIds.length > 0 ? connectedTargetIds : undefined -} - export function buildWorkspaceSessionPayload( snapshot: WorkspaceSessionSnapshot ): WorkspaceSessionState { @@ -380,7 +371,10 @@ export function buildWorkspaceSessionPayload( // Persist only layouts backed by real tabs so a reload cannot restore a // blank split pane from that transient midpoint. ...buildPersistedUnifiedTabSessionData(snapshot), - activeConnectionIdsAtShutdown: buildActiveConnectionIdsAtShutdown(snapshot), + activeConnectionIdsAtShutdown: buildActiveConnectionIdsAtShutdown( + snapshot, + terminalSessionData.remoteSessionIdsByTabId ?? null + ), remoteSessionIdsByTabId: terminalSessionData.remoteSessionIdsByTabId, // Why: per-worktree focus-recency for Cmd+J's empty-query ordering. // Omit when empty so sessions written by builds that never stamped diff --git a/src/renderer/src/runtime/abortable-runtime-environment-call.ts b/src/renderer/src/runtime/abortable-runtime-environment-call.ts new file mode 100644 index 00000000000..88b9dc9d6fb --- /dev/null +++ b/src/renderer/src/runtime/abortable-runtime-environment-call.ts @@ -0,0 +1,65 @@ +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' + +export function createRuntimeRpcAbortError(): Error { + const error = new Error('Runtime request aborted') + error.name = 'AbortError' + return error +} + +export async function callAbortableRuntimeEnvironment( + environmentId: string, + method: string, + params: unknown, + timeoutMs: number | undefined, + signal: AbortSignal +): Promise> { + if (signal.aborted) { + throw createRuntimeRpcAbortError() + } + // Why: the one-shot runtime call bridge cannot cancel host work; the + // subscription bridge closes its request context when we unsubscribe. + return new Promise((resolve, reject) => { + let handle: { unsubscribe: () => void } | null = null + let settled = false + // Why: the subscription transport's own timeout only bounds subscription + // start, not the response. Keep the one-shot call path's response deadline + // so a connected-but-unresponsive runtime cannot stall the caller forever. + const deadline = + timeoutMs === undefined + ? null + : setTimeout(() => { + finish(() => reject(new Error(`Runtime request timed out before ${method} completed`))) + }, timeoutMs) + const finish = (complete: () => void): void => { + if (settled) { + return + } + settled = true + if (deadline !== null) { + clearTimeout(deadline) + } + signal.removeEventListener('abort', onAbort) + handle?.unsubscribe() + complete() + } + const onAbort = (): void => finish(() => reject(createRuntimeRpcAbortError())) + signal.addEventListener('abort', onAbort, { once: true }) + void window.api.runtimeEnvironments + .subscribe( + { selector: environmentId, method, params, timeoutMs }, + { + onResponse: (response) => finish(() => resolve(response)), + onError: (error) => finish(() => reject(new Error(error.message))), + onClose: () => + finish(() => reject(new Error(`Runtime request closed before ${method} completed`))) + } + ) + .then((subscription) => { + handle = subscription + if (settled) { + subscription.unsubscribe() + } + }) + .catch((error) => finish(() => reject(error))) + }) +} diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index 52086493559..6b4cbd268bd 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -1845,6 +1845,56 @@ describe('runtime file client', () => { expect(retryPayload).toHaveBeenCalledTimes(1) }) + it('evicts a failed watcher setup before notifying retrying listeners', async () => { + const callbacks: { onResponse: (response: unknown) => void; onClose: () => void }[] = [] + const unsubscribes = [vi.fn(), vi.fn()] + runtimeEnvironmentSubscribe.mockImplementation((_args, nextCallbacks) => { + callbacks.push(nextCallbacks) + return Promise.resolve({ + unsubscribe: unsubscribes[callbacks.length - 1], + 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-setup-failed', + ok: false, + error: { code: 'watch_failed', message: 'root unavailable' }, + _meta: { runtimeId: 'remote-runtime' } + }) + await retryPromise + + expect(runtimeEnvironmentSubscribe).toHaveBeenCalledTimes(2) + expect(unsubscribes[0]).toHaveBeenCalledTimes(1) + 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' } + }) + expect(retryPayload).toHaveBeenCalledTimes(1) + callbacks[1]?.onResponse({ + id: 'rpc-retry-end', + ok: true, + result: { type: 'end' }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + 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 6c70cd9e63c..a84d4382e5a 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -99,6 +99,7 @@ type RuntimeImportResult = } type RuntimeFileWatchEvent = + | { type: 'starting'; subscriptionId: string } | { type: 'ready'; subscriptionId: string } | { type: 'changed'; worktree: string; events: FsChangedPayload['events'] } | { type: 'error'; message: string } @@ -890,7 +891,7 @@ function createSharedRuntimeFileWatch( handleSharedRuntimeFileWatchResponse(key, shared, worktreePath, response) }, onError: (error) => { - notifySharedRuntimeFileWatchError(shared, new Error(error.message)) + failSharedRuntimeFileWatch(key, shared, new Error(error.message)) }, onClose: () => { if (sharedRuntimeFileWatches.get(key) === shared) { @@ -912,11 +913,7 @@ function createSharedRuntimeFileWatch( } }) .catch((err) => { - if (sharedRuntimeFileWatches.get(key) === shared) { - sharedRuntimeFileWatches.delete(key) - } - shared.closed = true - notifySharedRuntimeFileWatchError(shared, err instanceof Error ? err : new Error(String(err))) + failSharedRuntimeFileWatch(key, shared, err instanceof Error ? err : new Error(String(err))) throw err }) return shared @@ -932,7 +929,7 @@ function handleSharedRuntimeFileWatchResponse( const event = unwrapRuntimeRpcResult( response as RuntimeRpcResponse ) - if (event.type === 'ready') { + if (event.type === 'starting' || event.type === 'ready') { shared.remoteSubscriptionId = event.subscriptionId if (shared.closed) { shared.unsubscribe?.() @@ -948,15 +945,7 @@ function handleSharedRuntimeFileWatchResponse( } 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)) - } + failSharedRuntimeFileWatch(key, shared, 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. @@ -971,12 +960,26 @@ function handleSharedRuntimeFileWatchResponse( unsubscribe?.() } } catch (err) { - notifySharedRuntimeFileWatchError(shared, err instanceof Error ? err : new Error(String(err))) + failSharedRuntimeFileWatch(key, shared, err instanceof Error ? err : new Error(String(err))) } } -function notifySharedRuntimeFileWatchError(shared: SharedRuntimeFileWatch, error: Error): void { - for (const listener of Array.from(shared.listeners)) { +function failSharedRuntimeFileWatch( + key: string, + shared: SharedRuntimeFileWatch, + error: Error +): void { + if (sharedRuntimeFileWatches.get(key) === shared) { + sharedRuntimeFileWatches.delete(key) + } + shared.closed = true + shared.remoteSubscriptionId = null + const unsubscribe = shared.unsubscribe + shared.unsubscribe = null + const listeners = Array.from(shared.listeners) + shared.listeners.clear() + unsubscribe?.() + for (const listener of listeners) { listener.onError?.(error) } } @@ -989,7 +992,7 @@ function closeSharedRuntimeFileWatch(key: string, shared: SharedRuntimeFileWatch sharedRuntimeFileWatches.delete(key) if (shared.keepStreamUntilReady) { // Why: WebRuntimeClient owns shared-socket file-watch cleanup, including - // pre-ready fallback timers and late-ready files.unwatch. + // pre-ready cancellation ownership and late-ready files.unwatch. shared.unsubscribe?.() shared.unsubscribe = null return diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 64c12946a4b..78ed0853a48 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -23,6 +23,7 @@ import { import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' const gitStatus = vi.fn() +const gitCancelStatus = vi.fn() const gitCheckIgnored = vi.fn() const gitSubmoduleStatus = vi.fn() const gitDiff = vi.fn() @@ -44,6 +45,8 @@ const runtimeCall = vi.fn() beforeEach(() => { clearRuntimeCompatibilityCacheForTests() gitStatus.mockReset() + gitCancelStatus.mockReset() + gitCancelStatus.mockResolvedValue(undefined) gitCheckIgnored.mockReset() gitSubmoduleStatus.mockReset() gitDiff.mockReset() @@ -68,6 +71,7 @@ beforeEach(() => { api: { git: { status: gitStatus, + cancelStatus: gitCancelStatus, checkIgnored: gitCheckIgnored, submoduleStatus: gitSubmoduleStatus, diff: gitDiff, @@ -204,6 +208,41 @@ describe('runtime git client', () => { }) }) + it('forwards line-stat reuse to local status and cancels tokenized work on abort', async () => { + const controller = new AbortController() + let resolveStatus!: (value: { entries: never[]; conflictOperation: string }) => void + gitStatus.mockImplementation( + () => + new Promise((resolve) => { + resolveStatus = resolve + }) + ) + + const request = getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { reuseLineStats: true, signal: controller.signal } + ) + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalled()) + const statusArgs = gitStatus.mock.calls[0]?.[0] as { requestToken?: string } + controller.abort() + resolveStatus({ entries: [], conflictOperation: 'unknown' }) + // Why: cancel is best-effort on the main process; even if status still + // settles, the aborted local call must reject rather than look fresh. + await expect(request).rejects.toMatchObject({ name: 'AbortError' }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + reuseLineStats: true, + requestToken: expect.any(String) + }) + expect(gitCancelStatus).toHaveBeenCalledWith({ requestToken: statusArgs.requestToken }) + }) + it('checks ignored paths through local git IPC', async () => { gitCheckIgnored.mockResolvedValue(['dist/bundle.js']) @@ -408,6 +447,57 @@ describe('runtime git client', () => { }) }) + it('keeps safety (reuse) refreshes on the pooled call transport even with a signal', async () => { + const controller = new AbortController() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { reuseLineStats: true, signal: controller.signal } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'id:wt-1', reuseLineStats: true }, + timeoutMs: 15_000 + }) + }) + + it('forwards line-stat reuse through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { reuseLineStats: true } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'id:wt-1', reuseLineStats: true }, + timeoutMs: 15_000 + }) + }) + it('checks ignored paths through the active runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 972e2b7fdcb..019eaef5039 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -133,20 +133,30 @@ export function getRuntimeGitScope( export async function getRuntimeGitStatus( context: RuntimeGitContext, - options?: { includeIgnored?: boolean; bypassEffectiveUpstreamNegativeCache?: boolean } + options?: { + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + signal?: AbortSignal + } ): Promise { const target = getActiveRuntimeTarget(context.settings) const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache ? { bypassEffectiveUpstreamNegativeCache: true } : {} + const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} if (target.kind === 'local' || !context.worktreeId) { - return window.api.git.status({ - worktreePath: resolveLocalWorktreePath(context), - connectionId: context.connectionId, - ...includeIgnoredArgs, - ...upstreamCacheBypassArgs - }) + return callLocalGitStatus( + { + worktreePath: resolveLocalWorktreePath(context), + connectionId: context.connectionId, + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs, + ...lineStatsReuseArgs + }, + options?.signal + ) } return callRuntimeRpc( target, @@ -154,12 +164,58 @@ export async function getRuntimeGitStatus( { worktree: toRuntimeWorktreeSelector(context.worktreeId), ...includeIgnoredArgs, - ...upstreamCacheBypassArgs + ...upstreamCacheBypassArgs, + ...lineStatsReuseArgs }, - { timeoutMs: 15_000 } + { + timeoutMs: 15_000, + // Why: an abort signal forces the dedicated per-request subscription + // socket (one WebSocket + E2EE handshake per request). The safety + // refresh is the idle steady state, so it stays on the pooled call + // transport: its stale result is rejected by the caller's liveness + // guard and the 15s timeout bounds it. Activity refreshes keep the + // abortable transport so pause/host switches cancel real remote work. + ...(options?.reuseLineStats ? {} : { signal: options?.signal }) + } ) } +let nextGitStatusRequestToken = 0 + +function createGitStatusAbortError(): Error { + const error = new Error('Git status request aborted') + error.name = 'AbortError' + return error +} + +async function callLocalGitStatus( + args: Parameters[0], + signal?: AbortSignal +): Promise { + if (!signal) { + return window.api.git.status(args) + } + if (signal.aborted) { + throw createGitStatusAbortError() + } + const requestToken = `git-status-${Date.now()}-${++nextGitStatusRequestToken}` + const cancel = (): void => { + void window.api.git.cancelStatus({ requestToken }).catch(() => {}) + } + signal.addEventListener('abort', cancel, { once: true }) + try { + const status = await window.api.git.status({ ...args, requestToken }) + // Why: cancel is best-effort; a scan that finished after abort must still + // reject so callers never treat a cancelled request as a fresh result. + if (signal.aborted) { + throw createGitStatusAbortError() + } + return status + } finally { + signal.removeEventListener('abort', cancel) + } +} + export async function getRuntimeGitSubmoduleStatus( context: RuntimeGitContext, submodulePath: string, diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index 812e28b8233..a6c058ac9f7 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -1,5 +1,6 @@ import type { GlobalSettings, + JiraAuthType, JiraComment, JiraConnectionStatus, JiraCreateField, @@ -57,7 +58,7 @@ export async function jiraStatus(settings: RuntimeJiraSettings): Promise { const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' diff --git a/src/renderer/src/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts index 0bf2ac4f8f0..c9481d2746c 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -20,15 +20,20 @@ import { const runtimeCall = vi.fn() const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentSubscribe = vi.fn() beforeEach(() => { clearRuntimeCompatibilityCacheForTests() runtimeCall.mockReset() runtimeEnvironmentCall.mockReset() + runtimeEnvironmentSubscribe.mockReset() vi.stubGlobal('window', { api: { runtime: { call: runtimeCall }, - runtimeEnvironments: { call: runtimeEnvironmentCall } + runtimeEnvironments: { + call: runtimeEnvironmentCall, + subscribe: runtimeEnvironmentSubscribe + } } }) }) @@ -109,6 +114,49 @@ describe('runtime RPC client routing', () => { expect(runtimeCall).not.toHaveBeenCalled() }) + it('uses an abortable subscription for paired-runtime requests', async () => { + const controller = new AbortController() + const unsubscribe = vi.fn() + runtimeEnvironmentSubscribe.mockResolvedValue({ unsubscribe, sendBinary: vi.fn() }) + + const request = callRuntimeRpc( + { kind: 'environment', environmentId: 'env-1' }, + 'status.get', + undefined, + { signal: controller.signal } + ) + await vi.waitFor(() => expect(runtimeEnvironmentSubscribe).toHaveBeenCalled()) + controller.abort() + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1)) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('rejects an abortable paired-runtime request at the response deadline', async () => { + vi.useFakeTimers() + try { + const controller = new AbortController() + const unsubscribe = vi.fn() + runtimeEnvironmentSubscribe.mockResolvedValue({ unsubscribe, sendBinary: vi.fn() }) + + const request = callRuntimeRpc( + { kind: 'environment', environmentId: 'env-1' }, + 'status.get', + undefined, + { signal: controller.signal, timeoutMs: 15_000 } + ) + await vi.waitFor(() => expect(runtimeEnvironmentSubscribe).toHaveBeenCalled()) + const rejection = expect(request).rejects.toThrow('timed out before status.get completed') + await vi.advanceTimersByTimeAsync(15_000) + + await rejection + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1)) + } finally { + vi.useRealTimers() + } + }) + it('preflights remote runtime compatibility before non-status calls', async () => { runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { const result = diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index e9b58f9d694..1f024c1a67f 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -4,20 +4,22 @@ import type { RuntimeStatus } from '../../../shared/runtime-types' import type { RuntimeCapability } from '../../../shared/protocol-version' import { withBrowserPaneUiRuntimeRpcSource } from '../../../shared/runtime-rpc-feature-interaction-source' import { assertRuntimeStatusCompatible } from './runtime-protocol-compat' +import { + callAbortableRuntimeEnvironment, + createRuntimeRpcAbortError +} from './abortable-runtime-environment-call' export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } const RUNTIME_COMPATIBILITY_CACHE_MAX = 32 const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000 -// Why: a saved environment can restart into a different Orca version without -// changing ids; capability verdicts must eventually follow that version change. +// Why: capability verdicts must eventually follow a saved environment's version changes. const RUNTIME_CAPABILITY_STATUS_TTL_MS = 60_000 type RuntimeCompatibilityCacheEntry = { check: Promise failedAt: number | null - // True only once status.get settled and proved compatible. Stays false while - // the probe is in flight, so a recovery clear can drop a doomed pending probe. + // False while probing so recovery can drop a doomed pending compatibility check. provenCompatible: boolean status: RuntimeStatus | null statusCheckedAt: number | null @@ -73,34 +75,38 @@ export async function callRuntimeRpc( timeoutMs?: number suppressFeatureInteraction?: boolean reuseRecentCompatibilityFailure?: boolean + signal?: AbortSignal } = {} ): Promise { if (target.kind === 'environment' && method !== 'status.get') { await ensureRuntimeEnvironmentCompatible(target.environmentId, options) } - const nextParams = addFeatureInteractionSource(params, options) + if (options.signal?.aborted) { + throw createRuntimeRpcAbortError() + } + const nextParams = options.suppressFeatureInteraction + ? withBrowserPaneUiRuntimeRpcSource(params) + : params const response = target.kind === 'local' ? await window.api.runtime.call({ method, params: nextParams }) - : await window.api.runtimeEnvironments.call({ - selector: target.environmentId, - method, - params: nextParams, - timeoutMs: options.timeoutMs - }) + : options.signal + ? await callAbortableRuntimeEnvironment( + target.environmentId, + method, + nextParams, + options.timeoutMs, + options.signal + ) + : await window.api.runtimeEnvironments.call({ + selector: target.environmentId, + method, + params: nextParams, + timeoutMs: options.timeoutMs + }) return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) } -function addFeatureInteractionSource( - params: unknown, - options: { suppressFeatureInteraction?: boolean } -): unknown { - if (!options.suppressFeatureInteraction) { - return params - } - return withBrowserPaneUiRuntimeRpcSource(params) -} - async function ensureRuntimeEnvironmentCompatible( environmentId: string, options: { timeoutMs?: number; reuseRecentCompatibilityFailure?: boolean } = {} @@ -187,11 +193,8 @@ function rememberRuntimeEnvironmentCompatibility( } } -// Why: a live status.get answer proves any cached compatibility verdict that is -// not a settled success is stale. Drop settled failures AND still-pending probes -// (a probe queued on the dropped connection is doomed, and a reachability- -// triggered refresh must not coalesce onto it) so the refresh re-probes. Only -// proven-compatible successes stay cached. +// Why: a live status answer invalidates failures and pending probes from the +// dropped connection; only proven-compatible successes remain reusable. export function clearRecentRuntimeCompatibilityFailure(environmentId: string): void { const trimmed = environmentId.trim() if (!trimmed) { diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 4fb8bdc8581..c8fee5a0de6 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -35,6 +35,7 @@ import { createRuntimeStatusSlice } from './slices/runtime-status' import { createPullRequestGenerationSlice } from './slices/pull-request-generation' import { createCommitMessageGenerationSlice } from './slices/commit-message-generation' import { createPinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm' +import { createRecentlyClosedTabsSlice } from './slices/recently-closed-tabs' import { createOrcaProfilesSlice } from './slices/orca-profiles' import { createNewIssueDraftSlice } from './slices/new-issue-draft' import { e2eConfig } from '@/lib/e2e-config' @@ -76,6 +77,7 @@ export const useAppStore = create()((...a) => ({ ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a), ...createPinnedTabCloseConfirmSlice(...a), + ...createRecentlyClosedTabsSlice(...a), ...createOrcaProfilesSlice(...a), ...createNewIssueDraftSlice(...a) })) diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 96878adb3c9..10f955275bc 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -23,6 +23,7 @@ import { } from '../../../../shared/workspace-session-browser-history' import { pickNeighbor } from './tab-group-state' import { destroyWorkspaceWebviews } from './browser-webview-cleanup' +import { pushRecentlyClosedTabKind } from './recently-closed-tabs' import { callRuntimeRpc, getActiveRuntimeTarget, @@ -726,6 +727,11 @@ export const createBrowserSlice: StateCreator = { workspace: closedWorkspace, pages: closedPages }, ...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id) ].slice(0, 10) + const nextRecentlyClosedTabKindsByWorktree = pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + owningWorktreeId, + 'browser' + ) const nextRecentlyClosedBrowserPagesByWorkspace = { ...s.recentlyClosedBrowserPagesByWorkspace @@ -757,6 +763,7 @@ export const createBrowserSlice: StateCreator = pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId, activeTabTypeByWorktree: nextActiveTabTypeByWorktree, recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, + recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree, recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, browserAnnotationsByPageId: nextBrowserAnnotationsByPageId diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 15b6f3444ab..161c81f0819 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -140,6 +140,7 @@ import { createRuntimeStatusSlice } from './runtime-status' import { createPullRequestGenerationSlice } from './pull-request-generation' import { createCommitMessageGenerationSlice } from './commit-message-generation' import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm' +import { createRecentlyClosedTabsSlice } from './recently-closed-tabs' import { createOrcaProfilesSlice } from './orca-profiles' import { createNewIssueDraftSlice } from './new-issue-draft' @@ -180,6 +181,7 @@ function createTestStore() { ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a), ...createPinnedTabCloseConfirmSlice(...a), + ...createRecentlyClosedTabsSlice(...a), ...createOrcaProfilesSlice(...a), ...createNewIssueDraftSlice(...a) })) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 07bbab69f1c..bd2bc1fddeb 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' +import { pushRecentlyClosedTabKind } from './recently-closed-tabs' import { joinPath } from '@/lib/path' import { toast } from 'sonner' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' @@ -1771,6 +1772,7 @@ export const createEditorSlice: StateCreator = (s // recordReplacedPreview so file-explorer single-click (which // semantically *wants* silent eviction) is unaffected. let nextRecentlyClosed = s.recentlyClosedEditorTabsByWorktree + let nextRecentlyClosedKinds = s.recentlyClosedTabKindsByWorktree if (recordReplacedPreview && replacedPreview.id !== id) { const { id: _rid, @@ -1786,6 +1788,11 @@ export const createEditorSlice: StateCreator = (s MAX_RECENT_CLOSED_EDITOR_TABS ) } + nextRecentlyClosedKinds = pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + worktreeId, + 'editor' + ) } return { openFiles: newFiles, @@ -1796,6 +1803,7 @@ export const createEditorSlice: StateCreator = (s markdownFrontmatterVisible: nextMarkdownFrontmatterVisible, markdownTableOfContentsVisible: nextMarkdownTableOfContentsVisible, recentlyClosedEditorTabsByWorktree: nextRecentlyClosed, + recentlyClosedTabKindsByWorktree: nextRecentlyClosedKinds, ...previewTabBarUpdate, ...activeResult } @@ -2135,6 +2143,7 @@ export const createEditorSlice: StateCreator = (s : s.tabBarOrderByWorktree let nextRecentlyClosed = s.recentlyClosedEditorTabsByWorktree + let nextRecentlyClosedKinds = s.recentlyClosedTabKindsByWorktree const wtRecent = closedFile?.worktreeId // Why: untitled files that were never edited will be deleted from disk // after close. Adding them to the reopen stack would let Cmd+Shift+T @@ -2160,6 +2169,11 @@ export const createEditorSlice: StateCreator = (s MAX_RECENT_CLOSED_EDITOR_TABS ) } + nextRecentlyClosedKinds = pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + wtRecent, + 'editor' + ) } return { @@ -2186,7 +2200,8 @@ export const createEditorSlice: StateCreator = (s markdownTableOfContentsVisible: newMarkdownTableOfContentsVisible, tabBarOrderByWorktree: nextTabBarOrderByWorktree, pendingEditorReveal: null, - recentlyClosedEditorTabsByWorktree: nextRecentlyClosed + recentlyClosedEditorTabsByWorktree: nextRecentlyClosed, + recentlyClosedTabKindsByWorktree: nextRecentlyClosedKinds } }) @@ -2336,6 +2351,7 @@ export const createEditorSlice: StateCreator = (s const closingFiles = s.openFiles.filter((f) => f.worktreeId === activeWorktreeId) let nextRecentClosed = s.recentlyClosedEditorTabsByWorktree[activeWorktreeId] ?? [] + let capturedCloseCount = 0 for (const f of [...closingFiles].toReversed()) { // Why: untitled non-dirty files are deleted from disk after close — // skip them so the reopen stack doesn't reference vanished paths. @@ -2351,6 +2367,7 @@ export const createEditorSlice: StateCreator = (s 0, MAX_RECENT_CLOSED_EDITOR_TABS ) + capturedCloseCount += 1 } return { @@ -2385,7 +2402,13 @@ export const createEditorSlice: StateCreator = (s recentlyClosedEditorTabsByWorktree: { ...s.recentlyClosedEditorTabsByWorktree, [activeWorktreeId]: nextRecentClosed - } + }, + recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + activeWorktreeId, + 'editor', + capturedCloseCount + ) } }) if (typeof window !== 'undefined') { diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 8d2bc710604..6ddba03caec 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -13,6 +13,7 @@ import { prChecksCacheSuffix, prCommentsCacheSuffix, projectViewCacheKey, + shouldClearBranchMismatchedLinkedOpenPR, workItemsCacheKey } from './github' import { createHostedReviewSlice } from './hosted-review' @@ -2224,6 +2225,71 @@ describe('createGitHubSlice.fetchPRForBranch', () => { expect(store.getState().prCache[`${repoId}::${branch}`]).toBeUndefined() }) + it('unlinks a stale open PR and re-resolves the current branch with one follow-up lookup', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/current' + const worktreeId = 'wt-stale-open-pr' + const stalePR = makePR({ + number: 12, + title: 'Stale linked PR', + headSha: 'old-head', + headRefName: 'feature/old' + }) + const currentPR = makePR({ + number: 13, + title: 'Current branch PR', + headSha: 'current-head', + headRefName: branch + }) + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree: makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'current-head', + linkedPR: 12 + }) + }) + mockApi.gh.refreshPRNow + .mockResolvedValueOnce({ kind: 'found', pr: stalePR, fetchedAt: 2 }) + .mockResolvedValueOnce({ kind: 'found', pr: currentPR, fetchedAt: 3 }) + + await expect( + store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId, + linkedPRNumber: 12 + }) + ).resolves.toEqual(stalePR) + + await vi.waitFor(() => { + expect(mockApi.gh.refreshPRNow).toHaveBeenCalledTimes(2) + expect(store.getState().prCache[`${repoId}::${branch}`]?.data).toEqual(currentPR) + }) + expect(updateWorktreeMeta).toHaveBeenCalledWith( + worktreeId, + { linkedPR: null }, + { + suppressHostedReviewRefresh: true, + shouldApply: expect.any(Function) + } + ) + expect(mockApi.gh.refreshPRNow.mock.calls[1]?.[0]).toMatchObject({ + candidate: expect.objectContaining({ + branch, + linkedPRNumber: null, + worktreeId + }) + }) + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBeNull() + }) + it('clears a linked merged PR on a fresh cache hit that already carries a head-scoped divergence signal', async () => { const store = createTestStore() const repoPath = '/repo' @@ -3126,6 +3192,339 @@ describe('createGitHubSlice.fetchPRForBranch', () => { expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBeNull() }) + it('clears a branch-mismatched linked open PR from an accepted coordinator event', () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/current' + const worktreeId = 'wt-coordinator-open-mismatch' + const cacheKey = `${repoId}::${branch}` + const worktree = makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'current-head', + linkedPR: 12 + }) + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree + }) + + store.getState().applyGitHubPRRefreshEvent({ + sequence: 1, + reason: 'swr', + aliases: [ + { + cacheKey, + repoPath, + repoId, + branch, + worktreeId, + linkedPRNumber: 12, + currentHeadOid: 'current-head' + } + ], + outcome: { + kind: 'found', + pr: makePR({ number: 12, state: 'open', headSha: 'old-head', headRefName: 'feature/old' }), + fetchedAt: 2 + } + }) + + expect(updateWorktreeMeta).toHaveBeenCalledWith( + worktreeId, + { linkedPR: null }, + { shouldApply: expect.any(Function) } + ) + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBeNull() + }) + + it('does not scan worktrees for a found coordinator outcome without a durable PR link', () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/unlinked' + let worktreeIdReads = 0 + const worktrees = Array.from({ length: 100 }, (_, index) => { + const worktree = makePRRefreshWorktree({ + id: `wt-${index}`, + repoId, + branch: `feature/${index}`, + head: `head-${index}`, + linkedPR: null + }) + Object.defineProperty(worktree, 'id', { + enumerable: true, + get: () => { + worktreeIdReads += 1 + return `wt-${index}` + } + }) + return worktree + }) + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { [repoId]: worktrees } + } as unknown as Partial) + worktreeIdReads = 0 + + store.getState().applyGitHubPRRefreshEvent({ + sequence: 1, + reason: 'swr', + aliases: [ + { + cacheKey: `${repoId}::${branch}`, + repoPath, + repoId, + branch, + worktreeId: 'wt-0', + linkedPRNumber: null, + currentHeadOid: 'head-0' + } + ], + outcome: { + kind: 'found', + pr: makePR({ number: 12, state: 'open', headRefName: branch }), + fetchedAt: 2 + } + }) + + expect(worktreeIdReads).toBe(0) + }) + + it('indexes worktrees once for a coordinator outcome with many linked aliases', () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/shared' + let worktreeIdReads = 0 + const worktrees = Array.from({ length: 100 }, (_, index) => { + const worktree = makePRRefreshWorktree({ + id: `wt-${index}`, + repoId, + branch, + head: 'shared-head', + linkedPR: 12 + }) + Object.defineProperty(worktree, 'id', { + enumerable: true, + get: () => { + worktreeIdReads += 1 + return `wt-${index}` + } + }) + return worktree + }) + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { [repoId]: worktrees } + } as unknown as Partial) + worktreeIdReads = 0 + + store.getState().applyGitHubPRRefreshEvent({ + sequence: 1, + reason: 'swr', + aliases: worktrees.map((_, index) => ({ + cacheKey: `${repoId}::${branch}::${index}`, + repoPath, + repoId, + branch, + worktreeId: `wt-${index}`, + linkedPRNumber: 12, + currentHeadOid: 'shared-head' + })), + outcome: { + kind: 'found', + pr: makePR({ number: 12, state: 'open', headSha: 'shared-head', headRefName: branch }), + fetchedAt: 2 + } + }) + + // One read per stored row proves aliases share the event-local index. + expect(worktreeIdReads).toBe(worktrees.length) + }) + + it('does not unlink from a branch-mismatched outcome rejected by the sequence gate', () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/current' + const worktreeId = 'wt-coordinator-stale-sequence' + const cacheKey = `${repoId}::${branch}` + const worktree = makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'current-head', + linkedPR: 12 + }) + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree + }) + const aliases = [ + { + cacheKey, + repoPath, + repoId, + branch, + worktreeId, + linkedPRNumber: 12, + currentHeadOid: 'current-head' + } + ] + + store.getState().applyGitHubPRRefreshEvent({ + sequence: 2, + reason: 'swr', + aliases, + outcome: { + kind: 'found', + pr: makePR({ number: 12, state: 'open', headRefName: branch }), + fetchedAt: 3 + } + }) + store.getState().applyGitHubPRRefreshEvent({ + sequence: 1, + reason: 'swr', + aliases, + outcome: { + kind: 'found', + pr: makePR({ number: 12, state: 'open', headSha: 'old-head', headRefName: 'feature/old' }), + fetchedAt: 4 + } + }) + + expect(updateWorktreeMeta).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBe(12) + expect(store.getState().prRefreshSequences[cacheKey]).toBe(2) + }) + + it('does not unlink an ambiguous worktree id shared by multiple hosts', () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/current' + const worktreeId = 'repo-1::/same/path' + const cacheKey = `${repoId}::${branch}` + const updateWorktreeMeta = vi.fn() + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + [repoId]: [ + makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'local-head', + linkedPR: 12, + hostId: 'local' + }), + makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'ssh-head', + linkedPR: 12, + hostId: 'ssh:ssh-1' + }) + ] + }, + updateWorktreeMeta + } as unknown as Partial) + + store.getState().applyGitHubPRRefreshEvent({ + sequence: 1, + reason: 'swr', + aliases: [ + { + cacheKey, + repoPath, + repoId, + branch, + worktreeId, + linkedPRNumber: 12, + currentHeadOid: 'local-head' + } + ], + outcome: { + kind: 'found', + pr: makePR({ number: 12, state: 'open', headSha: 'old-head', headRefName: 'feature/old' }), + fetchedAt: 2 + } + }) + + expect(updateWorktreeMeta).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo[repoId]).toHaveLength(2) + expect( + store.getState().worktreesByRepo[repoId]?.every((worktree) => worktree.linkedPR === 12) + ).toBe(true) + }) + + it('does not let an old-host coordinator event unlink a row now owned by another host', () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/current' + const worktreeId = 'repo-1::/same/path' + const cacheKey = `ssh:ssh-1::${repoId}::${branch}` + const updateWorktreeMeta = vi.fn() + store.setState({ + repos: [ + { + id: repoId, + path: repoPath, + name: 'repo', + kind: 'git', + connectionId: 'ssh-2' + } + ], + worktreesByRepo: { + [repoId]: [ + makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'ssh-2-head', + linkedPR: 12, + hostId: 'ssh:ssh-2' + }) + ] + }, + updateWorktreeMeta + } as unknown as Partial) + + store.getState().applyGitHubPRRefreshEvent({ + sequence: 1, + reason: 'swr', + aliases: [ + { + cacheKey, + repoPath, + repoId, + branch, + worktreeId, + linkedPRNumber: 12, + currentHeadOid: 'old-host-head', + executionHostId: 'ssh:ssh-1' + } + ], + outcome: { + kind: 'found', + pr: makePR({ number: 12, state: 'open', headSha: 'old-head', headRefName: 'feature/old' }), + fetchedAt: 2 + } + }) + + expect(updateWorktreeMeta).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBe(12) + }) + it('does not clear a linked merged PR from a coordinator refresh event without a request head', () => { const store = createTestStore() const repoPath = '/repo' @@ -5932,7 +6331,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { 24, 100, '', - '2026-05-21T00:00:00Z' + 1 ) expect(result.failedCount).toBe(0) @@ -5978,7 +6377,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { 24, 100, 'is:open', - '2026-05-22T00:00:00Z' + 1 ) expect(mockApi.gh.listWorkItems).not.toHaveBeenCalled() @@ -5989,7 +6388,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { repo: 'runtime-repo-id', limit: 24, query: 'is:open', - before: '2026-05-22T00:00:00Z' + page: 1 }, timeoutMs: 30_000 }) @@ -6014,9 +6413,13 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { const result = await store .getState() - .countWorkItemsAcrossRepos([{ repoId: 'caller-repo-id', path: '/server/repo' }], 'is:open') + .countWorkItemsAcrossRepos( + [{ repoId: 'caller-repo-id', path: '/server/repo' }], + 'is:open', + 10 + ) - expect(result).toBe(12) + expect(result).toEqual({ totalCount: 12, totalPages: 2 }) expect(mockApi.gh.countWorkItems).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', @@ -6035,9 +6438,9 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { const result = await store .getState() - .countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], '') + .countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], '', 10) - expect(result).toBe(7) + expect(result).toEqual({ totalCount: 7, totalPages: 1 }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() expect(mockApi.gh.countWorkItems).toHaveBeenCalledWith({ repoPath: '/local/repo', @@ -6046,6 +6449,22 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) }) + it('derives page count from the repo with the most results', async () => { + const store = createTestStore() + mockApi.gh.countWorkItems.mockResolvedValueOnce(100).mockResolvedValueOnce(1) + + const result = await store.getState().countWorkItemsAcrossRepos( + [ + { repoId: 'large-repo', path: '/local/large' }, + { repoId: 'small-repo', path: '/local/small' } + ], + 'is:issue', + 36 + ) + + expect(result).toEqual({ totalCount: 101, totalPages: 3 }) + }) + it('rejects oversized work-item queries before cache keys or provider calls', async () => { const store = createTestStore() const secret = 'github-work-items-secret' @@ -6072,14 +6491,14 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { 24, 24, oversizedQuery, - 'cursor' + 1 ) ).resolves.toEqual({ items: [], failedCount: 0 }) await expect( store .getState() - .countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], oversizedQuery) - ).resolves.toBe(0) + .countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], oversizedQuery, 24) + ).resolves.toEqual({ totalCount: 0, totalPages: 0 }) store.getState().prefetchWorkItems('repo-id', '/local/repo', 24, oversizedQuery) expect(store.getState().getCachedWorkItems('repo-id', 24, oversizedQuery, '/local/repo')).toBe( @@ -6526,3 +6945,97 @@ describe('IssueSourceIndicator suppression', () => { expect(itemMarkup).not.toContain('Issues from') }) }) + +describe('shouldClearBranchMismatchedLinkedOpenPR', () => { + const basePR = (overrides: Partial = {}): PRInfo => + ({ + number: 20, + title: 'Sample PR', + state: 'open', + url: 'https://github.com/o/r/pull/20', + checksStatus: 'success', + updatedAt: '2026-01-01T00:00:00Z', + mergeable: 'MERGEABLE', + headSha: 'prhead', + headRefName: 'feature-one', + ...overrides + }) as PRInfo + + const baseArgs = { + pr: basePR(), + linkedPRNumber: 20, + branch: 'feature-two', + requestHeadOid: 'otherhead', + pushTargetBranch: null + } + + it('clears when an open linked PR heads a different branch than the worktree', () => { + expect(shouldClearBranchMismatchedLinkedOpenPR(baseArgs)).toBe(true) + }) + + it('clears when the mismatched linked PR is a draft', () => { + expect( + shouldClearBranchMismatchedLinkedOpenPR({ ...baseArgs, pr: basePR({ state: 'draft' }) }) + ).toBe(true) + }) + + it('strips refs/heads/ from the worktree branch before comparing', () => { + expect( + shouldClearBranchMismatchedLinkedOpenPR({ ...baseArgs, branch: 'refs/heads/feature-one' }) + ).toBe(false) + expect( + shouldClearBranchMismatchedLinkedOpenPR({ ...baseArgs, branch: 'refs/heads/feature-two' }) + ).toBe(true) + }) + + it('keeps the link when the branch matches the PR head', () => { + expect(shouldClearBranchMismatchedLinkedOpenPR({ ...baseArgs, branch: 'feature-one' })).toBe( + false + ) + }) + + it('keeps the link when the push target routes to the PR head branch', () => { + expect( + shouldClearBranchMismatchedLinkedOpenPR({ ...baseArgs, pushTargetBranch: 'feature-one' }) + ).toBe(false) + }) + + it('keeps the link when the worktree HEAD is the PR head commit', () => { + expect(shouldClearBranchMismatchedLinkedOpenPR({ ...baseArgs, requestHeadOid: 'prhead' })).toBe( + false + ) + }) + + it('only applies to active PRs', () => { + expect( + shouldClearBranchMismatchedLinkedOpenPR({ ...baseArgs, pr: basePR({ state: 'merged' }) }) + ).toBe(false) + expect( + shouldClearBranchMismatchedLinkedOpenPR({ ...baseArgs, pr: basePR({ state: 'closed' }) }) + ).toBe(false) + }) + + it('requires a known PR head branch and a current branch', () => { + expect( + shouldClearBranchMismatchedLinkedOpenPR({ + ...baseArgs, + pr: basePR({ headRefName: undefined }) + }) + ).toBe(false) + // Detached HEAD reports an empty branch; there is no mismatch to act on. + expect(shouldClearBranchMismatchedLinkedOpenPR({ ...baseArgs, branch: '' })).toBe(false) + expect(shouldClearBranchMismatchedLinkedOpenPR({ ...baseArgs, requestHeadOid: null })).toBe( + false + ) + }) + + it('ignores lookups that resolved a different PR than the link', () => { + expect( + shouldClearBranchMismatchedLinkedOpenPR({ ...baseArgs, pr: basePR({ number: 21 }) }) + ).toBe(false) + expect(shouldClearBranchMismatchedLinkedOpenPR({ ...baseArgs, pr: null })).toBe(false) + expect(shouldClearBranchMismatchedLinkedOpenPR({ ...baseArgs, linkedPRNumber: null })).toBe( + false + ) + }) +}) diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 44df38082b6..ef5e67111a5 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -34,7 +34,7 @@ import type { } from '../../../../shared/github-project-types' import { isGitHubWorkItemsSshRemoteRequiredError, - sortWorkItemsByUpdatedAt, + sortWorkItemsByNumber, PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' import { deriveCheckStatusFromChecks, syncPRChecksStatus } from './github-checks' @@ -206,7 +206,7 @@ type GitHubWorkItemRequestTarget = type GitHubWorkItemsListArgs = { limit: number query?: string - before?: string + page?: number noCache?: true } @@ -994,15 +994,80 @@ function findWorktreeById(state: AppState, worktreeId: string): Worktree | null return null } +type WorktreeLookupEntry = { + first: Worktree + unique: Worktree | null +} + +type WorktreeLookupIndex = { + byId: Map + repoHostIdsByRepoId: Map> +} + +function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { + const byId = new Map() + for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktree of worktrees) { + const worktreeId = worktree.id + const existing = byId.get(worktreeId) + if (existing) { + existing.unique = null + } else { + byId.set(worktreeId, { first: worktree, unique: worktree }) + } + } + } + + const repoHostIdsByRepoId = new Map>() + for (const repo of state.repos ?? []) { + let hostIds = repoHostIdsByRepoId.get(repo.id) + if (!hostIds) { + hostIds = new Set() + repoHostIdsByRepoId.set(repo.id, hostIds) + } + hostIds.add(getRepoExecutionHostId(repo)) + } + return { byId, repoHostIdsByRepoId } +} + +function findUniqueWorktreeById( + state: AppState, + worktreeId: string, + executionHostId?: string, + lookupIndex = buildWorktreeLookupIndex(state) +): Worktree | null { + const match = lookupIndex.byId.get(worktreeId)?.unique ?? null + // Why: metadata persistence is keyed only by worktree id. If two hosts own + // that id, the index marks it non-unique and destructive clears fail closed. + if (!match || executionHostId === undefined) { + return match + } + const expectedHostId = normalizeExecutionHostId(executionHostId) ?? LOCAL_EXECUTION_HOST_ID + const explicitWorktreeHostId = normalizeExecutionHostId(match.hostId) + if (explicitWorktreeHostId) { + return explicitWorktreeHostId === expectedHostId ? match : null + } + const repoHostIds = lookupIndex.repoHostIdsByRepoId.get(match.repoId) + // Pre-host persisted rows are safe only when their repo has one unambiguous owner. + if (!repoHostIds || repoHostIds.size !== 1 || !repoHostIds.has(expectedHostId)) { + return null + } + return match +} + function isStaleExactLinkedPRLookup( state: AppState, worktreeId: string | undefined, - linkedPRNumber: number | null | undefined + linkedPRNumber: number | null | undefined, + lookupIndex?: WorktreeLookupIndex ): boolean { if (!worktreeId || linkedPRNumber == null) { return false } - return findWorktreeById(state, worktreeId)?.linkedPR !== linkedPRNumber + const worktree = lookupIndex + ? (lookupIndex.byId.get(worktreeId)?.first ?? null) + : findWorktreeById(state, worktreeId) + return worktree?.linkedPR !== linkedPRNumber } function shouldClearDivergedLinkedMergedPR(args: { @@ -1043,6 +1108,58 @@ function shouldApplyDivergedLinkedPRClear(args: { ) } +// Why: a linked PR is a branch-scoped hint. When a lookup returns the linked +// open or draft PR while the worktree sits on a different branch — and neither +// the push target nor the worktree HEAD still points at the PR head — the link is stale +// (a branch switch whose identity-path clear was missed) and would otherwise +// pin Checks to the previous branch's PR on every refresh. +export function shouldClearBranchMismatchedLinkedOpenPR(args: { + pr: PRInfo | null + linkedPRNumber: number | null + branch: string + requestHeadOid: string | null + pushTargetBranch: string | null +}): boolean { + const { pr, linkedPRNumber, branch, requestHeadOid, pushTargetBranch } = args + const headRefName = pr?.headRefName?.trim() ?? '' + const currentBranch = branch.replace(/^refs\/heads\//, '').trim() + return ( + linkedPRNumber != null && + pr?.number === linkedPRNumber && + // Draft reviews are open PRs too; their distinct renderer state must not + // leave the same stale durable link permanently wedged after a branch switch. + (pr.state === 'open' || pr.state === 'draft') && + requestHeadOid !== null && + headRefName !== '' && + currentBranch !== '' && + headRefName !== currentBranch && + (pushTargetBranch === null || pushTargetBranch !== headRefName) && + // A worktree parked on the PR's own head commit is the same line of work + // (e.g. a PR checkout under a renamed local branch); keep the link. + !(pr.headSha != null && pr.headSha === requestHeadOid) + ) +} + +function shouldApplyBranchMismatchedLinkedPRClear(args: { + worktree: Pick | undefined + linkedPRNumber: number + branch: string + requestHeadOid: string | null +}): boolean { + const { worktree, linkedPRNumber, branch, requestHeadOid } = args + return ( + Boolean(worktree) && + requestHeadOid !== null && + worktree?.linkedPR === linkedPRNumber && + // Branch-scoped: only clear while the worktree is still on the branch the + // mismatch was computed against; a newer switch gets its own validation. + worktree.branch.replace(/^refs\/heads\//, '') === branch.replace(/^refs\/heads\//, '') && + worktree.head === requestHeadOid && + worktree.isBare !== true && + worktree.isArchived !== true + ) +} + function buildPRRefreshCandidate( state: AppState, worktree: Worktree, @@ -1315,17 +1432,23 @@ function shouldPreserveExistingPRForFallbackMiss(args: { fallbackPRNumber?: number | null fallbackPRSource?: GitHubPRFallbackSource | null }): boolean { + if ( + args.nextPR !== null || + args.linkedPRNumber != null || + args.currentPR?.state !== 'merged' || + typeof args.currentPR.headSha !== 'string' || + args.currentPR.headSha.length === 0 + ) { + return false + } + // Why: the common found/non-merged paths do not depend on worktree state. + // Gate the global lookup so batched refresh aliases do not multiply full scans. const worktree = args.worktreeId ? findWorktreeById(args.state, args.worktreeId) : null const worktreeHead = worktree?.head // Why: merged branch PRs are only safe to keep when cached PR metadata still // matches the commit this stored worktree is actually on — exactly, or via a // head confirmed to be part of the merged PR. const preservesMergedPRForCurrentHead = - args.nextPR === null && - args.linkedPRNumber == null && - args.currentPR?.state === 'merged' && - typeof args.currentPR.headSha === 'string' && - args.currentPR.headSha.length > 0 && typeof worktreeHead === 'string' && worktreeHead.length > 0 && (args.currentPR.headSha === worktreeHead || @@ -1920,10 +2043,7 @@ export type GitHubSlice = { query: string, options?: FetchOptions ) => Promise<{ items: GitHubWorkItem[]; failedCount: number }> - /** - * Fetch the next page of work items using a date cursor. Does not cache — - * pagination pages are ephemeral and managed by TaskPage state. - */ + /** Fetch one numbered provider page. Pagination pages remain renderer-local. */ fetchWorkItemsNextPage: ( repos: { repoId: string @@ -1934,12 +2054,9 @@ export type GitHubSlice = { perRepoLimit: number, displayLimit: number, query: string, - before: string + page: number ) => Promise<{ items: GitHubWorkItem[]; failedCount: number }> - /** - * Count total work items across repos using GitHub's search API. - * Returns the sum of per-repo counts for the given query. - */ + /** Count items and derive pages from the largest per-repo result set. */ countWorkItemsAcrossRepos: ( repos: { repoId: string @@ -1947,8 +2064,9 @@ export type GitHubSlice = { executionHostId?: string | null sourceContext?: TaskSourceContext | null }[], - query: string - ) => Promise + query: string, + perRepoLimit: number + ) => Promise<{ totalCount: number; totalPages: number }> /** * Fire-and-forget prefetch used by UI entry points (hover/focus of the * "new workspace" buttons) to warm the cache before the page mounts. @@ -2773,11 +2891,11 @@ export const createGitHubSlice: StateCreator = (s } }) ) - const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit) + const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) return { items: merged, failedCount } }, - fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, before) => { + fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, page) => { if (isGitHubWorkItemsQueryTooLarge(query)) { return { items: [], failedCount: 0 } } @@ -2803,7 +2921,7 @@ export const createGitHubSlice: StateCreator = (s const envelope = await listGitHubWorkItemsForRepo(requestContext, { limit: perRepoLimit, query: query || undefined, - before + page }) // Why: page-N partial failures don't participate in the cache's per-repo // error banner (which is keyed on the initial-fetch cache entry). Log the @@ -2831,14 +2949,15 @@ export const createGitHubSlice: StateCreator = (s } }) ) - const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit) + const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) return { items: merged, failedCount } }, - countWorkItemsAcrossRepos: async (repos, query) => { + countWorkItemsAcrossRepos: async (repos, query, perRepoLimit) => { if (isGitHubWorkItemsQueryTooLarge(query)) { - return 0 + return { totalCount: 0, totalPages: 0 } } + const normalizedLimit = Math.max(1, Math.floor(perRepoLimit)) const counts = await Promise.all( repos.map(async (r) => { // Why: same stampede cap as the item-fetch paths — without a slot, @@ -2868,7 +2987,15 @@ export const createGitHubSlice: StateCreator = (s } }) ) - return counts.reduce((sum, c) => sum + c, 0) + return { + totalCount: counts.reduce((sum, count) => sum + count, 0), + // Why: each repo advances independently by the same numbered page. A sum + // divided by page width undercounts when one repo owns most results. + totalPages: counts.reduce( + (maxPages, count) => Math.max(maxPages, Math.ceil(count / normalizedLimit)), + 0 + ) + } }, prefetchWorkItems: (repoId, repoPath, limit = PER_REPO_FETCH_LIMIT, query = '', options) => { @@ -3133,8 +3260,17 @@ export const createGitHubSlice: StateCreator = (s if (didUpdatePRCache) { debouncedSaveCache(get()) } + const linkedPRWorktree = + options?.worktreeId && linkedPRNumber != null + ? findUniqueWorktreeById( + get(), + options.worktreeId, + repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID + ) + : null if ( options?.worktreeId && + linkedPRWorktree && linkedPRNumber != null && shouldClearDivergedLinkedMergedPR({ pr, linkedPRNumber, requestHeadOid }) ) { @@ -3144,9 +3280,14 @@ export const createGitHubSlice: StateCreator = (s options.worktreeId, { linkedPR: null }, { - shouldApply: (worktree) => + shouldApply: () => shouldApplyDivergedLinkedPRClear({ - worktree, + worktree: + findUniqueWorktreeById( + get(), + options.worktreeId!, + repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID + ) ?? undefined, linkedPRNumber, branch, requestHeadOid @@ -3154,6 +3295,47 @@ export const createGitHubSlice: StateCreator = (s } ) } + if ( + options?.worktreeId && + linkedPRWorktree && + linkedPRNumber != null && + shouldClearBranchMismatchedLinkedOpenPR({ + pr, + linkedPRNumber, + branch, + requestHeadOid, + pushTargetBranch: linkedPRWorktree.pushTarget?.branchName ?? null + }) + ) { + void get().updateWorktreeMeta( + options.worktreeId, + { linkedPR: null }, + { + // Why: the branch-scoped PR refetch below updates both GitHub + // caches; the generic metadata refresh would duplicate provider work. + suppressHostedReviewRefresh: true, + shouldApply: () => + shouldApplyBranchMismatchedLinkedPRClear({ + worktree: + findUniqueWorktreeById( + get(), + options.worktreeId!, + repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID + ) ?? undefined, + linkedPRNumber, + branch, + requestHeadOid + }) + } + ) + // Re-resolve by branch right away so visible Checks recover on this + // refresh instead of keeping the stale linked PR cached. + void get().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId: options.worktreeId + }) + } } if ( shouldPreserveExistingPRForFallbackMiss({ @@ -3881,28 +4063,18 @@ export const createGitHubSlice: StateCreator = (s linkedPRNumber: number branch: string requestHeadOid: string | null + executionHostId: string + }[] = [] + const branchMismatchedLinkedPRClears: { + worktreeId: string + linkedPRNumber: number + branch: string + requestHeadOid: string | null + executionHostId: string }[] = [] - if (event.outcome?.kind === 'found') { - const pr = event.outcome.pr - for (const alias of event.aliases) { - const linkedPRNumber = alias.linkedPRNumber ?? null - const requestHeadOid = alias.currentHeadOid ?? null - if ( - alias.worktreeId && - linkedPRNumber != null && - shouldClearDivergedLinkedMergedPR({ pr, linkedPRNumber, requestHeadOid }) - ) { - divergedLinkedPRClears.push({ - worktreeId: alias.worktreeId, - linkedPRNumber, - branch: alias.branch, - requestHeadOid - }) - } - } - } let didUpdatePRCache = false set((s) => { + let linkedWorktreeLookupIndex: WorktreeLookupIndex | undefined const nextSequences = { ...s.prRefreshSequences } const prunedStates = pruneExpiredPRRefreshStates(s.prRefreshStates) const nextStates = { ...prunedStates } @@ -4015,11 +4187,69 @@ export const createGitHubSlice: StateCreator = (s return pr })() : null + const linkedPRNumber = alias.linkedPRNumber ?? null + // Why: one coordinator outcome can fan out to many linked aliases. + // Build one lazy index instead of rescanning all worktrees per alias. + const worktreeLookupIndex = + alias.worktreeId && linkedPRNumber != null + ? (linkedWorktreeLookupIndex ??= buildWorktreeLookupIndex(s)) + : undefined // Why: queued local refreshes may finish after the user unlinks an // exact PR; those older results must not restore the manual-link UI. - if (isStaleExactLinkedPRLookup(s, alias.worktreeId, alias.linkedPRNumber)) { + if ( + isStaleExactLinkedPRLookup(s, alias.worktreeId, linkedPRNumber, worktreeLookupIndex) + ) { continue } + if (event.outcome.kind === 'found' && alias.worktreeId) { + const requestHeadOid = alias.currentHeadOid ?? null + const worktree = + linkedPRNumber != null + ? findUniqueWorktreeById( + s, + alias.worktreeId, + aliasExecutionHostId, + worktreeLookupIndex + ) + : null + // Why: only an event that won the sequence gate above owns metadata + // side effects; rejected late outcomes must not unlink a newer PR. + if ( + worktree && + linkedPRNumber != null && + shouldClearDivergedLinkedMergedPR({ + pr: event.outcome.pr, + linkedPRNumber, + requestHeadOid + }) + ) { + divergedLinkedPRClears.push({ + worktreeId: alias.worktreeId, + linkedPRNumber, + branch: alias.branch, + requestHeadOid, + executionHostId: aliasExecutionHostId + }) + } else if ( + worktree && + linkedPRNumber != null && + shouldClearBranchMismatchedLinkedOpenPR({ + pr: event.outcome.pr, + linkedPRNumber, + branch: alias.branch, + requestHeadOid, + pushTargetBranch: worktree.pushTarget?.branchName ?? null + }) + ) { + branchMismatchedLinkedPRClears.push({ + worktreeId: alias.worktreeId, + linkedPRNumber, + branch: alias.branch, + requestHeadOid, + executionHostId: aliasExecutionHostId + }) + } + } const nextCaches = applyGitHubPRResultToCaches({ prCache: nextPRCache, hostedReviewCache: nextHostedReviewCache, @@ -4102,9 +4332,26 @@ export const createGitHubSlice: StateCreator = (s clear.worktreeId, { linkedPR: null }, { - shouldApply: (worktree) => + shouldApply: () => shouldApplyDivergedLinkedPRClear({ - worktree, + worktree: + findUniqueWorktreeById(get(), clear.worktreeId, clear.executionHostId) ?? undefined, + linkedPRNumber: clear.linkedPRNumber, + branch: clear.branch, + requestHeadOid: clear.requestHeadOid + }) + } + ) + } + for (const clear of branchMismatchedLinkedPRClears) { + void get().updateWorktreeMeta( + clear.worktreeId, + { linkedPR: null }, + { + shouldApply: () => + shouldApplyBranchMismatchedLinkedPRClear({ + worktree: + findUniqueWorktreeById(get(), clear.worktreeId, clear.executionHostId) ?? undefined, linkedPRNumber: clear.linkedPRNumber, branch: clear.branch, requestHeadOid: clear.requestHeadOid diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index fd084a3d985..4f6957366f2 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -4,6 +4,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { + JiraAuthType, JiraConnectionStatus, JiraIssue, JiraIssueFilter, @@ -163,6 +164,7 @@ export type JiraSlice = { siteUrl: string email: string apiToken: string + authType?: JiraAuthType }) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> testJiraConnection: ( siteId?: string | null diff --git a/src/renderer/src/store/slices/recently-closed-tabs.test.ts b/src/renderer/src/store/slices/recently-closed-tabs.test.ts new file mode 100644 index 00000000000..cca4ef69337 --- /dev/null +++ b/src/renderer/src/store/slices/recently-closed-tabs.test.ts @@ -0,0 +1,291 @@ +/** + * Cmd/Ctrl+Shift+T reopen behavior: terminal tabs join the recently-closed + * stacks, and the unified dispatcher pops the most recently closed tab of any + * kind (terminal/browser/editor) in true cross-type MRU order. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { getDefaultSettings } from '../../../../shared/constants' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = { + worktrees: { + list: vi.fn().mockResolvedValue([]), + remove: vi.fn().mockResolvedValue(undefined), + updateMeta: vi.fn().mockResolvedValue({}) + }, + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } +} + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: mockApi } + +import { createTestStore, seedStore, makeWorktree, makeOpenFile } from './store-test-helpers' +import { + pushRecentlyClosedTabKind, + remapClosedTerminalTabSnapshotCwds +} from './recently-closed-tabs' + +const WT = 'repo1::/path/wt1' + +function makeSeededStore(): ReturnType { + const store = createTestStore() + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: WT + }) + return store +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('terminal recently-closed capture', () => { + it('captures a snapshot and kind entry on user close', () => { + const store = makeSeededStore() + const tab = store + .getState() + .createTab(WT, undefined, undefined, { startupCwd: '/path/wt1/packages/app' }) + store.getState().setTabCustomTitle(tab.id, 'build shell') + store.getState().setTabColor(tab.id, '#ff0000') + + store.getState().closeTab(tab.id) + + expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toEqual([ + { startupCwd: '/path/wt1/packages/app', customTitle: 'build shell', color: '#ff0000' } + ]) + expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual(['terminal']) + }) + + it.each(['cleanup', 'pty-exit'] as const)('skips capture for %s closes', (reason) => { + const store = makeSeededStore() + const tab = store.getState().createTab(WT) + + store.getState().closeTab(tab.id, { reason }) + + expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toBeUndefined() + expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toBeUndefined() + }) + + it('skips capture for a confirmed parked PTY exit', () => { + const store = makeSeededStore() + const tab = store.getState().createTab(WT) + + store.getState().closeTab(tab.id, { captureRecentlyClosed: false }) + + expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toBeUndefined() + expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toBeUndefined() + }) + + it('caps the terminal stack at 10 snapshots, newest first', () => { + const store = makeSeededStore() + for (let i = 0; i < 12; i++) { + const tab = store + .getState() + .createTab(WT, undefined, undefined, { startupCwd: `/path/wt1/dir-${i}` }) + store.getState().closeTab(tab.id) + } + + const stack = store.getState().recentlyClosedTerminalTabsByWorktree[WT] + expect(stack).toHaveLength(10) + expect(stack[0]?.startupCwd).toBe('/path/wt1/dir-11') + expect(stack[9]?.startupCwd).toBe('/path/wt1/dir-2') + }) +}) + +describe('recently-closed history bounds', () => { + it('caps the bulk kind allocation before constructing it', () => { + const result = pushRecentlyClosedTabKind({}, WT, 'editor', Number.MAX_SAFE_INTEGER) + + expect(result[WT]).toHaveLength(30) + expect(result[WT]?.every((kind) => kind === 'editor')).toBe(true) + }) +}) + +describe('terminal snapshot cwd remapping', () => { + it('handles case-insensitive Windows roots and preserves the new separator style', () => { + expect( + remapClosedTerminalTabSnapshotCwds( + [{ startupCwd: 'c:\\REPO\\old\\packages\\app' }], + 'C:\\Repo\\Old\\', + 'D:\\Worktrees\\New' + ) + ).toEqual([{ startupCwd: 'D:\\Worktrees\\New\\packages\\app' }]) + }) + + it('does not treat a POSIX backslash as a directory separator', () => { + const snapshot = { startupCwd: '/repo/old\\sibling' } + + expect(remapClosedTerminalTabSnapshotCwds([snapshot], '/repo/old', '/repo/new')).toEqual([ + snapshot + ]) + }) +}) + +describe('reopenClosedTerminalTab', () => { + it('recreates a fresh terminal with the snapshot cwd, shell, title, and color', () => { + const store = makeSeededStore() + const tab = store + .getState() + .createTab(WT, undefined, 'zsh', { startupCwd: '/path/wt1/packages/app' }) + store.getState().setTabCustomTitle(tab.id, 'build shell') + store.getState().setTabColor(tab.id, '#ff0000') + store.getState().closeTab(tab.id) + expect(store.getState().tabsByWorktree[WT]).toHaveLength(0) + + expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true) + + const restored = store.getState().tabsByWorktree[WT] + expect(restored).toHaveLength(1) + expect(restored[0]).toMatchObject({ + startupCwd: '/path/wt1/packages/app', + shellOverride: 'zsh', + customTitle: 'build shell', + color: '#ff0000' + }) + // Why: a reopened tab is a fresh surface — never the old PTY. + expect(restored[0].id).not.toBe(tab.id) + expect(restored[0].ptyId).toBeNull() + expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toHaveLength(0) + }) + + it('returns false when the stack is empty (no double-restore)', () => { + const store = makeSeededStore() + const tab = store.getState().createTab(WT) + store.getState().closeTab(tab.id) + + expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true) + expect(store.getState().reopenClosedTerminalTab(WT)).toBe(false) + expect(store.getState().tabsByWorktree[WT]).toHaveLength(1) + }) + + it('skips local reopen on a remote-runtime worktree (host owns the terminal)', () => { + // Why: a `runtime:` host id makes the worktree host-owned; a local createTab + // would leave an unbacked phantom tab, so reopen must bail and let the + // cross-type dispatcher fall through to browser/editor. + const store = createTestStore() + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1', hostId: 'runtime:env-1' }) + ] + }, + activeWorktreeId: WT + }) + store.setState({ + recentlyClosedTerminalTabsByWorktree: { [WT]: [{ startupCwd: '/path/wt1/api' }] }, + recentlyClosedTabKindsByWorktree: { [WT]: ['terminal'] } + }) + + expect(store.getState().reopenClosedTerminalTab(WT)).toBe(false) + // No local tab spawned, and the snapshot is left intact (not consumed). + expect(store.getState().tabsByWorktree[WT] ?? []).toHaveLength(0) + expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toHaveLength(1) + }) + + it('reopens a legacy local worktree while another runtime is merely focused', () => { + const store = makeSeededStore() + store.setState({ + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'env-1' } + }) + const tab = store.getState().createTab(WT) + store.getState().closeTab(tab.id) + + expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true) + expect(store.getState().tabsByWorktree[WT]).toHaveLength(1) + }) + + it('reopens SSH terminals through the renderer-owned create path', () => { + const store = createTestStore() + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT, repoId: 'repo1', path: '/remote/wt1', hostId: 'ssh:host-1' }) + ] + }, + activeWorktreeId: WT + }) + const tab = store + .getState() + .createTab(WT, undefined, undefined, { startupCwd: '/remote/wt1/packages/app' }) + store.getState().closeTab(tab.id) + + expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true) + expect(store.getState().tabsByWorktree[WT]?.[0]?.startupCwd).toBe('/remote/wt1/packages/app') + }) +}) + +describe('reopenClosedTab cross-type MRU', () => { + it('reopens closed tabs of mixed kinds in most-recent-first order', () => { + const store = makeSeededStore() + + // Close order: editor → terminal → browser. + store.setState({ openFiles: [makeOpenFile({ id: '/repo/a.ts', worktreeId: WT })] }) + store.getState().closeFile('/repo/a.ts') + const terminal = store + .getState() + .createTab(WT, undefined, undefined, { startupCwd: '/path/wt1/api' }) + store.getState().closeTab(terminal.id) + const browser = store.getState().createBrowserTab(WT, 'https://example.com', { title: 'Ex' }) + store.getState().closeBrowserTab(browser.id) + + expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual([ + 'browser', + 'terminal', + 'editor' + ]) + + // First press: browser comes back. + expect(store.getState().reopenClosedTab(WT)).toBe(true) + expect(store.getState().browserTabsByWorktree[WT]?.[0]?.url).toBe('https://example.com') + expect(store.getState().tabsByWorktree[WT] ?? []).toHaveLength(0) + + // Second press: terminal comes back. + expect(store.getState().reopenClosedTab(WT)).toBe(true) + expect(store.getState().tabsByWorktree[WT]?.[0]?.startupCwd).toBe('/path/wt1/api') + expect(store.getState().openFiles).toHaveLength(0) + + // Third press: editor comes back. + expect(store.getState().reopenClosedTab(WT)).toBe(true) + expect(store.getState().openFiles[0]?.filePath).toBe('/repo/a.ts') + + // Nothing left. + expect(store.getState().reopenClosedTab(WT)).toBe(false) + }) + + it('skips kind entries whose per-type snapshot has drained', () => { + const store = makeSeededStore() + store.setState({ + recentlyClosedTabKindsByWorktree: { [WT]: ['browser', 'terminal'] }, + recentlyClosedTerminalTabsByWorktree: { [WT]: [{ startupCwd: '/path/wt1' }] }, + recentlyClosedBrowserTabsByWorktree: { [WT]: [] } + }) + + expect(store.getState().reopenClosedTab(WT)).toBe(true) + + expect(store.getState().tabsByWorktree[WT]?.[0]?.startupCwd).toBe('/path/wt1') + expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual([]) + }) + + it('returns false when no tab was ever closed', () => { + const store = makeSeededStore() + expect(store.getState().reopenClosedTab(WT)).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/recently-closed-tabs.ts b/src/renderer/src/store/slices/recently-closed-tabs.ts new file mode 100644 index 00000000000..f2fd9ff3d7b --- /dev/null +++ b/src/renderer/src/store/slices/recently-closed-tabs.ts @@ -0,0 +1,193 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + isWindowsAbsolutePathLike, + relativePathInsideRoot +} from '../../../../shared/cross-platform-path' + +/** Snapshot of a terminal tab captured at user-initiated close time. Reopen + * recreates a fresh shell in the same startup directory (Ghostty semantics) — + * never the old PTY, scrollback, or a relaunched agent session. */ +export type ClosedTerminalTabSnapshot = { + startupCwd?: string + shellOverride?: string + customTitle?: string + color?: string +} + +export type RecentlyClosedTabKind = 'terminal' | 'browser' | 'editor' + +const MAX_RECENT_CLOSED_TERMINAL_TABS = 10 +// Why: wider than the per-type stacks (10) so cross-type ordering survives a +// full per-type stack; kind entries whose snapshot aged out are skipped on pop. +const MAX_RECENT_CLOSED_TAB_KINDS = 30 + +// Why: the map params tolerate undefined because several test harnesses build +// partial stores (single-slice spreads) that lack this slice's state. +export function pushClosedTerminalTabSnapshot( + map: Record | undefined, + worktreeId: string, + snapshot: ClosedTerminalTabSnapshot +): Record { + return { + ...map, + [worktreeId]: [snapshot, ...(map?.[worktreeId] ?? [])].slice(0, MAX_RECENT_CLOSED_TERMINAL_TABS) + } +} + +export function pushRecentlyClosedTabKind( + map: Record | undefined, + worktreeId: string, + kind: RecentlyClosedTabKind, + count = 1 +): Record { + // Why: preserve the original reference on no-op pushes so unrelated + // subscribers don't re-evaluate (mirrors the closeTab unread-map pattern). + if (count <= 0) { + return map ?? {} + } + // Why: close-all may contain thousands of editor tabs, but entries beyond + // the retained history cap can never affect reopen ordering. + const retainedCount = Math.min(count, MAX_RECENT_CLOSED_TAB_KINDS) + return { + ...map, + [worktreeId]: [ + ...(Array(retainedCount).fill(kind) as RecentlyClosedTabKind[]), + ...(map?.[worktreeId] ?? []) + ].slice(0, MAX_RECENT_CLOSED_TAB_KINDS) + } +} + +export function remapClosedTerminalTabSnapshotCwds( + snapshots: ClosedTerminalTabSnapshot[], + oldWorktreePath: string, + newWorktreePath: string +): ClosedTerminalTabSnapshot[] { + return snapshots.map((snapshot) => { + if (!snapshot.startupCwd) { + return snapshot + } + const relative = relativePathInsideRoot(oldWorktreePath, snapshot.startupCwd) + if (relative === null) { + return snapshot + } + if (!relative) { + return { ...snapshot, startupCwd: newWorktreePath } + } + const useBackslash = + isWindowsAbsolutePathLike(newWorktreePath) && newWorktreePath.includes('\\') + const separator = useBackslash ? '\\' : '/' + const base = newWorktreePath.replace(/[\\/]+$/g, '') + const suffix = useBackslash ? relative.replace(/\//g, '\\') : relative + return { ...snapshot, startupCwd: `${base}${separator}${suffix}` } + }) +} + +export type RecentlyClosedTabsSlice = { + /** Newest-first snapshots of user-closed terminal tabs, per worktree. */ + recentlyClosedTerminalTabsByWorktree: Record + /** Newest-first close order across the terminal/browser/editor reopen stacks + * so Cmd+Shift+T pops true cross-type MRU (Chrome/Ghostty semantics). */ + recentlyClosedTabKindsByWorktree: Record + reopenClosedTerminalTab: (worktreeId: string) => boolean + reopenClosedTab: (worktreeId: string) => boolean +} + +export const createRecentlyClosedTabsSlice: StateCreator< + AppState, + [], + [], + RecentlyClosedTabsSlice +> = (set, get) => ({ + recentlyClosedTerminalTabsByWorktree: {}, + recentlyClosedTabKindsByWorktree: {}, + + reopenClosedTerminalTab: (worktreeId) => { + // Why: explicitly remote-owned worktrees own terminals through the host + // session. A raw local createTab here would leave an + // unbacked phantom tab that races the next host snapshot, so skip local + // reopen for those worktrees — the cross-type dispatcher falls through to + // browser/editor. Imported directly instead of via web-runtime-session to + // avoid a store slice ↔ store-index import cycle. Remote terminal reopen is + // deferred (see PR notes); local + SSH worktrees are the covered surface. + if (getExplicitRuntimeEnvironmentIdForWorktree(get(), worktreeId)?.trim()) { + return false + } + // Why: read and pop atomically inside set() to prevent a TOCTOU race where + // two rapid Cmd+Shift+T presses both restore the same entry (mirrors + // reopenClosedBrowserTab). + let snapshot: ClosedTerminalTabSnapshot | undefined + set((s) => { + const stack = s.recentlyClosedTerminalTabsByWorktree[worktreeId] ?? [] + snapshot = stack[0] + if (!snapshot) { + return s + } + return { + recentlyClosedTerminalTabsByWorktree: { + ...s.recentlyClosedTerminalTabsByWorktree, + [worktreeId]: stack.slice(1) + } + } + }) + if (!snapshot) { + return false + } + + const tab = get().createTab(worktreeId, undefined, snapshot.shellOverride, { + ...(snapshot.startupCwd ? { startupCwd: snapshot.startupCwd } : {}), + activate: true + }) + if (snapshot.customTitle) { + get().setTabCustomTitle(tab.id, snapshot.customTitle) + } + if (snapshot.color) { + get().setTabColor(tab.id, snapshot.color) + } + get().setActiveTabType('terminal') + // Why: with a stored order the strip appends unknown ids last already, but + // an explicit append keeps the reopened tab at the end even after future + // reorders write the stored order back. + const order = get().tabBarOrderByWorktree[worktreeId] + if (order && !order.includes(tab.id)) { + get().setTabBarOrder(worktreeId, [...order, tab.id]) + } + return true + }, + + reopenClosedTab: (worktreeId) => { + // Why: a kind entry can outlive its snapshot (per-type caps are tighter, + // and the browser stack dedupes by workspace id), so skip drained kinds + // instead of giving up. Each iteration shifts one entry, so the loop is + // bounded by the kind list length. + for (;;) { + let kind: RecentlyClosedTabKind | undefined + set((s) => { + const kinds = s.recentlyClosedTabKindsByWorktree[worktreeId] ?? [] + kind = kinds[0] + if (!kind) { + return s + } + return { + recentlyClosedTabKindsByWorktree: { + ...s.recentlyClosedTabKindsByWorktree, + [worktreeId]: kinds.slice(1) + } + } + }) + if (!kind) { + return false + } + const reopened = + kind === 'terminal' + ? get().reopenClosedTerminalTab(worktreeId) + : kind === 'browser' + ? get().reopenClosedBrowserTab(worktreeId) !== null + : get().reopenClosedEditorTab(worktreeId) + if (reopened) { + return true + } + } + } +}) diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts index 56d98ca2cba..23325aa73e9 100644 --- a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -29,9 +29,11 @@ const reposRemove = vi.fn() const reposRemoveForHost = vi.fn() const reposUpdate = vi.fn() const reposReorder = vi.fn() +const reposReorderForHost = vi.fn() const ptyKill = vi.fn() const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() +const uiSet = vi.fn() function deferred() { let resolve!: (value: T) => void @@ -63,9 +65,12 @@ beforeEach(() => { reposRemoveForHost.mockReset() reposUpdate.mockReset() reposReorder.mockReset() + reposReorderForHost.mockReset() ptyKill.mockReset() runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() + uiSet.mockReset() + uiSet.mockResolvedValue(undefined) runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) }) @@ -75,10 +80,12 @@ beforeEach(() => { remove: reposRemove, removeForHost: reposRemoveForHost, update: reposUpdate, - reorder: reposReorder + reorder: reposReorder, + reorderForHost: reposReorderForHost }, pty: { kill: ptyKill }, - runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + ui: { set: uiSet } } }) }) @@ -457,7 +464,7 @@ describe('repo slice host identity routing', () => { }) it('reorders duplicate repo ids once per owning host', async () => { - reposReorder.mockResolvedValue({ status: 'applied' }) + reposReorderForHost.mockResolvedValue({ status: 'applied' }) runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-duplicate-reorder', ok: true, @@ -470,7 +477,17 @@ describe('repo slice host identity routing', () => { await store.getState().reorderRepos(['same-repo', 'same-repo']) expect(store.getState().repos).toEqual([localDuplicate, remoteDuplicate]) - expect(reposReorder).toHaveBeenCalledWith({ orderedIds: ['same-repo'] }) + expect(reposReorderForHost).toHaveBeenCalledWith({ + hostId: 'local', + orderedIds: ['same-repo'] + }) + expect(reposReorder).not.toHaveBeenCalled() + expect(uiSet).toHaveBeenCalledWith({ + manualRepoOrder: [ + { hostId: 'local', repoId: 'same-repo' }, + { hostId: 'runtime:env-1', repoId: 'same-repo' } + ] + }) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'repo.reorder', @@ -478,4 +495,108 @@ describe('repo slice host identity routing', () => { timeoutMs: 15_000 }) }) + + it('persists a moved paired-host project block without reversing its host occurrences', async () => { + reposReorderForHost.mockResolvedValue({ status: 'applied' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-paired-project-reorder', + ok: true, + result: { status: 'applied' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const bravo = { ...localDuplicate, id: 'bravo' } + const charlie = { ...remoteDuplicate, id: 'charlie' } + const store = createTestStore() + store.setState({ repos: [bravo, localDuplicate, charlie, remoteDuplicate] }) + + await store.getState().reorderRepos(['same-repo', 'same-repo', 'bravo', 'charlie']) + + expect(store.getState().repos).toEqual([localDuplicate, remoteDuplicate, bravo, charlie]) + expect(uiSet).toHaveBeenCalledWith({ + manualRepoOrder: [ + { hostId: 'local', repoId: 'same-repo' }, + { hostId: 'runtime:env-1', repoId: 'same-repo' }, + { hostId: 'local', repoId: 'bravo' }, + { hostId: 'runtime:env-1', repoId: 'charlie' } + ] + }) + expect(reposReorderForHost).toHaveBeenCalledWith({ + hostId: 'local', + orderedIds: ['same-repo', 'bravo'] + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.reorder', + params: { orderedIds: ['same-repo', 'charlie'] }, + timeoutMs: 15_000 + }) + }) + + it('persists a complete cross-host overlay alongside host-local permutations', async () => { + reposReorderForHost.mockResolvedValue({ status: 'applied' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-cross-host-reorder', + ok: true, + result: { status: 'applied' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const alpha = { ...localDuplicate, id: 'alpha' } + const bravo = { ...localDuplicate, id: 'bravo' } + const charlie = { ...remoteDuplicate, id: 'charlie' } + const delta = { ...remoteDuplicate, id: 'delta' } + const store = createTestStore() + store.setState({ repos: [alpha, bravo, charlie, delta] }) + + await store.getState().reorderRepos(['alpha', 'charlie', 'bravo', 'delta']) + + expect(reposReorderForHost).toHaveBeenCalledWith({ + hostId: 'local', + orderedIds: ['alpha', 'bravo'] + }) + expect(reposReorder).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.reorder', + params: { orderedIds: ['charlie', 'delta'] }, + timeoutMs: 15_000 + }) + expect(uiSet).toHaveBeenCalledWith({ + manualRepoOrder: [ + { hostId: 'local', repoId: 'alpha' }, + { hostId: 'runtime:env-1', repoId: 'charlie' }, + { hostId: 'local', repoId: 'bravo' }, + { hostId: 'runtime:env-1', repoId: 'delta' } + ] + }) + }) + + it('persists local and direct SSH permutations through host-scoped IPC', async () => { + reposReorderForHost.mockResolvedValue({ status: 'applied' }) + const alpha = { ...localDuplicate, id: 'alpha' } + const bravo = { ...localDuplicate, id: 'bravo' } + const charlie = { + ...localDuplicate, + id: 'charlie', + path: '/ssh/charlie', + connectionId: 'target', + executionHostId: undefined + } + const delta = { ...charlie, id: 'delta', path: '/ssh/delta' } + const store = createTestStore() + store.setState({ repos: [alpha, charlie, bravo, delta] }) + + await store.getState().reorderRepos(['bravo', 'delta', 'alpha', 'charlie']) + + expect(reposReorderForHost).toHaveBeenCalledTimes(2) + expect(reposReorderForHost).toHaveBeenCalledWith({ + hostId: 'local', + orderedIds: ['bravo', 'alpha'] + }) + expect(reposReorderForHost).toHaveBeenCalledWith({ + hostId: 'ssh:target', + orderedIds: ['delta', 'charlie'] + }) + expect(reposReorder).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/store/slices/repos-manual-order-hydration.test.ts b/src/renderer/src/store/slices/repos-manual-order-hydration.test.ts new file mode 100644 index 00000000000..91374d47701 --- /dev/null +++ b/src/renderer/src/store/slices/repos-manual-order-hydration.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { createTestStore } from './store-test-helpers' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const reposList = vi.fn() +const projectsList = vi.fn() +const listHostSetups = vi.fn() +const runtimeEnvironmentsList = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +const reposByEnvironment: Record = { + 'node-a': [ + { id: 'alpha', path: '/alpha', displayName: 'alpha', badgeColor: '#000', addedAt: 1 }, + { id: 'bravo', path: '/bravo', displayName: 'bravo', badgeColor: '#000', addedAt: 2 } + ], + 'node-b': [ + { id: 'charlie', path: '/charlie', displayName: 'charlie', badgeColor: '#000', addedAt: 1 }, + { id: 'delta', path: '/delta', displayName: 'delta', badgeColor: '#000', addedAt: 2 } + ] +} + +type RepoListResolver = (value: unknown) => void + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset().mockResolvedValue([]) + projectsList.mockReset().mockResolvedValue([]) + listHostSetups.mockReset().mockResolvedValue([]) + runtimeEnvironmentsList.mockReset().mockResolvedValue([ + { id: 'node-a', name: 'A' }, + { id: 'node-b', name: 'B' } + ]) + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + ) + vi.stubGlobal('window', { + api: { + repos: { list: reposList }, + projects: { list: projectsList, listHostSetups }, + runtimeEnvironments: { + list: runtimeEnvironmentsList, + call: runtimeEnvironmentTransportCall + } + } + }) +}) + +async function loadWithCompletionOrder(completionOrder: string[]): Promise { + clearRuntimeCompatibilityCacheForTests() + const repoListResolvers = new Map() + runtimeEnvironmentCall.mockImplementation( + (args: RuntimeEnvironmentCallRequest & { selector?: string }) => { + if (args.method === 'repo.list' && args.selector) { + return new Promise((resolve) => repoListResolvers.set(args.selector!, resolve)) + } + const result = args.method === 'project.list' ? { projects: [] } : { setups: [] } + return { id: `rpc-${args.method}`, ok: true, result, _meta: { runtimeId: 'runtime' } } + } + ) + const store = createTestStore() + store.setState({ + manualRepoOrder: [ + { hostId: 'runtime:node-a', repoId: 'alpha' }, + { hostId: 'runtime:node-b', repoId: 'charlie' }, + { hostId: 'runtime:node-a', repoId: 'bravo' }, + { hostId: 'runtime:node-b', repoId: 'delta' } + ] + }) + + const load = store.getState().fetchReposForAllHosts() + await vi.waitFor(() => expect(repoListResolvers.size).toBe(2)) + for (const environmentId of completionOrder) { + repoListResolvers.get(environmentId)?.({ + id: `rpc-repo-${environmentId}`, + ok: true, + result: { repos: reposByEnvironment[environmentId] }, + _meta: { runtimeId: environmentId } + }) + await Promise.resolve() + } + await load + expect(store.getState().manualRepoOrder).toHaveLength(4) + return store.getState().repos.map((repo) => `${repo.executionHostId}:${repo.id}`) +} + +describe('manual repo order hydration', () => { + it('restores the same cross-host order for either catalog completion order', async () => { + const expected = [ + 'runtime:node-a:alpha', + 'runtime:node-b:charlie', + 'runtime:node-a:bravo', + 'runtime:node-b:delta' + ] + + await expect(loadWithCompletionOrder(['node-b', 'node-a'])).resolves.toEqual(expected) + await expect(loadWithCompletionOrder(['node-a', 'node-b'])).resolves.toEqual(expected) + }) +}) diff --git a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts index 738a805bba5..ef96a644e77 100644 --- a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts +++ b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts @@ -44,6 +44,7 @@ export const reposCloneRemote: Mock = vi.fn() export const reposRemove: Mock = vi.fn() export const reposUpdate: Mock = vi.fn() export const reposReorder: Mock = vi.fn() +export const reposReorderForHost: Mock = vi.fn() export const projectsCreateHostSetup: Mock = vi.fn() export const projectsSetupExistingFolder: Mock = vi.fn() export const projectsUpdateHostSetup: Mock = vi.fn() @@ -54,6 +55,7 @@ export const ptyKill: Mock = vi.fn() export const runtimeEnvironmentCall: Mock = vi.fn() export const runtimeEnvironmentTransportCall: Mock = vi.fn() export const orcaProfileFindProjectProfiles: Mock = vi.fn() +export const uiSet: Mock = vi.fn() // Registers the per-test reset + window stub. Call once inside the suite's module scope. export function installReposRuntimeRoutingHarness(): void { @@ -71,6 +73,7 @@ export function installReposRuntimeRoutingHarness(): void { reposRemove.mockReset() reposUpdate.mockReset() reposReorder.mockReset() + reposReorderForHost.mockReset() projectsCreateHostSetup.mockReset() projectsSetupExistingFolder.mockReset() projectsUpdateHostSetup.mockReset() @@ -81,6 +84,8 @@ export function installReposRuntimeRoutingHarness(): void { orcaProfileFindProjectProfiles.mockReset() runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() + uiSet.mockReset() + uiSet.mockResolvedValue(undefined) runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) }) @@ -94,7 +99,8 @@ export function installReposRuntimeRoutingHarness(): void { pickFolder: reposPickFolder, remove: reposRemove, update: reposUpdate, - reorder: reposReorder + reorder: reposReorder, + reorderForHost: reposReorderForHost }, projects: { update: projectsUpdate, @@ -110,7 +116,8 @@ export function installReposRuntimeRoutingHarness(): void { findProjectProfiles: orcaProfileFindProjectProfiles }, pty: { kill: ptyKill }, - runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + ui: { set: uiSet } } }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 165807f27f8..476092c2c34 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -44,6 +44,7 @@ import { import { isGitRepoKind } from '../../../../shared/repo-kind' import { sanitizeRepoIcon } from '../../../../shared/repo-icon' import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' +import { applyManualRepoOrder, getManualRepoOrder } from '../../../../shared/manual-repo-order' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' @@ -1567,7 +1568,7 @@ export const createRepoSlice: StateCreator = (set, // Drop rows on unknown SSH targets that a live-host sibling supersedes. const result = mergeFetchedRepoCatalog(catalog, s.repos) const reconciliation = reconcileSupersededSshRepos(result.repos, s) - const prunedRepos = reconciliation.repos + const prunedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) const validRepoIds = new Set(prunedRepos.map((repo) => repo.id)) const projectCompatibility = projectCompatibilityForReconciledRepos( prunedRepos, @@ -1617,7 +1618,7 @@ export const createRepoSlice: StateCreator = (set, set((s) => { const result = mergeFetchedRepoCatalog(catalog, s.repos) const reconciliation = reconcileSupersededSshRepos(result.repos, s) - const finalizedRepos = reconciliation.repos + const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) const validRepoIds = new Set(finalizedRepos.map((repo) => repo.id)) const projectCompatibility = projectCompatibilityForReconciledRepos( finalizedRepos, @@ -1683,7 +1684,7 @@ export const createRepoSlice: StateCreator = (set, set((s) => { const result = mergeFetchedRepoCatalog(catalog, s.repos) const reconciliation = reconcileSupersededSshRepos(result.repos, s) - const finalizedRepos = reconciliation.repos + const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) const projectCompatibility = projectCompatibilityForReconciledRepos( finalizedRepos, catalog.projectHostSetupCompatibility @@ -3122,8 +3123,10 @@ export const createRepoSlice: StateCreator = (set, // Caller passed a non-permutation — refuse to apply locally. return } + const manualRepoOrder = getManualRepoOrder(next) set({ repos: next, + manualRepoOrder, folderWorkspacePathStatuses: {} }) try { @@ -3131,23 +3134,31 @@ export const createRepoSlice: StateCreator = (set, // so split the cross-host order into per-host permutations and dispatch one // reorder per owner host. const groups = splitRepoReorderByHost(orderedIds, next, get().settings) - const results = await Promise.all( - groups.map(async (group) => { - const parsed = parseExecutionHostId(group.hostId) - const target = - parsed?.kind === 'runtime' - ? ({ kind: 'environment', environmentId: parsed.environmentId } as const) - : ({ kind: 'local' } as const) - return target.kind === 'local' - ? window.api.repos.reorder({ orderedIds: group.orderedIds }) - : callRuntimeRpc<{ status: 'applied' | 'rejected' }>( - target, - 'repo.reorder', - { orderedIds: group.orderedIds }, - { timeoutMs: 15_000 } - ) - }) - ) + const [results] = await Promise.all([ + Promise.all( + groups.map(async (group) => { + const parsed = parseExecutionHostId(group.hostId) + const target = + parsed?.kind === 'runtime' + ? ({ kind: 'environment', environmentId: parsed.environmentId } as const) + : ({ kind: 'local' } as const) + return target.kind === 'local' + ? window.api.repos.reorderForHost({ + hostId: group.hostId, + orderedIds: group.orderedIds + }) + : callRuntimeRpc<{ status: 'applied' | 'rejected' }>( + target, + 'repo.reorder', + { orderedIds: group.orderedIds }, + { timeoutMs: 15_000 } + ) + }) + ), + // Why: servers can only persist their local permutations. The desktop + // profile owns the cross-host relationships needed after a cold load. + window.api.ui.set({ manualRepoOrder }) + ]) if (results.some((result) => result.status === 'rejected')) { await get().fetchReposForAllHosts() } diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index ed3e16c75b1..5140cdd0e08 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -43,6 +43,7 @@ import { createRuntimeStatusSlice } from './runtime-status' import { createPullRequestGenerationSlice } from './pull-request-generation' import { createCommitMessageGenerationSlice } from './commit-message-generation' import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm' +import { createRecentlyClosedTabsSlice } from './recently-closed-tabs' import { createOrcaProfilesSlice } from './orca-profiles' import { createNewIssueDraftSlice } from './new-issue-draft' import { translate } from '@/i18n/i18n' @@ -92,6 +93,7 @@ export function createTestStore() { ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a), ...createPinnedTabCloseConfirmSlice(...a), + ...createRecentlyClosedTabsSlice(...a), ...createOrcaProfilesSlice(...a), ...createNewIssueDraftSlice(...a) })) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index e127c1973a8..a64a9a2c9e2 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -49,6 +49,7 @@ import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output- import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards' import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' +import { pushClosedTerminalTabSnapshot, pushRecentlyClosedTabKind } from './recently-closed-tabs' import { isClaudeAgent } from '@/lib/agent-status' import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers' @@ -608,6 +609,7 @@ export type TerminalSlice = { opts?: { recordInteraction?: boolean reason?: TerminalTabCloseReason + captureRecentlyClosed?: boolean remoteCloseOwnedByHost?: boolean localPtyTeardownOwnedExternally?: boolean precomputedRetirementPlan?: TerminalTabRetirementPlan @@ -1274,17 +1276,39 @@ export const createTerminalSlice: StateCreator set((s) => { const next = { ...s.tabsByWorktree } + let closedTab: TerminalTab | null = null + let closedWorktreeId: string | null = null for (const wId of Object.keys(next)) { const before = next[wId] - const closingTab = before.find((t) => t.id === tabId) - if (closingTab) { + const closing = before.find((t) => t.id === tabId) + if (closing) { closingWorktreeId = wId + // Why: capture the first-matched tab's snapshot for the Cmd+Shift+T + // reopen stack (see capturedSnapshot below). + if (!closedTab) { + closedTab = closing + closedWorktreeId = wId + } } const after = before.filter((t) => t.id !== tabId) if (after.length !== before.length) { next[wId] = after } } + // Why: only explicit user closes feed the Cmd+Shift+T reopen stack. + // Cleanup and PTY-exit closes must not pollute user undo history. + const capturedSnapshot = + closeReason === 'user' && + opts?.captureRecentlyClosed !== false && + closedTab && + closedWorktreeId + ? { + ...(closedTab.startupCwd ? { startupCwd: closedTab.startupCwd } : {}), + ...(closedTab.shellOverride ? { shellOverride: closedTab.shellOverride } : {}), + ...(closedTab.customTitle ? { customTitle: closedTab.customTitle } : {}), + ...(closedTab.color ? { color: closedTab.color } : {}) + } + : null const nextExpanded = { ...s.expandedPaneByTabId } delete nextExpanded[tabId] const nextCanExpand = { ...s.canExpandPaneByTabId } @@ -1438,7 +1462,21 @@ export const createTerminalSlice: StateCreator cacheTimerByKey: nextCacheTimer, tabBarOrderByWorktree: nextTabBarOrderByWorktree, pendingSnapshotByPtyId: nextSnapshots, - pendingColdRestoreByPtyId: nextColdRestores + pendingColdRestoreByPtyId: nextColdRestores, + ...(capturedSnapshot && closedWorktreeId + ? { + recentlyClosedTerminalTabsByWorktree: pushClosedTerminalTabSnapshot( + s.recentlyClosedTerminalTabsByWorktree, + closedWorktreeId, + capturedSnapshot + ), + recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + closedWorktreeId, + 'terminal' + ) + } + : {}) } }) // Why: sweep live AND retained agent-status entries for this tab — closing diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 64c8f0e4a22..f7fa68279f1 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -7,6 +7,7 @@ import type { JiraIssue, LinearIssue, PersistedUIState, + Repo, TerminalTab, Worktree } from '../../../../shared/types' @@ -725,6 +726,8 @@ describe('createUISlice hydratePersistedUI', () => { expect(createUIStore().getState().visibleWorkspaceHostIds).toBeNull() expect(getDefaultUIState().workspaceHostOrder).toEqual([]) expect(createUIStore().getState().workspaceHostOrder).toEqual([]) + expect(getDefaultUIState().manualRepoOrder).toEqual([]) + expect(createUIStore().getState().manualRepoOrder).toEqual([]) }) it('defaults the persisted active view to terminal', () => { @@ -971,6 +974,41 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().workspaceHostOrder).toEqual(['ssh:win%20vm', 'local']) }) + it('hydrates and immediately applies the manual cross-host repo order', () => { + const store = createUIStore() + const local: Repo = { + id: 'same', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'local' + } + const remote: Repo = { + ...local, + path: '/remote', + displayName: 'Remote', + executionHostId: 'runtime:node-b' + } + store.setState({ repos: [local, remote] }) + + store.getState().hydratePersistedUI( + makePersistedUI({ + manualRepoOrder: [ + { hostId: 'runtime:node-b', repoId: 'same' }, + { hostId: 'invalid' as never, repoId: 'ignored' }, + { hostId: 'local', repoId: 'same' } + ] + }) + ) + + expect(store.getState().manualRepoOrder).toEqual([ + { hostId: 'runtime:node-b', repoId: 'same' }, + { hostId: 'local', repoId: 'same' } + ]) + expect(store.getState().repos).toEqual([remote, local]) + }) + it('falls back to all hosts for invalid persisted workspace host scopes', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 26d87bb201c..4be3cc90b3c 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -12,6 +12,7 @@ import type { GitHubWorkItem, JiraIssue, LinearIssue, + ManualRepoOrderEntry, PersistedTrustedOrcaHooks, PersistedUIState, StatusBarItem, @@ -30,6 +31,10 @@ import type { VisibleWorkspaceHostIds, TopLevelView } from '../../../../shared/types' +import { + applyManualRepoOrder, + normalizeManualRepoOrder +} from '../../../../shared/manual-repo-order' import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' import { DEFAULT_USAGE_PERCENTAGE_DISPLAY, @@ -716,6 +721,7 @@ export type UISlice = { title: string url: string linearIdentifier?: string + linearBranchName?: string } | null /** Why: starting from a task must preserve where provider data came from * separately from the host selected to run the workspace. */ @@ -889,6 +895,7 @@ export type UISlice = { setVisibleWorkspaceHostIds: (ids: VisibleWorkspaceHostIds) => void workspaceHostOrder: WorkspaceHostOrder setWorkspaceHostOrder: (ids: WorkspaceHostOrder) => void + manualRepoOrder: ManualRepoOrderEntry[] hideDefaultBranchWorkspace: boolean setHideDefaultBranchWorkspace: (v: boolean) => void hideAutomationGeneratedWorkspaces: boolean @@ -2060,6 +2067,7 @@ export const createUISlice: StateCreator = (set, get) set({ workspaceHostOrder }) window.api.ui.set({ workspaceHostOrder }).catch(console.error) }, + manualRepoOrder: [], hideDefaultBranchWorkspace: false, setHideDefaultBranchWorkspace: (v) => set({ hideDefaultBranchWorkspace: v }), @@ -2372,6 +2380,8 @@ export const createUISlice: StateCreator = (set, get) hydratePersistedUI: (ui, source = 'sync') => set((s) => { + const manualRepoOrder = normalizeManualRepoOrder(ui.manualRepoOrder) + const orderedRepos = applyManualRepoOrder(s.repos, manualRepoOrder) const validRepoIds = new Set(s.repos.map((repo) => repo.id)) const persistedFilterRepoIds = sanitizePersistedRepoIds(ui.filterRepoIds) // Why: persisted UI from pre-rename builds used sidekick* keys. Read @@ -2474,6 +2484,10 @@ export const createUISlice: StateCreator = (set, get) workspaceHostScope: normalizeExecutionHostScope(ui.workspaceHostScope), visibleWorkspaceHostIds: normalizeHydratedVisibleWorkspaceHostIds(ui), workspaceHostOrder: normalizeExecutionHostOrder(ui.workspaceHostOrder), + manualRepoOrder, + // Why: UI state can arrive after a catalog or from another client; apply + // the desktop-owned overlay immediately instead of waiting for a refetch. + repos: orderedRepos, hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false, hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true, showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 16b25ae9655..3b4e3ac974b 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -42,6 +42,8 @@ export type WorktreeMetaUpdateGuard = (worktree: Worktree | DetectedWorktree | u export type WorktreeMetaUpdateOptions = { shouldApply?: WorktreeMetaUpdateGuard + /** Skip the automatic review refetch when the caller owns an equivalent refresh. */ + suppressHostedReviewRefresh?: boolean } export type WorktreeRenameRequest = { diff --git a/src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts b/src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts new file mode 100644 index 00000000000..0edf89bfb7b --- /dev/null +++ b/src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Worktree } from '../../../../shared/types' +import { routeListingBranchSwitchesThroughGitIdentity } from './worktree-listing-branch-switch' + +function makeWorktree(overrides: Partial & { id: string }): Worktree { + return { + repoId: 'repo1', + path: '/path/wt', + head: 'abc123', + branch: 'refs/heads/feature-one', + isBare: false, + isMainWorktree: false, + displayName: 'feature-one', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +const hasLinkedPR = (worktree: Worktree): boolean => worktree.linkedPR != null +const matchesAnyHost = (): boolean => true + +describe('routeListingBranchSwitchesThroughGitIdentity', () => { + it('routes a listing-observed branch switch through updateWorktreeGitIdentity', () => { + const updateWorktreeGitIdentity = vi.fn() + const current = [makeWorktree({ id: 'repo1::/path/wt1', linkedPR: 101 })] + + routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: current, + current, + incoming: [ + makeWorktree({ + id: 'repo1::/path/wt1', + branch: 'refs/heads/feature-two', + head: 'def456' + }) + ], + matchesRefreshHost: matchesAnyHost, + hasBranchScopedReviewContext: hasLinkedPR, + updateWorktreeGitIdentity + }) + + expect(updateWorktreeGitIdentity).toHaveBeenCalledTimes(1) + expect(updateWorktreeGitIdentity).toHaveBeenCalledWith('repo1::/path/wt1', { + head: 'def456', + branch: 'refs/heads/feature-two' + }) + }) + + it('does nothing when the branch is unchanged', () => { + const updateWorktreeGitIdentity = vi.fn() + const current = [makeWorktree({ id: 'repo1::/path/wt1', linkedPR: 101 })] + + routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: current, + current, + incoming: [ + makeWorktree({ + id: 'repo1::/path/wt1', + branch: 'refs/heads/feature-one', + head: 'def456' + }) + ], + matchesRefreshHost: matchesAnyHost, + hasBranchScopedReviewContext: hasLinkedPR, + updateWorktreeGitIdentity + }) + + expect(updateWorktreeGitIdentity).not.toHaveBeenCalled() + }) + + it('skips entries without branch-scoped review context (stale-refetch protection)', () => { + const updateWorktreeGitIdentity = vi.fn() + const current = [makeWorktree({ id: 'repo1::/path/wt1', linkedPR: null })] + + routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: current, + current, + incoming: [ + makeWorktree({ + id: 'repo1::/path/wt1', + branch: 'refs/heads/feature-two', + head: 'def456' + }) + ], + matchesRefreshHost: matchesAnyHost, + hasBranchScopedReviewContext: hasLinkedPR, + updateWorktreeGitIdentity + }) + + expect(updateWorktreeGitIdentity).not.toHaveBeenCalled() + }) + + it('skips incoming worktrees with no current entry (cold hydration)', () => { + const updateWorktreeGitIdentity = vi.fn() + const current = [makeWorktree({ id: 'repo1::/path/other', linkedPR: 101 })] + + routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: current, + current, + incoming: [ + makeWorktree({ + id: 'repo1::/path/wt1', + branch: 'refs/heads/feature-two', + head: 'def456' + }) + ], + matchesRefreshHost: matchesAnyHost, + hasBranchScopedReviewContext: hasLinkedPR, + updateWorktreeGitIdentity + }) + + expect(updateWorktreeGitIdentity).not.toHaveBeenCalled() + }) + + it('does nothing when there is no current list', () => { + const updateWorktreeGitIdentity = vi.fn() + + routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: undefined, + current: undefined, + incoming: [ + makeWorktree({ + id: 'repo1::/path/wt1', + branch: 'refs/heads/feature-two', + head: 'def456' + }) + ], + matchesRefreshHost: matchesAnyHost, + hasBranchScopedReviewContext: hasLinkedPR, + updateWorktreeGitIdentity + }) + + expect(updateWorktreeGitIdentity).not.toHaveBeenCalled() + }) + + it('maps an empty listing branch to the explicit detached-HEAD signal', () => { + const updateWorktreeGitIdentity = vi.fn() + const current = [makeWorktree({ id: 'repo1::/path/wt1', linkedPR: 101 })] + + routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: current, + current, + incoming: [makeWorktree({ id: 'repo1::/path/wt1', branch: '', head: 'def456' })], + matchesRefreshHost: matchesAnyHost, + hasBranchScopedReviewContext: hasLinkedPR, + updateWorktreeGitIdentity + }) + + expect(updateWorktreeGitIdentity).toHaveBeenCalledWith('repo1::/path/wt1', { + head: 'def456', + branch: null + }) + }) + + it('rejects a listing response when the branch changed again after the request started', () => { + const updateWorktreeGitIdentity = vi.fn() + const current = [ + makeWorktree({ + id: 'repo1::/path/wt1', + branch: 'refs/heads/feature-three', + head: 'newest-head', + linkedPR: 303 + }) + ] + + const reconciled = routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: [ + makeWorktree({ + id: 'repo1::/path/wt1', + branch: 'refs/heads/feature-one', + linkedPR: 101 + }) + ], + current, + incoming: [ + makeWorktree({ + id: 'repo1::/path/wt1', + branch: 'refs/heads/feature-two', + head: 'stale-head' + }) + ], + matchesRefreshHost: matchesAnyHost, + hasBranchScopedReviewContext: hasLinkedPR, + updateWorktreeGitIdentity + }) + + expect(updateWorktreeGitIdentity).not.toHaveBeenCalled() + expect(reconciled).toEqual(current) + }) + + it('preserves a manual relink made while the listing request was in flight', () => { + const updateWorktreeGitIdentity = vi.fn() + const requestStarted = [makeWorktree({ id: 'repo1::/path/wt1', linkedPR: 101 })] + const current = [makeWorktree({ id: 'repo1::/path/wt1', linkedPR: 303 })] + + const reconciled = routeListingBranchSwitchesThroughGitIdentity({ + requestStarted, + current, + incoming: [ + makeWorktree({ + id: 'repo1::/path/wt1', + branch: 'refs/heads/feature-two', + head: 'stale-head', + linkedPR: 101 + }) + ], + matchesRefreshHost: matchesAnyHost, + hasBranchScopedReviewContext: hasLinkedPR, + updateWorktreeGitIdentity + }) + + expect(updateWorktreeGitIdentity).not.toHaveBeenCalled() + expect(reconciled).toEqual(current) + }) + + it('fails closed when the same worktree id belongs to multiple execution hosts', () => { + const updateWorktreeGitIdentity = vi.fn() + const requestStarted = [ + makeWorktree({ id: 'repo1::/same/path', hostId: 'local', linkedPR: 101 }), + makeWorktree({ id: 'repo1::/same/path', hostId: 'ssh:ssh-1', linkedPR: 202 }) + ] + + routeListingBranchSwitchesThroughGitIdentity({ + requestStarted, + current: requestStarted, + incoming: [ + makeWorktree({ + id: 'repo1::/same/path', + branch: 'refs/heads/feature-two', + head: 'remote-head' + }) + ], + matchesRefreshHost: matchesAnyHost, + hasBranchScopedReviewContext: hasLinkedPR, + updateWorktreeGitIdentity + }) + + expect(updateWorktreeGitIdentity).not.toHaveBeenCalled() + }) + + it('does not route an old-host response through a row now owned by another host', () => { + const updateWorktreeGitIdentity = vi.fn() + + routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: [ + makeWorktree({ id: 'repo1::/same/path', hostId: 'ssh:ssh-1', linkedPR: 101 }) + ], + current: [makeWorktree({ id: 'repo1::/same/path', hostId: 'ssh:ssh-2', linkedPR: 202 })], + incoming: [ + makeWorktree({ + id: 'repo1::/same/path', + hostId: 'ssh:ssh-1', + branch: 'refs/heads/feature-two', + head: 'old-host-head' + }) + ], + matchesRefreshHost: (worktree) => worktree.hostId === 'ssh:ssh-1', + hasBranchScopedReviewContext: hasLinkedPR, + updateWorktreeGitIdentity + }) + + expect(updateWorktreeGitIdentity).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/worktree-listing-branch-switch.ts b/src/renderer/src/store/slices/worktree-listing-branch-switch.ts new file mode 100644 index 00000000000..3782463668c --- /dev/null +++ b/src/renderer/src/store/slices/worktree-listing-branch-switch.ts @@ -0,0 +1,107 @@ +import type { Worktree } from '../../../../shared/types' + +function indexUnambiguousWorktrees( + worktrees: readonly Worktree[], + include?: (worktree: Worktree) => boolean +): Map { + const byId = new Map() + for (const worktree of worktrees) { + if (include && !include(worktree)) { + continue + } + byId.set(worktree.id, byId.has(worktree.id) ? null : worktree) + } + return byId +} + +function branchScopedReviewContextMatches(left: Worktree, right: Worktree): boolean { + return ( + left.linkedPR === right.linkedPR && + left.linkedGitLabMR === right.linkedGitLabMR && + left.linkedBitbucketPR === right.linkedBitbucketPR && + left.linkedAzureDevOpsPR === right.linkedAzureDevOpsPR && + left.linkedGiteaPR === right.linkedGiteaPR && + left.pushTarget?.remoteName === right.pushTarget?.remoteName && + left.pushTarget?.branchName === right.pushTarget?.branchName + ) +} + +/** + * Route branch switches observed by a worktree-listing refresh through + * `updateWorktreeGitIdentity` before the listing is merged into the store. + * + * Why: a terminal branch switch is observed by two independent refresh paths — + * the git-status identity path (which clears branch-scoped review links) and + * the worktree-listing path (which rehydrates persisted metadata, including a + * now-stale linked PR, alongside the new branch). If the listing merge lands + * first, the identity path sees no branch change and never clears, leaving + * Checks pinned to the previous branch's PR. Applying the identity update + * first makes the link clear happen no matter which path wins the race. + * + * Only entries that still carry branch-scoped review context are routed: when + * there is nothing to clear the plain merge already applies the new branch, + * and skipping the rest preserves the stale-refetch protection where an old + * listing row must not roll back a newer branch identity. + */ +export function routeListingBranchSwitchesThroughGitIdentity(args: { + requestStarted: readonly Worktree[] | undefined + current: readonly Worktree[] | undefined + incoming: Worktree[] + matchesRefreshHost: (worktree: Worktree) => boolean + hasBranchScopedReviewContext: (worktree: Worktree) => boolean + updateWorktreeGitIdentity: ( + worktreeId: string, + identity: { head?: string; branch?: string | null } + ) => void +}): Worktree[] { + const { + requestStarted, + current, + incoming, + matchesRefreshHost, + hasBranchScopedReviewContext, + updateWorktreeGitIdentity + } = args + if (!requestStarted?.length || !current?.length) { + return incoming + } + + const allLatestById = indexUnambiguousWorktrees(current) + const startedById = indexUnambiguousWorktrees(requestStarted, matchesRefreshHost) + const latestById = indexUnambiguousWorktrees(current, matchesRefreshHost) + let reconciled: Worktree[] | null = null + for (const [index, worktree] of incoming.entries()) { + const requestStartedWorktree = startedById.get(worktree.id) + const existing = latestById.get(worktree.id) + const isAmbiguousAcrossHosts = allLatestById.get(worktree.id) === null + if ( + requestStartedWorktree && + existing && + (existing.branch !== requestStartedWorktree.branch || + existing.head !== requestStartedWorktree.head || + !branchScopedReviewContextMatches(existing, requestStartedWorktree)) + ) { + // Why: rejecting the clear alone is insufficient; preserve the newer row + // so the subsequent listing merge cannot roll its branch and metadata back. + reconciled ??= [...incoming] + reconciled[index] = existing + continue + } + if ( + isAmbiguousAcrossHosts || + !requestStartedWorktree || + !existing || + existing.branch === worktree.branch || + !hasBranchScopedReviewContext(existing) + ) { + continue + } + updateWorktreeGitIdentity(worktree.id, { + head: worktree.head, + // Empty branch means detached HEAD in listing results; null is the + // explicit detached signal expected by updateWorktreeGitIdentity. + branch: worktree.branch === '' ? null : worktree.branch + }) + } + return reconciled ?? incoming +} diff --git a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts index fc00b3082ac..d379a8ec895 100644 --- a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts +++ b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts @@ -121,6 +121,11 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously [WT1]: [makeOpenFile({ id: '/path/wt1/closed.ts', worktreeId: WT1 })], [WT2]: [makeOpenFile({ id: '/path/wt2/closed.ts', worktreeId: WT2 })] }, + recentlyClosedTerminalTabsByWorktree: { + [WT1]: [{ startupCwd: '/path/wt1' }], + [WT2]: [{ startupCwd: '/path/wt2' }] + }, + recentlyClosedTabKindsByWorktree: { [WT1]: ['terminal'], [WT2]: ['terminal'] }, defaultTerminalTabsAppliedByWorktreeId: { [WT1]: true, [WT2]: true } }) } @@ -135,10 +140,14 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously // Evicted for the removed worktree. expect(s.remoteStatusesByWorktree[WT1]).toBeUndefined() expect(s.recentlyClosedEditorTabsByWorktree[WT1]).toBeUndefined() + expect(s.recentlyClosedTerminalTabsByWorktree[WT1]).toBeUndefined() + expect(s.recentlyClosedTabKindsByWorktree[WT1]).toBeUndefined() expect(s.defaultTerminalTabsAppliedByWorktreeId[WT1]).toBeUndefined() // Retained for the surviving worktree (guard over-eviction). expect(s.remoteStatusesByWorktree[WT2]).toBeDefined() expect(s.recentlyClosedEditorTabsByWorktree[WT2]).toBeDefined() + expect(s.recentlyClosedTerminalTabsByWorktree[WT2]).toBeDefined() + expect(s.recentlyClosedTabKindsByWorktree[WT2]).toBeDefined() expect(s.defaultTerminalTabsAppliedByWorktreeId[WT2]).toBe(true) }) @@ -152,9 +161,13 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously const s = store.getState() expect(s.remoteStatusesByWorktree[WT1]).toBeUndefined() expect(s.recentlyClosedEditorTabsByWorktree[WT1]).toBeUndefined() + expect(s.recentlyClosedTerminalTabsByWorktree[WT1]).toBeUndefined() + expect(s.recentlyClosedTabKindsByWorktree[WT1]).toBeUndefined() expect(s.defaultTerminalTabsAppliedByWorktreeId[WT1]).toBeUndefined() expect(s.remoteStatusesByWorktree[WT2]).toBeDefined() expect(s.recentlyClosedEditorTabsByWorktree[WT2]).toBeDefined() + expect(s.recentlyClosedTerminalTabsByWorktree[WT2]).toBeDefined() + expect(s.recentlyClosedTabKindsByWorktree[WT2]).toBeDefined() expect(s.defaultTerminalTabsAppliedByWorktreeId[WT2]).toBe(true) }) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 71133b4af3a..3b148562442 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -409,6 +409,88 @@ describe('fetchWorktrees', () => { expect(store.getState().sortEpoch).toBe(8) }) + it('clears branch-scoped linked reviews when the listing observes a branch switch', async () => { + const store = createTestStore() + const existing = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/feature-one', + linkedPR: 101, + pushTarget: { remoteName: 'origin', branchName: 'feature-one' } + }) + // Persisted metadata still carries the stale link when the listing refresh + // is the first path to observe the terminal branch switch. + const refreshed = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/feature-two', + head: 'def456', + linkedPR: 101, + pushTarget: { remoteName: 'origin', branchName: 'feature-one' } + }) + + mockApi.worktrees.list.mockResolvedValue([refreshed]) + store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial) + + await store.getState().fetchWorktrees('repo1') + await Promise.resolve() + await Promise.resolve() + + expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ + branch: 'refs/heads/feature-two', + head: 'def456', + linkedPR: null, + pushTarget: undefined + }) + expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ + worktreeId: 'repo1::/path/wt1', + updates: expect.objectContaining({ linkedPR: null, pushTarget: undefined }) + }) + }) + + it('does not merge a stale listing row over a newer branch and review link', async () => { + const store = createTestStore() + const worktreeId = 'repo1::/path/wt1' + const requestStarted = makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/feature-one', + head: 'first-head', + linkedPR: 101 + }) + const staleResponse = makeWorktree({ + ...requestStarted, + branch: 'refs/heads/feature-two', + head: 'stale-head', + linkedPR: 202 + }) + let resolveListing!: (worktrees: Worktree[]) => void + const listing = new Promise((resolve) => { + resolveListing = resolve + }) + worktreeListMock.mockReturnValueOnce(listing) + store.setState({ worktreesByRepo: { repo1: [requestStarted] } } as Partial) + + const refresh = store.getState().fetchWorktrees('repo1') + await vi.waitFor(() => expect(worktreeListMock).toHaveBeenCalledTimes(1)) + const latest = makeWorktree({ + ...requestStarted, + branch: 'refs/heads/feature-three', + head: 'latest-head', + linkedPR: 303 + }) + store.setState({ worktreesByRepo: { repo1: [latest] } } as Partial) + resolveListing([staleResponse]) + + await refresh + + expect(store.getState().worktreesByRepo.repo1).toEqual([latest]) + expect(mockApi.worktrees.updateMeta).not.toHaveBeenCalled() + }) + it('updates the repo entry when only the persisted base ref changes', async () => { const store = createTestStore() const existing = makeWorktree({ @@ -4392,6 +4474,40 @@ describe('worktree remote runtime mutations', () => { expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toBeUndefined() }) + it('skips duplicate hosted-review work when the unlinking caller owns the refresh', async () => { + const store = createTestStore() + const fetchHostedReviewForBranch = vi.fn().mockResolvedValue(null) + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/review-branch', + linkedPR: 2548, + pushTarget: { remoteName: 'fork', branchName: 'owner/old-pr' } + }) + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { repo1: [wt] }, + fetchHostedReviewForBranch + } as Partial) + + await store + .getState() + .updateWorktreeMeta(wt.id, { linkedPR: null }, { suppressHostedReviewRefresh: true }) + + expect(fetchHostedReviewForBranch).not.toHaveBeenCalled() + expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ + worktreeId: wt.id, + updates: { linkedPR: null, pushTarget: undefined } + }) + expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ + linkedPR: null, + pushTarget: undefined + }) + }) + it('clears an older GitHub link and target when replacing it with a GitLab MR', async () => { const store = createTestStore() const oldPushTarget = { remoteName: 'fork', branchName: 'owner/old-pr' } @@ -6570,12 +6686,13 @@ describe('setWorktreesPinnedAndReveal', () => { resetRemoteRuntimeMocks() }) - it('pins a worktree and reveals it so the viewport follows it into the Pinned section', () => { + it('pins the focused worktree and reveals it so the viewport follows it into the Pinned section', () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: false }) const reveal = vi.fn() store.setState({ worktreesByRepo: { repo1: [wt] }, + activeWorktreeId: wt.id, revealWorktreeInSidebar: reveal } as Partial) @@ -6585,12 +6702,13 @@ describe('setWorktreesPinnedAndReveal', () => { expect(reveal).toHaveBeenCalledWith(wt.id, { behavior: 'smooth', highlight: true }) }) - it('reveals on unpin so the viewport follows the row back to its status group', () => { + it('reveals on unpin of the focused worktree so the viewport follows it back to its status group', () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: true }) const reveal = vi.fn() store.setState({ worktreesByRepo: { repo1: [wt] }, + activeWorktreeId: wt.id, revealWorktreeInSidebar: reveal } as Partial) @@ -6600,6 +6718,41 @@ describe('setWorktreesPinnedAndReveal', () => { expect(reveal).toHaveBeenCalledWith(wt.id, { behavior: 'smooth', highlight: true }) }) + it('does not scroll when unpinning an unfocused worktree, but still unpins it', () => { + const store = createTestStore() + const focused = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: false }) + const pinned = makeWorktree({ id: 'repo1::/b', repoId: 'repo1', path: '/b', isPinned: true }) + const reveal = vi.fn() + store.setState({ + worktreesByRepo: { repo1: [focused, pinned] }, + activeWorktreeId: focused.id, + revealWorktreeInSidebar: reveal + } as Partial) + + store.getState().setWorktreesPinnedAndReveal([pinned.id], false) + + // The row unpins, but the viewport stays put because it isn't focused. + expect(store.getState().worktreesByRepo.repo1[1].isPinned).toBe(false) + expect(reveal).not.toHaveBeenCalled() + }) + + it('does not scroll when pinning an unfocused worktree, but still pins it', () => { + const store = createTestStore() + const focused = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: false }) + const other = makeWorktree({ id: 'repo1::/b', repoId: 'repo1', path: '/b', isPinned: false }) + const reveal = vi.fn() + store.setState({ + worktreesByRepo: { repo1: [focused, other] }, + activeWorktreeId: focused.id, + revealWorktreeInSidebar: reveal + } as Partial) + + store.getState().setWorktreesPinnedAndReveal([other.id], true) + + expect(store.getState().worktreesByRepo.repo1[1].isPinned).toBe(true) + expect(reveal).not.toHaveBeenCalled() + }) + it('skips a no-op toggle without requesting a reveal', () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: true }) @@ -6643,7 +6796,7 @@ describe('setWorktreesPinnedAndReveal', () => { expect(reveal).not.toHaveBeenCalled() }) - it('reveals only the first newly-pinned worktree when pinning several at once', () => { + it('pins several at once and reveals the focused row even when it is not first', () => { const store = createTestStore() const alreadyPinned = makeWorktree({ id: 'repo1::/a', @@ -6652,23 +6805,49 @@ describe('setWorktreesPinnedAndReveal', () => { isPinned: true }) const first = makeWorktree({ id: 'repo1::/b', repoId: 'repo1', path: '/b', isPinned: false }) - const second = makeWorktree({ id: 'repo1::/c', repoId: 'repo1', path: '/c', isPinned: false }) + const focused = makeWorktree({ id: 'repo1::/c', repoId: 'repo1', path: '/c', isPinned: false }) const reveal = vi.fn() store.setState({ - worktreesByRepo: { repo1: [alreadyPinned, first, second] }, + worktreesByRepo: { repo1: [alreadyPinned, first, focused] }, + activeWorktreeId: focused.id, revealWorktreeInSidebar: reveal } as Partial) - store.getState().setWorktreesPinnedAndReveal([alreadyPinned.id, first.id, second.id], true) + store.getState().setWorktreesPinnedAndReveal([alreadyPinned.id, first.id, focused.id], true) + // Only the focused row is revealed, not the first-changed one. expect(reveal).toHaveBeenCalledTimes(1) - expect(reveal).toHaveBeenCalledWith(first.id, { behavior: 'smooth', highlight: true }) + expect(reveal).toHaveBeenCalledWith(focused.id, { behavior: 'smooth', highlight: true }) // Every targeted row is pinned, not just the revealed one, and the // already-pinned row is left untouched. expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(true) expect(store.getState().worktreesByRepo.repo1[1].isPinned).toBe(true) expect(store.getState().worktreesByRepo.repo1[2].isPinned).toBe(true) }) + + it('pins several at once without scrolling when none of them are focused', () => { + const store = createTestStore() + const first = makeWorktree({ id: 'repo1::/b', repoId: 'repo1', path: '/b', isPinned: false }) + const second = makeWorktree({ id: 'repo1::/c', repoId: 'repo1', path: '/c', isPinned: false }) + const elsewhere = makeWorktree({ + id: 'repo1::/z', + repoId: 'repo1', + path: '/z', + isPinned: false + }) + const reveal = vi.fn() + store.setState({ + worktreesByRepo: { repo1: [first, second, elsewhere] }, + activeWorktreeId: elsewhere.id, + revealWorktreeInSidebar: reveal + } as Partial) + + store.getState().setWorktreesPinnedAndReveal([first.id, second.id], true) + + expect(reveal).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(true) + expect(store.getState().worktreesByRepo.repo1[1].isPinned).toBe(true) + }) }) describe('migrateWorktreeIdentity', () => { @@ -6720,6 +6899,10 @@ describe('migrateWorktreeIdentity', () => { lastVisitedAtByWorktreeId: { [OLD]: 123 }, defaultTerminalTabsAppliedByWorktreeId: { [OLD]: true }, recentlyClosedEditorTabsByWorktree: { [OLD]: [{ id: 'f1', worktreeId: OLD }] }, + recentlyClosedTerminalTabsByWorktree: { + [OLD]: [{ startupCwd: '/ws/cunner/packages/app' }, { startupCwd: '/elsewhere/dir' }] + }, + recentlyClosedTabKindsByWorktree: { [OLD]: ['terminal', 'editor'] }, remoteStatusesByWorktree: { [OLD]: { ahead: 1 } }, everActivatedWorktreeIds: new Set([OLD]), openFiles: [{ id: 'f1', worktreeId: OLD }], @@ -6766,6 +6949,15 @@ describe('migrateWorktreeIdentity', () => { expect(s.defaultTerminalTabsAppliedByWorktreeId[NEW]).toBe(true) // The two maps absent from the purge list are still re-keyed. expect(s.recentlyClosedEditorTabsByWorktree[NEW]).toEqual([{ id: 'f1', worktreeId: NEW }]) + // Terminal reopen snapshots re-key AND remap startupCwd under the old + // worktree path; paths outside the renamed folder stay as-is. + expect(s.recentlyClosedTerminalTabsByWorktree[OLD]).toBeUndefined() + expect(s.recentlyClosedTerminalTabsByWorktree[NEW]).toEqual([ + { startupCwd: '/ws/worktree-creation-spinner/packages/app' }, + { startupCwd: '/elsewhere/dir' } + ]) + expect(s.recentlyClosedTabKindsByWorktree[OLD]).toBeUndefined() + expect(s.recentlyClosedTabKindsByWorktree[NEW]).toEqual(['terminal', 'editor']) expect(s.remoteStatusesByWorktree[NEW]).toEqual({ ahead: 1 }) expect(s.everActivatedWorktreeIds.has(NEW)).toBe(true) expect(s.everActivatedWorktreeIds.has(OLD)).toBe(false) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 1f15f2217d5..5541ad0ef92 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -25,6 +25,11 @@ import { getRepoIdFromWorktreeId, type WorktreeSlice } from './worktree-helpers' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import { + remapClosedTerminalTabSnapshotCwds, + type ClosedTerminalTabSnapshot +} from './recently-closed-tabs' import { findRepoForHost } from './repo-host-identity' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' @@ -38,6 +43,7 @@ import { } from '../../runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { getHostedReviewCacheKey, refreshHostedReviewCard } from './hosted-review' +import { routeListingBranchSwitchesThroughGitIdentity } from './worktree-listing-branch-switch' import { isPositiveHostedReviewNumber } from '../../../../shared/hosted-review' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' import { moveFocusToRendererBeforeFocusedWebviewHidden } from './browser-webview-cleanup' @@ -67,6 +73,7 @@ import { import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { folderWorkspaceKey, + getActiveSidebarWorkspaceId, isWorkspaceKey, parseWorkspaceKey, worktreeWorkspaceKey @@ -1763,7 +1770,8 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [ 'showDotfilesByWorktree', 'expandedDirs', 'lastVisitedAtByWorktreeId', - 'defaultTerminalTabsAppliedByWorktreeId' + 'defaultTerminalTabsAppliedByWorktreeId', + 'recentlyClosedTabKindsByWorktree' ] as const satisfies readonly (keyof AppState)[] /** @@ -1824,6 +1832,17 @@ function buildWorktreeRenameState( renameKey('recentlyClosedEditorTabsByWorktree', (files: { worktreeId: string }[]) => files.map(withNewWorktreeId) ) + // Why: terminal reopen snapshots carry absolute startupCwd paths under the + // old worktree folder; remap them or Cmd+Shift+T respawns into a directory + // that no longer exists after the rename. Paths outside the renamed folder + // are untouched by the move and stay valid as-is. + const oldWorktreePath = splitWorktreeIdForFilesystem(oldWorktreeId)?.worktreePath + const newWorktreePath = splitWorktreeIdForFilesystem(newWorktreeId)?.worktreePath + renameKey('recentlyClosedTerminalTabsByWorktree', (snapshots: ClosedTerminalTabSnapshot[]) => + oldWorktreePath && newWorktreePath + ? remapClosedTerminalTabSnapshotCwds(snapshots, oldWorktreePath, newWorktreePath) + : snapshots + ) renameKey('remoteStatusesByWorktree') const openFiles = s.openFiles?.some((f) => f.worktreeId === oldWorktreeId) @@ -2241,6 +2260,8 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial fetchWorktrees: async (repoId, options) => { try { const ownerState = get() + const requestStartedWorktrees = ownerState.worktreesByRepo[repoId] const hostId = repoHostId(ownerState, repoId) const ownerWasMissingAtStart = !ownerState.repos.some((repo) => repo.id === repoId) const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId) @@ -2337,11 +2359,21 @@ export const createWorktreeSlice: StateCreator if (options?.requireAuthoritative && !detected.authoritative) { return false } + let incoming = toVisibleWorktrees(detected, hostId, setup) + const latestState = get() + if (repoHasExecutionHost(latestState, repoId, hostId, ownerWasMissingAtStart)) { + const matchOptions = worktreeHostMatchOptions(latestState, repoId, hostId) + incoming = routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: requestStartedWorktrees, + current: latestState.worktreesByRepo[repoId], + incoming, + matchesRefreshHost: (worktree) => worktreeMatchesHost(worktree, hostId, matchOptions), + hasBranchScopedReviewContext: hasBranchScopedHostedReviewContext, + updateWorktreeGitIdentity: latestState.updateWorktreeGitIdentity + }) + } const current = get().worktreesByRepo[repoId] - const worktrees = sanitizeHostedReviewLinksForBranchClears( - toVisibleWorktrees(detected, hostId, setup), - current - ) + const worktrees = sanitizeHostedReviewLinksForBranchClears(incoming, current) const currentMatchOptions = worktreeHostMatchOptions(get(), repoId, hostId) const currentForHost = (current ?? []).filter((worktree) => worktreeMatchesHost(worktree, hostId, currentMatchOptions) @@ -2477,15 +2509,30 @@ export const createWorktreeSlice: StateCreator if (get().hasHydratedWorktreePurge) { await mapReposForWorktreeRefresh(repos, async (r) => { try { + const requestStartedState = get() + const requestStartedWorktrees = requestStartedState.worktreesByRepo[r.id] const hostId = getRepoExecutionHostId(r) - const setup = getProjectHostSetupForRepoHost(get(), r.id, hostId) - const settings = settingsForKnownRepoOwner(get().settings, r) + const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) + const settings = settingsForKnownRepoOwner(requestStartedState.settings, r) const detected = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { executionHostId: hostId, reuseRecentCompatibilityFailure: true }) + let incoming = toVisibleWorktrees(detected, hostId, setup) + const latestState = get() + if (repoHasExecutionHost(latestState, r.id, hostId, false)) { + const matchOptions = worktreeHostMatchOptions(latestState, r.id, hostId) + incoming = routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: requestStartedWorktrees, + current: latestState.worktreesByRepo[r.id], + incoming, + matchesRefreshHost: (worktree) => worktreeMatchesHost(worktree, hostId, matchOptions), + hasBranchScopedReviewContext: hasBranchScopedHostedReviewContext, + updateWorktreeGitIdentity: latestState.updateWorktreeGitIdentity + }) + } const worktrees = sanitizeHostedReviewLinksForBranchClears( - toVisibleWorktrees(detected, hostId, setup), + incoming, get().worktreesByRepo[r.id] ) set((s) => { @@ -2556,18 +2603,30 @@ export const createWorktreeSlice: StateCreator | { repoId: string; ok: false } > => { try { + const requestStartedState = get() + const requestStartedWorktrees = requestStartedState.worktreesByRepo[r.id] const hostId = getRepoExecutionHostId(r) - const setup = getProjectHostSetupForRepoHost(get(), r.id, hostId) + const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) const detected = await listDetectedWorktreesForRepoCoalesced( - settingsForKnownRepoOwner(get().settings, r), + settingsForKnownRepoOwner(requestStartedState.settings, r), r.id, { executionHostId: hostId, reuseRecentCompatibilityFailure: true } ) + let incoming = toVisibleWorktrees(detected, hostId, setup) + const latestState = get() + if (repoHasExecutionHost(latestState, r.id, hostId, false)) { + const matchOptions = worktreeHostMatchOptions(latestState, r.id, hostId) + incoming = routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: requestStartedWorktrees, + current: latestState.worktreesByRepo[r.id], + incoming, + matchesRefreshHost: (worktree) => worktreeMatchesHost(worktree, hostId, matchOptions), + hasBranchScopedReviewContext: hasBranchScopedHostedReviewContext, + updateWorktreeGitIdentity: latestState.updateWorktreeGitIdentity + }) + } const current = get().worktreesByRepo[r.id] - const list = sanitizeHostedReviewLinksForBranchClears( - toVisibleWorktrees(detected, hostId, setup), - current - ) + const list = sanitizeHostedReviewLinksForBranchClears(incoming, current) const currentMatchOptions = worktreeHostMatchOptions(get(), r.id, hostId) const currentForHost = (current ?? []).filter((worktree) => worktreeMatchesHost(worktree, hostId, currentMatchOptions) @@ -3559,6 +3618,18 @@ export const createWorktreeSlice: StateCreator delete next[worktreeId] return next })(), + recentlyClosedTerminalTabsByWorktree: (() => { + const next = { ...s.recentlyClosedTerminalTabsByWorktree } + delete next[worktreeId] + return next + })(), + // Why: a deleted worktree's tabs can never be reopened, so purge the + // cross-type kind list symmetrically with the snapshot stacks above. + recentlyClosedTabKindsByWorktree: (() => { + const next = { ...s.recentlyClosedTabKindsByWorktree } + delete next[worktreeId] + return next + })(), defaultTerminalTabsAppliedByWorktreeId: (() => { const next = { ...s.defaultTerminalTabsAppliedByWorktreeId } delete next[worktreeId] @@ -3928,7 +3999,12 @@ export const createWorktreeSlice: StateCreator try { await persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, enriched) - if (reviewRepo && reviewBranch && typeof get().fetchHostedReviewForBranch === 'function') { + if ( + !options?.suppressHostedReviewRefresh && + reviewRepo && + reviewBranch && + typeof get().fetchHostedReviewForBranch === 'function' + ) { // Why: the old cache entry may have been populated by the previous // provider link. Refetch against the post-update links so stale lookups // cannot keep showing the removed review. @@ -4053,33 +4129,44 @@ export const createWorktreeSlice: StateCreator }, setWorktreesPinnedAndReveal: (worktreeIds, isPinned) => { + // Only follow a toggled row with the viewport when it's the focused + // worktree; pinning/unpinning an unfocused card shouldn't yank the user's + // scroll to a row they aren't looking at. + const activeSidebarWorktreeId = getActiveSidebarWorkspaceId( + get().activeWorkspaceKey, + get().activeWorktreeId + ) // Skip worktrees already in the target state so a no-op toggle doesn't // scroll the viewport away from where the user is. const updates = new Map>() + let didChange = false let revealWorktreeId: string | null = null for (const worktreeId of worktreeIds) { const current = get().getKnownWorktreeById(worktreeId) if (!current || current.isPinned === isPinned) { continue } + didChange = true const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { void get().updateWorktreeMeta(worktreeId, { isPinned }) } else { updates.set(worktreeId, { isPinned }) } - if (revealWorktreeId === null) { + if (revealWorktreeId === null && worktreeId === activeSidebarWorktreeId) { revealWorktreeId = worktreeId } } - if (revealWorktreeId === null) { + if (!didChange) { return } // updateWorktreesMeta applies its store update synchronously (only the // persistence is async), so the reveal below resolves against a render // where the shortcut row already exists. void get().updateWorktreesMeta(updates) - get().revealWorktreeInSidebar(revealWorktreeId, { behavior: 'smooth', highlight: true }) + if (revealWorktreeId !== null) { + get().revealWorktreeInSidebar(revealWorktreeId, { behavior: 'smooth', highlight: true }) + } }, markWorktreeUnread: (worktreeId) => { diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index be1a2f191ca..0dd192c0266 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -33,6 +33,7 @@ import type { RuntimeStatusSlice } from './slices/runtime-status' import type { PullRequestGenerationSlice } from './slices/pull-request-generation' import type { CommitMessageGenerationSlice } from './slices/commit-message-generation' import type { PinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm' +import type { RecentlyClosedTabsSlice } from './slices/recently-closed-tabs' import type { OrcaProfilesSlice } from './slices/orca-profiles' import type { NewIssueDraftSlice } from './slices/new-issue-draft' @@ -71,5 +72,6 @@ export type AppState = RepoSlice & PullRequestGenerationSlice & CommitMessageGenerationSlice & PinnedTabCloseConfirmSlice & + RecentlyClosedTabsSlice & OrcaProfilesSlice & NewIssueDraftSlice diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index ba8fefd905d..bb77e236218 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -1780,6 +1780,14 @@ describe('web repos preload API', () => { vi.doUnmock('./web-runtime-client') }) + it('rejects desktop host-scoped reorders in paired web clients', async () => { + const { api } = await installApi('Linux') + + await expect( + api.repos.reorderForHost({ hostId: 'ssh:target', orderedIds: ['repo-1'] }) + ).rejects.toThrow('Host-scoped project reordering is unavailable in paired web clients.') + }) + it.each([ ['/home/alice', '/home/alice/orca/projects'], ['/', '/orca/projects'], @@ -2513,13 +2521,13 @@ describe('web GitHub preload API', () => { }, { key: 'listWorkItems', - args: { repoPath, limit: 20, query: 'is:pr', before: 'cursor', noCache: true }, + args: { repoPath, limit: 20, query: 'is:pr', page: 2, noCache: true }, expectedMethod: 'github.listWorkItems', expectedParams: withRepo({ repoPath, limit: 20, query: 'is:pr', - before: 'cursor', + page: 2, noCache: true }) }, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d9d22e13093..d6f6d6e5830 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -35,6 +35,7 @@ import type { WorkspaceSessionState } from '../../../shared/types' import type { SkillDiscoveryResult } from '../../../shared/skills' +import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types' import { getDefaultOnboardingState, @@ -66,6 +67,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector' +import { callAbortableRuntimeEnvironment } from '../runtime/abortable-runtime-environment-call' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' import { normalizeTuiAgentArgsRecord, @@ -649,6 +651,8 @@ function createWebPreloadApi(): Partial { }) ) }, + // localStorage writes synchronously, so there is no deferred web flush. + flush: async () => {}, readTerminalScrollback: () => null, setSync: (session, hostId) => { writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) @@ -1087,41 +1091,114 @@ function createNativeChatApi(): NativeChatApi { limit, transcriptPath }), - subscribe: (args, onAppended) => { + subscribe: (args, onFrame) => { // No paired runtime yet: nothing to subscribe to, and // requireActiveEnvironment() would throw. Return a no-op teardown so the // chat view mounts cleanly until a runtime is paired (only the not-paired // case is swallowed — real subscribe errors still surface via .catch). const environment = requireActiveEnvironmentOrNull() if (!environment) { + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: translate( + 'components.native-chat.state.pairHost', + 'Pair a host to view agent chat history.' + ) + }) return () => {} } let handle: { unsubscribe: () => void } | null = null let cancelled = false + let receivedInitial = false void getClientForEnvironment(environment) .subscribe( 'nativeChat.subscribe', - { agent: args.agent, sessionId: args.sessionId, transcriptPath: args.transcriptPath }, + { + agent: args.agent, + sessionId: args.sessionId, + subscriptionId: args.subscriptionId, + transcriptPath: args.transcriptPath, + limit: args.limit + }, { onResponse: (response) => { - if (cancelled || !response.ok) { + if (cancelled) { + return + } + if (!response.ok) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: response.error.message + }) + } return } const result = response.result as { type?: string messages?: NativeChatAppendedMessages + hasMore?: boolean + error?: string } - if (result?.type === 'appended' && Array.isArray(result.messages)) { - onAppended(result.messages) + if ( + (result?.type === 'appended' || + result?.type === 'snapshot' || + result?.type === 'replacement') && + Array.isArray(result.messages) + ) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), + ...(result.error ? { error: result.error } : {}) + }) + } else if (result.type === 'snapshot') { + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? false, + ...(result.error ? { error: result.error } : {}) + }) + } else { + onFrame( + result.type === 'replacement' + ? { + type: 'replacement', + messages: result.messages, + hasMore: result.hasMore ?? false + } + : { type: 'appended', messages: result.messages } + ) + } + } else if (!receivedInitial) { + // Why: an ok response whose payload shape we don't recognize would + // otherwise never flip receivedInitial, stranding the view on + // 'loading'. Settle it with an empty snapshot (carrying any error + // the runtime sent) so the UI resolves. + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + ...(result?.error ? { error: result.error } : {}) + }) } } }, { // Why: send nativeChat.unsubscribe on teardown so the server reaps // the transcript fs-watcher on view-toggle, not just on socket close - // (the watcher-leak fix). Uses the same agent:sessionId cleanup token - // mobile sends, via the shared key-builder so it can't drift. - buildUnsubscribe: () => buildNativeChatUnsubscribe(args.agent, args.sessionId) + // (the watcher-leak fix). Echo the pane-specific token so two panes + // watching one session cannot tear down each other's watcher. + buildUnsubscribe: () => + buildNativeChatUnsubscribe(args.agent, args.sessionId, args.subscriptionId) } ) .then((h) => { @@ -1131,7 +1208,17 @@ function createNativeChatApi(): NativeChatApi { handle = h } }) - .catch(() => {}) + .catch((err: unknown) => { + if (!cancelled && !receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: err instanceof Error ? err.message : String(err) + }) + } + }) return () => { cancelled = true handle?.unsubscribe() @@ -1264,6 +1351,11 @@ function createReposApi(): NonNullable['repos']> { throw new Error('Forgetting a host is unavailable in paired web clients.') }, reorder: async ({ orderedIds }) => callRuntimeResult('repo.reorder', { orderedIds }), + // Why: this path persists desktop-owned local or SSH rows. Paired web + // clients own only their single runtime, which uses repo.reorder directly. + reorderForHost: async () => { + throw new Error('Host-scoped project reordering is unavailable in paired web clients.') + }, update: async ({ repoId, updates }) => (await callRuntimeResult<{ repo: Repo }>('repo.update', { repo: repoId, updates })).repo, pickFolder: () => Promise.resolve(null), @@ -1637,14 +1729,64 @@ function createFileApi(): NonNullable['fs']> { } } +// Why: track the in-flight abortable status request per token so cancelStatus +// can abort the matching subscription and close its remote request context. +const webGitStatusAbortControllers = new Map() + +async function callAbortableRuntimeStatus( + requestToken: string, + params: unknown +): Promise { + const environment = requireActiveEnvironment() + webGitStatusAbortControllers.get(requestToken)?.abort() + const controller = new AbortController() + webGitStatusAbortControllers.set(requestToken, controller) + try { + const response = await callAbortableRuntimeEnvironment( + environment.id, + 'git.status', + params, + undefined, + controller.signal + ) + updateEnvironmentFromResponse(environment, response) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult + } finally { + if (webGitStatusAbortControllers.get(requestToken) === controller) { + webGitStatusAbortControllers.delete(requestToken) + } + } +} + function createGitApi(): NonNullable['git']> { return { - status: async ({ worktreePath, includeIgnored }) => { + status: async ({ + worktreePath, + includeIgnored, + bypassEffectiveUpstreamNegativeCache, + reuseLineStats, + requestToken + }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.status', { + const params = { worktree: toRuntimeWorktreeSelector(worktree.id), - includeIgnored - }) + includeIgnored, + bypassEffectiveUpstreamNegativeCache, + reuseLineStats + } + // Why: without a token there's nothing to cancel, so stay on the pooled + // call transport. With one, route through the subscription bridge so + // cancelStatus can close the request context and abort the remote scan. + if (!requestToken) { + return callRuntimeResult('git.status', params) + } + return callAbortableRuntimeStatus(requestToken, params) + }, + cancelStatus: async ({ requestToken }) => { + webGitStatusAbortControllers.get(requestToken)?.abort() }, submoduleStatus: async ({ worktreePath, submodulePath, area }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) @@ -2320,6 +2462,9 @@ function createWebUiApi(): NonNullable['ui']> { }, isMaximized: () => Promise.resolve(false), onOpenSettings: () => noopUnsubscribe, + // Why: the web client has no native tray/menu bar, so there is never a + // queued open-settings intent to consume. + consumePendingOpenSettings: () => Promise.resolve(false), onOpenSetupGuide: () => noopUnsubscribe, onOpenFeatureTour: () => noopUnsubscribe, onOpenCrashReport: () => noopUnsubscribe, @@ -2368,6 +2513,7 @@ function createWebUiApi(): NonNullable['ui']> { onActivateWorktree: () => noopUnsubscribe, onCreateTerminal: () => noopUnsubscribe, onRequestTerminalCreate: () => noopUnsubscribe, + onRequestTerminalTabMount: () => noopUnsubscribe, replyTerminalCreate: () => {}, onSplitTerminal: () => noopUnsubscribe, onRenameTerminal: () => noopUnsubscribe, @@ -2380,6 +2526,8 @@ function createWebUiApi(): NonNullable['ui']> { onMobileMarkdownRequest: () => noopUnsubscribe, respondMobileMarkdownRequest: () => {}, onCloseTerminal: () => noopUnsubscribe, + onTerminalTabCloseRequest: () => noopUnsubscribe, + respondTerminalTabClose: () => {}, onSleepWorktree: () => noopUnsubscribe, // Why: paired web is a full renderer that wakes on activation; mobile wake is // desktop-host-scoped, so the web client never receives this signal. @@ -2596,7 +2744,16 @@ function createSkillsApi(): NonNullable['skills']> { skills: [], sources: [], scannedAt: Date.now() - })) + })), + // Why: browser clients have no local skill homes, and remote-host + // freshness stays disabled until its update rail has equivalent coverage. + freshnessInventory: (): Promise => + Promise.resolve({ + schemaVersion: 1, + installations: [], + eligibleUpdateNames: [], + scannedAt: Date.now() + }) } } @@ -2809,6 +2966,7 @@ function createPtyApi(): NonNullable['pty']> { declarePendingPaneSerializer: () => Promise.resolve(0), settlePaneSerializer: () => Promise.resolve(), clearPendingPaneSerializer: () => Promise.resolve(), + reportRendererSerializerReady: () => Promise.resolve(), management: { listSessions: () => Promise.resolve({ sessions: [], degraded: false }), killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }), diff --git a/src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts b/src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts new file mode 100644 index 00000000000..ee522962669 --- /dev/null +++ b/src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts @@ -0,0 +1,269 @@ +// Regression tests for the reconnect-gap resync of replayed paired-web file +// watches: a replayed files.watch reports changes only from its own native +// setup, so consumers must receive a conservative overflow once it is ready. +import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest' +import { WebRuntimeClient } from './web-runtime-client' +import { encrypt } from '../../../shared/e2ee-crypto' + +const fakeSockets: FakeWebSocket[] = [] + +class FakeWebSocket { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + readyState = FakeWebSocket.CONNECTING + binaryType = 'arraybuffer' + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onclose: (() => void) | null = null + onerror: (() => void) | null = null + close = vi.fn() + send = vi.fn() + + constructor(readonly _url: string) { + fakeSockets.push(this) + } +} + +describe('WebRuntimeClient file-watch replay resync', () => { + beforeEach(() => { + fakeSockets.length = 0 + vi.stubGlobal('window', { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + atob: (value: string) => Buffer.from(value, 'base64').toString('binary'), + btoa: (value: string) => Buffer.from(value, 'binary').toString('base64') + }) + vi.stubGlobal('WebSocket', FakeWebSocket) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('delivers a conservative overflow after a replayed file watch is ready', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const initialSocket = fakeSockets[0]! + initialSocket.readyState = FakeWebSocket.OPEN + const onResponse = vi.fn() + const internals = client as unknown as { + ws: FakeWebSocket | null + sharedKey: Uint8Array | null + state: string + subscriptions: Map< + string, + { + callbacks: { onResponse: typeof onResponse } + needsReplay: boolean + } + > + setState(next: string): void + } + internals.sharedKey = new Uint8Array(32) + internals.state = 'connected' + + await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) + const [firstId, first] = Array.from(internals.subscriptions.entries())[0]! + first.callbacks.onResponse({ + id: firstId, + ok: true, + streaming: true, + result: { type: 'ready', subscriptionId: 'sub-1' }, + _meta: { runtimeId: 'runtime-web-test' } + }) + // Why: an initial ready is not a replay — consumers already scan on setup. + expect(onResponse).toHaveBeenCalledTimes(1) + + initialSocket.onclose?.() + const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') + replacementSocket.readyState = FakeWebSocket.OPEN + internals.ws = replacementSocket + internals.sharedKey = new Uint8Array(32) + internals.setState('connected') + + const [replacementId, replacement] = Array.from(internals.subscriptions.entries())[0]! + replacement.callbacks.onResponse({ + id: replacementId, + ok: true, + streaming: true, + result: { type: 'ready', subscriptionId: 'sub-2' }, + _meta: { runtimeId: 'runtime-web-test' } + }) + + // Why: changes during the reconnect gap produced no watch events, so the + // replayed watch must be followed by an overflow that forces a re-scan. + expect(onResponse).toHaveBeenCalledTimes(3) + expect(onResponse.mock.calls[2]![0]).toMatchObject({ + ok: true, + result: { + type: 'changed', + worktree: 'wt-1', + events: [{ kind: 'overflow' }] + } + }) + client.close() + }) + + it('replays an active file watch on reconnect and keeps its logical handle', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const initialSocket = fakeSockets[0]! + initialSocket.readyState = FakeWebSocket.OPEN + const onResponse = vi.fn() + const onClose = vi.fn() + const internals = client as unknown as { + ws: FakeWebSocket | null + sharedKey: Uint8Array | null + state: string + subscriptions: Map< + string, + { + callbacks: { onResponse: typeof onResponse } + needsReplay: boolean + } + > + setState(next: string): void + handleSocketClosed(socket: FakeWebSocket): void + } + internals.sharedKey = new Uint8Array(32) + internals.state = 'connected' + + const handle = await client.subscribe( + 'files.watch', + { worktree: 'wt-1' }, + { onResponse, onClose } + ) + const firstId = Array.from(internals.subscriptions.keys())[0] + expect(initialSocket.send).toHaveBeenCalledTimes(1) + + initialSocket.onclose?.() + + expect(onClose).not.toHaveBeenCalled() + expect(internals.subscriptions.get(firstId!)?.needsReplay).toBe(true) + + const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') + replacementSocket.readyState = FakeWebSocket.OPEN + internals.ws = replacementSocket + internals.sharedKey = new Uint8Array(32) + internals.setState('connected') + + const [replacementId, replacement] = Array.from(internals.subscriptions.entries())[0]! + expect(replacementId).not.toBe(firstId) + expect(replacementSocket.send).toHaveBeenCalledTimes(1) + replacement.callbacks.onResponse({ + id: replacementId, + ok: true, + streaming: true, + result: { type: 'changed', worktree: 'wt-1', events: [] }, + _meta: { runtimeId: 'runtime-web-test' } + }) + expect(onResponse).toHaveBeenCalledTimes(1) + + handle.unsubscribe() + internals.handleSocketClosed(replacementSocket) + expect(internals.subscriptions.size).toBe(0) + const secondReplacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') + secondReplacementSocket.readyState = FakeWebSocket.OPEN + internals.ws = secondReplacementSocket + internals.sharedKey = new Uint8Array(32) + internals.setState('connected') + expect(secondReplacementSocket.send).not.toHaveBeenCalled() + client.close() + expect(internals.subscriptions.size).toBe(0) + }) + + it('does not replay a file watch stopped after its socket disconnects', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const initialSocket = fakeSockets[0]! + initialSocket.readyState = FakeWebSocket.OPEN + const internals = client as unknown as { + ws: FakeWebSocket | null + sharedKey: Uint8Array | null + state: string + subscriptions: Map + setState(next: string): void + } + internals.sharedKey = new Uint8Array(32) + internals.state = 'connected' + const handle = await client.subscribe( + 'files.watch', + { worktree: 'wt-1' }, + { onResponse: vi.fn() } + ) + + initialSocket.onclose?.() + expect(Array.from(internals.subscriptions.values())[0]?.needsReplay).toBe(true) + handle.unsubscribe() + expect(internals.subscriptions.size).toBe(0) + + const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') + replacementSocket.readyState = FakeWebSocket.OPEN + internals.ws = replacementSocket + internals.sharedKey = new Uint8Array(32) + internals.setState('connected') + expect(replacementSocket.send).not.toHaveBeenCalled() + client.close() + }) + + it('evicts a failed file-watch setup before reconnect can replay it', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const initialSocket = fakeSockets[0]! + initialSocket.readyState = FakeWebSocket.OPEN + const sharedKey = new Uint8Array(32) + const onResponse = vi.fn() + const internals = client as unknown as { + ws: FakeWebSocket | null + sharedKey: Uint8Array | null + state: string + subscriptions: Map + handleSocketMessage(rawData: unknown): Promise + setState(next: string): void + } + internals.sharedKey = sharedKey + internals.state = 'connected' + await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) + const subscriptionId = Array.from(internals.subscriptions.keys())[0]! + + await internals.handleSocketMessage( + encrypt( + JSON.stringify({ + id: subscriptionId, + ok: false, + error: { code: 'watch_failed', message: 'root unavailable' } + }), + sharedKey + ) + ) + + expect(onResponse).toHaveBeenCalledTimes(1) + expect(internals.subscriptions.size).toBe(0) + initialSocket.onclose?.() + const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') + replacementSocket.readyState = FakeWebSocket.OPEN + internals.ws = replacementSocket + internals.sharedKey = sharedKey + internals.setState('connected') + expect(replacementSocket.send).not.toHaveBeenCalled() + client.close() + }) +}) diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 8f7e500c3d3..4a15fcce2ef 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -205,6 +205,49 @@ describe('WebRuntimeClient', () => { } }) + it('delivers plain unary responses to their subscription instead of dropping them', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + try { + const keyPair = generateKeyPair() + const sharedKey = deriveSharedKey(keyPair.secretKey, keyPair.publicKey) + const internals = client as unknown as { + state: string + sharedKey: Uint8Array | null + subscriptions: Map< + string, + { callbacks: { onResponse: (response: unknown) => void; onClose?: () => void } } + > + handleSocketMessage: (rawData: unknown, sourceWs?: unknown) => Promise + } + internals.state = 'connected' + internals.sharedKey = sharedKey + const onResponse = vi.fn() + internals.subscriptions.set('req-1', { callbacks: { onResponse } }) + + // A unary RPC (e.g. abortable git.status) answered over the subscription + // bridge: not streaming and neither an end nor a scrollback frame. + const response = { + id: 'req-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'runtime-1' } + } + await internals.handleSocketMessage(encrypt(JSON.stringify(response), sharedKey)) + + expect(onResponse).toHaveBeenCalledWith(response) + // The caller owns the unsubscribe; a plain reply must not close it. + expect(internals.subscriptions.has('req-1')).toBe(true) + internals.subscriptions.delete('req-1') + } finally { + client.close() + } + }) + it('keeps file watches on the owning WebSocket instead of opening child clients', async () => { const client = new WebRuntimeClient({ v: 2, @@ -220,12 +263,9 @@ describe('WebRuntimeClient', () => { const subscribeOnCurrentConnection = vi .spyOn(internals, 'subscribeOnCurrentConnection') .mockResolvedValue(handle) + const onResponse = vi.fn() - const subscription = await client.subscribe( - 'files.watch', - { worktree: 'wt-1' }, - { onResponse: vi.fn() } - ) + const subscription = await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) expect(subscribeOnCurrentConnection).toHaveBeenCalledWith( 'files.watch', @@ -234,6 +274,14 @@ describe('WebRuntimeClient', () => { undefined ) expect(internals.childClients.size).toBe(0) + subscribeOnCurrentConnection.mock.calls[0]?.[2].onResponse({ + id: 'watch', + ok: true, + streaming: true, + result: { type: 'starting', subscriptionId: 'files-watch-starting' }, + _meta: { runtimeId: 'runtime-web-test' } + } as RuntimeRpcResponse & { streaming: true }) + expect(onResponse).not.toHaveBeenCalled() const frame = new Uint8Array([1]) subscription.sendBinary(frame) expect(handle.sendBinary).toHaveBeenCalledWith(frame) @@ -287,7 +335,7 @@ describe('WebRuntimeClient', () => { client.close() }) - it('removes the shared local subscription when remote unwatch fails', async () => { + it('retains and retries a shared local subscription when remote unwatch returns failure', async () => { const client = new WebRuntimeClient({ v: 2, endpoint: 'ws://127.0.0.1:6768', @@ -301,8 +349,19 @@ describe('WebRuntimeClient', () => { const subscribeOnCurrentConnection = vi .spyOn(internals, 'subscribeOnCurrentConnection') .mockResolvedValue(localHandle) - const unwatchError = new Error('remote unwatch failed') - const unwatch = vi.spyOn(client, 'call').mockRejectedValue(unwatchError) + const unwatch = vi + .spyOn(client, 'call') + .mockResolvedValueOnce({ + id: 'unwatch-failed', + ok: false, + error: { code: 'teardown_failed', message: 'remote unwatch failed' } + }) + .mockResolvedValueOnce({ + id: 'unwatch-retry', + ok: true, + result: { unsubscribed: true }, + _meta: { runtimeId: 'runtime-web-test' } + }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { @@ -327,8 +386,83 @@ describe('WebRuntimeClient', () => { { subscriptionId: 'files-watch-failing-unwatch' }, { timeoutMs: 5_000 } ) - await vi.waitFor(() => expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1)) - expect(warn).toHaveBeenCalledWith('Failed to unwatch remote file subscription:', unwatchError) + await vi.waitFor(() => expect(warn).toHaveBeenCalledTimes(1)) + expect(localHandle.unsubscribe).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + 'Failed to unwatch remote file subscription:', + expect.objectContaining({ message: 'teardown_failed: remote unwatch failed' }) + ) + + await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse: vi.fn() }) + expect(unwatch).toHaveBeenCalledTimes(2) + expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1) + expect(subscribeOnCurrentConnection).toHaveBeenCalledTimes(2) + } finally { + client.close() + warn.mockRestore() + } + }) + + it('retries every failed same-root teardown before opening a replacement watch', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const handles = Array.from({ length: 3 }, () => ({ + unsubscribe: vi.fn(), + sendBinary: vi.fn() + })) + const internals = client as unknown as { + subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] + } + const subscribeOnCurrentConnection = vi + .spyOn(internals, 'subscribeOnCurrentConnection') + .mockResolvedValueOnce(handles[0]) + .mockResolvedValueOnce(handles[1]) + .mockResolvedValueOnce(handles[2]) + const unwatch = vi + .spyOn(client, 'call') + .mockRejectedValueOnce(new Error('first teardown failed')) + .mockRejectedValueOnce(new Error('second teardown failed')) + .mockResolvedValue({ + id: 'unwatch-retry', + ok: true, + result: { unsubscribed: true }, + _meta: { runtimeId: 'runtime-web-test' } + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const first = await client.subscribe( + 'files.watch', + { worktree: 'wt-1' }, + { onResponse: vi.fn() } + ) + const second = await client.subscribe( + 'files.watch', + { worktree: 'wt-1' }, + { onResponse: vi.fn() } + ) + for (const [index, subscriptionId] of ['watch-a', 'watch-b'].entries()) { + subscribeOnCurrentConnection.mock.calls[index]?.[2].onResponse({ + id: subscriptionId, + ok: true, + streaming: true, + result: { type: 'ready', subscriptionId }, + _meta: { runtimeId: 'runtime-web-test' } + } as RuntimeRpcResponse & { streaming: true }) + } + first.unsubscribe() + second.unsubscribe() + await vi.waitFor(() => expect(warn).toHaveBeenCalledTimes(2)) + + await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse: vi.fn() }) + + expect(unwatch).toHaveBeenCalledTimes(4) + expect(handles[0].unsubscribe).toHaveBeenCalledTimes(1) + expect(handles[1].unsubscribe).toHaveBeenCalledTimes(1) + expect(subscribeOnCurrentConnection).toHaveBeenCalledTimes(3) } finally { client.close() warn.mockRestore() @@ -382,7 +516,7 @@ describe('WebRuntimeClient', () => { client.close() }) - it('cleans up a stopped pre-ready shared file watch if ready never arrives', async () => { + it('retains a stopped pre-ready file watch until its cancellation id arrives', async () => { vi.useFakeTimers() const timerWindow = window as unknown as { setTimeout: typeof setTimeout @@ -400,8 +534,15 @@ describe('WebRuntimeClient', () => { const internals = client as unknown as { subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] } - vi.spyOn(internals, 'subscribeOnCurrentConnection').mockResolvedValue(localHandle) - const unwatch = vi.spyOn(client, 'call') + const subscribeOnCurrentConnection = vi + .spyOn(internals, 'subscribeOnCurrentConnection') + .mockResolvedValue(localHandle) + const unwatch = vi.spyOn(client, 'call').mockResolvedValue({ + id: 'unwatch', + ok: true, + result: { unsubscribed: true }, + _meta: { runtimeId: 'runtime-web-test' } + }) try { const subscription = await client.subscribe( @@ -414,12 +555,24 @@ describe('WebRuntimeClient', () => { expect(unwatch).not.toHaveBeenCalled() expect(localHandle.unsubscribe).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(4_999) + await vi.advanceTimersByTimeAsync(300_000) expect(localHandle.unsubscribe).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(1) - expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1) - expect(unwatch).not.toHaveBeenCalled() + const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2] + wrappedCallbacks?.onResponse({ + id: 'watch', + ok: true, + streaming: true, + result: { type: 'starting', subscriptionId: 'files-watch-pending' }, + _meta: { runtimeId: 'runtime-web-test' } + } as RuntimeRpcResponse & { streaming: true }) + + expect(unwatch).toHaveBeenCalledWith( + 'files.unwatch', + { subscriptionId: 'files-watch-pending' }, + { timeoutMs: 5_000 } + ) + await vi.waitFor(() => expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1)) } finally { client.close() vi.useRealTimers() diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index f94cf04476e..0162dc24ff9 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -35,12 +35,16 @@ type SubscriptionCallbacks = { onBinary?: (bytes: Uint8Array) => void onError?: (error: { code: string; message: string }) => void onClose?: () => void + onTransportInterrupted?: () => void + onTransportReplayed?: () => void } type RuntimeSubscription = { + id: string method: string params: unknown callbacks: SubscriptionCallbacks + needsReplay: boolean } export type WebRuntimeSubscriptionHandle = { @@ -61,7 +65,6 @@ export type SubscribeOptions = { const REQUEST_TIMEOUT_MS = 30_000 const CONNECT_TIMEOUT_MS = 12_000 const HANDSHAKE_TIMEOUT_MS = 10_000 -const FILE_WATCH_READY_CLEANUP_TIMEOUT_MS = 5_000 const RECONNECT_DELAYS_MS = [500, 1000, 2000, 4000, 8000, 15_000] const SHARED_CONNECTION_SUBSCRIPTION_METHODS = new Set(['files.watch']) // Why: the browser WebSocket API hides protocol pings/pongs, so a half-open @@ -99,6 +102,7 @@ export class WebRuntimeClient { private lastHeartbeatTickAt = 0 private readonly pending = new Map() private readonly subscriptions = new Map() + private readonly fileWatchTeardownRetries = new Map Promise>>() private readonly childClients = new Set() private readonly waiters: { resolve: () => void; reject: (error: Error) => void }[] = [] private readonly serverPublicKey: Uint8Array @@ -187,69 +191,93 @@ export class WebRuntimeClient { callbacks: SubscriptionCallbacks, options?: { timeoutMs?: number } ): Promise { + const teardownKey = JSON.stringify(params) ?? String(params) + await Promise.all( + Array.from(this.fileWatchTeardownRetries.get(teardownKey) ?? [], (retry) => retry()) + ) let stopped = false let remoteSubscriptionId: string | null = null + let transportInterrupted = false + let pendingReplayResync = false let unwatchStarted = false let handle: WebRuntimeSubscriptionHandle | null = null - let readyCleanupTimer: number | null = null - const clearReadyCleanupTimer = (): void => { - if (readyCleanupTimer === null) { - return - } - window.clearTimeout(readyCleanupTimer) - readyCleanupTimer = null - } const dropLocalSubscription = (): void => { - clearReadyCleanupTimer() handle?.unsubscribe() } - const schedulePreReadyCleanup = (): void => { - if (readyCleanupTimer !== null) { - return + let unwatchAttempt: Promise | null = null + const retryRemoteUnwatch = (): Promise => { + if (unwatchAttempt) { + return unwatchAttempt } - // Why: if a stopped watch never reaches ready/error, no remote id exists - // to unwatch; bound the lifetime of the local callback on this socket. - readyCleanupTimer = window.setTimeout(() => { - readyCleanupTimer = null - handle?.unsubscribe() - }, FILE_WATCH_READY_CLEANUP_TIMEOUT_MS) + unwatchStarted = true + const attempt = this.call( + 'files.unwatch', + { subscriptionId: remoteSubscriptionId! }, + { timeoutMs: 5_000 } + ) + .then((response) => { + if (response.ok === false) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + const retries = this.fileWatchTeardownRetries.get(teardownKey) + retries?.delete(retryRemoteUnwatch) + if (retries?.size === 0) { + this.fileWatchTeardownRetries.delete(teardownKey) + } + dropLocalSubscription() + }) + .catch((error: unknown) => { + console.warn('Failed to unwatch remote file subscription:', error) + throw error + }) + .finally(() => { + unwatchAttempt = null + unwatchStarted = false + }) + unwatchAttempt = attempt + return attempt } const unwatchAndDropLocalSubscription = (): void => { if (unwatchStarted) { return } - unwatchStarted = true if (!remoteSubscriptionId) { dropLocalSubscription() return } - clearReadyCleanupTimer() - // Why: shared files.watch streams stay on this socket, so stop the - // server watcher before removing the local callback that receives ready. - void this.call( - 'files.unwatch', - { subscriptionId: remoteSubscriptionId }, - { timeoutMs: 5_000 } - ) - .catch((error) => { - console.warn('Failed to unwatch remote file subscription:', error) - }) - .finally(() => { - dropLocalSubscription() - }) + // Why: retain the shared-socket callback and retry ownership until the + // server acknowledges physical teardown; a new watch joins this barrier. + const retries = this.fileWatchTeardownRetries.get(teardownKey) ?? new Set() + retries.add(retryRemoteUnwatch) + this.fileWatchTeardownRetries.set(teardownKey, retries) + void retryRemoteUnwatch().catch(() => {}) } const wrappedCallbacks: SubscriptionCallbacks = { ...callbacks, onResponse: (response) => { - if (isFileWatchReadyResponse(response)) { - remoteSubscriptionId = response.result.subscriptionId + transportInterrupted = false + const nextSubscriptionId = getFileWatchSubscriptionId(response) + if (nextSubscriptionId) { + remoteSubscriptionId = nextSubscriptionId if (stopped) { unwatchAndDropLocalSubscription() return } } + // Why: the server publishes cancellation ownership before native setup; + // callers should still become ready only after the watcher is live. + if (isFileWatchStartingResponse(response)) { + return + } if (!stopped) { callbacks.onResponse(response) + if (pendingReplayResync && nextSubscriptionId && response.ok) { + pendingReplayResync = false + // Why: a replayed watch reports changes only from its own native + // setup; the reconnect gap produced no events, so consumers must + // conservatively re-scan once the replacement is ready. + callbacks.onResponse(createFileWatchReplayOverflowResponse(response, params)) + } } else if (response.ok === false) { dropLocalSubscription() } @@ -263,6 +291,25 @@ export class WebRuntimeClient { if (!stopped) { callbacks.onClose?.() } + }, + onTransportInterrupted: () => { + transportInterrupted = true + remoteSubscriptionId = null + if (!stopped) { + return + } + const retries = this.fileWatchTeardownRetries.get(teardownKey) + retries?.delete(retryRemoteUnwatch) + if (retries?.size === 0) { + this.fileWatchTeardownRetries.delete(teardownKey) + } + // Why: socket close physically releases the old server subscription; + // a locally stopped watch must not be replayed on the replacement. + dropLocalSubscription() + }, + onTransportReplayed: () => { + transportInterrupted = false + pendingReplayResync = true } } handle = await this.subscribeOnCurrentConnection( @@ -280,9 +327,13 @@ export class WebRuntimeClient { stopped = true if (remoteSubscriptionId) { unwatchAndDropLocalSubscription() - } else { - schedulePreReadyCleanup() + } else if (transportInterrupted) { + // Why: socket close already released the old server subscription; + // remove its replay record instead of reviving a locally stopped watch. + dropLocalSubscription() } + // Why: an older server may not publish its id until ready. Retain the + // callback so a late response can still physically unwatch the root. }, sendBinary: (bytes) => handle?.sendBinary(bytes) } @@ -296,14 +347,15 @@ export class WebRuntimeClient { ): Promise { await this.waitForConnected(options?.timeoutMs) const id = this.nextId() - this.subscriptions.set(id, { method, params, callbacks }) + const subscription: RuntimeSubscription = { id, method, params, callbacks, needsReplay: false } + this.subscriptions.set(id, subscription) if (!this.sendEncrypted({ id, deviceToken: this.pairing.deviceToken, method, params })) { this.subscriptions.delete(id) throw new Error('Remote Orca runtime is not connected.') } return { unsubscribe: () => { - this.subscriptions.delete(id) + this.subscriptions.delete(subscription.id) // Tell the server to reap its keyed cleanup (e.g. native-chat fs-watcher) // before the socket goes away. Best-effort: a closed socket already reaps. const teardown = options?.buildUnsubscribe?.(params) @@ -329,6 +381,7 @@ export class WebRuntimeClient { child.close({ notifySubscriptions: shouldNotifySubscriptions }) } this.childClients.clear() + this.fileWatchTeardownRetries.clear() this.clearTimers() this.rejectAllPending('Remote Orca runtime connection closed.') this.rejectAllWaiters(new Error('Remote Orca runtime connection closed.')) @@ -511,9 +564,15 @@ export class WebRuntimeClient { } const subscription = this.subscriptions.get(response.id) - if (subscription && isSubscriptionResponse(response)) { - subscription.callbacks.onResponse(response) - if (response.ok && isEndResult(response.result)) { + if (subscription) { + const subscriptionResponse = response as RuntimeRpcResponse + // Why: setup failures must be evicted before callbacks so reconnect cannot replay them. + if (subscriptionResponse.ok === false) { + this.subscriptions.delete(response.id) + } + // Why: subscription-backed unary RPCs can return ordinary success frames. + subscription.callbacks.onResponse(subscriptionResponse) + if (subscriptionResponse.ok && isEndResult(subscriptionResponse.result)) { this.subscriptions.delete(response.id) subscription.callbacks.onClose?.() } @@ -595,7 +654,7 @@ export class WebRuntimeClient { this.clearHandshakeTimer() this.clearHeartbeatTimer() this.rejectAllPending('Remote Orca runtime connection interrupted.') - this.notifySubscriptionsClosed() + this.handleInterruptedSubscriptions() if (this.intentionallyClosed || this.state === 'auth-failed') { this.setState(this.state === 'auth-failed' ? 'auth-failed' : 'disconnected') return @@ -620,6 +679,7 @@ export class WebRuntimeClient { private setState(next: WebRuntimeConnectionState): void { this.state = next if (next === 'connected') { + this.replayInterruptedSubscriptions() this.startHeartbeat() for (const waiter of this.waiters.splice(0)) { waiter.resolve() @@ -657,6 +717,44 @@ export class WebRuntimeClient { } } + private handleInterruptedSubscriptions(): void { + for (const [id, subscription] of Array.from(this.subscriptions)) { + if (!SHARED_CONNECTION_SUBSCRIPTION_METHODS.has(subscription.method)) { + this.subscriptions.delete(id) + subscription.callbacks.onClose?.() + continue + } + subscription.callbacks.onTransportInterrupted?.() + if (this.subscriptions.get(subscription.id) === subscription) { + subscription.needsReplay = true + } + } + } + + private replayInterruptedSubscriptions(): void { + for (const subscription of Array.from(this.subscriptions.values())) { + if (!subscription.needsReplay) { + continue + } + this.subscriptions.delete(subscription.id) + subscription.id = this.nextId() + subscription.needsReplay = false + this.subscriptions.set(subscription.id, subscription) + if ( + this.sendEncrypted({ + id: subscription.id, + deviceToken: this.pairing.deviceToken, + method: subscription.method, + params: subscription.params + }) + ) { + subscription.callbacks.onTransportReplayed?.() + } else { + subscription.needsReplay = true + } + } + } + private notifySubscriptionsError(code: string, message: string): void { const subscriptions = Array.from(this.subscriptions.values()) this.subscriptions.clear() @@ -765,24 +863,6 @@ export class WebRuntimeClient { } } -function isSubscriptionResponse( - response: RuntimeRpcResponse | Record -): response is RuntimeRpcResponse { - if (!('ok' in response)) { - return false - } - if (response.ok === false) { - return true - } - if (response.ok === false) { - return true - } - const success = response as RuntimeRpcResponse & { ok: true; streaming?: true } - return ( - success.streaming === true || isEndResult(success.result) || isScrollbackResult(success.result) - ) -} - function isRuntimeFailureResponse( response: RuntimeRpcResponse | Record ): response is RuntimeRpcResponse & { ok: false } { @@ -796,18 +876,49 @@ function isRuntimeFailureResponse( ) } -function isFileWatchReadyResponse( - response: RuntimeRpcResponse -): response is RuntimeRpcSuccess<{ type: 'ready'; subscriptionId: string }> { +function getFileWatchSubscriptionId(response: RuntimeRpcResponse): string | null { if (!response.ok) { - return false + return null } const result = response.result + if (!result || typeof result !== 'object') { + return null + } + const subscriptionId = (result as { subscriptionId?: unknown }).subscriptionId + return typeof subscriptionId === 'string' ? subscriptionId : null +} + +function createFileWatchReplayOverflowResponse( + readyResponse: RuntimeRpcSuccess, + params: unknown +): RuntimeRpcSuccess<{ + type: 'changed' + worktree: string + events: { kind: 'overflow'; absolutePath: string }[] +}> { + const worktree = (params as { worktree?: unknown } | null)?.worktree + return { + id: readyResponse.id, + ok: true, + result: { + type: 'changed', + worktree: typeof worktree === 'string' ? worktree : '', + // Why: overflow consumers re-scan the whole root and never read the + // path; the client does not know the server-side root path here. + events: [{ kind: 'overflow', absolutePath: '' }] + }, + _meta: readyResponse._meta + } +} + +function isFileWatchStartingResponse( + response: RuntimeRpcResponse +): response is RuntimeRpcSuccess<{ type: 'starting'; subscriptionId: string }> { return ( - !!result && - typeof result === 'object' && - (result as { type?: unknown }).type === 'ready' && - typeof (result as { subscriptionId?: unknown }).subscriptionId === 'string' + response.ok && + !!response.result && + typeof response.result === 'object' && + (response.result as { type?: unknown }).type === 'starting' ) } @@ -815,10 +926,6 @@ function isEndResult(value: unknown): value is { type: 'end' } { return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'end' } -function isScrollbackResult(value: unknown): value is { type: 'scrollback' } { - return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'scrollback' -} - async function websocketPayloadToUint8( value: unknown ): Promise | null> { diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index efafcaa5b06..e9f9e265f62 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -2350,92 +2350,83 @@ describe('shared agent-hook-listener', () => { expect(stopped?.payload.state).toBe('done') }) - it('resolves teams-mode teammates to done despite background_tasks reporting running', () => { - // Why: this is the interactive agent-teams shape observed live — + it('removes a finished teammate/named agent on SubagentStop despite its task reading running', () => { + // Why: the interactive agent-teams / orchestration shape observed live — // lifecycle events use `a-` agent ids while background_tasks - // uses unrelated task ids and keeps idle-but-alive teammates as - // status "running". The unmatched task entry must neither create a - // duplicate child row nor keep the pane spinning forever. + // uses unrelated `type: "teammate"` task ids that report "running" + // forever, even after the named agent finished. The finished row must + // leave the sidebar at once (the reported "long idle list" symptom). claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'spawn probe' }) claudeEvent({ hook_event_name: 'SubagentStart', agent_id: 'aprobe1-6d3cb5b52120b7bf', agent_type: 'probe1' }) + const teammateTask = { + id: 'tlkjjs0jv', + type: 'teammate', + status: 'running', + description: 'Run the shell command: sleep 25.' + } const spawnStop = claudeEvent({ hook_event_name: 'Stop', - background_tasks: [ - { - id: 'tlkjjs0jv', - type: 'teammate', - status: 'running', - description: 'Run the shell command: sleep 25.' - } - ] + background_tasks: [teammateTask] }) expect(spawnStop?.payload.state).toBe('working') expect(spawnStop?.payload.subagents).toEqual([ expect.objectContaining({ id: 'aprobe1-6d3cb5b52120b7bf', state: 'working' }) ]) - claudeEvent({ + // SubagentStop is the reliable finish signal — the row goes even though + // its teammate task is still listed "running". + const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'aprobe1-6d3cb5b52120b7bf', - agent_type: 'probe1' + agent_type: 'probe1', + background_tasks: [teammateTask] }) - const idled = claudeEvent({ + expect(stopped?.payload.subagents).toBeUndefined() + + claudeEvent({ hook_event_name: 'TeammateIdle', teammate_name: 'probe1', team_name: 'session-56c87269' }) - expect(idled?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'aprobe1-6d3cb5b52120b7bf', state: 'idle' }) - ]) const wakeStop = claudeEvent({ hook_event_name: 'Stop', - background_tasks: [ - { - id: 'tlkjjs0jv', - type: 'teammate', - status: 'running', - description: 'Run the shell command: sleep 25.' - } - ] + background_tasks: [teammateTask] }) expect(wakeStop?.payload.state).toBe('done') - expect(wakeStop?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'aprobe1-6d3cb5b52120b7bf', state: 'idle' }) - ]) + expect(wakeStop?.payload.subagents).toBeUndefined() }) - it('keeps a teammate whose name differs from its configured agent type', () => { + it('removes a working teammate via TeammateIdle when its id prefix matches the name', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'spawn reviewer' }) claudeEvent({ hook_event_name: 'SubagentStart', agent_id: 'areviewer-6d3cb5b52120b7bf', agent_type: 'security-reviewer' }) + // Lead turn ends while the teammate works; pane stays working. + const stop = claudeEvent({ + hook_event_name: 'Stop', + background_tasks: [{ id: 'trev', type: 'teammate', status: 'running' }] + }) + expect(stop?.payload.state).toBe('working') // Why: teammate name and agent type are separate Agent-tool inputs; the // lifecycle id embeds the former while the hook reports the latter. - claudeEvent({ - hook_event_name: 'SubagentStop', - agent_id: 'areviewer-6d3cb5b52120b7bf', - agent_type: 'security-reviewer' - }) + // TeammateIdle keyed by name reaps it via the id prefix (fallback when + // its SubagentStop was lost), so the finished row leaves and the pane + // can settle back to the lead's done state. const idled = claudeEvent({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer', team_name: 'session-x' }) - expect(idled?.payload.subagents).toEqual([ - expect.objectContaining({ - id: 'areviewer-6d3cb5b52120b7bf', - agentType: 'security-reviewer', - state: 'idle' - }) - ]) + expect(idled?.payload.subagents).toBeUndefined() + expect(idled?.payload.state).toBe('done') }) it('scopes subagent rosters per pane', () => { @@ -2679,9 +2670,8 @@ describe('shared agent-hook-listener', () => { teammate_name: 'lane-hooks', team_name: 'session-x' }) - expect(idled?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'alane-hooks-6d3cb5b5', state: 'idle' }) - ]) + // Why: idle means finished — the exact-name match reaps the row. + expect(idled?.payload.subagents).toBeUndefined() }) it('keeps an inferred interrupt terminal across later child lifecycle events', () => { @@ -2703,7 +2693,11 @@ describe('shared agent-hook-listener', () => { expect(idled?.payload.interrupted).toBe(true) }) - it('seeds the roster from persisted snapshots so a teammate-bearing Stop keeps child rows', () => { + it('does not resurrect persisted idle child rows after a restart', () => { + // Why: the roster tracks only working children now. A persisted idle + // snapshot (from a build that kept idle rows) is a finished child, so + // hydration must drop it — otherwise restart would re-pile the exact + // squatting rows this fix removes. seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, [ { id: 'aprobe2-6d3cb5b52120b7bf', @@ -2720,13 +2714,7 @@ describe('shared agent-hook-listener', () => { ] }) expect(stop?.payload.state).toBe('done') - expect(stop?.payload.subagents).toEqual([ - expect.objectContaining({ - id: 'aprobe2-6d3cb5b52120b7bf', - agentType: 'security-reviewer', - state: 'idle' - }) - ]) + expect(stop?.payload.subagents).toBeUndefined() }) it('rebuilds a running one-shot subagent from background_tasks after restart', () => { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 5870fd5f43b..bde6baf4431 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -40,9 +40,8 @@ import { claudeTeammateIdMatchesName, finishClaudeSubagent, foldClaudeBackgroundTasksIntoRoster, - isClaudeTeammateLifecycleId, - markClaudeTeammateIdleByName, readClaudeBackgroundAgentTasks, + removeClaudeTeammateByName, upsertWorkingClaudeSubagent, type ClaudeSubagentRoster } from './claude-subagent-roster' @@ -2436,7 +2435,10 @@ function normalizeClaudeSubagentLifecycleEvent( if (!teammateName) { return null } - markClaudeTeammateIdleByName(roster, teammateName) + // Why: idle means not working, and only working children keep a row. This + // is the fallback finish signal for a named agent whose SubagentStop was + // lost — its background_tasks entry never stops reading "running". + removeClaudeTeammateByName(roster, teammateName) clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => claudeTeammateIdMatchesName(waitingAgentId, teammateName) ) @@ -2453,14 +2455,11 @@ function normalizeClaudeSubagentLifecycleEvent( Date.now() ) } else { - // Why: SubagentStop carries the session's task inventory; a stopping - // agent listed id-exact as a subagent task is a workflow/named one-shot - // (teammate lifecycle ids never appear there), not a resumable teammate. - const stopTasks = readClaudeBackgroundAgentTasks(hookPayload) - finishClaudeSubagent(roster, agentId, { - listedAsSubagentTask: - stopTasks.present && stopTasks.tasks.some((task) => !task.teammate && task.id === agentId) - }) + // Why: a finished child (one-shot, workflow lane, or named teammate) + // leaves the sidebar at once. SubagentStop is the reliable finish + // signal even for teammate-shaped ids — their background_tasks entries + // stay "running" forever — and a resumed teammate re-earns its row. + finishClaudeSubagent(roster, agentId) // Why: a blocked child that dies (killed, errored) without another tool // event would otherwise pin its permission/question wait on the pane // forever — nothing else references that agent again. @@ -2478,11 +2477,9 @@ export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done', interrupted: true }) } -/** Rebuild a pane's roster from a persisted status snapshot during hydration. - * Restart loses the in-memory roster while the renderer replays the child - * rows from disk; without reseeding, the next teammate-bearing Stop (whose - * task ids never match lifecycle ids) would silently drop those rows. Stale - * seeds self-heal: an empty background_tasks clears, activity refreshes. */ +/** Rebuild a pane's working roster from a persisted status snapshot. Live + * activity confirms a seed after restart; a complete task inventory may reap + * an unconfirmed seed whose finish hook arrived while Orca was offline. */ export function seedClaudeSubagentRosterFromSnapshots( state: HookListenerState, paneKey: string, @@ -2493,18 +2490,19 @@ export function seedClaudeSubagentRosterFromSnapshots( } const roster = getOrCreateClaudeSubagentRoster(state, paneKey) for (const snapshot of snapshots) { + // Why: the roster only tracks working children now. A persisted idle + // snapshot (from a build that kept idle rows) is a finished child — drop + // it so restart doesn't resurrect the stale pile this fix removes. + if (snapshot.state !== 'working') { + continue + } roster.set(snapshot.id, { - state: snapshot.state === 'working' ? 'working' : 'idle', startedAt: snapshot.startedAt, agentType: snapshot.agentType, description: snapshot.description, - // Why: teammate name and agent type can differ, but the provider id - // shape survives persistence and keeps the row across restart folds. - ...(isClaudeTeammateLifecycleId(snapshot.id) ? { teammate: true as const } : {}), // Why: the seed can be a phantom (child finished while Orca was down, // its SubagentStop lost). Let a PRESENT background_tasks list that - // omits the id remove it (or demote a teammate) instead of gating the - // pane 'working' forever. + // omits the id remove it instead of gating the pane 'working' forever. backgroundTasksAuthoritative: true }) } diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts new file mode 100644 index 00000000000..b638334f633 --- /dev/null +++ b/src/shared/agent-type-label.ts @@ -0,0 +1,34 @@ +import type { AgentType } from './agent-status-types' + +// Shared so the desktop renderer and the mobile app show the same agent name +// (e.g. native chat's empty state on both surfaces) from one source of truth. +const WELL_KNOWN_LABELS: Record = { + claude: 'Claude', + openclaude: 'OpenClaude', + codex: 'Codex', + gemini: 'Gemini', + antigravity: 'Antigravity', + amp: 'Amp', + copilot: 'GitHub Copilot', + opencode: 'OpenCode', + 'mimo-code': 'MiMo Code', + cursor: 'Cursor', + aider: 'Aider', + pi: 'Pi', + omp: 'OMP', + droid: 'Droid', + 'command-code': 'Command Code', + grok: 'Grok', + hermes: 'Hermes', + devin: 'Devin', + ante: 'Ante', + kimi: 'Kimi' +} + +export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { + if (!agentType || agentType === 'unknown') { + return 'Agent' + } + // Capitalize well-known names nicely; pass through custom names as-is + return WELL_KNOWN_LABELS[agentType] ?? agentType +} diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index a654dd8217e..c7ac1423e10 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -3,6 +3,17 @@ import { describe, expect, it } from 'vitest' import { buildAiVaultResumeCommand } from './ai-vault-types' describe('buildAiVaultResumeCommand', () => { + it('uses Antigravity conversation ids instead of Gemini resume flags', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'antigravity', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + cwd: '/repo/app', + platform: 'darwin' + }) + ).toBe("cd '/repo/app' && agy --conversation 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'") + }) + it('builds a self-contained cmd wrapper when no live shell is known', () => { expect( buildAiVaultResumeCommand({ diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index fe07aca6963..dd780ef9ac4 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -15,6 +15,7 @@ export const AI_VAULT_AGENTS = [ 'omp', 'cursor', 'gemini', + 'antigravity', 'rovo', 'copilot', 'opencode', @@ -43,6 +44,7 @@ export const AI_VAULT_AGENT_LABELS = { omp: 'OMP', cursor: 'Cursor', gemini: 'Gemini', + antigravity: 'Antigravity', rovo: 'Rovo Dev', copilot: 'GitHub Copilot', opencode: 'OpenCode', @@ -323,6 +325,8 @@ function buildAgentResumeInvocation( // but the `--resume ` invocation form is identical to the others here. case 'omp': return `${baseCommand} --resume ${sessionArg}` + case 'antigravity': + return `${baseCommand} --conversation ${sessionArg}` } } diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts index 99cd9bbb8dc..7d91bd45576 100644 --- a/src/shared/claude-subagent-roster.test.ts +++ b/src/shared/claude-subagent-roster.test.ts @@ -6,117 +6,53 @@ import { claudeTeammateIdMatchesName, finishClaudeSubagent, foldClaudeBackgroundTasksIntoRoster, - markClaudeTeammateIdleByName, readClaudeBackgroundAgentTasks, + removeClaudeTeammateByName, upsertWorkingClaudeSubagent, type ClaudeSubagentRoster } from './claude-subagent-roster' +const task = ( + over: Partial['tasks'][number]> +) => ({ + id: 'a1', + agentType: undefined, + description: undefined, + running: true, + teammate: false, + ...over +}) + describe('claude-subagent-roster', () => { it('removes a finished one-shot subagent on stop', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'a1', { agentType: 'general-purpose' }, 100) expect(claudeRosterHasWorkingSubagent(roster)).toBe(true) - // Why: retaining finished one-shots as idle rows piled up dozens of dead + // Why: retaining finished children as idle rows piled up dozens of dead // "Idle - general-purpose" sidebar rows over a long workflow session. finishClaudeSubagent(roster, 'a1') expect(roster.size).toBe(0) expect(claudeRosterToSnapshots(roster)).toBeUndefined() }) - it('idles a teammate on stop instead of removing it', () => { + it('removes a finished teammate-shaped named agent on stop', () => { const roster: ClaudeSubagentRoster = new Map() + // Why: named/workflow agents report teammate-shaped ids, and their + // background_tasks teammate entries never stop reading "running" — so + // SubagentStop is the only reliable finish signal and must remove the row. upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') - expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle', teammate: true }) + expect(roster.has('aprobe1-6d3cb5b5')).toBe(false) }) - it('removes a finished workflow lane despite its teammate-shaped id when task-listed', () => { - const roster: ClaudeSubagentRoster = new Map() - // Why: workflow lanes get name-embedding ids (afinder-C-) like - // teammates, but their SubagentStop payload lists them id-exact as a - // subagent task — real teammate lifecycle ids never appear there. - upsertWorkingClaudeSubagent( - roster, - 'afinder-C-5d713c0781b7f8d2', - { agentType: 'finder-C' }, - 100 - ) - finishClaudeSubagent(roster, 'afinder-C-5d713c0781b7f8d2', { listedAsSubagentTask: true }) - expect(roster.size).toBe(0) - }) - - it('reclassifies a task-listed teammate-shaped entry as a one-shot', () => { - const roster: ClaudeSubagentRoster = new Map() - upsertWorkingClaudeSubagent( - roster, - 'av1-streaming-0b1c2d3e', - { agentType: 'v1-streaming' }, - 100 - ) - foldClaudeBackgroundTasksIntoRoster( - roster, - [ - { - id: 'av1-streaming-0b1c2d3e', - agentType: 'v1-streaming', - description: undefined, - running: true, - teammate: false - }, - { - id: 'tteam1', - agentType: undefined, - description: undefined, - running: true, - teammate: true - } - ], - 200 - ) - // A later stop without its own inventory still removes it: the fold - // already proved the id is a task id, not a teammate. - finishClaudeSubagent(roster, 'av1-streaming-0b1c2d3e') - expect(roster.has('av1-streaming-0b1c2d3e')).toBe(false) - }) - - it('removes teammate-shaped leftovers when a complete inventory lists no teammates', () => { - const roster: ClaudeSubagentRoster = new Map() - upsertWorkingClaudeSubagent(roster, 'acr-triage-1-c5a0588e', { agentType: 'cr-triage-1' }, 100) - finishClaudeSubagent(roster, 'acr-triage-1-c5a0588e') - expect(roster.get('acr-triage-1-c5a0588e')).toMatchObject({ state: 'idle' }) - upsertWorkingClaudeSubagent(roster, 'afix-main-11223344', { agentType: 'fix-main' }, 150) - - // Why: a teams session lists its teammates (even idle) as teammate-typed - // tasks; an inventory with none proves these name-shaped rows are dead - // workflow lanes, not resumable teammates. - foldClaudeBackgroundTasksIntoRoster( - roster, - [ - { - id: 'aunrelated0000001', - agentType: 'general-purpose', - description: undefined, - running: true, - teammate: false - } - ], - 200 - ) - expect(roster.has('acr-triage-1-c5a0588e')).toBe(false) - expect(roster.has('afix-main-11223344')).toBe(false) - expect(roster.has('aunrelated0000001')).toBe(true) - }) - - it('re-marks an idle teammate working without resetting startedAt', () => { + it('re-adds a resumed agent as working with a fresh startedAt', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { description: 'round two' }, 200) expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ - state: 'working', - startedAt: 100, + startedAt: 200, description: 'round two' }) }) @@ -127,22 +63,39 @@ describe('claude-subagent-roster', () => { expect(roster.size).toBe(0) }) - it('caps roster size, evicting the oldest idle entry first', () => { + it('drops new spawns at the cap rather than evicting live children', () => { const roster: ClaudeSubagentRoster = new Map() - upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 0) - for (let i = 1; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { + for (let i = 0; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { upsertWorkingClaudeSubagent(roster, `a${i}`, {}, i) } - // Why: all working — a new spawn cannot evict live children and is dropped. + // Why: every tracked entry is working — nothing is safe to evict, so the + // overflow spawn is dropped (it would be invisible past the wire cap). upsertWorkingClaudeSubagent(roster, 'overflow', {}, 999) expect(roster.has('overflow')).toBe(false) + expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) - // Why: only teammates hold idle entries now; an idle teammate is the - // eviction pool when a burst of new spawns hits the cap. - finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') + // Once a child finishes, a new spawn takes the freed slot. + finishClaudeSubagent(roster, 'a0') upsertWorkingClaudeSubagent(roster, 'replacement', {}, 1000) expect(roster.has('replacement')).toBe(true) - expect(roster.has('aprobe1-6d3cb5b5')).toBe(false) + expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) + }) + + it('reconciles stale entries before adding replacement tasks at the cap', () => { + const roster: ClaudeSubagentRoster = new Map() + for (let i = 0; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { + upsertWorkingClaudeSubagent(roster, `a${i}`, {}, i) + } + const tasks = Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS }, (_, index) => + task({ id: index === 0 ? 'replacement' : `a${index}` }) + ) + + // Why: a complete inventory can replace a stale child while the roster is + // full. Reap the stale slot first so the new live child keeps the done-gate. + foldClaudeBackgroundTasksIntoRoster(roster, tasks, 999) + + expect(roster.has('a0')).toBe(false) + expect(roster.has('replacement')).toBe(true) expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) }) @@ -197,70 +150,102 @@ describe('claude-subagent-roster', () => { expect(result.truncated).toBe(true) }) - it('folds background_tasks in without trusting ambiguous entries', () => { + it('trusts id-exact subagent matches and ignores teammate-typed entries', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'a1', {}, 100) + // A named agent mid-run: teammate-shaped id, never a task id. upsertWorkingClaudeSubagent(roster, 'ateam-6d3cb5b5', { agentType: 'security-reviewer' }, 150) foldClaudeBackgroundTasksIntoRoster( roster, [ - { - id: 'a1', - agentType: 'general-purpose', - description: 'review loop', - running: true, - teammate: false - }, + task({ id: 'a1', agentType: 'general-purpose', description: 'review loop' }), // Why: teammate task ids never match lifecycle agent_ids; unmatched // teammate entries must not create phantom duplicate children. - { - id: 'tlkjjs0jv', - agentType: undefined, - description: 'teammate task', - running: true, - teammate: true - } + task({ id: 'tlkjjs0jv', description: 'teammate task', teammate: true }) ], 200 ) expect(roster.size).toBe(2) - // Why: id-exact matches are one-shot subagents whose run state IS reliable. - expect(roster.get('a1')).toMatchObject({ state: 'working', description: 'review loop' }) - // Why: the working lifecycle-tracked teammate is not listed by id, but - // omission proves nothing for teammates — it must survive the fold. - expect(roster.get('ateam-6d3cb5b5')).toMatchObject({ - state: 'working', - agentType: 'security-reviewer' - }) + expect(roster.get('a1')).toMatchObject({ description: 'review loop' }) + // Why: the working named agent is not listed by id, but a teammate-typed + // task is present — so omission proves nothing and it survives the fold. + expect(roster.get('ateam-6d3cb5b5')).toMatchObject({ agentType: 'security-reviewer' }) }) - it('removes an id-matched task reported not running', () => { + it('removes an id-matched subagent task reported not running', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'a1', { agentType: 'general-purpose' }, 100) - foldClaudeBackgroundTasksIntoRoster( - roster, - [{ id: 'a1', agentType: undefined, description: undefined, running: false, teammate: false }], - 200 - ) + foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'a1', running: false })], 200) expect(roster.size).toBe(0) }) it('removes a killed one-shot missing from a present list', () => { const roster: ClaudeSubagentRoster = new Map() // Why: a running one-shot is always listed id-exact at a lead Stop, so a - // working non-teammate missing from the list is dead (SubagentStop lost); - // keeping it pinned the pane 'working' forever. + // working hyphen-free child missing from the list is dead (SubagentStop + // lost); keeping it pinned the pane 'working' forever. upsertWorkingClaudeSubagent(roster, 'akilled0000000001', { agentType: 'general-purpose' }, 100) + foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'other', teammate: true })], 200) + expect(roster.size).toBe(0) + }) + + it('keeps a live named agent whose teammate-typed id never appears in the list', () => { + const roster: ClaudeSubagentRoster = new Map() + // Why: this is the core regression — a running named agent (teammate-shaped + // id, lifecycle-tracked, never a task id) must survive a lead Stop whose + // list still shows teammate-typed tasks, or the pane resolves done early. + upsertWorkingClaudeSubagent( + roster, + 'aweb-research-8a76b7d7', + { agentType: 'web-research' }, + 100 + ) + foldClaudeBackgroundTasksIntoRoster( + roster, + [task({ id: 't6s2brfv7', description: 'named agent task', teammate: true })], + 200 + ) + expect(roster.has('aweb-research-8a76b7d7')).toBe(true) + }) + + it('removes teammate-shaped leftovers when a complete inventory lists no teammates', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'acr-triage-1-c5a0588e', { agentType: 'cr-triage-1' }, 100) + // Why: a session with named agents/teammates always lists at least one + // teammate-typed task while any is alive; an inventory with none proves + // this teammate-shaped row is dead (its SubagentStop was lost). + foldClaudeBackgroundTasksIntoRoster( + roster, + [task({ id: 'aunrelated0000001', agentType: 'general-purpose' })], + 200 + ) + expect(roster.has('acr-triage-1-c5a0588e')).toBe(false) + expect(roster.has('aunrelated0000001')).toBe(true) + }) + + it('removes a leftover once a subagent-typed task listed its id id-exact', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent( + roster, + 'av1-streaming-0b1c2d3e', + { agentType: 'v1-streaming' }, + 100 + ) + // A subagent-typed task lists it id-exact → tag it listedAsSubagentTask. foldClaudeBackgroundTasksIntoRoster( roster, [ - { id: 'other', agentType: undefined, description: undefined, running: true, teammate: true } + task({ id: 'av1-streaming-0b1c2d3e', agentType: 'v1-streaming' }), + task({ id: 'tteam1', teammate: true }) ], 200 ) - expect(roster.size).toBe(0) + // A later Stop omits it while a teammate-typed task is still present: the + // subagent-listing proof overrides the teammate-shape protection. + foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'tteam1', teammate: true })], 300) + expect(roster.has('av1-streaming-0b1c2d3e')).toBe(false) }) it('retains an unlisted live child when the background task inventory was truncated', () => { @@ -285,25 +270,13 @@ describe('claude-subagent-roster', () => { foldClaudeBackgroundTasksIntoRoster( roster, [ - { - id: 'a9', - agentType: 'general-purpose', - description: 'long build', - running: true, - teammate: false - }, - { - id: 'gone', - agentType: undefined, - description: undefined, - running: false, - teammate: false - } + task({ id: 'a9', agentType: 'general-purpose', description: 'long build' }), + task({ id: 'gone', running: false }) ], 500 ) - expect(roster.get('a9')).toMatchObject({ state: 'working', startedAt: 500 }) - // Why: a finished unmatched one-shot leaves no reason to add an idle row. + expect(roster.get('a9')).toMatchObject({ startedAt: 500 }) + // Why: a finished unmatched one-shot leaves no reason to add a row. expect(roster.has('gone')).toBe(false) }) @@ -314,86 +287,46 @@ describe('claude-subagent-roster', () => { expect(roster.size).toBe(0) }) - it('removes non-teammate authoritative entries and keeps live teammates on omission', () => { + it('does not clear the roster on an empty but incomplete (truncated) inventory', () => { const roster: ClaudeSubagentRoster = new Map() - roster.set('a-phantom', { - state: 'working', - startedAt: 100, - backgroundTasksAuthoritative: true - }) - upsertWorkingClaudeSubagent(roster, 'ateam-6d3cb5b5', { agentType: 'security-reviewer' }, 150) - - foldClaudeBackgroundTasksIntoRoster( - roster, - [ - { id: 'other', agentType: undefined, description: undefined, running: true, teammate: true } - ], - 200 - ) - expect(roster.has('a-phantom')).toBe(false) - expect(roster.get('ateam-6d3cb5b5')).toMatchObject({ state: 'working' }) + upsertWorkingClaudeSubagent(roster, 'a1', {}, 100) + foldClaudeBackgroundTasksIntoRoster(roster, [], 200, { inventoryComplete: false }) + expect(roster.has('a1')).toBe(true) }) - it('demotes a seeded teammate phantom missing from a present list to idle', () => { + it('removes a seeded phantom missing from a present list', () => { const roster: ClaudeSubagentRoster = new Map() - // Why: a teammate seeded from a pre-restart snapshot may be dead, but its - // task id never appears in background_tasks — demote instead of delete so - // a live idle teammate keeps its row while a phantom stops gating the pane. + // A snapshot-seeded entry (child finished while Orca was down) is + // authoritative — a present list omitting it removes it even though its + // id is teammate-shaped. roster.set('aprobe1-6d3cb5b5', { - state: 'working', startedAt: 100, agentType: 'probe1', - teammate: true, backgroundTasksAuthoritative: true }) - foldClaudeBackgroundTasksIntoRoster( - roster, - [ - { id: 'other', agentType: undefined, description: undefined, running: true, teammate: true } - ], - 200 - ) - expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle', teammate: true }) + foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'other', teammate: true })], 200) + expect(roster.has('aprobe1-6d3cb5b5')).toBe(false) }) - it('removes fold-recreated entries missing from a later present list', () => { - const roster: ClaudeSubagentRoster = new Map() - foldClaudeBackgroundTasksIntoRoster( - roster, - [{ id: 'a9', agentType: undefined, description: undefined, running: true, teammate: false }], - 100 - ) - foldClaudeBackgroundTasksIntoRoster( - roster, - [ - { id: 'other', agentType: undefined, description: undefined, running: true, teammate: true } - ], - 200 - ) - expect(roster.has('a9')).toBe(false) - }) - - it('keeps a re-tracked working teammate missing from a present list', () => { + it('keeps a re-tracked working named agent missing from a present list', () => { const roster: ClaudeSubagentRoster = new Map() roster.set('aprobe1-6d3cb5b5', { - state: 'working', startedAt: 100, agentType: 'probe1', - teammate: true, backgroundTasksAuthoritative: true }) - // Why: live activity clears the authoritative flag; a busy teammate must - // not be demoted by a Stop that (as always) omits its lifecycle id. + // Why: live activity clears the authoritative flag; a busy named agent + // must not be reaped by a Stop that (as always) omits its lifecycle id. upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 150) + foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'other', teammate: true })], 200) + expect(roster.has('aprobe1-6d3cb5b5')).toBe(true) + }) - foldClaudeBackgroundTasksIntoRoster( - roster, - [ - { id: 'other', agentType: undefined, description: undefined, running: true, teammate: true } - ], - 200 - ) - expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'working' }) + it('removes fold-recreated one-shots missing from a later present list', () => { + const roster: ClaudeSubagentRoster = new Map() + foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'a9' })], 100) + foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'other', teammate: true })], 200) + expect(roster.has('a9')).toBe(false) }) it('matches teammate ids by name only up to the hyphen-free suffix', () => { @@ -404,17 +337,29 @@ describe('claude-subagent-roster', () => { expect(claudeTeammateIdMatchesName('aprobe1', 'probe1')).toBe(false) }) - it('marks teammates idle by name via agent_type or agent_id prefix', () => { + it('removes teammates by the name embedded in agent_id', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) upsertWorkingClaudeSubagent(roster, 'aother-123', { agentType: 'other' }, 100) - expect(markClaudeTeammateIdleByName(roster, 'probe1')).toBe(true) - expect(roster.get('aprobe1-6d3cb5b5')?.state).toBe('idle') - expect(roster.get('aother-123')?.state).toBe('working') - // Why: repeat idles are no-ops so lifecycle refreshes don't churn state. - expect(markClaudeTeammateIdleByName(roster, 'probe1')).toBe(false) - expect(markClaudeTeammateIdleByName(roster, 'ghost')).toBe(false) + // Why: TeammateIdle is keyed by name — idle means finished, so the row goes. + expect(removeClaudeTeammateByName(roster, 'probe1')).toBe(true) + expect(roster.has('aprobe1-6d3cb5b5')).toBe(false) + expect(roster.has('aother-123')).toBe(true) + // Repeat/unknown removals are no-ops so lifecycle refreshes don't churn. + expect(removeClaudeTeammateByName(roster, 'probe1')).toBe(false) + expect(removeClaudeTeammateByName(roster, 'ghost')).toBe(false) + }) + + it('does not remove an unrelated one-shot whose agent_type matches the teammate name', () => { + const roster: ClaudeSubagentRoster = new Map() + // Why: a teammate's start hook may be missing (restart, cap, or lost + // delivery). Agent type is not identity, so its idle hook must not reap + // another live child that happens to use the same type name. + upsertWorkingClaudeSubagent(roster, 'aoneshot00000001', { agentType: 'reviewer' }, 100) + + expect(removeClaudeTeammateByName(roster, 'reviewer')).toBe(false) + expect(roster.has('aoneshot00000001')).toBe(true) }) it('serializes snapshots deterministically ordered by startedAt then id', () => { @@ -422,7 +367,10 @@ describe('claude-subagent-roster', () => { upsertWorkingClaudeSubagent(roster, 'b', {}, 200) upsertWorkingClaudeSubagent(roster, 'z', {}, 100) upsertWorkingClaudeSubagent(roster, 'a', {}, 100) - expect(claudeRosterToSnapshots(roster)?.map((s) => s.id)).toEqual(['a', 'z', 'b']) + const snapshots = claudeRosterToSnapshots(roster) + expect(snapshots?.map((s) => s.id)).toEqual(['a', 'z', 'b']) + // Why: only working children are tracked, so every emitted row is working. + expect(snapshots?.every((s) => s.state === 'working')).toBe(true) expect(claudeRosterToSnapshots(new Map())).toBeUndefined() }) }) diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index b0b945a76e2..a94cbae0aa0 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -5,28 +5,33 @@ import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent- * invisible in the emitted snapshots (which drop such ids). */ const CLAUDE_SUBAGENT_ID_MAX_LENGTH = 64 -/** Live subagents/teammates tracked for one Claude pane, keyed by the - * provider-assigned `agent_id` from SubagentStart/SubagentStop payloads. */ +/** Currently WORKING subagents/teammates tracked for one Claude pane, keyed + * by the provider-assigned `agent_id` from SubagentStart/SubagentStop + * payloads. The roster intentionally holds only working children: a child + * that finished leaves the sidebar immediately. Claude gives no other + * finish signal for named agents — their `background_tasks` teammate + * entries stay `status: "running"` forever, even after they complete + * (verified live on 2.1.210) — so retaining "idle" rows piled up dead + * entries for hours. A teammate resumed later re-earns its row via + * SubagentStart. */ export type ClaudeSubagentRoster = Map export type TrackedClaudeSubagent = { agentType?: string description?: string - state: 'working' | 'idle' startedAt: number - /** Known agent-teams teammate: its id embeds its name (`a-`) - * while one-shot ids are hyphen-free (`a`). Teammates are long-lived — - * SubagentStop means "finished a task", not "gone" — and their lifecycle - * ids never appear in background_tasks, so omission proves nothing. */ - teammate?: true /** The id came from a persisted snapshot or background_tasks, not live - * lifecycle events. Only matters for teammate-shaped seeds now: a PRESENT - * list omitting a seeded-working teammate demotes it to idle so a phantom - * seeded before restart can't gate the pane 'working' forever (teams - * sessions never send an empty list). Cleared once live activity re-tracks - * the id. Non-teammate entries omitted from a present list are removed - * outright regardless of this flag. */ + * lifecycle events, so it may be a phantom whose SubagentStop was never + * observed (Orca restart). A present complete task list omitting it + * removes it even when teammate-shaped, so it can't gate the pane + * 'working' forever. Cleared once live activity re-tracks the id. */ backgroundTasksAuthoritative?: boolean + /** A subagent-typed background task listed this lifecycle id id-exact + * (workflow/named lanes) — proof the task list tracks this id, so a later + * complete list omitting it means finished/killed even though the id is + * teammate-shaped. Never cleared: the listing mode of an id can't change + * mid-life. */ + listedAsSubagentTask?: true } /** One agent entry from the `background_tasks` array Claude attaches to Stop @@ -37,14 +42,15 @@ export type ClaudeBackgroundAgentTask = { agentType?: string description?: string running: boolean - /** True for `type: "teammate"` entries, whose ids never match lifecycle - * agent_ids and whose "running" status persists while idle. */ + /** True for `type: "teammate"` entries. Their ids never match lifecycle + * agent_ids and they report "running" permanently — even after the named + * agent finished — so they carry no per-agent state at all. */ teammate: boolean } -/** Agent-team lifecycle ids are `a-`. The teammate name - * and agent type are independent spawn fields, so the id shape is the only - * reliable discriminator available on SubagentStart/SubagentStop hooks. */ +/** Agent-team/named-agent lifecycle ids are `a-` while one-shot + * ids are hyphen-free (`a`). Such ids are never listed as task ids in + * `background_tasks`, so omission from the list proves nothing for them. */ export function isClaudeTeammateLifecycleId(id: string): boolean { const separator = id.lastIndexOf('-') return separator > 1 && id.startsWith('a') && /^[0-9a-f]+$/i.test(id.slice(separator + 1)) @@ -59,73 +65,33 @@ export function upsertWorkingClaudeSubagent( if (id.length === 0 || id.length > CLAUDE_SUBAGENT_ID_MAX_LENGTH) { return } - const teammate = isClaudeTeammateLifecycleId(id) const existing = roster.get(id) if (existing) { - existing.state = 'working' existing.agentType = fields.agentType ?? existing.agentType existing.description = fields.description ?? existing.description - if (teammate) { - existing.teammate = true - } // Why: live activity proves the lifecycle stream owns this id again; - // background_tasks absence must stop demoting it (teammate ids never - // appear there). The fold re-tags its own recreations after this call. + // background_tasks omission must stop reaping it (teammate-shaped ids + // never appear there). The fold re-tags its own recreations after this. existing.backgroundTasksAuthoritative = undefined return } - if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS && !evictOldestIdleClaudeSubagent(roster)) { + // Why: beyond the wire cap extra rows would be invisible anyway; with only + // working entries tracked there is nothing safe to evict. + if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS) { return } roster.set(id, { - state: 'working', startedAt: now, agentType: fields.agentType, - description: fields.description, - ...(teammate ? { teammate: true as const } : {}) + description: fields.description }) } -function evictOldestIdleClaudeSubagent(roster: ClaudeSubagentRoster): boolean { - let oldestId: string | null = null - let oldestStartedAt = Infinity - for (const [id, tracked] of roster) { - if (tracked.state === 'idle' && tracked.startedAt < oldestStartedAt) { - oldestId = id - oldestStartedAt = tracked.startedAt - } - } - if (oldestId === null) { - return false - } - roster.delete(oldestId) - return true -} - -/** SubagentStop: a finished one-shot subagent leaves the sidebar immediately — - * retaining it as an idle row made long workflow/ultracode sessions pile up - * dozens of dead rows. Teammates only idle (alive + resumable): SubagentStop - * fires each time a teammate finishes a task, not just at shutdown. - * - * Workflow/named one-shots share the teammate id shape (`a