diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 31fd088d324..189428c47fe 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -56,17 +56,15 @@ CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-type ## Pull Requests -Each pull request should: +Each pull request should follow [`.github/pull_request_template.md`](./pull_request_template.md). In particular: -- explain the user-visible change -- stay focused on a single topic when possible -- include screenshots or screen recordings for new UI or behavior changes +- open with an ELI5 of the change (plain language paragraph; the PR title is the one-liner) +- explain what changed and why, and stay focused on a single topic when possible +- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual change, say `No visual change` and why - include high-quality tests when behavior changes or bug fixes warrant them - include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk - mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes -- **Include your X (Twitter) handle!** We love giving shoutouts to our contributors when we merge features on [@orca_build](https://x.com/orca_build). - -If there is no visual change, say that explicitly in the PR description. +- **Include your X (Twitter) handle** in the PR template Author section — we shout out contributors when we merge features on [@orca_build](https://x.com/orca_build). ## Release Process diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b69e2dcc233..21f7fa08eb8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,29 +1,52 @@ -## Summary +## ELI5 -Describe the user-visible change. + -## Screenshots +## What Changed -- Add screenshots or a screen recording for any new or changed UI behavior. -- If there is no visual change, say `No visual change`. + + +## Why + + + +## Linked Issue + + + +Fixes # + +## Visual Proof + + + ## Testing -- [ ] `pnpm lint` -- [ ] `pnpm typecheck` -- [ ] `pnpm test` -- [ ] `pnpm build` -- [ ] Added or updated high-quality tests that would catch regressions, or explained why tests were not needed + -## AI Review Report +- [ ] I manually tested these changes locally +- [ ] Automated tests added/updated, or explained why not below -Summarize the code review you ran with your AI coding agent. Include the main risks it checked, what it flagged, and what you changed or verified as a result. -Confirm that the review explicitly checked cross-platform compatibility for macOS, Linux, and Windows, including shortcuts, labels, paths, shell behavior, and any Electron-specific platform differences touched by this PR. -## Security Audit +## AI Disclosure + + -Provide a basic security audit summary from your AI coding agent. Call out any input handling, command execution, path handling, auth, secrets, dependency, or IPC risks that were reviewed, plus any follow-up needed. +## Review -## Notes +Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance -Call out any platform-specific behavior, risks, or follow-up work. +## Checklist + +- [ ] This PR is small and focused +- [ ] I explained what changed and why (including ELI5) +- [ ] Before/after screenshots or videos attached for UI changes, or `N/A` with reason +- [ ] Self-reviewed for correctness, security, and performance +- [ ] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A) +- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred) + +## Author + +- X / Twitter: @your_handle + diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5455d0b7fb8..9838baf4afd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -126,7 +126,7 @@ jobs: # scripts still need the Linux toolchain even though this shard reuses # the prebuilt Electron output. - name: Install native build tools - run: sudo apt-get update && sudo apt-get install -y build-essential python3 + run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh # Why: Electron on Linux needs an X display even when the app # suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a @@ -174,7 +174,7 @@ jobs: # ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron # launches but never creates a BrowserWindow. - name: Run E2E tests (${{ matrix.shard_name }}) - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e --shard=${{ matrix.shard }} + run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 pnpm run test:e2e --shard=${{ matrix.shard }} # Why: Playwright retains traces/screenshots only on failure. Uploading # them as an artifact makes post-mortem debugging on CI possible without @@ -202,7 +202,7 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb + run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh - name: Setup Node.js uses: actions/setup-node@v6 @@ -232,8 +232,15 @@ jobs: env: TEST_FILES_JSON: ${{ inputs.test_files }} run: | - mapfile -t TEST_FILES < <(jq -r '.[]' <<<"$TEST_FILES_JSON") - xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 \ + mapfile -t TEST_FILES < <(jq -r '.[] | select( + . != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and + . != "tests/e2e/paired-startup-exec-readiness.spec.ts" + )' <<<"$TEST_FILES_JSON") + if [ "${#TEST_FILES[@]}" -eq 0 ]; then + echo "Changed startup-readiness specs are owned by the dedicated live lane." + exit 0 + fi + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 \ pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 - name: Upload Playwright traces @@ -248,10 +255,13 @@ jobs: ssh-docker-watcher-isolation: name: ssh docker watcher isolation needs: build - if: inputs.test_files == '' + if: >- + inputs.test_files == '' || + contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') || + contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts') runs-on: ubuntu-latest - # Why 35: the terminal parking + retention specs below add two more - # docker-rig tests capped at 240s each on top of the watcher isolation pair. + # Why 35: the parking, retention, startup-exec, and paired parity specs run + # serially on isolated Electron/SSH fixtures after watcher isolation. timeout-minutes: 35 steps: @@ -261,7 +271,7 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb + run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb zsh - name: Setup Node.js uses: actions/setup-node@v6 @@ -295,10 +305,9 @@ jobs: - name: Run Docker SSH watcher isolation E2E run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation - # Why always(): these specs gate the C1 terminal parking/retention budget - # over a real SSH host and run in no other lane, so a watcher-isolation - # failure above must not silently skip them. - - name: Run Docker SSH terminal parking + retention E2E + # Why always(): this lane gates SSH parking/retention plus startup-exec + # readiness across live SSH, headed paired, and headless serve topologies. + - name: Run Docker SSH terminal parking + startup readiness E2E if: always() run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 89c1c86096f..5a2562bf2ba 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -23,10 +23,9 @@ jobs: platform: linux - os: macos-15 platform: mac - # Why: Windows golden E2E is temporarily disabled on CI while its - # flaky runner-only failures are investigated. - # - os: windows-latest - # platform: windows + # Match the release gate: Windows runs the fresh-startup golden only. + - os: windows-2022 + platform: windows steps: - name: Checkout @@ -67,12 +66,22 @@ jobs: run: | xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden - name: Run golden E2E tests on macOS if: runner.os == 'macOS' run: | env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden + + - name: Run golden E2E tests on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $env:SKIP_BUILD = '1' + $env:ORCA_E2E_FORWARD_APP_LOGS = '1' + pnpm run test:e2e:windows-fresh-startup-golden - name: Upload Playwright traces if: failure() diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d10463173dd..0c1789bf3ea 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -176,6 +176,12 @@ jobs: shell_contracts: name: shell contracts runs-on: ubuntu-latest + env: + # Why: the suites below gate their live fish tests on the binary, which is + # right on a developer machine and wrong here — this job is a required check + # and its fish lane is the only end-to-end guard for #9993, so a skip would + # report green with nothing exercised. Turns those skips into failures. + ORCA_REQUIRE_FISH: '1' steps: - name: Checkout @@ -186,8 +192,46 @@ jobs: # Why fish: shell-ready.test.ts gates its live fish test on the binary being # present, so without this the fish barrier is only covered by config-shape # assertions and never actually exercised. + # Why release-4: DECSET 2031 arming lives in the fish 4.0 Rust tty_handoff, and + # fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships + # 3.7, so the PPA is what makes that lane real. - name: Install zsh and fish - run: sudo apt-get update && sudo apt-get install -y zsh fish + run: | + # Why the update/PPA/fish steps are tolerant: a repo the runner image already + # ships can lack a Release file for this suite, and a failed add-apt-repository + # still leaves its list entry behind — either makes `apt-get update` exit + # non-zero and would red this required check over something unrelated to the + # PR. Every fish outcome is judged by the version gate below instead, so only + # the zsh install (which has no such gate) stays fatal here. + sudo apt-get update || true + # Why retry only here: adding the PPA is the network-flaky step, and the + # version gate below is fatal, so a transient Launchpad blip would + # otherwise red a required check on PRs unrelated to shells. + for attempt in 1 2 3; do + sudo add-apt-repository -y ppa:fish-shell/release-4 && break + echo "add-apt-repository attempt ${attempt} failed; retrying" >&2 + sudo add-apt-repository -y -r ppa:fish-shell/release-4 || true + sleep 5 + done + sudo apt-get update || true + # Why both shells on one line: pr-workflow-parallelism.test.mjs parses only the + # first install command in this step to prove the lane really installs them. + sudo apt-get install -y zsh fish + + # Separate from the install so the failure names the contract, not an apt error. + # ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds + # instead of after a full dependency install. + - name: Require fish 4+ + run: | + version="$(fish --version 2>/dev/null || true)" + major="${version##*version }" + major="${major%%.*}" + case "$major" in '' | *[!0-9]*) major=0 ;; esac + echo "${version:-}" + if [ "$major" -lt 4 ]; then + echo "::error::shell contracts needs fish 4+ (DECSET 2031 arming, #9993) but got '${version:-none}'. Fix the ppa:fish-shell/release-4 install rather than letting the fish lane skip." >&2 + exit 1 + fi - uses: ./.github/actions/install-node-dependencies with: @@ -195,12 +239,14 @@ jobs: - name: Test real shell contracts run: | - pnpm exec vitest run --config config/vitest.config.ts \ + pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \ + src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ src/main/daemon/shell-ready.test.ts \ src/main/daemon/node-pty-fd-leak.test.ts \ src/main/providers/local-pty-shell-ready.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ + src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ src/shared/posix-command-path-lookup.test.ts test: @@ -230,11 +276,13 @@ jobs: - name: Test shard run: | pnpm exec vitest run --config config/vitest.config.ts \ + --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ --exclude=src/main/daemon/shell-ready.test.ts \ --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ --exclude=src/main/providers/local-pty-shell-ready.test.ts \ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ + --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ --exclude=src/shared/posix-command-path-lookup.test.ts \ --exclude=tests/e2e/cross-version-wire/** \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} @@ -378,8 +426,12 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Test Windows PowerShell CLI boundary - run: pnpm exec vitest run --config config/vitest.config.ts src/main/cli/wsl-cli-powershell-boundary.test.ts + - name: Test Windows-specific boundaries + run: >- + pnpm exec vitest run --config config/vitest.config.ts + src/main/cli/wsl-cli-powershell-boundary.test.ts + src/main/orca-profiles/profile-index-store.test.ts + src/shared/secure-file-fsync-flags.test.ts - name: Build package inputs run: pnpm run build:release diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 6dba5ac1ff6..370fbcc0a32 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -783,7 +783,7 @@ jobs: { echo "## Release E2E Signal" echo "" - echo "- Terminal rendering golden is release-blocking." + echo "- Platform golden E2E is release-blocking: terminal rendering and restrictive-umask profile writes on Linux/macOS, plus fresh startup on Windows." echo "- Full E2E runs separately after publication and cannot change the release result." echo "- Terminal rendering release evidence is diagnostic/non-blocking." echo "" @@ -817,7 +817,7 @@ jobs: terminal-rendering-golden: needs: cut if: needs.cut.outputs.should_release == 'true' - name: terminal rendering golden ${{ matrix.platform }} + name: golden e2e ${{ matrix.platform }} runs-on: ${{ matrix.os }} timeout-minutes: 30 env: @@ -830,10 +830,10 @@ jobs: platform: linux - os: macos-15 platform: mac - # Why: Windows terminal rendering golden is temporarily disabled on - # CI while its flaky runner-only failures are investigated. - # - os: windows-latest - # platform: windows + # Windows terminal rendering remains flaky; keep its blocking signal + # scoped to the fresh-profile startup regression from #14130. + - os: windows-2022 + platform: windows steps: - name: Checkout @@ -866,22 +866,34 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build Electron app for terminal rendering golden + - name: Build Electron app for platform golden run: npx electron-vite build --mode e2e - name: Run terminal rendering golden on Linux if: runner.os == 'Linux' - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + run: | + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden - name: Run terminal rendering golden on macOS if: runner.os == 'macOS' - run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + run: | + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden + + - name: Run fresh-startup golden on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $env:SKIP_BUILD = '1' + $env:ORCA_E2E_FORWARD_APP_LOGS = '1' + pnpm run test:e2e:windows-fresh-startup-golden - name: Upload Playwright traces if: failure() uses: actions/upload-artifact@v7 with: - name: terminal-rendering-golden-${{ matrix.platform }}-playwright-traces + name: golden-e2e-${{ matrix.platform }}-playwright-traces path: test-results/ retention-days: 7 if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 17bb18529af..8fb2d756f1c 100644 --- a/.gitignore +++ b/.gitignore @@ -102,7 +102,9 @@ docs/** !docs/reference/linux-glibc-compatibility.md !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/remote-wire-compatibility.md +!docs/reference/renderer-agent-status-performance.md !docs/reference/windows-setup-shell.md +!docs/reference/worktree-scan-fingerprint.md # Stably CLI (only docs/ are tracked) .stably/* @@ -125,6 +127,10 @@ playwright-report/ /.agents/skills/ /skills-lock.json +# Generated Clawpatch state includes machine-local paths and review records. +/.clawpatch/ +/mobile/.clawpatch/ + # Agent hook runtime endpoints (machine-local secrets) /agent-hooks/ validation-screenshots/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 0f679f17d52..97a2b68701a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -56,6 +56,7 @@ "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "typescript/array-type": "error", + "typescript/consistent-generic-constructors": "error", "typescript/consistent-indexed-object-style": "error", "typescript/consistent-type-assertions": "error", "typescript/consistent-type-definitions": ["error", "type"], @@ -68,12 +69,16 @@ "typescript/prefer-function-type": "error", "typescript/prefer-includes": "error", "typescript/prefer-optional-chain": "error", + "typescript/prefer-ts-expect-error": "error", "typescript/switch-exhaustiveness-check": [ "error", { "allowDefaultCaseForExhaustiveSwitch": false } ], "curly": "error", "no-unneeded-ternary": "error", + "no-else-return": "error", + "prefer-exponentiation-operator": "error", + "prefer-object-has-own": "error", "no-restricted-imports": [ "error", { @@ -90,11 +95,16 @@ "no-useless-return": "error", "prefer-template": "error", "unicorn/consistent-empty-array-spread": "error", + "unicorn/consistent-existence-index-check": "error", + "unicorn/empty-brace-spaces": "error", "unicorn/error-message": "error", "unicorn/no-array-fill-with-reference-type": "warn", "unicorn/no-array-reverse": "error", "unicorn/no-instanceof-builtins": "error", + "unicorn/no-typeof-undefined": "error", + "unicorn/no-unnecessary-array-splice-count": "error", "unicorn/no-useless-promise-resolve-reject": "error", + "unicorn/no-zero-fractions": "error", "unicorn/prefer-array-find": "error", "unicorn/prefer-array-flat-map": "warn", "unicorn/prefer-array-index-of": "error", diff --git a/config/electron-vite-target.config.ts b/config/electron-vite-target.config.ts index 60651dd6590..490fa654f5b 100644 --- a/config/electron-vite-target.config.ts +++ b/config/electron-vite-target.config.ts @@ -8,7 +8,7 @@ const configByTarget = { renderer: { renderer: electronViteConfig.renderer } } -if (!target || !Object.prototype.hasOwnProperty.call(configByTarget, target)) { +if (!target || !Object.hasOwn(configByTarget, target)) { throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? ''}`) } diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 4554e6bba8b..0cad754a205 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -10,7 +10,6 @@ inline src/main/agent-hooks/server.test.ts inline src/main/agent-hooks/server.ts inline src/main/amp/hook-service.ts inline src/main/antigravity/hook-service.ts -inline src/main/attribution/terminal-attribution.ts inline src/main/automations/external-manager.ts inline src/main/automations/hermes-cron-output.ts inline src/main/browser/agent-browser-bridge.test.ts diff --git a/config/oxlint-code-quality-native-plugins.json b/config/oxlint-code-quality-native-plugins.json index 5d9cd118e74..5f2fb031a51 100644 --- a/config/oxlint-code-quality-native-plugins.json +++ b/config/oxlint-code-quality-native-plugins.json @@ -16,6 +16,7 @@ "import/namespace": "warn", "import/no-cycle": ["warn", { "maxDepth": 3 }], "import/no-duplicates": "warn", + "import/no-empty-named-blocks": "warn", "import/no-self-import": "warn" }, "overrides": [ diff --git a/config/oxlint-code-quality-type-aware.json b/config/oxlint-code-quality-type-aware.json index 4c0e01fa748..8ae3cbab60e 100644 --- a/config/oxlint-code-quality-type-aware.json +++ b/config/oxlint-code-quality-type-aware.json @@ -12,6 +12,7 @@ }, "rules": { "typescript/await-thenable": "warn", + "typescript/no-redundant-type-constituents": "warn", "typescript/restrict-plus-operands": "warn", "typescript/restrict-template-expressions": "warn", "typescript/switch-exhaustiveness-check": [ diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f5e5a39caeb..eab6a047bc6 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-10", + "updatedAt": "2026-08-12", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -10,6 +10,176 @@ } }, "gates": [ + { + "id": "terminal-session.shell-ready-exec-prompt-fallback", + "title": "Startup exec falls back to the identified shell's line editor", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-session-readiness", + "layer": "posix-pty-and-shell-startup", + "surfaces": ["queued terminal startup commands", "zsh, bash, and Fish startup output"], + "platforms": ["macos", "linux"], + "providers": ["local", "local-daemon", "ssh-daemon", "ssh-relay", "paired-runtime"], + "coveredPlatforms": ["macos", "linux"], + "coveredProviders": ["local", "local-daemon", "ssh-relay", "paired-runtime"], + "coverageNotes": "Real macOS node-pty integration covers zsh 5.9 .zshenv/.zprofile and bash 3.2 profile replacement shells, including exec -a names, same-shell and child-process silent reads, a blocking zle-line-init hook, non-shell readline replacements, and a non-shell image renamed zsh. A real Fish PTY proves marker stripping is byte-exact and does not render bracketed-paste control text. An Ubuntu 20.04 container confirms compatible zsh/bash termios. Deterministic owner contracts cover local, daemon, and SSH relay identity/readiness composition plus provider-owned delivery; explicit terminal-paste remains renderer-owned. Live isolated journeys cover a macOS headed desktop owner with paired web client, headless orca serve with paired web client, and a Docker Linux OpenSSH/Bash target with one readiness-time reconnect. Linux desktop, WSL/Windows, live zsh-over-SSH, and physical remote hosts remain gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/13767", + "https://linear.app/stably/issue/STA-4067", + "https://linear.app/stably/issue/STA-4068", + "https://github.com/stablyai/orca/pull/14161" + ], + "invariant": "Shell startup scanning preserves every non-Orca output byte in order across arbitrary PTY chunk boundaries. Before user startup files run, the wrapper publishes its shell PID. If a later exec discards the ready marker, proof-based recovery releases queued startup input only after the launched shell's actual executable image with that exact PID owns the PTY foreground, emits its post-hook line-editor enable sequence, and exposes zle/readline termios; startup reads, line-init hooks, child foreground processes, and non-shell readline replacements cannot trigger that proof. The existing bounded compatibility timeout may enqueue input without proof, but must not publish readiness, lose or duplicate that input across reconnect, or execute it before the replacement shell can accept it.", + "oracle": "Split Fish output immediately after the ready marker and after the following ESC introducer; local and daemon owners must emit the exact ESC [ ? 2004 h bytes and headless xterm must not render literal control text. Run the real generated wrappers under node-pty with disposable zsh, bash, and Fish startup files. Plain and exec -a replacement shells must receive one queued command at their first completed line editor without waiting for the 15-second timeout. The identical oracle must time out without the startup identity and prompt fallback. Same-shell reads, child-process reads, a blocking zle-line-init hook, sqlite3, and sqlite3 renamed zsh must not trigger prompt fallback. Relay owner coverage must use the same lossless scanner composition and release through the same prompt contract.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready.test.ts src/main/providers/local-pty-provider.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/daemon/shell-ready.test.ts src/main/shell-startup-output-scanner.test.ts", + "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headless --project electron-headful --workers=1" + ], + "testFiles": [ + "src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts", + "src/main/line-editor-ready-output-scanner.test.ts", + "src/main/shell-startup-identity-scanner.test.ts", + "src/main/shell-startup-output-scanner.test.ts", + "src/main/shell-prompt-readiness-probe.test.ts", + "src/shared/pty-slave-line-discipline-echo.test.ts", + "src/shared/shell-process-readiness.test.ts", + "src/main/daemon/session.test.ts", + "src/main/daemon/shell-ready.test.ts", + "src/main/providers/local-pty-shell-ready.test.ts", + "src/main/providers/local-pty-provider.test.ts", + "src/relay/pty-shell-launch.test.ts", + "src/relay/pty-handler.test.ts", + "tests/e2e/ssh-startup-exec-readiness.spec.ts", + "tests/e2e/paired-startup-exec-readiness.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/shell-startup-output-scanner.test.ts", + "assertions": [ + "Fish bracketed-paste output stays byte-exact across marker and ESC chunk splits", + "every byte after readiness is preserved across every chunk boundary, even when it resembles an identity marker", + "pre-ready identity and readiness markers strip across every chunk boundary without reordering prompt output", + "incomplete scanner prefixes drain in byte order" + ] + }, + { + "file": "src/main/daemon/session.test.ts", + "assertions": [ + "daemon output is byte-exact for both Fish chunk splits", + "headless xterm never renders literal bracketed-paste control text" + ] + }, + { + "file": "src/main/providers/local-pty-provider.test.ts", + "assertions": ["local PTY output is byte-exact for both Fish chunk splits"] + }, + { + "file": "src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts", + "assertions": [ + "real zsh .zshenv/.zprofile and bash profile replacement shells receive queued input exactly once", + "same-shell raw silent reads retain queued input", + "child-process raw silent reads retain queued input while the identified shell is not foreground", + "a blocking zle-line-init read retains queued input until the hook completes", + "sqlite3 and a sqlite3 image renamed zsh cannot impersonate the launched shell executable path", + "zsh configurations without a line-editor enable sequence retain the existing timeout backstop" + ] + }, + { + "file": "src/relay/pty-handler.test.ts", + "assertions": [ + "the SSH relay owner strips startup identity and releases provider-delivered input through prompt readiness", + "renderer-owned SSH delivery receives the existing ready marker after authoritative relay prompt readiness", + "normal renderer-owned ready markers pass through unchanged and retain no readiness state" + ] + }, + { + "file": "tests/e2e/ssh-startup-exec-readiness.spec.ts", + "assertions": [ + "a Docker Linux OpenSSH/Bash replacement shell retains one compatibility-fallback input across reconnect without executing it before the blocked replacement can accept it", + "the owning terminal handle and raw PTY identity remain singular and stable", + "painted output, authoritative terminal output, and the first interactive input each appear exactly once" + ] + }, + { + "file": "tests/e2e/paired-startup-exec-readiness.spec.ts", + "assertions": [ + "a headed desktop owner and paired client recover startup exec on one stable owning PTY", + "an isolated headless orca serve owner provides the same exact-once output and input contract" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "manual", + "platform": "linux", + "command": "pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/daemon/shell-ready.test.ts src/main/shell-startup-output-scanner.test.ts", + "result": "passed", + "durationSeconds": 7, + "summary": "A clean Debian 12 arm64 container with real Fish 3.6 and node-pty passed exact marker-stripped output plus headless-xterm rendering." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready.test.ts src/main/providers/local-pty-provider.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler.test.ts", + "result": "passed", + "durationSeconds": 6, + "summary": "473 tests passed with one local Fish skip, including every-boundary scanner composition, real exec, silent-read, line-init-hook, renamed-image, disabled-bracketed-paste, and relay-owner oracles." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "linux", + "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 17, + "summary": "The live OpenSSH/Bash relay journey crossed the bounded compatibility fallback while rc-file exec was blocked, proved one queued command echo with no command execution, then passed a disconnect/reconnect with exact-once startup output/input and stable pane plus owner PTY identities." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headless --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 14, + "summary": "The shared oracle passed a headed desktop owner plus paired web client and an isolated headless orca serve owner plus paired web client." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused local wrapper contracts plus isolated SSH and paired live journeys" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Fresh deterministic and isolated live runs pass locally; CI and soak history have not started." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Daemon oracle SHA-256 b13b08006a38b2adc2acb51ae9342909514e55ab4822fac4fbb76cd49b344cc6 was byte-identical across runs: affected baseline 09ec516ae5 failed 4 of 14, while pre-PR main bc2e30000b and this PR passed 14 of 14. The unchanged live SSH oracle failed on bc2e30000b and with the ownership change reverted because the ledger stayed pending after reconnect, then passed on this PR. Fish chunk oracle patch SHA-256 fd49a4d3d91bd5f0d10c1b1f44f4cfbd92cef9155a5505b415c8680184cdc765 was also byte-identical: scan target e4c278eb52, main 150cc6f50c, and candidate ae7f30318a with shared scanner integration disabled failed only the ESC-introducer split in local and daemon owners, while ae7f30318a passed all 163 cases plus headless-xterm rendering." + }, + "performanceBudget": { + "required": true, + "evidence": "Two bounded linear scans partition identity ownership at the exact ready-marker boundary and retain only the existing bounded prefixes. The normal ready-marker path cancels before external probing. A missing marker triggers a debounced stty read only after a line-editor enable sequence, followed by narrow PID status and executable-image inspection only when termios matches. Slow ordinary startup output triggers zero probes; rejected protocol emissions are capped at four probes per startup. There is no per-command hook or steady-state polling." + }, + "promotionCriteria": [ + "Collect CI and soak history for the Docker SSH and paired live journeys.", + "Collect CI and soak history without unexplained flakes.", + "Keep same-shell, child-process, line-init-hook, and non-shell readline contracts green." + ], + "knownGaps": [ + "The fallback requires a readable POSIX PTY slave; WSL through wsl.exe, ConPTY, and native Windows retain the existing timeout backstop.", + "The live SSH journey covers Docker Linux OpenSSH with Bash, not zsh, WSL, Windows SSH, a Linux desktop renderer, or a physical remote host.", + "An exec integration that forks a replacement shell instead of retaining the identified shell PID retains the existing timeout backstop.", + "A zsh configuration that disables bracketed-paste output emits no safe post-hook protocol signal and retains the existing timeout backstop.", + "The pre-existing timeout backstop can still deliver queued input to a non-shell replacement after it expires; this fallback prevents indefinite startup hangs and is unchanged by the prompt fallback.", + "The fallback restores safe startup-command delivery but cannot recreate OSC 133 hooks or overlay restores already discarded by exec.", + "Startup code running in the launched shell can deliberately spoof the public line-editor enable sequence after configuring matching termios; the existing timeout path already trusts a public OSC marker from the same startup code." + ], + "demotionRule": "Keep experimental or demote if prompt fallback enters a normal, child, or line-init read; a non-shell executable triggers prompt fallback; the identified shell PID is not foreground; delivery duplicates; ordinary startup output spawns probes; probe attempts exceed four; or the focused gate flakes without an identified harness defect." + }, { "id": "editor.restored-sibling-owner-reparent", "title": "Restored sibling tabs migrate filesystem authority before becoming editable", @@ -1361,6 +1531,442 @@ ], "demotionRule": "Demote if a retired runtime transport can leave a renderer subscription without a close event, if a close is delivered more than once, if one failing teardown strands its sibling subscriptions, if payload frames from a retired transport reach the renderer, or if a revealed remote terminal can stay blank or reject input after a reconnect." }, + { + "id": "browser-session.remote-terminal-link-ownership", + "title": "Paired terminal links open one browser on their owning runtime", + "maturity": "experimental", + "protection": "partial", + "owner": "browser-runtime", + "layer": "renderer-paired-runtime-routing", + "surfaces": [ + "paired remote terminal HTTP links", + "terminal link action destinations", + "runtime-owned managed browser creation" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime", "ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["remote-runtime", "ssh"], + "coverageNotes": "A headed macOS desktop host and separate paired Electron client exercise a real mirrored terminal link, owner-asserted host browser creation, exact host/client page identity, rendered DOM, explicit browser-tab activation, client external-browser suppression, exact host/client close convergence, and continued original-PTY liveness. Unit contracts cover OSC, WebLinksAddon, click fallback, explicit System Browser routing, failed creation, incapable runtimes, and unchanged direct SSH behavior.", + "motivatingLinks": [ + "https://stablygroup.slack.com/archives/C0AK2T3JEF4/p1786567689897109", + "https://github.com/stablyai/orca/issues/12418" + ], + "invariant": "Selecting Orca Browser for an HTTP link in a runtime-owned terminal creates exactly one browser page on that owning runtime and exactly one mirrored client browser workspace, with matching remote page identity and no client external-browser call. Explicit System Browser remains client-owned, direct SSH remains system-browser-only, and unknown or mismatched owners fail closed.", + "oracle": "Use the focused routing contracts as the byte-identical baseline/candidate oracle. Require runtime OSC, WebLinksAddon, click fallback, action-menu, and modifier paths to target the exact owner without client fallback, while direct SSH and generic remote document links stay external. Separately run one byte-identical headed paired Electron journey on the candidate and with only runtime browser registration disabled. Require one new host browser tab, one new client mirror with exact page identity, rendered fixture DOM, explicit browser-tab activation, zero client shell.openExternal calls, exact close convergence, unchanged terminal counts, and continued output on the original PTY.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts src/renderer/src/lib/http-link-routing.test.ts src/renderer/src/lib/http-link-modifier-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts", + "src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts", + "src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts", + "src/renderer/src/lib/http-link-routing.test.ts", + "src/renderer/src/lib/http-link-modifier-routing.test.ts", + "src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "tests/e2e/paired-remote-terminal-browser-link.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts", + "assertions": [ + "OSC, WebLinksAddon, and click-fallback activations route through the exact runtime owner", + "direct SSH continues to use only the system browser" + ] + }, + { + "file": "src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "assertions": [ + "an asserted runtime cannot fall back to a client browser", + "mismatched and incapable runtime owners fail closed" + ] + }, + { + "file": "tests/e2e/paired-remote-terminal-browser-link.spec.ts", + "assertions": [ + "one terminal action produces one host page and one client mirror with exact identity", + "the host browser renders the fixture while client external navigation stays empty", + "an explicit browser-tab click reveals the exact mirror", + "closing the mirror returns host and client inventories to baseline", + "the original remote PTY accepts and renders fresh output after browser close" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "result": "failed", + "durationSeconds": 2.58, + "summary": "Latest main e4c278eb523 failed 6/25 owner-routing assertions: runtime terminal links called the viewing client's external browser and the managed-browser helper did not enforce an asserted runtime owner." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts src/renderer/src/lib/http-link-routing.test.ts src/renderer/src/lib/http-link-modifier-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "result": "passed", + "durationSeconds": 1.83, + "summary": "Candidate passed 84/84 across six focused routing, modifier, availability, owner assertion, failure, SSH, and system-browser contract files." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 32.9, + "summary": "Candidate created one host browser and one client mirror with matching page identity, rendered the fixture, activated on an explicit tab click, made zero client external-browser calls, converged both inventories after exact UI close, and delivered fresh output on the original remote PTY." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 60, + "summary": "The byte-identical final Electron oracle failed at its exact host/client identity barrier when the sole source delta replaced src/renderer/src/store/index.ts registration lines 110-113 with registerRuntimeHttpLinkBrowserOpener(null); no host-owned browser appeared within the deterministic 60-second bound." + } + ], + "runtimeBudget": { + "p95Seconds": 45, + "scope": "six focused renderer contract files plus one isolated headed paired-runtime journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The focused contracts are deterministic and the headed paired-runtime journey passed locally after its PTY namespace oracle was corrected; longer CI and soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The focused owner-routing oracle is red on latest main e4c278eb523 and green on the candidate. The byte-identical headed Electron oracle is green on the candidate and red when the sole source delta replaces the runtime browser opener registration with registerRuntimeHttpLinkBrowserOpener(null); candidate evidence proves exact host/client identity, rendered content, explicit activation, exact close convergence, original-PTY survival, and absence of client external navigation." + }, + "performanceBudget": { + "required": true, + "evidence": "The production change adds no timer, polling, retry, subprocess, stream opcode, RPC method, or host publication. Each link hover or activation performs bounded in-memory owner and capability lookups; an Orca Browser action issues the same single browser creation and snapshot reconciliation already used by the tab bar." + }, + "promotionCriteria": [ + "Collect Linux and physical Windows paired-server evidence.", + "Collect a live headless-serve terminal-link journey.", + "Collect 100 consecutive focused CI passes or 14 days without an unexplained flake." + ], + "knownGaps": [ + "Live paired-server validation currently covers macOS only.", + "A live headless-serve journey and multiple simultaneous viewers are not exercised by the Electron journey.", + "Direct SSH intentionally retains its existing system-browser-only semantics.", + "Terminal-link activation does not change the existing asynchronous host/client focus policy." + ], + "demotionRule": "Demote if one runtime terminal link creates a client-owned or duplicate browser, targets a runtime other than the pane owner, invokes the client external browser for Orca Browser, or removes the source terminal." + }, + { + "id": "browser-session.remote-html-preview-ownership", + "title": "Paired HTML previews keep one host page and one stable client split", + "maturity": "experimental", + "protection": "partial", + "owner": "browser-runtime", + "layer": "renderer-paired-runtime-reconciliation", + "surfaces": [ + "paired remote HTML file preview", + "background preview creation and explicit browser activation", + "client-only split placement", + "capability-unknown and mixed-version preview rejection cleanup", + "post-create reconciliation failure and compensating rollback", + "remote browser close convergence" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["remote-runtime"], + "coverageNotes": "A headed macOS desktop host and separate paired client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, browser close, forced capability rejection after caller preflight, and a forced post-create reconciliation timeout. The same fault oracles pass against a live headless server. Unit contracts cover absent, unknown, and mixed-version capabilities, visible rejection, empty-split cleanup, owning-runtime routing, exact client materialization, confirmed rollback, ambiguous cleanup, and teardown. Linux and Windows live journeys remain gaps.", + "motivatingLinks": [ + "https://linear.app/stably/issue/STA-3872/unable-to-open-html-files-in-remote-server", + "https://linear.app/stably/issue/STA-4025/p1-browser-create-reports-success-when-tab-reconcile-fails-after-pr", + "https://linear.app/stably/issue/STA-4063/p1-paired-html-preview-rejects-outside-cleanup-after-pr-13909", + "https://github.com/stablyai/orca/pull/13876" + ], + "invariant": "Opening one HTML preview from a paired client succeeds only after exactly one browser page on the worktree-owning runtime materializes as exactly one client browser workspace and unified tab in the requested group. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback. Preview focus and close preserve the source editor and terminal.", + "oracle": "Run one unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host. Require the original unsupported error or visible toast, zero RPCs, and no retained new split. Run it on the scan target, latest main, candidate, and candidate with the cleanup boundary disabled. In headed paired Electron, arm capability rejection after the caller preflight, invoke the visible HTML side-preview action, require the failure toast, and compare exact host/client browser, editor, terminal, and group inventories to baseline. Repeat against headless serve. Retain the separate exact-page reconciliation rollback oracle.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", + "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line" + ], + "testFiles": [ + "src/main/runtime/orca-runtime-browser.test.ts", + "src/main/runtime/rpc/methods/browser.test.ts", + "src/renderer/src/lib/file-preview.test.ts", + "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "src/renderer/src/runtime/web-session-browser-placement.test.ts", + "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", + "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", + "src/renderer/src/runtime/web-runtime-session.test.ts", + "src/renderer/src/runtime/web-session-tabs-sync.test.ts", + "src/renderer/src/runtime/remote-server-parity.test.ts", + "tests/e2e/paired-remote-html-browser-focus.spec.ts", + "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", + "assertions": [ + "absent status, unknown capabilities, and mixed-version hosts preserve the unsupported rejection", + "direct creation removes the caller-declared new split without issuing an RPC" + ] + }, + { + "file": "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "assertions": [ + "known unavailability reports visibly without creating a split", + "capability loss after caller preflight reports visibly without duplicating transaction cleanup" + ] + }, + { + "file": "src/renderer/src/lib/file-preview.test.ts", + "assertions": [ + "a failed side preview reports visibly without caller-owned split cleanup", + "overlapping side previews reuse the split without the first failure deleting it" + ] + }, + { + "file": "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", + "assertions": [ + "success requires the exact environment and remote page handle plus a unified browser tab", + "a requested client group must contain the materialized browser tab" + ] + }, + { + "file": "src/renderer/src/runtime/web-runtime-session.test.ts", + "assertions": [ + "a post-create timeout returns false only after exact host close and a confirming snapshot", + "unconfirmed cleanup rejects so callers cannot create a competing local fallback", + "the canonical placement and a newly-created empty split are released on rollback", + "one failed preview cannot remove a shared split still reserved after an environment ownership change", + "if every overlapping creation fails, the last reservation closes the caller-created empty split" + ] + }, + { + "file": "src/renderer/src/runtime/web-session-browser-placement.test.ts", + "assertions": [ + "pending page placements are bounded and reserve worktree groups across environment ownership changes", + "caller-created cleanup transfers to the last failed overlapping reservation", + "capacity rejects a new placement without evicting an active reservation or its cleanup ownership", + "worktree and environment teardown clear only their own placement state" + ] + }, + { + "file": "src/renderer/src/runtime/web-session-tabs-sync.test.ts", + "assertions": [ + "a host browser page stays in its exact client-owned preview group", + "a reserved empty split survives a pre-publication host snapshot", + "later client moves supersede initial preview placement" + ] + }, + { + "file": "src/renderer/src/runtime/remote-server-parity.test.ts", + "assertions": [ + "a caller-created browser gains focus when the exact predecessor tab is still visible" + ] + }, + { + "file": "tests/e2e/paired-remote-html-browser-focus.spec.ts", + "assertions": [ + "one HTML open produces one client workspace, one client unified tab, and one host page", + "the source editor and remote frame remain visible in separate groups", + "preview creation leaves the editor active, an explicit browser click is authoritative, and close preserves the editor and terminal" + ] + }, + { + "file": "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts", + "assertions": [ + "the exact created page exists on the host while publication is held and no browser tab materializes on the client", + "a forced post-create reconciliation timeout closes the exact host page and removes only the reserved empty split", + "a forced capability rejection after caller preflight shows a toast and restores exact host and client inventories", + "headed and headless hosts preserve the unrelated editor and terminal without local fallback" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "result": "failed", + "durationSeconds": 0.72, + "summary": "The unchanged STA-4063 oracle failed 5/5 on scan e4c278eb52 and latest main bc2e30000b: direct create never closed the new split, and side-preview rejection produced neither cleanup nor a toast. Pre-fix PR #14100 passed the two caller cases but still failed all three direct transaction cases." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "result": "passed", + "durationSeconds": 0.7, + "summary": "Rebased candidate 14b5877a75 passed 5/5: absent, unknown, mixed-version, preflight-known, and post-preflight capability failures all reported failure and retained no new empty split." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 18.3, + "summary": "The full headed spec passed 2/2. A visible desktop host and separate paired Electron client proved both reconciliation rollback and capability rejection with a failure toast and exact host/client browser, group, editor, and terminal inventory restoration." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 9.1, + "summary": "The full isolated headless-serve journey passed 1/1, applying both reconciliation and capability faults to the same live host while preserving the visible-toast and exact inventory-restoration oracle." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", + "result": "passed", + "durationSeconds": 0.11, + "summary": "The exact environment, remote page, client workspace, unified browser tab, and requested group materialization contract passed 1/1 on the final rebased candidate." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 24.3, + "summary": "A visible desktop host and separate paired Electron client held one exact host page before client materialization, forced the post-create timeout, then proved exact host rollback, empty-split removal, failure UI, no local fallback, and surviving editor and terminal." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 18.5, + "summary": "The same exact-page hold, timeout, rollback, client cleanup, no-fallback, editor, and terminal oracle passed against isolated headless serve." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts", + "result": "passed", + "durationSeconds": 3.48, + "summary": "Seven focused host and renderer contract files passed 183/183 tests, covering owning-runtime routing, focused and unfocused creation, placement, reconciliation, failure cleanup, and teardown." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", + "result": "passed", + "durationSeconds": 16.1, + "summary": "The candidate passed exact host/client cardinality, split stability, rendered DOM, focus authority, close convergence, and surviving editor and terminal." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 11.6, + "summary": "Latest-main a90a18d43da333e1cc327779a3f9912a78308848 failed the byte-identical 5806b23f oracle because the HTML preview collapsed into the source editor group instead of retaining its requested split." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 6.5, + "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e passed the byte-identical 5806b23f oracle: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 10.9, + "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e with only the side-preview owning-runtime routing seam disabled failed the byte-identical 5806b23f oracle with two client workspaces." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 7.6, + "summary": "Final candidate d5767481b982b736e7cc5b4653b2a32a61280638 on merge-base 36d45af062ec4022265aabac8fc6a9d36eecedf1 passed the byte-identical 5806b23f oracle after final review and rebase: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 11.8, + "summary": "Latest-main 346e59c879 failed the byte-identical 2ec1dcf1 oracle with two client HTML browser workspaces instead of one." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 8.4, + "summary": "Candidate d05217c40d passed the byte-identical 2ec1dcf1 oracle: one host page, one client workspace, stable split, rendered DOM, editor focus after creation, explicit-click browser focus, and browser-only close." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 12.3, + "summary": "Candidate d05217c40d with only the side-preview focusOnCreate:false call disabled failed the byte-identical 2ec1dcf1 oracle because preview creation activated the browser instead of preserving editor focus." + } + ], + "runtimeBudget": { + "p95Seconds": 90, + "scope": "focused host/renderer contracts plus normal and faulted headed/headless paired-runtime journeys" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The candidate passed isolated local macOS Electron runs before and after the final rebase; longer CI and soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing." + }, + "performanceBudget": { + "required": true, + "evidence": "The transaction adds no timer, polling, retry, subprocess, RPC method, stream opcode, or host publication. Capability rejection performs zero RPCs and one bounded empty-group close. Successful creation adds one post-operation list; known-page failure adds one exact close and one confirming list. The E2E-only one-shot faults are build-gated, retain one promise/flag and a bounded page-id set, and reset after each run. Pending client placements remain capped at 128 and clear on adoption, failure, or teardown." + }, + "promotionCriteria": [ + "Collect Linux and physical Windows paired-server evidence.", + "Collect 100 consecutive focused CI passes or 14 days without an unexplained flake." + ], + "knownGaps": [ + "Live paired-server validation currently covers macOS only.", + "Multiple simultaneous viewers are not exercised by the Electron journey.", + "Direct SSH remote file URLs remain intentionally unsupported and local file previews retain their existing path." + ], + "demotionRule": "Demote if one paired HTML action can create duplicate client or host browser identities, lose the requested split, steal editor focus before an explicit browser click, lose browser focus after that click, or remove unrelated editor or terminal state on close." + }, { "id": "browser-stream.bounded-reconnect", "title": "Remote browser streams recover within a bounded retry budget", @@ -2440,7 +3046,7 @@ "platform": "macos", "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", "result": "passed", - "durationSeconds": 0.4, + "durationSeconds": 0.34, "summary": "1 test file passed, 58 tests passed; covers cwdFallback forwarding only for local fresh spawns and metadata handoff." }, { @@ -2686,6 +3292,130 @@ ], "demotionRule": "Demote or quarantine if identity-mismatched completion can retire newer work, cleanup retains compact authority, or the focused gate flakes without a product or harness bug." }, + { + "id": "agent-launch.discovery-authority", + "title": "Agent discovery and launch use the same workspace authority", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-session", + "layer": "renderer-store-contract", + "surfaces": ["Quick Launch agent inventory", "agent launch", "Floating Workspace"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "wsl", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos", "windows"], + "coveredProviders": ["local", "wsl"], + "coverageNotes": "A deterministic Windows-mode renderer oracle keeps an active WSL project and explicit-local Floating Workspace mounted together with disjoint agent inventories, then independently asserts discovery and launch authority. A headed physical Windows 2 journey with real Ubuntu WSL2 independently verified the rendered inventory and native PowerShell launch; SSH and paired-runtime behavior retain their existing contract coverage.", + "motivatingLinks": [ + "https://linear.app/stably/issue/STA-4027/p1-floating-workspace-agent-detection-still-uses-active-wsl-project", + "https://github.com/stablyai/orca/pull/13995" + ], + "invariant": "Every agent launch surface must advertise the provider set detected on its explicit execution authority and launch on that same authority; a local Floating Workspace must never inherit an unrelated active project's WSL, SSH, or paired-runtime context.", + "oracle": "Model an active WSL project and a native-Windows Floating Workspace with WSL-only Claude and host-only Codex. Keep both detection consumers mounted, require their advertised sets to stay disjoint through refresh, require the legacy active-project inventory to remain unchanged, then independently assert the Floating launch call and PTY transport select local IPC with no WSL project runtime.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useDetectedAgents.test.tsx src/renderer/src/hooks/useAgentDetectionTarget.test.ts src/renderer/src/store/slices/detected-agents.test.ts src/renderer/src/store/slices/local-detected-agent-state.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/lib/local-preflight-context.test.ts src/renderer/src/lib/launch-agent-in-new-tab.test.ts --reporter=verbose", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts --reporter=dot", + "Headed Electron/CDP STA-4027 journey on Orca environment windows 2 with a selected Ubuntu-24.04 WSL project and local Floating Workspace" + ], + "testFiles": [ + "src/renderer/src/hooks/useDetectedAgents.test.tsx", + "src/renderer/src/hooks/useAgentDetectionTarget.test.ts", + "src/renderer/src/store/slices/detected-agents.test.ts", + "src/renderer/src/store/slices/local-detected-agent-state.test.ts", + "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts", + "src/renderer/src/lib/local-preflight-context.test.ts", + "src/renderer/src/lib/launch-agent-in-new-tab.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/hooks/useDetectedAgents.test.tsx", + "assertions": [ + "the active WSL project advertises only its WSL agent while Floating advertises only the native-host agent", + "Floating refresh preserves both scoped inventories and does not overwrite the legacy active-project inventory" + ] + }, + { + "file": "src/renderer/src/store/slices/local-detected-agent-state.test.ts", + "assertions": [ + "same-context callers deduplicate without cached broadcast fanout", + "refresh supersedes stale detection and failed cold refresh remains retryable", + "project removal evicts settled and in-flight context state" + ] + }, + { + "file": "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts", + "assertions": [ + "removing one project evicts its authority-scoped agent inventory without disturbing another project" + ] + }, + { + "file": "src/renderer/src/lib/launch-agent-in-new-tab.test.ts", + "assertions": [ + "the advertised Floating agent launches in a Floating tab with null runtime ownership and no paired-runtime create call" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "the Floating agent's actual PTY selects local IPC, carries executionHostId local, and omits the active project's WSL runtime" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useDetectedAgents.test.tsx src/renderer/src/hooks/useAgentDetectionTarget.test.ts src/renderer/src/store/slices/detected-agents.test.ts src/renderer/src/store/slices/local-detected-agent-state.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/lib/local-preflight-context.test.ts src/renderer/src/lib/launch-agent-in-new-tab.test.ts --reporter=verbose", + "result": "passed", + "durationSeconds": 2, + "summary": "Seven focused files passed independent WSL/local inventory, refresh isolation, cache lifecycle, project eviction, and Floating launch-call assertions." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 20, + "summary": "The full PTY connection suite passed the independent Floating native-host transport oracle." + }, + { + "date": "2026-08-12", + "runner": "manual", + "platform": "windows", + "command": "Headed Electron/CDP STA-4027 journey on Orca environment windows 2 with a selected Ubuntu-24.04 WSL project and local Floating Workspace", + "result": "passed", + "durationSeconds": 22.3, + "summary": "Environment abfee683-80eb-4ac3-ada0-da5d1b6303a6, runtime b3bea13e-f6b7-41e7-b850-0a118f7eda38: Floating advertised native Claude/Codex without WSL-only Gemini and launched Claude through native PowerShell with local host ownership while the WSL project remained active." + } + ], + "runtimeBudget": { + "p95Seconds": 40, + "scope": "eight focused renderer authority, cache, eviction, and launch test files" + }, + "flakeHistory": { + "status": "soaking", + "evidence": "The deterministic oracle uses mocked provider results and React effect barriers. Exact-head PR run 31644438349 passed the full Node 24/26 matrix; an earlier patch-identical run's transient shard failures passed on rerun, but long-term soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical oracle fails on merged PR #13995 and the tested latest-main snapshot ebe5125476 because Floating receives the WSL-only Claude inventory while launch remains local. The candidate passes; disabling the scoped Floating target reproduces the mismatch." + }, + "performanceBudget": { + "required": true, + "evidence": "Floating uses one stable host context key, retained tab bars do not rescan worktree indexes on store writes, concurrent probes deduplicate per context, and in-flight promise entries are deleted on settlement." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Keep the physical Windows/WSL inventory and launch journey green when authority routing changes.", + "Keep disjoint provider inventories, refresh isolation, launch authority, and bounded in-flight state green." + ], + "knownGaps": [ + "Live SSH and paired-runtime launch journeys are covered by existing authority contracts rather than this focused oracle." + ], + "demotionRule": "Demote if discovery and launch can resolve different authorities, a scoped Floating probe overwrites the active-project inventory, or local context selection adds recurring scans or retained in-flight entries." + }, { "id": "agent-session.provider-ownership", "title": "Provider sessions are resumed once per workspace ownership claim", @@ -3226,16 +3956,22 @@ "providers": ["local", "remote-runtime", "ssh", "wsl"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "remote-runtime", "ssh", "wsl"], - "coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, Add Project, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, Add Project owner capture and stale-result fencing, host-qualified group/folder/worktree identity, and restart reset of transient routing are covered. A visible macOS Orca desktop server plus a separate paired Electron client proves real Git, plain-folder, Clone, Create, nested import, runtime-switch, disconnect, reconnect, concurrently visible same-ID local/runtime repo/worktree/group/folder rows, reversed catalog order, reversed refresh completion, local-after-runtime catalog refresh routing, and first paired-terminal activation without local fallback; hidden-window parity also passed. Live Linux, Windows, WSL, and SSH Add Project journeys remain uncollected.", - "motivatingLinks": ["https://github.com/stablyai/orca/pull/9687"], - "invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Every Add Project scan, add, import, catalog refresh, and worktree refresh must stay on the host selected when that operation began, even if the durable preference changes or the repo catalog is not hydrated. Generic settings mutation cannot bypass the dedicated preference IPC.", - "oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat with multiple clients and servers, browser host switches, remote-owned and local-owned workspaces, pairing/connect/disconnect, and generic settings writes. In a separate paired Electron client, select a non-default headed runtime and add a real Git repo, plain folder, clone, created project, and nested repos while switching the preference and disconnecting/reconnecting. Before Git Add, plain-folder Add, Clone, and Create completion, insert a same-ID local repo/worktree decoy; require final activation to remain on the captured runtime. Merge same-ID local/runtime repo, worktree, group, and folder rows, reverse same-host refresh completions, overlap refresh with reconnect, issue ordinary unqualified local refreshes, then create the first paired runtime terminal while the same-ID local/runtime worktrees remain visible. Require only the selected server inventory to change, exact runtime ownership in the client store, preserved host-qualified catalogs, authoritative direct-SSH pruning without runtime sibling loss, visible projects, and zero direct SSH fallback. Assert only the dedicated validated preference method changes activeRuntimeEnvironmentId and stale host-operation completions cannot overwrite the newly selected transient host.", + "coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, Add Project, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, Add Project eligibility/owner capture/stale-result fencing, host-qualified group/folder/worktree identity, and restart reset of transient routing are covered. A visible macOS Orca desktop server plus separate paired Electron and web clients prove selected-runtime Add Project operations and disabled creation when the only listed host is blocked or unavailable; headless-server parity also passed. Live Linux, Windows, WSL, and SSH Add Project journeys remain uncollected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/9687", + "https://linear.app/stably/issue/STA-4024/p1-paired-web-add-project-enables-unavailable-host-fallback-after-pr" + ], + "invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Every Add Project selected host must satisfy canSelectAddRepoHost; paired web has no local filesystem fallback, so no valid host means Browse, Clone, and Create remain disabled. Every operation stays on the host selected when it began, even if preference or catalog state changes. Generic settings mutation cannot bypass the dedicated preference IPC.", + "oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat transient routing and selected-runtime Add Project operations with exact-ID host decoys and completion reordering. Separately pair the real web client to an isolated headed host, then make its sole still-listed runtime compatibility-blocked and unavailable. Require a null authoritative selection, a disabled host option, and disabled Browse, Clone, and Create actions with no local fallback. Repeat against headless serve. Run the byte-identical selector/action contract on latest main, candidate, and candidate with the eligibility seam disabled.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/runtime/runtime-server-directory-browser.test.ts src/renderer/src/store/slices/repos-paired-runtime-add.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1", "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", - "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line" + "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line" ], "testFiles": [ "src/main/ipc/settings.test.ts", @@ -3246,11 +3982,14 @@ "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts", "src/renderer/src/components/status-bar/SshStatusSegment.test.ts", "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts", + "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts", "src/renderer/src/hooks/useIpcEvents.test.ts", "src/renderer/src/web/web-preload-api.test.ts", "src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx", "src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx", "src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts", + "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx", + "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx", "src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts", "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts", "src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts", @@ -3262,15 +4001,46 @@ "src/renderer/src/lib/resolved-worktree-execution-host.test.ts", "src/renderer/src/lib/worktree-runtime-owner.test.ts", "src/renderer/src/store/selectors.test.ts", + "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts", "src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts", "src/renderer/src/store/slices/repos-project-groups.test.ts", "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts", "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts", "src/renderer/src/store/slices/worktrees.test.ts", "src/renderer/src/runtime/web-runtime-session.test.ts", - "tests/e2e/pr11346-selected-runtime-add.spec.ts" + "tests/e2e/pr11346-selected-runtime-add.spec.ts", + "tests/e2e/paired-web-add-project-unavailable-host.spec.ts" ], "assertionRefs": [ + { + "file": "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts", + "assertions": [ + "paired web hides the viewing computer as an Add Project filesystem authority and selects only an eligible paired runtime", + "a sole blocked or errored runtime remains listed but produces no selected Add Project host" + ] + }, + { + "file": "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts", + "assertions": [ + "paired runtime browsing opens the host browser without invoking native pickFolders while desktop local and SSH routes remain intact" + ] + }, + { + "file": "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx", + "assertions": ["paired runtime browsing renders the in-app host filesystem browser"] + }, + { + "file": "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx", + "assertions": [ + "host-scoped Add Project actions remain disabled until a paired runtime is selectable" + ] + }, + { + "file": "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts", + "assertions": [ + "an explicitly selected paired runtime submits repo.add to that runtime without native repo or folder-picker calls" + ] + }, { "file": "src/main/ipc/settings.test.ts", "assertions": [ @@ -3378,9 +4148,35 @@ "keeps every expanded selected-runtime Add Project path in hidden-window desktop parity", "creates the first paired runtime terminal while same-ID local/runtime worktrees remain visible without clearing the selected runtime owner" ] + }, + { + "file": "tests/e2e/paired-web-add-project-unavailable-host.spec.ts", + "assertions": [ + "a version-blocked sole host stays visible and non-selectable while Browse, Clone, and Create are disabled", + "an unavailable sole host has the same disabled action contract without a local fallback", + "the rendered contract passes against headed desktop and headless serve hosts" + ] } ], "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 12.3, + "summary": "A real paired web client against a visible desktop host kept the sole blocked and unavailable runtime listed but non-selectable, with Browse, Clone, and Create disabled and no local host fallback." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 5.8, + "summary": "The same present-but-blocked and present-but-unavailable Add Project action oracle passed against isolated headless serve." + }, { "date": "2026-07-22", "runner": "local", @@ -3455,7 +4251,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The byte-identical focused oracle (SHA-256 6de18c140a86801460e58f52287e27a58b97f1576f9f387fa82e1bd4eb378f7b) fails 2/3 on review baseline aa6f945001a5f78a07663c854fabb95c55d8b40b and on the r5-disabled parent e21f4ca58e: local-first lookup returns /local/repo and folder activation has no runtime host. It passes 3/3 on the candidate. Main later advanced to 94cf2f1422f30fc309cb47c5e864a831d516fa8b without changing any of the candidate's 68 files or the focused oracle production boundary; the rebased disabled parent f247d961cab15237bc7fcf0598fe76007f78ec7c has the same stable patch ID as e21f4ca58e. The final paired spec and fixture have SHA-256 bd6d9014d1bed2715397bcd2121d5d7e01792347b13ff14778ccf608bf98789c and d1d78a82f868e2f3152777550825c0ca2d4dc3cba5c6de548e066d2931751fc7. With the final runtime-session owner fix disabled, the focused session test fails 3/43 and the byte-identical hidden paired oracle deterministically creates the host terminal but reports null active client owner while same-ID local/runtime worktrees remain. The integrated candidate passes the same focused test and both headed/hidden paired oracles with exact runtime ownership, server inventory, local exclusion, preserved catalogs, visible rows, and zero direct SSH fallback. Earlier Active Server preference paths retain deterministic coverage but do not yet have a saved intentional-break artifact." + "evidence": "The Add Project selector file from fix-disabled overlay 17a612c819 and candidate 8abf0f5d20 is byte-identical (SHA-256 f34c3c69c8adba23811c3a3b6a0efb669f787666c5ada086df8a167e785eea20): its blocked and unavailable sole-host cases fail with the production fix disabled on latest main ebe5125476 after PR #13909 and pass on the candidate. The focused combined selector/transaction invocation was 3 failed with 69 skipped on the red overlay and 3 passed with 75 skipped on the candidate; the different totals come from supplemental browser-transaction cases, not this selector file. The originating/latest affected main 1136503c6a and its fix-disabled overlay were also red before the provider-gating rebase. The byte-identical focused oracle (SHA-256 6de18c140a86801460e58f52287e27a58b97f1576f9f387fa82e1bd4eb378f7b) fails 2/3 on review baseline aa6f945001a5f78a07663c854fabb95c55d8b40b and on the r5-disabled parent e21f4ca58e: local-first lookup returns /local/repo and folder activation has no runtime host. It passes 3/3 on the candidate. Main later advanced to 94cf2f1422f30fc309cb47c5e864a831d516fa8b without changing any of the candidate's 68 files or the focused oracle production boundary; the rebased disabled parent f247d961cab15237bc7fcf0598fe76007f78ec7c has the same stable patch ID as e21f4ca58e. The final paired spec and fixture have SHA-256 bd6d9014d1bed2715397bcd2121d5d7e01792347b13ff14778ccf608bf98789c and d1d78a82f868e2f3152777550825c0ca2d4dc3cba5c6de548e066d2931751fc7. With the final runtime-session owner fix disabled, the focused session test fails 3/43 and the byte-identical hidden paired oracle deterministically creates the host terminal but reports null active client owner while same-ID local/runtime worktrees remain. The integrated candidate passes the same focused test and both headed/hidden paired oracles with exact runtime ownership, server inventory, local exclusion, preserved catalogs, visible rows, and zero direct SSH fallback. Earlier Active Server preference paths retain deterministic coverage but do not yet have a saved intentional-break artifact." }, "performanceBudget": { "required": true, @@ -6701,7 +7497,7 @@ }, { "id": "terminal-input.ime-and-synthetic-forwarding", - "title": "IME, native text, and synthetic input commit exactly once and do not leak preedit bytes", + "title": "IME, native text, and synthetic input commit exactly once, do not leak preedit bytes, and pair every claimed press with one release", "maturity": "experimental", "protection": "partial", "owner": "terminal-input", @@ -6718,18 +7514,19 @@ "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": ["macos", "linux"], "coveredProviders": [], - "coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, and Electron/CDP live-PTY Sogou-style repros. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", + "coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, Electron/CDP live-PTY Sogou-style repros, and a macOS renderer/main slice for Preview kitty commits: claimed press/release pairing in both keyup orderings, and snapshot flag provenance where a proven 0, a proven non-zero, and an absent field stay three distinct facts from daemon through the sequenced remote SnapshotStart frame. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6699", "https://github.com/stablyai/orca/pull/6682", "https://github.com/stablyai/orca/pull/6513", "https://github.com/stablyai/orca/pull/6999" ], - "invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.", - "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", + "invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY. When the snapshot source proves kitty state, macOS Preview commits must use the application's effective flags after the sequenced snapshot and every proven replay/live transition, and a claimed press delivered under report_event_types must produce exactly one matching release regardless of whether keyup arrives before or after insertText and regardless of xterm's defensive internal reset.", + "oracle": "Exact outbound bytes from the REAL native-text forwarder behind installPreviewImeBridge after initial snapshot restore, replacement resync, live transitions, and both keydown->insertText->keyup and keydown->keyup->insertText orderings; a wiring-only mock is supporting evidence, not the oracle. The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/shared/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts src/main/daemon/daemon-pty-adapter.test.ts", "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "testFiles": [ @@ -6742,6 +7539,14 @@ "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts", "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts", "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts", + "src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts", + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx", + "src/shared/terminal-kitty-keyboard-mode-tracker.test.ts", + "src/main/ipc/terminal-preview.test.ts", + "src/main/ipc/terminal-preview-output-stream.test.ts", + "src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts", + "src/main/daemon/daemon-pty-adapter.test.ts", "tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "assertionRefs": [ @@ -6806,6 +7611,65 @@ "macOS standalone Process key behavior and composition-owned key suppression stay intact" ] }, + { + "file": "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts", + "assertions": [ + "the real forwarder behind the Preview bridge encodes the commit from the snapshot-proven flags before any live output", + "flags 0/1 commit raw text, flags 2 add one CSI-u release, flags 8 emit one CSI-u press, and flags 10 emit a CSI-u press followed by exactly one release", + "both keydown->insertText->keyup and keydown->keyup->insertText orderings produce the same bytes", + "the release is emitted by the forwarder while xterm's internal kitty flags stay 0", + "the mutable flags getter is read exactly once, at commit time, never on keydown or keyup", + "auto-repeat, two-key rollover, a swallowed commit, a retired claim, blur, and disposal each emit at most one release and never synthesize one" + ] + }, + { + "file": "src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts", + "assertions": [ + "a Preview opened on a snapshot carrying flags 8 adopts 8 before any live output", + "a replacement snapshot whose ANSI carries no kitty bytes keeps its proven flags, and a proven 0 clears an earlier 8", + "a missing field leaves the mirror unproven at the raw-text fallback and a later live negotiation still upgrades it", + "restored flags land on the screen the snapshot scan selected", + "a proven post-snapshot suffix advances the stack while redelivered bytes apply idempotently" + ] + }, + { + "file": "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx", + "assertions": [ + "the Preview hands the forwarder the live kitty mirror seeded from the snapshot's proven flags" + ] + }, + { + "file": "src/shared/terminal-kitty-keyboard-mode-tracker.test.ts", + "assertions": [ + "a fresh PTY's known 0 is distinguishable from a snapshot's unproven 0 fallback", + "restoreSnapshotFlags proves only the active screen and rejects non-safe-integer values", + "provenance survives absolute sets, screen switches, RIS and DECSTR, but a pop past omitted push history returns to unknown rather than a manufactured 0" + ] + }, + { + "file": "src/main/ipc/terminal-preview.test.ts", + "assertions": [ + "terminalPreview:connect returns image, sequence, and kitty flags from the same capture, including after an overflow retry" + ] + }, + { + "file": "src/main/ipc/terminal-preview-output-stream.test.ts", + "assertions": [ + "fully covered chunks are dropped, a sliced or strictly newer sequenced suffix gets live mode semantics, and an unsliceable or metadata-less overlap gets replay semantics" + ] + }, + { + "file": "src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts", + "assertions": [ + "SnapshotStart decodes proven 0 and 8 with their seq into requested and host-pushed snapshot paths", + "negative, fractional, unsafe, and non-numeric values are treated as absent rather than clamped", + "an old host that omits the field stays usable and is never coerced to a proven 0" + ] + }, + { + "file": "src/main/daemon/daemon-pty-adapter.test.ts", + "assertions": ["daemon provider snapshots publish proven kitty flags including a known 0"] + }, { "file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts", "assertions": [ @@ -6817,6 +7681,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-11", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/shared/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts src/main/daemon/daemon-pty-adapter.test.ts", + "result": "passed", + "durationSeconds": 9.84, + "summary": "8 test file(s) passed, 288 tests, covering the Preview kitty slice: real forwarder bytes at snapshot-proven flags, press/release pairing across both keyup/insertText orderings, and flag provenance where a proven 0, a proven non-zero, and an absent field stay three distinct facts — asserted on the daemon snapshot and on a host-pushed remote snapshot that carries the flags with their sequence. The emitter-side rule that a remote SnapshotStart withholds flags without a seq has no direct assertion yet; the client-side gate is what these files pin." + }, { "date": "2026-08-09", "runner": "local", @@ -6882,7 +7755,7 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix." + "evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, live-PTY Sogou-style candidate commits, and the Preview kitty slice (press/release pairing in both keyup orderings, and snapshot flag provenance separating a proven 0 from an absent field). Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix." }, "performanceBudget": { "required": true, @@ -6895,7 +7768,9 @@ ], "knownGaps": [ "Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.", - "Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage." + "Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage.", + "An older remote host omits the optional SnapshotStart kitty field, so a new client falls back to raw commits until live negotiation is observed.", + "Snapshot authorities expose only the active screen's effective flags, not kitty's complete two-screen push stacks; a pop that crosses the restored boundary degrades to unknown rather than a proven value." ], "demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence." }, @@ -7238,14 +8113,16 @@ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon"], - "coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal on a shell conflict.", + "coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, verified managed-Claude launch identity, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal when no verified launch identity exists.", "motivatingLinks": ["https://github.com/stablyai/orca/issues/8303"], - "invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.", - "oracle": "Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", + "invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; a generic busy title requires independent agent identity from the title, a verified managed launch bound to the current PTY incarnation, or the foreground process; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.", + "oracle": "A quarter-circle Claude task title authorizes without a provider lookup only on the exact PTY incarnation carrying verified managed-Claude launch identity; the same title in a bare pane, with an unverified launch hint, or after PTY incarnation replacement is refused while current Claude activity remains a working signal. Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" ], "testFiles": [ + "src/main/runtime/quarter-circle-title-send-authorization.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/rpc/terminal-send.test.ts", "src/main/ipc/pty.test.ts", @@ -7258,6 +8135,14 @@ "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" ], "assertionRefs": [ + { + "file": "src/main/runtime/quarter-circle-title-send-authorization.test.ts", + "assertions": [ + "a verified managed-Claude launch authorizes a task-text quarter-circle title without foreground-process work", + "a bare pane, an unverified launch hint, and a replacement PTY incarnation remain refused", + "quarter circles remain working signals and braille authorization is unchanged" + ] + }, { "file": "src/main/runtime/orca-runtime.test.ts", "assertions": [ @@ -7325,6 +8210,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts", + "result": "passed", + "durationSeconds": 20.9, + "summary": "The focused file passed 9 tests, including current-incarnation managed-Claude authorization with zero foreground calls and fail-closed bare, unverified, and replacement-incarnation quarter-circle titles." + }, { "date": "2026-07-11", "runner": "local", @@ -7345,11 +8239,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; saved intentional-break and physical Windows evidence remain missing." + "evidence": "Before the verified-launch fallback, the managed-Claude unavailable-foreground case failed with isRunningAgent false; the focused file passed 7 tests after the fix. Removing the launch-incarnation comparison makes the replacement-incarnation refusal fail. Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; physical Windows evidence remains missing." }, "performanceBudget": { "required": true, - "evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove permission/title blockers and ordinary recognized-agent evidence add zero confirmations; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative." + "evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove verified managed-Claude launch identity, permission/title blockers, and ordinary recognized-agent evidence add zero provider calls; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative." }, "promotionCriteria": [ "Run the browser annotation existing-agent and repeat-send path in Electron on Windows ConPTY without a recognition refusal.", @@ -7362,7 +8256,7 @@ "SSH, WSL, legacy daemon, and remote-runtime providers without confirmation remain intentionally fail-closed on an ordinary-shell conflict; no live artifacts cover those degraded paths.", "No live Linux PTY, paired-web, mobile/relay, restore/replay, or multi-window artifact is attached; those surfaces receive no renderer, persistence, or protocol change." ], - "demotionRule": "Keep non-blocking or demote to protection none if provider confirmation becomes unconditional, exact-PTY mismatch can write bytes, unsupported providers fail open, or the focused gate flakes without an actionable product or harness defect." + "demotionRule": "Keep non-blocking or demote to protection none if a generic busy title authorizes without exact title, verified-launch, or foreground identity; provider confirmation becomes unconditional; exact-PTY mismatch can write bytes; unsupported providers fail open; or the focused gate flakes without an actionable product or harness defect." }, { "id": "terminal-input.agent-prompt-injection", @@ -7507,19 +8401,25 @@ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon", "ssh"], - "coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, creator pane/process/Run-generation fencing, indexed retained-Run lookup scaling, the SSH in-process CLI fallback, and federated non-reveal. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.", - "motivatingLinks": ["https://github.com/stablyai/orca/pull/11107#discussion_r3663321387"], - "invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata. A nested worker's creator edge requires the current creator pane, process incarnation, and owning Run generation; reminting and rebinding that pane to another Run must remove the stale edge. Explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.", + "coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, creator pane/process/Run-generation fencing, indexed retained-Run lookup scaling, the SSH in-process CLI fallback, exact legacy Task/Dispatch/report-receipt verification, JSON rejection envelopes, and capability-negotiated Run-home settlement with lost-ack replay. Three isolated macOS Electron journeys launch fake Codex workers through the real RPC path. The compiled-CLI journey spawns fresh out/cli processes for rejected and accepted worker_done plus worker-release, then independently verifies runtime and SQLite state. The presentation and restart journeys assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/11107#discussion_r3663321387", + "https://github.com/stablyai/orca/issues/13858" + ], + "invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. A worker_done caller may report success only after the owning runtime returns an explicit lifecycle verdict or authoritative reads prove that the exact Task, Dispatch, and worker report receipt settled the expected outcome. A federated worker may accept only the Run-home runtime's settlement verdict. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata. A nested worker's creator edge requires the current creator pane, process incarnation, and owning Run generation; reminting and rebinding that pane to another Run must remove the stale edge. Explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.", "oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately remint a creator pane and process from Run A into Run B, require the nested Run A worker to fall back to its current coordinator, require indexed query plans, and bound 300 Task reads with 50,000 retained Runs. They also assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration-lifecycle-rejection.test.ts src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts src/cli/handlers/orchestration-migration.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts", "pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1", - "pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1" + "pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1", + "pnpm run build:cli && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-worker-settlement-release-cli.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts", @@ -7528,13 +8428,18 @@ "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/cli/handlers/orchestration.test.ts", + "src/cli/handlers/orchestration-lifecycle-rejection.test.ts", + "src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts", + "src/cli/handlers/orchestration-migration.test.ts", "src/cli/handlers/orchestration-check-identity.test.ts", "src/cli/handlers/orchestration-worker-cli.test.ts", "src/main/runtime/rpc/methods/orchestration.test.ts", "src/main/runtime/rpc/methods/orchestration-federation.test.ts", + "src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts", "src/main/ssh/ssh-remote-orca-cli.test.ts", "tests/e2e/orchestration-worker-terminal-visibility.spec.ts", - "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts" + "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts", + "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts" ], "assertionRefs": [ { @@ -7577,6 +8482,27 @@ "current formatting remains unchanged" ] }, + { + "file": "src/cli/handlers/orchestration-lifecycle-rejection.test.ts", + "assertions": [ + "an explicit current worker_done verdict completes without a compatibility read", + "a worker_done response without a lifecycle verdict checks the exact authoritative Dispatch and report receipt", + "legacy succeeded and failed reports require matching terminal Task and Dispatch state", + "stopped or stale Dispatches cannot impersonate report settlement", + "an idempotent retry accepts the first authoritative report from the same worker", + "a Run-home relay rejection reaches the CLI error boundary" + ] + }, + { + "file": "src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts", + "assertions": ["a rejected lifecycle verdict is emitted as a JSON ok=false envelope"] + }, + { + "file": "src/cli/handlers/orchestration-migration.test.ts", + "assertions": [ + "first and replayed legacy worker_done settlements are accepted from their authoritative runtime verdict" + ] + }, { "file": "src/cli/handlers/orchestration-check-identity.test.ts", "assertions": [ @@ -7600,6 +8526,20 @@ "a live handle cannot be retargeted by mismatched pane metadata" ] }, + { + "file": "src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts", + "assertions": [ + "a remote worker_done waits for Run-home settlement even when an older CLI omits the wait hint", + "older negotiated federation protocols reject before queueing", + "a committed settlement is replayed after an acknowledgment is lost", + "an aborted settlement wait returns operation_unknown instead of accepted success", + "an already-aborted signal registers no settlement waiter", + "a Run-home terminal verdict settles the remote attachment and revokes its capability", + "a terminal verdict cannot settle an unrelated relay item", + "a terminal verdict cannot contradict the exact queued worker report outcome", + "a rejected report replays its exact verdict without mutating durable rejection mail twice" + ] + }, { "file": "src/main/runtime/rpc/methods/orchestration-federation.test.ts", "assertions": ["federated worker placement explicitly sets activate=false"] @@ -7630,9 +8570,27 @@ "sleeping-session, resume-claim, and pending-startup state are cleared after adoption", "no second spawn, resume argv, input, signal, interruption, or duplicate tab occurs after workspace re-entry" ] + }, + { + "file": "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts", + "assertions": [ + "a compiled CLI worker_done with the wrong identity exits nonzero with ok=false", + "the exact Task and Dispatch remain dispatched after rejection", + "the correct worker identity receives an explicit completed lifecycle verdict", + "an independent runtime read observes the Dispatch completed" + ] } ], "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm run build:cli && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-worker-settlement-release-cli.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 20, + "summary": "The combined candidate passed one real Electron and compiled-CLI journey: wrong worker identity exited nonzero with ok=false while Dispatch stayed dispatched, the exact worker received an explicit completed verdict, and runtime state independently reached completed." + }, { "date": "2026-08-03", "runner": "local", @@ -7678,6 +8636,24 @@ "durationSeconds": 5.27, "summary": "Five focused files passed with 216 tests, covering visible inactive local worker creation, reveal-failure warnings, stable-pane mailbox routing, live-handle precedence, and SSH fallback parity." }, + { + "date": "2026-08-11", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration-lifecycle-rejection.test.ts src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts src/cli/handlers/orchestration-migration.test.ts", + "result": "passed", + "durationSeconds": 0.4, + "summary": "Three focused files passed 12 deterministic tests covering explicit current and Run-home verdicts, JSON failure output, exact report and terminal-state verification for stripped responses, stale/stopped rejection, idempotent retry, and first/replayed attested legacy settlements." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 4.58, + "summary": "Nine deterministic tests passed for protocol negotiation, Run-home completion and rejection, already-aborted waits, authoritative remote-attachment settlement bound to the exact queued worker_done outcome, and exact verdict replay after lost acknowledgments without mutating durable rejection mail twice." + }, { "date": "2026-07-28", "runner": "local", @@ -7712,11 +8688,11 @@ }, "flakeHistory": { "status": "unknown", - "evidence": "The deterministic units and two isolated Electron journeys pass locally; CI and soak history are not yet available." + "evidence": "The deterministic units and three isolated Electron journeys pass locally; CI and soak history are not yet available." }, "redGreenEvidence": { - "status": "partial", - "evidence": "The byte-identical 5f45c270f1 service oracle is red as the sole overlay 063340a8ba on origin/main@34291f07e9, green on candidate@2748b0b29b, red with current-authority propagation actually disabled in child 08a7db37bf, and green again after revert d2a4e2e024. The focused presentation and stale-handle tests failed against the earlier pre-fix implementation. The restart journey additionally failed first on empty retained output while the original PTY/PID remained live, then passed after the scoped recovered-worker snapshot fallback. The live incident and pre-fix Electron topology showed workspace re-entry replaying provider resume against a worker whose tab binding was missing. Distinct installed A/B and CI artifacts are still needed." + "status": "complete", + "evidence": "The byte-identical compiled-CLI Electron oracle failed on latest main 5ea7df1a5b because a wrong worker identity exited zero with ok=true while Dispatch stayed dispatched, passed on combined candidate d697666ce8 with a nonzero ok=false rejection and explicit completed verdict for the exact worker, and reproduced the main failure when the claimed production files were disabled at merge-base 64aec94cb2. The earlier byte-identical service oracle was also red on main, green on candidate, red with authority propagation disabled, and green after restore. Distinct installed A/B and CI artifacts are still needed." }, "performanceBudget": { "required": true, @@ -7877,24 +8853,42 @@ "platforms": ["macos", "linux", "windows"], "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], - "coveredProviders": ["local"], - "coverageNotes": "Deterministic service tests cover release-versus-reuse ordering, transactional retain and takeover cancellation, exact host/pane/process identity, conservative schema-v23 backfill, immutable transcript and bounded terminal archives, mutation restart, reset cleanup, replay idempotency, and 50-resource accounting. Live SSH, WSL, Windows, paired-runtime, and provider-close lost-ack journeys remain explicit gaps.", - "motivatingLinks": ["https://github.com/stablyai/orca/pull/12355", "STA-905"], - "invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.", - "oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.", + "coveredProviders": ["local", "ssh"], + "coverageNotes": "Deterministic service tests cover release-versus-reuse ordering, transactional retain and takeover cancellation, exact host/pane/process identity, dead external/user-owned/transferred/stopped/abandoned reconciliation, conservative unknown provider and legacy metadata handling, immutable transcript and bounded terminal archives, mutation restart, reset cleanup, replay idempotency, and 50-resource accounting. A macOS Electron journey invokes the freshly compiled worker-release CLI after the worker process disappears, then independently checks released SQLite state and coordinator liveness. Injected inventories cover local and SSH provider routing; live SSH, WSL, Windows, paired-runtime, and provider-close lost-ack journeys remain explicit gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/12355", + "https://github.com/stablyai/orca/issues/13860", + "STA-905", + "STA-3932" + ], + "invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Once the authoritative owning provider positively excludes the resource's exact immutable process incarnation, even an external, user-owned, or transferred dead resource must converge to released without any process close. Unknown host scope, missing incarnation metadata, or unavailable inventory must remain retained. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.", + "oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. For retained external, user-owned, transferred, stopped, and abandoned resources, run one fresh inventory against the exact local/WSL or SSH provider: an exact live incarnation and every unknown inventory shape stay retained, while positive absence atomically sets ownership_state and release_state to released with processAction none and zero closeTerminal calls. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.", "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose" + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose", + "pnpm run build:cli && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-worker-settlement-release-cli.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ + "src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts", "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts", "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts", "src/main/runtime/orchestration/worker-transcript-read.test.ts", "src/renderer/src/lib/worker-terminal-takeover-report.test.ts", - "tests/e2e/completed-worker-retirement-resume.unit.test.ts" + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts" ], "assertionRefs": [ + { + "file": "src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts", + "assertions": [ + "classifies only an exact incarnation as live on its owning provider", + "keeps missing or malformed identity and unavailable inventory unknown", + "does not inspect an unproven host scope", + "uses the local provider inventory for local and WSL scopes" + ] + }, { "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "assertions": [ @@ -7904,6 +8898,10 @@ { "file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "assertions": [ + "reconciles a dead external terminal without closing a process", + "reconciles a dead user-taken-over terminal without closing a process", + "reconciles a dead stopped or abandoned worker without closing a process", + "reconciles dead transferred ownership after the current owner settles", "rejects exact reuse after release intent instead of closing the new worker", "lets an explicit retain cancel a release while output capture is pending", "retains when the terminal host scope changed instead of closing", @@ -7923,9 +8921,36 @@ "coalesces overlapping reconciliation passes and closes each resource once", "keeps live terminals bounded across 50 settled workers while controls survive" ] + }, + { + "file": "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts", + "assertions": [ + "compiled worker-release reconciles a dead external retained resource to released", + "the CLI reports processAction none instead of closing a process", + "SQLite independently records released ownership and release state", + "the unrelated coordinator terminal remains live" + ] } ], "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm run build:cli && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-worker-settlement-release-cli.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 20, + "summary": "The combined candidate's compiled worker-release returned released with processAction none after positive process absence; an independent SQLite read showed both states released and the coordinator terminal remained live." + }, + { + "date": "2026-08-11", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 4.98, + "summary": "Six focused files passed 67 tests on the rebased candidate, covering dead external, user-owned, stopped, abandoned, and transferred reconciliation; exact local/WSL/SSH provider routing; malformed, missing, and unavailable inventory retention; zero process closes; existing lease, archive, recovery, mutation, and renderer-input contracts." + }, { "date": "2026-08-03", "runner": "local", @@ -7937,20 +8962,20 @@ } ], "runtimeBudget": { - "p95Seconds": 8, - "scope": "deterministic runtime, SQLite, transcript, renderer-input, and mutation contracts" + "p95Seconds": 45, + "scope": "deterministic runtime, SQLite, transcript, renderer-input, mutation contracts, and one compiled-CLI Electron journey" }, "flakeHistory": { "status": "unknown", - "evidence": "First local deterministic run only; focused CI and soak history are not yet available." + "evidence": "Repeated local deterministic runs pass; focused CI and soak history are not yet available." }, "redGreenEvidence": { "status": "complete", - "evidence": "The byte-identical 56-test oracle produced 12 deterministic failures and 44 passes with production restored to exact PR head 4aa66b62e3, including ready reuse during release, unsafe migration, stale archives, mutable transcript output, host/identity close gaps, and mutation replay loss. Candidate 457bc4edd1 passed all 56; restoring the same five production files to 4aa66b62e3 reproduced the same red state." + "evidence": "The byte-identical compiled-CLI Electron oracle left the dead resource external/retained on latest main 5ea7df1a5b, passed on combined candidate d697666ce8 with released/released SQLite state and processAction none, and reproduced external/retained after disabling the claimed production files at merge-base 64aec94cb2. The earlier unchanged three-case dead external/user-owned/transferred service oracle likewise failed 3/3 on main, passed 3/3 on candidate, and failed 3/3 with production restored; every run asserted durable state and zero terminal close calls." }, "performanceBudget": { "required": true, - "evidence": "Release performs constant-count indexed resource and identity queries plus one bounded archive capture. Worker-list uses two set queries rather than one resource lookup per worker. Reconciliation remains serial and adds no polling, timers, subprocesses, renderer subscriptions, or provider-wide listing." + "evidence": "Normal owned release performs constant-count indexed resource and identity queries plus one bounded archive capture. A retained release performs exactly one bounded inventory against its authoritative local/WSL or specific SSH provider, with no retry, polling, timer, subprocess, renderer subscription, or per-session follow-up fanout. Worker-list uses two set queries rather than one resource lookup per worker." }, "promotionCriteria": [ "Collect 100 consecutive focused CI passes or 14 days of soak history.", @@ -7961,7 +8986,7 @@ "knownGaps": [ "Provider close has no durable cross-process operation receipt, so a crash after host mutation but before SQLite settlement remains release_pending until exact inventory returns.", "Federated release remains explicitly unsupported and retained.", - "Mobile/direct remote input takeover and live Linux, Windows, WSL, SSH, headed, and headless paired-runtime release are not exercised." + "Mobile/direct remote input takeover and live Linux, Windows, WSL, SSH, headed, and headless paired-runtime release are not exercised; the current fix changes provider inventory classification and SQLite state only, not ConPTY, filesystem, update, sleep, firewall, or window lifecycle semantics." ], "demotionRule": "Keep experimental or demote if release can overlap exact reuse, close a conflicting lease, lose immutable output, retain reset archives, fan out per worker, or any focused ordering test flakes without a product or harness defect." }, diff --git a/config/scripts/build-notification-status-macos.mjs b/config/scripts/build-notification-status-macos.mjs index e8150e32b6f..41d4c84a4af 100644 --- a/config/scripts/build-notification-status-macos.mjs +++ b/config/scripts/build-notification-status-macos.mjs @@ -79,7 +79,7 @@ try { function readArg(name) { const index = args.indexOf(name) - return index >= 0 ? args[index + 1] : undefined + return index !== -1 ? args[index + 1] : undefined } function embeddedInfoPlist(identifier) { diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index 6f9b86bdb4b..27c17b3d15c 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -56,5 +56,5 @@ function findFrameworkCompiler(env) { function readArg(name) { const index = process.argv.indexOf(name) - return index >= 0 ? process.argv[index + 1] : undefined + return index !== -1 ? process.argv[index + 1] : undefined } diff --git a/config/scripts/computer-use-modifier-safety.test.mjs b/config/scripts/computer-use-modifier-safety.test.mjs index 725f17fdf4c..2a3234d3d9c 100644 --- a/config/scripts/computer-use-modifier-safety.test.mjs +++ b/config/scripts/computer-use-modifier-safety.test.mjs @@ -11,7 +11,7 @@ function source(path) { function sourceBetween(contents, startMarker, endMarker) { const start = contents.indexOf(startMarker) const end = contents.indexOf(endMarker, start + startMarker.length) - if (start < 0 || end < 0) { + if (start === -1 || end === -1) { throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`) } return contents.slice(start, end) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index f59f4a80f88..818b380fc2f 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -196,7 +196,8 @@ describe('Electron Vite output contract', () => { }) it('rejects prototype properties as build targets', () => { - expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)') + // Own-property check only: an inherited key like `constructor` must not select a build target. + expect(targetConfig).toContain('Object.hasOwn(configByTarget, target)') }) it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => { diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 98b064cd30d..44261e968a9 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -45,7 +45,7 @@ function readRuntimeArg() { } const runtimeIndex = process.argv.indexOf('--runtime') - if (runtimeIndex >= 0) { + if (runtimeIndex !== -1) { return process.argv[runtimeIndex + 1] } diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs index 899cb6436df..226ea74c4fb 100644 --- a/config/scripts/generate-skill-bundle-manifest.mjs +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -227,7 +227,7 @@ function readGitBlobs(objectShas) { let offset = 0 for (const requestedSha of uniqueShas) { const headerEnd = output.indexOf(10, offset) - if (headerEnd < 0) { + if (headerEnd === -1) { throw new Error(`Missing git cat-file header for ${requestedSha}`) } const header = output.subarray(offset, headerEnd).toString('utf8') @@ -648,8 +648,8 @@ async function main() { const argv = process.argv.slice(2) const rebuildFromTags = argv.includes('--rebuild-from-tags') const releaseIndex = argv.indexOf('--release') - const releaseVersion = releaseIndex >= 0 ? argv[releaseIndex + 1] : null - if (releaseIndex >= 0 && !releaseVersion) { + const releaseVersion = releaseIndex !== -1 ? argv[releaseIndex + 1] : null + if (releaseIndex !== -1 && !releaseVersion) { throw new Error('--release requires a version argument, e.g. --release 1.4.160') } diff --git a/config/scripts/idle-cpu-process-sampling.mjs b/config/scripts/idle-cpu-process-sampling.mjs new file mode 100644 index 00000000000..81f9218e5ad --- /dev/null +++ b/config/scripts/idle-cpu-process-sampling.mjs @@ -0,0 +1,323 @@ +import { execFileSync, spawnSync } from 'node:child_process' + +function parseCpuTimeSeconds(value) { + const trimmed = String(value || '').trim() + if (!trimmed) { + return null + } + const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed] + const days = dayOrTime === null ? 0 : Number(dayOrTime) + const parts = maybeTime.split(':').map(Number) + if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) { + return null + } + if (parts.length === 3) { + return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2] + } + if (parts.length === 2) { + return days * 86400 + parts[0] * 60 + parts[1] + } + if (parts.length === 1) { + return days * 86400 + parts[0] + } + return null +} + +function parseUnixProcesses(stdout) { + const rows = [] + for (const raw of stdout.split('\n')) { + const line = raw.trim() + if (!line) { + continue + } + const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/) + if (!match) { + continue + } + rows.push({ + pid: Number(match[1]), + ppid: Number(match[2]), + percentCpu: Number(match[3]), + rssBytes: Number(match[4]) * 1024, + cpuTimeSeconds: parseCpuTimeSeconds(match[5]), + command: match[6] + }) + } + return rows +} + +function readUnixProcesses() { + const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], { + encoding: 'utf8', + env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, + maxBuffer: 20 * 1024 * 1024 + }) + return parseUnixProcesses(stdout) +} + +function readWindowsProcesses() { + const script = + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress' + const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024 + }) + if (result.status !== 0) { + throw new Error(result.stderr || 'PowerShell process enumeration failed') + } + const parsed = JSON.parse(result.stdout || '[]') + const entries = Array.isArray(parsed) ? parsed : [parsed] + return entries.map((entry) => ({ + pid: Number(entry.ProcessId), + ppid: Number(entry.ParentProcessId), + percentCpu: 0, + cpuTimeSeconds: null, + rssBytes: Number(entry.WorkingSetSize) || 0, + command: String(entry.CommandLine || '') + })) +} + +export function readProcessRows() { + return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses() +} + +export function descendantsOf(rows, rootPid) { + const children = new Map() + for (const row of rows) { + const list = children.get(row.ppid) ?? [] + list.push(row) + children.set(row.ppid, list) + } + const result = [] + const stack = [rootPid] + const seen = new Set() + while (stack.length > 0) { + const pid = stack.pop() + if (seen.has(pid)) { + continue + } + seen.add(pid) + const row = rows.find((candidate) => candidate.pid === pid) + if (row) { + result.push(row) + } + for (const child of children.get(pid) ?? []) { + stack.push(child.pid) + } + } + return result +} + +export function classify(row, rootPid) { + const command = row.command.toLowerCase() + if (row.pid === rootPid) { + return 'main' + } + if (command.includes('daemon-entry')) { + return 'daemon' + } + if (command.includes('--type=gpu-process')) { + return 'gpu' + } + if (command.includes('--type=renderer')) { + return 'renderer' + } + if (command.includes('--type=utility')) { + return 'utility' + } + if (command.includes('--type=')) { + return 'electron-other' + } + if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) { + return 'agent-or-node' + } + return 'other-descendant' +} + +const DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS = 120_000 + +export async function sampleProcessTreeUntilWorkloadsComplete({ + rootPid, + requestedDurationMs, + intervalMs, + workloadPromise, + maxWorkloadOverrunMs = DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS, + readRows = readProcessRows, + now = Date.now, + wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +}) { + const samplingStartedAt = now() + const requestedDeadline = samplingStartedAt + requestedDurationMs + const hardDeadline = requestedDeadline + maxWorkloadOverrunMs + let workloadSettled = false + let workloadResult + let workloadError + let workloadSettledAt = null + void workloadPromise.then( + (result) => { + workloadResult = result + workloadSettled = true + workloadSettledAt = now() + }, + (error) => { + workloadError = error + workloadSettled = true + workloadSettledAt = now() + } + ) + const samples = [] + let previousSnapshot = null + const needsFinalWorkloadSample = () => + workloadSettledAt !== null && (previousSnapshot?.at ?? -Infinity) < workloadSettledAt + while ( + now() <= requestedDeadline || + samples.length === 0 || + !workloadSettled || + needsFinalWorkloadSample() + ) { + const sampledAt = now() + if (workloadError) { + throw workloadError + } + if ( + (!workloadSettled && sampledAt >= hardDeadline) || + (workloadSettledAt !== null && workloadSettledAt > hardDeadline) + ) { + throw new Error( + `Benchmark workload exceeded the ${maxWorkloadOverrunMs}ms sampling overrun limit` + ) + } + const processRows = descendantsOf(readRows(), rootPid) + const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) })) + if (previousSnapshot) { + const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000) + const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc])) + const processes = rawProcesses.map((row) => { + const previous = previousByPid.get(row.pid) + const canComputeDelta = + typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number' + const cpu = canComputeDelta + ? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100) + : row.percentCpu + return { ...row, cpu } + }) + samples.push({ + at: sampledAt, + elapsedMs: sampledAt - previousSnapshot.at, + totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0), + totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0), + processes + }) + } + previousSnapshot = { at: sampledAt, processes: rawProcesses } + await wait(intervalMs) + } + if (workloadError) { + throw workloadError + } + const measuredDurationMs = Math.max( + 0, + (previousSnapshot?.at ?? samplingStartedAt) - samplingStartedAt + ) + return { + samples, + workloadResult, + samplingWindow: { + requestedDurationMs, + measuredDurationMs, + maxWorkloadOverrunMs, + extendedForWorkload: workloadSettledAt !== null && workloadSettledAt > requestedDeadline, + workloadSettledElapsedMs: + workloadSettledAt === null ? null : Math.max(0, workloadSettledAt - samplingStartedAt), + workloadSettledBeforeStop: workloadSettled + } + } +} + +function mean(values) { + return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length +} + +function percentile(sorted, fraction) { + if (sorted.length === 0) { + return 0 + } + const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1) + return sorted[index] +} + +export function summarizeSamples(samples) { + const byKind = new Map() + for (const sample of samples) { + for (const proc of sample.processes) { + const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 } + bucket.cpuValues.push(proc.cpu) + bucket.rssValues.push(proc.rssBytes) + byKind.set(proc.kind, bucket) + } + const counts = new Map() + for (const proc of sample.processes) { + counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1) + } + for (const [kind, count] of counts) { + byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count) + } + } + const summary = {} + for (const [kind, values] of byKind) { + const cpuSorted = [...values.cpuValues].sort((a, b) => a - b) + const rssSumBySample = samples.map((sample) => + sample.processes + .filter((proc) => proc.kind === kind) + .reduce((sum, proc) => sum + proc.rssBytes, 0) + ) + summary[kind] = { + meanCpuPercent: mean(values.cpuValues), + p95CpuPercent: percentile(cpuSorted, 0.95), + maxCpuPercent: Math.max(0, ...values.cpuValues), + meanRssBytes: mean(rssSumBySample), + maxProcessCount: values.maxProcessCount + } + } + summary.total = { + meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)), + p95CpuPercent: percentile( + samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b), + 0.95 + ), + meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes)) + } + return summary +} + +export function summarizeProcessInventory(samples) { + const inventory = {} + for (const sample of samples) { + const counts = new Map() + for (const proc of sample.processes) { + counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1) + const entry = inventory[proc.kind] ?? { + maxProcessCount: 0, + maxCpuPercent: 0, + commandSamples: [] + } + entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu) + if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) { + entry.commandSamples.push(proc.command) + } + inventory[proc.kind] = entry + } + for (const [kind, count] of counts) { + inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count) + } + } + return inventory +} + +export function terminateProcesses(processes) { + for (const proc of processes) { + try { + process.kill(proc.pid) + } catch {} + } +} diff --git a/config/scripts/idle-cpu-process-sampling.test.mjs b/config/scripts/idle-cpu-process-sampling.test.mjs new file mode 100644 index 00000000000..d452c6e3ce0 --- /dev/null +++ b/config/scripts/idle-cpu-process-sampling.test.mjs @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { sampleProcessTreeUntilWorkloadsComplete } from './idle-cpu-process-sampling.mjs' + +function createClock(workloadCompletesAt) { + let currentMs = 0 + let resolveWorkload + const workloadPromise = new Promise((resolve) => { + resolveWorkload = resolve + }) + const wait = async (durationMs) => { + currentMs += durationMs + if (currentMs >= workloadCompletesAt) { + resolveWorkload('complete') + } + await Promise.resolve() + } + const readRows = () => [ + { + pid: 10, + ppid: 0, + percentCpu: 0, + rssBytes: 1_024, + cpuTimeSeconds: currentMs / 2_000, + command: 'electron' + } + ] + return { now: () => currentMs, readRows, wait, workloadPromise } +} + +describe('idle CPU process sampling window', () => { + it('extends through a slow workload and captures a final CPU delta', async () => { + const clock = createClock(40) + const result = await sampleProcessTreeUntilWorkloadsComplete({ + rootPid: 10, + requestedDurationMs: 20, + intervalMs: 10, + maxWorkloadOverrunMs: 100, + ...clock + }) + + expect(result.workloadResult).toBe('complete') + expect(result.samples.map((sample) => sample.at)).toEqual([10, 20, 30, 40]) + expect(result.samplingWindow).toEqual({ + requestedDurationMs: 20, + measuredDurationMs: 40, + maxWorkloadOverrunMs: 100, + extendedForWorkload: true, + workloadSettledElapsedMs: 40, + workloadSettledBeforeStop: true + }) + }) + + it('invalidates a run that reaches the workload overrun guard', async () => { + const clock = createClock(Infinity) + await expect( + sampleProcessTreeUntilWorkloadsComplete({ + rootPid: 10, + requestedDurationMs: 20, + intervalMs: 10, + maxWorkloadOverrunMs: 20, + ...clock + }) + ).rejects.toThrow('exceeded the 20ms sampling overrun limit') + }) +}) diff --git a/config/scripts/idle-cpu-renderer-scale-fixture.mjs b/config/scripts/idle-cpu-renderer-scale-fixture.mjs new file mode 100644 index 00000000000..4759b768e27 --- /dev/null +++ b/config/scripts/idle-cpu-renderer-scale-fixture.mjs @@ -0,0 +1,202 @@ +export async function configureRendererScaleFixture(page, options, repoPath) { + return page.evaluate( + ({ agentsPerWorktree, lineageDepth, repoPath }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const normalizePath = (value) => + String(value ?? '') + .replaceAll('\\', '/') + .toLowerCase() + const primaryPath = normalizePath(repoPath) + const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0) + const state = store.getState() + const worktrees = Object.values(state.worktreesByRepo) + .flat() + .filter((worktree) => !worktree.isArchived) + .sort((left, right) => { + const primaryOrder = + Number(normalizePath(right.path) === primaryPath) - + Number(normalizePath(left.path) === primaryPath) + return ( + primaryOrder || + compare(normalizePath(left.path), normalizePath(right.path)) || + compare(left.id, right.id) + ) + }) + const appliedLineageDepth = Math.min(lineageDepth, Math.max(0, worktrees.length - 1)) + if (lineageDepth === 0 && agentsPerWorktree === 0) { + return { + applied: false, + requestedLineageDepth: lineageDepth, + appliedLineageDepth, + agentsPerWorktree, + seededAgentRows: 0 + } + } + + state.setActiveView('terminal') + state.setSidebarOpen(true) + state.setGroupBy('none') + state.setSortBy('recent') + state.setShowActiveOnly(false) + state.setShowSleepingWorkspaces(true) + state.setHideDefaultBranchWorkspace(false) + state.setFilterRepoIds([]) + + const lineageById = { ...store.getState().worktreeLineageById } + const lineageParentIds = new Set() + if (appliedLineageDepth > 0) { + for (const worktree of worktrees) { + delete lineageById[worktree.id] + if (!worktree.instanceId) { + throw new Error(`Worktree ${worktree.id} has no instanceId for lineage seeding`) + } + } + for (let index = 1; index < worktrees.length; index += 1) { + const child = worktrees[index] + const parent = worktrees[Math.min(index - 1, appliedLineageDepth - 1)] + lineageParentIds.add(parent.id) + lineageById[child.id] = { + worktreeId: child.id, + worktreeInstanceId: child.instanceId, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1_700_000_000_000 + index + } + } + } + const collapsedGroups = new Set(store.getState().collapsedGroups) + for (const parentId of lineageParentIds) { + collapsedGroups.delete(`lineage:${parentId}`) + } + store.setState({ worktreeLineageById: lineageById, collapsedGroups }) + + let seededAgentRows = 0 + if (agentsPerWorktree > 0) { + store.getState().setWorktreeCardMode('Default') + store.getState().setAgentActivityDisplayMode('full') + const fixtureNow = Date.now() + worktrees.forEach((worktree, worktreeIndex) => { + const next = store.getState() + const tab = + next.tabsByWorktree[worktree.id]?.[0] ?? + next.createTab(worktree.id, undefined, undefined, { + activate: false, + id: `idle-cpu-tab-${worktreeIndex}` + }) + for (let agentIndex = 0; agentIndex < agentsPerWorktree; agentIndex += 1) { + const agentType = agentIndex % 2 === 0 ? 'codex' : 'claude' + const leafSequence = + BigInt(worktreeIndex) * BigInt(agentsPerWorktree) + BigInt(agentIndex + 1) + const leafId = `00000000-0000-4000-8000-${leafSequence.toString(16).padStart(12, '0')}` + store.getState().setAgentStatus( + `${tab.id}:${leafId}`, + { + state: 'working', + prompt: `Idle CPU agent ${worktreeIndex + 1}.${agentIndex + 1}`, + agentType + }, + agentType, + { updatedAt: fixtureNow, stateStartedAt: fixtureNow }, + { tabId: tab.id, worktreeId: worktree.id } + ) + seededAgentRows += 1 + } + }) + } + return { + applied: true, + requestedLineageDepth: lineageDepth, + appliedLineageDepth, + lineageEdges: appliedLineageDepth > 0 ? Math.max(0, worktrees.length - 1) : 0, + expandedLineageGroups: lineageParentIds.size, + agentsPerWorktree, + seededAgentRows, + orderedWorktreeIds: worktrees.map((worktree) => worktree.id) + } + }, + { agentsPerWorktree: options.agentsPerWorktree, lineageDepth: options.lineageDepth, repoPath } + ) +} + +export async function collectRendererCensus(page, configuredLineageDepth) { + return page.evaluate((configuredDepth) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('window.__store is not available') + } + const worktrees = Object.values(state.worktreesByRepo).flat() + const worktreeIds = new Set(worktrees.map((worktree) => worktree.id)) + const lineageDepthById = new Map() + const getDepth = (worktreeId, trail = new Set()) => { + if (lineageDepthById.has(worktreeId)) { + return lineageDepthById.get(worktreeId) + } + const lineage = state.worktreeLineageById[worktreeId] + if (!lineage || !worktreeIds.has(lineage.parentWorktreeId) || trail.has(worktreeId)) { + return 0 + } + const nextTrail = new Set(trail) + nextTrail.add(worktreeId) + const depth = 1 + getDepth(lineage.parentWorktreeId, nextTrail) + lineageDepthById.set(worktreeId, depth) + return depth + } + const logicalDepths = worktrees.map((worktree) => getDepth(worktree.id)) + const lineageParentIds = new Set( + Object.values(state.worktreeLineageById) + .filter((lineage) => worktreeIds.has(lineage.worktreeId)) + .map((lineage) => lineage.parentWorktreeId) + ) + const sidebar = document.querySelector('[data-worktree-sidebar]') + const mountedWorktreeIds = [ + ...new Set( + [...(sidebar?.querySelectorAll('[data-worktree-id]') ?? [])] + .map((element) => element.getAttribute('data-worktree-id')) + .filter(Boolean) + ) + ] + const mountedAgentRows = [...(sidebar?.querySelectorAll('*') ?? [])].filter( + (element) => + element.classList.contains('group/agent-row') || + element.classList.contains('compact-agent-row') + ).length + let diagnosticCensus = null + try { + diagnosticCensus = window.__orcaTypingDiagnostic?.report().census ?? null + } catch {} + const collapsedLineageGroups = [...lineageParentIds].filter((parentId) => + state.collapsedGroups.has(`lineage:${parentId}`) + ).length + return { + capturedAt: new Date().toISOString(), + worktrees: { + store: worktrees.length, + mountedCards: sidebar?.querySelectorAll('[data-worktree-card-surface]').length ?? 0, + mountedUnique: mountedWorktreeIds.length, + mountedIds: mountedWorktreeIds.slice(0, 100), + mountedIdsTruncated: Math.max(0, mountedWorktreeIds.length - 100) + }, + agentRows: { + storeLive: Object.keys(state.agentStatusByPaneKey ?? {}).length, + storeRetained: Object.keys(state.retainedAgentsByPaneKey ?? {}).length, + mounted: mountedAgentRows, + diagnosticMounted: diagnosticCensus?.agentRows.mountedDom ?? null + }, + storeListeners: diagnosticCensus?.storeListeners ?? null, + lineage: { + configuredDepth, + measuredMaxDepth: Math.max(0, ...logicalDepths), + edges: logicalDepths.filter((depth) => depth > 0).length, + groups: lineageParentIds.size, + expandedGroups: lineageParentIds.size - collapsedLineageGroups, + collapsedGroups: collapsedLineageGroups + }, + diagnostic: diagnosticCensus + } + }, configuredLineageDepth) +} diff --git a/config/scripts/idle-cpu-renderer-timing-probe.mjs b/config/scripts/idle-cpu-renderer-timing-probe.mjs new file mode 100644 index 00000000000..b010bcb3433 --- /dev/null +++ b/config/scripts/idle-cpu-renderer-timing-probe.mjs @@ -0,0 +1,191 @@ +const RENDERER_TIMER_INTERVAL_MS = 100 + +export async function startRendererTimingProbe(page) { + await page.evaluate((timerIntervalMs) => { + const maxSamples = 5_000 + const maxEntries = 80 + let phaseStartedAt = performance.now() + let phaseStartedAtIso = new Date().toISOString() + let timerId = null + let driftCount = 0 + let driftSamples = [] + let longTaskEntries = [] + let observer = null + let longTaskSupported = false + + const round = (value) => Math.round(value * 100) / 100 + const summarize = (values, totalCount = values.length) => { + const sorted = [...values].sort((left, right) => left - right) + const percentile = (fraction) => + sorted.length === 0 + ? null + : sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] + return { + count: totalCount, + retainedCount: sorted.length, + mean: + sorted.length === 0 + ? null + : round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length), + p50: sorted.length === 0 ? null : round(percentile(0.5)), + p95: sorted.length === 0 ? null : round(percentile(0.95)), + max: sorted.length === 0 ? null : round(sorted.at(-1)) + } + } + const recordLongTasks = (entries) => { + for (const entry of entries) { + longTaskEntries.push({ + startTime: entry.startTime, + duration: entry.duration, + name: entry.name + }) + if (longTaskEntries.length > maxSamples) { + longTaskEntries.shift() + } + } + } + try { + longTaskSupported = PerformanceObserver.supportedEntryTypes?.includes('longtask') === true + if (longTaskSupported) { + observer = new PerformanceObserver((list) => recordLongTasks(list.getEntries())) + observer.observe({ type: 'longtask', buffered: true }) + } + } catch { + observer = null + longTaskSupported = false + } + const scheduleTimer = () => { + const expectedAt = performance.now() + timerIntervalMs + timerId = setTimeout(() => { + driftCount += 1 + driftSamples.push(Math.max(0, performance.now() - expectedAt)) + if (driftSamples.length > maxSamples) { + driftSamples.shift() + } + scheduleTimer() + }, timerIntervalMs) + } + const snapshot = (reset) => { + if (observer) { + recordLongTasks(observer.takeRecords()) + } + const capturedAt = performance.now() + const phaseLongTasks = longTaskEntries.filter((entry) => entry.startTime >= phaseStartedAt) + const durations = phaseLongTasks.map((entry) => entry.duration) + const result = { + startedAt: phaseStartedAtIso, + capturedAt: new Date().toISOString(), + durationMs: round(capturedAt - phaseStartedAt), + timerIntervalMs, + timerDriftMs: summarize(driftSamples, driftCount), + longTasks: { + supported: longTaskSupported, + ...summarize(durations), + totalDurationMs: round(durations.reduce((sum, value) => sum + value, 0)), + entries: phaseLongTasks.slice(-maxEntries).map((entry) => ({ + startMs: round(entry.startTime - phaseStartedAt), + durationMs: round(entry.duration), + name: entry.name + })), + entriesTruncated: Math.max(0, phaseLongTasks.length - maxEntries) + } + } + if (reset) { + phaseStartedAt = capturedAt + phaseStartedAtIso = new Date().toISOString() + driftCount = 0 + driftSamples = [] + longTaskEntries = [] + } + return result + } + scheduleTimer() + window.__orcaIdleCpuTimingProbe = { + snapshot: () => snapshot(true), + stop: () => { + if (timerId !== null) { + clearTimeout(timerId) + } + const result = snapshot(false) + observer?.disconnect() + return result + } + } + }, RENDERER_TIMER_INTERVAL_MS) +} + +export async function snapshotRendererTimingProbe(page) { + return page.evaluate(() => window.__orcaIdleCpuTimingProbe?.snapshot() ?? null) +} + +export async function stopRendererTimingProbe(page) { + return page.evaluate(() => window.__orcaIdleCpuTimingProbe?.stop() ?? null) +} + +export async function runZustandPublications(page, count, intervalMs) { + return page.evaluate( + ({ count, intervalMs }) => + new Promise((resolve, reject) => { + const store = window.__store + if (!store) { + reject(new Error('window.__store is not available')) + return + } + const maxSamples = 5_000 + const startedAt = performance.now() + const startedAtIso = new Date().toISOString() + const schedulingDriftMs = [] + let completed = 0 + const finish = () => { + const sorted = [...schedulingDriftMs].sort((left, right) => left - right) + const percentile = (fraction) => + sorted.length === 0 + ? null + : sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] + const round = (value) => Math.round(value * 100) / 100 + resolve({ + requested: count, + completed, + intervalMs, + startedAt: startedAtIso, + completedAt: new Date().toISOString(), + durationMs: round(performance.now() - startedAt), + schedulingDriftMs: { + retainedCount: sorted.length, + p50: sorted.length === 0 ? null : round(percentile(0.5)), + p95: sorted.length === 0 ? null : round(percentile(0.95)), + max: sorted.length === 0 ? null : round(sorted.at(-1)) + } + }) + } + const publish = () => { + const publishedAt = performance.now() + const scheduledAt = startedAt + completed * intervalMs + schedulingDriftMs.push(Math.max(0, publishedAt - scheduledAt)) + if (schedulingDriftMs.length > maxSamples) { + schedulingDriftMs.shift() + } + try { + // Why: an empty partial notifies every subscriber without changing domain state. + store.setState({}) + } catch (error) { + reject(error) + return + } + completed += 1 + if (completed >= count) { + finish() + return + } + const nextAt = startedAt + completed * intervalMs + setTimeout(publish, Math.max(0, nextAt - performance.now())) + } + if (count === 0) { + finish() + } else { + setTimeout(publish, 0) + } + }), + { count, intervalMs } + ) +} diff --git a/config/scripts/install-electron-package-binary.mjs b/config/scripts/install-electron-package-binary.mjs index f75d1eae30a..314cd31710f 100644 --- a/config/scripts/install-electron-package-binary.mjs +++ b/config/scripts/install-electron-package-binary.mjs @@ -28,6 +28,8 @@ const transientDownloadErrorCodes = new Set([ 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', + // GitHub release CDN can refuse HTTP/2 streams under load; retry like socket resets. + 'ERR_HTTP2_STREAM_ERROR', 'ENETDOWN', 'ENETRESET', 'ENETUNREACH', @@ -185,7 +187,9 @@ async function downloadElectronArtifactWithRetry(downloadOptions) { function getDownloadRetryDelays() { const configured = process.env.ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS if (!configured) { - return [1_000, 3_000] + // Why: GitHub release CDN returns intermittent 503 / HTTP2 stream refusals + // under CI fan-out; a few short attempts still exhaust during outages. + return [1_000, 3_000, 5_000, 10_000] } const delays = configured.split(',').map(Number) @@ -200,7 +204,7 @@ function isTransientDownloadError(error) { if (transientDownloadErrorCodes.has(candidate?.code)) { return true } - const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode + const statusCode = getDownloadErrorStatusCode(candidate) if ( statusCode === 408 || statusCode === 425 || @@ -213,6 +217,13 @@ function isTransientDownloadError(error) { return false } +function getDownloadErrorStatusCode(error) { + // @electron/get FetchDownloader uses Fetch Response.status; older got uses statusCode. + return ( + error?.statusCode ?? error?.status ?? error?.response?.statusCode ?? error?.response?.status + ) +} + function getErrorChain(error) { const errors = [] let candidate = error @@ -225,7 +236,7 @@ function getErrorChain(error) { function formatDownloadError(error) { for (const candidate of getErrorChain(error)) { - const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode + const statusCode = getDownloadErrorStatusCode(candidate) if (statusCode) { return `HTTP ${statusCode}` } diff --git a/config/scripts/install-electron-package-binary.test.mjs b/config/scripts/install-electron-package-binary.test.mjs index 2c37f95cfbe..ad04336e8e7 100644 --- a/config/scripts/install-electron-package-binary.test.mjs +++ b/config/scripts/install-electron-package-binary.test.mjs @@ -118,6 +118,58 @@ describe('install-electron-package-binary', () => { } }) + it('retries HTTP/2 stream refusal Electron download failures', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { + downloadFailures: 1, + downloadErrorCode: 'ERR_HTTP2_STREAM_ERROR' + }) + writeFakeExtractor(projectDir, { createExecutable: true }) + + const result = runInstallScript(projectDir, { + ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0' + }) + + expect(result.status, result.stderr).toBe(0) + expect( + readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n') + ).toHaveLength(2) + expect(result.stderr).toContain( + 'Transient Electron download failure (ERR_HTTP2_STREAM_ERROR)' + ) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('retries Electron download HTTP 503 failures from Fetch Response.status', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { + downloadFailures: 1, + downloadHttpStatus: 503 + }) + writeFakeExtractor(projectDir, { createExecutable: true }) + + const result = runInstallScript(projectDir, { + ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0' + }) + + expect(result.status, result.stderr).toBe(0) + expect( + readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n') + ).toHaveLength(2) + expect(result.stderr).toContain('Transient Electron download failure (HTTP 503)') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + it('fails after exhausting transient Electron download retries', () => { const projectDir = mkTempProject() @@ -279,7 +331,12 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8')) function writeFakeElectronGet( projectDir, - { downloadNeverSettles = false, downloadFailures = 0, downloadErrorCode = 'ECONNRESET' } = {} + { + downloadNeverSettles = false, + downloadFailures = 0, + downloadErrorCode = 'ECONNRESET', + downloadHttpStatus = null + } = {} ) { const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get') mkdirSync(getDir, { recursive: true }) @@ -299,6 +356,11 @@ exports.downloadArtifact = async function downloadArtifact(details) { return new Promise(() => {}) } if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) { + if (${JSON.stringify(downloadHttpStatus)} != null) { + const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)}) + error.response = { status: ${JSON.stringify(downloadHttpStatus)} } + throw error + } const cause = Object.assign(new Error('download failed'), { code: ${JSON.stringify(downloadErrorCode)} }) diff --git a/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs b/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs index 5f4160600cc..76555d4e78e 100644 --- a/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs +++ b/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs @@ -30,8 +30,8 @@ const recoveryEnd = runtimeSource.indexOf( ) const recoverySource = runtimeSource.slice(recoveryStart, recoveryEnd) if ( - recoveryStart < 0 || - recoveryEnd < 0 || + recoveryStart === -1 || + recoveryEnd === -1 || !recoverySource.includes('await this.flushWorkspaceSessionOrThrowAsync()') || recoverySource.includes('flushOrThrow()') ) { diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index bd0c3c15aee..aacf053926e 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -2273,12 +2273,6 @@ "auto.components.settings.GitPane.976afc6b3e": { "ko": "워크스페이스를 만들면 Orca가 원격 기준 브랜치를 새로고침하고, 일치하는 로컬 브랜치를 안전하게 fast-forward합니다. 예:" }, - "auto.components.settings.GitPane.b5f534717a": { - "ko": "공동 작성" - }, - "auto.components.settings.GitPane.b9b5771bb1": { - "ko": "표기" - }, "auto.components.settings.GitPane.b9c011fbc2": { "ko": "속도 제한" }, @@ -2288,9 +2282,6 @@ "auto.components.settings.GitPane.db3a127eb1": { "ko": "이렇게 하면 다음과 같은 명령이 유지됩니다" }, - "auto.components.settings.GitPane.e02ea23a32": { - "ko": "Orca 표기" - }, "auto.components.settings.GitPane.f35007e6e8": { "ko": "git-username" }, diff --git a/config/scripts/locale-ko-value-overrides.mjs b/config/scripts/locale-ko-value-overrides.mjs index 5526d963b7a..bcc5b6e9e8e 100644 --- a/config/scripts/locale-ko-value-overrides.mjs +++ b/config/scripts/locale-ko-value-overrides.mjs @@ -44,7 +44,6 @@ export const KO_VALUE_OVERRIDES = { 'Issue #{{value0}}': '이슈 #{{value0}}', 'Linked issue #{{value0}}': '연결된 이슈 #{{value0}}', 'Issue # or GitHub URL': '이슈 # 또는 GitHub URL', - 'Add Orca attribution to commits, PRs, and issues.': '커밋, PR 및 이슈에 Orca 속성을 추가합니다.', 'Paste an issue URL, or enter a number. Leave blank to remove the link.': '이슈 URL을 붙여넣거나 숫자를 입력하세요. 링크를 제거하려면 비워두세요.', 'Connect Linear in Settings to search issues.': '이슈를 검색하려면 설정에서 Linear를 연결하세요.', @@ -157,8 +156,7 @@ export const KO_VALUE_OVERRIDES = { '저장되지 않은 Git AI Author 변경사항이 있습니다. 떠나면 폐기됩니다.', 'Discard unsaved Git AI Author changes?': '저장되지 않은 Git AI Author 변경사항을 삭제하시겠습니까?', - 'Branch naming, base refs, attribution, and Git AI Author.': - '브랜치 이름 지정, 기본 참조, 속성 및 Git AI Author.', + 'Branch naming, base refs, and Git AI Author.': '브랜치 이름 지정, 기본 참조 및 Git AI Author.', 'Used by repositories that inherit global hosted-review defaults.': '전역 호스팅 PR 기본값을 상속하는 저장소에서 사용됩니다.', 'Defaults used when the hosted-review composer opens.': diff --git a/config/scripts/locale-search-keyword-overrides.mjs b/config/scripts/locale-search-keyword-overrides.mjs index 2a9d6266f5d..352199c1f7e 100644 --- a/config/scripts/locale-search-keyword-overrides.mjs +++ b/config/scripts/locale-search-keyword-overrides.mjs @@ -164,7 +164,6 @@ export const SEARCH_KEYWORD_OVERRIDES = { gitlab: 'GitLab', gitea: 'Gitea', oauth: 'OAuth', - attribution: '归因', skill: '技能', skills: '技能', phone: '手机', diff --git a/config/scripts/locale-translation-policy.es-pr.test.mjs b/config/scripts/locale-translation-policy.es-pr.test.mjs index a3c046ce895..389f914c86b 100644 --- a/config/scripts/locale-translation-policy.es-pr.test.mjs +++ b/config/scripts/locale-translation-policy.es-pr.test.mjs @@ -25,10 +25,10 @@ describe('locale-translation-policy es PR glossary', () => { it('rewrites PR inside longer sentences', () => { expect( repairEs( - 'Add Orca attribution to commits, PRs, and issues.', - 'Agregue la atribución de Orca a commits, relaciones públicas y problemas.' + 'Split a large change into smaller PRs', + 'Dividir un cambio grande en relaciones públicas más pequeños' ) - ).toBe('Agregue la atribución de Orca a commits, PR y problemas.') + ).toBe('Dividir un cambio grande en PR más pequeños') expect( repairEs( 'Open the PR details to view current reviewers.', diff --git a/config/scripts/locale-translation-policy.zh-round5.test.mjs b/config/scripts/locale-translation-policy.zh-round5.test.mjs index 28901fba8d4..622b3c5c31c 100644 --- a/config/scripts/locale-translation-policy.zh-round5.test.mjs +++ b/config/scripts/locale-translation-policy.zh-round5.test.mjs @@ -44,22 +44,14 @@ describe('locale-translation-policy zh round 5', () => { locale: 'zh' }) ).toBe('显示 Orca Mobile 按钮') - expect( - repairTranslatedValue({ - key: 'auto.components.settings.GitPane.e02ea23a32', - enValue: 'Orca Attribution', - localeValue: 'Orca归属', - locale: 'zh' - }) - ).toBe('Orca 归因') expect( repairTranslatedValue({ key: 'auto.hooks.useSettingsNavigationMetadata.ab4b21b58e', - enValue: 'Branch naming, base refs, attribution, and Git AI Author.', - localeValue: '分支命名、基本引用、归属和 Git AI 作者。', + enValue: 'Branch naming, base refs, and Git AI Author.', + localeValue: '分支命名、基本引用和 Git AI 作者。', locale: 'zh' }) - ).toBe('分支命名、基础引用、归因和 Git AI Author。') + ).toBe('分支命名、基础引用和 Git AI Author。') expect( repairTranslatedValue({ key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569', diff --git a/config/scripts/locale-zh-phrase-fixes-round5.mjs b/config/scripts/locale-zh-phrase-fixes-round5.mjs index 1aa117395e8..23fcc6d32a5 100644 --- a/config/scripts/locale-zh-phrase-fixes-round5.mjs +++ b/config/scripts/locale-zh-phrase-fixes-round5.mjs @@ -3,7 +3,6 @@ export const ZH_PHRASE_FIXES_ROUND5 = [ { pattern: /Orca集成开发环境/g, replacement: 'Orca IDE', whenEnIncludes: 'Orca IDE' }, { pattern: /Orca第一/g, replacement: 'Orca 优先', whenEnIncludes: 'Orca first' }, { pattern: /Orca移动/g, replacement: 'Orca Mobile', whenEnIncludes: 'Orca Mobile' }, - { pattern: /Orca归属/g, replacement: 'Orca 归因', whenEnIncludes: 'Orca Attribution' }, { pattern: /Orca标志/g, replacement: 'Orca 标志', whenEnIncludes: 'Orca logo' }, { pattern: /喜欢Orca/g, replacement: '喜欢 Orca', whenEnIncludes: 'Enjoying Orca' }, { pattern: /认识Orca/g, replacement: '了解 Orca', whenEnIncludes: 'Get to know Orca' }, diff --git a/config/scripts/locale-zh-value-overrides.mjs b/config/scripts/locale-zh-value-overrides.mjs index 087091a3f3e..b53fb1d2248 100644 --- a/config/scripts/locale-zh-value-overrides.mjs +++ b/config/scripts/locale-zh-value-overrides.mjs @@ -144,7 +144,6 @@ export const ZH_VALUE_OVERRIDES = { 'Expand Orca': '展开 Orca', 'Support Orca': '支持 Orca', 'Restarting Orca…': '正在重启 Orca…', - 'Orca Attribution': 'Orca 归因', 'Show Orca Mobile Button': '显示 Orca Mobile 按钮', 'Codex Accounts': 'Codex 账户', 'Codex Account': 'Codex 账户', @@ -186,8 +185,7 @@ export const ZH_VALUE_OVERRIDES = { "Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see.": '让代理直接访问 Orca 的浏览器,以便测试页面、捕获屏幕截图并根据所见内容执行操作。', 'X finishes, send it the review task.”': 'X 完成后,把评审任务发给它。”', - 'Branch naming, base refs, attribution, and Git AI Author.': - '分支命名、基础引用、归因和 Git AI Author。', + 'Branch naming, base refs, and Git AI Author.': '分支命名、基础引用和 Git AI Author。', 'You have unsaved Git AI Author changes. Leaving will discard them.': '您有未保存的 Git AI Author 更改。离开将丢弃它们。', 'Discard unsaved Git AI Author changes?': '放弃未保存的 Git AI Author 更改?', diff --git a/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs b/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs index 8724cf8faf4..b6f1d9571a5 100644 --- a/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs +++ b/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs @@ -210,7 +210,7 @@ function connectInvalidPeer(socketPath) { const onData = (chunk) => { buffer += chunk const newline = buffer.indexOf('\n') - if (newline < 0) { + if (newline === -1) { return } let response diff --git a/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs b/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs index 86791745c10..5e350bb24f8 100644 --- a/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs +++ b/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs @@ -54,7 +54,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { const frames = [] while (cursor.length > 0) { const frameStart = cursor.indexOf(JPEG_START) - if (frameStart < 0) { + if (frameStart === -1) { const keepLastByte = cursor.at(-1) === 0xff return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) } } @@ -62,7 +62,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { cursor = cursor.subarray(frameStart) } const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length) - if (frameEnd < 0) { + if (frameEnd === -1) { const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes) return { frames, pending: trackedCopy(tail) } } @@ -79,7 +79,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { const frames = [] while (cursor.length > 0) { const frameStart = cursor.indexOf(JPEG_START) - if (frameStart < 0) { + if (frameStart === -1) { const keepLastByte = cursor.at(-1) === 0xff return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) } } @@ -87,7 +87,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { cursor = cursor.subarray(frameStart) } const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length) - if (frameEnd < 0) { + if (frameEnd === -1) { const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes) return { frames, pending: trackedCopy(tail) } } diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 080cb4fa785..b9a88ae12ad 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -281,7 +281,7 @@ describe('orchestration skill guidance', () => { expect(workerLoop).toContain('opaque provider model id with `--model`') expect(workerLoop).toContain('`--effort` requires `--model`') expect(workerLoop).toContain('neither option can combine with `--terminal`') - expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json') + expect(workerLoop).toContain('--agent claude --model opus --effort high --json') expect(workerLoop).toContain('`launch.requested` and `launch.effective`') }) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 1b695086808..c6e96c88b2c 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -80,6 +80,7 @@ describe('Electron runtime package contract', () => { 'build:linux', 'test:e2e', 'test:e2e:terminal-rendering-golden', + 'test:e2e:posix-profile-index-golden', 'test:e2e:terminal-rendering-release-evidence', 'test:e2e:headful' ] @@ -492,7 +493,7 @@ describe('Electron runtime package contract', () => { expect(uploadStep.with.path).toBe('${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}') }) - it('keeps terminal rendering regressions in the manual golden E2E workflow', () => { + it('keeps platform golden regressions in the manual and release workflows', () => { const packageScripts = packageJson.scripts const goldenWorkflow = parse( readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8') @@ -506,17 +507,19 @@ describe('Electron runtime package contract', () => { ['mac', 'macOS'], ['windows', 'Windows'] ]) - const goldenPlatforms = goldenWorkflow.jobs['golden-e2e'].strategy.matrix.include - .map(({ platform }) => platform) - .sort() - const goldenRunSteps = goldenPlatforms.map((platform) => { - const label = goldenPlatformLabels.get(platform) + const goldenMatrix = goldenWorkflow.jobs['golden-e2e'].strategy.matrix.include + const goldenPlatforms = goldenMatrix.map(({ platform }) => platform).sort() + const goldenRunSteps = new Map( + goldenPlatforms.map((platform) => { + const label = goldenPlatformLabels.get(platform) - expect(label, platform).toBeDefined() + expect(label, platform).toBeDefined() - return steps.find((step) => step.name === `Run golden E2E tests on ${label}`) - }) + return [platform, steps.find((step) => step.name === `Run golden E2E tests on ${label}`)] + }) + ) const releaseGoldenJob = releaseWorkflow.jobs['terminal-rendering-golden'] + const releaseGoldenMatrix = releaseGoldenJob.strategy.matrix.include const releaseEvidenceJob = releaseWorkflow.jobs['terminal-rendering-release-evidence'] const releaseBuildNeeds = releaseWorkflow.jobs.build.needs const publishReleaseNeeds = releaseWorkflow.jobs['publish-release'].needs @@ -535,15 +538,47 @@ describe('Electron runtime package contract', () => { expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain( 'terminal-long-table-scroll-restore.spec.ts' ) + expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain( + 'golden-windows-fresh-startup.spec.ts' + ) + expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain( + '@windows-fresh-startup-golden' + ) + expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( + 'golden-posix-profile-index-fsync.spec.ts' + ) + expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( + 'golden-posix-fresh-startup.spec.ts' + ) + expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( + '@posix-profile-index-golden' + ) expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain( 'terminal-opencode-emoji-table-rendering.spec.ts' ) expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain( 'terminal-long-table-scroll-restore.spec.ts' ) - for (const runStep of goldenRunSteps) { - expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden') - } + expect(goldenMatrix).toEqual([ + { os: 'ubuntu-latest', platform: 'linux' }, + { os: 'macos-15', platform: 'mac' }, + { os: 'windows-2022', platform: 'windows' } + ]) + expect(goldenRunSteps.get('linux')?.run).toContain( + 'pnpm run test:e2e:terminal-rendering-golden' + ) + expect(goldenRunSteps.get('linux')?.run).toContain( + 'pnpm run test:e2e:posix-profile-index-golden' + ) + expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:terminal-rendering-golden') + expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:posix-profile-index-golden') + expect(goldenRunSteps.get('windows')).toMatchObject({ + if: "runner.os == 'Windows'", + shell: 'pwsh' + }) + expect(goldenRunSteps.get('windows').run).toContain( + 'pnpm run test:e2e:windows-fresh-startup-golden' + ) expect(goldenWorkflow.on.pull_request).toBeUndefined() expect(goldenWorkflow.on.workflow_dispatch).toBeDefined() expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden') @@ -552,12 +587,25 @@ describe('Electron runtime package contract', () => { expect(publishReleaseNeeds).toContain('build') expect(publishReleaseNeeds).not.toContain('terminal-rendering-release-evidence') expect(releaseGoldenJob['continue-on-error']).toBeUndefined() - expect(releaseGoldenJob.strategy.matrix.include.map(({ platform }) => platform).sort()).toEqual( - goldenPlatforms + expect(releaseGoldenMatrix).toEqual(goldenMatrix) + const releaseLinuxRunStep = releaseGoldenJob.steps.find( + (step) => step.name === 'Run terminal rendering golden on Linux' ) - expect(releaseGoldenJob.steps.map((step) => step.run ?? '')).toContain( - 'xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden' + expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden') + expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:posix-profile-index-golden') + const releaseMacRunStep = releaseGoldenJob.steps.find( + (step) => step.name === 'Run terminal rendering golden on macOS' ) + expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden') + expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:posix-profile-index-golden') + const releaseWindowsRunStep = releaseGoldenJob.steps.find( + (step) => step.name === 'Run fresh-startup golden on Windows' + ) + expect(releaseWindowsRunStep).toMatchObject({ + if: "runner.os == 'Windows'", + shell: 'pwsh' + }) + expect(releaseWindowsRunStep.run).toContain('pnpm run test:e2e:windows-fresh-startup-golden') expect(releaseEvidenceJob['continue-on-error']).toBe(true) expect( releaseEvidenceJob.strategy.matrix.include.map(({ platform }) => platform).sort() diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 860e70c892b..d44039a2ca8 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -6,6 +6,10 @@ import { parse } from 'yaml' const projectDir = resolve(import.meta.dirname, '../..') const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8')) +const sshDockerRunner = readFileSync( + join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'), + 'utf8' +) const filterStep = prWorkflow.jobs['e2e-paths'].steps.find( (step) => step.name === 'Filter changed E2E specs' @@ -64,14 +68,36 @@ describe('PR E2E gate contract', () => { expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''") expect(e2eWorkflow.jobs['changed-e2e'].if).toBe("inputs.test_files != ''") expect(e2eWorkflow.jobs['changed-e2e'].strategy).toBeUndefined() - expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toBe("inputs.test_files == ''") const changedRun = e2eWorkflow.jobs['changed-e2e'].steps.find( (step) => step.name === 'Run changed E2E specs' ) expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}') + expect(changedRun.run).toContain('. != "tests/e2e/ssh-startup-exec-readiness.spec.ts"') + expect(changedRun.run).toContain('. != "tests/e2e/paired-startup-exec-readiness.spec.ts"') + expect(changedRun.run).toContain('if [ "${#TEST_FILES[@]}" -eq 0 ]') expect(changedRun.run).toContain('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1') }) + it('keeps startup-exec live parity in the isolated SSH lane', () => { + const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if + expect(sshLaneCondition).toContain("inputs.test_files == ''") + expect(sshLaneCondition).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts') + expect(sshLaneCondition).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts') + expect(sshDockerRunner).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts') + expect(sshDockerRunner).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts') + expect(sshDockerRunner).toContain("'electron-headless'") + expect(sshDockerRunner).toContain("'electron-headful'") + }) + + it('installs zsh in every Linux lane that can run paired startup readiness', () => { + for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) { + const installStep = e2eWorkflow.jobs[jobName].steps.find((step) => + step.name.startsWith('Install native build') + ) + expect(installStep.run, jobName).toMatch(/\bzsh\b/) + } + }) + it('keeps dedicated E2E workflows out of pull request CI', () => { const dedicatedWorkflows = [ 'golden-e2e-experiment.yml', diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 5b3b66dea3f..165931dbeb1 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -8,6 +8,7 @@ const dependencyAction = parse( ) const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) const shellContractFiles = [ + 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts', 'src/main/daemon/shell-ready.test.ts', 'src/main/providers/local-pty-shell-ready.test.ts', 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', @@ -75,6 +76,7 @@ describe('PR workflow parallelism', () => { expect(shellStep).toBeDefined() expect(shellInstall).toBeDefined() + expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1') // Why the whole workflow, not just the general shards: any other lane installing // these shells would silently start running the real-shell tests twice. expect(jobsInstallingPackages).toEqual(['shell_contracts']) diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index b27a1f945d9..784abfc4850 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -94,7 +94,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) { stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) const sentinel = Buffer.from(protocol.RELAY_SENTINEL) const index = stdoutBuffer.indexOf(sentinel) - if (index < 0) { + if (index === -1) { return } settled = true @@ -148,7 +148,7 @@ function createRelayClient(entryPath, args, env, protocol) { stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) const sentinel = Buffer.from(protocol.RELAY_SENTINEL) const index = stdoutBuffer.indexOf(sentinel) - if (index < 0) { + if (index === -1) { return } ready = true diff --git a/config/scripts/remote-shared-control-retirement-probe.ts b/config/scripts/remote-shared-control-retirement-probe.ts index 63abd01e376..30edeb9802a 100644 --- a/config/scripts/remote-shared-control-retirement-probe.ts +++ b/config/scripts/remote-shared-control-retirement-probe.ts @@ -5,7 +5,8 @@ import { resolveEnvironment, resolveEnvironmentPairingOffer } from '../../src/shared/runtime-environment-store' -import type { MemorySnapshot, RuntimeStatus } from '../../src/shared/types' +import type { MemorySnapshot } from '../../src/shared/types' +import type { RuntimeStatus } from '../../src/shared/runtime-types' async function main(): Promise { const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME diff --git a/config/scripts/run-idle-cpu-benchmark.mjs b/config/scripts/run-idle-cpu-benchmark.mjs index 53cabd3380b..3892de8845d 100644 --- a/config/scripts/run-idle-cpu-benchmark.mjs +++ b/config/scripts/run-idle-cpu-benchmark.mjs @@ -1,15 +1,34 @@ #!/usr/bin/env node import { _electron as electron } from '@stablyai/playwright-test' -import { execFileSync, spawnSync } from 'node:child_process' +import { execFileSync } from 'node:child_process' import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' +import { + descendantsOf, + readProcessRows, + sampleProcessTreeUntilWorkloadsComplete, + summarizeProcessInventory, + summarizeSamples, + terminateProcesses +} from './idle-cpu-process-sampling.mjs' +import { + collectRendererCensus, + configureRendererScaleFixture +} from './idle-cpu-renderer-scale-fixture.mjs' +import { + runZustandPublications, + snapshotRendererTimingProbe, + startRendererTimingProbe, + stopRendererTimingProbe +} from './idle-cpu-renderer-timing-probe.mjs' import { installSyntheticVisibleSpinners } from './idle-cpu-synthetic-spinners.mjs' const DEFAULT_WARMUP_MS = 15_000 const DEFAULT_SAMPLE_MS = 30_000 const DEFAULT_INTERVAL_MS = 1_000 const DEFAULT_WORKTREE_COUNT = 1 +const DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS = 100 const ONBOARDING_FINAL_STEP = 3 const ONBOARDING_FLOW_VERSION = 2 @@ -19,6 +38,10 @@ function parseArgs(argv) { sampleMs: DEFAULT_SAMPLE_MS, intervalMs: DEFAULT_INTERVAL_MS, worktrees: DEFAULT_WORKTREE_COUNT, + lineageDepth: 0, + agentsPerWorktree: 0, + zustandPublications: 0, + zustandPublicationIntervalMs: DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS, skipBuild: false, headful: false, output: null, @@ -47,6 +70,14 @@ function parseArgs(argv) { options.intervalMs = Number(readValue()) } else if (arg === '--worktrees') { options.worktrees = Number(readValue()) + } else if (arg === '--lineage-depth') { + options.lineageDepth = Number(readValue()) + } else if (arg === '--agents-per-worktree') { + options.agentsPerWorktree = Number(readValue()) + } else if (arg === '--zustand-publications') { + options.zustandPublications = Number(readValue()) + } else if (arg === '--zustand-publication-interval-ms') { + options.zustandPublicationIntervalMs = Number(readValue()) } else if (arg === '--output') { options.output = readValue() } else if (arg === '--skip-build') { @@ -73,6 +104,10 @@ function parseArgs(argv) { 'sampleMs', 'intervalMs', 'worktrees', + 'lineageDepth', + 'agentsPerWorktree', + 'zustandPublications', + 'zustandPublicationIntervalMs', 'syntheticVisibleSpinners', 'syntheticSpinnerSteps' ]) { @@ -82,16 +117,33 @@ function parseArgs(argv) { } options.worktrees = Math.max(1, Math.floor(options.worktrees)) options.intervalMs = Math.max(250, Math.floor(options.intervalMs)) + options.lineageDepth = Math.floor(options.lineageDepth) + options.agentsPerWorktree = Math.floor(options.agentsPerWorktree) + options.zustandPublications = Math.floor(options.zustandPublications) + options.zustandPublicationIntervalMs = Math.max( + 1, + Math.floor(options.zustandPublicationIntervalMs) + ) options.syntheticVisibleSpinners = Math.max(0, Math.floor(options.syntheticVisibleSpinners)) options.syntheticSpinnerSteps = Math.max(1, Math.floor(options.syntheticSpinnerSteps)) if (!['smooth', 'steps'].includes(options.syntheticSpinnerAnimation)) { throw new Error(`Invalid --synthetic-spinner-animation: ${options.syntheticSpinnerAnimation}`) } + if (options.lineageDepth > 0 && options.worktrees < 2) { + throw new Error('--lineage-depth requires at least two --worktrees') + } + const publicationSpanMs = + Math.max(0, options.zustandPublications - 1) * options.zustandPublicationIntervalMs + if (publicationSpanMs > options.sampleMs) { + throw new Error( + `Zustand publication span ${publicationSpanMs}ms exceeds --sample-ms ${options.sampleMs}` + ) + } return options } function printUsage() { console.log( - `Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners Measurement-only: add visible working spinners\n --synthetic-spinner-animation Spinner animation style (default smooth)\n --synthetic-spinner-steps Step count for --synthetic-spinner-animation steps (default 12)\n` + `Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --lineage-depth Nest all worktrees under one expanded lineage, up to this depth\n --agents-per-worktree Seed this many visible inline agent rows per worktree\n --zustand-publications Publish exactly this many store updates during sampling\n --zustand-publication-interval-ms Publication cadence (default ${DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners Measurement-only: add visible working spinners\n --synthetic-spinner-animation Spinner animation style (default smooth)\n --synthetic-spinner-steps Step count for --synthetic-spinner-animation steps (default 12)\n` ) } function run(command, args, options = {}) { @@ -188,140 +240,6 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } -function parseCpuTimeSeconds(value) { - const trimmed = String(value || '').trim() - if (!trimmed) { - return null - } - const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed] - const days = dayOrTime === null ? 0 : Number(dayOrTime) - const parts = maybeTime.split(':').map(Number) - if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) { - return null - } - if (parts.length === 3) { - return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2] - } - if (parts.length === 2) { - return days * 86400 + parts[0] * 60 + parts[1] - } - if (parts.length === 1) { - return days * 86400 + parts[0] - } - return null -} - -function parseUnixProcesses(stdout) { - const rows = [] - for (const raw of stdout.split('\n')) { - const line = raw.trim() - if (!line) { - continue - } - const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/) - if (!match) { - continue - } - rows.push({ - pid: Number(match[1]), - ppid: Number(match[2]), - percentCpu: Number(match[3]), - rssBytes: Number(match[4]) * 1024, - cpuTimeSeconds: parseCpuTimeSeconds(match[5]), - command: match[6] - }) - } - return rows -} - -function readUnixProcesses() { - const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], { - encoding: 'utf8', - env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, - maxBuffer: 20 * 1024 * 1024 - }) - return parseUnixProcesses(stdout) -} - -function readWindowsProcesses() { - const script = - 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress' - const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], { - encoding: 'utf8', - maxBuffer: 20 * 1024 * 1024 - }) - if (result.status !== 0) { - throw new Error(result.stderr || 'PowerShell process enumeration failed') - } - const parsed = JSON.parse(result.stdout || '[]') - const entries = Array.isArray(parsed) ? parsed : [parsed] - return entries.map((entry) => ({ - pid: Number(entry.ProcessId), - ppid: Number(entry.ParentProcessId), - percentCpu: 0, - cpuTimeSeconds: null, - rssBytes: Number(entry.WorkingSetSize) || 0, - command: String(entry.CommandLine || '') - })) -} - -function readProcessRows() { - return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses() -} - -function descendantsOf(rows, rootPid) { - const children = new Map() - for (const row of rows) { - const list = children.get(row.ppid) ?? [] - list.push(row) - children.set(row.ppid, list) - } - const result = [] - const stack = [rootPid] - const seen = new Set() - while (stack.length > 0) { - const pid = stack.pop() - if (seen.has(pid)) { - continue - } - seen.add(pid) - const row = rows.find((candidate) => candidate.pid === pid) - if (row) { - result.push(row) - } - for (const child of children.get(pid) ?? []) { - stack.push(child.pid) - } - } - return result -} - -function classify(row, rootPid) { - const command = row.command.toLowerCase() - if (row.pid === rootPid) { - return 'main' - } - if (command.includes('daemon-entry')) { - return 'daemon' - } - if (command.includes('--type=gpu-process')) { - return 'gpu' - } - if (command.includes('--type=renderer')) { - return 'renderer' - } - if (command.includes('--type=utility')) { - return 'utility' - } - if (command.includes('--type=')) { - return 'electron-other' - } - if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) { - return 'agent-or-node' - } - return 'other-descendant' -} - async function collectRendererIdleState(page) { return page.evaluate(() => { const describeElement = (element) => { @@ -364,93 +282,6 @@ async function collectRendererIdleState(page) { }) } -function summarizeSamples(samples) { - const byKind = new Map() - for (const sample of samples) { - for (const proc of sample.processes) { - const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 } - bucket.cpuValues.push(proc.cpu) - bucket.rssValues.push(proc.rssBytes) - byKind.set(proc.kind, bucket) - } - const counts = new Map() - for (const proc of sample.processes) { - counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1) - } - for (const [kind, count] of counts) { - byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count) - } - } - const summary = {} - for (const [kind, values] of byKind) { - const cpuSorted = [...values.cpuValues].sort((a, b) => a - b) - const rssSumBySample = samples.map((sample) => - sample.processes - .filter((proc) => proc.kind === kind) - .reduce((sum, proc) => sum + proc.rssBytes, 0) - ) - summary[kind] = { - meanCpuPercent: mean(values.cpuValues), - p95CpuPercent: percentile(cpuSorted, 0.95), - maxCpuPercent: Math.max(0, ...values.cpuValues), - meanRssBytes: mean(rssSumBySample), - maxProcessCount: values.maxProcessCount - } - } - summary.total = { - meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)), - p95CpuPercent: percentile( - samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b), - 0.95 - ), - meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes)) - } - return summary -} - -function summarizeProcessInventory(samples) { - const inventory = {} - for (const sample of samples) { - const counts = new Map() - for (const proc of sample.processes) { - counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1) - const entry = inventory[proc.kind] ?? { - maxProcessCount: 0, - maxCpuPercent: 0, - commandSamples: [] - } - entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu) - if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) { - entry.commandSamples.push(proc.command) - } - inventory[proc.kind] = entry - } - for (const [kind, count] of counts) { - inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count) - } - } - return inventory -} -function mean(values) { - return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length -} - -function percentile(sorted, fraction) { - if (sorted.length === 0) { - return 0 - } - const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1) - return sorted[index] -} - -function terminateProcesses(processes) { - for (const proc of processes) { - try { - process.kill(proc.pid) - } catch {} - } -} - async function main() { const options = parseArgs(process.argv.slice(2)) const root = path.resolve(import.meta.dirname, '..', '..') @@ -491,6 +322,11 @@ async function main() { const page = await app.firstWindow({ timeout: 120_000 }) await page.waitForLoadState('domcontentloaded') await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) + await page.waitForFunction( + () => window.__store?.getState().workspaceSessionReady === true, + null, + { timeout: 60_000 } + ) const measurementCss = [] if (options.disableRendererAnimations) { measurementCss.push( @@ -506,63 +342,111 @@ async function main() { options.syntheticSpinnerAnimation, options.syntheticSpinnerSteps ) - await page.evaluate(async (repoPath) => { - await window.api.repos.add({ path: repoPath }) + const fixtureState = await page.evaluate(async (repoPath) => { + const added = await window.api.repos.add({ path: repoPath }) + if ('error' in added) { + return { error: added.error } + } const store = window.__store await store?.getState().fetchRepos() - const repo = store?.getState().repos.find((candidate) => candidate.path === repoPath) + const repo = store?.getState().repos.find((candidate) => candidate.id === added.repo.id) if (repo) { - await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' }) - await store.getState().fetchWorktrees(repo.id) + const detected = await store + .getState() + .fetchWorktrees(repo.id, { requireAuthoritative: true }) + const importedWorktreePaths = ( + store.getState().detectedWorktreesByRepo[repo.id]?.worktrees ?? [] + ) + .filter((worktree) => !worktree.selectedCheckout) + .map((worktree) => worktree.path) + const updated = await store.getState().updateRepo(repo.id, { + externalWorktreeVisibility: 'show', + importedExternalWorktreePaths: importedWorktreePaths, + externalWorktreeInboxBaselinePaths: importedWorktreePaths + }) + const refreshed = await store + .getState() + .fetchWorktrees(repo.id, { requireAuthoritative: true }) + return { + detected, + updated, + refreshed, + detectedCount: store.getState().detectedWorktreesByRepo[repo.id]?.worktrees.length ?? 0, + importedCount: importedWorktreePaths.length, + visibleCount: store.getState().worktreesByRepo[repo.id]?.length ?? 0 + } } + return { error: 'repo-not-found' } }, repoDir) + console.log(`[idle-cpu] fixture ${JSON.stringify(fixtureState)}`) await page.waitForFunction( - () => window.__store?.getState().workspaceSessionReady === true, + (expectedWorktrees) => { + const state = window.__store?.getState() + return ( + state?.workspaceSessionReady === true && + Object.values(state.worktreesByRepo).flat().length === expectedWorktrees + ) + }, + options.worktrees, + { timeout: 180_000 } + ) + const scaleFixtureState = await configureRendererScaleFixture(page, options, repoDir) + if (scaleFixtureState.applied) { + console.log(`[idle-cpu] scale fixture ${JSON.stringify(scaleFixtureState)}`) + } + await page.waitForFunction( + (expectedAgentRows) => { + const state = window.__store?.getState() + return ( + state !== undefined && + Object.keys(state.agentStatusByPaneKey ?? {}).length >= expectedAgentRows + ) + }, + scaleFixtureState.seededAgentRows, + { timeout: 30_000 } + ) + await page.waitForFunction( + () => Boolean(document.querySelector('[data-worktree-sidebar] [data-worktree-id]')), null, - { timeout: 60_000 } + { timeout: 30_000 } ) console.log( - `[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees}` + `[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees} lineage-depth=${options.lineageDepth} agents/worktree=${options.agentsPerWorktree} publications=${options.zustandPublications}` ) + await startRendererTimingProbe(page) await sleep(options.warmupMs) const rendererIdleState = await collectRendererIdleState(page) - const deadline = Date.now() + options.sampleMs - const samples = [] - let previousSnapshot = null - while (Date.now() <= deadline || samples.length === 0) { - const sampledAt = Date.now() - const processRows = descendantsOf(readProcessRows(), rootPid) - const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) })) - if (previousSnapshot) { - const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000) - const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc])) - const processes = rawProcesses.map((row) => { - const previous = previousByPid.get(row.pid) - const canComputeDelta = - typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number' - const cpu = canComputeDelta - ? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100) - : row.percentCpu - return { ...row, cpu } - }) - samples.push({ - at: sampledAt, - elapsedMs: sampledAt - previousSnapshot.at, - totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0), - totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0), - processes - }) - } - previousSnapshot = { at: sampledAt, processes: rawProcesses } - await sleep(options.intervalMs) - } + const rendererCensusBefore = await collectRendererCensus(page, options.lineageDepth) + const rendererTimingBefore = await snapshotRendererTimingProbe(page) + const publicationPromise = runZustandPublications( + page, + options.zustandPublications, + options.zustandPublicationIntervalMs + ) + const sampled = await sampleProcessTreeUntilWorkloadsComplete({ + rootPid, + requestedDurationMs: options.sampleMs, + intervalMs: options.intervalMs, + workloadPromise: publicationPromise + }) + const samples = sampled.samples + const zustandPublications = sampled.workloadResult + const rendererTimingAfter = await stopRendererTimingProbe(page) + const rendererCensusAfter = await collectRendererCensus(page, options.lineageDepth) const report = { benchmark: 'orca-idle-cpu', createdAt: new Date().toISOString(), options, rootPid, platform: { platform: process.platform, arch: process.arch, cpus: os.cpus().length }, + fixtureState, + scaleFixtureState, rendererIdleState, + rendererCensusBefore, + rendererCensusAfter, + rendererTiming: { before: rendererTimingBefore, after: rendererTimingAfter }, + zustandPublications, + samplingWindow: sampled.samplingWindow, sampleCount: samples.length, summary: summarizeSamples(samples), processInventory: summarizeProcessInventory(samples), @@ -578,7 +462,13 @@ async function main() { { summary: report.summary, processInventory: report.processInventory, - sampleCount: report.sampleCount + sampleCount: report.sampleCount, + scaleFixtureState: report.scaleFixtureState, + rendererCensusBefore: report.rendererCensusBefore, + rendererCensusAfter: report.rendererCensusAfter, + rendererTiming: report.rendererTiming, + zustandPublications: report.zustandPublications, + samplingWindow: report.samplingWindow }, null, 2 diff --git a/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs b/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs index 8d4183b4515..b894ac15217 100644 --- a/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs +++ b/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs @@ -5,7 +5,8 @@ const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArg const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' const env = { ...process.env, - ORCA_E2E_SSH_DOCKER: '1' + ORCA_E2E_SSH_DOCKER: '1', + ORCA_E2E_WEB_CLIENT: '1' } // Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. @@ -21,9 +22,8 @@ if (runtime.status !== 0) { process.exit(runtime.status ?? 1) } -// Why both specs in one runner: they share the docker SSH rig and the same -// ORCA_E2E_SSH_DOCKER gate — the SSH parking spec proves SSH panes park and -// restore, the retention spec proves the C1 budget bounds what they retain. +// Why one runner: these specs share the Docker SSH rig; startup-exec also joins +// the paired desktop/headless parity proof under the same isolated CI lane. const result = spawnSync( pnpm, [ @@ -32,10 +32,14 @@ const result = spawnSync( 'test', 'tests/e2e/ssh-terminal-parking.spec.ts', 'tests/e2e/terminal-retention-budget.spec.ts', + 'tests/e2e/ssh-startup-exec-readiness.spec.ts', + 'tests/e2e/paired-startup-exec-readiness.spec.ts', '--config', 'tests/playwright.config.ts', '--project', 'electron-headless', + '--project', + 'electron-headful', '--workers=1', ...extraArgs ], diff --git a/config/scripts/run-ssh-staged-upload-reliability.mjs b/config/scripts/run-ssh-staged-upload-reliability.mjs index 4fadf6f3001..fb3d0040350 100644 --- a/config/scripts/run-ssh-staged-upload-reliability.mjs +++ b/config/scripts/run-ssh-staged-upload-reliability.mjs @@ -17,8 +17,8 @@ const defaultFiles = [ const cliArguments = process.argv.slice(2) const powerShellFlag = cliArguments.indexOf('--powershell') const configuredPowerShell = - powerShellFlag >= 0 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE -if (powerShellFlag >= 0 && !configuredPowerShell) { + powerShellFlag !== -1 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE +if (powerShellFlag !== -1 && !configuredPowerShell) { console.error('--powershell requires an executable path') process.exit(2) } diff --git a/config/scripts/terminal-e2e-helpers.mjs b/config/scripts/terminal-e2e-helpers.mjs index 2f89f1a7f90..8b698416326 100644 --- a/config/scripts/terminal-e2e-helpers.mjs +++ b/config/scripts/terminal-e2e-helpers.mjs @@ -222,8 +222,8 @@ if (process.argv[1]?.endsWith('terminal-e2e-helpers.mjs')) { const args = process.argv.slice(2) const portIdx = args.indexOf('--port') const cmdIdx = args.indexOf('--command') - const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 9444 - const command = cmdIdx >= 0 ? args[cmdIdx + 1] : null + const port = portIdx !== -1 ? Number(args[portIdx + 1]) : 9444 + const command = cmdIdx !== -1 ? args[cmdIdx + 1] : null const term = new OrcaTerminal(port) console.log('Connecting to Orca on CDP port', port, '...') diff --git a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs index 079735c41ec..187b5aa3fc2 100644 --- a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs +++ b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs @@ -142,7 +142,7 @@ function readGeneratedScripts(home, minMtime) { const body = readFileSync(path, 'utf8') const captureIndex = body.indexOf('payload=$(cat)') const firstExitIndex = body.indexOf('exit 0') - if (captureIndex < 0 || firstExitIndex <= captureIndex) { + if (captureIndex === -1 || firstExitIndex <= captureIndex) { throw new Error([fileName, ' can exit before capturing stdin'].join('')) } return { body, fileName, path, source } diff --git a/config/scripts/verify-computer-native.mjs b/config/scripts/verify-computer-native.mjs index ba23a49cdd0..56efeb33521 100644 --- a/config/scripts/verify-computer-native.mjs +++ b/config/scripts/verify-computer-native.mjs @@ -489,11 +489,11 @@ function verifyNativeArgumentGuardrails() { function powerShellFunctionBody(source, name) { const start = source.indexOf(`function ${name}`) - if (start < 0) { + if (start === -1) { return null } const bodyStart = source.indexOf('{', start) - if (bodyStart < 0) { + if (bodyStart === -1) { return null } let depth = 0 @@ -513,11 +513,11 @@ function powerShellFunctionBody(source, name) { function swiftFunctionBody(source, name) { const start = source.indexOf(`func ${name}`) - if (start < 0) { + if (start === -1) { return null } const bodyStart = source.indexOf('{', start) - if (bodyStart < 0) { + if (bodyStart === -1) { return null } let depth = 0 diff --git a/config/scripts/wsl-git-shell-benchmark.mjs b/config/scripts/wsl-git-shell-benchmark.mjs index 00ea47ded0a..d29aaea2824 100644 --- a/config/scripts/wsl-git-shell-benchmark.mjs +++ b/config/scripts/wsl-git-shell-benchmark.mjs @@ -175,7 +175,7 @@ async function main() { wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)]) ) const markerOffset = result.stdout.indexOf(outputMarker) - if (markerOffset < 0) { + if (markerOffset === -1) { throw new Error('Login shell did not emit the Git output marker') } result.stdout = result.stdout.subarray(markerOffset + Buffer.byteLength(outputMarker)) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index a3074230367..610bdb8b92e 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 18m + + downloads: 19m @@ -15,7 +15,7 @@ downloads downloads - 18m - 18m + 19m + 19m diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index e247ace7d7c..d02a025cb5f 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -179,12 +179,14 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   OpenCode logo OpenCode   + MiMo Code logo MiMo Code   Amp logo Amp   OpenClaude logo OpenClaude   Antigravity logo Antigravity   Pi logo Pi   oh-my-pi logo oh-my-pi   Hermes Agent logo Hermes Agent   + Devin logo Devin   Goose logo Goose   Auggie logo Auggie   Autohand Code logo Autohand Code   @@ -211,6 +213,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 - **[onOrca.dev에서 다운로드](https://onorca.dev/download)** - 또는 빌드를 직접 받기: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [전체 빌드](https://github.com/stablyai/orca/releases/latest) +- headless Linux 서버에서 `orca serve`를 실행하시나요? [Headless Linux 서버 가이드](../reference/headless-linux-server.md)를 확인하세요. _또는 패키지 매니저로 설치:_ @@ -226,8 +229,8 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. -- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) +- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA) +- **Android:** [APK 0.0.42 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) --- @@ -235,6 +238,10 @@ yay -S stably-orca-bin - **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요. - **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요. +- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 7에 참여하세요. + + Orca 커뮤니티 WeChat 그룹 7 QR 코드 + - **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요. - **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요. - **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요. @@ -249,6 +256,14 @@ yay -S stably-orca-bin Orca 기여자 +

+ stablyai/orca GitHub Star 히스토리 차트 +

+ +## 서명된 빌드 + +Windows 코드 서명은 [SignPath.io](https://signpath.io)가 후원·제공하며, 인증서는 [SignPath Foundation](https://signpath.org)에서 제공합니다. + ## 라이선스 Orca는 [MIT 라이선스](../../LICENSE)에 따라 자유롭게 사용할 수 있는 오픈 소스입니다. diff --git a/docs/reference/renderer-agent-status-performance.md b/docs/reference/renderer-agent-status-performance.md new file mode 100644 index 00000000000..8ed43d868ce --- /dev/null +++ b/docs/reference/renderer-agent-status-performance.md @@ -0,0 +1,328 @@ +# Renderer agent-status performance + +## Status + +This design is adopted for the renderer's high-frequency agent-status path. It +keeps the existing event semantics while bounding the amount of synchronous +store fanout performed for one IPC burst. + +It lands in slices. This document and the `bench:idle-cpu` harness land first so +the store changes can be reviewed against a baseline someone else measured. Until +the store slice lands, the `setAgentStatuses` / `transactAgentStatuses` actions +and the agent-status write workload described below are not yet on `main`; the +harness measures scale, listener census, and raw publication fanout only. + +## Context + +Orca can display a large expanded worktree lineage inside one virtualized list +row. Virtualizing the root row does not virtualize its descendants, so a +100-worktree lineage can mount 100 `WorktreeCard` instances at once. + +Agent-status IPC events are bursty. The renderer already groups live events into +a 33 ms window, but the original flush applied every queued event with a +separate Zustand write. Zustand synchronously visits every listener for every +publication. The resulting work therefore grew with both the number of status +events and the number of mounted subscriptions: + +```text +burst work ~= status events x store listeners x selector work +``` + +A production trace captured the renderer repeatedly entering +`flushLiveAgentStatusBurst -> applyAgentStatus -> setAgentStatus -> setState` +through `Set.forEach`. A deterministic 100-worktree fixture reproduces the +structural multiplier; see "Baseline on `main`" below for the currently measured +listener count and publication cost. + +The production app later recovered substantially when all configured remote +hosts were removed. Host removal can stop relay/reconnect traffic, remove +mounted remote worktrees, or both, depending on host type and removal options. +That observation identifies remote presence as the production trigger but does +not by itself distinguish traffic volume from mounted-listener fanout. + +A read-only reconnect audit ruled out systematic double status emission from a +full PTY replay: replay bytes bypass OSC status parsing. Reconnect still causes +a full terminal-buffer repaint for every attached remote pane, which is a +separate source of renderer work and remains a follow-up investigation. + +## Goals + +- Keep a large expanded lineage responsive during dense agent-status traffic. +- Preserve every ordered status transition, including repeated updates for one + pane inside the same burst. +- Publish agent-status state once for a deferred live burst. +- Preserve selector identity and child render isolation. +- Make the regression reproducible without relying on a user's production data. + +## Non-goals + +- Changing the client/server status payload or remote protocol. +- Deduplicating status events by pane. +- Changing agent freshness, retention, history, title, completion, or provider + session behavior. +- Changing remote reconnect, PTY replay, or terminal repaint behavior. +- Redesigning lineage presentation or collapsing worktrees automatically. + +## Design + +### Bound mounted subscription fanout + +Sidebar components select cohesive state bundles with shallow equality instead +of registering one listener per field. Derived arrays and maps retain their +existing shallow identity behavior so unrelated store writes do not rerender a +card. Full agent-list mode keeps its child-level subscription boundary; compact +mode passes the already selected rows to avoid selecting the same inputs twice. + +The deterministic 100-worktree fixture pins the resulting listener budget: + +| Surface | Listener budget | +| ------------------------------ | --------------: | +| Worktree card state and caches | 2 | +| Agent-row inputs | 1 | +| Worktree activity status | 1 | +| Closed context menu | 1 | + +Unmount tests require the listener count to return to its prior baseline. In the +bundled prototype, the fixture without seeded agents fell from 8,518 listeners +to 1,218; with 100 visible agent rows the candidate mounted 1,618. Compare +against the census in "Baseline on `main`", which the harness reports directly. + +### Share working-spinner phase without per-element animation queries + +Working rows keep the existing compositor-driven CSS animation and shared +visual phase. Each mount derives one negative animation delay from the document +timeline instead of querying `getAnimations()` and mutating the animation start +time. This removes per-row Web Animations setup from dense status transitions +without adding a JavaScript animation clock. + +### Fold a burst in event order + +The store exposes the single-update action and two batch forms: + +- `setAgentStatus(paneKey, payload, ...)` retains the positional single-update + API for the immediate live path. +- `setAgentStatuses(updates)` applies a prebuilt ordered list, while + `transactAgentStatuses(operation)` lets IPC derive each update against the + exact staged state before the single commit. + +Both entry points reuse the same single-update state transition. The batch +reducer passes each resulting state into the next update, so a sequence such as +`working -> waiting -> done` retains the same history and timestamps as three +sequential calls. Updates are never keyed or deduplicated before the fold. + +The live IPC queue is spliced before it is processed. This preserves the +existing reentrancy guarantee: a synchronous subscriber can enqueue another +event without causing the current queue to be drained recursively. The first +event outside an active burst remains immediate; events accumulated within the +33 ms window are applied as one ordered transaction. Startup snapshots and +bounded pending-hydration retries use the same transaction path instead of +publishing once per restored pane. + +Each transaction builds pane-routing ownership once with the same first-match +semantics as the standalone resolver. Split-layout leaf membership is indexed +once per layout root, so a large snapshot performs linear tab and leaf work +instead of rescanning every mounted worktree for every pane. + +### Run effects after the transaction + +Generated-title work that requires committed state is deferred until after the +transaction. Accepted updates also request freshness scheduling; the outer batch +coalesces those requests and schedules the shared freshness timer once after its +single commit. Generated-title requests are folded in event order and published +together, including first-write and forced-replacement semantics. Resolved tab +titles are projected while the transaction folds, then final title changes are +published together. Completion-triggered review refreshes remain deferred +microtasks. + +Bulk title application preserves event order and duplicate-tab behavior while +indexing owners once, cloning each changed owner array once, and replacing each +top-level map once. This keeps the post-commit title phase linear in mounted +tabs plus changed titles. + +This separation is important: invoking store actions from inside a Zustand +updater would re-enter the store, while running an effect before the commit +would let it observe stale state. + +## Semantic invariants + +Sequential and batched application must agree on: + +- live and retained agent maps; +- state history, `updatedAt`, and `stateStartedAt`; +- agent identity, model, prompt, tools, assistant messages, and subagents; +- orchestration and provider-session continuity; +- sleeping-session and launch-config recovery records; +- retired/closed-pane rejection and inherited-status suppression; +- retention cleanup and live-map eviction; +- `agentStatusEpoch` and `sortEpoch`; +- automation completion observation across intermediate transitions; +- generated-title inputs, freshness scheduling, and completion refreshes. + +Equivalence tests use fixed timestamps and include repeated same-pane +transitions. A publication-count test subscribes to the real store and requires +one notification for a non-empty batch and none for an empty batch. + +## Benchmark contract + +The benchmark launches an E2E-mode Electron build with the store exposed only +for instrumentation. It creates 100 worktrees in one expanded lineage, verifies +100 mounted cards, captures the store listener census, and then applies seeded +ordered agent-status traffic through the real store action. + +The benchmark measures the synchronous store action, not the live IPC leading +edge or post-commit notification path. A real-store snapshot test covers the +end-to-end budget for 100 panes with auto-generated titles enabled: one status, +one bulk generated-title, and one bulk resolved-title publication. Disabling +generated titles removes that middle publication, independent of pane count. + +The artifact records only fixed diagnostic fields needed for comparison: + +- requested and completed batches and updates; +- store action calls and observed publications; +- elapsed time, throughput, and scheduling drift; +- final-state verification; +- renderer mean, p95, and maximum CPU; +- renderer timer drift and long tasks; +- mounted-card and listener counts. + +Raw process inventories, temporary paths, pane identifiers, and DOM text are +diagnostic-only and must not be embedded in the shareable report. + +Run baseline and candidate on the same machine and OS with the same Electron +build mode. CPU samples from macOS and Linux are comparable within that +constraint; Windows process CPU collection currently cannot support this +comparison. + +## Harness + +`pnpm run bench:idle-cpu` drives `config/scripts/run-idle-cpu-benchmark.mjs`, +which composes four modules: + +| Module | Responsibility | +| ------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `idle-cpu-renderer-scale-fixture.mjs` | Seeds the lineage, agent rows, and sidebar view state; takes the mounted-card and listener census | +| `idle-cpu-renderer-timing-probe.mjs` | In-page timer drift and long-task probe; runs the no-op publication workload | +| `idle-cpu-process-sampling.mjs` | Classifies the Electron process tree and samples per-role CPU/RSS | +| `idle-cpu-synthetic-spinners.mjs` | Measurement-only visible spinners | + +The sampling window extends past `--sample-ms` until the workload settles, and +fails the run rather than reporting a truncated window if the workload overruns +the guard. That is why a 2,000-publication run reports a measured window longer +than the requested one. + +`--zustand-publications` publishes an empty partial through the real store, so +each publication costs exactly one full subscriber visit and nothing else. It +isolates the `listeners x selector work` half of the burst-cost model from +agent-status payload work, and it is store-API independent — it measures the +same thing before and after the batching slice. + +The agent-status write workload (`--agent-status-batches`, +`--agent-status-write-mode`) is not in this harness yet. It depends on +`setAgentStatuses`, so it lands with the store slice. + +## Baseline on `main` + +Measured on `main` at `077f5a11cd4` (macOS, arm64, 16 CPUs), Electron built with +`electron-vite --mode e2e`, headless, 100 worktrees at lineage depth 99 with 100 +seeded agent rows, 10 s warmup and a 30 s sampling window. + +Fixture scale is confirmed by the census rather than assumed: 100 store +worktrees, 100 mounted cards, 100 mounted agent rows, and **9,279 store +listeners**. That listener count is the multiplier the design targets. + +2,000 no-op store publications at a 1 ms cadence, three repetitions. The +listener census was 9,279 in every run. + +| Measure | Median | Runs | +| ------------------------ | ----------: | ------------------------------ | +| Wall time to complete | 12,325.7 ms | 13,148.6 / 12,325.7 / 11,870.5 | +| p50 scheduling drift | 5,150.3 ms | 5,432.3 / 5,150.3 / 4,945.2 | +| p95 scheduling drift | 9,802.9 ms | 10,570.4 / 9,802.9 / 9,390.0 | +| Renderer mean CPU | 18.25% | 20.25 / 18.25 / 15.88 | +| Renderer p95 CPU | 32.59% | 40.07 / 32.59 / 31.28 | +| Renderer timer drift p95 | 7.0 ms | 7.3 / 5.3 / 7.0 | + +2,000 publications requested over 2 s take about 12 s, so the renderer sustains +roughly 160 publications per second at this scale. Each publication is +individually short - the long-task observer recorded zero entries in all three +runs - so the cost surfaces as scheduling drift and sustained CPU rather than as +discrete long tasks. Compare drift and CPU here, not long-task counts. + +The idle control at the same scale with `--zustand-publications 0` reports 6.63% +renderer mean CPU, 17.11% p95, and 1.6 ms p95 timer drift. Roughly 11.6 points +of mean renderer CPU are therefore attributable to publication fanout rather than +to the mounted fixture itself. 200 spinner animations run in both cases, so the +control also bounds the animation cost out of the comparison. + +Reproduce with: + +```bash +pnpm run bench:idle-cpu -- --worktrees 100 --lineage-depth 99 \ + --agents-per-worktree 1 --warmup-ms 10000 --sample-ms 30000 \ + --zustand-publications 2000 --zustand-publication-interval-ms 1 \ + --output /tmp/idle-cpu-baseline.json +``` + +## Results + +Three repetitions used 100 mounted worktrees, lineage depth 99, 100 seeded +agent rows, and verified final state. Medians from the regenerated evidence set +are: + +| Single 2,000-update burst | Sequential | Batched | +| ------------------------- | ---------: | ---------: | +| Status-state publications | 2,000 | 1 | +| Store action time | 3,692.0 ms | 188.7 ms | +| Update throughput | 541.7/s | 10,598.8/s | +| Renderer mean CPU | 36.2% | 2.9% | +| Renderer p95 CPU | 107.3% | 8.2% | +| p95 long task | 4,653 ms | 216 ms | + +The direct store transaction performs 99.95% fewer status-state publications, +spends 94.9% less time in the store action, and processes updates 19.6 times +faster. Renderer mean CPU falls 92.0%, renderer p95 CPU falls 92.4%, and the p95 +long task falls 95.4%. + +The 60-burst × 32-update case at 33 ms is a sustained saturation stress, not a +real-time production SLO. Publications fall from 1,920 to 60 and median store +action time falls from 2,791.9 ms to 323.3 ms. Median completion time falls from +5,298.2 ms to 2,710.6 ms, p95 scheduling drift falls from 3,073.0 ms to 664.9 +ms, and long-task count falls from 57 to 1. Renderer p95 CPU remains saturated +and noisy in this cadence, so it is not used as the discriminating measure. + +The 20-pane artificial OpenCode regression passes with 12.4 ms median key echo, +25.2 ms worst key echo, 19.4 ms maximum timer drift, and zero dropped renderer +backlogs. + +These figures come from the bundled prototype and are restated here as the +target. They are re-measured with the harness when the store slice lands. + +## Acceptance criteria + +- The 100-worktree fixture stays at or below the pinned listener budgets. +- A deferred transaction performs one status-state publication while preserving + ordered final state, including live-map eviction at the 500-row cap. +- A 100-pane startup snapshot performs one status and one bulk resolved-title + publication with generated titles disabled; enabling generated titles adds at + most one ordered bulk publication while preserving final statuses and titles. +- Sequential-versus-batch equivalence tests pass across same-pane transitions + and side-effect-bearing updates. +- Renderer CPU tails and scheduling drift improve in repeated candidate runs. +- The 20-pane artificial terminal test reports no dropped output backlog and no + material typing-latency regression. +- Web typecheck, focused unit tests, lint, max-lines ratchet, and E2E build pass. + +## Compatibility + +This is renderer-local. It adds no RPC field, stream opcode, persisted data, Git +command, or provider-specific contract. Native, WSL, SSH, relay, folder +workspace, and git-worktree status events enter the same renderer action. Mixed +client/server versions therefore need no capability negotiation. + +## Failure containment + +The first live event remains immediate. Startup replay and bounded pending +retries fold synchronously without waiting for the 33 ms live-burst window, but +publish their accepted updates together. Empty batches are no-ops. If an update +is stale or targets retired authority, the reducer skips only that update and +continues folding later events in order. diff --git a/docs/reference/worktree-scan-fingerprint.md b/docs/reference/worktree-scan-fingerprint.md new file mode 100644 index 00000000000..f82d453a82c --- /dev/null +++ b/docs/reference/worktree-scan-fingerprint.md @@ -0,0 +1,274 @@ +# Steady-state worktree rescan: Git-admin fingerprint gate + +## Status + +Adopted for the main-process worktree resolution cache +(`OrcaRuntimeService.listRepoWorktreesForResolution`). It keeps the existing +30-second freshness contract for externally created, removed, moved, locked, and +re-checked-out worktrees while removing the `git worktree list` subprocess that +previously ran for every registered repository every 30 seconds. + +## Context + +A production trace (10 registered repositories, 3 h 27 min) recorded 4,272 +`git worktree` invocations and 8,663 total Git subprocesses. The invocations +arrived in full-fleet sweeps roughly every 30.5 s — one sweep per repository per +`WORKTREE_SCAN_CACHE_TTL_MS`. + +### What actually expires the cache + +The originating report assumed some specific terminal/status/orchestration +request was invalidating the 30-second cache. That is not what happens, and the +distinction changes the fix: + +- `resolvedWorktreeCache` (the whole-fleet snapshot) has a **1-second** TTL + (`RESOLVED_WORKTREE_CACHE_TTL_MS`). Any caller polling faster than 1 Hz + recomputes the snapshot. +- `computeResolvedWorktrees` fans out over **every** registered repo + (`src/main/runtime/orca-runtime.ts`), calling + `listRepoWorktreesForResolution` per repo. +- That per-repo call is backed by `worktreeScanCache` with a **30-second** TTL. + When it expires, the next poll shells out. + +So no request "expires" the 30-second cache. It expires on wall-clock time, and +whichever poller arrives first afterwards pays a full-fleet `git worktree list` +fan-out. The many high-frequency callers — `listTerminals` without a selector, +`showTerminal`, `getWorktreePs`, `listManagedWorktrees`, +`resolveWorktreeSelector`, orchestration authority refresh — only determine +*who* pays, not *how often*. Steady-state subprocess volume is therefore +`repos / 30 s`, independent of poll rate, which is exactly the observed +~1 sweep / 30.5 s. + +The in-Orca mutation surface is already event-driven: create, remove, rename, +folder-rename, sparse edits, repo add/update/remove, SSH reconnect, and +mixed-version remote invalidation all call +`invalidateWorktreeScanCacheForRepo` / `invalidateResolvedWorktreeCache` +(≈40 call sites). The 30-second TTL exists for exactly one reason: discovering +worktree changes made **outside** Orca (`git worktree add/remove/move/prune`, +`git checkout` in another worktree, `rm -rf` of a worktree directory). + +That is a filesystem question, and the filesystem can answer it without a +subprocess. + +## Goals + +- Remove the periodic all-repository `git worktree list` fan-out in steady + state. +- Preserve the current ≈30 s discovery latency for externally created, removed, + moved, pruned, locked/unlocked, and re-checked-out worktrees. +- Keep a bounded reconciliation so anything the cheap probe cannot observe still + converges. +- Change nothing for SSH repos, WSL-routed repos, folder workspaces, bare repos, + or hosts where the probe cannot resolve Git's admin layout. +- Fail open: any probe error must behave exactly like today (run the real scan). + +## Non-goals + +- Changing `RESOLVED_WORKTREE_CACHE_TTL_MS` or the whole-fleet snapshot shape. +- Changing the renderer-facing `worktrees:list` / `worktrees:listAll` IPC scan + cache in `src/main/ipc/worktrees.ts` (separate 5 s cache, invalidated by + `registerWorktreeChangeInvalidator`, not a polling source in the trace). +- Scoping `resolveWorktreeSelector` to a single repository. See + "Rejected alternatives". +- Adding a filesystem watcher per repository. +- Any wire/RPC/persisted-schema change. + +## Design + +Introduce a cheap, subprocess-free **Git worktree admin fingerprint** for a +local repository, and consult it before re-running a scan whose TTL has expired. + +### Fingerprint inputs + +`readRepoWorktreeAdminFingerprint(repoPath)` in +`src/main/runtime/repo-worktree-admin-fingerprint.ts` resolves the repo's Git +common directory without a subprocess (read `.git`; if it is a `gitdir:` file, +follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a +bare gitdir), then records: + +| Input | External change it catches | +| --- | --- | +| sorted entry names of `/worktrees` | `worktree add`, `worktree remove`, `worktree prune` | +| existence of `repoPath` | main checkout deleted | +| `/packed-refs` mtime + size | a tip moved while its loose ref is packed away | +| `/reftable` mtime + size | a tip moved under the reftable backend | +| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) | +| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip | +| per entry: `gitdir` contents | `worktree move`, `worktree repair` | +| per entry: `locked` presence | `worktree lock` / `unlock` | +| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) | + +"per checkout" covers the main worktree and each linked worktree. Reading the +ref HEAD names is what makes an ordinary commit visible: committing rewrites +`refs/heads/` and leaves `HEAD` untouched, but moves the oid +`git worktree list --porcelain` prints. A symref target is only followed when it +is a relative path under `refs/`, so a hand-edited `HEAD` cannot steer the probe +outside the ref store. + +Every input is a `stat`, `readdir`, or small `readFile` on an already-hot inode, +and the fingerprint depends on nothing but the repo path — no prior scan result +is threaded in. Per-repo fan-out is capped at 8 concurrent linked-worktree +probes, mirroring `SPARSE_CHECKOUT_DETECTION_CONCURRENCY`. A 10-repo fleet with +10 worktrees each costs a few hundred filesystem calls per 30 s window versus 10 +process spawns; the spawns dominate by orders of magnitude, and process-table +churn was the reported symptom. + +The fingerprint is a NUL-delimited string; any read failure yields `null`, which +the caller treats as "cannot prove unchanged". + +### Cache decision + +`listRepoWorktreesForResolution` gains one branch on the expired-TTL path: + +```text +cached entry exists, same generation + runtimeKey, TTL expired + └─ probe eligible? (no connectionId, no wslDistro, fingerprint recorded) + ├─ no → real scan (today's behaviour) + └─ yes → read fingerprint now + ├─ null or different → real scan + ├─ equal, last real scan < 5 min → extend TTL, no subprocess + └─ equal, last real scan ≥ 5 min → real scan (bounded reconcile) +``` + +`WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS` is 5 min, matching the existing +`WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS` precedent. A cached result whose scan +failed (`ok: false`) is never extended, so a transient Git failure still retries +on the 30 s TTL. + +### Git version compatibility + +Every path the probe reads is part of Git's on-disk layout well before the 2.25 +baseline in [`git-compatibility.md`](./git-compatibility.md): `.git` as a +directory or `gitdir:` file, `commondir`, `worktrees//{HEAD,gitdir,locked}`, +`packed-refs`, and loose `refs/`. `reftable` arrived in 2.45; on older Git it +simply stats as missing, which is a stable value and therefore harmless. No new +Git command is introduced — this change only skips one. + +Agent-scratch repos already carry a 5-minute scan TTL +(`WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS`), which equals the reconciliation +interval, so the gate never fires for them and their behaviour is unchanged. + +### Ordering + +The fingerprint stored with a scan result is captured **before** the scan runs. +A mutation landing while the scan is in flight therefore leaves the stored +fingerprint stale-by-construction, so the next probe sees a difference and +rescans. Capturing after the scan would let that mutation be masked forever. + +### Interaction with existing invalidation + +Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint +included) and bumps the generation, so every event-driven path still forces a +real scan on the next read. The fingerprint only ever *extends* an entry that +the TTL alone would have refreshed. + +## Freshness budget + +| Change | Before | After | +| --- | --- | --- | +| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) | +| SSH reconnect / provider generation bump | immediate (event) | immediate (event) | +| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s | +| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s | +| External `rm -rf ` | ≤ 30 s | ≤ 30 s | +| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min | +| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min | +| SSH / WSL repos, folder workspaces | unchanged | unchanged | + +The two regressions are bounded by the reconciliation interval and are both +changes Orca does not make itself. + +### Main-thread cost + +Both the scan and the probe are asynchronous, so neither "runs on the main +thread" in the naive sense — but they are not equally free there. Measured on +macOS with a 1 ms interval sampling event-loop lag while each ran 30 times +against a repo with 20 linked worktrees: + +| | wall per call | main-thread stall per call | worst single stall | +| --- | --- | --- | --- | +| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms | +| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms | + +`fs/promises` dispatches to libuv's threadpool, so ~99 % of the probe's latency +is off-thread. Spawning Git does not: `uv_spawn`, fd and pipe setup, and stdout +collection and decoding are real synchronous main-process work. Ten repos +refreshing together therefore cost ≈27 ms of event-loop stall per sweep before +this change and ≈0.1 ms after — this reduces main-thread pressure rather than +adding to it, which is why moving either side onto a worker thread would not +help. + +### Residual risk + +A repo registered at a UNC path (`\\wsl$\...`) but executed by the local Windows +Git runtime is still probed, because it is not WSL-routed from Orca's point of +view. The probe is correct there and strictly cheaper than the subprocess it +replaces, but its filesystem calls cross the 9p boundary like the existing +sparse-checkout probes already do. + +## Measured effect + +`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts` drives the +reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30 +simulated minutes — and counts `git worktree list` invocations: + +| | `git worktree list` per 30 min | per hour | +| --- | --- | --- | +| TTL only (before) | 600 | 1,200 | +| fingerprint gate (after) | 60 | 120 | + +A 90 % reduction, with the remainder being the bounded reconciliation. Repos +with genuine external activity keep rescanning at the 30 s cadence because the +fingerprint flips. + +Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272 +`git worktree` invocations would become ≈427. + +## Rejected alternatives + +**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess +reduction, but it degrades *every* external-change latency to 5 min, including +the common "I ran `git worktree add` in a terminal" case. The fingerprint buys +the same reduction without that regression. + +**Per-repo `fs.watch` on `/worktrees`.** Lower latency, but adds +persistent watcher handles per repo, inherits recursive-watch platform +differences, and would need its own dormancy/rearm story. The existing watcher +infrastructure is scoped to workspace files; extending it to Git admin dirs is a +larger change with a worse risk profile for the same steady-state win. + +**Scope `resolveWorktreeSelector` to the owning repository.** The original brief +asks for this. `listTerminals` already avoids the fan-out for explicit worktree +ids via `buildResolvedWorktreeFromId` + +`listKnownResolvedWorktreesForExplicitTarget`. Extending that to +`resolveWorktreeSelector` means splitting the highest-fan-in method in the +runtime (38 call sites) and rebuilding lineage projection for a repo subset — +material regression risk. Once the fingerprint gate lands, the remaining +fan-out cost for a targeted call is a batch of stats, not a subprocess, so the +gain no longer justifies the risk. Tracked as follow-up, not in this change. + +## Test plan + +`src/main/runtime/repo-worktree-admin-fingerprint.test.ts` (real temp dirs, real +`git` binary — a mocked filesystem would only restate the assumptions): + +- stable across repeated reads with no change +- changes after `worktree add`, `worktree remove`, `worktree move`, + `worktree lock`, `checkout` in a linked worktree, `checkout` in the main + worktree, a commit in either, and `rm -rf` of a worktree directory +- still tracks a tip whose loose ref has been packed away by `git pack-refs` +- a linked worktree path and the main repo path produce the same fingerprint +- a bare repo resolves through its own gitdir and still tracks `worktree add` +- `null` for a non-Git directory and for a missing path + +`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts`: + +- unchanged fingerprint suppresses the rescan past the 30 s TTL and re-arms it +- changed fingerprint rescans at the 30 s TTL +- the 5-minute reconciliation forces a rescan while the fingerprint is unchanged +- `notifyBranchRenamed` (event invalidation) still forces an immediate rescan +- a `null` fingerprint (probe failure) falls back to scanning +- SSH repos never consult the probe +- a failed scan is never extended +- concurrent callers share one probe and one scan +- the 1 Hz / 10-repo workload measurement above diff --git a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx index 65412d51958..df60f8fc117 100644 --- a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx @@ -10,6 +10,11 @@ export default function MobileFilePreviewRoute() { source?: string | string[] absolutePath?: string | string[] grantId?: string | string[] + terminal?: string | string[] + pathText?: string | string[] + cwd?: string | string[] + nativeChatTab?: string | string[] + nativeChatSession?: string | string[] line?: string | string[] column?: string | string[] name?: string | string[] diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index cf0f1dd7edb..123a1212eb0 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -127,6 +127,15 @@ import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal- import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply' import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version' +import { + addLegacyTerminalAttributionDisableRequest, + MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE, + withLegacyTerminalAttributionDisabledEnv +} from '../../../../../src/shared/legacy-terminal-attribution-env' +import { + assertMobileTerminalAttributionDisableSupported, + MOBILE_TERMINAL_CREATE_RPC_OPTIONS +} from '../../../../src/session/mobile-terminal-attribution-compat' import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate' import { @@ -917,6 +926,9 @@ export default function SessionScreen() { } | null>(null) const terminalUnsubsRef = useRef void>>(new Map()) const subscribingHandlesRef = useRef>(new Set()) + // Why: a lease-only subscribe never renders, so the reconciler needs to tell it apart + // from a stream that does — an uncovered handle holding one is a blank terminal. + const leaseOnlyHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics()) // Why: bounds the scrollback→resubscribe fit loop per handle (STA-3337). @@ -1233,6 +1245,7 @@ export default function SessionScreen() { terminalUnsubsRef.current.get(handle)?.() terminalUnsubsRef.current.delete(handle) subscribingHandlesRef.current.delete(handle) + leaseOnlyHandlesRef.current.delete(handle) terminalDiagnosticsRef.current.terminalUnsubscribed(handle) subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) // Why: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale. @@ -1265,6 +1278,7 @@ export default function SessionScreen() { clearNativeChatInputLease() terminalUnsubsRef.current.clear() subscribingHandlesRef.current.clear() + leaseOnlyHandlesRef.current.clear() initializedHandlesRef.current.clear() terminalDiagnosticsRef.current.clearTerminalCache() viewportResubscribeBudgetRef.current.clear() @@ -1331,6 +1345,11 @@ export default function SessionScreen() { } subscribingHandlesRef.current.add(handle) + if (covered) { + leaseOnlyHandlesRef.current.add(handle) + } else { + leaseOnlyHandlesRef.current.delete(handle) + } const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1 subscribeSeqRef.current.set(handle, seq) diagnostics.streamArmed(handle, seq, viewportRef.current) @@ -1519,6 +1538,7 @@ export default function SessionScreen() { streamRevision: coveredStreamRevision, subscriptionsRef: terminalUnsubsRef, subscribingRef: subscribingHandlesRef, + leaseOnlyRef: leaseOnlyHandlesRef, webReadyRef: webReadyHandlesRef, initializedRef: initializedHandlesRef, subscribe: subscribeToTerminal, @@ -3085,6 +3105,10 @@ export default function SessionScreen() { hostId, worktreeId, worktreeName: routeWorktreeName, + nativeChatSessionId: + activeSessionTab?.type === 'terminal' + ? (activeSessionTab.agentStatus?.providerSession?.id ?? null) + : null, activeHandleRef, terminalCwdRef, openBrowser: (url) => void handleCreateBrowserRef.current?.(url), @@ -3650,20 +3674,27 @@ export default function SessionScreen() { .slice(2, 10)}` try { - const response = await client.sendRequest('session.tabs.createTerminal', { - worktree: `id:${worktreeId}`, - afterTabId: activeSessionTabId ?? undefined, - clientMutationId, - ...(options?.startupCommand ? { command: options.startupCommand } : {}), - ...(options?.startupCommandDelivery - ? { startupCommandDelivery: options.startupCommandDelivery } - : {}), - ...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}), - ...(agent ? { agent } : {}), - activate: false, - select: true, - navigation: 'caller' - }) + const authority = await assertMobileTerminalAttributionDisableSupported(client) + const response = await client.sendRequest( + 'session.tabs.createTerminal', + { + worktree: `id:${worktreeId}`, + afterTabId: activeSessionTabId ?? undefined, + clientMutationId, + ...(options?.startupCommand ? { command: options.startupCommand } : {}), + ...(options?.startupCommandDelivery + ? { startupCommandDelivery: options.startupCommandDelivery } + : {}), + env: withLegacyTerminalAttributionDisabledEnv(undefined), + envToDelete: addLegacyTerminalAttributionDisableRequest(undefined), + ...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}), + ...(agent ? { agent } : {}), + activate: false, + select: true, + navigation: 'caller' + }, + { ...MOBILE_TERMINAL_CREATE_RPC_OPTIONS, expectedRuntimeId: authority.runtimeId } + ) if (response.ok) { const result = (response as RpcSuccess).result as TerminalCreateResult const created = result.tab @@ -3763,10 +3794,17 @@ export default function SessionScreen() { showToast(message, 1800) } } - } catch { - const message = options?.errorToast ?? 'Failed to create terminal' + } catch (error) { + const errorMessage = error instanceof Error ? error.message : null + const message = + errorMessage === MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE + ? errorMessage + : (options?.errorToast ?? errorMessage ?? 'Failed to create terminal') setCreateError(message) - if (options?.errorToast) { + if ( + options?.errorToast || + errorMessage === MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE + ) { triggerError() showToast(message, 1800) } diff --git a/mobile/fastlane/Fastfile b/mobile/fastlane/Fastfile index 6e66a72bada..204c14672b6 100644 --- a/mobile/fastlane/Fastfile +++ b/mobile/fastlane/Fastfile @@ -253,6 +253,9 @@ platform :ios do upload_to_testflight( api_key: api_key, app_identifier: BUNDLE_ID, + # distribute_only means there is no local .ipa to sniff, so fastlane would + # otherwise fall through to an interactive platform prompt and crash in CI. + app_platform: "ios", app_version: version, build_number: build_number, distribute_only: true, diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts index 5e320a3f10c..c51151b9ebf 100644 --- a/mobile/src/components/markdown-file-path-detection.ts +++ b/mobile/src/components/markdown-file-path-detection.ts @@ -3,6 +3,8 @@ // 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. +import { parseFileLinkLocation } from '../../../src/shared/file-link-location' + export type FilePathSegment = | { type: 'text'; value: string } | { type: 'file'; value: string; path: string } @@ -101,8 +103,6 @@ function hasMidTokenAt(candidate: string): boolean { return /[^\\/]@/.test(candidate) } -const LINE_SUFFIX_PATTERN = /^(.+?):([1-9]\d*)(?::([1-9]\d*))?$/ - /** * Split an agent-style `path:line(:col)` citation into its parts. Windows drive * colons are safe: only a trailing all-digit suffix is treated as a line ref. @@ -112,14 +112,14 @@ export function splitFilePathLineSuffix(pathText: string): { line: number | null column: number | null } { - const match = LINE_SUFFIX_PATTERN.exec(pathText) - if (!match) { + const parsed = parseFileLinkLocation(pathText) + if (!parsed || (parsed.line === null && parsed.column === null)) { return { path: pathText, line: null, column: null } } return { - path: match[1]!, - line: Number.parseInt(match[2]!, 10), - column: match[3] ? Number.parseInt(match[3], 10) : null + path: parsed.pathText, + line: parsed.line, + column: parsed.column } } diff --git a/mobile/src/components/mobile-markdown-preview-html.ts b/mobile/src/components/mobile-markdown-preview-html.ts index 8b2d247f8f2..8bb5fca0934 100644 --- a/mobile/src/components/mobile-markdown-preview-html.ts +++ b/mobile/src/components/mobile-markdown-preview-html.ts @@ -97,7 +97,7 @@ function attrValue(tag: string, name: string): string { const valueStart = cursor if (quote) { const valueEnd = tag.indexOf(quote, cursor) - if (valueEnd < 0) { + if (valueEnd === -1) { return '' } cursor = valueEnd + 1 diff --git a/mobile/src/components/mobile-markdown-preview-tag-stripper.ts b/mobile/src/components/mobile-markdown-preview-tag-stripper.ts index 90e3c3cc3cc..6eeca2aa612 100644 --- a/mobile/src/components/mobile-markdown-preview-tag-stripper.ts +++ b/mobile/src/components/mobile-markdown-preview-tag-stripper.ts @@ -191,7 +191,7 @@ function closingMarkupTagNames(value: string): Set { let cursor = 0 while (cursor < value.length) { const start = value.indexOf('<', cursor) - if (start < 0) { + if (start === -1) { return names } const end = findMobileMarkdownMarkupTagEnd(value, start + 1) @@ -228,7 +228,7 @@ export function stripMobileMarkdownMarkupTags(value: string): string { const closingTagNames = closingMarkupTagNames(value) while (cursor < value.length) { const start = value.indexOf('<', cursor) - if (start < 0) { + if (start === -1) { return output + value.slice(cursor) } output += value.slice(cursor, start) @@ -261,7 +261,7 @@ export function stripMobileMarkdownMarkupTags(value: string): string { !/[-:]/.test(name) && !knownMarkupTagNames.has(lowerName) && !closingTagNames.has(lowerName) - if (isNestedGeneric && nestedStart >= 0) { + if (isNestedGeneric && nestedStart !== -1) { output += value.slice(start, nestedStart) cursor = nestedStart continue diff --git a/mobile/src/files/MobileFilePreviewScreen.test.ts b/mobile/src/files/MobileFilePreviewScreen.test.ts index 1595454a12a..fa61f7351c6 100644 --- a/mobile/src/files/MobileFilePreviewScreen.test.ts +++ b/mobile/src/files/MobileFilePreviewScreen.test.ts @@ -39,6 +39,21 @@ describe('MobileFilePreviewScreen', () => { ).toBe(false) }) + it('treats native-chat artifact grants as read-only', () => { + expect( + isEditableMobileTerminalArtifactPreview( + { + status: 'ready', + kind: 'text', + content: '

Result

', + truncated: false, + byteLength: 15 + }, + true + ) + ).toBe(false) + }) + it('keeps dirty terminal artifact drafts protected while preview is waiting for reconnect', () => { const sourceKey = sourceKeyForPreview({ source: 'terminalArtifact', diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx index 0879e02ec7e..3f56890243a 100644 --- a/mobile/src/files/MobileFilePreviewScreen.tsx +++ b/mobile/src/files/MobileFilePreviewScreen.tsx @@ -181,7 +181,8 @@ export function MobileFilePreviewScreen({ route }: Props) { ) const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview' const isEditableTerminalArtifact = - previewSource?.source === 'terminalArtifact' && isEditableMobileTerminalArtifactPreview(preview) + previewSource?.source === 'terminalArtifact' && + isEditableMobileTerminalArtifactPreview(preview, previewSource.readOnly === true) const canSaveArtifact = isEditableTerminalArtifact && draftSourceKeyRef.current === previewSourceKey && diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts index 71f26ca155b..8a3e795ac2d 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/file-tree.ts @@ -132,7 +132,7 @@ export function getDirectoryCacheState( ): DirectoryState | undefined { // Why: repository paths are arbitrary object keys; inherited keys like // "constructor" must not masquerade as loaded directory state. - return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined + return Object.hasOwn(cache, relativePath) ? cache[relativePath] : undefined } export function joinRelativePath(parentPath: string, name: string): string { diff --git a/mobile/src/files/mobile-file-preview-editability.ts b/mobile/src/files/mobile-file-preview-editability.ts index ff0fa18d3cb..d8ed196e5cb 100644 --- a/mobile/src/files/mobile-file-preview-editability.ts +++ b/mobile/src/files/mobile-file-preview-editability.ts @@ -3,7 +3,13 @@ import type { MobileFilePreviewSource } from './mobile-file-preview-request' -export function isEditableMobileTerminalArtifactPreview(preview: MobileFilePreviewResult): boolean { +export function isEditableMobileTerminalArtifactPreview( + preview: MobileFilePreviewResult, + readOnly = false +): boolean { + if (readOnly) { + return false + } return ( (preview.status === 'ready' && preview.kind !== 'image' && !preview.truncated) || preview.status === 'empty' diff --git a/mobile/src/files/mobile-file-preview-request.test.ts b/mobile/src/files/mobile-file-preview-request.test.ts index f7f77fabd19..fe15cad0b07 100644 --- a/mobile/src/files/mobile-file-preview-request.test.ts +++ b/mobile/src/files/mobile-file-preview-request.test.ts @@ -179,6 +179,47 @@ describe('mobile-file-preview-request', () => { }) }) + it('refreshes a native-chat artifact grant with the same transcript provenance', async () => { + const client = clientWithResponses([ + fail('terminal_file_grant_expired'), + ok({ + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-2', + readOnly: true + } + }), + ok({ content: '

Result

', truncated: false, byteLength: 15 }) + ]) + + const onTerminalArtifactSourceRefreshed = vi.fn() + await loadMobileFilePreview( + client, + { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-1', + pathText: '~/orca-plans/result.html', + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }, + undefined, + { onTerminalArtifactSourceRefreshed } + ) + + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', { + worktree: 'id:wt-1', + pathText: '~/orca-plans/result.html', + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }) + expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith( + expect.objectContaining({ grantId: 'grant-2', readOnly: true }) + ) + }) + it.each([ ['terminal_file_grant_expired'], ['terminal_file_grant_mismatch'], @@ -242,6 +283,25 @@ describe('mobile-file-preview-request', () => { }) }) + it('does not send writes for read-only native-chat artifacts', async () => { + const client = clientWith(ok({ ok: true })) + + await expect( + saveMobileTerminalArtifactPreview( + client, + { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-1', + readOnly: true + }, + '

Changed

' + ) + ).resolves.toMatchObject({ status: 'error' }) + expect(client.sendRequest).not.toHaveBeenCalled() + }) + it('does not refresh and retry a failed terminal artifact save without a base content check', async () => { const client = clientWithResponses([fail('terminal_file_grant_mismatch')]) diff --git a/mobile/src/files/mobile-file-preview-request.ts b/mobile/src/files/mobile-file-preview-request.ts index ff6cd755c50..63b61326654 100644 --- a/mobile/src/files/mobile-file-preview-request.ts +++ b/mobile/src/files/mobile-file-preview-request.ts @@ -116,6 +116,9 @@ export async function saveMobileTerminalArtifactPreview( content: string, options: TerminalArtifactSaveOptions = {} ): Promise { + if (source.readOnly) { + return previewError('This file is read-only') + } let writeSource = source if (typeof options.baseContent === 'string') { const verified = await verifyTerminalArtifactBaseContent( diff --git a/mobile/src/files/mobile-file-preview-route.test.ts b/mobile/src/files/mobile-file-preview-route.test.ts index ac1888ed46f..784313464ee 100644 --- a/mobile/src/files/mobile-file-preview-route.test.ts +++ b/mobile/src/files/mobile-file-preview-route.test.ts @@ -65,6 +65,8 @@ describe('mobile-file-preview-route', () => { terminal: 'term-1', pathText: 'result.json', cwd: '/tmp/run', + nativeChatTab: 'tab-1', + nativeChatSession: 'session-1', line: '12', column: '3' }) @@ -80,6 +82,8 @@ describe('mobile-file-preview-route', () => { terminal: 'term-1', pathText: 'result.json', cwd: '/tmp/run', + nativeChatTab: 'tab-1', + nativeChatSession: 'session-1', line: '12', column: '3' } diff --git a/mobile/src/files/mobile-file-preview-route.ts b/mobile/src/files/mobile-file-preview-route.ts index 1b277de7faa..f0e7dfbbf49 100644 --- a/mobile/src/files/mobile-file-preview-route.ts +++ b/mobile/src/files/mobile-file-preview-route.ts @@ -10,6 +10,8 @@ export type MobileFilePreviewRouteParams = { terminal?: string pathText?: string cwd?: string + nativeChatTab?: string + nativeChatSession?: string line?: string column?: string name?: string @@ -35,6 +37,8 @@ type RawPreviewRouteParams = { terminal?: MobileFilePreviewParamValue pathText?: MobileFilePreviewParamValue cwd?: MobileFilePreviewParamValue + nativeChatTab?: MobileFilePreviewParamValue + nativeChatSession?: MobileFilePreviewParamValue line?: MobileFilePreviewParamValue column?: MobileFilePreviewParamValue name?: MobileFilePreviewParamValue @@ -76,6 +80,8 @@ export function normalizeMobileFilePreviewRouteParams( terminal: optionalSingleParam(params.terminal), pathText: optionalSingleParam(params.pathText), cwd: optionalSingleParam(params.cwd), + nativeChatTab: optionalSingleParam(params.nativeChatTab), + nativeChatSession: optionalSingleParam(params.nativeChatSession), line: optionalSingleParam(params.line), column: optionalSingleParam(params.column), name: optionalSingleParam(params.name), diff --git a/mobile/src/files/mobile-file-preview-source.test.ts b/mobile/src/files/mobile-file-preview-source.test.ts index b5fec782cfc..9c326fd0278 100644 --- a/mobile/src/files/mobile-file-preview-source.test.ts +++ b/mobile/src/files/mobile-file-preview-source.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { sourceKeyForPreview } from './mobile-file-preview-source' +import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source' describe('mobile-file-preview-source', () => { it('uses structured preview keys so colons in paths cannot collide', () => { @@ -25,4 +25,21 @@ describe('mobile-file-preview-source', () => { sourceKeyForPreview({ source: 'worktree', worktreeId: 'wt:1', relativePath: 'a:b.ts' }) ).toBe(JSON.stringify(['worktree', 'wt:1', 'a:b.ts'])) }) + + it('makes native-chat artifact previews read-only', () => { + expect( + previewSourceFromRoute({ + hostId: 'host-1', + worktreeId: 'wt-1', + source: 'terminalArtifact', + absolutePath: '/tmp/result.html', + grantId: 'grant-1', + nativeChatTab: 'tab-1', + nativeChatSession: 'session-1' + }) + ).toMatchObject({ + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }, + readOnly: true + }) + }) }) diff --git a/mobile/src/files/mobile-file-preview-source.ts b/mobile/src/files/mobile-file-preview-source.ts index b1b23757a6e..2c1f01631e6 100644 --- a/mobile/src/files/mobile-file-preview-source.ts +++ b/mobile/src/files/mobile-file-preview-source.ts @@ -15,7 +15,16 @@ export function previewSourceFromRoute( grantId: params.grantId, ...(params.terminal ? { terminalHandle: params.terminal } : {}), ...(params.pathText ? { pathText: params.pathText } : {}), - ...(params.cwd ? { cwd: params.cwd } : {}) + ...(params.cwd ? { cwd: params.cwd } : {}), + ...(params.nativeChatTab && params.nativeChatSession + ? { + nativeChatContext: { + tabId: params.nativeChatTab, + sessionId: params.nativeChatSession + }, + readOnly: true as const + } + : {}) } } if (!params.relativePath) { @@ -28,12 +37,12 @@ export function sourceKeyForPreview(source: MobileFilePreviewSource | null): str if (!source) { return null } - return source.source === 'terminalArtifact' - ? JSON.stringify([ - 'terminal', - source.worktreeId, - source.absolutePath, - source.terminalHandle ?? '' - ]) - : JSON.stringify(['worktree', source.worktreeId, source.relativePath]) + if (source.source !== 'terminalArtifact') { + return JSON.stringify(['worktree', source.worktreeId, source.relativePath]) + } + const key = ['terminal', source.worktreeId, source.absolutePath, source.terminalHandle ?? ''] + if (source.nativeChatContext) { + key.push(source.nativeChatContext.tabId, source.nativeChatContext.sessionId) + } + return JSON.stringify(key) } diff --git a/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts index a8864119be4..cbe5ffdebd8 100644 --- a/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts +++ b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts @@ -1,3 +1,4 @@ +import type { RuntimeNativeChatFileContext } from '../../../src/shared/runtime-types' import type { RpcClient } from '../transport/rpc-client' import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error' @@ -12,6 +13,8 @@ export type MobileTerminalArtifactPreviewSource = { terminalHandle?: string pathText?: string cwd?: string + nativeChatContext?: RuntimeNativeChatFileContext + readOnly?: true } export type TerminalArtifactRetryOptions = { @@ -32,7 +35,8 @@ export async function refreshTerminalArtifactSourceAfterGrantFailure( worktree: `id:${source.worktreeId}`, pathText: source.pathText ?? source.absolutePath, ...(source.cwd ? { cwd: source.cwd } : {}), - ...(source.terminalHandle ? { terminal: source.terminalHandle } : {}) + ...(source.terminalHandle ? { terminal: source.terminalHandle } : {}), + ...(source.nativeChatContext ? { nativeChatContext: source.nativeChatContext } : {}) }) if (!refreshed.ok) { return null @@ -51,7 +55,9 @@ export async function refreshTerminalArtifactSourceAfterGrantFailure( grantId: result.openTarget.grantId, ...(source.terminalHandle ? { terminalHandle: source.terminalHandle } : {}), ...(source.pathText ? { pathText: source.pathText } : {}), - ...(source.cwd ? { cwd: source.cwd } : {}) + ...(source.cwd ? { cwd: source.cwd } : {}), + ...(source.nativeChatContext ? { nativeChatContext: source.nativeChatContext } : {}), + ...(source.readOnly || result.openTarget.readOnly === true ? { readOnly: true as const } : {}) } } @@ -68,7 +74,7 @@ function isTerminalArtifactGrantFailure( function isTerminalArtifactResolution(result: unknown): result is { exists: true isDirectory: false - openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string } + openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string; readOnly?: true } } { if (!result || typeof result !== 'object') { return false diff --git a/mobile/src/hooks/use-active-worktree-scroll.ts b/mobile/src/hooks/use-active-worktree-scroll.ts index 7600f3ceafe..ff0441c85a8 100644 --- a/mobile/src/hooks/use-active-worktree-scroll.ts +++ b/mobile/src/hooks/use-active-worktree-scroll.ts @@ -37,7 +37,7 @@ export function useActiveWorktreeScroll( (worktreeId: string): boolean => { for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { const itemIndex = sections[sectionIndex].data.findIndex((w) => w.worktreeId === worktreeId) - if (itemIndex >= 0) { + if (itemIndex !== -1) { sectionListRef.current?.scrollToLocation({ sectionIndex, itemIndex, diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index 9efc0e50f1a..3e9af99191c 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -226,6 +226,13 @@ describe('resumeAiVaultSessionInTerminal', () => { it('creates a fresh terminal and sends the command with Enter', async () => { const sendRequest = vi .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + runtimeId: 'runtime', + capabilities: ['terminal.attribution-removed.v1'] + } + }) .mockResolvedValueOnce({ ok: true, result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } } @@ -249,13 +256,20 @@ describe('resumeAiVaultSessionInTerminal', () => { navigation: 'caller' }) ).resolves.toMatchObject({ id: 'tab-1', terminal: 'pty-1' }) + expect(sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, { + timeoutMs: 30_000, + budgetSpansConnect: true + }) expect(sendRequest).toHaveBeenNthCalledWith( - 1, + 2, 'session.tabs.createTerminal', { worktree: 'id:worktree-1', - env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }, - envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], + env: { + ANTHROPIC_BASE_URL: 'http://localhost:3000', + ORCA_ATTRIBUTION_BYPASS: '1' + }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'ORCA_ENABLE_GIT_ATTRIBUTION'], launchConfig: { agentCommand: 'claude', agentArgs: '', @@ -269,10 +283,14 @@ describe('resumeAiVaultSessionInTerminal', () => { }, // Why: a socket drop mid-resume must reject within the request timeout // instead of parking on the reconnect waiter with the spinner pinned. - { timeoutMs: RESUME_RPC_TIMEOUT_MS } + { + timeoutMs: RESUME_RPC_TIMEOUT_MS, + budgetSpansConnect: true, + expectedRuntimeId: 'runtime' + } ) expect(sendRequest).toHaveBeenNthCalledWith( - 2, + 3, 'terminal.send', { terminal: 'pty-1', @@ -284,17 +302,35 @@ describe('resumeAiVaultSessionInTerminal', () => { }) it('throws when terminal creation fails', async () => { - const sendRequest = vi.fn().mockResolvedValueOnce({ - ok: false, - error: { message: 'no terminal' } - }) + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + runtimeId: 'runtime', + capabilities: ['terminal.attribution-removed.v1'] + } + }) + .mockResolvedValueOnce({ + ok: false, + error: { message: 'no terminal' } + }) await expect( resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' }) ).rejects.toThrow('no terminal') }) it('throws when the created terminal response is malformed', async () => { - const sendRequest = vi.fn().mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } }) + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + runtimeId: 'runtime', + capabilities: ['terminal.attribution-removed.v1'] + } + }) + .mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } }) await expect( resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' }) ).rejects.toThrow('Created terminal response was invalid') @@ -303,6 +339,13 @@ describe('resumeAiVaultSessionInTerminal', () => { it('throws when terminal send fails or is locked', async () => { const failedSend = vi .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + runtimeId: 'runtime', + capabilities: ['terminal.attribution-removed.v1'] + } + }) .mockResolvedValueOnce({ ok: true, result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } } @@ -316,6 +359,13 @@ describe('resumeAiVaultSessionInTerminal', () => { const lockedSend = vi .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + runtimeId: 'runtime', + capabilities: ['terminal.attribution-removed.v1'] + } + }) .mockResolvedValueOnce({ ok: true, result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } } @@ -327,6 +377,17 @@ describe('resumeAiVaultSessionInTerminal', () => { }) ).rejects.toThrow('Terminal input is locked') }) + + it('refuses an old host before creating a resume terminal', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ ok: true, result: { appVersion: '1.4.89' } }) + + await expect( + resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' }) + ).rejects.toThrow('Update the host and try again') + expect(sendRequest).toHaveBeenCalledTimes(1) + }) }) describe('createMobileAiVaultResumeMutationRegistry', () => { diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index aa8c1bf98a6..6975b6f355d 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -16,6 +16,10 @@ import { normalizeAiVaultResumeFilePath } from '../../../src/shared/ai-vault-res import type { TuiAgent } from '../../../src/shared/types' import { parseWslUncPath } from '../../../src/shared/wsl-paths' import { resolveWindowsShellStartupFamily } from '../../../src/shared/windows-terminal-shell' +import { + addLegacyTerminalAttributionDisableRequest, + withLegacyTerminalAttributionDisabledEnv +} from '../../../src/shared/legacy-terminal-attribution-env' import type { RpcClient } from '../transport/rpc-client' import { readMobileReviewCreatedTerminal, @@ -23,6 +27,7 @@ import { type MobileReviewTerminalTab } from './mobile-diff-review-rpc' import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target' +import { assertMobileTerminalAttributionDisableSupported } from './mobile-terminal-attribution-compat' const NODE_PLATFORMS = new Set([ 'aix', @@ -169,12 +174,13 @@ export async function resumeAiVaultSessionInTerminal( worktreeId: string, launch: MobileAiVaultResumeLaunch & { clientMutationId?: string } ): Promise { + const authority = await assertMobileTerminalAttributionDisableSupported(client) const created = await client.sendRequest( 'session.tabs.createTerminal', { worktree: `id:${worktreeId}`, - ...(launch.env ? { env: launch.env } : {}), - ...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}), + env: withLegacyTerminalAttributionDisabledEnv(launch.env), + envToDelete: addLegacyTerminalAttributionDisableRequest(launch.envToDelete), ...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}), ...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}), ...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}), @@ -182,7 +188,11 @@ export async function resumeAiVaultSessionInTerminal( select: true, navigation: 'caller' }, - { timeoutMs: RESUME_RPC_TIMEOUT_MS } + { + timeoutMs: RESUME_RPC_TIMEOUT_MS, + budgetSpansConnect: true, + expectedRuntimeId: authority.runtimeId + } ) if (!created.ok) { throw new Error(created.error?.message || 'Failed to create terminal') diff --git a/mobile/src/session/ai-vault-resume-preparation.test.ts b/mobile/src/session/ai-vault-resume-preparation.test.ts index a0eed5515c6..3e64a06a0f8 100644 --- a/mobile/src/session/ai-vault-resume-preparation.test.ts +++ b/mobile/src/session/ai-vault-resume-preparation.test.ts @@ -53,6 +53,14 @@ describe('prepareMobileAiVaultSessionResume', () => { const sendRequest = vi .fn() .mockResolvedValueOnce({ ok: false, error }) + .mockResolvedValueOnce({ + ok: true, + result: { + runtimeId: 'runtime-1', + appVersion: '1.4.90', + capabilities: ['terminal.attribution-removed.v1'] + } + }) .mockResolvedValueOnce({ ok: true, result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } } @@ -64,9 +72,12 @@ describe('prepareMobileAiVaultSessionResume', () => { await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', launch) expect(prepared).toBe(legacy) - expect(sendRequest.mock.calls[1]?.[1]).not.toHaveProperty('envToDelete') + expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({ + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'] + }) expect(sendRequest).toHaveBeenNthCalledWith( - 3, + 4, 'terminal.send', { terminal: 'pty-1', diff --git a/mobile/src/session/mobile-diff-review-screen-model.ts b/mobile/src/session/mobile-diff-review-screen-model.ts index 3dcaed35716..f8f1ceedf99 100644 --- a/mobile/src/session/mobile-diff-review-screen-model.ts +++ b/mobile/src/session/mobile-diff-review-screen-model.ts @@ -100,7 +100,7 @@ export function nextReviewIndexAfterMarkReviewed({ const wrappedIndex = filteredQueue.findIndex( (item) => item.key !== currentItemKey && !item.isReviewed ) - const targetIndex = nextIndex >= 0 ? nextIndex : wrappedIndex >= 0 ? wrappedIndex : null + const targetIndex = nextIndex !== -1 ? nextIndex : wrappedIndex !== -1 ? wrappedIndex : null if (targetIndex === null) { return null } diff --git a/mobile/src/session/mobile-file-tap-open.ts b/mobile/src/session/mobile-file-tap-open.ts index 627d9a5ff39..13c306af649 100644 --- a/mobile/src/session/mobile-file-tap-open.ts +++ b/mobile/src/session/mobile-file-tap-open.ts @@ -1,5 +1,6 @@ import type { RuntimeFileOpenResult, + RuntimeNativeChatFileContext, RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path' @@ -22,6 +23,7 @@ export type OpenMobileFileTapOptions = { terminalHandle?: string | null pathText: string cwd?: string | null + nativeChatContext?: RuntimeNativeChatFileContext | null line: number | null column: number | null pushPreviewRoute: (href: ReturnType) => void @@ -80,7 +82,8 @@ async function openMobileFileTapAsync( ...(options.terminalHandle && options.terminalHandle.trim().length > 0 ? { terminal: options.terminalHandle } : {}), - ...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}) + ...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}), + ...(options.nativeChatContext ? { nativeChatContext: options.nativeChatContext } : {}) }, { timeoutMs: 10_000 } ) @@ -116,6 +119,12 @@ async function openMobileFileTapAsync( ...(options.terminalHandle && options.terminalHandle.trim().length > 0 ? { terminal: options.terminalHandle } : {}), + ...(options.nativeChatContext + ? { + nativeChatTab: options.nativeChatContext.tabId, + nativeChatSession: options.nativeChatContext.sessionId + } + : {}), name: displayNameFromPath(resolved.openTarget.absolutePath), ...(options.line !== null ? { line: String(options.line) } : {}), ...(options.column !== null ? { column: String(options.column) } : {}), diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 360fd22587b..a6f599b7458 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -87,7 +87,7 @@ export function mergeLandedImagePreviewEchoes( const entries = Object.entries(previous[sessionKey] ?? {}) for (const preview of landed) { const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId) - if (existingIndex >= 0) { + if (existingIndex !== -1) { entries.splice(existingIndex, 1) } entries.push([preview.messageId, preview.images]) diff --git a/mobile/src/session/mobile-native-chat-open-file.test.ts b/mobile/src/session/mobile-native-chat-open-file.test.ts index d44530e5cd0..bef222721ac 100644 --- a/mobile/src/session/mobile-native-chat-open-file.test.ts +++ b/mobile/src/session/mobile-native-chat-open-file.test.ts @@ -65,6 +65,54 @@ describe('openMobileNativeChatFileTap', () => { ) }) + it('carries the native-chat session identity into resolution and grant refresh', async () => { + const sendRequest = vi.fn(async () => + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/Users/ada/orca-plans/result.html', + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + provider: 'local', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-1', + readOnly: true + } + }) + ) + const options = baseOptions({ sendRequest }) + + openMobileNativeChatFileTap({ + ...options, + pathText: '~/orca-plans/result.html', + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }) + await Promise.resolve() + + expect(sendRequest).toHaveBeenCalledWith( + 'files.resolveTerminalPath', + { + worktree: 'id:wt-1', + pathText: '~/orca-plans/result.html', + crossWorkspace: true, + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }, + { timeoutMs: 10_000 } + ) + expect(options.pushPreviewRoute).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/files/preview/[worktreeId]', + params: expect.objectContaining({ + source: 'terminalArtifact', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-1', + nativeChatTab: 'tab-1', + nativeChatSession: 'session-1' + }) + }) + }) + it('parses a :line:col citation and opens the mobile preview route', async () => { const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts')) const options = baseOptions({ sendRequest }) diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts index df860efc6b1..99c4c47cbcd 100644 --- a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts +++ b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts @@ -12,6 +12,7 @@ const base = { activeTabType: 'terminal', streamActive: true, streamCovered: false, + streamIsLeaseOnly: false, webViewReady: true } @@ -93,8 +94,40 @@ describe('mobile native-chat terminal stream lifecycle', () => { ).toBe('rearm') }) + it('resumes an uncovered handle still holding a lease-only stream', () => { + // Leaving a chat tab for a terminal tab subscribes the incoming handle before the + // route learns chat is gone, so the terminal streams nothing but its input lease. + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamIsLeaseOnly: true })).toBe( + 'resume' + ) + // Same wait the other resume paths take — never init a WebView that cannot receive it. + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamIsLeaseOnly: true, + webViewReady: false + }) + ).toBe('none') + }) + + it('leaves a lease-only stream alone while chat still covers it (#10681)', () => { + // Lease-only is the correct shape under chat; trading it for output here would + // drop the input floor the composer depends on. + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamCovered: true, + streamIsLeaseOnly: true + }) + ).toBe('none') + }) + it('does nothing for non-terminal tabs, missing handles, or settled states', () => { - expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none') + // A full stream on an uncovered handle is the one genuinely settled shape. + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamIsLeaseOnly: false })).toBe( + 'none' + ) expect( resolveMobileNativeChatTerminalStreamAction({ ...base, diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.ts b/mobile/src/session/mobile-native-chat-terminal-stream.ts index b9b58448113..9d2727f2c30 100644 --- a/mobile/src/session/mobile-native-chat-terminal-stream.ts +++ b/mobile/src/session/mobile-native-chat-terminal-stream.ts @@ -8,6 +8,10 @@ export function resolveMobileNativeChatTerminalStreamAction(args: { activeTabType: string | null streamActive: boolean streamCovered: boolean + /** The active stream was opened as an input lease, so the host sends `subscribed` + * and nothing else — no scrollback, no data. Distinguishes a stream that renders + * from one that only holds the input floor. */ + streamIsLeaseOnly: boolean webViewReady: boolean }): MobileNativeChatTerminalStreamAction { if (!args.activeHandle || args.activeTabType !== 'terminal') { @@ -22,7 +26,13 @@ export function resolveMobileNativeChatTerminalStreamAction(args: { // the composer locked forever — nothing else re-subscribes a covered handle. return args.streamActive ? 'none' : 'rearm' } - return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none' + // Why `streamIsLeaseOnly`: switching from a chat tab to a terminal tab subscribes the + // incoming handle before the route learns chat is gone, so it lands a lease-only stream + // on an uncovered handle. Without this input that is indistinguishable from a healthy + // stream and the terminal renders blank until restart. + return (args.streamCovered || args.streamIsLeaseOnly || !args.streamActive) && args.webViewReady + ? 'resume' + : 'none' } export function isTerminalCoveredByNativeChat( diff --git a/mobile/src/session/mobile-terminal-attribution-compat.test.ts b/mobile/src/session/mobile-terminal-attribution-compat.test.ts new file mode 100644 index 00000000000..9f0ebf873bd --- /dev/null +++ b/mobile/src/session/mobile-terminal-attribution-compat.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE } from '../../../src/shared/legacy-terminal-attribution-env' +import { assertMobileTerminalAttributionDisableSupported } from './mobile-terminal-attribution-compat' + +function status(result: unknown) { + return { + id: 'status', + ok: true as const, + result: { runtimeId: 'runtime', ...(result as object) }, + _meta: { runtimeId: 'runtime' } + } +} + +describe('mobile terminal attribution compatibility', () => { + it('refuses hosts before terminal creation environment forwarding', async () => { + const sendRequest = vi.fn().mockResolvedValue( + status({ + capabilities: [ + 'runtime.status.compat.v1', + 'runtime.environments.v1', + 'mobile.tasks.v1', + 'workspace-run-context.v1' + ] + }) + ) + + await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow( + MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE + ) + expect(sendRequest).toHaveBeenCalledWith('status.get', undefined, { + timeoutMs: 30_000, + budgetSpansConnect: true + }) + }) + + it('rejects historical hosts without explicit removal capability', async () => { + const sendRequest = vi.fn().mockResolvedValue(status({ appVersion: '1.4.90' })) + + await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow( + MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE + ) + }) + + it('accepts capability-proven current development hosts', async () => { + const sendRequest = vi + .fn() + .mockResolvedValue( + status({ appVersion: '0.0.0-dev', capabilities: ['terminal.attribution-removed.v1'] }) + ) + + await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).resolves.toEqual( + { runtimeId: 'runtime' } + ) + }) + + it('fails closed when host status cannot be verified', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + id: 'status', + ok: false, + error: { code: 'offline', message: 'Host is offline' }, + _meta: { runtimeId: 'runtime' } + }) + + await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow( + 'Host is offline' + ) + }) + + it('fails closed with an actionable error for malformed host status', async () => { + const sendRequest = vi.fn().mockResolvedValue( + status({ + appVersion: 1.49, + capabilities: 'terminal.attribution-removed.v1' + }) + ) + + await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow( + 'Update the host and try again' + ) + }) +}) diff --git a/mobile/src/session/mobile-terminal-attribution-compat.ts b/mobile/src/session/mobile-terminal-attribution-compat.ts new file mode 100644 index 00000000000..ceb2d3f694e --- /dev/null +++ b/mobile/src/session/mobile-terminal-attribution-compat.ts @@ -0,0 +1,34 @@ +import { + hostSupportsSessionTabTerminalCreateAttributionDisable, + MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE +} from '../../../src/shared/legacy-terminal-attribution-env' +import type { RpcClient } from '../transport/rpc-client' + +export const MOBILE_TERMINAL_CREATE_RPC_TIMEOUT_MS = 30_000 +export const MOBILE_TERMINAL_CREATE_RPC_OPTIONS = { + timeoutMs: MOBILE_TERMINAL_CREATE_RPC_TIMEOUT_MS, + budgetSpansConnect: true +} as const + +export type MobileTerminalAttributionAuthority = Readonly<{ runtimeId: string }> + +export async function assertMobileTerminalAttributionDisableSupported( + client: Pick +): Promise { + const response = await client.sendRequest( + 'status.get', + undefined, + MOBILE_TERMINAL_CREATE_RPC_OPTIONS + ) + if (!response.ok) { + throw new Error(response.error?.message || 'Unable to verify the workspace host version.') + } + if (!hostSupportsSessionTabTerminalCreateAttributionDisable(response.result)) { + throw new Error(MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE) + } + const runtimeId = Reflect.get(response.result as object, 'runtimeId') + if (typeof runtimeId !== 'string' || !runtimeId) { + throw new Error(MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE) + } + return Object.freeze({ runtimeId }) +} diff --git a/mobile/src/session/pr-ai-triage-launch.test.ts b/mobile/src/session/pr-ai-triage-launch.test.ts index c2273c9046e..fe9271f5e4d 100644 --- a/mobile/src/session/pr-ai-triage-launch.test.ts +++ b/mobile/src/session/pr-ai-triage-launch.test.ts @@ -12,6 +12,10 @@ function failure(message: string): RpcResponse { const createdTerminal = success({ tab: { type: 'terminal', id: 't1', terminal: 'term-1' } }) const sendAccepted = success({ send: { accepted: true } }) +const safeHost = success({ + runtimeId: 'r', + capabilities: ['terminal.attribution-removed.v1'] +}) function clientReturning(...responses: RpcResponse[]) { const sendRequest = vi.fn(async () => responses[sendRequest.mock.calls.length - 1]) @@ -20,16 +24,27 @@ function clientReturning(...responses: RpcResponse[]) { describe('createTerminalAndSendPrompt', () => { it('creates a terminal then sends the prompt with enter', async () => { - const client = clientReturning(createdTerminal, sendAccepted) + const client = clientReturning(safeHost, createdTerminal, sendAccepted) await createTerminalAndSendPrompt(client, 'wt-1', 'do the thing') - expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.createTerminal', { - worktree: 'id:wt-1', - activate: false, - select: true, - navigation: 'caller' + expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, { + timeoutMs: 30_000, + budgetSpansConnect: true }) - expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'terminal.send', { + expect(client.sendRequest).toHaveBeenNthCalledWith( + 2, + 'session.tabs.createTerminal', + { + worktree: 'id:wt-1', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + activate: false, + select: true, + navigation: 'caller' + }, + { timeoutMs: 30_000, budgetSpansConnect: true, expectedRuntimeId: 'r' } + ) + expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'terminal.send', { terminal: 'term-1', text: 'do the thing', enter: true @@ -37,28 +52,41 @@ describe('createTerminalAndSendPrompt', () => { }) it('throws and skips terminal.send when createTerminal fails', async () => { - const client = clientReturning(failure('boom')) + const client = clientReturning(safeHost, failure('boom')) await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow('boom') - expect(client.sendRequest).toHaveBeenCalledTimes(1) + expect(client.sendRequest).toHaveBeenCalledTimes(2) }) it('throws when the created-terminal response is malformed', async () => { - const client = clientReturning(success({ tab: { type: 'terminal' } })) + const client = clientReturning(safeHost, success({ tab: { type: 'terminal' } })) await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow( 'Created terminal response was invalid' ) - expect(client.sendRequest).toHaveBeenCalledTimes(1) + expect(client.sendRequest).toHaveBeenCalledTimes(2) }) it('throws when terminal.send returns a failure', async () => { - const client = clientReturning(createdTerminal, failure('send failed')) + const client = clientReturning(safeHost, createdTerminal, failure('send failed')) await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow('send failed') }) it('throws when terminal input is locked', async () => { - const client = clientReturning(createdTerminal, success({ send: { accepted: false } })) + const client = clientReturning( + safeHost, + createdTerminal, + success({ send: { accepted: false } }) + ) await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow( 'Terminal input is locked' ) }) + + it('refuses an old host before terminal creation', async () => { + const client = clientReturning(success({ appVersion: '1.4.89' })) + + await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow( + 'Update the host and try again' + ) + expect(client.sendRequest).toHaveBeenCalledTimes(1) + }) }) diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index 6d6c0bbc8b2..1f764be1789 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -3,6 +3,14 @@ import { readMobileReviewCreatedTerminal, readMobileReviewTerminalSendAccepted } from './mobile-diff-review-rpc' +import { + addLegacyTerminalAttributionDisableRequest, + withLegacyTerminalAttributionDisabledEnv +} from '../../../src/shared/legacy-terminal-attribution-env' +import { + assertMobileTerminalAttributionDisableSupported, + MOBILE_TERMINAL_CREATE_RPC_OPTIONS +} from './mobile-terminal-attribution-compat' // Pure launch path for the PR triage actions ("Fix checks with AI" / "Resolve // conflicts with AI"). Reuses the same two RPCs the diff-review send flow uses — @@ -15,12 +23,19 @@ export async function createTerminalAndSendPrompt( worktreeId: string, prompt: string ): Promise { - const created = await client.sendRequest('session.tabs.createTerminal', { - worktree: `id:${worktreeId}`, - activate: false, - select: true, - navigation: 'caller' - }) + const authority = await assertMobileTerminalAttributionDisableSupported(client) + const created = await client.sendRequest( + 'session.tabs.createTerminal', + { + worktree: `id:${worktreeId}`, + env: withLegacyTerminalAttributionDisabledEnv(undefined), + envToDelete: addLegacyTerminalAttributionDisableRequest(undefined), + activate: false, + select: true, + navigation: 'caller' + }, + { ...MOBILE_TERMINAL_CREATE_RPC_OPTIONS, expectedRuntimeId: authority.runtimeId } + ) if (!created.ok) { throw new Error(created.error?.message || 'Failed to create terminal') } diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts index 1d06190948c..9ae2749412b 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts @@ -163,6 +163,74 @@ describe('useMobileDiffReviewSendActions', () => { expect(setSendSheet).toHaveBeenCalledWith(null) }) + it('creates an unattributed terminal before sending review notes', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + id: 'status', + ok: true, + result: { + runtimeId: 'runtime', + capabilities: ['terminal.attribution-removed.v1'] + }, + _meta: { runtimeId: 'runtime' } + }) + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'terminal-1' } }, + _meta: { runtimeId: 'runtime' } + }) + .mockResolvedValueOnce(sendResponse(true)) + await mount({ sendRequest } as unknown as RpcClient) + + await act(async () => { + await actions?.createTerminalAndSend([COMMENT]) + }) + + expect(sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, { + timeoutMs: 30_000, + budgetSpansConnect: true + }) + expect(sendRequest).toHaveBeenNthCalledWith( + 2, + 'session.tabs.createTerminal', + { + worktree: 'id:wt-1', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + activate: false, + select: true, + navigation: 'caller' + }, + { timeoutMs: 30_000, budgetSpansConnect: true, expectedRuntimeId: 'runtime' } + ) + expect(sendRequest).toHaveBeenNthCalledWith( + 3, + 'terminal.send', + expect.objectContaining({ terminal: 'terminal-1', enter: true }) + ) + }) + + it('refuses to create review terminals on hosts that strip the bypass', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + id: 'status', + ok: true, + result: { appVersion: '1.4.89' }, + _meta: { runtimeId: 'runtime' } + }) + await mount({ sendRequest } as unknown as RpcClient) + + let error: unknown + await act(async () => { + error = await actions?.createTerminalAndSend([COMMENT]).catch((err) => err) + }) + + expect((error as Error).message).toContain('Update the host and try again') + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest).not.toHaveBeenCalledWith('terminal.send', expect.anything()) + }) + it('only heals the terminal that was marked', async () => { const sendRequest = vi.fn().mockResolvedValue(sendResponse(true)) await mount({ sendRequest } as unknown as RpcClient) diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.ts b/mobile/src/session/use-mobile-diff-review-send-actions.ts index 0867a3884b3..266caaf6879 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.ts @@ -13,6 +13,14 @@ import { } from './mobile-diff-review-rpc' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import type { ReviewScreenState, SendSheetState } from './mobile-diff-review-screen-model' +import { + addLegacyTerminalAttributionDisableRequest, + withLegacyTerminalAttributionDisabledEnv +} from '../../../src/shared/legacy-terminal-attribution-env' +import { + assertMobileTerminalAttributionDisableSupported, + MOBILE_TERMINAL_CREATE_RPC_OPTIONS +} from './mobile-terminal-attribution-compat' type SendActionsInput = { client: RpcClient | null @@ -104,12 +112,19 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { if (!client || connState !== 'connected') { throw new Error('Waiting for desktop...') } - const response = await client.sendRequest('session.tabs.createTerminal', { - worktree: `id:${worktreeId}`, - activate: false, - select: true, - navigation: 'caller' - }) + const authority = await assertMobileTerminalAttributionDisableSupported(client) + const response = await client.sendRequest( + 'session.tabs.createTerminal', + { + worktree: `id:${worktreeId}`, + env: withLegacyTerminalAttributionDisabledEnv(undefined), + envToDelete: addLegacyTerminalAttributionDisableRequest(undefined), + activate: false, + select: true, + navigation: 'caller' + }, + { ...MOBILE_TERMINAL_CREATE_RPC_OPTIONS, expectedRuntimeId: authority.runtimeId } + ) if (!response.ok) { throw new Error(response.error?.message || 'Failed to create terminal') } diff --git a/mobile/src/session/use-mobile-file-tap-handlers.test.ts b/mobile/src/session/use-mobile-file-tap-handlers.test.ts index 0cfa617f269..21f07562459 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.test.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.test.ts @@ -34,12 +34,13 @@ describe('useMobileFileTapHandlers', () => { hostId: 'host-1', worktreeId: 'wt-1', worktreeName: 'Orca', + nativeChatSessionId: 'session-1', activeHandleRef: { current: 'terminal-1' as string | null }, terminalCwdRef: { current: new Map([['terminal-1', '/repo/sub']]) }, openBrowser: vi.fn(), fetchSessionTabs: vi.fn(async () => {}), getSessionTabs: () => [], - getActiveSessionTabId: () => null, + getActiveSessionTabId: () => 'terminal-tab', getActiveSessionTabType: () => 'terminal', switchSessionTab: vi.fn(), scheduleDelayedAction: vi.fn(), @@ -131,7 +132,12 @@ describe('useMobileFileTapHandlers', () => { expect(sendRequest).toHaveBeenCalledWith( 'files.resolveTerminalPath', - { worktree: 'id:wt-1', pathText: 'mobile/src/x.ts', crossWorkspace: true }, + { + worktree: 'id:wt-1', + pathText: 'mobile/src/x.ts', + crossWorkspace: true, + nativeChatContext: { tabId: 'terminal-tab', sessionId: 'session-1' } + }, { timeoutMs: 10_000 } ) expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12") diff --git a/mobile/src/session/use-mobile-file-tap-handlers.ts b/mobile/src/session/use-mobile-file-tap-handlers.ts index 276be5b566f..67995115f45 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.ts @@ -10,6 +10,7 @@ type MobileFileTapHandlerOptions = { hostId: string worktreeId: string worktreeName?: string + nativeChatSessionId: string | null activeHandleRef: MutableRefObject terminalCwdRef: MutableRefObject> openBrowser: (url: string) => void @@ -48,6 +49,7 @@ export function useMobileFileTapHandlers( getActiveSessionTabType, getSessionTabs, hostId, + nativeChatSessionId, openBrowser, scheduleDelayedAction, reportChatTapFailure, @@ -71,6 +73,7 @@ export function useMobileFileTapHandlers( getActiveSessionTabType, getSessionTabs, hostId, + nativeChatSessionId, openBrowser, scheduleDelayedAction, reportChatTapFailure, @@ -87,6 +90,7 @@ export function useMobileFileTapHandlers( getActiveSessionTabType, getSessionTabs, hostId, + nativeChatSessionId, openBrowser, router, scheduleDelayedAction, @@ -144,12 +148,18 @@ export function useMobileFileTapHandlers( return } const activationSeq = ++activationSeqRef.current + const nativeChatSessionId = current.nativeChatSessionId + const nativeChatTabId = current.getActiveSessionTabId() openMobileNativeChatFileTap({ client: current.client, hostId: current.hostId, worktreeId: current.worktreeId, worktreeName: current.worktreeName, pathText, + nativeChatContext: + nativeChatSessionId && nativeChatTabId + ? { tabId: nativeChatTabId, sessionId: nativeChatSessionId } + : null, pushPreviewRoute: (href) => routerRef.current.push(href), openBrowser: current.openBrowser, triggerOpenFeedback: triggerSelection, 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 index ce282fcd5b1..47b3d982095 100644 --- a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts @@ -8,10 +8,25 @@ describe('useMobileNativeChatTerminalStream', () => { let harnessRenderCount = 0 const subscriptionsRef = { current: new Map void>() } const subscribingRef = { current: new Set() } + const leaseOnlyRef = { 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)) + /** Mirrors the route's subscribe-time coverage guess, which is what decides whether + * the host opens a lease-only stream or a streaming one. */ + let subscribeOpensLeaseOnly = false + const registerSubscription = (handle: string): void => { + subscriptionsRef.current.set(handle, () => {}) + if (subscribeOpensLeaseOnly) { + leaseOnlyRef.current.add(handle) + } else { + leaseOnlyRef.current.delete(handle) + } + } + const subscribe = vi.fn(registerSubscription) + const unsubscribe = vi.fn((handle: string) => { + subscriptionsRef.current.delete(handle) + leaseOnlyRef.current.delete(handle) + }) const notifyWebReadyRef = { current: (_handle: string, _wasAlreadyReady: boolean): void => {} } const notifyListedHandlesRef = { current: (_liveHandles: ReadonlySet): void => {} } const hasTabsRecoveryNeedRef = { current: (): boolean => false } @@ -19,6 +34,8 @@ describe('useMobileNativeChatTerminalStream', () => { beforeEach(() => { subscriptionsRef.current = new Map([['terminal-1', () => {}]]) subscribingRef.current = new Set() + leaseOnlyRef.current = new Set() + subscribeOpensLeaseOnly = false webReadyRef.current = new Set(['terminal-1']) initializedRef.current = new Set(['terminal-1']) harnessRenderCount = 0 @@ -51,6 +68,7 @@ describe('useMobileNativeChatTerminalStream', () => { streamRevision, subscriptionsRef, subscribingRef, + leaseOnlyRef, webReadyRef, initializedRef, subscribe, @@ -107,6 +125,111 @@ describe('useMobileNativeChatTerminalStream', () => { expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') }) + /** What switchTab does synchronously on the tap, before the route re-renders: the + * coverage it reads still describes the chat tab being left, so the incoming + * terminal gets a lease-only subscribe — the shape that renders nothing. */ + function handOffToTerminalTwo(): void { + subscribeOpensLeaseOnly = true + subscribe('terminal-2') + subscribeOpensLeaseOnly = false + subscribe.mockClear() + unsubscribe.mockClear() + } + + it('trades a lease-only stream for output when a chat tab hands off to a terminal tab', async () => { + webReadyRef.current.add('terminal-2') + initializedRef.current.add('terminal-2') + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + handOffToTerminalTwo() + + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: false, activeHandle: 'terminal-2' }) + ) + }) + + expect(unsubscribe).toHaveBeenCalledWith('terminal-2') + expect(subscribe).toHaveBeenCalledWith('terminal-2') + expect(leaseOnlyRef.current.has('terminal-2')).toBe(false) + // The lease-only stream carried no scrollback, so xterm is empty — a stale + // initialized mark would drop the replacement snapshot and keep the tab blank. + expect(initializedRef.current.has('terminal-2')).toBe(false) + }) + + it('settles once a lease-only stream has been replaced by a full one', async () => { + webReadyRef.current.add('terminal-2') + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + handOffToTerminalTwo() + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: false, activeHandle: 'terminal-2' }) + ) + }) + subscribe.mockClear() + unsubscribe.mockClear() + + await act(async () => { + renderer?.update( + createElement(Harness, { + showNativeChat: false, + activeHandle: 'terminal-2', + streamRevision: 1 + }) + ) + }) + + // A stream that renders is settled — resubscribing it again would be a loop. + expect(subscribe).not.toHaveBeenCalled() + expect(unsubscribe).not.toHaveBeenCalled() + }) + + it('repairs a lease-only stream once its WebView reports ready', async () => { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + handOffToTerminalTwo() + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: false, activeHandle: 'terminal-2' }) + ) + }) + + // Cold WebView: the resume must wait, and the route's own web-ready path bails + // because the lease-only subscription already counts as live. + expect(subscribe).not.toHaveBeenCalled() + + await act(async () => { + webReadyRef.current.add('terminal-2') + notifyWebReadyRef.current('terminal-2', false) + }) + + expect(unsubscribe).toHaveBeenCalledWith('terminal-2') + expect(subscribe).toHaveBeenCalledWith('terminal-2') + }) + + it('keeps a covered lease-only stream instead of trading it for output (#10681)', async () => { + subscribeOpensLeaseOnly = true + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + unsubscribe.mockClear() + + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: true, streamRevision: 1 })) + }) + + // Lease-only is the correct shape under chat; swapping it for output would drop + // the input floor the composer holds. + expect(subscribe).not.toHaveBeenCalled() + expect(unsubscribe).not.toHaveBeenCalled() + expect(leaseOnlyRef.current.has('terminal-1')).toBe(true) + }) + it('re-subscribes a covered stream torn down under chat (#10681)', async () => { await act(async () => { renderer = create(createElement(Harness, { showNativeChat: true })) @@ -174,9 +297,7 @@ describe('useMobileNativeChatTerminalStream', () => { expect(subscribe).toHaveBeenCalledTimes(5) } finally { - subscribe.mockImplementation((handle: string) => - subscriptionsRef.current.set(handle, () => {}) - ) + subscribe.mockImplementation(registerSubscription) } }) diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts index 0f0a642a149..b5663b8dc8a 100644 --- a/mobile/src/session/use-mobile-native-chat-terminal-stream.ts +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts @@ -23,6 +23,8 @@ export function useMobileNativeChatTerminalStream(args: { streamRevision: number subscriptionsRef: MutableRefObject void>> subscribingRef: MutableRefObject> + /** Handles whose current subscribe went out as an input lease only. */ + leaseOnlyRef: MutableRefObject> webReadyRef: MutableRefObject> initializedRef: MutableRefObject> subscribe: (handle: string) => void @@ -55,13 +57,21 @@ export function useMobileNativeChatTerminalStream(args: { healthyStreamProofRef.current = null } }, []) - 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) - } - }, []) + 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. + // A lease-only stream counts: the route's own web-ready path bails on any live + // subscription, so nothing else would ever trade that lease for output. + if ( + !wasAlreadyReady && + (coveredHandleRef.current === handle || args.leaseOnlyRef.current.has(handle)) + ) { + setWebReadyRevision((revision) => revision + 1) + } + }, + [args.leaseOnlyRef] + ) const notifyListedHandles = useCallback( (liveHandles: ReadonlySet) => { // Why: the rearm budget bounds one teardown, not the handle's lifetime. A covered @@ -106,7 +116,7 @@ export function useMobileNativeChatTerminalStream(args: { (rearmAttemptsRef.current.get(handle) ?? 0) >= MAX_REARM_ATTEMPTS ) }, []) - useEffect(() => { + const reconcileStream = useCallback(() => { const handle = args.activeHandle if (coveredHandleRef.current && coveredHandleRef.current !== handle) { forgetRearmState(coveredHandleRef.current) @@ -115,12 +125,15 @@ export function useMobileNativeChatTerminalStream(args: { const streamActive = handle != null && (args.subscriptionsRef.current.has(handle) || args.subscribingRef.current.has(handle)) + const streamIsLeaseOnly = + streamActive && handle != null && args.leaseOnlyRef.current.has(handle) const action = resolveMobileNativeChatTerminalStreamAction({ showNativeChat: args.showNativeChat, activeHandle: handle, activeTabType: args.activeTabType, streamActive, streamCovered: coveredHandleRef.current === handle, + streamIsLeaseOnly, webViewReady: handle != null && args.webReadyRef.current.has(handle) }) const streamHolding = action === 'none' && streamActive && coveredHandleRef.current === handle @@ -181,7 +194,11 @@ export function useMobileNativeChatTerminalStream(args: { args.subscribe(handle) return } - if (coveredHandleRef.current === handle) { + if (coveredHandleRef.current === handle || streamIsLeaseOnly) { + // Why clear `initialized`: a lease-only stream delivered no scrollback, so xterm + // holds nothing — a stale mark would drop the replacement snapshot and the + // terminal would stay blank through the resubscribe. + args.initializedRef.current.delete(handle) args.unsubscribe(handle) coveredHandleRef.current = null } @@ -190,6 +207,7 @@ export function useMobileNativeChatTerminalStream(args: { args.activeHandle, args.activeTabType, args.initializedRef, + args.leaseOnlyRef, args.leaseReady, args.showNativeChat, args.streamRevision, @@ -203,6 +221,7 @@ export function useMobileNativeChatTerminalStream(args: { rearmBudgetRevision, webReadyRevision ]) + useEffect(() => reconcileStream(), [reconcileStream]) useEffect(() => cancelHealthyStreamProof, [cancelHealthyStreamProof]) // Why memoized: the session route keeps this object in callback dep arrays, and a // fresh literal per render would rebuild them on every keystroke. All three members diff --git a/mobile/src/source-control/mobile-create-pr-action.test.ts b/mobile/src/source-control/mobile-create-pr-action.test.ts index c665b034f7f..ec399b45913 100644 --- a/mobile/src/source-control/mobile-create-pr-action.test.ts +++ b/mobile/src/source-control/mobile-create-pr-action.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { HostedReviewCreationBlockedReason, - HostedReviewCreationEligibility, - HostedReviewProvider + HostedReviewCreationEligibility } from '../../../src/shared/hosted-review' import { buildMobileCreatePrAction, @@ -146,7 +145,7 @@ describe('buildMobileCreatePrAction', () => { it('keeps a disabled status row for providers without review creation', () => { const { descriptor } = action({ - eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider }) + eligibility: eligibility({ provider: 'unsupported' }) }) expect(descriptor).toMatchObject({ @@ -211,7 +210,7 @@ describe('cold-mount layout stability (issue #8411)', () => { 'unsupported provider', { kind: 'ready', - eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider }) + eligibility: eligibility({ provider: 'unsupported' }) } satisfies MobileCreatePrEligibilityState ], ['eligibility error', { kind: 'error' } satisfies MobileCreatePrEligibilityState] diff --git a/mobile/src/source-control/mobile-create-pr-action.ts b/mobile/src/source-control/mobile-create-pr-action.ts index fe75cc60a58..3268a1796c3 100644 --- a/mobile/src/source-control/mobile-create-pr-action.ts +++ b/mobile/src/source-control/mobile-create-pr-action.ts @@ -72,7 +72,7 @@ export function buildMobileCreatePrAction({ } // Why: mirror desktop's structural provider gate (supportsHostedReviewCreation) // instead of relying on the host always emitting a hidden blockedReason for - // non-creatable providers like bitbucket. + // non-creatable providers. if (!supportsHostedReviewCreation(eligibility.provider)) { return { visible: true, diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts index ac2a8ea0ff3..8e7545a4cfb 100644 --- a/mobile/src/terminal/terminal-path-tap.ts +++ b/mobile/src/terminal/terminal-path-tap.ts @@ -3,11 +3,12 @@ // one path span containing the tapped column — mobile opens a tapped path, it // does not render hover links over the whole line. -export type TappedFilePath = { - pathText: string - line: number | null - column: number | null -} +import { + parseFileLinkLocation, + type ParsedFileLinkLocation +} from '../../../src/shared/file-link-location' + +export type TappedFilePath = ParsedFileLinkLocation // Separator-anchored path tokens (absolute, relative, ~/, drive-letter, UNC) OR // a bare filename with an extension (README.md, index.ts), optionally suffixed @@ -47,18 +48,13 @@ function trimBoundaryPunctuation( } export function parsePathWithOptionalLineColumn(value: string): TappedFilePath | null { - const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value) - if (!match) { + const parsed = parseFileLinkLocation(value) + if (!parsed) { return null } - const pathText = match[1] + const { pathText, line, column } = parsed // Reject a directory-only token (trailing separator) for either slash style. - if (!pathText || pathText.endsWith('/') || pathText.endsWith('\\')) { - return null - } - const line = match[2] ? Number.parseInt(match[2], 10) : null - const column = match[3] ? Number.parseInt(match[3], 10) : null - if ((line !== null && line < 1) || (column !== null && column < 1)) { + if (pathText.endsWith('/') || pathText.endsWith('\\')) { return null } return { pathText, line, column } diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 99421ffd192..c32ac44ac77 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -182,7 +182,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise } } let next: StoredHostProfile[] - if (index >= 0) { + if (index !== -1) { updatedExistingHost = true // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id. next = hosts @@ -195,7 +195,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise next = [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] } if (duplicateHostIds.size > 0) { - if (index < 0) { + if (index === -1) { // Why: process death between the early token write and metadata publication must leave cleanup discoverable. await recordHostCredentialCleanupIntent(stored.id) cleanupIntentRecordedBeforeMetadata = true @@ -304,7 +304,7 @@ export async function updateHostNameAndEndpoint( ): Promise { await mutateStoredHosts((hosts) => { const index = hosts.findIndex((host) => host.id === hostId) - if (index < 0) { + if (index === -1) { throw new Error('Host not found') } const next = hosts.slice() @@ -321,7 +321,7 @@ export async function updateLastConnected(hostId: string): Promise { try { await mutateStoredHosts((hosts) => { const index = hosts.findIndex((h) => h.id === hostId) - if (index < 0) { + if (index === -1) { return hosts } const next = hosts.slice() diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.ts b/mobile/src/transport/mobile-relay-host-overlay-store.ts index 7f1519eb5c9..4960fbec3f0 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.ts @@ -80,7 +80,7 @@ export async function saveMobileRelayHostOverlay(overlay: MobileRelayHostOverlay const validated = MobileRelayHostOverlaySchema.parse(overlay) return mutateOverlays((overlays) => { const index = overlays.findIndex(({ hostId }) => hostId === validated.hostId) - if (index < 0) { + if (index === -1) { return [...overlays, validated] } const next = overlays.slice() diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 9b0afe937f3..f316f8c2b76 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -54,6 +54,7 @@ type ConnectWaiter = { export type SendRequestOptions = { timeoutMs?: number + expectedRuntimeId?: string /** Spend `timeoutMs` across connect-wait AND the request instead of giving each * phase its own. Interactive chat writes need it: they run as sequential loops * under one shared budget, so a per-phase clock lets the composer sit `sending` @@ -1013,7 +1014,15 @@ export function connect( } }) - if (!sendEncrypted({ id, deviceToken, method, params })) { + if ( + !sendEncrypted({ + id, + deviceToken, + method, + params, + ...(options?.expectedRuntimeId ? { expectedRuntimeId: options.expectedRuntimeId } : {}) + }) + ) { pending.delete(id) clearTimeout(timeout) reject(new Error('Connection interrupted')) diff --git a/mobile/src/transport/rpc-response-shape.ts b/mobile/src/transport/rpc-response-shape.ts index 54903f5f3e1..61ecaf57362 100644 --- a/mobile/src/transport/rpc-response-shape.ts +++ b/mobile/src/transport/rpc-response-shape.ts @@ -13,7 +13,7 @@ export function isRpcResponse(value: unknown): value is RpcResponse { return false } if (response.ok === true) { - return Object.prototype.hasOwnProperty.call(response, 'result') + return Object.hasOwn(response, 'result') } return ( response.ok === false && diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 9d96f0561ad..8fe2bdd78f9 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -18,6 +18,7 @@ export type RpcRequest = { deviceToken: string method: string params?: unknown + expectedRuntimeId?: string } export type RpcSuccess = { diff --git a/package.json b/package.json index 83cd7597e5f..1c2c1deb2df 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,8 @@ "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs", @@ -217,7 +219,7 @@ "mermaid": "^11.16.1", "monaco-editor": "^0.55.1", "oxfmt": "^0.52.0", - "oxlint": "^1.75.0", + "oxlint": "^1.77.0", "oxlint-plugin-react-doctor": "0.9.1", "oxlint-tsgolint": "7.0.2001", "pdfjs-dist": "^6.2.108", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1872f9860e8..57885d16e25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,8 +294,8 @@ importers: specifier: ^0.52.0 version: 0.52.0 oxlint: - specifier: ^1.75.0 - version: 1.75.0(oxlint-tsgolint@7.0.2001) + specifier: ^1.77.0 + version: 1.77.0(oxlint-tsgolint@7.0.2001) oxlint-plugin-react-doctor: specifier: 0.9.1 version: 0.9.1 @@ -1488,124 +1488,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.75.0': - resolution: {integrity: sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==} + '@oxlint/binding-android-arm-eabi@1.77.0': + resolution: {integrity: sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.75.0': - resolution: {integrity: sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==} + '@oxlint/binding-android-arm64@1.77.0': + resolution: {integrity: sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.75.0': - resolution: {integrity: sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==} + '@oxlint/binding-darwin-arm64@1.77.0': + resolution: {integrity: sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.75.0': - resolution: {integrity: sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==} + '@oxlint/binding-darwin-x64@1.77.0': + resolution: {integrity: sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.75.0': - resolution: {integrity: sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==} + '@oxlint/binding-freebsd-x64@1.77.0': + resolution: {integrity: sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.75.0': - resolution: {integrity: sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==} + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': + resolution: {integrity: sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.75.0': - resolution: {integrity: sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==} + '@oxlint/binding-linux-arm-musleabihf@1.77.0': + resolution: {integrity: sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.75.0': - resolution: {integrity: sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==} + '@oxlint/binding-linux-arm64-gnu@1.77.0': + resolution: {integrity: sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.75.0': - resolution: {integrity: sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==} + '@oxlint/binding-linux-arm64-musl@1.77.0': + resolution: {integrity: sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.75.0': - resolution: {integrity: sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==} + '@oxlint/binding-linux-ppc64-gnu@1.77.0': + resolution: {integrity: sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.75.0': - resolution: {integrity: sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==} + '@oxlint/binding-linux-riscv64-gnu@1.77.0': + resolution: {integrity: sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.75.0': - resolution: {integrity: sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==} + '@oxlint/binding-linux-riscv64-musl@1.77.0': + resolution: {integrity: sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.75.0': - resolution: {integrity: sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==} + '@oxlint/binding-linux-s390x-gnu@1.77.0': + resolution: {integrity: sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.75.0': - resolution: {integrity: sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==} + '@oxlint/binding-linux-x64-gnu@1.77.0': + resolution: {integrity: sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.75.0': - resolution: {integrity: sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==} + '@oxlint/binding-linux-x64-musl@1.77.0': + resolution: {integrity: sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.75.0': - resolution: {integrity: sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==} + '@oxlint/binding-openharmony-arm64@1.77.0': + resolution: {integrity: sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.75.0': - resolution: {integrity: sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==} + '@oxlint/binding-win32-arm64-msvc@1.77.0': + resolution: {integrity: sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.75.0': - resolution: {integrity: sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==} + '@oxlint/binding-win32-ia32-msvc@1.77.0': + resolution: {integrity: sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.75.0': - resolution: {integrity: sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==} + '@oxlint/binding-win32-x64-msvc@1.77.0': + resolution: {integrity: sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -5541,8 +5541,8 @@ packages: resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true - oxlint@1.75.0: - resolution: {integrity: sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==} + oxlint@1.77.0: + resolution: {integrity: sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -7809,61 +7809,61 @@ snapshots: '@oxlint-tsgolint/win32-x64@7.0.2001': optional: true - '@oxlint/binding-android-arm-eabi@1.75.0': + '@oxlint/binding-android-arm-eabi@1.77.0': optional: true - '@oxlint/binding-android-arm64@1.75.0': + '@oxlint/binding-android-arm64@1.77.0': optional: true - '@oxlint/binding-darwin-arm64@1.75.0': + '@oxlint/binding-darwin-arm64@1.77.0': optional: true - '@oxlint/binding-darwin-x64@1.75.0': + '@oxlint/binding-darwin-x64@1.77.0': optional: true - '@oxlint/binding-freebsd-x64@1.75.0': + '@oxlint/binding-freebsd-x64@1.77.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.75.0': + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.75.0': + '@oxlint/binding-linux-arm-musleabihf@1.77.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.75.0': + '@oxlint/binding-linux-arm64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.75.0': + '@oxlint/binding-linux-arm64-musl@1.77.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.75.0': + '@oxlint/binding-linux-ppc64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.75.0': + '@oxlint/binding-linux-riscv64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.75.0': + '@oxlint/binding-linux-riscv64-musl@1.77.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.75.0': + '@oxlint/binding-linux-s390x-gnu@1.77.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.75.0': + '@oxlint/binding-linux-x64-gnu@1.77.0': optional: true - '@oxlint/binding-linux-x64-musl@1.75.0': + '@oxlint/binding-linux-x64-musl@1.77.0': optional: true - '@oxlint/binding-openharmony-arm64@1.75.0': + '@oxlint/binding-openharmony-arm64@1.77.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.75.0': + '@oxlint/binding-win32-arm64-msvc@1.77.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.75.0': + '@oxlint/binding-win32-ia32-msvc@1.77.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.75.0': + '@oxlint/binding-win32-x64-msvc@1.77.0': optional: true '@parcel/watcher-android-arm64@2.5.6': @@ -12174,27 +12174,27 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.75.0(oxlint-tsgolint@7.0.2001): + oxlint@1.77.0(oxlint-tsgolint@7.0.2001): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.75.0 - '@oxlint/binding-android-arm64': 1.75.0 - '@oxlint/binding-darwin-arm64': 1.75.0 - '@oxlint/binding-darwin-x64': 1.75.0 - '@oxlint/binding-freebsd-x64': 1.75.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.75.0 - '@oxlint/binding-linux-arm-musleabihf': 1.75.0 - '@oxlint/binding-linux-arm64-gnu': 1.75.0 - '@oxlint/binding-linux-arm64-musl': 1.75.0 - '@oxlint/binding-linux-ppc64-gnu': 1.75.0 - '@oxlint/binding-linux-riscv64-gnu': 1.75.0 - '@oxlint/binding-linux-riscv64-musl': 1.75.0 - '@oxlint/binding-linux-s390x-gnu': 1.75.0 - '@oxlint/binding-linux-x64-gnu': 1.75.0 - '@oxlint/binding-linux-x64-musl': 1.75.0 - '@oxlint/binding-openharmony-arm64': 1.75.0 - '@oxlint/binding-win32-arm64-msvc': 1.75.0 - '@oxlint/binding-win32-ia32-msvc': 1.75.0 - '@oxlint/binding-win32-x64-msvc': 1.75.0 + '@oxlint/binding-android-arm-eabi': 1.77.0 + '@oxlint/binding-android-arm64': 1.77.0 + '@oxlint/binding-darwin-arm64': 1.77.0 + '@oxlint/binding-darwin-x64': 1.77.0 + '@oxlint/binding-freebsd-x64': 1.77.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.77.0 + '@oxlint/binding-linux-arm-musleabihf': 1.77.0 + '@oxlint/binding-linux-arm64-gnu': 1.77.0 + '@oxlint/binding-linux-arm64-musl': 1.77.0 + '@oxlint/binding-linux-ppc64-gnu': 1.77.0 + '@oxlint/binding-linux-riscv64-gnu': 1.77.0 + '@oxlint/binding-linux-riscv64-musl': 1.77.0 + '@oxlint/binding-linux-s390x-gnu': 1.77.0 + '@oxlint/binding-linux-x64-gnu': 1.77.0 + '@oxlint/binding-linux-x64-musl': 1.77.0 + '@oxlint/binding-openharmony-arm64': 1.77.0 + '@oxlint/binding-win32-arm64-msvc': 1.77.0 + '@oxlint/binding-win32-ia32-msvc': 1.77.0 + '@oxlint/binding-win32-x64-msvc': 1.77.0 oxlint-tsgolint: 7.0.2001 p-cancelable@2.1.1: {} diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index bc7b1cbbe3e..20b70a15b63 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -195,7 +195,7 @@ orca orchestration worker-start --task --worktree current --agent claud For a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt: ```bash -orca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json +orca orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json ``` `--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option. diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 487c9152b89..e0a65fc876f 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nname=\"orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}\" # sanitize+cap to 63 chars\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/cli/handlers/emulator.test.ts b/src/cli/handlers/emulator.test.ts index 07bdd51e2f5..ff0dc5f141a 100644 --- a/src/cli/handlers/emulator.test.ts +++ b/src/cli/handlers/emulator.test.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { callMock, remoteMock } = vi.hoisted(() => ({ callMock: vi.fn(), @@ -29,6 +29,9 @@ import { main } from '../index' import { okFixture, queueFixtures } from '../test-fixtures' describe('orca emulator CLI handlers', () => { + const originalWorkspaceId = process.env.ORCA_WORKSPACE_ID + const originalWorktreeId = process.env.ORCA_WORKTREE_ID + beforeEach(() => { vi.restoreAllMocks() callMock.mockReset() @@ -38,6 +41,19 @@ describe('orca emulator CLI handlers', () => { process.exitCode = undefined }) + afterEach(() => { + if (originalWorkspaceId === undefined) { + delete process.env.ORCA_WORKSPACE_ID + } else { + process.env.ORCA_WORKSPACE_ID = originalWorkspaceId + } + if (originalWorktreeId === undefined) { + delete process.env.ORCA_WORKTREE_ID + } else { + process.env.ORCA_WORKTREE_ID = originalWorktreeId + } + }) + it('resolves relative APK paths before calling the runtime', async () => { queueFixtures(callMock, okFixture('req_install', { ok: true })) @@ -73,6 +89,46 @@ describe('orca emulator CLI handlers', () => { ) }) + it('uses the folder workspace exported by the current Orca terminal', async () => { + process.env.ORCA_WORKSPACE_ID = 'folder:folder-1' + delete process.env.ORCA_WORKTREE_ID + callMock.mockResolvedValue( + okFixture('req_attach', { + attached: true, + info: { deviceUdid: 'device-1', streamUrl: 'scrcpy://device-1' } + }) + ) + + await main(['emulator', 'attach', 'device-1'], '/folder/project') + + expect(callMock).toHaveBeenCalledOnce() + expect(callMock).toHaveBeenCalledWith( + 'emulator.attach', + { device: 'device-1', worktree: 'folder:folder-1', focus: false }, + { timeoutMs: 180_000 } + ) + }) + + it('uses the current git worktree exported by the Orca terminal', async () => { + process.env.ORCA_WORKSPACE_ID = 'folder:stale-parent' + process.env.ORCA_WORKTREE_ID = 'repo-1::/repo/project ' + callMock.mockResolvedValue( + okFixture('req_attach', { + attached: true, + info: { deviceUdid: 'device-1', streamUrl: 'scrcpy://device-1' } + }) + ) + + await main(['emulator', 'attach', 'device-1'], '/repo/project') + + expect(callMock).toHaveBeenCalledOnce() + expect(callMock).toHaveBeenCalledWith( + 'emulator.attach', + { device: 'device-1', worktree: 'repo-1::/repo/project ', focus: false }, + { timeoutMs: 180_000 } + ) + }) + it('rejects relative APK paths for remote runtimes', async () => { remoteMock.mockReturnValue(true) diff --git a/src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts b/src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts new file mode 100644 index 00000000000..a7b1766437e --- /dev/null +++ b/src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts @@ -0,0 +1,51 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { reportCliError } from '../format' +import { ORCHESTRATION_HANDLERS } from './orchestration' + +afterEach(() => { + vi.restoreAllMocks() +}) + +it('prints a rejected lifecycle verdict as a JSON failure envelope', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const client = { + call: vi.fn().mockResolvedValue({ + result: { + message: { id: 'msg_rejected' }, + lifecycle: { + action: 'rejected', + code: 'sender_not_assignee', + reason: 'dispatch ctx_1 expected the assigned pane' + } + } + }) + } + let rejection: unknown + try { + await ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_foreign'], + ['to', 'term_coord'], + ['subject', 'done'], + ['type', 'worker_done'], + ['outcome', 'succeeded'] + ]), + client, + cwd: '/tmp/repo', + json: true + } as never) + } catch (error) { + rejection = error + } + + expect(rejection).toBeDefined() + reportCliError(rejection, true, { commandPath: ['orchestration', 'send'] }) + + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ + ok: false, + error: { + code: 'sender_not_assignee', + message: 'dispatch ctx_1 expected the assigned pane' + } + }) +}) diff --git a/src/cli/handlers/orchestration-lifecycle-rejection.test.ts b/src/cli/handlers/orchestration-lifecycle-rejection.test.ts index c55eed4391d..369edf9c4f6 100644 --- a/src/cli/handlers/orchestration-lifecycle-rejection.test.ts +++ b/src/cli/handlers/orchestration-lifecycle-rejection.test.ts @@ -10,9 +10,11 @@ import { ORCHESTRATION_HANDLERS } from './orchestration' afterEach(() => { process.exitCode = originalExitCode + vi.clearAllMocks() + callMock.mockReset() }) -it('prints a lifecycle rejection and exits unsuccessfully', async () => { +it('raises a lifecycle rejection for the CLI error boundary', async () => { const response = { result: { message: { id: 'msg_rejected' }, @@ -25,22 +27,328 @@ it('prints a lifecycle rejection and exits unsuccessfully', async () => { } callMock.mockResolvedValueOnce(response) + await expect( + ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_foreign'], + ['to', 'term_coord'], + ['subject', 'done'], + ['type', 'worker_done'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: false + } as never) + ).rejects.toMatchObject({ + code: 'sender_not_assignee', + message: 'dispatch ctx_1 expected the assigned pane' + }) + expect(printResult).not.toHaveBeenCalled() +}) + +it('raises a Run-home relay rejection for the CLI error boundary', async () => { + callMock.mockResolvedValueOnce({ + result: { + relay: { + messageId: 'relay_rejected', + sequence: 1, + dispatchId: 'ctx_1', + destination: 'run_home', + accepted: true + }, + lifecycle: { + action: 'rejected', + code: 'task_dispatch_mismatch', + reason: 'wrong task', + authority: 'run_home' + } + } + }) + + await expect( + ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'done'], + ['type', 'worker_done'], + ['task-id', 'task_wrong'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + ).rejects.toMatchObject({ code: 'task_dispatch_mismatch', message: 'wrong task' }) + expect(printResult).not.toHaveBeenCalled() +}) + +it('accepts an explicit current settlement without a compatibility read', async () => { + callMock.mockResolvedValueOnce({ + result: { message: { id: 'msg_done' }, lifecycle: { action: 'completed' } } + }) + await ORCHESTRATION_HANDLERS['orchestration send']({ flags: new Map([ - ['from', 'term_foreign'], - ['to', 'term_coord'], + ['from', 'term_worker'], ['subject', 'done'], ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], ['outcome', 'succeeded'] ]), client: { call: callMock }, cwd: '/tmp/repo', - json: false + json: true } as never) - expect(process.exitCode).toBe(1) - const formatter = vi.mocked(printResult).mock.calls[0]?.[2] - expect(formatter?.(response.result)).toBe( - 'Rejected msg_rejected: dispatch ctx_1 expected the assigned pane' - ) + expect(callMock).toHaveBeenCalledOnce() +}) + +it('fails closed when an older runtime leaves worker_done dispatched without a verdict', async () => { + callMock + .mockResolvedValueOnce({ + result: { message: { id: 'msg_unconfirmed', run_id: 'run_1' } } + }) + .mockResolvedValueOnce({ + result: { dispatch: { id: 'ctx_1', status: 'dispatched' } } + }) + .mockResolvedValueOnce({ result: { tasks: [] } }) + + await expect( + ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_foreign'], + ['subject', 'done'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + ).rejects.toMatchObject({ code: 'operation_unknown' }) + + expect(callMock).toHaveBeenNthCalledWith(2, 'orchestration.dispatchShow', { task: 'task_1' }) + expect(callMock).toHaveBeenNthCalledWith(3, 'orchestration.taskList', { + status: 'completed', + run: 'run_1' + }) +}) + +it('normalizes compatibility-read failures to operation_unknown', async () => { + callMock + .mockResolvedValueOnce({ + result: { message: { id: 'msg_unconfirmed', run_id: 'run_1' } } + }) + .mockRejectedValueOnce(new Error('runtime disconnected')) + .mockResolvedValueOnce({ result: { tasks: [] } }) + + await expect( + ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'done'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + ).rejects.toMatchObject({ code: 'operation_unknown' }) +}) + +it('accepts a legacy response only after the authoritative dispatch is terminal', async () => { + callMock + .mockResolvedValueOnce({ + result: { message: { id: 'msg_legacy_completed', run_id: 'run_1' } } + }) + .mockResolvedValueOnce({ + result: { dispatch: { id: 'ctx_1', status: 'completed' } } + }) + .mockResolvedValueOnce({ + result: { + tasks: [ + { + id: 'task_1', + status: 'completed', + result: JSON.stringify({ + provenance: 'worker_report', + messageId: 'msg_legacy_completed', + outcome: 'succeeded' + }) + } + ] + } + }) + + await ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'done'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + expect(callMock).toHaveBeenCalledTimes(3) + expect(callMock).toHaveBeenNthCalledWith(2, 'orchestration.dispatchShow', { task: 'task_1' }) +}) + +it('accepts a legacy failed report only after the authoritative dispatch failed', async () => { + callMock + .mockResolvedValueOnce({ + result: { message: { id: 'msg_legacy_failed', run_id: 'run_1' } } + }) + .mockResolvedValueOnce({ result: { dispatch: { id: 'ctx_1', status: 'failed' } } }) + .mockResolvedValueOnce({ + result: { + tasks: [ + { + id: 'task_1', + status: 'failed', + result: JSON.stringify({ + provenance: 'worker_report', + messageId: 'msg_legacy_failed', + outcome: 'failed' + }) + } + ] + } + }) + + await ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'failed'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'failed'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + expect(callMock).toHaveBeenCalledTimes(3) +}) + +it('accepts an idempotent retry whose first report already settled', async () => { + callMock + .mockResolvedValueOnce({ + result: { + message: { id: 'msg_retry', run_id: 'run_1', from_handle: 'term_worker' } + } + }) + .mockResolvedValueOnce({ result: { dispatch: { id: 'ctx_1', status: 'completed' } } }) + .mockResolvedValueOnce({ + result: { + tasks: [ + { + id: 'task_1', + status: 'completed', + result: JSON.stringify({ + provenance: 'worker_report', + messageId: 'msg_first', + reportedBy: 'term_worker', + outcome: 'succeeded' + }) + } + ] + } + }) + + await ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'done'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + expect(callMock).toHaveBeenCalledTimes(3) +}) + +it('rejects an unrelated failed Dispatch that left its Task blocked', async () => { + callMock + .mockResolvedValueOnce({ + result: { message: { id: 'msg_after_stop', run_id: 'run_1' } } + }) + .mockResolvedValueOnce({ result: { dispatch: { id: 'ctx_1', status: 'failed' } } }) + .mockResolvedValueOnce({ result: { tasks: [] } }) + + await expect( + ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'failed'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'failed'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + ).rejects.toMatchObject({ code: 'operation_unknown' }) +}) + +it('rejects a terminal Dispatch with the wrong identity', async () => { + callMock + .mockResolvedValueOnce({ + result: { message: { id: 'msg_stale', run_id: 'run_1' } } + }) + .mockResolvedValueOnce({ + result: { dispatch: { id: 'ctx_replacement', status: 'completed' } } + }) + .mockResolvedValueOnce({ + result: { + tasks: [ + { + id: 'task_1', + status: 'completed', + result: JSON.stringify({ + provenance: 'worker_report', + messageId: 'msg_stale', + outcome: 'succeeded' + }) + } + ] + } + }) + + await expect( + ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'done'], + ['type', 'worker_done'], + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + ).rejects.toMatchObject({ code: 'operation_unknown' }) }) diff --git a/src/cli/handlers/orchestration-migration.test.ts b/src/cli/handlers/orchestration-migration.test.ts index a1a117b90b3..863720eaab1 100644 --- a/src/cli/handlers/orchestration-migration.test.ts +++ b/src/cli/handlers/orchestration-migration.test.ts @@ -12,33 +12,43 @@ afterEach(() => { }) describe('orchestration CLI migration recovery', () => { - it('forwards legacy worker_done without inventing a completion outcome', async () => { - process.env.ORCA_PANE_KEY = 'tab-worker:leaf-worker' - const call = vi.fn().mockResolvedValue({ result: { message: { id: 'msg_done' } } }) + it.each([false, true])( + 'accepts an attested legacy worker_done settlement (replayed: %s)', + async (replayed) => { + process.env.ORCA_PANE_KEY = 'tab-worker:leaf-worker' + const call = vi.fn().mockResolvedValue({ + result: { + message: { id: 'msg_done' }, + lifecycle: { action: 'settled', outcome: 'succeeded', duplicate: replayed } + } + }) - await ORCHESTRATION_HANDLERS['orchestration send']({ - flags: new Map([ - ['from', 'term_worker'], - ['subject', 'Done'], - ['type', 'worker_done'] - ]), - client: { call }, - cwd: '/tmp/repo', - json: true - } as never) + await ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'Done'], + ['type', 'worker_done'] + ]), + client: { call }, + cwd: '/tmp/repo', + json: true + } as never) - expect(call).toHaveBeenCalledWith('orchestration.send', { - from: 'term_worker', - to: undefined, - run: undefined, - subject: 'Done', - body: undefined, - type: 'worker_done', - priority: undefined, - threadId: undefined, - payload: undefined, - senderPaneKey: 'tab-worker:leaf-worker', - devMode: false - }) - }) + expect(call).toHaveBeenCalledWith('orchestration.send', { + from: 'term_worker', + to: undefined, + run: undefined, + subject: 'Done', + body: undefined, + type: 'worker_done', + priority: undefined, + threadId: undefined, + payload: undefined, + senderPaneKey: 'tab-worker:leaf-worker', + waitForLifecycleSettlement: true, + devMode: false + }) + expect(call).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/cli/handlers/orchestration-worker-settlement.ts b/src/cli/handlers/orchestration-worker-settlement.ts new file mode 100644 index 00000000000..8b9425391ee --- /dev/null +++ b/src/cli/handlers/orchestration-worker-settlement.ts @@ -0,0 +1,147 @@ +import type { RuntimeClient } from '../runtime-client' +import { RuntimeClientError } from '../runtime-client' + +export async function requireWorkerDoneSettlement( + client: RuntimeClient, + type: string | undefined, + payload: string | undefined, + result: unknown +): Promise { + if (type !== 'worker_done' || hasLifecycleVerdict(result)) { + return + } + const target = parseWorkerDoneTarget(payload) + const receipt = parseWorkerDoneReceipt(result) + if (target && receipt) { + const [dispatchVerification, taskVerification] = await Promise.all([ + client.call<{ dispatch: { id: string; status: string } | null }>( + 'orchestration.dispatchShow', + { task: target.taskId } + ), + client.call<{ + tasks: { id: string; status: string; result: string | null }[] + }>('orchestration.taskList', { status: target.expectedStatus, run: receipt.runId }) + ]).catch(() => { + throw workerDoneSettlementUnknown() + }) + const task = taskVerification.result.tasks.find((candidate) => candidate.id === target.taskId) + if ( + dispatchVerification.result.dispatch?.id === target.dispatchId && + dispatchVerification.result.dispatch.status === target.expectedStatus && + task?.status === target.expectedStatus && + isExactWorkerReport(task.result, receipt, target.outcome) + ) { + return + } + } + throw workerDoneSettlementUnknown() +} + +function workerDoneSettlementUnknown(): RuntimeClientError { + return new RuntimeClientError( + 'operation_unknown', + 'The runtime accepted worker_done but did not confirm that the exact report settled its Task and Dispatch. Retry from the assigned worker after verifying its active Dispatch.' + ) +} + +function parseWorkerDoneReceipt( + result: unknown +): { messageId: string; runId: string; fromHandle?: string } | undefined { + if (!result || typeof result !== 'object' || !('message' in result)) { + return undefined + } + const message = (result as { message?: unknown }).message + if (!message || typeof message !== 'object') { + return undefined + } + const { + id, + run_id: runId, + from_handle: fromHandle + } = message as { + id?: unknown + run_id?: unknown + from_handle?: unknown + } + return typeof id === 'string' && typeof runId === 'string' + ? { + messageId: id, + runId, + ...(typeof fromHandle === 'string' ? { fromHandle } : {}) + } + : undefined +} + +function isExactWorkerReport( + result: string | null | undefined, + receipt: { messageId: string; fromHandle?: string }, + outcome: 'succeeded' | 'failed' +): boolean { + if (!result) { + return false + } + try { + const parsed = JSON.parse(result) as Record + return ( + parsed.provenance === 'worker_report' && + parsed.outcome === outcome && + (parsed.messageId === receipt.messageId || + (receipt.fromHandle !== undefined && parsed.reportedBy === receipt.fromHandle)) + ) + } catch { + return false + } +} + +function hasLifecycleVerdict(result: unknown): boolean { + if (!result || typeof result !== 'object' || !('lifecycle' in result)) { + return false + } + const lifecycle = (result as { lifecycle?: unknown }).lifecycle + if (!lifecycle || typeof lifecycle !== 'object') { + return false + } + const action = (lifecycle as { action?: unknown }).action + if ('relay' in result) { + return ( + (lifecycle as { authority?: unknown }).authority === 'run_home' && + (action === 'completed' || action === 'failed' || action === 'rejected') + ) + } + if (action === 'settled') { + const outcome = (lifecycle as { outcome?: unknown }).outcome + return outcome === 'succeeded' || outcome === 'failed' + } + return action === 'completed' || action === 'failed' || action === 'rejected' +} + +function parseWorkerDoneTarget(payload: string | undefined): + | { + taskId: string + dispatchId: string + outcome: 'succeeded' | 'failed' + expectedStatus: 'completed' | 'failed' + } + | undefined { + if (!payload) { + return undefined + } + try { + const parsed = JSON.parse(payload) as Record + if ( + typeof parsed.taskId !== 'string' || + typeof parsed.dispatchId !== 'string' || + (parsed.outcome !== 'succeeded' && parsed.outcome !== 'failed') + ) { + return undefined + } + return { + taskId: parsed.taskId, + dispatchId: parsed.dispatchId, + outcome: parsed.outcome, + expectedStatus: parsed.outcome === 'succeeded' ? 'completed' : 'failed' + } + } catch { + return undefined + } +} diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index ef7299efb43..c43f7b45b62 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -50,7 +50,7 @@ afterEach(() => { describe('orchestration send structured payload flags', () => { beforeEach(() => { - callMock.mockReset().mockResolvedValue({ result: { message: { id: 'msg_1' } } }) + callMock.mockReset().mockResolvedValue({ result: { lifecycle: { action: 'completed' } } }) getTerminalHandleMock.mockReset() delete process.env.ORCA_TERMINAL_HANDLE delete process.env.ORCA_PANE_KEY @@ -94,6 +94,7 @@ describe('orchestration send structured payload flags', () => { filesModified: ['src/a.ts', 'src/b.ts'], reportPath: 'reports/done.md' }), + waitForLifecycleSettlement: true, devMode: false }) }) @@ -207,6 +208,7 @@ describe('orchestration send structured payload flags', () => { priority: undefined, threadId: undefined, payload: JSON.stringify({ outcome: 'succeeded' }), + waitForLifecycleSettlement: true, devMode: false }) }) @@ -233,6 +235,7 @@ describe('orchestration send structured payload flags', () => { priority: undefined, threadId: undefined, payload: JSON.stringify({ outcome: 'succeeded' }), + waitForLifecycleSettlement: true, devMode: false }) }) diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 5be6e62394d..771b8367b88 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -8,6 +8,7 @@ import { getRequiredStringFlag } from '../flags' import { RuntimeClientError } from '../runtime-client' +import { requireWorkerDoneSettlement } from './orchestration-worker-settlement' import { getTerminalHandle } from '../selectors' import { clampOrchestrationAskTimeoutMs, @@ -90,14 +91,13 @@ const WORKER_TERMINAL_LIST_STATES = [ 'released' ] as const -type LifecycleSendRejection = { - action: 'rejected' - code: string - reason: string -} +type LifecycleSendResult = + | { action: 'completed' | 'failed'; authority?: 'run_home' } + | { action: 'settled'; outcome: 'succeeded' | 'failed'; duplicate?: boolean } + | { action: 'rejected'; code: string; reason: string } type OrchestrationSendResult = - | { message: { id: string }; lifecycle?: LifecycleSendRejection } + | { message: { id: string; run_id?: string }; lifecycle?: LifecycleSendResult } | { messages: { id: string }[]; recipients: number } | { relay: { @@ -107,7 +107,7 @@ type OrchestrationSendResult = destination?: 'run_home' | 'worker' accepted: true } - lifecycle?: { action: 'completed' | 'failed' } + lifecycle?: LifecycleSendResult } function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' { @@ -579,6 +579,7 @@ export const ORCHESTRATION_HANDLERS: Record = { payload: getOptionalStructuredMessagePayload(flags), // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it. senderPaneKey: process.env.ORCA_PANE_KEY || undefined, + waitForLifecycleSettlement: type === 'worker_done' ? true : undefined, devMode: isDevCliInvocation() } const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability') @@ -589,15 +590,12 @@ export const ORCHESTRATION_HANDLERS: Record = { sendParams, dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined ) - if ('message' in result.result && result.result.lifecycle?.action === 'rejected') { - // Why: a rejected lifecycle signal isn't completion; non-zero exit stops workers from treating it as such. - process.exitCode = 1 + await requireWorkerDoneSettlement(client, type, sendParams.payload, result.result) + if ('lifecycle' in result.result && result.result.lifecycle?.action === 'rejected') { + throw new RuntimeClientError(result.result.lifecycle.code, result.result.lifecycle.reason) } printResult(result, json, (r) => { if ('message' in r) { - if (r.lifecycle?.action === 'rejected') { - return `Rejected ${r.message.id}: ${r.lifecycle.reason}` - } return `Sent ${r.message.id}` } if ('relay' in r) { diff --git a/src/cli/handlers/terminal.test.ts b/src/cli/handlers/terminal.test.ts index 7dadf41c2d3..8c5ff460c77 100644 --- a/src/cli/handlers/terminal.test.ts +++ b/src/cli/handlers/terminal.test.ts @@ -61,3 +61,67 @@ describe('terminal close CLI', () => { expect(help).toContain('durable persistence') }) }) + +describe('terminal split CLI', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('sends the old-host attribution bypass through the legacy-compatible env field', async () => { + const call = vi.fn(async (method: string) => + method === 'status.get' + ? { + result: { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + } + } + : { result: { split: { handle: 'term-2', parentHandle: 'term-1' } } } + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal split']({ + flags: new Map([ + ['terminal', 'term-1'], + ['direction', 'horizontal'] + ]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + + expect(call).toHaveBeenNthCalledWith(1, 'status.get') + expect(call).toHaveBeenNthCalledWith( + 2, + 'terminal.split', + { + terminal: 'term-1', + direction: 'horizontal', + command: undefined, + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'] + }, + { expectedRuntimeId: 'runtime-1' } + ) + }) + + it('refuses legacy hosts because renderer-owned splits discard environment fields', async () => { + const call = vi.fn().mockResolvedValue({ + result: { appVersion: '1.4.181', capabilities: ['mobile.tasks.v1'] } + }) + + await expect( + TERMINAL_HANDLERS['terminal split']({ + flags: new Map([['terminal', 'term-1']]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + ).rejects.toMatchObject({ + code: 'runtime_update_required', + message: expect.stringContaining('Update the host and try again') + }) + expect(call).toHaveBeenCalledTimes(1) + expect(call).not.toHaveBeenCalledWith('terminal.split', expect.anything()) + }) +}) diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index 551ab8d95c1..677cdc81034 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -8,7 +8,8 @@ import type { RuntimeTerminalSend, RuntimeTerminalShow, RuntimeTerminalSplit, - RuntimeTerminalWait + RuntimeTerminalWait, + RuntimeStatus } from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' import { shouldUseRendererBackedInteractiveTerminal } from '../codex-command-classification' @@ -37,6 +38,14 @@ import { getRequiredWorktreeSelector, getTerminalHandle } from '../selectors' +import { + addLegacyTerminalAttributionDisableRequest, + hostSupportsTerminalCreateAttributionDisable, + hostSupportsTerminalSplitAttributionDisable, + TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE, + TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE, + withLegacyTerminalAttributionDisabledEnv +} from '../../shared/legacy-terminal-attribution-env' // Why: terminal wait legitimately needs to outlive the CLI's default RPC // timeout. Even without an explicit server timeout, the client must allow @@ -137,17 +146,30 @@ export const TERMINAL_HANDLERS: Record = { const useRendererBackedInteractiveTerminal = !client.isRemote && shouldUseRendererBackedInteractiveTerminal(command) const focus = flags.get('focus') === true - const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { - worktree: await getBrowserWorktreeSelector(flags, cwd, client), - command, - title: getOptionalStringFlag(flags, 'title'), - // Why: interactive local agent TUIs need the renderer-backed terminal - // path for browser-side features, but CLI creates must stay backgrounded - // unless the caller explicitly asks for focus. - focus, - ...(focus ? { presentation: 'focused' } : {}), - ...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {}) - }) + const status = await client.call('status.get') + if (!hostSupportsTerminalCreateAttributionDisable(status.result)) { + throw new RuntimeClientError( + 'runtime_update_required', + TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE + ) + } + const result = await client.call<{ terminal: RuntimeTerminalCreate }>( + 'terminal.create', + { + worktree: await getBrowserWorktreeSelector(flags, cwd, client), + command, + env: withLegacyTerminalAttributionDisabledEnv(undefined), + envToDelete: addLegacyTerminalAttributionDisableRequest(undefined), + title: getOptionalStringFlag(flags, 'title'), + // Why: interactive local agent TUIs need the renderer-backed terminal + // path for browser-side features, but CLI creates must stay backgrounded + // unless the caller explicitly asks for focus. + focus, + ...(focus ? { presentation: 'focused' } : {}), + ...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {}) + }, + { expectedRuntimeId: status.result.runtimeId } + ) printResult(result, json, formatTerminalCreate) }, // `focus` resolves to this canonical path via CommandSpec.aliases before dispatch. @@ -168,11 +190,24 @@ export const TERMINAL_HANDLERS: Record = { ) { throw new RuntimeClientError('invalid_argument', '--direction must be horizontal or vertical') } - const result = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', { - terminal: await getTerminalHandle(flags, cwd, client), - direction: directionFlag, - command: getOptionalStringFlag(flags, 'command') - }) + const status = await client.call('status.get') + if (!hostSupportsTerminalSplitAttributionDisable(status.result)) { + throw new RuntimeClientError( + 'runtime_update_required', + TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE + ) + } + const result = await client.call<{ split: RuntimeTerminalSplit }>( + 'terminal.split', + { + terminal: await getTerminalHandle(flags, cwd, client), + direction: directionFlag, + command: getOptionalStringFlag(flags, 'command'), + env: withLegacyTerminalAttributionDisabledEnv(undefined), + envToDelete: addLegacyTerminalAttributionDisableRequest(undefined) + }, + { expectedRuntimeId: status.result.runtimeId } + ) printResult(result, json, formatTerminalSplit) } } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 4867aebc29b..2f86ee9583f 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -3112,6 +3112,10 @@ describe('orca cli worktree awareness', () => { it('passes explicit focus through terminal.create', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3136,13 +3140,19 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: undefined, - title: 'RUNNER', - focus: true, - presentation: 'focused' - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: undefined, + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'RUNNER', + focus: true, + presentation: 'focused' + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('prints terminal.read fallback screen lines in json mode', async () => { @@ -3184,6 +3194,10 @@ describe('orca cli worktree awareness', () => { it('keeps interactive Codex startup commands backgrounded unless focus is explicit', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3209,19 +3223,29 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex', - title: 'Codex', - focus: false, - rendererBacked: true, - activate: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex', + focus: false, + rendererBacked: true, + activate: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('keeps explicit focus semantics when forcing Codex through the renderer path', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3248,20 +3272,30 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex', - title: 'Codex', - focus: true, - presentation: 'focused', - rendererBacked: true, - activate: true - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex', + focus: true, + presentation: 'focused', + rendererBacked: true, + activate: true + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('does not force the visible terminal path for explicit Codex exec commands', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3287,17 +3321,27 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex exec summarize', - title: 'Codex exec', - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex exec summarize', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex exec', + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('does not force the visible terminal path for Codex exec commands after global options', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3323,17 +3367,27 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex -m gpt-5 --sandbox workspace-write exec summarize', - title: 'Codex exec', - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex -m gpt-5 --sandbox workspace-write exec summarize', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex exec', + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('does not force the visible terminal path for Codex review commands after long options', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3359,17 +3413,27 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex --model=gpt-5 --sandbox=workspace-write review', - title: 'Codex review', - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex --model=gpt-5 --sandbox=workspace-write review', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex review', + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('does not force the visible terminal path for Codex help commands', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3395,17 +3459,27 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex --help', - title: 'Codex help', - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex --help', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex help', + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('keeps Codex prompts after global options backgrounded unless focus is explicit', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3431,19 +3505,29 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'codex -m gpt-5 "fix the flaky test"', - title: 'Codex prompt', - focus: false, - rendererBacked: true, - activate: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'codex -m gpt-5 "fix the flaky test"', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex prompt', + focus: false, + rendererBacked: true, + activate: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('keeps interactive Claude startup commands backgrounded unless focus is explicit', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3469,19 +3553,29 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'claude', - title: 'Claude', - focus: false, - rendererBacked: true, - activate: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'claude', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Claude', + focus: false, + rendererBacked: true, + activate: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('keeps Claude print commands on the background terminal path', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3507,12 +3601,18 @@ describe('orca cli worktree awareness', () => { '/tmp/repo' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'path:/tmp/repo/feature', - command: 'claude -p "summarize"', - title: 'Claude print', - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'path:/tmp/repo/feature', + command: 'claude -p "summarize"', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Claude print', + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('uses the resolved enclosing worktree for other worktree consumers', async () => { @@ -3819,6 +3919,10 @@ describe('orca cli worktree awareness', () => { it('sends explicit remote terminal create worktree selectors unchanged', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3842,12 +3946,18 @@ describe('orca cli worktree awareness', () => { '/tmp/client/repo/src' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'id:repo-1::/srv/orca/feature', - command: undefined, - title: undefined, - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'id:repo-1::/srv/orca/feature', + command: undefined, + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: undefined, + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('collects and formats memory diagnostics', async () => { @@ -3955,6 +4065,10 @@ describe('orca cli worktree awareness', () => { it('does not force remote Codex terminal creates through a local renderer path', async () => { queueFixtures( callMock, + okFixture('req_status', { + runtimeId: 'runtime-1', + capabilities: ['terminal.attribution-removed.v1'] + }), okFixture('req_terminal_create', { terminal: { handle: 'term_1', @@ -3982,12 +4096,18 @@ describe('orca cli worktree awareness', () => { '/tmp/client/repo/src' ) - expect(callMock).toHaveBeenCalledWith('terminal.create', { - worktree: 'id:repo-1::/srv/orca/feature', - command: 'codex', - title: 'Codex', - focus: false - }) + expect(callMock).toHaveBeenCalledWith( + 'terminal.create', + { + worktree: 'id:repo-1::/srv/orca/feature', + command: 'codex', + env: { ORCA_ATTRIBUTION_BYPASS: '1' }, + envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'], + title: 'Codex', + focus: false + }, + { expectedRuntimeId: 'runtime-1' } + ) }) it('does not resolve implicit remote browser targets from client cwd', async () => { diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 3a8951de554..b6c49ff7c22 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -87,6 +87,7 @@ export class RuntimeClient { } : {} const envelope = { + expectedRuntimeId: options?.expectedRuntimeId, orchestrationCapability: options?.orchestrationCapability, orchestrationContractVersion: method.startsWith('orchestration.') ? ORCHESTRATION_CONTRACT_VERSION diff --git a/src/cli/runtime/transport.test.ts b/src/cli/runtime/transport.test.ts index 8de35f619e5..7858d6cd131 100644 --- a/src/cli/runtime/transport.test.ts +++ b/src/cli/runtime/transport.test.ts @@ -50,6 +50,48 @@ describe('runtime transport timeout validation', () => { // Why: these tests create Unix domain socket servers in temp directories. // Windows does not support Unix domain sockets in the same way. describe.skipIf(process.platform === 'win32')('runtime transport', () => { + it('forwards the expected runtime fence in local requests', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-')) + const endpoint = join(userDataPath, 'runtime.sock') + let receivedRequest: Record | null = null + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + receivedRequest = JSON.parse(String(data).trim()) as Record + socket.write( + `${JSON.stringify({ + id: receivedRequest.id, + ok: true, + result: { terminal: { handle: 'terminal-1' } }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + + await sendRequest( + { + runtimeId: 'runtime-1', + pid: 123, + transports: [{ kind: 'unix', endpoint }], + authToken: 'token', + startedAt: 1 + }, + 'terminal.create', + { worktree: 'id:worktree-1' }, + 1_000, + { expectedRuntimeId: 'runtime-1' } + ) + + expect(receivedRequest).toMatchObject({ + method: 'terminal.create', + expectedRuntimeId: 'runtime-1' + }) + }) + it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-')) const endpoint = join(userDataPath, 'runtime.sock') diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index 091ca9da0e0..998052dc4b6 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -186,6 +186,7 @@ export async function sendRequest( authToken: metadata.authToken, method, params, + expectedRuntimeId: envelope?.expectedRuntimeId, orchestrationCapability: envelope?.orchestrationCapability, orchestrationContractVersion: envelope?.orchestrationContractVersion, orchestrationRequestId: envelope?.orchestrationRequestId, diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 3729b8598a7..aef583ad0ac 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -237,6 +237,14 @@ export async function getEmulatorWorktreeSelector( if (client.isRemote) { return undefined } + const terminalWorktreeId = process.env.ORCA_WORKTREE_ID + if (terminalWorktreeId?.trim()) { + return terminalWorktreeId + } + const folderWorkspaceId = process.env.ORCA_WORKSPACE_ID?.trim() + if (folderWorkspaceId?.startsWith('folder:')) { + return folderWorkspaceId + } try { return await resolveCurrentWorktreeSelector(cwd, client) } catch { diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index a85997d87de..fb7b3d85487 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -29,9 +29,12 @@ export const WINDOWS_HOOK_STDIN_READER = '"%SystemRoot%\\System32\\more.com"' export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = `${WINDOWS_HOOK_STDIN_READER} >nul 2>nul` // Why (#11549): missing Orca context means the hook ran outside an Orca pane, where the caller -// may abandon stdin rather than close it — more.com then drains forever and strands a visible -// cmd.exe per hook event. Batch can exit instead because it streams to curl rather than -// capturing, so unlike the POSIX/PowerShell hooks it loses no payload by giving up stdin. +// may abandon stdin rather than close it — a read-to-EOF then blocks forever and strands a +// visible window per hook event. The Windows rule: a hook must check the Orca env before it +// owns stdin, and exit without reading when the env is missing — the payload is discarded on +// that path anyway. This applies to .cmd, the copilot .ps1, and the Git Bash kimi .sh alike. +// POSIX hooks keep capture-first: their callers close stdin, and exiting mid-write there +// surfaces as EPIPE the agent can see (#8110). export function buildWindowsHookEnvironmentGuardLines(): string[] { return [ 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts index aa7e47d258a..b4518220e90 100644 --- a/src/main/agent-hooks/installer-utils-remote.ts +++ b/src/main/agent-hooks/installer-utils-remote.ts @@ -8,8 +8,6 @@ // We deliberately keep the JSON merge logic in the existing // `installer-utils.ts` and only swap fs primitives — the JSON shape and // managed-command matching must stay identical to the local install. -// -// See docs/design/agent-status-over-ssh.md §8 (commit #8). import { randomUUID } from 'node:crypto' import type { SFTPWrapper, FileEntryWithStats } from 'ssh2' @@ -83,9 +81,9 @@ export async function writeHooksJsonRemote( } } -/** Write the managed hook script to the remote and chmod 0o755. POSIX-only — - * the relay deliberately does not support Windows-remote in v1 (see design - * doc §3 + §6). */ +/** Write the managed hook script to the remote and chmod 0o755. POSIX-only: + * the payload is a shell script and every path here is POSIX-joined, so a + * Windows SSH target would need a separate installer, not a tweak to this one. */ export async function writeManagedScriptRemote( sftp: SFTPWrapper, remotePath: string, diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts index f6dcf14471a..3213ee0210e 100644 --- a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts +++ b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts @@ -2,7 +2,10 @@ import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' +import { + installRemoteManagedAgentHooks, + REMOTE_MANAGED_HOOK_INSTALLER_AGENTS +} from './remote-managed-hook-installers' import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' const tempHomes: string[] = [] @@ -34,7 +37,10 @@ describe('managed-hook local filesystem', () => { it('supports cold and warm aggregate installs without SFTP or temp-file residue', async () => { const home = await createTempHome() const filesystem = createManagedHookLocalFilesystem() - const options = { grokHomeDir: join(home, '.grok') } + const options = { + grokHomeDir: join(home, '.grok'), + agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS + } const cold = await installRemoteManagedAgentHooks(filesystem, home, options) const warm = await installRemoteManagedAgentHooks(filesystem, home, options) @@ -61,7 +67,8 @@ describe('managed-hook local filesystem', () => { await writeFile(claudeConfig, '{"hooks": }', 'utf8') const results = await installRemoteManagedAgentHooks(createManagedHookLocalFilesystem(), home, { - grokHomeDir: join(home, '.grok') + grokHomeDir: join(home, '.grok'), + agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS }) expect(results).toHaveLength(14) diff --git a/src/main/agent-hooks/managed-hook-owner-identity.ts b/src/main/agent-hooks/managed-hook-owner-identity.ts index 7f60956e6e4..51f299595cd 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.ts @@ -32,7 +32,7 @@ function hasCode(error: unknown, code: string): boolean { export function parseLinuxStartTicks(statLine: string): string | null { const commandEnd = statLine.lastIndexOf(')') - if (commandEnd < 0) { + if (commandEnd === -1) { return null } // Field 22 is index 19 after removing pid and the parenthesized command. diff --git a/src/main/agent-hooks/managed-hook-runtime.test.ts b/src/main/agent-hooks/managed-hook-runtime.test.ts index 456a741df87..e4c23e13959 100644 --- a/src/main/agent-hooks/managed-hook-runtime.test.ts +++ b/src/main/agent-hooks/managed-hook-runtime.test.ts @@ -1,22 +1,160 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { resolveRelayGrokHome } from './managed-hook-runtime' +import { chmod, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeChildProcess from 'node:child_process' -afterEach(() => { +// Why: the probe spawns a real login shell, and `resolveRelayGrokHome` swallows every +// spawn failure into its fallback. Left unmocked this asserts the runner's scheduling +// latency, not the parser: on a loaded sharded CI box the 8s timeout expires and the +// first case silently flips to the fallback path. +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, execFile: vi.fn() } +}) + +const { execFile } = await import('node:child_process') +const execFileMock = vi.mocked(execFile) +const { execFile: actualExecFile } = + await vi.importActual('node:child_process') +const { installManagedHooks, resolveRelayGrokHome } = await import('./managed-hook-runtime') + +type ExecFileCallback = (error: Error | null, result?: { stdout: string; stderr: string }) => void + +function stubProbeOutput(stdout: string): void { + execFileMock.mockImplementation(((...args: unknown[]) => { + ;(args.at(-1) as ExecFileCallback)(null, { stdout, stderr: '' }) + return undefined + }) as unknown as typeof execFile) +} + +function stubProbeFailure(error: Error): void { + execFileMock.mockImplementation(((...args: unknown[]) => { + ;(args.at(-1) as ExecFileCallback)(error) + return undefined + }) as unknown as typeof execFile) +} + +beforeEach(() => { + execFileMock.mockReset() + // Why: `installManagedHooks` proves a skipped probe through the login-shell run log, so the + // probe must really spawn unless a case above stubs it. The mock loses `promisify.custom`, + // so the real `(error, stdout, stderr)` callback is reshaped into the `{ stdout, stderr }` + // that `promisify(execFile)` resolves in production. + execFileMock.mockImplementation(((...args: unknown[]) => { + const callback = args.at(-1) as ExecFileCallback + return (actualExecFile as (...callArgs: unknown[]) => unknown)( + ...args.slice(0, -1), + (error: Error | null, stdout: string, stderr: string) => callback(error, { stdout, stderr }) + ) + }) as unknown as typeof execFile) +}) + +const tempHomes: string[] = [] +const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp' +const SHELL_NAME = 'login-shell' +const SHELL_RUNS_NAME = 'login-shell-runs' + +async function createTempHome(): Promise { + const home = await mkdtemp(join(tempRoot, 'orca-managed-hook-runtime-')) + tempHomes.push(home) + return home +} + +/** Login shell that records each invocation, so a skipped GROK_HOME probe is observable. */ +async function stubLoginShell(home: string): Promise { + const shell = join(home, SHELL_NAME) + await writeFile( + shell, + `#!/bin/sh\necho ran >> "${join(home, SHELL_RUNS_NAME)}"\nexit 0\n`, + 'utf8' + ) + await chmod(shell, 0o755) + vi.stubEnv('HOME', home) + vi.stubEnv('SHELL', shell) +} + +afterEach(async () => { vi.unstubAllEnvs() + await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) }) describe.runIf(process.platform !== 'win32')('resolveRelayGrokHome', () => { it('uses the login-shell GROK_HOME and normalizes trailing separators', async () => { vi.stubEnv('SHELL', '/bin/sh') - vi.stubEnv('GROK_HOME', '/srv/grok///') + stubProbeOutput('/srv/grok///\n') await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok') + + const [shell, args] = execFileMock.mock.calls[0] ?? [] + expect(shell).toBe('/bin/sh') + // `sh`/`dash` reject `-lc`, so the mode choice is part of the contract under test. + expect(args?.[0]).toBe('-c') + }) + + it('passes -lc to a login shell that supports it', async () => { + vi.stubEnv('SHELL', '/bin/zsh') + stubProbeOutput('/srv/grok\n') + + await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok') + + const [shell, args] = execFileMock.mock.calls[0] ?? [] + expect(shell).toBe('/bin/zsh') + expect(args?.[0]).toBe('-lc') }) it('falls back when the login-shell GROK_HOME is not an absolute POSIX path', async () => { vi.stubEnv('SHELL', '/bin/sh') - vi.stubEnv('GROK_HOME', '../relative') + stubProbeOutput('../relative\n') + + await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') + }) + + // Why: this is the branch that made the old test flaky — pin it so a probe failure is + // an asserted fallback rather than an invisible substitution for a real answer. + it('falls back when the probe fails or times out', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeFailure(Object.assign(new Error('spawn timed out'), { killed: true })) await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') }) }) + +describe.runIf(process.platform !== 'win32')('installManagedHooks', () => { + it.each([ + ['omitted', undefined], + ['empty', { agents: [] }] + ])( + 'writes nothing and runs no probe when the allowlist is %s (issue #11641)', + async (_label, options) => { + const home = await createTempHome() + await stubLoginShell(home) + + await expect(installManagedHooks(options)).resolves.toEqual({ installers: 0, errors: 0 }) + + // Why: no agent config home, no ~/.orca install lock, and no GROK_HOME login-shell probe. + expect(await readdir(home)).toEqual([SHELL_NAME]) + } + ) + + it('still rejects an aborted request rather than resolving an empty summary', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + installManagedHooks({ signal: controller.signal, agents: [] }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('installs only the allowlisted agent, under the install lock', async () => { + const home = await createTempHome() + await stubLoginShell(home) + + await expect(installManagedHooks({ agents: ['claude'] })).resolves.toEqual({ + installers: 1, + errors: 0 + }) + + expect((await readdir(home)).sort()).toEqual(['.claude', '.orca', SHELL_NAME, SHELL_RUNS_NAME]) + }) +}) diff --git a/src/main/agent-hooks/managed-hook-runtime.ts b/src/main/agent-hooks/managed-hook-runtime.ts index bdd7195b50d..e995467ee7d 100644 --- a/src/main/agent-hooks/managed-hook-runtime.ts +++ b/src/main/agent-hooks/managed-hook-runtime.ts @@ -79,6 +79,11 @@ export async function installManagedHooks(options?: { agents?: readonly AgentHookTarget[] }): Promise { options?.signal?.throwIfAborted() + // Why: empty/omitted allowlist fails closed before any home/host probes. + const agents = options?.agents ?? [] + if (agents.length === 0) { + return { installers: 0, errors: 0 } + } const home = homedir() const grokHomeDir = await resolveRelayGrokHome(home, options?.signal) options?.signal?.throwIfAborted() @@ -96,7 +101,7 @@ export async function installManagedHooks(options?: { { grokHomeDir, signal: options?.signal, - agents: options?.agents + agents } ) return { diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 7c27c2fadee..f805e6c25f1 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -293,13 +293,17 @@ describe('Windows managed hook stdin structure', () => { claude.indexOf('if not "%DEVIN_PROJECT_DIR%"=="" goto :orca_agent_hook_drain_stdin') ) + // Why (#11549 class): every Windows-local hook now guards before owning stdin — + // the caller may abandon the pipe, and the payload is discarded on this path anyway. const copilot = readFileSync(join(hooksDir, 'copilot-hook.ps1'), 'utf8') - expect(copilot.indexOf('[Console]::In.ReadToEnd()')).toBeLessThan( - copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT') + expect(copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT')).toBeGreaterThan(-1) + expect(copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT')).toBeLessThan( + copilot.indexOf('[Console]::In.ReadToEnd()') ) const kimi = readFileSync(join(hooksDir, 'kimi-hook.sh'), 'utf8') - expect(kimi.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`)).toBeLessThan( - kimi.indexOf('exit 0') + expect(kimi.indexOf('if [ -z "$ORCA_AGENT_HOOK_PORT" ]')).toBeGreaterThan(-1) + expect(kimi.indexOf('if [ -z "$ORCA_AGENT_HOOK_PORT" ]')).toBeLessThan( + kimi.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`) ) } finally { homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) @@ -318,7 +322,7 @@ describe('Windows managed hook stdin structure', () => { }) it.skipIf(process.platform !== 'win32')( - 'exits 0 for every local script and missing-script launcher, and only .cmd drops stdin', + 'exits 0 for every local script and missing-script launcher, dropping stdin only without Orca env', async () => { const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-windows-live-')) homedirMock.mockReturnValue(home) @@ -356,16 +360,13 @@ describe('Windows managed hook stdin structure', () => { : [scriptPath] const result = await runHookProcess(executable, args, hookEnvironment()) expect(result.exitCode, `${fileName} exit code`).toBe(0) - if (fileName.endsWith('.cmd')) { - // Why (#11549): these guards exit rather than own stdin, so the writer breaks — - // EPIPE, or ECONNRESET when Windows tears the pipe down first. hookEnvironment() - // strips every ORCA_* var, so the relaxation only ever covers the missing-env - // path — a happy-path case added to this loop must not reuse it. - for (const error of result.stdinErrors) { - expect(['EPIPE', 'ECONNRESET'], `${fileName} stdin error`).toContain(error.code) - } - } else { - expect(result.stdinErrors, `${fileName} stdin errors`).toHaveLength(0) + // Why (#11549 class): every Windows-local hook exits before owning stdin when the + // Orca env is missing, so the writer may break — EPIPE, or ECONNRESET when Windows + // tears the pipe down first. hookEnvironment() strips every ORCA_* var, so this + // relaxation only ever covers the missing-env path — a happy-path case added to + // this loop must not reuse it. + for (const error of result.stdinErrors) { + expect(['EPIPE', 'ECONNRESET'], `${fileName} stdin error`).toContain(error.code) } } diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 2b2cfcdcf38..9ba01bc81c6 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -707,7 +707,9 @@ describe('remote hook service installers', () => { it('installs Droid and Copilot when running the aggregate remote installer (issue #7253)', async () => { const { sftp } = createFakeSftp() - const results = await installRemoteManagedAgentHooks(sftp, '/home/dev') + const results = await installRemoteManagedAgentHooks(sftp, '/home/dev', { + agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS + }) const byAgent = new Map(results.map((r) => [r.agent, r.state])) expect(byAgent.get('droid')).toBe('installed') expect(byAgent.get('copilot')).toBe('installed') @@ -727,6 +729,19 @@ describe('remote hook service installers', () => { } }) + it('fails closed when the agent allowlist is omitted or empty (issue #11641)', async () => { + const { sftp, fs } = createFakeSftp() + + await expect(installRemoteManagedAgentHooks(sftp, '/home/dev')).resolves.toEqual([]) + await expect( + installRemoteManagedAgentHooks(sftp, '/home/dev', { agents: [] }) + ).resolves.toEqual([]) + + // Why: fake SFTP seeds '/' only; no agent config homes or files may appear. + expect([...fs.files.keys()]).toEqual([]) + expect([...fs.dirs]).toEqual(['/']) + }) + it('stops before the next installer when its relay request is cancelled', async () => { const controller = new AbortController() const claudeInstall = vi @@ -746,7 +761,10 @@ describe('remote hook service installers', () => { const { sftp } = createFakeSftp() await expect( - installRemoteManagedAgentHooks(sftp, '/home/dev', { signal: controller.signal }) + installRemoteManagedAgentHooks(sftp, '/home/dev', { + signal: controller.signal, + agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS + }) ).rejects.toMatchObject({ name: 'AbortError' }) expect(claudeInstall).toHaveBeenCalledTimes(1) expect(openClaudeInstall).not.toHaveBeenCalled() diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 0fdf0c47da4..6471ec5cfb4 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -26,7 +26,8 @@ export type RemoteManagedHookInstallOptions = { /** Stops before starting the next installer when the owning relay request * is cancelled. Individual filesystem mutations remain atomic. */ signal?: AbortSignal - /** Positively detected and enabled agents allowed to mutate config. */ + /** Positively detected and enabled agents allowed to mutate config. + * Required for any install: omit/empty fails closed (no config mutation). */ agents?: readonly AgentHookTarget[] } @@ -81,10 +82,15 @@ export async function installRemoteManagedAgentHooks( remoteHome: string, options?: RemoteManagedHookInstallOptions ): Promise { + // Why: omit/empty allowlist must never mean "install every agent" — that + // recreates config homes for CLIs the user never installed (issue #11641). + const allowedAgents = new Set(options?.agents ?? []) + if (allowedAgents.size === 0) { + return [] + } const results: AgentHookInstallStatus[] = [] - const allowedAgents = options?.agents ? new Set(options.agents) : null for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) { - if (allowedAgents && !allowedAgents.has(agent)) { + if (!allowedAgents.has(agent)) { continue } // Why: relay requests can disappear during reconnect; do not start more diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index e5ef70e1395..4dc64d523de 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: this file owns the loopback HTTP adapter, the on-disk last-status persistence layer (hydrate, sanitize, TTL, atomic write, drop), and the relay ingest path in one place so the cache lifecycle (set → schedule → drain) lives next to the surfaces that mutate it. Splitting would force mutual `private` accessor scaffolding for a single class. */ -// Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron. See docs/design/agent-status-over-ssh.md §5. +// Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron; parsing that drifts back into this file stops applying to SSH panes. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { createHash, randomBytes, randomUUID } from 'node:crypto' import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index cb538595c19..e6a3a727fb4 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -11,7 +11,10 @@ import { RelayDispatcher } from '../../relay/dispatcher' import { registerWslHookFsHandlers } from '../../relay/wsl-hook-fs-bridge' import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' import { createWslHookSftpAdapter } from './wsl-hook-fs-adapter' -import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' +import { + installRemoteManagedAgentHooks, + REMOTE_MANAGED_HOOK_INSTALLER_AGENTS +} from './remote-managed-hook-installers' import { WslHookRelayManager } from './wsl-hook-relay-manager' import { FAILURE_COOLDOWN_BASE_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps' import { @@ -122,7 +125,9 @@ describe.skipIf(process.platform === 'win32')( it('runs the unchanged remote managed hook installers against a WSL guest home', async () => { const adapter = createWslHookSftpAdapter(harness.mux) - const results = await installRemoteManagedAgentHooks(adapter, home) + const results = await installRemoteManagedAgentHooks(adapter, home, { + agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS + }) expect(results.length).toBeGreaterThan(0) expect(results.every((r) => r.state !== 'error')).toBe(true) @@ -233,13 +238,6 @@ describe('WslHookRelayManager', () => { return { manager: new WslHookRelayManager(deps), deps } } - it('exposes the stable guest endpoint instance key with a port fallback', () => { - expect(createManager({}).manager.getInstanceKey()).toBe('testinstance') - expect(createManager({ instanceKey: () => 'unsafe/key' }).manager.getInstanceKey()).toBe( - 'port43117' - ) - }) - it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => { const { manager, deps } = createManager({}) manager.ensureForDistro('Ubuntu') diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 828e2fa888a..74d163ca42f 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -109,15 +109,6 @@ export class WslHookRelayManager { return this.stateFor(distro)?.opencodeOverlayDir ?? null } - getInstanceKey(): string | null { - const stableKey = sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) - if (stableKey) { - return stableKey - } - const port = Number(this.deps.hookCoordsEnv().ORCA_AGENT_HOOK_PORT ?? '') - return Number.isInteger(port) && port > 0 ? `port${port}` : null - } - /** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the * manager reusable, so re-enabling hooks can start relays again without an app restart. */ disposeAll({ permanent = true }: { permanent?: boolean } = {}): void { @@ -181,7 +172,8 @@ export class WslHookRelayManager { } // Why: restart-stable instance identity keeps the guest endpoint file at // ONE path across restarts so daemon-surviving agents re-coordinate. - const instanceKey = this.getInstanceKey() ?? `port${port}` + const instanceKey = + sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) ?? `port${port}` if (existing) { this.recovery.clearTimers(existing) } diff --git a/src/main/ai-vault/remote-session-file-stat.ts b/src/main/ai-vault/remote-session-file-stat.ts index aa5809a776d..57e6d2c8ed6 100644 --- a/src/main/ai-vault/remote-session-file-stat.ts +++ b/src/main/ai-vault/remote-session-file-stat.ts @@ -2,7 +2,7 @@ import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types import type { ExecutionHostId } from '../../shared/execution-host' import type { FileStat } from '../providers/types' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { recordRemoteSessionScanIssue } from './remote-session-scan-issues' +import { recordSessionScanIssue } from './session-scan-issues' import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' @@ -32,7 +32,7 @@ export async function statRemoteSessionFile( } catch (error) { throwIfAiVaultScanCancelled(options?.signal) if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) { - recordRemoteSessionScanIssue(issues, { + recordSessionScanIssue(issues, { executionHostId, agent, path, diff --git a/src/main/ai-vault/remote-session-scan-issues.ts b/src/main/ai-vault/remote-session-scan-issues.ts deleted file mode 100644 index aee229f443c..00000000000 --- a/src/main/ai-vault/remote-session-scan-issues.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { AiVaultScanIssue } from '../../shared/ai-vault-types' - -const REMOTE_SCAN_ISSUE_LIMIT = 500 - -export function recordRemoteSessionScanIssue( - issues: AiVaultScanIssue[], - issue: AiVaultScanIssue -): void { - if (issues.length < REMOTE_SCAN_ISSUE_LIMIT - 1) { - issues.push(issue) - return - } - if (issues.length === REMOTE_SCAN_ISSUE_LIMIT - 1) { - // Kinded: this row is a scan notice, not a skipped transcript — the panel - // counts unkinded issues as skipped transcript files. - issues.push({ - executionHostId: issue.executionHostId, - agent: issue.agent, - kind: 'notice', - path: 'Agent Session History scan', - message: 'Additional scan issues were omitted.' - }) - } -} diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index 91f21eab720..329bdb2e584 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -7,7 +7,7 @@ import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' import { mapRemoteScanBatches } from './remote-session-scan-batching' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { recordRemoteSessionScanIssue } from './remote-session-scan-issues' +import { recordSessionScanIssue } from './session-scan-issues' import type { RemoteScannerContext, RemoteSessionCandidate, @@ -124,7 +124,7 @@ function recordRemoteDirectoryIssue( err: unknown ): void { if (!isMissingRemoteSessionPathError(err)) { - recordRemoteSessionScanIssue(issues, { + recordSessionScanIssue(issues, { executionHostId, agent: source.agent, kind: 'host', diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 2523cd22a43..568b42c27cd 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -24,7 +24,7 @@ import { createAntigravityWorkspaceResolver } from './session-scanner-antigravit import { errorMessage } from './session-scanner-values' import { mapRemoteScanBatches } from './remote-session-scan-batching' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' -import { recordRemoteSessionScanIssue } from './remote-session-scan-issues' +import { recordSessionScanIssue } from './session-scan-issues' import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth' @@ -205,7 +205,7 @@ async function scanRemoteInScopeSessions(args: { } if (index < candidates.length && sessions.length < args.limit) { - recordRemoteSessionScanIssue(args.issues, { + recordSessionScanIssue(args.issues, { executionHostId: args.context.executionHostId, agent: 'codex', kind: 'scope', @@ -241,7 +241,7 @@ async function parseRemoteSessionCandidate( return session } catch (err) { throwIfAiVaultScanCancelled(context.signal) - recordRemoteSessionScanIssue(issues, { + recordSessionScanIssue(issues, { executionHostId: context.executionHostId, agent: candidate.source.agent, path: candidate.file.path, diff --git a/src/main/ai-vault/session-first-user-prompt-read.ts b/src/main/ai-vault/session-first-user-prompt-read.ts index f250846a20e..ac5a35f2a53 100644 --- a/src/main/ai-vault/session-first-user-prompt-read.ts +++ b/src/main/ai-vault/session-first-user-prompt-read.ts @@ -1,10 +1,10 @@ -import { stat } from 'node:fs/promises' import type { AiVaultAgent, AiVaultFirstUserPromptResult, AiVaultSession } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { wslGatedStat } from '../native-chat/wsl-transcript-fs-access' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' @@ -118,7 +118,9 @@ async function fileWithMtimeForPath( } try { - const info = await stat(filePath) + // 'scan' matches the parser this feeds, so the two halves of one re-parse + // share a lane instead of the stat jumping the live-transcript queue. + const info = await wslGatedStat(filePath, 'scan') return { path: filePath, mtimeMs: info.mtimeMs, diff --git a/src/main/ai-vault/session-scan-issues.ts b/src/main/ai-vault/session-scan-issues.ts new file mode 100644 index 00000000000..b3e21f9c5a8 --- /dev/null +++ b/src/main/ai-vault/session-scan-issues.ts @@ -0,0 +1,27 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +// Why: a stalled WSL distro or an unreachable remote host fails one probe per +// discovered path, so an uncapped list ships one row per transcript file in the +// tree over IPC. Local and remote scans share the bound — the failure shape is +// the same and both feed the same panel. +const SCAN_ISSUE_LIMIT = 500 + +/** Append a scan issue, collapsing everything past the cap into one notice. */ +export function recordSessionScanIssue(issues: AiVaultScanIssue[], issue: AiVaultScanIssue): void { + if (issues.length < SCAN_ISSUE_LIMIT - 1) { + issues.push(issue) + return + } + if (issues.length === SCAN_ISSUE_LIMIT - 1) { + // Kinded: this row is a scan notice, not a skipped transcript — the panel + // counts unkinded issues as skipped transcript files. Spread first so a + // remote issue keeps its executionHostId and a local one never gains an + // explicit `undefined` (which would outrank the scanner's own stamping). + issues.push({ + ...issue, + kind: 'notice', + path: 'Agent Session History scan', + message: 'Additional scan issues were omitted.' + }) + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-history.ts b/src/main/ai-vault/session-scanner-antigravity-history.ts index 989ac7b4179..ccfb506b7f7 100644 --- a/src/main/ai-vault/session-scanner-antigravity-history.ts +++ b/src/main/ai-vault/session-scanner-antigravity-history.ts @@ -1,8 +1,28 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' +import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { normalizeTitleText, parseJsonObject, timestampMs } from './session-scanner-values' const HISTORY_MATCH_WINDOW_MS = 2_000 +/** + * The local-scan `readHistory`; remote scans inject their own transport. A + * missing or unreadable history file is genuinely "no enrichment", but a gate + * refusal must propagate so the caller records a scan issue — degrading it to + * null lists the session with a missing cwd and no retry signal, and the + * resolver's memo relies on the rejection to evict rather than pin a stall. + */ +export async function readLocalAntigravityHistory(path: string): Promise { + try { + return await wslGatedReadFile(path, 'utf-8', 'scan') + } catch (error) { + if (error instanceof WslTranscriptFsError) { + throw error + } + return null + } +} + type AntigravityHistoryEntry = { timestampMs: number workspace: string @@ -26,8 +46,19 @@ export function createAntigravityWorkspaceResolver( } let index = indexes.get(historyPath) if (!index) { - index = readHistory(historyPath).then(indexAntigravityHistory) - indexes.set(historyPath, index) + // Why: a read failure is transient (a stalled WSL distro refuses here), + // so it must not be memoized — every later session under this history + // file would inherit the rejection for the process lifetime. + const pending: Promise = readHistory(historyPath) + .then(indexAntigravityHistory) + .catch((error: unknown) => { + if (indexes.get(historyPath) === pending) { + indexes.delete(historyPath) + } + throw error + }) + index = pending + indexes.set(historyPath, pending) } const workspace = findAntigravityWorkspace(session, await index) return workspace ? { ...session, cwd: workspace } : session diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts index 818167fbd0c..1896138749c 100644 --- a/src/main/ai-vault/session-scanner-antigravity-parser.ts +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -1,5 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' @@ -26,11 +26,16 @@ 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 }) + const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan') + const lines = createInterface({ input, crlfDelay: Infinity }) + try { + return await parseAntigravitySessionLines({ file, lines, platform }) + } finally { + // readline.close() leaves the underlying stream open; destroy it so a + // mid-parse throw cannot leak the gated transcript handle. + lines.close() + input.destroy() + } } export async function parseAntigravitySessionContent( diff --git a/src/main/ai-vault/session-scanner-claude-subagents.ts b/src/main/ai-vault/session-scanner-claude-subagents.ts index 5c269d91cf3..a2dc5745948 100644 --- a/src/main/ai-vault/session-scanner-claude-subagents.ts +++ b/src/main/ai-vault/session-scanner-claude-subagents.ts @@ -1,5 +1,3 @@ -import { createReadStream } from 'node:fs' -import { readdir, readFile, stat } from 'node:fs/promises' import { basename, extname, join } from 'node:path' import { createInterface } from 'node:readline' import type { @@ -8,6 +6,14 @@ import type { AiVaultSubagentListResult, AiVaultSubagentRunStatus } from '../../shared/ai-vault-types' +import { + openTranscriptReadStream, + wslGatedReaddir, + wslGatedReadFile, + wslGatedStat +} from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import { recordSessionScanIssue } from './session-scan-issues' import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator' import { parseClaudeSessionFile } from './session-scanner-primary-parsers' import { @@ -29,6 +35,10 @@ const SUBAGENT_RUNNING_RECENCY_MS = 5 * 60_000 // Match the main scanner's deliberate parse batching (SESSION_PARSE_CONCURRENCY): // opening every subagent read stream at once stalls over SSH/WSL UNC paths. const SUBAGENT_PARSE_CONCURRENCY = 8 +// 'scan', not 'exact', even though a user expands this on demand: it is bulk +// directory work feeding the same parsers as the main scan, and the exact lane +// stays reserved for live transcript probes. +const SUBAGENT_FS_PRIORITY = 'scan' const TASK_NOTIFICATION_MARKER = '' const TOOL_USE_RESULT_MARKER = '"toolUseResult"' @@ -71,8 +81,13 @@ export async function listClaudeSubagentSessions(args: { let entries try { - entries = await readdir(subagentsDir, { withFileTypes: true }) - } catch { + entries = await wslGatedReaddir(subagentsDir, SUBAGENT_FS_PRIORITY) + } catch (err) { + // A gate refusal is a stalled distro, not a session without subagents — + // report it so the panel offers a retry instead of showing an empty list. + if (err instanceof WslTranscriptFsError) { + recordSessionScanIssue(issues, { agent: 'claude', path: subagentsDir, message: err.message }) + } return { sessions: [], issues } } @@ -130,7 +145,7 @@ async function parseSubagentTranscript(args: { issues: AiVaultScanIssue[] }): Promise { try { - const fileStat = await stat(args.filePath) + const fileStat = await wslGatedStat(args.filePath, SUBAGENT_FS_PRIORITY) const session = await parseClaudeSessionFile( { path: args.filePath, @@ -161,7 +176,11 @@ async function parseSubagentTranscript(args: { } } } catch (err) { - args.issues.push({ agent: 'claude', path: args.filePath, message: errorMessage(err) }) + recordSessionScanIssue(args.issues, { + agent: 'claude', + path: args.filePath, + message: errorMessage(err) + }) return null } } @@ -187,11 +206,13 @@ function resolveSubagentStatus(args: { // 'async_launched', notification 'running') are superseded by terminal ones. async function collectSubagentTaskStatuses(parentFilePath: string): Promise> { const statuses = new Map() + const input = openTranscriptReadStream( + parentFilePath, + { encoding: 'utf-8' }, + SUBAGENT_FS_PRIORITY + ) + const lines = createInterface({ input, crlfDelay: Infinity }) try { - const lines = createInterface({ - input: createReadStream(parentFilePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) for await (const line of lines) { const hasNotification = line.includes(TASK_NOTIFICATION_MARKER) const hasTaskResult = @@ -232,6 +253,11 @@ async function collectSubagentTaskStatuses(parentFilePath: string): Promise { const metaPath = `${transcriptPath.slice(0, -extname(transcriptPath).length)}.meta.json` try { - const record = asRecord(JSON.parse(await readFile(metaPath, 'utf-8')) as unknown) + const raw = await wslGatedReadFile(metaPath, 'utf-8', SUBAGENT_FS_PRIORITY) + const record = asRecord(JSON.parse(raw) as unknown) return { description: normalizeTitleText(extractString(record?.description) ?? ''), agentType: extractString(record?.agentType) diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index 0f73fa54126..6887b7ed5d8 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -1,4 +1,4 @@ -import { createReadStream } from 'node:fs' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index' @@ -42,7 +42,7 @@ export async function parseCodexSessionFile( executionHostId?: ExecutionHostId ): Promise { const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), + input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) diff --git a/src/main/ai-vault/session-scanner-codex-title-index.ts b/src/main/ai-vault/session-scanner-codex-title-index.ts index 8ef2d5645cb..635a215562a 100644 --- a/src/main/ai-vault/session-scanner-codex-title-index.ts +++ b/src/main/ai-vault/session-scanner-codex-title-index.ts @@ -1,7 +1,7 @@ -import { createReadStream } from 'node:fs' -import { stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' +import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' // Codex names threads lazily in /session_index.jsonl; transcripts @@ -77,7 +77,7 @@ async function readCodexSessionIndexTitles(codexHome: string): Promise ({ - signature, - titles - })) + const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then(({ titles, refused }) => { + // Why: a gate refusal is a stalled distro, not a title-less index — caching + // it would pin "no titles" until the index's size or mtime changes. + if (refused && codexSessionIndexTitleCache.get(codexHome) === pending) { + codexSessionIndexTitleCache.delete(codexHome) + } + return { signature, titles } + }) storeCodexSessionIndexTitleCacheEntry(codexHome, pending) return (await pending).titles } @@ -133,11 +137,11 @@ function storeCodexSessionIndexTitleCacheEntry( async function readCodexSessionIndexTitlesFromDisk( indexPath: string -): Promise> { +): Promise<{ titles: Map; refused: boolean }> { const titleBySessionId = new Map() try { const lines = createInterface({ - input: createReadStream(indexPath, { encoding: 'utf-8' }), + input: openTranscriptReadStream(indexPath, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) for await (const line of lines) { @@ -151,8 +155,9 @@ async function readCodexSessionIndexTitlesFromDisk( titleBySessionId.set(sessionId, title) } } - } catch { + } catch (error) { // Codex creates the index opportunistically; older homes may only have raw transcripts. + return { titles: titleBySessionId, refused: error instanceof WslTranscriptFsError } } - return titleBySessionId + return { titles: titleBySessionId, refused: false } } diff --git a/src/main/ai-vault/session-scanner-copilot-parser.ts b/src/main/ai-vault/session-scanner-copilot-parser.ts index 4d0140fc3df..7e5450393e0 100644 --- a/src/main/ai-vault/session-scanner-copilot-parser.ts +++ b/src/main/ai-vault/session-scanner-copilot-parser.ts @@ -1,5 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' @@ -35,7 +35,7 @@ export async function parseCopilotSessionFile( platform: NodeJS.Platform = process.platform ): Promise { const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), + input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) return parseCopilotSessionLines({ file, lines, platform }) diff --git a/src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts new file mode 100644 index 00000000000..44fe71c80b2 --- /dev/null +++ b/src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import type * as NodePathModule from 'node:path' +import type { SessionFileCandidate } from './session-scanner-types' + +const mocks = vi.hoisted(() => ({ + open: vi.fn(), + readFile: vi.fn(), + readdir: vi.fn(), + stat: vi.fn() +})) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + open: mocks.open, + readFile: mocks.readFile, + readdir: mocks.readdir, + stat: mocks.stat +})) + +// Derived sibling paths must retain Windows separators in this cross-platform test. +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual.win32, default: actual.win32 } +}) + +import { + parseAgentSessionFileCached, + resetSessionParseCacheForTests +} from './session-scanner-parse-cache' +import { + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, + WslTranscriptFsError +} from '../native-chat/wsl-transcript-fs-gate' + +function uncPath(distro: string, ...segments: string[]): string { + return `\\\\wsl.localhost\\${distro}\\home\\ada\\${segments.join('\\')}` +} + +const KIMI_HOME = ['.kimi-code', 'sessions', 'wd_app_9f2', 'session_abc'] +const GROK_DIR = ['.grok', 'sessions', 'ses-1'] +const OPENCODE_ROOT = ['.local', 'share', 'opencode', 'storage'] + +const KIMI_STATE = JSON.stringify({ + title: 'Kimi session', + createdAt: '2026-06-01T10:00:00.000Z', + updatedAt: '2026-06-01T10:05:00.000Z', + agents: { main: { type: 'main', parentAgentId: null } } +}) + +const KIMI_WIRE = `${JSON.stringify({ + type: 'context.append_message', + message: { role: 'user', origin: { kind: 'user' }, content: 'ship it' } +})}\n` + +const GROK_SESSION = JSON.stringify({ + info: { id: 'ses-1', cwd: '/repo/app' }, + generated_title: 'Grok session', + created_at: '2026-06-01T10:00:00.000Z' +}) + +const GROK_HISTORY = `${JSON.stringify({ + type: 'user', + content: 'ship it' +})}\n` + +const OPENCODE_SESSION = JSON.stringify({ + id: 'ses-1', + title: 'OpenCode session', + directory: '/repo/app', + time: { created: 1780000000000 } +}) + +const OPENCODE_MESSAGE = JSON.stringify({ + role: 'user', + content: [{ type: 'text', text: 'ship it' }], + time: { created: 1780000000000 } +}) + +type ReadResult = { bytesRead: number; buffer: Buffer } + +let releaseStall: (() => void) | undefined + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) } as T) + }) +} + +function stallingHandle() { + return { read: vi.fn(stalls), close: vi.fn(async () => {}) } +} + +function servingHandle(body: string) { + const bytes = Buffer.from(body) + return { + read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const slice = bytes.subarray(position, Math.min(position + length, bytes.length)) + slice.copy(buffer, offset) + return { bytesRead: slice.length, buffer } + }), + close: vi.fn(async () => {}) + } +} + +function missing(): Error { + return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) +} + +function dirent(name: string) { + return { name, isFile: () => true } +} + +function candidate(agent: SessionFileCandidate['agent'], path: string): SessionFileCandidate { + return { + agent, + file: { + path, + mtimeMs: 1, + modifiedAt: '2026-06-01T10:05:00.000Z', + sizeBytes: 128 + }, + codexHome: null + } +} + +async function expectRefusal(target: SessionFileCandidate): Promise { + const refusal = expect(parseAgentSessionFileCached(target, 'linux')).rejects.toBeInstanceOf( + WslTranscriptFsError + ) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + await refusal +} + +async function releaseAndSettle(): Promise { + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) +} + +beforeEach(() => { + resetSessionParseCacheForTests() + mocks.open.mockReset() + mocks.readFile.mockReset() + mocks.readdir.mockReset() + mocks.stat.mockReset() + releaseStall = undefined + mocks.stat.mockRejectedValue(missing()) + vi.useFakeTimers() +}) + +afterEach(async () => { + await releaseAndSettle() + vi.useRealTimers() +}) + +describe('Kimi session parse against a stalled WSL transcript', () => { + it('refuses a stalled state.json read instead of reporting no session', async () => { + const state = uncPath('KimiState', ...KIMI_HOME, 'state.json') + mocks.readFile.mockImplementation(stalls) + + await expectRefusal(candidate('kimi', state)) + }) + + it('refuses a stalled wire read, then caches the full session once it recovers', async () => { + const state = uncPath('KimiWire', ...KIMI_HOME, 'state.json') + mocks.readFile.mockResolvedValue(KIMI_STATE) + mocks.open.mockResolvedValue(stallingHandle()) + + await expectRefusal(candidate('kimi', state)) + await releaseAndSettle() + + mocks.open.mockResolvedValue(servingHandle(KIMI_WIRE)) + const recovered = await parseAgentSessionFileCached(candidate('kimi', state), 'linux') + + expect(recovered?.messageCount).toBe(1) + }) + + // Pins the narrowness of the parser's `instanceof WslTranscriptFsError` + // rethrow: widening it to every error turns this into a hard scan issue. + it('still returns a metadata-only session when the wire file is simply missing', async () => { + const state = uncPath('KimiMissing', ...KIMI_HOME, 'state.json') + mocks.readFile.mockResolvedValue(KIMI_STATE) + mocks.open.mockRejectedValue(missing()) + + const session = await parseAgentSessionFileCached(candidate('kimi', state), 'linux') + + expect(session).toMatchObject({ agent: 'kimi', title: 'Kimi session', messageCount: 0 }) + }) +}) + +describe('Grok session parse against a stalled WSL transcript', () => { + it('refuses a stalled chat_history read instead of reporting a summary-only session', async () => { + const file = uncPath('GrokHistory', ...GROK_DIR, 'session.json') + mocks.readFile.mockResolvedValue(GROK_SESSION) + mocks.open.mockResolvedValue(stallingHandle()) + + await expectRefusal(candidate('grok', file)) + await releaseAndSettle() + + mocks.open.mockResolvedValue(servingHandle(GROK_HISTORY)) + const recovered = await parseAgentSessionFileCached(candidate('grok', file), 'linux') + + expect(recovered?.previewMessages).toEqual([ + expect.objectContaining({ role: 'user', text: 'ship it' }) + ]) + }) + + it('still returns the summary-only session when chat_history is missing', async () => { + const file = uncPath('GrokMissing', ...GROK_DIR, 'session.json') + mocks.readFile.mockResolvedValue(GROK_SESSION) + mocks.open.mockRejectedValue(missing()) + + const session = await parseAgentSessionFileCached(candidate('grok', file), 'linux') + + expect(session).toMatchObject({ + agent: 'grok', + title: 'Grok session', + previewMessages: [] + }) + }) +}) + +describe('OpenCode session parse against a stalled WSL transcript', () => { + function sessionPath(distro: string): string { + return uncPath(distro, ...OPENCODE_ROOT, 'session', 'prj', 'ses-1.json') + } + + it('refuses a stalled message-directory listing instead of an empty transcript', async () => { + mocks.readFile.mockResolvedValue(OPENCODE_SESSION) + mocks.readdir.mockImplementation(stalls) + + await expectRefusal(candidate('opencode', sessionPath('OpenCodeDir'))) + }) + + it('refuses a stalled per-message read instead of a partial transcript', async () => { + const file = sessionPath('OpenCodeMessage') + mocks.readdir.mockResolvedValue([dirent('msg-1.json')]) + mocks.readFile.mockImplementation((path: string) => + path === file ? Promise.resolve(OPENCODE_SESSION) : stalls() + ) + + await expectRefusal(candidate('opencode', file)) + await releaseAndSettle() + + mocks.readFile.mockImplementation((path: string) => + Promise.resolve(path === file ? OPENCODE_SESSION : OPENCODE_MESSAGE) + ) + const recovered = await parseAgentSessionFileCached(candidate('opencode', file), 'linux') + + expect(recovered?.messageCount).toBe(1) + }) + + // Same guard as Kimi's: a live process mid-write must stay a parseable + // session, not a refusal. + it('still returns the session when a live process left a half-written message', async () => { + const file = sessionPath('OpenCodeHalfWritten') + mocks.readdir.mockResolvedValue([dirent('msg-1.json')]) + mocks.readFile.mockImplementation((path: string) => + Promise.resolve(path === file ? OPENCODE_SESSION : '{"role":"user",') + ) + + const session = await parseAgentSessionFileCached(candidate('opencode', file), 'linux') + + expect(session).toMatchObject({ agent: 'opencode', title: 'OpenCode session', messageCount: 0 }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-cursor-parser.ts b/src/main/ai-vault/session-scanner-cursor-parser.ts index b18402584e5..0f44aa923fa 100644 --- a/src/main/ai-vault/session-scanner-cursor-parser.ts +++ b/src/main/ai-vault/session-scanner-cursor-parser.ts @@ -1,5 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' @@ -33,7 +33,7 @@ export async function parseCursorSessionFile( platform: NodeJS.Platform = process.platform ): Promise { const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), + input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) return parseCursorSessionLines({ file, lines, platform }) diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts index 4ad6fa45f78..a43603bca47 100644 --- a/src/main/ai-vault/session-scanner-devin-parser.ts +++ b/src/main/ai-vault/session-scanner-devin-parser.ts @@ -1,4 +1,4 @@ -import { readFile } from 'node:fs/promises' +import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime } from './session-scanner-types' @@ -27,7 +27,11 @@ export async function parseDevinSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform) + return parseDevinSessionContent( + file, + await wslGatedReadFile(file.path, 'utf-8', 'scan'), + platform + ) } export function parseDevinSessionContent( diff --git a/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts b/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts new file mode 100644 index 00000000000..962a2e20fa9 --- /dev/null +++ b/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Dirent } from 'node:fs' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +const fsMocks = vi.hoisted(() => ({ readdir: vi.fn(), stat: vi.fn() })) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readdir: fsMocks.readdir, + stat: fsMocks.stat +})) + +import { + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, + WslTranscriptFsError +} from '../native-chat/wsl-transcript-fs-gate' +import { discoverFiles, walkSessionFiles } from './session-scanner-discovery' + +const SLOW_MESSAGE = + 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' + +function dirent(name: string): Dirent { + return { name, isDirectory: () => false, isFile: () => true } as Dirent +} + +let releaseStall: (() => void) | undefined + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve(undefined as T) + }) +} + +beforeEach(() => { + fsMocks.readdir.mockReset() + fsMocks.stat.mockReset() + fsMocks.readdir.mockResolvedValue([]) + releaseStall = undefined +}) + +// A stalled task holds its gate permit until the call settles; releasing it +// keeps the next case from fast-failing on an already-exhausted gate. +afterEach(async () => { + if (!vi.isFakeTimers()) { + return + } + releaseStall?.() + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() +}) + +describe('walkSessionFiles WSL gate refusals', () => { + it('rethrows gate refusals instead of reporting an empty tree', async () => { + const refusal = new WslTranscriptFsError('timeout', 'slow share') + await expect( + walkSessionFiles('\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions', 'codex', [], { + extensions: new Set(['.jsonl']), + readDirectory: async () => { + throw refusal + } + }) + ).rejects.toBe(refusal) + }) + + it('still treats ordinary readdir failures as an empty tree', async () => { + await expect( + walkSessionFiles('/missing/root', 'codex', [], { + extensions: new Set(['.jsonl']), + readDirectory: async () => { + throw Object.assign(new Error('no such directory'), { code: 'ENOENT' }) + } + }) + ).resolves.toEqual([]) + }) +}) + +describe('discoverFiles containment for a stalled WSL root', () => { + const UNC_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions' + + it('contains a stalled readdir to one root so a Promise.all fan-out still resolves', async () => { + vi.useFakeTimers() + { + fsMocks.readdir.mockImplementation((dir: string) => + dir === UNC_ROOT ? stalls() : Promise.resolve([]) + ) + const issues: AiVaultScanIssue[] = [] + const discovery = discoverFiles({ + rootDir: UNC_ROOT, + limit: 10, + agent: 'codex', + issues, + extensions: ['.jsonl'] + }) + const healthy = discoverFiles({ + rootDir: '/home/ada/.codex/sessions', + limit: 10, + agent: 'codex', + issues: [], + extensions: ['.jsonl'] + }) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + + await expect(Promise.all([discovery, healthy])).resolves.toEqual([ + { agent: 'codex', rootDir: UNC_ROOT, files: [] }, + { agent: 'codex', rootDir: '/home/ada/.codex/sessions', files: [] } + ]) + expect(issues).toEqual([{ agent: 'codex', path: UNC_ROOT, message: SLOW_MESSAGE }]) + } + }) + + it('reports every stalled file without spending one deadline per file', async () => { + vi.useFakeTimers() + { + const names = ['a', 'b', 'c', 'd', 'e'].map((name) => dirent(`${name}.jsonl`)) + fsMocks.readdir.mockResolvedValue(names) + fsMocks.stat.mockImplementation(stalls) + const issues: AiVaultScanIssue[] = [] + const discovery = discoverFiles({ + rootDir: UNC_ROOT, + limit: 10, + agent: 'codex', + issues, + extensions: ['.jsonl'] + }) + + // Two deadlines total, not five: once the first stalled stat is marked + // stuck, every later file fast-fails on the route instead of queueing. + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2) + + await expect(discovery).resolves.toEqual({ agent: 'codex', rootDir: UNC_ROOT, files: [] }) + expect(issues).toHaveLength(names.length) + expect(issues.every((issue) => issue.path.startsWith(UNC_ROOT))).toBe(true) + } + }) +}) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 031f94f3619..9ec569bc547 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -1,7 +1,9 @@ import type { Dirent } from 'node:fs' -import { readdir, stat } from 'node:fs/promises' import { extname, join } from 'node:path' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import { recordSessionScanIssue } from './session-scan-issues' import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types' import { errorMessage } from './session-scanner-values' @@ -14,15 +16,31 @@ export async function discoverFiles(args: { filePredicate?: (path: string) => boolean directoryPredicate?: (name: string, depth: number) => boolean }): Promise { - const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { - extensions: new Set(args.extensions), - filePredicate: args.filePredicate, - directoryPredicate: args.directoryPredicate - }) + let paths: string[] + try { + paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { + extensions: new Set(args.extensions), + filePredicate: args.filePredicate, + directoryPredicate: args.directoryPredicate + }) + } catch (err) { + // Why: discoverAiVaultSessionSources fans out with Promise.all, so one + // stalled distro would otherwise reject the whole vault scan — including + // every healthy local agent. Contain it to this root. + if (!(err instanceof WslTranscriptFsError)) { + throw err + } + recordSessionScanIssue(args.issues, { + agent: args.agent, + path: args.rootDir, + message: err.message + }) + return { agent: args.agent, rootDir: args.rootDir, files: [] } + } const files: FileWithMtime[] = [] for (const path of paths) { try { - const fileStat = await stat(path) + const fileStat = await wslGatedStat(path, 'scan') files.push({ path, mtimeMs: fileStat.mtimeMs, @@ -33,7 +51,11 @@ export async function discoverFiles(args: { nlink: fileStat.nlink }) } catch (err) { - args.issues.push({ agent: args.agent, path, message: errorMessage(err) }) + recordSessionScanIssue(args.issues, { + agent: args.agent, + path, + message: errorMessage(err) + }) } } return { @@ -63,9 +85,14 @@ export async function walkSessionFiles( try { entries = options.readDirectory ? await options.readDirectory(dirPath) - : await readdir(dirPath, { withFileTypes: true }) - } catch { + : await wslGatedReaddir(dirPath, 'scan', options.signal) + } catch (error) { options.signal?.throwIfAborted() + // Why: a gate refusal means the scan could not run, not that the tree is + // empty — swallowing it would misreport a stalled distro as "no transcript". + if (error instanceof WslTranscriptFsError) { + throw error + } return [] } diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts index 557920e7f40..c896b47a3e8 100644 --- a/src/main/ai-vault/session-scanner-droid-parser.ts +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -1,5 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' @@ -34,11 +34,16 @@ export async function parseDroidSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - return parseDroidSessionLines({ file, lines, platform }) + const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan') + const lines = createInterface({ input, crlfDelay: Infinity }) + try { + return await parseDroidSessionLines({ file, lines, platform }) + } finally { + // readline.close() leaves the underlying stream open; destroy it so a + // mid-parse throw cannot leak the gated transcript handle. + lines.close() + input.destroy() + } } export async function parseDroidSessionContent( diff --git a/src/main/ai-vault/session-scanner-fs-import-guard.test.ts b/src/main/ai-vault/session-scanner-fs-import-guard.test.ts new file mode 100644 index 00000000000..472ca932eac --- /dev/null +++ b/src/main/ai-vault/session-scanner-fs-import-guard.test.ts @@ -0,0 +1,81 @@ +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const AI_VAULT_DIR = join(import.meta.dirname, '.') +const NATIVE_CHAT_DIR = join(import.meta.dirname, '..', 'native-chat') + +// Transcript I/O in these modules must go through the WSL admission gate, so a +// stalled 9P mount cannot hang a scan or a Native Chat load (STA-4049). +const NATIVE_CHAT_MODULES = [ + 'transcript-read-cache.ts', + 'transcript-reader.ts', + 'transcript-tail-reader.ts', + 'transcript-file-version.ts', + 'transcript-incremental-reader.ts', + 'transcript-watch-engine.ts' +] + +// Adding to this list must be a reviewed act: each entry names why the file +// touches a path that can never be a WSL UNC transcript. +const ALLOWLIST = new Set([ + // `existsSync` on bundled module paths inside the app dir. + 'session-scanner-service-entry-path.ts', + 'session-scanner-service-spawn.ts', + 'session-scanner-worker-spawn.ts', + 'session-scanner-opencode-sqlite-worker-spawn.ts', + // On-demand IPC readers, gated in the STA-4049 follow-up. + 'session-scanner-claude-subagents.ts', + 'session-scanner-omp-subagent-listing.ts', + // Test-only fixture builder. + 'session-scanner-test-fixtures.ts' +]) + +const FS_IMPORT = /import\s+([\s\S]*?)\s+from\s+['"]node:fs(?:\/promises)?['"]/g + +function valueImportsOfNodeFs(source: string): string[] { + return [...source.matchAll(FS_IMPORT)] + .map((match) => match[1]!.trim()) + .filter((clause) => !clause.startsWith('type')) +} + +async function guardedFiles(): Promise<{ name: string; path: string }[]> { + const aiVault = (await readdir(AI_VAULT_DIR)) + .filter((name) => name.startsWith('session-scanner') || name === 'session-title-file-reader.ts') + .filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts')) + .filter((name) => !ALLOWLIST.has(name)) + .map((name) => ({ name, path: join(AI_VAULT_DIR, name) })) + return [ + ...aiVault, + ...NATIVE_CHAT_MODULES.map((name) => ({ name, path: join(NATIVE_CHAT_DIR, name) })) + ] +} + +describe('WSL transcript gate import guard', () => { + it('leaves no raw node:fs value import in a gated transcript module', async () => { + const files = await guardedFiles() + const offenders: string[] = [] + for (const file of files) { + for (const clause of valueImportsOfNodeFs(await readFile(file.path, 'utf-8'))) { + offenders.push(`${file.name}: import ${clause} from 'node:fs…'`) + } + } + expect(offenders).toEqual([]) + }) + + it('actually covers the modules it claims to', async () => { + const names = (await guardedFiles()).map((file) => file.name) + expect(names).toEqual(expect.arrayContaining(NATIVE_CHAT_MODULES)) + // The V12–V17 sites the previous `*parser*` wording silently skipped. + expect(names).toEqual( + expect.arrayContaining([ + 'session-title-file-reader.ts', + 'session-scanner.ts', + 'session-scanner-values.ts', + 'session-scanner-codex-title-index.ts', + 'session-scanner-kimi-paths.ts', + 'session-scanner-opencode-sources.ts' + ]) + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-gemini-parsers.ts b/src/main/ai-vault/session-scanner-gemini-parsers.ts index c3cda8856bb..d8d8a485d8f 100644 --- a/src/main/ai-vault/session-scanner-gemini-parsers.ts +++ b/src/main/ai-vault/session-scanner-gemini-parsers.ts @@ -1,6 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { @@ -34,7 +33,11 @@ export async function parseGeminiSessionFile( return parseGeminiJsonlSessionFile(file, platform) } - return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform) + return parseGeminiJsonSessionContent( + file, + await wslGatedReadFile(file.path, 'utf-8', 'scan'), + platform + ) } export async function parseGeminiSessionContent( @@ -83,7 +86,7 @@ export async function parseGeminiJsonlSessionFile( platform: NodeJS.Platform ): Promise { const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), + input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) return parseGeminiJsonlSessionLines({ file, lines, platform }) diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 250ebaf9ac9..d99893acaa2 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -1,6 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -41,7 +40,9 @@ export async function parseRovoSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const metadata = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + const metadata = asRecord( + JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown + ) if (!metadata) { return null } @@ -175,11 +176,16 @@ export async function parseMessageGraphSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - return parseMessageGraphSessionLines({ agent, file, lines, platform }) + const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan') + const lines = createInterface({ input, crlfDelay: Infinity }) + try { + return await parseMessageGraphSessionLines({ agent, file, lines, platform }) + } finally { + // readline.close() leaves the underlying stream open; destroy it so a + // mid-parse throw cannot leak the gated transcript handle. + lines.close() + input.destroy() + } } export async function parseMessageGraphSessionContent( diff --git a/src/main/ai-vault/session-scanner-grok-parser.ts b/src/main/ai-vault/session-scanner-grok-parser.ts index 46ed29e8f1f..94e41e4b052 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.ts @@ -1,5 +1,5 @@ -import { createReadStream } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -36,7 +36,7 @@ export async function parseGrokSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown) if (!record) { return null } @@ -62,12 +62,13 @@ async function consumeGrokChatHistory( accumulator: SessionAccumulator, sessionDir: string ): Promise { + const input = openTranscriptReadStream( + join(sessionDir, 'chat_history.jsonl'), + { encoding: 'utf-8' }, + 'scan' + ) + const lines = createInterface({ input, crlfDelay: Infinity }) try { - const lines = createInterface({ - input: createReadStream(join(sessionDir, 'chat_history.jsonl'), { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - for await (const line of lines) { const record = parseJsonObject(line) if (!record) { @@ -111,8 +112,18 @@ async function consumeGrokChatHistory( seedFirstUserPrompt: false }) } - } catch { - // Summary-only sessions still provide enough metadata for the Vault list. + } catch (error) { + // Summary-only sessions still provide enough metadata for the Vault list. A + // gate refusal is a different thing — the history exists but is unreachable, + // so a message-less session must not be cached under the summary's mtime. + if (error instanceof WslTranscriptFsError) { + throw error + } + } finally { + // readline.close() leaves the underlying stream open; destroy it so a + // mid-read failure cannot leak the gated transcript handle. + lines.close() + input.destroy() } } diff --git a/src/main/ai-vault/session-scanner-hermes-parser.ts b/src/main/ai-vault/session-scanner-hermes-parser.ts index be844cc6627..017fd9c8fdc 100644 --- a/src/main/ai-vault/session-scanner-hermes-parser.ts +++ b/src/main/ai-vault/session-scanner-hermes-parser.ts @@ -1,4 +1,4 @@ -import { readFile } from 'node:fs/promises' +import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime } from './session-scanner-types' @@ -26,7 +26,11 @@ export async function parseHermesSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform) + return parseHermesSessionContent( + file, + await wslGatedReadFile(file.path, 'utf-8', 'scan'), + platform + ) } export async function parseHermesSessionContent( diff --git a/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts new file mode 100644 index 00000000000..1a5da65f1a9 --- /dev/null +++ b/src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' + +const CODEX_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' +const CODEX_SESSION_FILE = `${CODEX_HOME}\\sessions\\2026\\01\\01\\rollout-1.jsonl` +const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const KIMI_INDEX = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.kimi\\session_index.jsonl' + +const mocks = vi.hoisted(() => ({ stat: vi.fn(), open: vi.fn(), readFile: vi.fn() })) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: mocks.stat, + open: mocks.open, + readFile: mocks.readFile +})) + +import { + readCodexSessionIndexTitle, + resetCodexSessionIndexTitleCacheForTests, + _hasCodexSessionIndexTitleCacheEntryForTest +} from './session-scanner-codex-title-index' +import { + clearKimiSessionIndexCache, + hasKimiSessionIndexCacheEntryForTests, + readKimiWorkDirBySessionId +} from './session-scanner-kimi-paths' +import { readJsonObjectIfExists } from './session-scanner-values' +import { + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, + WslTranscriptFsError +} from '../native-chat/wsl-transcript-fs-gate' + +// Identity must not change between the refused and the recovered read, or the +// cache would miss for that reason instead of because the entry was evicted. +const INDEX_STATS = { size: 128, mtimeMs: 7, ctimeMs: 7 } + +let releaseStall: (() => void) | undefined + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve(undefined as never) + }) +} + +function servingHandle(body: string) { + const bytes = Buffer.from(body) + return { + read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const slice = bytes.subarray(position, Math.min(position + length, bytes.length)) + slice.copy(buffer, offset) + return { bytesRead: slice.length, buffer } + }), + close: vi.fn(async () => {}) + } +} + +async function releaseAndSettle(): Promise { + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) +} + +beforeEach(() => { + resetCodexSessionIndexTitleCacheForTests() + clearKimiSessionIndexCache() + mocks.stat.mockReset() + mocks.open.mockReset() + mocks.readFile.mockReset() + mocks.stat.mockResolvedValue(INDEX_STATS) + releaseStall = undefined + vi.useFakeTimers() +}) + +afterEach(async () => { + await releaseAndSettle() + vi.useRealTimers() +}) + +describe('memoized WSL session indexes under a stalled mount', () => { + it('evicts the Codex title index instead of pinning "no titles"', async () => { + mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) }) + const refused = readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + + expect(await refused).toBeNull() + expect(_hasCodexSessionIndexTitleCacheEntryForTest(CODEX_HOME)).toBe(false) + + await releaseAndSettle() + mocks.open.mockResolvedValue( + servingHandle(`${JSON.stringify({ id: SESSION_ID, thread_name: 'Ship the gate' })}\n`) + ) + + // Same size/mtime as the refused read: only the eviction can produce a hit. + expect(await readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID)).toBe( + 'Ship the gate' + ) + }) + + it('evicts the Kimi work-dir index instead of pinning "no cwd"', async () => { + mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) }) + const refused = readKimiWorkDirBySessionId(KIMI_INDEX) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + + expect(await refused).toEqual(new Map()) + expect(hasKimiSessionIndexCacheEntryForTests(KIMI_INDEX)).toBe(false) + + await releaseAndSettle() + mocks.open.mockResolvedValue( + servingHandle(`${JSON.stringify({ sessionId: SESSION_ID, workDir: '/repo/app' })}\n`) + ) + + expect(await readKimiWorkDirBySessionId(KIMI_INDEX)).toEqual( + new Map([[SESSION_ID, '/repo/app']]) + ) + }) + + it('surfaces a refused optional JSON enrichment instead of caching it as absent', async () => { + mocks.readFile.mockImplementation(stalls) + const pending = readJsonObjectIfExists(`${CODEX_HOME}\\history.json`).catch( + (error: unknown) => error + ) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + + // A throw, not `null`: `parseSessionCandidate` turns it into a scan issue, + // where `null` would cache an un-enriched session that never re-reads. + expect(await pending).toBeInstanceOf(WslTranscriptFsError) + }) + + it('still degrades a missing optional JSON file to null', async () => { + mocks.readFile.mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' as const }) + ) + await expect(readJsonObjectIfExists(`${CODEX_HOME}\\missing.json`)).resolves.toBeNull() + }) +}) diff --git a/src/main/ai-vault/session-scanner-kimi-parser.ts b/src/main/ai-vault/session-scanner-kimi-parser.ts index 61341098e0d..ba6deaa8fb8 100644 --- a/src/main/ai-vault/session-scanner-kimi-parser.ts +++ b/src/main/ai-vault/session-scanner-kimi-parser.ts @@ -1,5 +1,5 @@ -import { createReadStream } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { @@ -36,8 +36,16 @@ export async function parseKimiSessionFile( ): Promise { let stateRecord: Record | null try { - stateRecord = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) - } catch { + stateRecord = asRecord( + JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown + ) + } catch (error) { + // Why: a missing/half-written state file is genuinely "no session", but a + // gate refusal is not — returning null would let the parse cache store that + // degraded answer under the file's unchanged mtime and never retry. + if (error instanceof WslTranscriptFsError) { + throw error + } return null } if (!stateRecord) { @@ -83,11 +91,9 @@ async function consumeKimiWireTranscript( } } + const input = openTranscriptReadStream(wirePath, { encoding: 'utf-8' }, 'scan') + const lines = createInterface({ input, crlfDelay: Infinity }) try { - const lines = createInterface({ - input: createReadStream(wirePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) for await (const line of lines) { const record = parseJsonObject(line) if (!record) { @@ -111,9 +117,18 @@ async function consumeKimiWireTranscript( break } } - } catch { + } catch (error) { // No transcript yet (session created but never ran a turn) — metadata-only - // sessions still belong in the panel. + // sessions still belong in the panel. A gate refusal instead means the wire + // bytes exist but are unreachable; a partial session must not be cached. + if (error instanceof WslTranscriptFsError) { + throw error + } + } finally { + // readline.close() leaves the underlying stream open; destroy it so a + // mid-read failure cannot leak the gated transcript handle. + lines.close() + input.destroy() } flushAssistant() } diff --git a/src/main/ai-vault/session-scanner-kimi-paths.ts b/src/main/ai-vault/session-scanner-kimi-paths.ts index 7ed457f6c07..590e4aee814 100644 --- a/src/main/ai-vault/session-scanner-kimi-paths.ts +++ b/src/main/ai-vault/session-scanner-kimi-paths.ts @@ -1,8 +1,8 @@ -import { stat } from 'node:fs/promises' -import { createReadStream } from 'node:fs' import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' +import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { asRecord, extractString } from './session-scanner-values' import { KimiSessionIndexCache, @@ -78,9 +78,9 @@ export function hasKimiSessionIndexCacheEntryForTests(indexPath: string): boolea export async function readKimiWorkDirBySessionId(indexPath: string): Promise> { const generation = workDirCacheByIndexPath.beginRead() - let identity: Awaited> + let identity: Awaited> try { - identity = await stat(indexPath) + identity = await wslGatedStat(indexPath, 'scan') } catch { // Missing index (e.g. user deleted it): sessions still list, just without cwd. workDirCacheByIndexPath.delete(indexPath, generation) @@ -95,18 +95,30 @@ export async function readKimiWorkDirBySessionId(indexPath: string): Promise parseKimiSessionIndex(indexPath) + () => + parseKimiSessionIndex(indexPath).then(({ map, refused }) => { + // Why: a gate refusal is a stalled distro, not a cwd-less index — evict + // so the partial map is not served until the index's identity changes. + // Inside the loader, not at the call site: only the caller that missed + // runs this, so a concurrent hit would otherwise serve and keep it. + if (refused) { + workDirCacheByIndexPath.delete(indexPath, generation) + } + return map + }) ) } -async function parseKimiSessionIndex(indexPath: string): Promise> { +async function parseKimiSessionIndex( + indexPath: string +): Promise<{ map: Map; refused: boolean }> { const map = new Map() // Why: never reject. This promise is memoized and shared by every session // under one Kimi home; a mid-read failure (file deleted after stat, EACCES) // must degrade to whatever was parsed so the other sessions still list. try { const lines = createInterface({ - input: createReadStream(indexPath, { encoding: 'utf-8' }), + input: openTranscriptReadStream(indexPath, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) for await (const line of lines) { @@ -126,8 +138,9 @@ async function parseKimiSessionIndex(indexPath: string): Promise { const filePath = join(args.artifactDir, args.name) try { - const fileStat = await stat(filePath) + const fileStat = await wslGatedStat(filePath, OMP_SUBAGENT_FS_PRIORITY) // The shared OMP parser decorates every parse with an artifact-dir count, so // a child row carries its own grandchild count. It is accurate but has no // renderer — subagent rows don't expand — and this lister is local-only, so @@ -107,7 +116,11 @@ async function parseOmpSubagentTranscript(args: { } } } catch (err) { - args.issues.push({ agent: 'omp', path: filePath, message: errorMessage(err) }) + recordSessionScanIssue(args.issues, { + agent: 'omp', + path: filePath, + message: errorMessage(err) + }) return null } } diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts index 9136e8c0d34..57cadebeee0 100644 --- a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts @@ -1,4 +1,4 @@ -import { readdir } from 'node:fs/promises' +import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access' import { basename, dirname, extname, join } from 'node:path' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts' @@ -51,7 +51,7 @@ export function isOmpSubagentTranscriptFileName(name: string, isFile: boolean): export async function countOmpSubagentTranscripts(transcriptFilePath: string): Promise { let entries try { - entries = await readdir(ompArtifactDirFor(transcriptFilePath), { withFileTypes: true }) + entries = await wslGatedReaddir(ompArtifactDirFor(transcriptFilePath), 'scan') } catch { return 0 } diff --git a/src/main/ai-vault/session-scanner-opencode-parser.ts b/src/main/ai-vault/session-scanner-opencode-parser.ts index 4231f7d9de4..30223e70866 100644 --- a/src/main/ai-vault/session-scanner-opencode-parser.ts +++ b/src/main/ai-vault/session-scanner-opencode-parser.ts @@ -1,4 +1,5 @@ -import { readFile, readdir } from 'node:fs/promises' +import { wslGatedReaddir, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { join } from 'node:path' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' @@ -26,7 +27,7 @@ export async function parseOpenCodeSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown) if (!record) { return null } @@ -47,8 +48,13 @@ export async function parseOpenCodeSessionFile( async function readOpenCodeMessagesInOrder(messageDir: string): Promise[]> { let entries try { - entries = await readdir(messageDir, { withFileTypes: true }) - } catch { + entries = await wslGatedReaddir(messageDir, 'scan') + } catch (error) { + // A session with no message dir yet is empty; a gate refusal is not — an + // empty transcript would otherwise be cached under the session's mtime. + if (error instanceof WslTranscriptFsError) { + throw error + } return [] } const messages: { name: string; createdMs: number; message: Record }[] = [] @@ -59,11 +65,15 @@ async function readOpenCodeMessagesInOrder(messageDir: string): Promise | null = null try { message = asRecord( - JSON.parse(await readFile(join(messageDir, entry.name), 'utf-8')) as unknown + JSON.parse(await wslGatedReadFile(join(messageDir, entry.name), 'utf-8', 'scan')) as unknown ) - } catch { + } catch (error) { // A live OpenCode process can leave a half-written file; skip it rather - // than discard the whole session. + // than discard the whole session. A refused read is not a bad file — the + // partial transcript it would produce must not become the cached answer. + if (error instanceof WslTranscriptFsError) { + throw error + } continue } if (!message) { diff --git a/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts new file mode 100644 index 00000000000..297f4b78b06 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Dirent } from 'node:fs' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +const WSL_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' +const WSL_DATA_DIR = `${WSL_HOME}/.local/share/opencode` + +const mocks = vi.hoisted(() => ({ readdir: vi.fn(), stat: vi.fn() })) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readdir: mocks.readdir, + stat: mocks.stat +})) + +// The SQLite leg spawns a real worker thread, which fake timers cannot drive. +vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({ + listOpenCodeSqliteSessionsViaWorker: async () => [] +})) + +import { opencodeDiscoveries } from './session-scanner-opencode-sources' +import { WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } from '../native-chat/wsl-transcript-fs-gate' + +let releaseStall: (() => void) | undefined + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } +} + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve(undefined as never) + }) +} + +beforeEach(() => { + mocks.readdir.mockReset() + mocks.stat.mockReset() + releaseStall = undefined + vi.useFakeTimers() +}) + +afterEach(async () => { + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() +}) + +describe('OpenCode source discovery with a stalled WSL data directory', () => { + it('reports the stalled home as an issue and still resolves every discovery', async () => { + mocks.readdir.mockImplementation((dir: string) => + dir === WSL_DATA_DIR ? stalls() : Promise.resolve([] as Dirent[]) + ) + const issues: AiVaultScanIssue[] = [] + const discoveries = Promise.all( + opencodeDiscoveries( + { opencodeStorageDir: '/home/ada/.local/share/opencode/storage' }, + [WSL_HOME], + 10, + issues + ) + ) + // Two deadlines: the data-dir probe, then the storage scan that queued + // behind it before the route was flagged stuck. + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2) + + // Zero databases for that home is the degraded answer; without the issue it + // would be indistinguishable from "OpenCode was never installed there". + const resolved = await discoveries + expect(resolved).toHaveLength(2) + expect(resolved.every((discovery) => discovery.files.length === 0)).toBe(true) + expect(issues.some((issue) => issue.path === WSL_DATA_DIR)).toBe(true) + expect(issues.every((issue) => issue.agent === 'opencode')).toBe(true) + }) + + it('reports a refused primary data directory instead of scanning clean', async () => { + const previousXdg = process.env.XDG_DATA_HOME + const previousDb = process.env.OPENCODE_DB + process.env.XDG_DATA_HOME = WSL_HOME + delete process.env.OPENCODE_DB + try { + mocks.readdir.mockImplementation(stalls) + const issues: AiVaultScanIssue[] = [] + const discoveries = Promise.all(opencodeDiscoveries({}, [], 10, issues)) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2) + + // The primary source is the one per-root containment cannot reach, so a + // silent [] here reads as "no OpenCode sessions" on a clean scan. + await expect(discoveries).resolves.toHaveLength(1) + expect( + issues.some( + (issue) => issue.agent === 'opencode' && issue.path === `${WSL_HOME}/opencode` + ) + ).toBe(true) + } finally { + restoreEnv('XDG_DATA_HOME', previousXdg) + restoreEnv('OPENCODE_DB', previousDb) + } + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts index 6d84965f8c0..365e6334512 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -1,8 +1,10 @@ -import { readdir } from 'node:fs/promises' import { dirname, join } from 'node:path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { resolveOpenCodeStorageDirectory } from '../opencode/opencode-data-directory' import { listOpenCodeDatabases } from '../opencode-usage/scanner' +import { recordSessionScanIssue } from './session-scan-issues' import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' @@ -16,7 +18,7 @@ export function opencodeDiscoveries( return storageDirs.map(async (storageDir, index) => discoverOpenCodeSessions({ storageDir, - dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index), + dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues), limitPerAgent: limit, issues }) @@ -37,32 +39,47 @@ async function opencodeDbPathsForSource( options: AiVaultScanOptions, wslHomeDirs: readonly string[], storageDir: string, - sourceIndex: number + sourceIndex: number, + issues: AiVaultScanIssue[] ): Promise { if (options.opencodeDbPaths) { return sourceIndex === 0 ? options.opencodeDbPaths : [] } // Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir. if (sourceIndex === 0 && options.opencodeStorageDir) { - return listOpenCodeDatabasesInDirectory(dirname(storageDir)) + return listOpenCodeDatabasesInDirectory(dirname(storageDir), issues) } if (sourceIndex === 0) { - return listOpenCodeDatabases() + return listOpenCodeDatabases((path, error) => { + recordSessionScanIssue(issues, { agent: 'opencode', path, message: error.message }) + }) } const wslHomeDir = wslHomeDirs[sourceIndex - 1] return wslHomeDir - ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode')) + ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'), issues) : [] } -async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise { +async function listOpenCodeDatabasesInDirectory( + dataDir: string, + issues: AiVaultScanIssue[] +): Promise { try { - const entries = await readdir(dataDir, { withFileTypes: true }) + const entries = await wslGatedReaddir(dataDir, 'scan') return entries .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) .map((entry) => join(dataDir, entry.name)) .sort() - } catch { + } catch (error) { + // A stalled WSL data dir still degrades to "no databases", but the gap has + // to be reportable — an empty list otherwise reads as "OpenCode not used". + if (error instanceof WslTranscriptFsError) { + recordSessionScanIssue(issues, { + agent: 'opencode', + path: dataDir, + message: error.message + }) + } return [] } } diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 727d70ac319..563055ffa39 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -1,5 +1,7 @@ -import { createReadStream } from 'node:fs' -import { open } from 'node:fs/promises' +import { + openTranscriptReadStream, + readTranscriptSlice +} from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser' import { parseAgentSessionFile } from './session-scanner-agent-parser' @@ -305,13 +307,8 @@ async function parseResumableCandidate(args: { // agent transcripts are append-only so that trade is accepted (worst case is // a stale vault row until the file is next truncated or the app restarts). async function endsWithNewlineAt(path: string, offset: number): Promise { - const handle = await open(path, 'r') - try { - const { bytesRead, buffer } = await handle.read(Buffer.alloc(1), 0, 1, offset - 1) - return bytesRead === 1 && buffer[0] === NEWLINE_BYTE - } finally { - await handle.close() - } + const slice = await readTranscriptSlice(path, offset - 1, 1, 'scan') + return slice.length === 1 && slice[0] === NEWLINE_BYTE } type JsonlReadResult = { @@ -336,7 +333,7 @@ async function consumeCompleteJsonlLines(args: { let remainderParts: Buffer[] = [] let remainderLength = 0 - const stream = createReadStream(args.path, { start: args.start }) + const stream = openTranscriptReadStream(args.path, { start: args.start }, 'scan') for await (const chunk of stream as AsyncIterable) { bytesRead += chunk.length // Why check the chunk alone: the pieces held over are all mid-line, so none diff --git a/src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts new file mode 100644 index 00000000000..9eea5176c66 --- /dev/null +++ b/src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import type { SessionFileCandidate } from './session-scanner-types' + +const STALLED_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\a.jsonl' +const SIBLING_PATH = '\\\\wsl.localhost\\Debian\\home\\ada\\.claude\\projects\\p\\b.jsonl' + +const mocks = vi.hoisted(() => ({ open: vi.fn(), readdir: vi.fn() })) + +// readdir too: every claude parse counts sibling subagent transcripts, and an +// unmocked one would reach the host UNC path and stall under fake timers. +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + open: mocks.open, + readdir: mocks.readdir +})) + +import { + parseAgentSessionFileCached, + resetSessionParseCacheForTests +} from './session-scanner-parse-cache' +import { + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, + WslTranscriptFsError +} from '../native-chat/wsl-transcript-fs-gate' + +type ReadResult = { bytesRead: number; buffer: Buffer } + +// A stalled task keeps the gate's single scan slot until the call settles, so +// every case releases its stall before the next one runs. +let releaseStall: (() => void) | undefined + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) }) + }) +} + +function servingHandle(body: Buffer) { + return { + read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const slice = body.subarray(position, Math.min(position + length, body.length)) + slice.copy(buffer, offset) + return { bytesRead: slice.length, buffer } + }), + close: vi.fn(async () => {}) + } +} + +function record(index: number, text: string): string { + return JSON.stringify({ + type: 'user', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + timestamp: new Date(1740000000000 + index * 60_000).toISOString(), + cwd: '/repo/app', + gitBranch: 'main', + message: { role: 'user', content: text } + }) +} + +function body(count: number): Buffer { + return Buffer.from( + Array.from({ length: count }, (_, index) => `${record(index, `line ${index}`)}\n`).join('') + ) +} + +function candidate(path: string, bytes: Buffer, mtimeMs: number): SessionFileCandidate { + return { + agent: 'claude', + file: { + path, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + sizeBytes: bytes.length + }, + codexHome: null + } +} + +async function releaseAndSettle(): Promise { + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) +} + +beforeEach(() => { + resetSessionParseCacheForTests() + mocks.open.mockReset() + mocks.readdir.mockReset() + mocks.readdir.mockResolvedValue([]) + releaseStall = undefined + vi.useFakeTimers() +}) + +afterEach(async () => { + await releaseAndSettle() + vi.useRealTimers() +}) + +describe('AI Vault session parse with a stalled WSL transcript body read', () => { + it('refuses the stalled candidate and still parses a sibling on a healthy distro', async () => { + mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) }) + // Rejecting (not resolving null) is what lets `parseSessionCandidate` + // report it as an issue instead of silently dropping the session. The + // assertion is attached before the clock moves so the rejection is never + // momentarily unhandled. + const refusal = expect( + parseAgentSessionFileCached(candidate(STALLED_PATH, body(2), 1), 'linux') + ).rejects.toBeInstanceOf(WslTranscriptFsError) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + await refusal + + await releaseAndSettle() + const siblingBytes = body(2) + mocks.open.mockResolvedValue(servingHandle(siblingBytes)) + + const sibling = await parseAgentSessionFileCached( + candidate(SIBLING_PATH, siblingBytes, 1), + 'linux' + ) + expect(sibling?.messageCount).toBe(2) + }) + + it('caches no resume point when the newline probe is refused mid-append', async () => { + const first = body(2) + mocks.open.mockResolvedValue(servingHandle(first)) + const seeded = await parseAgentSessionFileCached(candidate(STALLED_PATH, first, 1), 'linux') + expect(seeded?.messageCount).toBe(2) + + const grown = body(3) + const serving = servingHandle(grown) + mocks.open.mockResolvedValue({ + // Only the 1-byte resume probe stalls; the body read stays healthy. + read: vi.fn((buffer: Buffer, offset: number, length: number, position: number) => + length === 1 ? stalls() : serving.read(buffer, offset, length, position) + ), + close: vi.fn(async () => {}) + }) + const refused = expect( + parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux') + ).rejects.toBeInstanceOf(WslTranscriptFsError) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + await refused + + await releaseAndSettle() + mocks.open.mockResolvedValue(servingHandle(grown)) + const recovered = await parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux') + + resetSessionParseCacheForTests() + const cold = await parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux') + expect(recovered).toEqual(cold) + expect(recovered?.messageCount).toBe(3) + }) +}) diff --git a/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts b/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts new file mode 100644 index 00000000000..7470b3430d2 --- /dev/null +++ b/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Readable } from 'node:stream' +import type * as NodeReadlineModule from 'node:readline' +import type * as WslFsAccessModule from '../native-chat/wsl-transcript-fs-access' +import type { FileWithMtime } from './session-scanner-types' + +// readline.close() leaves its input open, so a parser that stops consuming +// mid-file (a throw, a break) strands the gated transcript handle until the +// gate's deadline and delays every later scan. These pin the finally blocks. +const mocks = vi.hoisted(() => ({ + openStream: vi.fn(), + readFile: vi.fn(), + stat: vi.fn(), + createInterface: vi.fn() +})) + +vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => ({ + ...(await importOriginal()), + openTranscriptReadStream: mocks.openStream, + wslGatedReadFile: mocks.readFile, + wslGatedStat: mocks.stat +})) + +vi.mock('node:readline', async (importOriginal) => ({ + ...(await importOriginal()), + createInterface: mocks.createInterface +})) + +import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser' +import { parseDroidSessionFile } from './session-scanner-droid-parser' +import { parseMessageGraphSessionFile } from './session-scanner-graph-parsers' +import { parseGrokSessionFile } from './session-scanner-grok-parser' +import { parseKimiSessionFile } from './session-scanner-kimi-parser' +import { clearKimiSessionIndexCache } from './session-scanner-kimi-paths' + +const PARSE_FAILURE = 'parser failed mid-transcript' + +const opened: { path: string; stream: Readable }[] = [] +const interfaces: { close: ReturnType }[] = [] + +function file(path: string): FileWithMtime { + return { path, mtimeMs: 1, modifiedAt: '2026-06-01T10:05:00.000Z', sizeBytes: 128 } +} + +function lastOpened(): { path: string; stream: Readable } { + const entry = opened.at(-1) + if (!entry) { + throw new Error('no transcript stream was opened') + } + return entry +} + +function expectStreamTornDown(): void { + expect(lastOpened().stream.destroyed).toBe(true) + expect(interfaces.at(-1)?.close).toHaveBeenCalled() +} + +beforeEach(() => { + opened.length = 0 + interfaces.length = 0 + clearKimiSessionIndexCache() + mocks.openStream.mockReset() + mocks.readFile.mockReset() + mocks.stat.mockReset() + mocks.createInterface.mockReset() + + mocks.openStream.mockImplementation((path: string) => { + const stream = new Readable({ + read() { + this.push(null) + } + }) + opened.push({ path, stream }) + return stream + }) + // One line, then a consumer-side throw: the parser must still tear the + // stream down on its way out. + mocks.createInterface.mockImplementation(() => { + const lines = { + close: vi.fn(), + [Symbol.asyncIterator]: async function* () { + yield '{}' + throw new Error(PARSE_FAILURE) + } + } + interfaces.push(lines) + return lines + }) +}) + +describe('session parsers that stop consuming a gated transcript early', () => { + it.each([ + ['antigravity', () => parseAntigravitySessionFile(file('/w/conversation.jsonl'), 'linux')], + ['droid', () => parseDroidSessionFile(file('/w/session.jsonl'), 'linux')], + ['message graph', () => parseMessageGraphSessionFile('pi', file('/w/session.jsonl'), 'linux')] + ])('destroys the stream when the %s parse throws', async (_agent, parse) => { + await expect(parse()).rejects.toThrow(PARSE_FAILURE) + + expectStreamTornDown() + }) + + it('destroys the chat_history stream when the Grok parse swallows the failure', async () => { + mocks.readFile.mockResolvedValue(JSON.stringify({ info: { id: 'ses-1' } })) + + // Grok degrades to a summary-only session on a non-gate failure, so the + // teardown has no rejection to ride out on. + await expect( + parseGrokSessionFile(file('/w/.grok/sessions/ses-1/session.json')) + ).resolves.toBeTruthy() + + expect(lastOpened().path).toContain('chat_history.jsonl') + expectStreamTornDown() + }) + + it('destroys the wire stream when the Kimi parse swallows the failure', async () => { + mocks.readFile.mockResolvedValue(JSON.stringify({ title: 'Kimi session' })) + // No session_index.jsonl, so only the wire transcript opens a stream. + mocks.stat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + + await expect( + parseKimiSessionFile(file('/w/.kimi-code/sessions/wd_app/session_abc/state.json')) + ).resolves.toBeTruthy() + + expect(lastOpened().path).toContain('wire.jsonl') + expectStreamTornDown() + }) +}) diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 92db2241320..f554268c7e5 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -1,5 +1,5 @@ import { remoteSessionContentLines } from './remote-session-content-lines' -import { createReadStream } from 'node:fs' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' @@ -210,7 +210,7 @@ export async function parseClaudeSessionFile( platform: NodeJS.Platform = process.platform ): Promise { const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), + input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) return parseClaudeSessionLines({ file, lines, platform }) diff --git a/src/main/ai-vault/session-scanner-scope-discovery.ts b/src/main/ai-vault/session-scanner-scope-discovery.ts index b0a4792ffd6..e787aef628e 100644 --- a/src/main/ai-vault/session-scanner-scope-discovery.ts +++ b/src/main/ai-vault/session-scanner-scope-discovery.ts @@ -1,13 +1,18 @@ -import { createReadStream } from 'node:fs' -import { readdir, stat } from 'node:fs/promises' import { createInterface } from 'node:readline' import { extname, join } from 'node:path' +import { + openTranscriptReadStream, + wslGatedReaddir, + wslGatedStat +} from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { isPathInsideOrEqual, normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { parseWslUncPath } from '../../shared/wsl-paths' +import { recordSessionScanIssue } from './session-scan-issues' import type { FileWithMtime } from './session-scanner-types' import { errorMessage, extractString, parseJsonObject } from './session-scanner-values' @@ -26,7 +31,18 @@ export function resetProjectDirCwdCacheForTests(): void { projectDirCwdCache.clear() } -async function cachedProjectDirCwd(projectDir: string): Promise { +// A gate refusal is a stalled WSL root, not an empty one: keep the existing +// degrade-to-empty containment but make the gap visible in the scan issues. +function recordRefusal(issues: AiVaultScanIssue[], path: string, error: unknown): void { + if (error instanceof WslTranscriptFsError) { + recordSessionScanIssue(issues, { agent: 'claude', path, message: error.message }) + } +} + +async function cachedProjectDirCwd( + projectDir: string, + issues: AiVaultScanIssue[] +): Promise { const cached = projectDirCwdCache.get(projectDir) if (cached !== undefined) { // Refresh recency so hot in-scope dirs outlive one-off ones at the cap. @@ -34,7 +50,7 @@ async function cachedProjectDirCwd(projectDir: string): Promise { projectDirCwdCache.set(projectDir, cached) return cached } - const cwd = await readProjectDirCwd(projectDir) + const cwd = await readProjectDirCwd(projectDir, issues) if (cwd) { if (projectDirCwdCache.size >= PROJECT_DIR_CWD_CACHE_MAX) { const oldest = projectDirCwdCache.keys().next() @@ -70,8 +86,8 @@ export async function discoverInScopeClaudeFiles(args: { const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths) const collected = new Map() for (const rootDir of args.rootDirs) { - for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) { - const cwd = await cachedProjectDirCwd(projectDir) + for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes, args.issues)) { + const cwd = await cachedProjectDirCwd(projectDir, args.issues) if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) { continue } @@ -148,12 +164,14 @@ function isCwdInsideScopePath(scopePath: string, cwd: string): boolean { async function listProjectDirs( rootDir: string, - scopeProjectPrefixes: ReadonlySet + scopeProjectPrefixes: ReadonlySet, + issues: AiVaultScanIssue[] ): Promise { let entries try { - entries = await readdir(rootDir, { withFileTypes: true }) - } catch { + entries = await wslGatedReaddir(rootDir, 'scan') + } catch (err) { + recordRefusal(issues, rootDir, err) return [] } return entries @@ -163,10 +181,13 @@ async function listProjectDirs( .map((entry) => join(rootDir, entry.name)) } -async function readProjectDirCwd(projectDir: string): Promise { - const files = await newestClaudeFilesInDir(projectDir) +async function readProjectDirCwd( + projectDir: string, + issues: AiVaultScanIssue[] +): Promise { + const files = await newestClaudeFilesInDir(projectDir, issues) for (const file of files.slice(0, REPRESENTATIVE_FILE_LIMIT)) { - const cwd = await readFirstCwd(file) + const cwd = await readFirstCwd(file, issues) if (cwd) { return cwd } @@ -174,11 +195,15 @@ async function readProjectDirCwd(projectDir: string): Promise { return null } -async function newestClaudeFilesInDir(projectDir: string): Promise { +async function newestClaudeFilesInDir( + projectDir: string, + issues: AiVaultScanIssue[] +): Promise { let entries try { - entries = await readdir(projectDir, { withFileTypes: true }) - } catch { + entries = await wslGatedReaddir(projectDir, 'scan') + } catch (err) { + recordRefusal(issues, projectDir, err) return [] } const newest: { path: string; mtimeMs: number }[] = [] @@ -190,18 +215,19 @@ async function newestClaudeFilesInDir(projectDir: string): Promise { try { addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, { path, - mtimeMs: (await stat(path)).mtimeMs + mtimeMs: (await wslGatedStat(path, 'scan')).mtimeMs }) - } catch { + } catch (err) { // Best effort: unreadable candidates are ignored here and reported during // full collection if the project directory proves in-scope. + recordRefusal(issues, path, err) } } return newest.sort((left, right) => right.mtimeMs - left.mtimeMs).map((value) => value.path) } -async function readFirstCwd(filePath: string): Promise { - const input = createReadStream(filePath, { encoding: 'utf-8' }) +async function readFirstCwd(filePath: string, issues: AiVaultScanIssue[]): Promise { + const input = openTranscriptReadStream(filePath, { encoding: 'utf-8' }, 'scan') const lines = createInterface({ input, crlfDelay: Infinity }) let read = 0 try { @@ -214,7 +240,8 @@ async function readFirstCwd(filePath: string): Promise { return cwd } } - } catch { + } catch (err) { + recordRefusal(issues, filePath, err) return null } finally { // readline.close() leaves the underlying stream open; destroy it so the early @@ -234,8 +261,9 @@ async function collectClaudeFiles(args: { }): Promise { let entries try { - entries = await readdir(args.projectDir, { withFileTypes: true }) - } catch { + entries = await wslGatedReaddir(args.projectDir, 'scan') + } catch (err) { + recordRefusal(args.issues, args.projectDir, err) return } for (const entry of entries) { @@ -247,7 +275,7 @@ async function collectClaudeFiles(args: { continue } try { - const fileStat = await stat(path) + const fileStat = await wslGatedStat(path, 'scan') addBoundedFile(args.collected, args.limit, { path, mtimeMs: fileStat.mtimeMs, @@ -255,7 +283,7 @@ async function collectClaudeFiles(args: { sizeBytes: fileStat.size }) } catch (err) { - args.issues.push({ agent: 'claude', path, message: errorMessage(err) }) + recordSessionScanIssue(args.issues, { agent: 'claude', path, message: errorMessage(err) }) } } } diff --git a/src/main/ai-vault/session-scanner-service-client.ts b/src/main/ai-vault/session-scanner-service-client.ts index cfcee4c2240..4f46dc643c5 100644 --- a/src/main/ai-vault/session-scanner-service-client.ts +++ b/src/main/ai-vault/session-scanner-service-client.ts @@ -130,7 +130,7 @@ export class AiVaultScannerServiceClient { continue } const index = this.queue.findIndex((call) => call.lane === lane) - if (index < 0) { + if (index === -1) { continue } const call = this.queue.splice(index, 1)[0]! @@ -257,7 +257,7 @@ export class AiVaultScannerServiceClient { call.cancelled = true call.reject(createAiVaultScanCancelledError()) const queuedIndex = this.queue.indexOf(call) - if (queuedIndex >= 0) { + if (queuedIndex !== -1) { this.queue.splice(queuedIndex, 1) clearAiVaultServiceCall(call) this.pump() diff --git a/src/main/ai-vault/session-scanner-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-subagent-transcripts.ts index d7df4aeedac..8d477f450af 100644 --- a/src/main/ai-vault/session-scanner-subagent-transcripts.ts +++ b/src/main/ai-vault/session-scanner-subagent-transcripts.ts @@ -1,4 +1,4 @@ -import { readdir } from 'node:fs/promises' +import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access' import { basename, dirname, extname, join } from 'node:path' // Exported so discovery can prune these subtrees using the same literal that @@ -38,7 +38,7 @@ export function subagentTranscriptsDirFor(transcriptFilePath: string): string { export async function countSubagentTranscripts(transcriptFilePath: string): Promise { let entries try { - entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath), { withFileTypes: true }) + entries = await wslGatedReaddir(subagentTranscriptsDirFor(transcriptFilePath), 'scan') } catch { return 0 } diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index b058876fda3..f7d62611adb 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -1,6 +1,7 @@ import { homedir } from 'node:os' import { basename, dirname, isAbsolute, join } from 'node:path' -import { readFile } from 'node:fs/promises' +import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { asRecord } from './session-scanner-record-value' export { asRecord } @@ -76,8 +77,15 @@ export async function readJsonObjectIfExists( filePath: string ): Promise | null> { try { - return asRecord(JSON.parse(await readFile(filePath, 'utf-8')) as unknown) - } catch { + return asRecord(JSON.parse(await wslGatedReadFile(filePath, 'utf-8', 'scan')) as unknown) + } catch (error) { + // A missing or malformed file is genuinely "no enrichment", but a gate + // refusal must reach `parseSessionCandidate` as an issue — degrading it to + // null caches the un-enriched session under an unchanged mtime, and the + // non-resumable agents that use this never re-read it. + if (error instanceof WslTranscriptFsError) { + throw error + } return null } } diff --git a/src/main/ai-vault/session-scanner-worker-client.ts b/src/main/ai-vault/session-scanner-worker-client.ts index 6b780c5086d..70e75c4f982 100644 --- a/src/main/ai-vault/session-scanner-worker-client.ts +++ b/src/main/ai-vault/session-scanner-worker-client.ts @@ -176,7 +176,7 @@ export class AiVaultScannerWorkerClient { return } const index = this.queue.indexOf(call) - if (index >= 0) { + if (index !== -1) { this.queue.splice(index, 1) this.clearCall(call) } diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 3a98b7018ac..b4ef6f06ce7 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -1,4 +1,3 @@ -import { readFile } from 'node:fs/promises' import type { AiVaultListResult, AiVaultScanIssue, @@ -14,6 +13,7 @@ import { } from './codex-session-root-dedup' import { createAntigravityWorkspaceResolver, + readLocalAntigravityHistory, type AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths' @@ -27,6 +27,7 @@ import { parseAgentSessionFileCached, type SessionParseStats } from './session-scanner-parse-cache' +import { recordSessionScanIssue } from './session-scan-issues' import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery' import { DEFAULT_CODEX_HOME_DIR, @@ -71,7 +72,9 @@ export async function scanAiVaultSessions( const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID const issues: AiVaultScanIssue[] = [] const parseStats = createSessionParseStats() - const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile) + const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver( + readLocalAntigravityHistory + ) // Why: persisted entries must be seeded before any candidate is parsed, or // the cold scan gains nothing from the cache file (#9210). throwIfAiVaultScanCancelled(options.signal) @@ -255,7 +258,7 @@ async function parseSessionCandidates(args: { for (const result of results) { if (result.issue) { - args.issues.push(result.issue) + recordSessionScanIssue(args.issues, result.issue) } if (result.session) { sessions.push(result.session) @@ -305,14 +308,6 @@ 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/ai-vault/session-title-file-reader-wsl-stall.test.ts b/src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts new file mode 100644 index 00000000000..337c2064d8a --- /dev/null +++ b/src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' + +const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\stalled.jsonl' +const STALLED_SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const LOCAL_SESSION_ID = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' + +const mocks = vi.hoisted(() => ({ lstat: vi.fn() })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + // Only the WSL request is faked; the local sibling reads a real temp file. + lstat: (path: string, ...rest: unknown[]) => + path.startsWith('\\\\') + ? mocks.lstat(path) + : (original.lstat as (...args: unknown[]) => unknown)(path, ...rest) + } +}) + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader' +import { resetSessionParseCacheForTests } from './session-scanner-parse-cache' +import { WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } from '../native-chat/wsl-transcript-fs-gate' + +let releaseStall: (() => void) | undefined +let tempRoot: string | undefined + +function stalls(): Promise { + return new Promise((resolve) => { + releaseStall = () => resolve(undefined as never) + }) +} + +function userRecord(sessionId: string, text: string): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp: new Date(1740000000000).toISOString(), + cwd: '/repo/app', + gitBranch: 'main', + message: { role: 'user', content: text } + }) +} + +beforeEach(async () => { + resetSessionParseCacheForTests() + mocks.lstat.mockReset() + releaseStall = undefined + tempRoot = await mkdtemp(join(tmpdir(), 'orca-title-stall-')) +}) + +afterEach(async () => { + releaseStall?.() + releaseStall = undefined + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }) + tempRoot = undefined + } +}) + +describe('AI Vault session titles with a stalled WSL transcript lstat', () => { + it('drops only the stalled request and still returns the rest of the batch', async () => { + const localPath = join(tempRoot!, `${LOCAL_SESSION_ID}.jsonl`) + await writeFile(localPath, `${userRecord(LOCAL_SESSION_ID, 'hello world')}\n`) + mocks.lstat.mockImplementation(stalls) + vi.useFakeTimers() + + const pending = readAiVaultSessionTitlesFromFiles([ + { agent: 'claude', sessionId: STALLED_SESSION_ID, transcriptPath: UNC_PATH }, + { agent: 'claude', sessionId: LOCAL_SESSION_ID, transcriptPath: localPath } + ]) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1) + + const { titles } = await pending + expect(titles).toEqual([{ agent: 'claude', sessionId: LOCAL_SESSION_ID, title: 'hello world' }]) + }) +}) diff --git a/src/main/ai-vault/session-title-file-reader.ts b/src/main/ai-vault/session-title-file-reader.ts index 799d2b95da9..15228d132bb 100644 --- a/src/main/ai-vault/session-title-file-reader.ts +++ b/src/main/ai-vault/session-title-file-reader.ts @@ -1,4 +1,4 @@ -import { lstat } from 'node:fs/promises' +import { wslGatedLstat } from '../native-chat/wsl-transcript-fs-access' import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT, type AiVaultSessionTitle, @@ -27,7 +27,7 @@ async function readOneTitle( return cache?.get(request) ?? null } try { - const stats = await lstat(transcriptPath) + const stats = await wslGatedLstat(transcriptPath, 'scan', signal) if (!stats.isFile() || signal?.aborted) { return null } diff --git a/src/main/ai-vault/session-title-request-paths.ts b/src/main/ai-vault/session-title-request-paths.ts index 86e78ebc397..5e25e512d92 100644 --- a/src/main/ai-vault/session-title-request-paths.ts +++ b/src/main/ai-vault/session-title-request-paths.ts @@ -1,5 +1,6 @@ import type { AiVaultSessionTitleRequest } from '../../shared/ai-vault-session-title' import { toHostReadableTranscriptPath } from '../native-chat/host-readable-transcript-path' +import { wslTranscriptFsRefusal } from '../native-chat/wsl-transcript-fs-gate' export function resolveHostReadableAiVaultTitleRequests( requests: AiVaultSessionTitleRequest[], @@ -10,7 +11,15 @@ export function resolveHostReadableAiVaultTitleRequests( if (!request.transcriptPath || signal?.aborted) { return request } - const transcriptPath = await toHostReadableTranscriptPath(request.transcriptPath) + let transcriptPath: string | null + try { + transcriptPath = await toHostReadableTranscriptPath(request.transcriptPath) + } catch (error) { + // Why: one stalled distro's path must not fail the whole titles batch; + // degrade to the id-only shape like any unreadable path. + void wslTranscriptFsRefusal(error) // rethrows anything that is not a gate refusal + transcriptPath = null + } return transcriptPath && !signal?.aborted ? { ...request, transcriptPath } : { agent: request.agent, sessionId: request.sessionId } diff --git a/src/main/attribution/terminal-attribution-path-env-casing.test.ts b/src/main/attribution/terminal-attribution-path-env-casing.test.ts deleted file mode 100644 index 95ff6fdc2e3..00000000000 --- a/src/main/attribution/terminal-attribution-path-env-casing.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { applyTerminalAttributionEnv } from './terminal-attribution' - -describe('applyTerminalAttributionEnv PATH key casing', () => { - let tmpRoot: string | null = null - - afterEach(() => { - if (tmpRoot) { - rmSync(tmpRoot, { force: true, recursive: true }) - tmpRoot = null - } - }) - - function makeTmpRoot(): string { - tmpRoot = mkdtempSync(join(tmpdir(), 'orca-attribution-path-casing-')) - return tmpRoot - } - - // Why: a Windows child that inherits both spellings crashes the packaged orca.exe launcher - // with "Item has already been added. Key in dictionary: 'PATH'" (stablyai/orca#12046). - function pathEnvKeys(env: Record): string[] { - return Object.keys(env).filter((key) => /^path$/i.test(key)) - } - - it('writes back the inherited Windows `Path` spelling instead of adding `PATH`', () => { - const root = makeTmpRoot() - const userDataPath = join(root, 'user-data') - const baseEnv: Record = { - Path: 'C:\\Windows\\system32;C:\\Program Files\\Git\\cmd', - SystemRoot: 'C:\\Windows' - } - - applyTerminalAttributionEnv(baseEnv, { - enabled: true, - platform: 'win32', - shellFamily: 'native-windows', - userDataPath - }) - - const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32') - const pathEntries = baseEnv.Path.split(';') - - expect(pathEnvKeys(baseEnv)).toEqual(['Path']) - expect(pathEntries[0]).toBe(win32Dir) - expect(pathEntries).toContain('C:\\Program Files\\Git\\cmd') - }) - - // Why: Win32 resolves a duplicated variable by taking the first match in the block, so the - // shim must land on the first-listed spelling and the shadowed one must keep its value — - // dropping it would promote it the moment anything downstream removes the live key. - it.each([ - { first: 'PATH', second: 'Path' }, - { first: 'Path', second: 'PATH' } - ])( - 'prepends the shim onto the first-listed `$first` and leaves `$second` untouched', - ({ first, second }) => { - const root = makeTmpRoot() - const userDataPath = join(root, 'user-data') - const baseEnv: Record = { - [first]: 'C:\\tools\\bin;C:\\Windows\\system32', - [second]: 'C:\\Windows\\system32' - } - - applyTerminalAttributionEnv(baseEnv, { - enabled: true, - platform: 'win32', - shellFamily: 'native-windows', - userDataPath - }) - - const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32') - expect(baseEnv[first].split(';')).toEqual([ - win32Dir, - 'C:\\tools\\bin', - 'C:\\Windows\\system32' - ]) - expect(baseEnv[second]).toBe('C:\\Windows\\system32') - } - ) - - it('keeps a shadowed duplicate when the live spelling strips down to nothing', () => { - const root = makeTmpRoot() - const userDataPath = join(root, 'user-data') - const shimDir = join(userDataPath, 'orca-terminal-attribution', 'win32') - const baseEnv: Record = { Path: shimDir, PATH: 'C:\\Windows\\system32' } - - applyTerminalAttributionEnv(baseEnv, { - enabled: false, - platform: 'win32', - shellFamily: 'native-windows', - userDataPath - }) - - expect(pathEnvKeys(baseEnv)).toEqual(['PATH']) - expect(baseEnv.PATH).toBe('C:\\Windows\\system32') - }) - - it('strips shim entries from the inherited `Path` when attribution is disabled', () => { - const root = makeTmpRoot() - const userDataPath = join(root, 'user-data') - const shimDir = join(userDataPath, 'orca-terminal-attribution', 'win32') - const baseEnv: Record = { Path: `${shimDir};C:\\Windows\\system32` } - - applyTerminalAttributionEnv(baseEnv, { - enabled: false, - platform: 'win32', - shellFamily: 'native-windows', - userDataPath - }) - - expect(pathEnvKeys(baseEnv)).toEqual(['Path']) - expect(baseEnv.Path).toBe('C:\\Windows\\system32') - }) - - // Why: the daemon receives a sparse env patch with no path key and re-merges its own block - // underneath it, so guessing the spelling here hands the child both. - it.each([ - { hostKey: 'PATH', otherKey: 'Path' }, - { hostKey: 'Path', otherKey: 'PATH' } - ])('adopts a host block spelt `$hostKey` on a path-less env', ({ hostKey, otherKey }) => { - const root = makeTmpRoot() - const userDataPath = join(root, 'user-data') - const inherited = { PATH: process.env.PATH, Path: process.env.Path } - delete process.env.PATH - delete process.env.Path - process.env[hostKey] = 'C:\\Windows\\system32' - - try { - const baseEnv: Record = { SystemRoot: 'C:\\Windows' } - - applyTerminalAttributionEnv(baseEnv, { - enabled: true, - platform: 'win32', - shellFamily: 'native-windows', - userDataPath - }) - - expect(pathEnvKeys(baseEnv)).toEqual([hostKey]) - expect(baseEnv[otherKey]).toBeUndefined() - } finally { - delete process.env[hostKey] - for (const [key, value] of Object.entries(inherited)) { - if (value !== undefined) { - process.env[key] = value - } - } - } - }) - - it('leaves a case-sensitive POSIX `Path` variable alone', () => { - const root = makeTmpRoot() - const baseEnv: Record = { - PATH: '/usr/bin:/bin', - Path: '/decoy' - } - - applyTerminalAttributionEnv(baseEnv, { - enabled: true, - platform: 'linux', - userDataPath: join(root, 'user-data') - }) - - expect(baseEnv.Path).toBe('/decoy') - expect(baseEnv.PATH.split(':')).toContain('/usr/bin') - }) -}) diff --git a/src/main/attribution/terminal-attribution-win32-dash-argument.test.ts b/src/main/attribution/terminal-attribution-win32-dash-argument.test.ts deleted file mode 100644 index 810322a53c9..00000000000 --- a/src/main/attribution/terminal-attribution-win32-dash-argument.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { applyTerminalAttributionEnv } from './terminal-attribution' - -// Why (#12046 follow-up): `powershell.exe -File ' + ) + return + } + response.end(` + + + FedCM fallback oracle + + + + pending + + + + `) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + return { url: `http://127.0.0.1:${port}/`, close: () => closeServer(server) } +} + +test('embedded browser omits unusable FedCM and reaches the popup fallback', async ({ + electronApp, + orcaPage +}) => { + const server = await startFedCmFallbackServer() + try { + await ensureTerminalVisible(orcaPage) + const worktreeId = await getActiveWorktreeId(orcaPage) + expect(worktreeId).not.toBeNull() + const browserTabId = await orcaPage.evaluate( + ({ targetWorktreeId, url }) => { + const tab = window.__store!.getState().createBrowserTab(targetWorktreeId!, url, { + title: 'FedCM fallback oracle', + activate: true + }) + return tab.id + }, + { targetWorktreeId: worktreeId, url: server.url } + ) + const readGuest = async (expression: string): Promise => + orcaPage.evaluate( + async ({ targetBrowserTabId, script }) => { + const slot = document.querySelector( + `[data-browser-overlay-tab-id="${targetBrowserTabId}"]` + ) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + throw new Error(`Missing webview for browser tab ${targetBrowserTabId}`) + } + return (await webview.executeJavaScript(script)) as T + }, + { targetBrowserTabId: browserTabId, script: expression } + ) + + await expect + .poll(() => readGuest('document.title'), { timeout: 10_000 }) + .toBe('FedCM fallback oracle') + const capabilities = await readGuest<{ + hasIdentityCredential: boolean + hasIdentityProvider: boolean + }>('JSON.parse(document.querySelector("#capabilities").textContent)') + expect.soft(capabilities).toEqual({ + hasIdentityCredential: false, + hasIdentityProvider: false + }) + + await readGuest('document.querySelector("#sign-in").click()') + const path = await readGuest('document.querySelector("#path").textContent') + expect.soft(path).toBe('popup-live') + await expect + .poll(() => + electronApp.evaluate(async ({ webContents }) => { + const popup = webContents.getAllWebContents().find((contents) => { + return contents.getURL().endsWith('/popup') + }) + if (!popup) { + return null + } + return { + openerLive: await popup.executeJavaScript('Boolean(window.opener)'), + title: await popup.executeJavaScript('document.title'), + url: popup.getURL() + } + }) + ) + .toEqual({ + openerLive: true, + title: 'Popup fallback', + url: `${server.url}popup` + }) + await expect + .poll(() => readGuest('window.popupMessages')) + .toEqual(['popup-opener-live']) + } finally { + await server.close() + } +}) diff --git a/tests/e2e/chinese-ime-chat-input-repro.spec.ts b/tests/e2e/chinese-ime-chat-input-repro.spec.ts index 8c57b8e6a9f..9c72e8cdd4d 100644 --- a/tests/e2e/chinese-ime-chat-input-repro.spec.ts +++ b/tests/e2e/chinese-ime-chat-input-repro.spec.ts @@ -305,7 +305,7 @@ async function readPromptState(page: Page): Promise .filter((value): value is string => value !== null) const promptIndex = content.lastIndexOf(PROMPT) const liveLine = - promptIndex >= 0 ? (content.slice(promptIndex + PROMPT.length).split(/\r?\n/)[0] ?? '') : '' + promptIndex !== -1 ? (content.slice(promptIndex + PROMPT.length).split(/\r?\n/)[0] ?? '') : '' return { model: liveLine.trimEnd(), cursor: Array.from(liveLine.trimEnd()).length, diff --git a/tests/e2e/codex-composer-echo-latency-probe.ts b/tests/e2e/codex-composer-echo-latency-probe.ts index 9fc9cabf2c7..c49d98d3708 100644 --- a/tests/e2e/codex-composer-echo-latency-probe.ts +++ b/tests/e2e/codex-composer-echo-latency-probe.ts @@ -112,7 +112,7 @@ export async function installCodexEchoLatencyProbe(page: Page, target: string): const observeRender = (): void => { renderEvents += 1 const paintedAt = performance.now() - for (const entry of awaitingRender.splice(0, awaitingRender.length)) { + for (const entry of awaitingRender.splice(0)) { entry.sample.keyToRenderMs = paintedAt - entry.startedAt } } diff --git a/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts index e49e0be3ecb..1eba41b7fef 100644 --- a/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeAll, describe, expect, it } from 'vitest' -import { resolveBaselineReleaseRef } from './release-checkout' +import { resolveBaselineReleaseRef, selectLatestStableReleaseTag } from './release-checkout' import { JOURNEY_INPUTS, JOURNEY_STEPS, @@ -92,6 +92,18 @@ function expectWireCompatible(record: JourneyRecord): void { } describe('cross-version remote terminal wire', () => { + it('ignores legacy, mobile, and prerelease tags when selecting the baseline', () => { + expect( + selectLatestStableReleaseTag([ + 'v799', + 'mobile-v9.0.0', + 'v1.4.177-rc.3', + 'v1.4.175', + 'v1.4.176' + ]) + ).toBe('v1.4.176') + }) + it( 'skews current code against a real published release', () => { diff --git a/tests/e2e/cross-version-wire/release-checkout.ts b/tests/e2e/cross-version-wire/release-checkout.ts index 205885a5e87..069375f4c18 100644 --- a/tests/e2e/cross-version-wire/release-checkout.ts +++ b/tests/e2e/cross-version-wire/release-checkout.ts @@ -21,6 +21,7 @@ const CHECKOUT_FORMAT = 1 const ARCHIVE_PATHS = ['src/main', 'src/shared', 'src/preload', 'src/renderer', 'src/types'] const BASELINE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF' +const STABLE_DESKTOP_RELEASE_TAG = /^v\d+\.\d+\.\d+$/ export type ReleaseCheckout = { /** The ref as requested, e.g. `v1.4.169`. */ @@ -61,7 +62,7 @@ function compareReleaseTags(a: string, b: string): number { /** * The version point the harness pairs current code against. An explicit - * {@link BASELINE_REF_ENV} wins; otherwise the newest non-prerelease `v*` tag. + * {@link BASELINE_REF_ENV} wins; otherwise the newest stable desktop release tag. * * Throws rather than skipping: a cross-version lane that quietly runs nothing is * the exact failure this harness exists to prevent. @@ -80,11 +81,10 @@ export function resolveBaselineReleaseRef(): string { `Run it inside a git checkout, or pin a ref with ${BASELINE_REF_ENV}.` ) } - const releases = tags.filter((tag) => !tag.includes('-')).sort(compareReleaseTags) - const latest = releases.at(-1) + const latest = selectLatestStableReleaseTag(tags) if (!latest) { throw new Error( - `Cross-version harness found no release tags matching v[0-9]* (saw ${tags.length} tag(s) total). ` + + `Cross-version harness found no stable desktop release tags matching vX.Y.Z (saw ${tags.length} tag(s) total). ` + 'CI checkouts default to a shallow clone with no tags: use `actions/checkout` with `fetch-depth: 0`, ' + `or pin a ref with ${BASELINE_REF_ENV}.` ) @@ -92,6 +92,15 @@ export function resolveBaselineReleaseRef(): string { return latest } +export function selectLatestStableReleaseTag(tags: string[]): string | null { + return ( + tags + .filter((tag) => STABLE_DESKTOP_RELEASE_TAG.test(tag)) + .sort(compareReleaseTags) + .at(-1) ?? null + ) +} + function resolveCommit(ref: string): string { try { return git(['rev-parse', `${ref}^{commit}`]) diff --git a/tests/e2e/fixtures/daemon-generation-legacy-close-client.ts b/tests/e2e/fixtures/daemon-generation-legacy-close-client.ts index b9bbb621597..730ebbeffc9 100644 --- a/tests/e2e/fixtures/daemon-generation-legacy-close-client.ts +++ b/tests/e2e/fixtures/daemon-generation-legacy-close-client.ts @@ -62,7 +62,7 @@ const OBSERVER = { function readConfig(): FixtureConfig { const configIndex = process.argv.indexOf('--config') - const configPath = configIndex >= 0 ? process.argv[configIndex + 1] : undefined + const configPath = configIndex !== -1 ? process.argv[configIndex + 1] : undefined if (!configPath) { throw new Error('Legacy close client requires --config ') } diff --git a/tests/e2e/fixtures/daemon-generation-reconnect-client.ts b/tests/e2e/fixtures/daemon-generation-reconnect-client.ts index 28c0a6686a7..6bd163cc474 100644 --- a/tests/e2e/fixtures/daemon-generation-reconnect-client.ts +++ b/tests/e2e/fixtures/daemon-generation-reconnect-client.ts @@ -41,7 +41,7 @@ const MAX_OUTPUT_CHARS = 32_768 function readConfig(): ClientConfig { const configIndex = process.argv.indexOf('--config') - const configPath = configIndex >= 0 ? process.argv[configIndex + 1] : undefined + const configPath = configIndex !== -1 ? process.argv[configIndex + 1] : undefined if (!configPath) { throw new Error('Reconnect client requires --config ') } diff --git a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs index 3702ad21a9f..63b7683916a 100644 --- a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs +++ b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs @@ -12,7 +12,7 @@ const MOUSE_REPORT_PATTERN = new RegExp(`${ESC}\\[<(64|65);\\d+;\\d+M`, 'g') // at 120 reports/s) instead of minimal rows. const args = process.argv.slice(2) const logIndex = args.indexOf('--log') -const LOG_PATH = logIndex >= 0 ? args[logIndex + 1] : null +const LOG_PATH = logIndex !== -1 ? args[logIndex + 1] : null const HEAVY_FRAMES = args.includes('--heavy') let offset = 0 diff --git a/tests/e2e/git-history-tooltip-wrap.spec.ts b/tests/e2e/git-history-tooltip-wrap.spec.ts new file mode 100644 index 00000000000..6a8c4f5895b --- /dev/null +++ b/tests/e2e/git-history-tooltip-wrap.spec.ts @@ -0,0 +1,144 @@ +import { execFileSync } from 'node:child_process' +import { rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { openSourceControlForWorktree } from './helpers/worktree-registration' + +const subject = 'brew-install: source shared brew context before resolving prefixes' +const reportedLine = 'Sources bin/lib/brew-context.sh, mirroring the brew-install update:' +const conventionalLine = 'Keep this conventional commit-message body line intact through column 72' +const unbrokenLine = `https://example.com/${'commit-message-segment-'.repeat(8)}` +const commitMessage = `${subject} + +${reportedLine} +${conventionalLine} +${unbrokenLine} +the resolved prefix list now feeds both the cask audit and the formula +path checks, so a missing context aborts before any network call runs. + +Verified by running the full tap audit locally with both prefixes set.` + +function createCommitWorktree(repoPath: string): { branchName: string; worktreePath: string } { + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}` + const branchName = `e2e-tooltip-wrap-${suffix}` + const worktreePath = path.join(os.tmpdir(), branchName) + execFileSync('git', ['worktree', 'add', worktreePath, '-b', branchName], { + cwd: repoPath, + stdio: 'pipe' + }) + return { branchName, worktreePath } +} + +async function cleanupCommitWorktree( + repoPath: string, + worktreePath: string, + branchName: string +): Promise { + try { + execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { + cwd: repoPath, + stdio: 'pipe' + }) + } catch { + rmSync(worktreePath, { recursive: true, force: true }) + execFileSync('git', ['worktree', 'prune'], { cwd: repoPath, stdio: 'pipe' }) + } + execFileSync('git', ['branch', '-D', branchName], { cwd: repoPath, stdio: 'pipe' }) +} + +test('keeps conventional commit-message lines intact in the history tooltip', async ({ + orcaPage, + testRepoPath, + registerPostElectronShutdownCleanup +}) => { + const fixture = createCommitWorktree(testRepoPath) + registerPostElectronShutdownCleanup(() => + cleanupCommitWorktree(testRepoPath, fixture.worktreePath, fixture.branchName) + ) + execFileSync('git', ['commit', '--allow-empty', '--file', '-'], { + cwd: fixture.worktreePath, + input: commitMessage, + stdio: ['pipe', 'pipe', 'pipe'] + }) + + await orcaPage.setViewportSize({ width: 1440, height: 900 }) + await waitForSessionReady(orcaPage) + await openSourceControlForWorktree(orcaPage, testRepoPath, fixture.worktreePath) + + const commitsToggle = orcaPage.getByRole('button', { name: /Commits/ }) + await expect(commitsToggle).toBeVisible() + await commitsToggle.click() + + const row = orcaPage.getByTestId('git-history-row').filter({ hasText: subject }) + await expect(row).toBeVisible({ timeout: 10_000 }) + const trigger = row.locator('[data-slot="tooltip-trigger"]').filter({ hasText: subject }) + await trigger.hover({ position: { x: 20, y: 10 } }) + await trigger.hover({ position: { x: 40, y: 10 } }) + + const tooltip = orcaPage + .locator('[data-slot="tooltip-content"]') + .filter({ hasText: reportedLine }) + await expect(tooltip).toBeVisible() + await expect(tooltip).toContainText(commitMessage) + await orcaPage.evaluate(async () => { + await document.fonts.ready + }) + + const layout = await tooltip.evaluate( + (element, targetLines) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + let textNode: Text | null = null + while (walker.nextNode()) { + const candidate = walker.currentNode as Text + if (targetLines.every((line) => candidate.data.includes(line))) { + textNode = candidate + break + } + } + if (!textNode) { + throw new Error('Commit message text node was not found in the tooltip') + } + + const visualLineCounts = targetLines.map((targetLine) => { + const start = textNode.data.indexOf(targetLine) + const range = document.createRange() + range.setStart(textNode, start) + range.setEnd(textNode, start + targetLine.length) + const rectTops = Array.from(range.getClientRects()) + .filter((rect) => rect.width > 0.5) + .map((rect) => rect.top) + const visualLineTops = rectTops.reduce((tops, top) => { + if (!tops.some((existing) => Math.abs(existing - top) < 2)) { + tops.push(top) + } + return tops + }, []) + return visualLineTops.length + }) + + const style = getComputedStyle(element) + const tooltipRect = element.getBoundingClientRect() + return { + visualLineCounts, + tooltipLeft: tooltipRect.left, + tooltipRight: tooltipRect.right, + tooltipWidth: tooltipRect.width, + viewportWidth: window.innerWidth, + overflowContained: element.scrollWidth <= element.clientWidth, + textWrap: style.textWrap, + whiteSpace: style.whiteSpace + } + }, + [reportedLine, conventionalLine] + ) + + expect(layout.whiteSpace).toBe('pre-wrap') + expect(layout.tooltipWidth).toBeGreaterThan(0) + expect(layout.tooltipLeft).toBeGreaterThanOrEqual(0) + expect(layout.tooltipRight).toBeLessThanOrEqual(layout.viewportWidth) + expect(layout.visualLineCounts).toEqual([1, 1]) + expect(layout.overflowContained).toBe(true) + expect(layout.textWrap).toBe('wrap') +}) diff --git a/tests/e2e/golden-posix-fresh-startup.spec.ts b/tests/e2e/golden-posix-fresh-startup.spec.ts new file mode 100644 index 00000000000..ba30f550498 --- /dev/null +++ b/tests/e2e/golden-posix-fresh-startup.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from './helpers/orca-app' + +test.skip(process.platform === 'win32', 'POSIX fresh-startup golden; Windows has its own suite') + +test.describe('POSIX fresh startup golden', () => { + test.use({ dismissOnboarding: false, seedTestRepo: false }) + + test('fresh profile reaches onboarding normally @posix-profile-index-golden', async ({ + orcaPage + }) => { + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ + timeout: 30_000 + }) + }) +}) diff --git a/tests/e2e/golden-posix-profile-index-fsync.spec.ts b/tests/e2e/golden-posix-profile-index-fsync.spec.ts new file mode 100644 index 00000000000..d3d34d6743d --- /dev/null +++ b/tests/e2e/golden-posix-profile-index-fsync.spec.ts @@ -0,0 +1,45 @@ +import { readFileSync, rmSync, statSync } from 'node:fs' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' + +test.use({ seedTestRepo: false }) +test.skip(process.platform === 'win32', 'Restrictive-umask fsync regression is POSIX-only') + +test('recreates a fresh profile index on disk with a restrictive umask @posix-profile-index-golden', async ({ + electronApp, + orcaPage +}) => { + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const indexPath = path.join(userDataDir, 'orca-profile-index.json') + // Why: the index tmp file is written with 0o666 & ~umask, so a umask that clears + // owner-write is what made the pre-fix fsync open(path, 'r+') fail with EACCES. + const originalUmask = await electronApp.evaluate(() => process.umask(0o200)) + + try { + rmSync(indexPath, { force: true }) + rmSync(`${indexPath}.bak`, { force: true }) + // Why: orcaProfiles:list reads the index from disk on every call and rebuilds + // it when missing, so this drives the real create + fsync + rename path. + const listed = await orcaPage.evaluate(async () => { + const result = await window.api.orcaProfiles.list() + window.__store!.getState().openSettingsPage() + return result + }) + expect(listed.profiles.length).toBeGreaterThan(0) + + // The rebuilt index must be on disk and complete — a cached in-memory list is + // not proof that the restrictive-umask write survived fsync. + const persisted = JSON.parse(readFileSync(indexPath, 'utf-8')) + expect(persisted.activeProfileId).toBe(listed.activeProfileId) + expect(persisted.profiles.map((profile: { id: string }) => profile.id)).toEqual( + listed.profiles.map((profile) => profile.id) + ) + // Owner-write cleared proves the file was created under the restrictive umask + // rather than left over from startup's normal-umask write. + expect(statSync(indexPath).mode & 0o200).toBe(0) + } finally { + await electronApp.evaluate((_electron, umask) => process.umask(umask), originalUmask) + } + + await expect(orcaPage.getByPlaceholder('Search settings')).toBeVisible() +}) diff --git a/tests/e2e/golden-windows-fresh-startup.spec.ts b/tests/e2e/golden-windows-fresh-startup.spec.ts new file mode 100644 index 00000000000..f153f765dcd --- /dev/null +++ b/tests/e2e/golden-windows-fresh-startup.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from './helpers/orca-app' + +test.use({ dismissOnboarding: false, seedTestRepo: false }) +test.skip(process.platform !== 'win32', 'Fresh-profile fsync regression is Windows-only') + +test('fresh Windows profile reaches onboarding @windows-fresh-startup-golden', async ({ + orcaPage +}) => { + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ + timeout: 30_000 + }) +}) diff --git a/tests/e2e/helpers/completed-worker-retirement-fixture.ts b/tests/e2e/helpers/completed-worker-retirement-fixture.ts index d1211234258..564f52e2f5f 100644 --- a/tests/e2e/helpers/completed-worker-retirement-fixture.ts +++ b/tests/e2e/helpers/completed-worker-retirement-fixture.ts @@ -86,7 +86,7 @@ export function readCompletedWorkerLedger(): LifecycleEvent[] { } const contents = readFileSync(lifecycleLedgerPath, 'utf8') const lastCompleteLine = contents.lastIndexOf('\n') - if (lastCompleteLine < 0) { + if (lastCompleteLine === -1) { return [] } return contents diff --git a/tests/e2e/helpers/docker-ssh-relay-target.ts b/tests/e2e/helpers/docker-ssh-relay-target.ts index 6c6f75dfc75..aa61f163aae 100644 --- a/tests/e2e/helpers/docker-ssh-relay-target.ts +++ b/tests/e2e/helpers/docker-ssh-relay-target.ts @@ -45,6 +45,21 @@ export function execDockerSshRelayTargetCommand( }) } +export function execDockerSshRelayTargetControlCommand( + target: DockerSshRelayTarget, + command: string +): string { + return run('docker', [ + 'exec', + target.containerName, + 'bash', + '--noprofile', + '--norc', + '-c', + command + ]) +} + function sshArgs(target: DockerSshRelayTarget, command: string): string[] { return [ '-i', diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index b083e37f36d..434a3297df3 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -252,6 +252,7 @@ export const test = base.extend({ ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) } }) + forwardElectronProcessLogs(app, testInfo) try { const resolvedHome = await app.evaluate(({ app }) => app.getPath('home')) assertElectronResolvedIsolatedHome(resolvedHome, homeIsolation) @@ -261,7 +262,6 @@ export const test = base.extend({ await removeUserDataDirAfterShutdown(userDataDir) throw error } - forwardElectronProcessLogs(app, testInfo) await provideFixture(app) // Why: the Playwright close promise can settle before all Electron and PTY // descendants are gone in CI; worker teardown then hangs on open handles. diff --git a/tests/e2e/helpers/startup-exec-readiness-oracle.ts b/tests/e2e/helpers/startup-exec-readiness-oracle.ts new file mode 100644 index 00000000000..48b2928ed6d --- /dev/null +++ b/tests/e2e/helpers/startup-exec-readiness-oracle.ts @@ -0,0 +1,274 @@ +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import type { + RuntimeTerminalListResult, + RuntimeTerminalRead, + RuntimeTerminalShow +} from '../../../src/shared/runtime-types' +import { toWebTerminalSurfaceTabId } from '../../../src/shared/terminal-surface-id' +import { expect } from './orca-app' +import { getTerminalContent, waitForActivePanePtyId } from './terminal' + +const RECOVERY_DEADLINE_MS = 8_000 + +export type StartupExecTerminal = { + hostPtyId: string + panePtyId: string + parentTabId: string + startupMarker: string + tabId: string + terminal: string + worktreeId: string +} + +export type BashExecProfileBarrier = { + releasePath: string + startedPath: string +} + +const BASH_EXEC_LINE = 'exec -a figterm-sta4067 /bin/bash --noprofile --norc -l -i' +const ZSH_EXEC_LINE = 'exec -a figterm-sta4067 /bin/zsh -o noglobalrcs -l -i' + +function execProfileContents( + runId: string, + execLine: string, + barrier?: BashExecProfileBarrier +): string { + const guard = `ORCA_STA4067_EXEC_${runId.replaceAll(/[^A-Za-z0-9_]/g, '_')}` + const barrierScript = barrier + ? [ + `: > ${shellQuote(barrier.startedPath)}`, + `while [[ ! -e ${shellQuote(barrier.releasePath)} ]]; do sleep 0.02; done` + ].join('\n ') + : '' + return `if [[ -z "\${${guard}:-}" ]]; then + export ${guard}=1 + ${barrierScript} + ${execLine} +fi +` +} + +export function bashExecProfileContents(runId: string, barrier?: BashExecProfileBarrier): string { + return execProfileContents(runId, BASH_EXEC_LINE, barrier) +} + +export function zshExecProfileContents(runId: string, barrier?: BashExecProfileBarrier): string { + return execProfileContents(runId, ZSH_EXEC_LINE, barrier) +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function splitMarker(marker: string): [string, string] { + const midpoint = Math.floor(marker.length / 2) + return [marker.slice(0, midpoint), marker.slice(midpoint)] +} + +function markerCommand(marker: string): string { + const [left, right] = splitMarker(marker) + return `printf '%s%s\\n' ${shellQuote(left)} ${shellQuote(right)}` +} + +function count(text: string, marker: string): number { + return text.split(marker).length - 1 +} + +async function expectSingleOwningPty( + page: Page, + worktreeId: string, + tabId: string, + terminal: string, + ptyId: string +): Promise { + const listed = await callStartupExecRuntime(page, 'terminal.list', { + worktree: `id:${worktreeId}`, + requireFreshPtyLiveness: true + }) + expect( + listed.terminals + .filter((candidate) => candidate.tabId === tabId) + .map((candidate) => ({ handle: candidate.handle, ptyId: candidate.ptyId })) + ).toEqual([{ handle: terminal, ptyId }]) +} + +export async function callStartupExecRuntime( + page: Page, + method: string, + params: unknown +): Promise { + return page.evaluate( + async ({ method, params }) => { + const response = await window.api.runtime.call({ method, params }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { method, params } + ) as Promise +} + +export function installBashExecProfile( + homePath: string, + runId: string, + barrier?: BashExecProfileBarrier +): () => void { + const profilePath = path.join(homePath, '.bash_profile') + writeFileSync(profilePath, bashExecProfileContents(runId, barrier)) + return () => rmSync(profilePath, { force: true }) +} + +export function installZshExecProfile( + homePath: string, + runId: string, + barrier?: BashExecProfileBarrier +): () => void { + const profilePath = path.join(homePath, '.zprofile') + writeFileSync(profilePath, zshExecProfileContents(runId, barrier)) + return () => rmSync(profilePath, { force: true }) +} + +export async function createStartupExecTerminal( + page: Page, + worktreeId: string, + runId: string, + ledgerPath: string, + tabIdNamespace: 'owning-client' | 'paired-client', + shell: '/bin/bash' | '/bin/zsh' = '/bin/bash', + shellEnv: Record = {} +): Promise { + const startupMarker = `STA4067_STARTUP_READY_${runId}` + const command = [ + `printf '%s|%s\\n' "$$" "$(tty)" > ${shellQuote(ledgerPath)}`, + markerCommand(startupMarker) + ].join('; ') + const created = await callStartupExecRuntime<{ + tab: { parentTabId: string; terminal: string | null } + }>(page, 'session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + command, + env: { ...shellEnv, SHELL: shell }, + startupCommandDelivery: 'shell-ready', + activate: false, + select: false, + navigation: 'caller' + }) + if (!created.tab.terminal) { + throw new Error('Startup-exec terminal did not publish an authoritative handle') + } + const terminal = created.tab.terminal + const tabId = + tabIdNamespace === 'paired-client' + ? toWebTerminalSurfaceTabId(created.tab.parentTabId) + : created.tab.parentTabId + await page.evaluate((id) => window.__store?.getState().setActiveWorktree(id), worktreeId) + const tab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`) + await expect(tab).toBeVisible({ timeout: 30_000 }) + await tab.click() + await expect(tab).toHaveAttribute('data-active', 'true') + const panePtyId = await waitForActivePanePtyId(page, 30_000) + const shown = await callStartupExecRuntime<{ terminal: RuntimeTerminalShow }>( + page, + 'terminal.show', + { terminal } + ) + if (!shown.terminal.ptyId) { + throw new Error('Startup-exec terminal has no owning PTY identity') + } + await expectSingleOwningPty( + page, + worktreeId, + created.tab.parentTabId, + terminal, + shown.terminal.ptyId + ) + return { + hostPtyId: shown.terminal.ptyId, + panePtyId, + parentTabId: created.tab.parentTabId, + startupMarker, + tabId, + terminal, + worktreeId + } +} + +async function readTerminal(page: Page, terminal: string): Promise { + return ( + await callStartupExecRuntime<{ terminal: RuntimeTerminalRead }>(page, 'terminal.read', { + terminal + }) + ).terminal +} + +export async function expectStartupExecRecovery( + page: Page, + created: StartupExecTerminal, + runId: string +): Promise { + await expect + .poll(() => readTerminal(page, created.terminal), { timeout: RECOVERY_DEADLINE_MS }) + .toMatchObject({ + tail: expect.arrayContaining([expect.stringContaining(created.startupMarker)]) + }) + await expect + .poll(() => getTerminalContent(page), { timeout: RECOVERY_DEADLINE_MS }) + .toContain(created.startupMarker) + const painted = await getTerminalContent(page) + expect(count(painted, created.startupMarker)).toBe(1) + + const authoritative = await readTerminal(page, created.terminal) + expect(authoritative.status).toBe('running') + expect(count(authoritative.tail.join('\n'), created.startupMarker)).toBe(1) + const shown = await callStartupExecRuntime<{ terminal: RuntimeTerminalShow }>( + page, + 'terminal.show', + { terminal: created.terminal } + ) + expect(shown.terminal).toMatchObject({ + connected: true, + ptyId: created.hostPtyId, + writable: true + }) + await expectSingleOwningPty( + page, + created.worktreeId, + created.parentTabId, + created.terminal, + created.hostPtyId + ) + + const inputMarker = `STA4067_FIRST_INPUT_${runId}` + const textarea = page.locator('.xterm-helper-textarea:visible').first() + await textarea.focus() + await page.keyboard.type(markerCommand(inputMarker)) + await page.keyboard.press('Enter') + await expect + .poll(() => getTerminalContent(page), { timeout: RECOVERY_DEADLINE_MS }) + .toContain(inputMarker) + expect(count(await getTerminalContent(page), inputMarker)).toBe(1) + expect(count((await readTerminal(page, created.terminal)).tail.join('\n'), inputMarker)).toBe(1) + expect(await waitForActivePanePtyId(page, 30_000)).toBe(created.panePtyId) +} + +export async function expectStartupCommandQueuedByCompatibilityFallback( + page: Page, + created: StartupExecTerminal +): Promise { + const [commandEchoMarker] = splitMarker(created.startupMarker) + await expect + .poll(() => readTerminal(page, created.terminal), { timeout: RECOVERY_DEADLINE_MS }) + .toMatchObject({ tail: expect.arrayContaining([expect.stringContaining(commandEchoMarker)]) }) + expect( + count((await readTerminal(page, created.terminal)).tail.join('\n'), commandEchoMarker) + ).toBe(1) +} + +export async function closeStartupExecTerminal(page: Page, terminal: string | null): Promise { + if (terminal) { + await callStartupExecRuntime(page, 'terminal.closeTab', { terminal }).catch(() => undefined) + } +} diff --git a/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts b/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts index 208e79a8928..1ddfd56adb4 100644 --- a/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts +++ b/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts @@ -339,7 +339,7 @@ async function dispatchPlainEnter(session: CDPSession): Promise { async function readPromptLine(page: Page): Promise { const content = stripTerminalControls(await getTerminalContent(page, 20_000)) const promptIndex = content.lastIndexOf(PROMPT) - if (promptIndex < 0) { + if (promptIndex === -1) { return '' } return (content.slice(promptIndex + PROMPT.length).split(/\r?\n/)[0] ?? '').trimEnd() diff --git a/tests/e2e/linear-url-workspace-entry.spec.ts b/tests/e2e/linear-url-workspace-entry.spec.ts new file mode 100644 index 00000000000..ccdf1d00c4a --- /dev/null +++ b/tests/e2e/linear-url-workspace-entry.spec.ts @@ -0,0 +1,230 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import type { LinearIssue } from '../../src/shared/types' + +const LINEAR_URL = + 'https://linear.app/stably/issue/STA-4084/restore-osc-133-shell-integration-when-an-exec-in-user-rc-files-strips' +const EXPECTED_WORKSPACE_NAME = 'sta-4084-restore-osc-133-shell-integration-when' + +const LINEAR_ISSUE: LinearIssue = { + id: 'linear-sta-4084', + workspaceId: 'workspace-1', + workspaceName: 'Stably', + identifier: 'STA-4084', + title: 'Restore OSC 133 shell integration when an exec in user rc files strips', + branchName: 'sta-4084-restore-osc-133-shell-integration', + url: LINEAR_URL, + state: { name: 'Todo', type: 'unstarted', color: '#999999' }, + team: { id: 'team-sta', name: 'Stably', key: 'STA' }, + labels: [], + labelIds: [], + priority: 2, + estimate: null, + updatedAt: '2026-08-12T00:00:00.000Z' +} + +function pasteChord(): string { + return process.platform === 'darwin' ? 'Meta+V' : 'Control+V' +} + +async function installLinearFixture( + page: Page, + issue: LinearIssue | null = LINEAR_ISSUE, + lookupDelayMs: number | null = 150 +): Promise { + await page.evaluate( + ({ resolvedIssue, lookupDelayMs }) => { + Reflect.deleteProperty(window, '__orcaTestReleaseLinearLookup') + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.setState({ + linearStatus: { + connected: true, + viewer: null, + activeWorkspaceId: 'workspace-other', + selectedWorkspaceId: 'workspace-other', + workspaces: [ + { + id: 'workspace-other', + displayName: 'Other User', + email: null, + organizationId: 'organization-other', + organizationName: 'Other', + organizationUrlKey: 'other' + }, + { + id: 'workspace-1', + displayName: 'Stably User', + email: null, + organizationId: 'organization-stably', + organizationName: 'Stably', + organizationUrlKey: 'stably' + } + ] + }, + linearStatusChecked: true, + checkLinearConnection: async () => {}, + searchLinearIssues: async () => [], + listLinearIssues: async () => ({ items: [] }), + fetchLinearIssue: async (_identifier: string, workspaceId?: string | null) => { + await (lookupDelayMs === null + ? new Promise((resolve) => { + Reflect.set(window, '__orcaTestReleaseLinearLookup', resolve) + }) + : new Promise((resolve) => window.setTimeout(resolve, lookupDelayMs))) + return resolvedIssue && workspaceId === resolvedIssue.workspaceId ? resolvedIssue : null + } + }) + }, + { resolvedIssue: issue, lookupDelayMs } + ) +} + +async function releaseHeldLinearLookup(page: Page): Promise { + await page.evaluate(() => { + const release = Reflect.get(window, '__orcaTestReleaseLinearLookup') + if (typeof release !== 'function') { + throw new Error('Linear lookup is not held') + } + Reflect.deleteProperty(window, '__orcaTestReleaseLinearLookup') + release() + }) +} + +async function pasteLinearUrl(page: Page, input: ReturnType): Promise { + await page.evaluate((text) => window.api.ui.writeClipboardText(text), LINEAR_URL) + await input.focus() + await page.keyboard.press(pasteChord()) +} + +async function openJumpPalette(electronApp: ElectronApplication): Promise { + // Headless Playwright keys bypass Electron's before-input-event shortcut path. + await electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.webContents.send('ui:toggleWorktreePalette') + }) +} + +test.describe('Linear URL workspace entry', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await installLinearFixture(orcaPage) + }) + + test('pasting into the composer selects the Linear issue without ArrowDown', async ({ + orcaPage + }, testInfo) => { + await installLinearFixture(orcaPage, LINEAR_ISSUE, null) + await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() + const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + const input = dialog.locator('[data-workspace-name-input="true"]') + await expect(input).toBeVisible() + + await pasteLinearUrl(orcaPage, input) + await expect + .poll(() => + orcaPage.evaluate( + () => typeof Reflect.get(window, '__orcaTestReleaseLinearLookup') === 'function' + ) + ) + .toBe(true) + await input.press('Enter') + await expect(input).toHaveValue(LINEAR_URL) + await expect(dialog.locator('[data-workspace-source-pill="true"]')).toHaveCount(0) + await releaseHeldLinearLookup(orcaPage) + + const issueRow = orcaPage.getByRole('option', { + name: `${LINEAR_ISSUE.identifier} ${LINEAR_ISSUE.title}`, + exact: true + }) + const useNameRow = orcaPage.getByRole('option', { + name: `Use "${LINEAR_URL}" as workspace name`, + exact: true + }) + await expect(useNameRow).toBeVisible() + await expect(useNameRow).not.toHaveAttribute('data-selected', 'true') + await expect(issueRow).toContainText(LINEAR_ISSUE.title) + await expect(issueRow).toHaveAttribute('data-selected', 'true') + await testInfo.attach('linear-url-smart-entry-selected.png', { + body: await dialog.screenshot(), + contentType: 'image/png' + }) + + await input.press('Enter') + const sourcePill = dialog.locator('[data-workspace-source-pill="true"]') + await expect(sourcePill).toContainText(LINEAR_ISSUE.title) + await expect(dialog.getByPlaceholder('Workspace name')).toHaveValue(EXPECTED_WORKSPACE_NAME) + await expect(dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i })).toBeEnabled() + await testInfo.attach('linear-url-smart-entry-composer.png', { + body: await dialog.screenshot(), + contentType: 'image/png' + }) + }) + + test('a Linear URL lookup miss falls back to an arbitrary workspace name', async ({ + orcaPage + }) => { + await installLinearFixture(orcaPage, null) + await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() + const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + const input = dialog.locator('[data-workspace-name-input="true"]') + + await pasteLinearUrl(orcaPage, input) + const useNameRow = orcaPage.getByRole('option', { + name: `Use "${LINEAR_URL}" as workspace name`, + exact: true + }) + await expect(useNameRow).toHaveAttribute('data-selected', 'true') + await expect(input).not.toHaveAttribute('aria-busy', 'true') + + await input.press('Enter') + await expect(input).toHaveValue(LINEAR_URL) + await expect(dialog.locator('[data-workspace-source-pill="true"]')).toHaveCount(0) + const suggestions = dialog.locator('[data-workspace-source-suggestions="true"]') + if (await suggestions.isVisible()) { + await input.press('Escape') + } + await expect(input).not.toHaveAttribute('aria-busy', 'true') + await input.press('Enter') + await expect(dialog.locator('[data-agent-combobox-root="true"][role="combobox"]')).toBeFocused() + }) + + test('pasting into Cmd+J previews the Linear issue and opens the linked composer', async ({ + electronApp, + orcaPage + }, testInfo) => { + await openJumpPalette(electronApp) + const palette = orcaPage.getByRole('dialog', { name: 'Jump to...' }) + const input = palette.getByPlaceholder( + 'Search chats, terminals, worktrees, settings, and actions...' + ) + await expect(input).toBeVisible() + + await pasteLinearUrl(orcaPage, input) + + const preview = palette.locator( + '[data-cmd-j-linear-issue-preview="true"][data-cmd-j-linear-issue-state="resolved"]' + ) + await expect(preview).toContainText(LINEAR_ISSUE.identifier) + await expect(preview).toContainText(LINEAR_ISSUE.title) + await expect(preview).toHaveAttribute('data-selected', 'true') + await testInfo.attach('linear-url-cmd-j-preview.png', { + body: await palette.screenshot(), + contentType: 'image/png' + }) + + await input.press('Enter') + const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + const sourcePill = dialog.locator('[data-workspace-source-pill="true"]') + await expect(sourcePill).toContainText(LINEAR_ISSUE.title) + await expect(dialog.getByPlaceholder('Workspace name')).toHaveValue(EXPECTED_WORKSPACE_NAME) + await expect(dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i })).toBeEnabled() + await testInfo.attach('linear-url-cmd-j-composer.png', { + body: await dialog.screenshot(), + contentType: 'image/png' + }) + }) +}) diff --git a/tests/e2e/multi-client-navigation-isolation.spec.ts b/tests/e2e/multi-client-navigation-isolation.spec.ts index 399ae5d963f..2c1018a75fa 100644 --- a/tests/e2e/multi-client-navigation-isolation.spec.ts +++ b/tests/e2e/multi-client-navigation-isolation.spec.ts @@ -1,5 +1,7 @@ import { execFileSync } from 'node:child_process' import { randomUUID } from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import os from 'node:os' import path from 'node:path' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' @@ -176,3 +178,98 @@ test('keeps two paired browser clients and the host on independent worktrees', a await clientA?.close() } }) + +test('shows only provider-backed creation actions in paired web', async ({ + electronApp, + orcaPage +}, testInfo) => { + const visibleWorktreeId = await orcaPage.evaluate( + () => window.__store?.getState().activeWorktreeId + ) + if (!visibleWorktreeId) { + throw new Error('Host worktree was not active before paired web validation') + } + + const offer = await createPairingOffer(orcaPage) + const client = await openPairedClient(electronApp, offer, visibleWorktreeId) + try { + await selectWorktree(client, visibleWorktreeId) + await expect + .poll(() => + client.evaluate(() => { + const state = window.__store?.getState() + const worktree = state + ?.allWorktrees() + .find((candidate) => candidate.id === state.activeWorktreeId) + const environmentId = worktree?.runtimeOwnerEnvironmentId + return environmentId + ? state.runtimeStatusByEnvironmentId + .get(environmentId) + ?.status.capabilities?.includes('browser.screencast.v1') === true + : false + }) + ) + .toBe(true) + + await client.getByRole('button', { name: 'New tab' }).first().click() + await expect(client.getByRole('menuitem', { name: /New Terminal/i })).toBeVisible() + await expect(client.getByRole('menuitem', { name: /New Browser Tab/i })).toBeVisible() + await expect(client.getByRole('menuitem', { name: /New Markdown/i })).toBeVisible() + await expect(client.getByRole('menuitem', { name: /Mobile Emulator/i })).toHaveCount(0) + + const screenshotPath = testInfo.outputPath('paired-web-provider-backed-create-menu.png') + await client.screenshot({ path: screenshotPath }) + await testInfo.attach('paired-web-provider-backed-create-menu', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + await client.close() + } +}) + +test('routes Add Project folder browsing through the paired host', async ({ + electronApp, + orcaPage, + registerPostElectronShutdownCleanup +}) => { + const hostFolder = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-web-folder-')) + const folderName = path.basename(hostFolder) + registerPostElectronShutdownCleanup(async () => { + rmSync(hostFolder, { recursive: true, force: true }) + }) + const visibleWorktreeId = await orcaPage.evaluate( + () => window.__store?.getState().activeWorktreeId + ) + if (!visibleWorktreeId) { + throw new Error('Host worktree was not active before paired web validation') + } + + const offer = await createPairingOffer(orcaPage) + const client = await openPairedClient(electronApp, offer, visibleWorktreeId) + try { + await client + .getByRole('button', { name: /Add Project/i }) + .first() + .click() + const addDialog = client.getByRole('dialog', { name: /Add a project/i }) + await expect(addDialog).toBeVisible() + await expect(addDialog).not.toContainText('Local Mac') + + await addDialog.getByRole('button', { name: /Browse folder/i }).click() + const browser = client.getByRole('dialog', { name: /Browse host filesystem/i }) + await expect(browser).toBeVisible() + await expect(browser.getByRole('button', { name: /Select folder/i })).toBeVisible() + await browser.getByRole('button', { name: /^Cancel$/i }).click() + + const manualPathDialog = client.getByRole('dialog', { name: /Open host project/i }) + await manualPathDialog.locator('#server-project-path').fill(hostFolder) + await manualPathDialog.getByRole('button', { name: /Open as Folder/i }).click() + await expect(manualPathDialog).toBeHidden({ timeout: 30_000 }) + await expect( + client.locator('[data-worktree-sidebar]').getByText(folderName, { exact: true }).first() + ).toBeVisible({ timeout: 30_000 }) + } finally { + await client.close() + } +}) diff --git a/tests/e2e/orchestration-worker-settlement-release-cli.spec.ts b/tests/e2e/orchestration-worker-settlement-release-cli.spec.ts new file mode 100644 index 00000000000..33ea97e13b0 --- /dev/null +++ b/tests/e2e/orchestration-worker-settlement-release-cli.spec.ts @@ -0,0 +1,290 @@ +import { spawnSync } from 'node:child_process' +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test as base, expect } from './helpers/orca-app' +import { waitForActivePaneHookDescriptor, waitForActivePanePtyId } from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { RuntimeClient } from '../../src/cli/runtime-client' +import Database from '../../src/main/sqlite/sync-database' +import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types' + +const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-settlement-release-')) +const cliLedgerPath = path.join(fakeCliDir, 'cli.jsonl') +const cliEntry = path.join(process.cwd(), 'out', 'cli', 'index.js') +const fakeCodexSource = ` +const { appendFileSync } = require('node:fs') +const { spawnSync } = require('node:child_process') +let capability = null +let acknowledged = false +process.stdout.write('\\u001b]0;Codex Ready\\u0007OpenAI Codex\\nmodel: e2e\\ndirectory: e2e\\n') +process.stdin.on('data', (chunk) => { + const input = chunk.toString() + capability ||= input.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] || null + if (!acknowledged && input.includes('\\r')) { + acknowledged = true + process.stdout.write('ACK\\n') + } + const encoded = input.match(/ORCA_E2E_WORKER_DONE:([A-Za-z0-9+/=]+)/)?.[1] + if (!encoded || !capability) return + const request = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) + const args = [ + 'orchestration', 'send', + '--from', request.mismatch ? 'term_foreign' : process.env.ORCA_TERMINAL_HANDLE, + '--dispatch-capability', capability, + '--to', request.coordinator, + '--type', 'worker_done', + '--subject', request.mismatch ? 'wrong sender' : 'completed', + '--body', 'Compiled CLI E2E worker completion.', + '--task-id', request.taskId, + '--dispatch-id', request.dispatchId, + '--outcome', 'succeeded', + '--json' + ] + const result = spawnSync(process.execPath, [process.env.ORCA_E2E_CLI_ENTRY, ...args], { + env: process.env, + encoding: 'utf8' + }) + appendFileSync( + process.env.ORCA_E2E_CLI_LEDGER, + JSON.stringify({ mismatch: request.mismatch, args, status: result.status, stdout: result.stdout, stderr: result.stderr }) + '\\n' + ) +}) +process.stdin.resume() +setInterval(() => {}, 60_000) +` + +if (process.platform === 'win32') { + writeFileSync(path.join(fakeCliDir, 'fake-codex.js'), fakeCodexSource) + writeFileSync( + path.join(fakeCliDir, 'codex.cmd'), + '@echo off\r\nnode "%~dp0\\fake-codex.js" %*\r\n' + ) +} else { + const executable = path.join(fakeCliDir, 'codex') + writeFileSync(executable, `#!/usr/bin/env node\n${fakeCodexSource}`) + chmodSync(executable, 0o755) +} + +const test = base.extend({ + launchEnv: [ + { + PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}`, + ORCA_E2E_CLI_ENTRY: cliEntry, + ORCA_E2E_CLI_LEDGER: cliLedgerPath + }, + { option: true } + ] +}) + +type CliLedgerEntry = { + mismatch: boolean + status: number + stdout: string + stderr: string +} + +function readCliLedger(): CliLedgerEntry[] { + if (!existsSync(cliLedgerPath)) { + return [] + } + return readFileSync(cliLedgerPath, 'utf8') + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line) as CliLedgerEntry) +} + +function invokeCompiledCli(userDataDir: string, args: string[]) { + return spawnSync(process.execPath, [cliEntry, ...args], { + env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir, ORCA_DEV_CLI_INVOCATION: '1' }, + encoding: 'utf8' + }) +} + +function encodeWorkerDone(input: { + coordinator: string + taskId: string + dispatchId: string + mismatch: boolean +}): string { + return Buffer.from(JSON.stringify(input)).toString('base64') +} + +test.afterAll(() => { + rmSync(fakeCliDir, { recursive: true, force: true }) +}) + +test('compiled CLI rejects false completion then reconciles the dead retained worker', async ({ + orcaPage, + electronApp +}) => { + test.setTimeout(180_000) + rmSync(cliLedgerPath, { force: true }) + await waitForSessionReady(orcaPage) + const worktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActivePanePtyId(orcaPage) + const coordinatorPane = await waitForActivePaneHookDescriptor(orcaPage) + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const client = new RuntimeClient(userDataDir, 30_000, null, null) + const coordinator = await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', { + paneKey: coordinatorPane.paneKey + }) + const run = await client.call<{ run: { id: string } }>('orchestration.runCreate', { + objective: 'Compiled CLI settlement and release E2E', + from: coordinator.result.terminal.handle + }) + const task = await client.call<{ task: { id: string } }>('orchestration.taskCreate', { + spec: 'Respond ACK and await the completion marker.', + run: run.result.run.id, + callerTerminalHandle: coordinator.result.terminal.handle + }) + const coordinatorTerminal = await client.call<{ terminal: { worktreeId: string } }>( + 'terminal.show', + { terminal: coordinator.result.terminal.handle } + ) + await expect + .poll(async () => { + const listed = await client.call<{ worktrees: { id: string }[] }>('worktree.list', {}) + return listed.result.worktrees.some( + (worktree) => worktree.id === coordinatorTerminal.result.terminal.worktreeId + ) + }) + .toBe(true) + const started = await client.call<{ effects: { kind: string; role?: string; id?: string }[] }>( + 'orchestration.workerStart', + { + task: task.result.task.id, + from: coordinator.result.terminal.handle, + agent: 'codex', + timeoutMs: 15_000 + } + ) + const workerHandle = started.result.effects.find( + (effect) => effect.kind === 'terminal' && effect.role === 'agent' + )?.id + expect(workerHandle).toBeTruthy() + + let worker = ( + await client.call('terminal.list') + ).result.terminals.find((terminal) => terminal.handle === workerHandle) + await expect + .poll(async () => { + const listed = await client.call('terminal.list') + worker = listed.result.terminals.find((terminal) => terminal.handle === workerHandle) + if (!worker) { + return '' + } + const read = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { + terminal: worker.handle, + limit: 200 + }) + return read.result.terminal.tail.join('\n') + }) + .toContain('ACK') + const dispatch = await client.call<{ dispatch: { id: string; status: string } | null }>( + 'orchestration.dispatchShow', + { task: task.result.task.id } + ) + expect(dispatch.result.dispatch?.status).toBe('dispatched') + + const baseMarker = { + coordinator: coordinator.result.terminal.handle, + taskId: task.result.task.id, + dispatchId: dispatch.result.dispatch!.id + } + await client.call('terminal.send', { + terminal: workerHandle, + text: `ORCA_E2E_WORKER_DONE:${encodeWorkerDone({ ...baseMarker, mismatch: true })}`, + enter: true + }) + await expect.poll(() => readCliLedger()).toHaveLength(1) + const rejected = readCliLedger()[0]! + expect.soft(rejected.status).not.toBe(0) + expect.soft(JSON.parse(rejected.stdout)).toMatchObject({ + ok: false, + error: { code: 'dispatch_capability_invalid' } + }) + const stillDispatched = await client.call<{ dispatch: { status: string } | null }>( + 'orchestration.dispatchShow', + { task: task.result.task.id } + ) + expect(stillDispatched.result.dispatch?.status).toBe('dispatched') + + await client.call('terminal.send', { + terminal: workerHandle, + text: `ORCA_E2E_WORKER_DONE:${encodeWorkerDone({ ...baseMarker, mismatch: false })}`, + enter: true + }) + await expect.poll(() => readCliLedger()).toHaveLength(2) + const completed = readCliLedger()[1]! + expect(completed.status).toBe(0) + expect.soft(JSON.parse(completed.stdout)).toMatchObject({ + ok: true, + result: { lifecycle: { action: 'completed' } } + }) + await expect + .poll(async () => { + const current = await client.call<{ dispatch: { status: string } | null }>( + 'orchestration.dispatchShow', + { task: task.result.task.id } + ) + return current.result.dispatch?.status + }) + .toBe('completed') + + await client.call('terminal.close', { terminal: workerHandle }) + await expect + .poll(async () => { + const listed = await client.call('terminal.list') + return listed.result.terminals.some((terminal) => terminal.handle === workerHandle) + }) + .toBe(false) + const db = new Database(path.join(userDataDir, 'orchestration.db')) + try { + db.prepare( + `UPDATE worker_terminal_resources + SET ownership_state = 'external', release_state = 'retained', + retained_reason = 'external_terminal' + WHERE owner_dispatch_id = ?` + ).run(dispatch.result.dispatch!.id) + } finally { + db.close() + } + + const released = invokeCompiledCli(userDataDir, [ + 'orchestration', + 'worker-release', + '--dispatch', + dispatch.result.dispatch!.id, + '--json' + ]) + expect(released.status).toBe(0) + expect.soft(JSON.parse(released.stdout)).toMatchObject({ + ok: true, + result: { state: 'released', processAction: 'none' } + }) + const verified = new Database(path.join(userDataDir, 'orchestration.db')) + try { + expect + .soft( + verified + .prepare( + `SELECT ownership_state, release_state + FROM worker_terminal_resources WHERE owner_dispatch_id = ?` + ) + .get(dispatch.result.dispatch!.id) + ) + .toEqual({ ownership_state: 'released', release_state: 'released' }) + } finally { + verified.close() + } + const coordinatorStillLive = await client.call('terminal.list', { + worktree: `id:${worktreeId}` + }) + expect( + coordinatorStillLive.result.terminals.some( + (terminal) => terminal.handle === coordinator.result.terminal.handle + ) + ).toBe(true) +}) diff --git a/tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts b/tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts new file mode 100644 index 00000000000..1e8de3f5287 --- /dev/null +++ b/tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts @@ -0,0 +1,394 @@ +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { RuntimeClient } from '../../src/cli/runtime/client' +import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' +import { expect, test } from './helpers/orca-app' +import { openFileExplorer } from './helpers/file-explorer' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './helpers/headless-paired-runtime-host' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient, + type RuntimeDesktopPairingOffer +} from './helpers/paired-electron-client' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const FIXTURE_NAME = 'paired-browser-reconcile-failure.html' + +type FaultSnapshot = { + armed: boolean + capabilityRejectionArmed: boolean + createdPageId: string | null + suppressedPageIds: string[] +} + +type FaultWindow = Window & { + __webRuntimeBrowserCreationFault?: { + arm: () => void + armCapabilityRejection: () => void + release: () => boolean + reset: () => void + snapshot: () => FaultSnapshot + } +} + +type ClientTabState = { + browserTabIds: string[] + browserWorkspaceIds: string[] + editorTabIds: string[] + groupIds: string[] + terminalTabIds: string[] +} + +async function readHostTabs( + hostClient: RuntimeClient, + repoPath: string +): Promise { + const response = await hostClient.call('session.tabs.list', { + worktree: `path:${repoPath}` + }) + return response.result +} + +async function readClientTabs(page: Page, worktreeId: string): Promise { + return page.evaluate((targetWorktreeId) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Paired client store unavailable') + } + const unifiedTabs = state.unifiedTabsByWorktree[targetWorktreeId] ?? [] + return { + browserTabIds: unifiedTabs + .filter((tab) => tab.contentType === 'browser') + .map((tab) => tab.id), + browserWorkspaceIds: (state.browserTabsByWorktree[targetWorktreeId] ?? []).map( + (workspace) => workspace.id + ), + editorTabIds: unifiedTabs.filter((tab) => tab.contentType === 'editor').map((tab) => tab.id), + groupIds: (state.groupsByWorktree[targetWorktreeId] ?? []).map((group) => group.id), + terminalTabIds: unifiedTabs + .filter((tab) => tab.contentType === 'terminal') + .map((tab) => tab.id) + } + }, worktreeId) +} + +async function runReconciliationFailureJourney(args: { + hostClient: RuntimeClient + offer: RuntimeDesktopPairingOffer + repoPath: string + testInfo: TestInfo + topology: 'headed' | 'headless' +}): Promise { + let client: PairedElectronClient | null = null + try { + client = await launchPairedElectronClient( + args.offer, + args.testInfo, + `${args.topology} browser reconciliation failure` + ) + await client.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.show()) + const page = client.page + const worktreeId = await expect + .poll( + () => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, args.repoPath), + { timeout: 60_000, message: 'paired client never received the host worktree' } + ) + .not.toBeNull() + .then(() => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, args.repoPath) + ) + if (!worktreeId) { + throw new Error('Paired client worktree disappeared after discovery') + } + await page.evaluate( + ({ environmentId, worktreeId }) => { + window.__store?.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`) + }, + { environmentId: client.environmentId, worktreeId } + ) + await expect + .poll(() => readClientTabs(page, worktreeId), { + timeout: 60_000, + message: 'paired client did not materialize the host terminal' + }) + .toMatchObject({ terminalTabIds: expect.arrayContaining([expect.any(String)]) }) + + await openFileExplorer(page) + const fixtureRow = page.locator('[data-file-explorer-row]').filter({ hasText: FIXTURE_NAME }) + await expect(fixtureRow).toBeVisible({ timeout: 30_000 }) + await fixtureRow.click() + const openPreviewToSide = page.getByRole('button', { name: 'Open Preview to the Side' }) + await expect(openPreviewToSide).toBeVisible({ timeout: 30_000 }) + const baselineClient = await readClientTabs(page, worktreeId) + expect(baselineClient.editorTabIds).not.toHaveLength(0) + expect(baselineClient.terminalTabIds).not.toHaveLength(0) + const baselineHost = await readHostTabs(args.hostClient, args.repoPath) + const baselineHostBrowserIds = baselineHost.tabs + .filter((tab) => tab.type === 'browser') + .map((tab) => tab.browserPageId) + + await page.evaluate(() => { + const fault = (window as FaultWindow).__webRuntimeBrowserCreationFault + if (!fault) { + throw new Error('Browser reconciliation E2E fault seam unavailable') + } + fault.arm() + }) + await openPreviewToSide.click() + + const faultSnapshot = await expect + .poll( + () => + page.evaluate( + () => (window as FaultWindow).__webRuntimeBrowserCreationFault?.snapshot() ?? null + ), + { timeout: 30_000, message: 'host browser creation never reached the held fault seam' } + ) + .toMatchObject({ armed: true, createdPageId: expect.any(String) }) + .then(() => + page.evaluate( + () => (window as FaultWindow).__webRuntimeBrowserCreationFault?.snapshot() ?? null + ) + ) + const createdPageId = faultSnapshot?.createdPageId + if (!createdPageId) { + throw new Error('Held browser creation did not expose its exact host page id') + } + + const heldHost = await readHostTabs(args.hostClient, args.repoPath) + expect( + heldHost.tabs.some((tab) => tab.type === 'browser' && tab.browserPageId === createdPageId) + ).toBe(true) + const heldClient = await readClientTabs(page, worktreeId) + expect(heldClient.browserTabIds).toEqual(baselineClient.browserTabIds) + expect(heldClient.browserWorkspaceIds).toEqual(baselineClient.browserWorkspaceIds) + expect(heldClient.editorTabIds).toEqual(baselineClient.editorTabIds) + expect(heldClient.terminalTabIds).toEqual(baselineClient.terminalTabIds) + expect(heldClient.groupIds).toHaveLength(baselineClient.groupIds.length + 1) + + await page.screenshot({ + path: args.testInfo.outputPath(`${args.topology}-browser-reconciliation-held.png`), + fullPage: true + }) + expect( + await page.evaluate( + () => (window as FaultWindow).__webRuntimeBrowserCreationFault?.release() ?? false + ) + ).toBe(true) + + await expect(page.getByText('Unable to open this file in Orca Browser.')).toBeVisible({ + timeout: 30_000 + }) + await expect + .poll( + async () => { + const snapshot = await readHostTabs(args.hostClient, args.repoPath) + return snapshot.tabs.some( + (tab) => tab.type === 'browser' && tab.browserPageId === createdPageId + ) + }, + { timeout: 30_000, message: 'rollback did not close the exact host browser page' } + ) + .toBe(false) + const settledClient = await expect + .poll(() => readClientTabs(page, worktreeId), { + timeout: 30_000, + message: 'client split and browser state did not settle after rollback' + }) + .toMatchObject({ + browserTabIds: baselineClient.browserTabIds, + browserWorkspaceIds: baselineClient.browserWorkspaceIds, + editorTabIds: baselineClient.editorTabIds, + groupIds: baselineClient.groupIds, + terminalTabIds: baselineClient.terminalTabIds + }) + .then(() => readClientTabs(page, worktreeId)) + expect(settledClient).toEqual(baselineClient) + expect( + (await readHostTabs(args.hostClient, args.repoPath)).tabs + .filter((tab) => tab.type === 'browser') + .map((tab) => tab.browserPageId) + ).toEqual(baselineHostBrowserIds) + await page.evaluate(() => (window as FaultWindow).__webRuntimeBrowserCreationFault?.reset()) + } finally { + await client?.dispose() + } +} + +async function runCapabilityFailureJourney(args: { + hostClient: RuntimeClient + offer: RuntimeDesktopPairingOffer + repoPath: string + testInfo: TestInfo + topology: 'headed' | 'headless' +}): Promise { + let client: PairedElectronClient | null = null + try { + client = await launchPairedElectronClient( + args.offer, + args.testInfo, + `${args.topology} browser capability failure` + ) + await client.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.show()) + const page = client.page + const worktreeId = await expect + .poll( + () => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, args.repoPath), + { timeout: 60_000, message: 'paired client never received the host worktree' } + ) + .not.toBeNull() + .then(() => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, args.repoPath) + ) + if (!worktreeId) { + throw new Error('Paired client worktree disappeared after discovery') + } + await page.evaluate( + ({ environmentId, worktreeId }) => { + window.__store?.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`) + }, + { environmentId: client.environmentId, worktreeId } + ) + await expect + .poll(() => readClientTabs(page, worktreeId), { + timeout: 60_000, + message: 'paired client did not materialize the host terminal' + }) + .toMatchObject({ terminalTabIds: expect.arrayContaining([expect.any(String)]) }) + + await openFileExplorer(page) + const fixtureRow = page.locator('[data-file-explorer-row]').filter({ hasText: FIXTURE_NAME }) + await expect(fixtureRow).toBeVisible({ timeout: 30_000 }) + await fixtureRow.click() + const openPreviewToSide = page.getByRole('button', { name: 'Open Preview to the Side' }) + await expect(openPreviewToSide).toBeVisible({ timeout: 30_000 }) + const baselineClient = await readClientTabs(page, worktreeId) + const baselineHost = await readHostTabs(args.hostClient, args.repoPath) + + await page.evaluate(() => { + const fault = (window as FaultWindow).__webRuntimeBrowserCreationFault + if (!fault) { + throw new Error('Browser capability E2E fault seam unavailable') + } + fault.armCapabilityRejection() + }) + await openPreviewToSide.click() + + await expect(page.getByText('Unable to open this file in Orca Browser.')).toBeVisible({ + timeout: 30_000 + }) + await expect + .poll(() => readClientTabs(page, worktreeId), { + timeout: 30_000, + message: 'client split state did not settle after capability rejection' + }) + .toEqual(baselineClient) + expect(await readHostTabs(args.hostClient, args.repoPath)).toEqual(baselineHost) + await page.screenshot({ + path: args.testInfo.outputPath(`${args.topology}-browser-capability-rejected.png`), + fullPage: true + }) + await page.evaluate(() => (window as FaultWindow).__webRuntimeBrowserCreationFault?.reset()) + } finally { + await client?.dispose() + } +} + +test('rolls back a headed-host browser when client reconciliation times out @headful', async ({ + electronApp, + orcaPage, + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + writeFileSync( + path.join(testRepoPath, FIXTURE_NAME), + '

browser reconciliation fault

\n' + ) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + await runReconciliationFailureJourney({ + hostClient: new RuntimeClient(userDataDir, 5_000), + offer, + repoPath: testRepoPath, + testInfo, + topology: 'headed' + }) +}) + +test('cleans up a headed-host preview when capability rejects after preflight @headful', async ({ + electronApp, + orcaPage, + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + writeFileSync( + path.join(testRepoPath, FIXTURE_NAME), + '

browser capability fault

\n' + ) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + await runCapabilityFailureJourney({ + hostClient: new RuntimeClient(userDataDir, 5_000), + offer, + repoPath: testRepoPath, + testInfo, + topology: 'headed' + }) +}) + +test('keeps browser failure cleanup on a headless host', async ({ testRepoPath }, testInfo) => { + test.setTimeout(300_000) + writeFileSync( + path.join(testRepoPath, FIXTURE_NAME), + '

headless capability fault

\n' + ) + const host: HeadlessPairedRuntimeHost = await launchHeadlessPairedRuntimeHost() + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + await host.client.call('terminal.create', { + worktree: `path:${testRepoPath}`, + title: 'Browser failure cleanup canary' + }) + await runReconciliationFailureJourney({ + hostClient: host.client, + offer: host.offer, + repoPath: testRepoPath, + testInfo, + topology: 'headless' + }) + await runCapabilityFailureJourney({ + hostClient: host.client, + offer: host.offer, + repoPath: testRepoPath, + testInfo, + topology: 'headless' + }) + } finally { + await host.dispose() + } +}) diff --git a/tests/e2e/paired-remote-browser-link-open-routing.spec.ts b/tests/e2e/paired-remote-browser-link-open-routing.spec.ts new file mode 100644 index 00000000000..6da4965703e --- /dev/null +++ b/tests/e2e/paired-remote-browser-link-open-routing.spec.ts @@ -0,0 +1,310 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { Page } from '@stablyai/playwright-test' +import { LOCAL_EXECUTION_HOST_ID } from '../../src/shared/execution-host' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './helpers/headless-paired-runtime-host' +import { expect, test } from './helpers/orca-app' +import { + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' + +// The link is a dev-server URL, so a client-local fallback would silently load a *different +// machine's* server while looking successful. + +const PANE_PATH = '/remote-pane' +const LINK_PATH = '/remote-link-target' +const LINK_MARKER = 'remote link target' + +type LinkFixtureServer = { + close: () => Promise + linkLoadCount: () => number + linkUrl: string + paneUrl: string +} + +async function startLinkFixtureServer(): Promise { + let linkLoadCount = 0 + const server: Server = createServer((request: IncomingMessage, response: ServerResponse) => { + const requestPath = request.url ?? '/' + if (requestPath.startsWith(LINK_PATH)) { + linkLoadCount += 1 + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-type': 'text/html; charset=utf-8' + }) + response.end(`

${LINK_MARKER}

`) + return + } + if (requestPath.startsWith(PANE_PATH)) { + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-type': 'text/html; charset=utf-8' + }) + // Why the viewport-filling anchor: the context menu reads the link with + // elementFromPoint over the screencast, so covering the viewport keeps the test off + // screencast coordinate mapping — any right-click lands on the link. + response.end( + `open me` + ) + return + } + response.writeHead(404, { 'content-type': 'text/plain' }) + response.end('not found') + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const { port } = server.address() as AddressInfo + const origin = `http://127.0.0.1:${port}` + return { + close: () => + new Promise((resolve, reject) => { + // Keep-alive sockets from either browser would otherwise hold the close open. + server.closeAllConnections() + server.close((error) => (error ? reject(error) : resolve())) + }), + linkLoadCount: () => linkLoadCount, + linkUrl: `${origin}${LINK_PATH}`, + paneUrl: `${origin}${PANE_PATH}` + } +} + +/** Asked over the host's own connection, not proxied through the client under test. */ +async function readHostBrowserUrls( + host: HeadlessPairedRuntimeHost, + worktreeId: string +): Promise { + const response = await host.client.call<{ tabs: { type: string; url?: string }[] }>( + 'session.tabs.list', + { worktree: `id:${worktreeId}` }, + { timeoutMs: 15_000 } + ) + return response.result.tabs.filter((tab) => tab.type === 'browser').map((tab) => tab.url ?? '') +} + +/** A remote pane is a screencast image; a means the page really loaded on this machine. */ +async function readLocalBrowserViewUrls(page: Page): Promise { + return page.evaluate(() => + Array.from(document.querySelectorAll('webview')).map( + (view) => (view as HTMLElement & { src?: string }).src || view.getAttribute('src') || '' + ) + ) +} + +async function readRemotePaneUrls(page: Page, worktreeId: string): Promise { + return page.evaluate((worktreeId) => { + const state = window.__store?.getState() + const workspaces = state?.browserTabsByWorktree[worktreeId] ?? [] + return workspaces + .flatMap((workspace) => state?.browserPagesByWorkspace[workspace.id] ?? []) + .filter( + (browserPage) => state?.remoteBrowserPageHandlesByPageId[browserPage.id]?.environmentId + ) + .map((browserPage) => browserPage.url) + }, worktreeId) +} + +/** The client mirrors host browser tabs on its own; this finds the mirrored page for one URL. */ +async function findMirroredPage( + page: Page, + worktreeId: string, + url: string +): Promise<{ handleEnvironmentId: string | null; pageId: string } | null> { + return page.evaluate( + ({ url, worktreeId }) => { + const state = window.__store?.getState() + for (const workspace of state?.browserTabsByWorktree[worktreeId] ?? []) { + for (const browserPage of state?.browserPagesByWorkspace[workspace.id] ?? []) { + if (browserPage.url.startsWith(url)) { + return { + handleEnvironmentId: + state?.remoteBrowserPageHandlesByPageId[browserPage.id]?.environmentId ?? null, + pageId: browserPage.id + } + } + } + } + return null + }, + { url, worktreeId } + ) +} + +async function focusMirroredPage(page: Page, worktreeId: string, pageId: string): Promise { + await page.evaluate( + ({ pageId, worktreeId }) => { + window.__store?.getState().focusBrowserTabInWorktree(worktreeId, pageId, { + surfacePane: true + }) + }, + { pageId, worktreeId } + ) +} + +type LinkOpenOutcome = 'opened on this machine' | 'pending' | 'refused' + +async function readLinkOpenOutcome(page: Page, linkUrl: string): Promise { + if ((await readLocalBrowserViewUrls(page)).some((url) => url.startsWith(linkUrl))) { + return 'opened on this machine' + } + const notice = page.getByTestId('remote-browser-stream-error') + const text = (await notice.count()) > 0 ? ((await notice.first().textContent()) ?? '') : '' + return text.includes('Unable to open URL.') ? 'refused' : 'pending' +} + +async function openLinkFromRemotePaneContextMenu(page: Page): Promise { + const remoteFrame = page.locator('[data-testid="remote-browser-frame"]:visible').first() + await expect(remoteFrame).toBeVisible({ timeout: 60_000 }) + await remoteFrame.click({ button: 'right', position: { x: 60, y: 60 }, force: true }) + await expect(page.getByTestId('remote-browser-context-menu')).toBeVisible({ timeout: 30_000 }) + // The item only renders once the remote hit-test resolves an anchor, so this wait is the + // wait for the link lookup itself. + const openInOrca = page.getByRole('menuitem', { name: 'Open Link In Orca Browser' }) + await expect(openInOrca).toBeVisible({ timeout: 30_000 }) + await openInOrca.click() +} + +test('opens a remote pane link on the pane runtime and refuses to fall back to the client', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + const fixture = await startLinkFixtureServer() + const host: HeadlessPairedRuntimeHost = await launchHeadlessPairedRuntimeHost() + let client: PairedElectronClient | null = null + + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + client = await launchPairedElectronClient(host.offer, testInfo, 'Remote browser link routing') + const page = client.page + const environmentId = client.environmentId + + await expect + .poll(() => page.evaluate(() => window.__store?.getState().allWorktrees().length ?? 0), { + timeout: 60_000, + message: 'paired client never saw a host worktree' + }) + .toBeGreaterThan(0) + const worktreeId = await page.evaluate( + () => window.__store?.getState().allWorktrees()[0]?.id ?? null + ) + if (!worktreeId) { + throw new Error('paired client did not receive the host worktree') + } + + // The workspace runs on the paired runtime, the way it does when the user picks that host. + await page.evaluate( + ({ environmentId, worktreeId }) => { + window.__store?.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`) + }, + { environmentId, worktreeId } + ) + + // The host opens the page on itself: this is the "remote server" whose links must stay remote. + await host.client.call( + 'browser.tabCreate', + { worktree: `id:${worktreeId}`, url: fixture.paneUrl, activate: true }, + { timeoutMs: 30_000 } + ) + await expect + .poll(() => findMirroredPage(page, worktreeId, fixture.paneUrl), { + timeout: 60_000, + message: 'the client never mirrored the host browser page' + }) + .not.toBeNull() + const pane = await findMirroredPage(page, worktreeId, fixture.paneUrl) + if (!pane) { + throw new Error('mirrored host browser page disappeared') + } + expect(pane.handleEnvironmentId).toBe(environmentId) + await focusMirroredPage(page, worktreeId, pane.pageId) + const paneCountBeforeOpen = await page.getByTestId('remote-browser-pane').count() + + // Act 1: the healthy path must land on the runtime, end to end. + await openLinkFromRemotePaneContextMenu(page) + + await expect + .poll( + async () => + (await readHostBrowserUrls(host, worktreeId)).filter((url) => + url.startsWith(fixture.linkUrl) + ).length, + { timeout: 60_000, message: 'the link never opened as a browser tab on the host runtime' } + ) + .toBe(1) + // The host's browser really fetched it; a tab record alone would not prove a load. + expect(fixture.linkLoadCount()).toBeGreaterThan(0) + // One more remote pane, and still nothing rendered by this machine's own browser. + await expect(page.getByTestId('remote-browser-pane')).toHaveCount(paneCountBeforeOpen + 1, { + timeout: 60_000 + }) + expect(await readRemotePaneUrls(page, worktreeId)).toContainEqual( + expect.stringContaining(fixture.linkUrl) + ) + expect(await readLocalBrowserViewUrls(page)).toHaveLength(0) + + // Drop every tab except the pane's, so the second act drives the pane it started with. + await page.evaluate( + ({ paneUrl, worktreeId }) => { + const state = window.__store?.getState() + for (const workspace of state?.browserTabsByWorktree[worktreeId] ?? []) { + const pages = state?.browserPagesByWorkspace[workspace.id] ?? [] + if (!pages.some((browserPage) => browserPage.url.startsWith(paneUrl))) { + state?.closeBrowserTab(workspace.id) + } + } + }, + { paneUrl: fixture.paneUrl, worktreeId } + ) + await expect(page.getByTestId('remote-browser-pane')).toHaveCount(paneCountBeforeOpen, { + timeout: 60_000 + }) + + // Act 2: the user moves this workspace onto their own machine while the runtime's page is + // still on screen. Opening the link must fail in the pane, not load the runtime's dev server + // here — the client has no business serving a page for a workspace it does not run. + await page.evaluate( + ({ localHostId, worktreeId }) => { + window.__store?.getState().setActiveWorktree(worktreeId, localHostId) + }, + { localHostId: LOCAL_EXECUTION_HOST_ID, worktreeId } + ) + await focusMirroredPage(page, worktreeId, pane.pageId) + const hostUrlsBefore = await readHostBrowserUrls(host, worktreeId) + const linkLoadsBefore = fixture.linkLoadCount() + + await openLinkFromRemotePaneContextMenu(page) + + // Wait for the click to produce an outcome — refusal or a local page — so the assertion below + // reports which one happened instead of racing past a fallback that lands a moment later. + await expect + .poll(() => readLinkOpenOutcome(page, fixture.linkUrl), { + timeout: 30_000, + message: 'the link open produced neither a refusal nor a page' + }) + .not.toBe('pending') + expect(await readLinkOpenOutcome(page, fixture.linkUrl)).toBe('refused') + + // The workspace must still be the local one, or the refusal above proved nothing. + expect( + await page.evaluate(() => window.__store?.getState().activeWorkspaceExecutionHostId ?? null) + ).toBe(LOCAL_EXECUTION_HOST_ID) + // Nothing rendered here, nothing new on the host, and nobody fetched the link anywhere. + expect(await readLocalBrowserViewUrls(page)).toHaveLength(0) + expect(await readHostBrowserUrls(host, worktreeId)).toEqual(hostUrlsBefore) + expect(fixture.linkLoadCount()).toBe(linkLoadsBefore) + } finally { + if (client) { + await client.dispose() + } + await host.dispose() + await fixture.close() + } +}) diff --git a/tests/e2e/paired-remote-html-browser-focus.spec.ts b/tests/e2e/paired-remote-html-browser-focus.spec.ts new file mode 100644 index 00000000000..5a70fa10465 --- /dev/null +++ b/tests/e2e/paired-remote-html-browser-focus.spec.ts @@ -0,0 +1,460 @@ +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { openFileExplorer } from './helpers/file-explorer' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const FIXTURE_NAME = 'paired-html-focus.html' + +test('keeps remote HTML preview placement and focuses it only after a click', async ({ + orcaPage, + testRepoPath +}, testInfo) => { + test.setTimeout(240_000) + writeFileSync( + path.join(testRepoPath, FIXTURE_NAME), + '

paired html focus

\n' + ) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + let client: PairedElectronClient | null = null + try { + client = await launchPairedElectronClient(offer, testInfo, 'Remote HTML focus') + const page = client.page + const worktreeId = await expect + .poll( + () => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state + ? (state.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null) + : null + }, testRepoPath), + { timeout: 60_000, message: 'paired client never received the host worktree' } + ) + .not.toBeNull() + .then(() => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, testRepoPath) + ) + if (!worktreeId) { + throw new Error('paired client worktree disappeared after discovery') + } + await page.evaluate( + ({ environmentId, worktreeId }) => { + window.__store?.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`) + }, + { environmentId: client.environmentId, worktreeId } + ) + await openFileExplorer(page) + const fixtureRow = page.locator('[data-file-explorer-row]').filter({ hasText: FIXTURE_NAME }) + await expect(fixtureRow).toBeVisible({ timeout: 30_000 }) + await fixtureRow.click() + const openPreviewToSide = page.getByRole('button', { name: 'Open Preview to the Side' }) + await expect(openPreviewToSide).toBeVisible({ timeout: 30_000 }) + const sourceEditor = await page.evaluate((targetWorktreeId) => { + const state = window.__store?.getState() + const groupId = state?.activeGroupIdByWorktree[targetWorktreeId] + const group = (state?.groupsByWorktree[targetWorktreeId] ?? []).find( + (candidate) => candidate.id === groupId + ) + const tab = (state?.unifiedTabsByWorktree[targetWorktreeId] ?? []).find( + (candidate) => candidate.id === group?.activeTabId && candidate.contentType === 'editor' + ) + return groupId && tab ? { groupId, tabId: tab.id } : null + }, worktreeId) + if (!sourceEditor) { + throw new Error('paired client editor had no source identity before side preview') + } + const sourceGroupId = sourceEditor.groupId + const browserBaseline = await page.evaluate( + async ({ environmentId, worktreeId }) => { + const state = window.__store?.getState() + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + return { + clientUnified: (state?.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'browser' + ).length, + clientWorkspaces: (state?.browserTabsByWorktree[worktreeId] ?? []).length, + host: response.ok + ? response.result.tabs.filter((tab) => tab.type === 'browser').length + : -1 + } + }, + { environmentId: client.environmentId, worktreeId } + ) + await openPreviewToSide.click() + + await expect + .poll( + () => + page.evaluate( + async ({ environmentId, fixtureName, worktreeId }) => { + const state = window.__store?.getState() + if (!state) { + return null + } + const browser = (state.browserTabsByWorktree[worktreeId] ?? []).find((tab) => + tab.url.endsWith(`/${fixtureName}`) + ) + const browserPage = browser + ? (state.browserPagesByWorkspace[browser.id] ?? [])[0] + : null + const handle = browserPage + ? state.remoteBrowserPageHandlesByPageId[browserPage.id] + : null + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + const hostHasHtml = + response.ok && + response.result.tabs.some( + (tab) => tab.type === 'browser' && tab.url.endsWith(`/${fixtureName}`) + ) + return { + browserRuntimeEnvironmentId: browserPage?.browserRuntimeEnvironmentId ?? null, + handleEnvironmentId: handle?.environmentId ?? null, + handleRemotePageId: handle?.remotePageId ?? null, + hostHasHtml + } + }, + { environmentId: client!.environmentId, fixtureName: FIXTURE_NAME, worktreeId } + ), + { timeout: 60_000, message: 'remote HTML browser ownership never converged' } + ) + .toMatchObject({ + browserRuntimeEnvironmentId: client.environmentId, + handleEnvironmentId: client.environmentId, + hostHasHtml: true + }) + const htmlTabInventory = await page.evaluate( + async ({ environmentId, fixtureName, worktreeId }) => { + const state = window.__store?.getState() + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + const clientWorkspaceIds = (state?.browserTabsByWorktree[worktreeId] ?? []) + .filter((tab) => tab.url.endsWith(`/${fixtureName}`)) + .map((tab) => tab.id) + return { + clientWorkspaceIds, + clientUnifiedTabs: (state?.unifiedTabsByWorktree[worktreeId] ?? []) + .filter( + (tab) => tab.contentType === 'browser' && clientWorkspaceIds.includes(tab.entityId) + ) + .map((tab) => ({ groupId: tab.groupId, id: tab.id })), + hostTabIds: response.ok + ? response.result.tabs + .filter((tab) => tab.type === 'browser' && tab.url.endsWith(`/${fixtureName}`)) + .map((tab) => tab.id) + : [], + hostTabGroups: response.ok ? (response.result.tabGroups ?? []) : [], + totalClientUnified: (state?.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'browser' + ).length, + totalClientWorkspaces: (state?.browserTabsByWorktree[worktreeId] ?? []).length, + totalHost: response.ok + ? response.result.tabs.filter((tab) => tab.type === 'browser').length + : -1 + } + }, + { environmentId: client.environmentId, fixtureName: FIXTURE_NAME, worktreeId } + ) + expect(htmlTabInventory.clientWorkspaceIds).toHaveLength(1) + expect(htmlTabInventory.clientUnifiedTabs).toHaveLength(1) + expect(htmlTabInventory.hostTabIds).toHaveLength(1) + expect(htmlTabInventory.totalClientUnified).toBe(browserBaseline.clientUnified + 1) + expect(htmlTabInventory.totalClientWorkspaces).toBe(browserBaseline.clientWorkspaces + 1) + expect(htmlTabInventory.totalHost).toBe(browserBaseline.host + 1) + expect(htmlTabInventory.clientUnifiedTabs[0]?.groupId).not.toBe(sourceGroupId) + expect( + htmlTabInventory.hostTabGroups.some( + (group) => group.id === htmlTabInventory.clientUnifiedTabs[0]?.groupId + ) + ).toBe(false) + const hostBrowserGroupId = htmlTabInventory.hostTabGroups.find((group) => + group.tabOrder.includes(htmlTabInventory.hostTabIds[0]!) + )?.id + expect(hostBrowserGroupId).toBeTruthy() + await expect(page.locator(`[data-tab-group-body-id="${sourceGroupId}"]`)).toBeVisible() + await expect( + page.locator(`[data-tab-group-body-id="${htmlTabInventory.clientUnifiedTabs[0]!.groupId}"]`) + ).toBeVisible() + await expect(page.getByTestId('remote-browser-frame')).toBeVisible({ timeout: 60_000 }) + + const previewFocus = await page.evaluate( + ({ sourceGroupId, sourceTabId, worktreeId: targetWorktreeId }) => { + const state = window.__store?.getState() + const activeGroup = (state?.groupsByWorktree[targetWorktreeId] ?? []).find( + (group) => group.id === state?.activeGroupIdByWorktree[targetWorktreeId] + ) + return { + activeGroupId: activeGroup?.id ?? null, + activeTabId: activeGroup?.activeTabId ?? null, + activeTabType: state?.activeTabTypeByWorktree[targetWorktreeId] ?? null, + sourceGroupId, + sourceTabId + } + }, + { sourceGroupId, sourceTabId: sourceEditor.tabId, worktreeId } + ) + expect(previewFocus).toEqual({ + activeGroupId: sourceGroupId, + activeTabId: sourceEditor.tabId, + activeTabType: 'editor', + sourceGroupId, + sourceTabId: sourceEditor.tabId + }) + + const tabIds = await page.evaluate( + ({ fixtureName, worktreeId: targetWorktreeId }) => { + const state = window.__store?.getState() + if (!state) { + return null + } + const terminalId = state.tabsByWorktree[targetWorktreeId]?.[0]?.id ?? null + const browserId = (state.browserTabsByWorktree[targetWorktreeId] ?? []).find((tab) => + tab.url.endsWith(`/${fixtureName}`) + )?.id + return terminalId && browserId ? { browserId, terminalId } : null + }, + { fixtureName: FIXTURE_NAME, worktreeId } + ) + if (!tabIds) { + throw new Error('paired client did not retain both terminal and HTML browser tabs') + } + await page.locator(`[data-tab-id="${tabIds.terminalId}"]`).click() + await expect + .poll( + () => + page.evaluate((targetWorktreeId) => { + const state = window.__store?.getState() + return state?.activeTabTypeByWorktree[targetWorktreeId] ?? null + }, worktreeId), + { message: 'terminal tab never became active before the browser click' } + ) + .toBe('terminal') + await expect + .poll( + () => + page.evaluate( + async ({ environmentId, worktreeId }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + if (!response.ok) { + return false + } + return ( + response.result.tabs.find((tab) => tab.id === response.result.activeTabId)?.type === + 'terminal' + ) + }, + { environmentId: client.environmentId, worktreeId } + ), + { timeout: 30_000, message: 'host never accepted terminal activation' } + ) + .toBe(true) + const hostBrowserTab = await page.evaluate( + async ({ environmentId, fixtureName, worktreeId }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + const tab = response.ok + ? response.result.tabs.find( + (candidate) => + candidate.type === 'browser' && candidate.url.endsWith(`/${fixtureName}`) + ) + : null + return tab?.type === 'browser' ? { browserPageId: tab.browserPageId, id: tab.id } : null + }, + { environmentId: client.environmentId, fixtureName: FIXTURE_NAME, worktreeId } + ) + if (!hostBrowserTab?.browserPageId) { + throw new Error('host HTML browser tab disappeared before activation') + } + const clientRemotePageId = await page.evaluate( + ({ fixtureName, worktreeId }) => { + const state = window.__store?.getState() + const workspace = (state?.browserTabsByWorktree[worktreeId] ?? []).find((tab) => + tab.url.endsWith(`/${fixtureName}`) + ) + const browserPage = workspace + ? (state?.browserPagesByWorkspace[workspace.id] ?? [])[0] + : null + return browserPage + ? (state?.remoteBrowserPageHandlesByPageId[browserPage.id]?.remotePageId ?? null) + : null + }, + { fixtureName: FIXTURE_NAME, worktreeId } + ) + expect(clientRemotePageId).toBe(hostBrowserTab.browserPageId) + const content = await page.evaluate( + async ({ browserPageId, environmentId, worktreeId }) => + window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'browser.eval', + params: { + worktree: `id:${worktreeId}`, + page: browserPageId, + expression: 'document.querySelector("h1")?.textContent' + }, + timeoutMs: 15_000 + }), + { + browserPageId: hostBrowserTab.browserPageId, + environmentId: client.environmentId, + worktreeId + } + ) + expect(content).toMatchObject({ ok: true, result: { result: 'paired html focus' } }) + await expect + .poll( + () => + page.evaluate( + ({ browserTabId, environmentId }) => { + const state = window.__store?.getState() + return (state?.browserPagesByWorkspace[browserTabId] ?? []).some((browserPage) => { + const handle = state?.remoteBrowserPageHandlesByPageId[browserPage.id] + return ( + handle?.environmentId === environmentId || + browserPage.browserRuntimeEnvironmentId === environmentId + ) + }) + }, + { browserTabId: tabIds.browserId, environmentId: client.environmentId } + ), + { timeout: 30_000, message: 'client lost remote browser ownership before activation' } + ) + .toBe(true) + await page.locator(`[data-tab-id="${tabIds.browserId}"]`).click() + + await expect + .poll( + () => + page.evaluate( + async ({ environmentId, fixtureName, worktreeId }) => { + const state = window.__store?.getState() + if (!state) { + return null + } + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + const activeGroup = (state.groupsByWorktree[worktreeId] ?? []).find( + (group) => group.id === state.activeGroupIdByWorktree[worktreeId] + ) + const activeUnified = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.id === activeGroup?.activeTabId + ) + const hostActive = response.ok + ? response.result.tabs.find((tab) => tab.id === response.result.activeTabId) + : null + return { + activeGroupType: activeUnified?.contentType ?? null, + activeTabType: state.activeTabTypeByWorktree[worktreeId] ?? null, + hostActiveHtml: + hostActive?.type === 'browser' && hostActive.url.endsWith(`/${fixtureName}`) + } + }, + { environmentId: client!.environmentId, fixtureName: FIXTURE_NAME, worktreeId } + ), + { timeout: 30_000, message: 'browser click did not remain authoritative' } + ) + .toEqual({ activeGroupType: 'browser', activeTabType: 'browser', hostActiveHtml: true }) + await expect(page.getByTestId('remote-browser-frame')).toBeVisible() + + const browserTab = page.locator(`[data-tab-id="${tabIds.browserId}"]`) + await browserTab.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Close', exact: true }).click() + await expect + .poll( + () => + page.evaluate( + async ({ + environmentId, + hostTabId, + sourceGroupId, + unifiedTabId, + workspaceId, + worktreeId + }) => { + const state = window.__store?.getState() + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + return { + clientUnifiedPresent: (state?.unifiedTabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.id === unifiedTabId + ), + clientWorkspacePresent: (state?.browserTabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.id === workspaceId + ), + hostTabPresent: response.ok + ? response.result.tabs.some((tab) => tab.id === hostTabId) + : true, + sourceGroupPresent: (state?.groupsByWorktree[worktreeId] ?? []).some( + (group) => group.id === sourceGroupId + ) + } + }, + { + environmentId: client!.environmentId, + hostTabId: hostBrowserTab.id, + sourceGroupId, + unifiedTabId: htmlTabInventory.clientUnifiedTabs[0]!.id, + workspaceId: htmlTabInventory.clientWorkspaceIds[0]!, + worktreeId + } + ), + { timeout: 30_000, message: 'remote HTML browser close did not converge cleanly' } + ) + .toEqual({ + clientUnifiedPresent: false, + clientWorkspacePresent: false, + hostTabPresent: false, + sourceGroupPresent: true + }) + await expect(browserTab).toHaveCount(0) + await expect(page.locator(`[data-tab-id="${tabIds.terminalId}"]`)).toBeVisible() + await expect(page.locator(`[data-tab-id="${sourceEditor.tabId}"]`)).toBeVisible() + await expect( + page.locator(`[data-tab-group-body-id="${sourceGroupId}"] .monaco-editor`) + ).toBeVisible() + } finally { + await client?.dispose() + } +}) diff --git a/tests/e2e/paired-remote-terminal-browser-link.spec.ts b/tests/e2e/paired-remote-terminal-browser-link.spec.ts new file mode 100644 index 00000000000..44d0d3484f1 --- /dev/null +++ b/tests/e2e/paired-remote-terminal-browser-link.spec.ts @@ -0,0 +1,368 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { + readPanelNavigationObserver, + startPanelNavigationObserver, + stopPanelNavigationObserver +} from './helpers/plugin-panel-navigation-observer' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +const PAGE_MARKER = 'remote terminal browser owner' +const TERMINAL_MARKER = 'remote-terminal-still-live-after-browser-close' + +async function readTabInventory( + page: Page, + environmentId: string, + worktreeId: string +): Promise<{ + clientBrowserWorkspaces: number + clientBrowserTabs: number + clientTerminalTabs: number + hostBrowserTabs: number + hostTerminalTabs: number +}> { + return page.evaluate( + async ({ environmentId, worktreeId }) => { + const state = window.__store?.getState() + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + return { + clientBrowserWorkspaces: (state?.browserTabsByWorktree[worktreeId] ?? []).length, + clientBrowserTabs: (state?.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'browser' + ).length, + clientTerminalTabs: (state?.tabsByWorktree[worktreeId] ?? []).length, + hostBrowserTabs: response.ok + ? response.result.tabs.filter((tab) => tab.type === 'browser').length + : -1, + hostTerminalTabs: response.ok + ? response.result.tabs.filter((tab) => tab.type === 'terminal').length + : -1 + } + }, + { environmentId, worktreeId } + ) +} + +async function startPageServer(): Promise<{ server: Server; url: string }> { + const server = createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + response.end(`

${PAGE_MARKER}

`) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() as AddressInfo + return { server, url: `http://127.0.0.1:${address.port}/remote-terminal` } +} + +async function closePageServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) +} + +async function findTerminalLinkTarget(page: Page, link: string): Promise<{ x: number; y: number }> { + return page.evaluate((targetLink) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = worktreeId ? state?.activeTabIdByWorktree?.[worktreeId] : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') ?? null + if (!pane || !screen) { + throw new Error('paired terminal screen unavailable') + } + const buffer = pane.terminal.buffer.active + for (let row = 0; row < pane.terminal.rows; row += 1) { + const text = buffer.getLine(buffer.viewportY + row)?.translateToString(false) ?? '' + const column = text.indexOf(targetLink) + if (column === -1) { + continue + } + const cell = pane.terminal.dimensions?.css.cell + if (!cell?.width || !cell.height) { + throw new Error('paired terminal cell dimensions unavailable') + } + const rect = screen.getBoundingClientRect() + return { + x: rect.left + (column + targetLink.length / 2) * cell.width, + y: rect.top + (row + 0.5) * cell.height + } + } + throw new Error('paired terminal link unavailable') + }, link) +} + +function remoteTerminalHandle(ptyId: string): string { + const separator = ptyId.indexOf('@@') + if (!ptyId.startsWith('remote:') || separator === -1) { + throw new Error(`Expected runtime-owned PTY id, received ${ptyId}`) + } + return decodeURIComponent(ptyId.slice(separator + 2)) +} + +test('opens a paired-runtime terminal link on its owning host', async ({ + orcaPage, + testRepoPath +}, testInfo) => { + test.setTimeout(240_000) + const fixture = await startPageServer() + let client: PairedElectronClient | null = null + let observerActive = false + try { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const hostPtyId = await waitForActivePanePtyId(orcaPage) + await execInTerminal(orcaPage, hostPtyId, `printf '%s\\n' ${JSON.stringify(fixture.url)}`) + await waitForTerminalOutput(orcaPage, fixture.url) + + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + client = await launchPairedElectronClient(offer, testInfo, 'Remote terminal browser link') + const page = client.page + const worktreeId = await expect + .poll( + () => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, testRepoPath), + { timeout: 60_000, message: 'paired client never received the host worktree' } + ) + .not.toBeNull() + .then(() => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, testRepoPath) + ) + if (!worktreeId) { + throw new Error('paired client worktree disappeared after discovery') + } + await page.evaluate( + async ({ environmentId, worktreeId }) => { + const state = window.__store?.getState() + state?.setActiveWorktree(worktreeId, `runtime:${environmentId}`) + await state?.updateSettings({ + openLinksInApp: true, + terminalLinkActionPopoverEnabled: true + }) + }, + { environmentId: client.environmentId, worktreeId } + ) + await ensureTerminalVisible(page, 30_000) + await waitForActiveTerminalManager(page, 30_000) + await waitForTerminalOutput(page, fixture.url, 30_000) + const clientPtyId = await waitForActivePanePtyId(page, 30_000) + expect(clientPtyId.startsWith(`remote:${client.environmentId}@@`)).toBe(true) + + const baseline = await readTabInventory(page, client.environmentId, worktreeId) + await startPanelNavigationObserver(client.app, page.url()) + observerActive = true + + const target = await findTerminalLinkTarget(page, fixture.url) + await page.mouse.move(target.x, target.y) + await expect(page.locator('.xterm-hover')).toHaveCount(1) + await page.mouse.click(target.x, target.y) + const actionPopover = page.locator('[data-terminal-link-action-popover]') + await expect(actionPopover).toBeVisible() + await expect(actionPopover.locator('[data-terminal-link-destination]')).toHaveText(fixture.url) + await expect( + actionPopover.getByRole('button').filter({ hasText: 'System Browser' }) + ).toBeVisible() + const orcaBrowserAction = actionPopover.getByRole('button').filter({ hasText: 'Orca Browser' }) + await expect(orcaBrowserAction).toBeVisible() + await orcaBrowserAction.click() + + const identity = await expect + .poll( + () => + page.evaluate( + async ({ environmentId, url, worktreeId }) => { + const state = window.__store?.getState() + const workspace = (state?.browserTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.url === url + ) + const browserPage = workspace + ? (state?.browserPagesByWorkspace[workspace.id] ?? [])[0] + : null + const handle = browserPage + ? state?.remoteBrowserPageHandlesByPageId[browserPage.id] + : null + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + const hostTab = response.ok + ? response.result.tabs.find((tab) => tab.type === 'browser' && tab.url === url) + : null + return workspace && browserPage && handle && hostTab?.type === 'browser' + ? { + clientPageId: browserPage.id, + clientRuntimeId: browserPage.browserRuntimeEnvironmentId, + clientWorkspaceId: workspace.id, + hostPageId: hostTab.browserPageId, + hostTabId: hostTab.id, + remoteEnvironmentId: handle.environmentId, + remotePageId: handle.remotePageId + } + : null + }, + { environmentId: client!.environmentId, url: fixture.url, worktreeId } + ), + { timeout: 60_000, message: 'terminal link never became one host-owned browser tab' } + ) + .not.toBeNull() + .then(() => + page.evaluate( + async ({ environmentId, url, worktreeId }) => { + const state = window.__store!.getState() + const workspace = state.browserTabsByWorktree[worktreeId]!.find( + (tab) => tab.url === url + )! + const browserPage = state.browserPagesByWorkspace[workspace.id]![0]! + const handle = state.remoteBrowserPageHandlesByPageId[browserPage.id]! + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + if (!response.ok) { + throw new Error('host tab inventory unavailable') + } + const hostTab = response.result.tabs.find( + (tab) => tab.type === 'browser' && tab.url === url + )! + return { + clientPageId: browserPage.id, + clientRuntimeId: browserPage.browserRuntimeEnvironmentId, + clientWorkspaceId: workspace.id, + hostPageId: hostTab.type === 'browser' ? hostTab.browserPageId : null, + hostTabId: hostTab.id, + remoteEnvironmentId: handle.environmentId, + remotePageId: handle.remotePageId + } + }, + { environmentId: client!.environmentId, url: fixture.url, worktreeId } + ) + ) + + expect(identity.clientRuntimeId).toBe(client.environmentId) + expect(identity.remoteEnvironmentId).toBe(client.environmentId) + expect(identity.remotePageId).toBe(identity.hostPageId) + const finalInventory = await readTabInventory(page, client.environmentId, worktreeId) + expect(finalInventory).toEqual({ + clientBrowserWorkspaces: baseline.clientBrowserWorkspaces + 1, + clientBrowserTabs: baseline.clientBrowserTabs + 1, + clientTerminalTabs: baseline.clientTerminalTabs, + hostBrowserTabs: baseline.hostBrowserTabs + 1, + hostTerminalTabs: baseline.hostTerminalTabs + }) + expect((await readPanelNavigationObserver(client.app)).externalUrls).toEqual([]) + + const content = await page.evaluate( + async ({ browserPageId, environmentId, worktreeId }) => + window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'browser.eval', + params: { + worktree: `id:${worktreeId}`, + page: browserPageId, + expression: 'document.querySelector("h1")?.textContent' + }, + timeoutMs: 15_000 + }), + { + browserPageId: identity.hostPageId!, + environmentId: client.environmentId, + worktreeId + } + ) + expect(content).toMatchObject({ ok: true, result: { result: PAGE_MARKER } }) + + const browserTab = page.locator(`[data-tab-id="${identity.clientWorkspaceId}"]`) + await browserTab.click() + await expect + .poll(() => + page.evaluate((worktreeId) => { + const state = window.__store?.getState() + return { + browserWorkspaceId: state?.activeBrowserTabIdByWorktree[worktreeId] ?? null, + tabType: state?.activeTabTypeByWorktree[worktreeId] ?? null + } + }, worktreeId) + ) + .toEqual({ browserWorkspaceId: identity.clientWorkspaceId, tabType: 'browser' }) + await expect( + page.locator(`[data-browser-overlay-tab-id="${identity.clientWorkspaceId}"]`) + ).toHaveCSS('opacity', '1') + + await browserTab.hover() + await browserTab.locator('button').click() + await expect + .poll(() => readTabInventory(page, client!.environmentId, worktreeId), { + timeout: 30_000, + message: 'host and client browser inventories did not return to baseline' + }) + .toEqual(baseline) + + await ensureTerminalVisible(page, 30_000) + await waitForActiveTerminalManager(page, 30_000) + expect(await waitForActivePanePtyId(page, 30_000)).toBe(clientPtyId) + const send = await page.evaluate( + async ({ environmentId, terminal, text }) => + window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'terminal.send', + params: { + terminal, + text, + enter: true, + client: { id: 'remote-browser-close-e2e', type: 'desktop' } + }, + timeoutMs: 15_000 + }), + { + environmentId: client.environmentId, + terminal: remoteTerminalHandle(clientPtyId), + text: `printf '%s\\n' ${JSON.stringify(TERMINAL_MARKER)}` + } + ) + expect(send).toMatchObject({ ok: true, result: { send: { accepted: true } } }) + await waitForTerminalOutput(page, TERMINAL_MARKER, 30_000) + expect(await readTabInventory(page, client.environmentId, worktreeId)).toEqual(baseline) + expect((await readPanelNavigationObserver(client.app)).externalUrls).toEqual([]) + } finally { + if (observerActive && client) { + await stopPanelNavigationObserver(client.app).catch(() => undefined) + } + await client?.dispose() + await closePageServer(fixture.server) + } +}) diff --git a/tests/e2e/paired-startup-exec-readiness.spec.ts b/tests/e2e/paired-startup-exec-readiness.spec.ts new file mode 100644 index 00000000000..cd96de038ce --- /dev/null +++ b/tests/e2e/paired-startup-exec-readiness.spec.ts @@ -0,0 +1,184 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { + createRuntimeDesktopPairingOffer, + launchPairedWebClient +} from './helpers/paired-electron-client' +import { + callStartupExecRuntime, + closeStartupExecTerminal, + createStartupExecTerminal, + expectStartupExecRecovery, + installZshExecProfile +} from './helpers/startup-exec-readiness-oracle' + +test.skip(process.platform === 'win32', 'Paired startup-exec readiness uses POSIX shells.') + +async function waitForWorktree(page: Page, id: string): Promise { + await expect + .poll( + () => + page.evaluate( + (worktreeId) => + window.__store + ?.getState() + .allWorktrees() + .some((candidate) => candidate.id === worktreeId), + id + ), + { timeout: 30_000 } + ) + .toBe(true) + await expect + .poll( + async () => { + const listed = await callStartupExecRuntime<{ worktrees: { id: string }[] }>( + page, + 'worktree.list', + {} + ) + return listed.worktrees.some((candidate) => candidate.id === id) + }, + { timeout: 30_000 } + ) + .toBe(true) +} + +function expectLedger(ledgerPath: string): void { + const [pid, tty] = readFileSync(ledgerPath, 'utf8').trim().split('|') + expect(Number(pid)).toBeGreaterThan(1) + expect(tty).toMatch(/^\/dev\//) +} + +async function releaseExecBarrier( + startedPath: string, + releasePath: string, + ledgerPath: string +): Promise { + await expect + .poll( + () => + existsSync(startedPath) + ? 'started' + : existsSync(ledgerPath) + ? readFileSync(ledgerPath, 'utf8').trim() + : 'pending', + { timeout: 30_000 } + ) + .toBe('started') + expect(existsSync(ledgerPath)).toBe(false) + writeFileSync(releasePath, '') +} + +function cleanupExecBarrier(startedPath: string, releasePath: string): void { + rmSync(startedPath, { force: true }) + rmSync(releasePath, { force: true }) +} + +test('recovers startup exec through a headed paired desktop owner @headful', async ({ + electronApp, + orcaPage +}) => { + test.setTimeout(90_000) + const runId = `headed_${Date.now()}` + const homePath = await electronApp.evaluate(({ app }) => app.getPath('home')) + const ledgerPath = path.join(homePath, `.sta4067-${runId}.ledger`) + const startedPath = path.join(homePath, `.sta4067-${runId}.started`) + const releasePath = path.join(homePath, `.sta4067-${runId}.release`) + const removeProfile = installZshExecProfile(homePath, runId, { releasePath, startedPath }) + const worktreeId = await orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId) + if (!worktreeId) { + throw new Error('Headed owner has no active worktree') + } + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedWebClient(electronApp, offer) + let terminal: string | null = null + try { + await waitForWorktree(client.page, worktreeId) + const created = await createStartupExecTerminal( + client.page, + worktreeId, + runId, + ledgerPath, + 'paired-client', + '/bin/zsh', + { ORCA_ORIG_ZDOTDIR: homePath, ORCA_ZSHENV_SOURCE_DIR: homePath } + ) + terminal = created.terminal + await releaseExecBarrier(startedPath, releasePath, ledgerPath) + await expectStartupExecRecovery(client.page, created, runId) + expectLedger(ledgerPath) + } finally { + await closeStartupExecTerminal(orcaPage, terminal) + await client.dispose() + removeProfile() + cleanupExecBarrier(startedPath, releasePath) + } +}) + +test('recovers the same startup exec through an isolated headless orca serve', async ({ + testRepoPath +}) => { + test.setTimeout(120_000) + const runId = `headless_${Date.now()}` + const host = await launchHeadlessPairedRuntimeHost() + let terminal: string | null = null + let removeProfile: (() => void) | undefined + let client: Awaited> | undefined + let startedPath = '' + let releasePath = '' + try { + const homePath = await host.app.evaluate(({ app }) => app.getPath('home')) + const ledgerPath = path.join(homePath, `.sta4067-${runId}.ledger`) + startedPath = path.join(homePath, `.sta4067-${runId}.started`) + releasePath = path.join(homePath, `.sta4067-${runId}.release`) + removeProfile = installZshExecProfile(homePath, runId, { releasePath, startedPath }) + client = await launchPairedWebClient(host.app, host.offer, { waitForWorkspace: false }) + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + await client.page.locator('[data-worktree-sidebar]').waitFor({ + state: 'visible', + timeout: 30_000 + }) + await expect + .poll( + () => client.page.evaluate(() => window.__store?.getState().allWorktrees()[0]?.id ?? null), + { timeout: 30_000 } + ) + .not.toBeNull() + const worktreeId = await client.page.evaluate( + () => window.__store?.getState().allWorktrees()[0]?.id ?? null + ) + if (!worktreeId) { + throw new Error('Headless owner did not publish its worktree') + } + const created = await createStartupExecTerminal( + client.page, + worktreeId, + runId, + ledgerPath, + 'paired-client', + '/bin/zsh', + { ORCA_ORIG_ZDOTDIR: homePath, ORCA_ZSHENV_SOURCE_DIR: homePath } + ) + terminal = created.terminal + await releaseExecBarrier(startedPath, releasePath, ledgerPath) + await expectStartupExecRecovery(client.page, created, runId) + expectLedger(ledgerPath) + } finally { + try { + if (client) { + await closeStartupExecTerminal(client.page, terminal) + await client.dispose() + } + removeProfile?.() + if (startedPath) { + cleanupExecBarrier(startedPath, releasePath) + } + } finally { + await host.dispose() + } + } +}) diff --git a/tests/e2e/paired-web-add-project-unavailable-host.spec.ts b/tests/e2e/paired-web-add-project-unavailable-host.spec.ts new file mode 100644 index 00000000000..771feffdde7 --- /dev/null +++ b/tests/e2e/paired-web-add-project-unavailable-host.spec.ts @@ -0,0 +1,158 @@ +import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './helpers/headless-paired-runtime-host' +import { + createRuntimeDesktopPairingOffer, + launchPairedWebClient, + type PairedWebClient, + type RuntimeDesktopPairingOffer +} from './helpers/paired-electron-client' +import { waitForSessionReady } from './helpers/store' + +test.skip( + process.env.ORCA_E2E_WEB_CLIENT !== '1', + 'Run with ORCA_E2E_WEB_CLIENT=1 so the paired web client is built' +) + +type HostHealth = 'blocked' | 'disconnected' + +async function setOnlyRuntimeHostHealth(page: Page, health: HostHealth): Promise { + return page.evaluate((nextHealth) => { + const store = window.__store + if (!store) { + throw new Error('Paired web client store unavailable') + } + const state = store.getState() + const environment = state.runtimeEnvironments[0] + if (!environment) { + throw new Error('Paired web client has no configured runtime') + } + const current = state.runtimeStatusByEnvironmentId.get(environment.id) + if (nextHealth === 'blocked' && !current?.status) { + throw new Error('Paired web runtime status unavailable for compatibility fault') + } + store.setState({ + runtimeStatusByEnvironmentId: new Map(state.runtimeStatusByEnvironmentId).set( + environment.id, + nextHealth === 'blocked' + ? { + ...current, + checkedAt: Date.now(), + status: { + ...current!.status!, + protocolVersion: 0, + runtimeProtocolVersion: 0 + } + } + : { ...current, checkedAt: Date.now(), status: null } + ) + }) + return environment.name + }, health) +} + +async function assertCreationActionsDisabled(args: { + health: HostHealth + hostName: string + page: Page + testInfo: TestInfo + topology: 'headed' | 'headless' +}): Promise { + await args.page + .getByRole('button', { name: /Add Project/i }) + .first() + .click() + const dialog = args.page.getByRole('dialog', { name: /Add a project/i }) + await expect(dialog).toBeVisible() + const hostPicker = dialog.getByRole('combobox') + await expect(hostPicker).toContainText(args.hostName) + await expect(hostPicker).toContainText( + args.health === 'blocked' ? 'Update needed' : 'Disconnected' + ) + await expect(dialog.getByRole('button', { name: /Browse folder|Browse host/i })).toBeDisabled() + await expect(dialog.getByRole('button', { name: /Clone from URL/i })).toBeDisabled() + await expect(dialog.getByRole('button', { name: /Create new project/i })).toBeDisabled() + + await hostPicker.click() + const hostOption = args.page + .locator('[cmdk-item][aria-disabled="true"]') + .filter({ hasText: args.hostName }) + await expect(hostOption).toHaveAttribute('aria-disabled', 'true') + await expect(hostOption).toContainText(args.health === 'blocked' ? 'Update Orca' : 'Disconnected') + await args.page.keyboard.press('Escape') + await args.page.screenshot({ + path: args.testInfo.outputPath(`${args.topology}-paired-web-add-project-${args.health}.png`), + fullPage: true + }) + await dialog.getByRole('button', { name: 'Close' }).click() + await expect(dialog).toBeHidden() +} + +async function runUnavailableHostJourney(args: { + app: ElectronApplication + offer: RuntimeDesktopPairingOffer + testInfo: TestInfo + topology: 'headed' | 'headless' +}): Promise { + let client: PairedWebClient | null = null + try { + client = await launchPairedWebClient(args.app, args.offer) + await args.app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows().forEach((window) => window.show()) + }) + const hostName = await setOnlyRuntimeHostHealth(client.page, 'blocked') + await assertCreationActionsDisabled({ + health: 'blocked', + hostName, + page: client.page, + testInfo: args.testInfo, + topology: args.topology + }) + expect(await setOnlyRuntimeHostHealth(client.page, 'disconnected')).toBe(hostName) + await assertCreationActionsDisabled({ + health: 'disconnected', + hostName, + page: client.page, + testInfo: args.testInfo, + topology: args.topology + }) + } finally { + await client?.dispose() + } +} + +test('disables paired-web Add Project for a blocked or unavailable headed host @headful', async ({ + electronApp, + orcaPage +}, testInfo) => { + test.setTimeout(240_000) + await waitForSessionReady(orcaPage) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + await runUnavailableHostJourney({ + app: electronApp, + offer, + testInfo, + topology: 'headed' + }) +}) + +test('keeps paired-web Add Project disabled for a headless unavailable host', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(240_000) + const host: HeadlessPairedRuntimeHost = await launchHeadlessPairedRuntimeHost() + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + await runUnavailableHostJourney({ + app: host.app, + offer: host.offer, + testInfo, + topology: 'headless' + }) + } finally { + await host.dispose() + } +}) diff --git a/tests/e2e/remote-agent-session-focus-authority.spec.ts b/tests/e2e/remote-agent-session-focus-authority.spec.ts index 2b007fddd98..4fb4c3e7ae8 100644 --- a/tests/e2e/remote-agent-session-focus-authority.spec.ts +++ b/tests/e2e/remote-agent-session-focus-authority.spec.ts @@ -140,7 +140,7 @@ async function readRenderedTabOrder(page: Page): Promise { function expectImmediatelyAfter(order: string[], predecessor: string, created: string): void { const predecessorIndex = order.indexOf(predecessor) - if (predecessorIndex < 0) { + if (predecessorIndex === -1) { throw new Error( `placement predecessor ${predecessor} missing before ${created}: ${JSON.stringify(order)}` ) diff --git a/tests/e2e/ssh-startup-exec-readiness.spec.ts b/tests/e2e/ssh-startup-exec-readiness.spec.ts new file mode 100644 index 00000000000..e77c9afd67f --- /dev/null +++ b/tests/e2e/ssh-startup-exec-readiness.spec.ts @@ -0,0 +1,111 @@ +import { expect, test } from './helpers/orca-app' +import { + connectDockerSshRelayTarget, + reconnectDockerSshRelayTarget +} from './helpers/docker-ssh-relay-connection' +import { + cleanupDockerSshRelayTarget, + execDockerSshRelayTargetControlCommand, + startDockerSshRelayTarget, + type DockerSshRelayTarget, + writeDockerSshRelayTargetFile +} from './helpers/docker-ssh-relay-target' +import { + bashExecProfileContents, + closeStartupExecTerminal, + createStartupExecTerminal, + expectStartupCommandQueuedByCompatibilityFallback, + expectStartupExecRecovery +} from './helpers/startup-exec-readiness-oracle' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActiveTerminalManager } from './helpers/terminal' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' + +test.describe('startup exec readiness over live SSH', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH E2E.') + test.skip(process.platform === 'win32', 'Docker SSH E2E uses POSIX ssh tooling.') + + test('survives an SSH reconnect while the replacement shell is not ready @headful', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(150_000) + const runId = `ssh_${Date.now()}` + const startedPath = `/tmp/sta4067-${runId}.started` + const releasePath = `/tmp/sta4067-${runId}.release` + const ledgerPath = `/tmp/sta4067-${runId}.ledger` + let target: DockerSshRelayTarget | null = null + let terminal: string | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerSshRelayTarget(orcaPage, target, { + relayGracePeriodSeconds: 15 + }) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + writeDockerSshRelayTargetFile( + target, + '/root/.bash_profile', + bashExecProfileContents(runId, { releasePath, startedPath }) + ) + const created = await createStartupExecTerminal( + orcaPage, + remote.worktreeId, + runId, + ledgerPath, + 'owning-client' + ) + terminal = created.terminal + await expect + .poll( + () => + execDockerSshRelayTargetControlCommand( + target!, + `if test -e '${startedPath}'; then echo ready; else echo pending; fi` + ), + { timeout: 30_000 } + ) + .toBe('ready') + await expectStartupCommandQueuedByCompatibilityFallback(orcaPage, created) + expect( + execDockerSshRelayTargetControlCommand( + target, + `if test ! -e '${ledgerPath}'; then echo pending; else cat '${ledgerPath}'; fi` + ) + ).toBe('pending') + + await reconnectDockerSshRelayTarget(orcaPage, remote.targetId) + execDockerSshRelayTargetControlCommand(target, `: > '${releasePath}'`) + await expect + .poll( + () => + execDockerSshRelayTargetControlCommand( + target!, + `if test -e '${ledgerPath}'; then cat '${ledgerPath}'; else echo pending; fi` + ), + { timeout: 8_000 } + ) + .toMatch(/^[0-9]+\|\/dev\/pts\/[0-9]+$/) + await expectStartupExecRecovery(orcaPage, created, runId) + + const [pidText, tty] = execDockerSshRelayTargetControlCommand( + target, + `cat '${ledgerPath}'` + ).split('|') + const pid = Number(pidText) + expect(pid).toBeGreaterThan(1) + expect(tty).toMatch(/^\/dev\/pts\/[0-9]+$/) + expect(execDockerSshRelayTargetControlCommand(target, `readlink '/proc/${pid}/exe'`)).toMatch( + /\/bash$/ + ) + expect( + execDockerSshRelayTargetControlCommand(target, `ps -o tpgid= -p '${pid}' | tr -d ' '`) + ).toBe(String(pid)) + } finally { + await closeStartupExecTerminal(orcaPage, terminal) + cleanupDockerSshRelayTarget(target) + } + }) +}) diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts index 96476cb0cd2..a78f4e81fa1 100644 --- a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -28,7 +28,7 @@ type HiddenTuiWindow = Window & { __terminalPtyOutputDebug?: { reset: () => void snapshot: () => { - hiddenRendererMode2031ReplyCount: number + hiddenRendererSkipCount: number } } } diff --git a/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts b/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts index 70912388cba..ee5ebe81079 100644 --- a/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts +++ b/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts @@ -56,7 +56,7 @@ async function locateHoverProbe(page: Page, needle: string): Promise continue } const idx = line.translateToString(true).indexOf(needle) - if (idx >= 0) { + if (idx !== -1) { hit = { row, col: idx } break } diff --git a/tests/e2e/terminal-paste-ownership.spec.ts b/tests/e2e/terminal-paste-ownership.spec.ts index 5df66cbce04..56c65a139aa 100644 --- a/tests/e2e/terminal-paste-ownership.spec.ts +++ b/tests/e2e/terminal-paste-ownership.spec.ts @@ -114,7 +114,7 @@ async function rightClickActiveTerminalSurface(page: Page): Promise { async function installClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise { await app.evaluate(({ BrowserWindow, ipcMain }) => { - type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise | unknown + type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => unknown const global = globalThis as unknown as { __orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler } @@ -152,7 +152,7 @@ async function installClipboardReadTerminalBlurRepro(app: ElectronApplication): async function restoreClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise { await app.evaluate(({ ipcMain }) => { - type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise | unknown + type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => unknown const global = globalThis as unknown as { __orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler } diff --git a/tests/e2e/terminal-tab-switch-visual-restore.spec.ts b/tests/e2e/terminal-tab-switch-visual-restore.spec.ts index 4f0d2a5df11..938dc852179 100644 --- a/tests/e2e/terminal-tab-switch-visual-restore.spec.ts +++ b/tests/e2e/terminal-tab-switch-visual-restore.spec.ts @@ -60,7 +60,6 @@ type SchedulerDebugWindow = Window & { type HiddenOutputDebugSnapshot = { hiddenRendererSkipCount: number hiddenRendererSkippedChars: number - hiddenRendererMode2031ReplyCount: number } type HiddenOutputRecoveryWindow = Window & { diff --git a/tests/e2e/worktree-card-downward-drag.spec.ts b/tests/e2e/worktree-card-downward-drag.spec.ts new file mode 100644 index 00000000000..b5cb685a4a6 --- /dev/null +++ b/tests/e2e/worktree-card-downward-drag.spec.ts @@ -0,0 +1,278 @@ +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +const SYNTHETIC_COUNT = 60 + +type PreviewOffsetSample = { + worktreeId: string + targetOffset: number + renderedOffset: number +} + +async function seedVirtualizedManualWorktrees(page: Page): Promise<{ + sourceId: string + nextId: string + idPrefix: string +}> { + const repo = await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + return { id: repo.id, path: repo.path } + }) + const now = Date.now() + const syntheticWorktrees = Array.from({ length: SYNTHETIC_COUNT }, (_, index) => { + const suffix = String(index).padStart(2, '0') + const worktreePath = path.join(repo.path, '..', `downward-drag-${suffix}`) + return { + id: `${repo.id}::downward-drag-${suffix}`, + instanceId: `downward-drag-${suffix}`, + repoId: repo.id, + path: worktreePath, + displayName: `Downward drag ${suffix}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 100_000 - index, + manualOrder: 100_000 - index, + lastActivityAt: now - index, + head: '0000000000000000000000000000000000000000', + branch: `downward-drag-${suffix}`, + isBare: false, + isMainWorktree: false + } + }) + await page.evaluate( + ({ repoId, worktrees }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const seededWorktrees = (state.worktreesByRepo[repoId] ?? []).map((worktree, index) => ({ + ...worktree, + manualOrder: -1_000 - index + })) + + store.setState({ + groupBy: 'none', + sortBy: 'manual', + showActiveOnly: false, + showSleepingWorkspaces: true, + hideDefaultBranchWorkspace: false, + filterRepoIds: [], + sidebarOpen: true, + worktreesByRepo: { + ...state.worktreesByRepo, + [repoId]: [...worktrees, ...seededWorktrees] + }, + updateWorktreesMeta: async (updatesByWorktreeId) => { + store.setState((current) => ({ + sortEpoch: current.sortEpoch + 1, + worktreesByRepo: Object.fromEntries( + Object.entries(current.worktreesByRepo).map(([repoId, worktrees]) => [ + repoId, + worktrees.map((worktree) => ({ + ...worktree, + ...updatesByWorktreeId.get(worktree.id) + })) + ]) + ) + })) + } + }) + }, + { repoId: repo.id, worktrees: syntheticWorktrees } + ) + const sourceId = syntheticWorktrees[0]!.id + return { + sourceId, + nextId: syntheticWorktrees[1]!.id, + idPrefix: sourceId.slice(0, -2) + } +} + +async function sampleMountedPreviewOffsets( + page: Page, + sourceId: string +): Promise { + return page.evaluate(async (draggedId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + if (!scroller) { + throw new Error('Worktree sidebar is not available') + } + const samples: PreviewOffsetSample[][] = [] + for (let frame = 0; frame < 20; frame++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + if (document.querySelector(`[data-worktree-drag-id=${JSON.stringify(draggedId)}]`)) { + continue + } + const frameSamples = [ + ...document.querySelectorAll('[data-worktree-virtual-row]') + ].flatMap((row) => { + const rowRect = row.getBoundingClientRect() + const scrollerRect = scroller.getBoundingClientRect() + if (rowRect.bottom <= scrollerRect.top || rowRect.top >= scrollerRect.bottom) { + return [] + } + const worktree = row.querySelector('[data-worktree-drag-id]') + const worktreeId = worktree?.getAttribute('data-worktree-drag-id') + const rowStart = Number(row.dataset.worktreeVirtualRowStart) + const matches = [...row.style.transform.matchAll(/translateY\((-?[\d.]+)px\)/g)] + const targetOffset = Number(matches[1]?.[1] ?? 0) + const renderedTop = new DOMMatrixReadOnly(getComputedStyle(row).transform).m42 + const renderedOffset = renderedTop - rowStart + return worktreeId && + Number.isFinite(rowStart) && + Number.isFinite(targetOffset) && + Number.isFinite(renderedOffset) + ? [{ worktreeId, targetOffset, renderedOffset }] + : [] + }) + if (frameSamples.length > 0) { + samples.push(frameSamples) + } + } + return samples + }, sourceId) +} + +test('dragging a virtualized worktree downward keeps rows stable', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await orcaPage.setViewportSize({ width: 1_000, height: 620 }) + const { sourceId, nextId, idPrefix } = await seedVirtualizedManualWorktrees(orcaPage) + const scroller = orcaPage.locator('[data-worktree-sidebar]') + const source = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(sourceId)}]` + ) + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect(source).toBeVisible() + + const sourceBox = await source.evaluate((element) => { + const rect = element.getBoundingClientRect() + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height } + }) + const nextSource = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(nextId)}]` + ) + const sourceStride = await nextSource.evaluate( + (element, sourceTop) => element.getBoundingClientRect().top - sourceTop, + sourceBox.y + ) + expect(Number.isFinite(sourceStride)).toBe(true) + expect(sourceStride).toBeGreaterThan(1) + const scrollerBox = await scroller.evaluate((element) => { + const rect = element.getBoundingClientRect() + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height } + }) + + await orcaPage.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await orcaPage.mouse.down() + try { + const edgeX = scrollerBox.x + 2 + const edgeY = scrollerBox.y + scrollerBox.height - 8 + // Keep the pointer in the edge zone while the renderer advances autoscroll. + for (let step = 0; step < 12; step++) { + await orcaPage.mouse.move(edgeX, edgeY, { steps: 2 }) + if ((await source.count()) === 0) { + break + } + await orcaPage.waitForTimeout(100) + } + if ((await source.count()) > 0) { + for (let step = 0; step < 8 && (await source.count()) > 0; step++) { + await scroller.evaluate((element) => { + element.scrollTop = Math.min( + element.scrollHeight, + element.scrollTop + element.clientHeight + ) + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await orcaPage.waitForTimeout(100) + } + } + await expect + .poll(() => source.count(), { + timeout: 10_000, + message: 'Downward autoscroll did not virtualize the dragged source row' + }) + .toBe(0) + + const samples = await sampleMountedPreviewOffsets(orcaPage, sourceId) + expect(samples.length).toBeGreaterThan(0) + const observationsById = new Map() + for (const sample of samples.flat()) { + const distanceFromValidTarget = Math.min( + Math.abs(sample.targetOffset), + Math.abs(sample.targetOffset + sourceStride) + ) + expect(distanceFromValidTarget).toBeLessThanOrEqual(1) + expect(sample.renderedOffset).toBeGreaterThanOrEqual(-sourceStride - 2) + expect(sample.renderedOffset).toBeLessThanOrEqual(2) + const observations = observationsById.get(sample.worktreeId) ?? [] + observations.push(sample) + observationsById.set(sample.worktreeId, observations) + } + + const recurrentRows = [...observationsById.values()].filter( + (observations) => observations.length > 1 + ) + expect(recurrentRows.length).toBeGreaterThan(0) + expect( + recurrentRows.some((observations) => + observations.some((sample) => sample.renderedOffset <= -sourceStride * 0.8) + ) + ).toBe(true) + for (const observations of recurrentRows) { + let renderedReversal = 0 + for (let index = 1; index < observations.length; index++) { + renderedReversal += Math.max( + 0, + observations[index]!.renderedOffset - observations[index - 1]!.renderedOffset + ) + } + expect(renderedReversal).toBeLessThanOrEqual(sourceStride) + } + } finally { + await orcaPage.mouse.up() + } + + await expect(orcaPage.locator('[data-worktree-sidebar-drag-preview="true"]')).toHaveCount(0) + await expect(orcaPage.locator('html')).not.toHaveAttribute( + 'data-worktree-sidebar-pointer-dragging' + ) + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect + .poll( + () => + scroller + .locator('[role="option"]') + .evaluateAll( + (options, prefix) => + options + .map((option) => option.getAttribute('data-worktree-id')) + .find((worktreeId) => worktreeId?.startsWith(prefix)) ?? null, + idPrefix + ), + { message: 'The downward drop did not move the source away from the first slot' } + ) + .toBe(nextId) +}) diff --git a/tests/tools/fish-mode2031-repro/capture-fish-mode2031-trace.mjs b/tests/tools/fish-mode2031-repro/capture-fish-mode2031-trace.mjs new file mode 100644 index 00000000000..1b2cbcae38c --- /dev/null +++ b/tests/tools/fish-mode2031-repro/capture-fish-mode2031-trace.mjs @@ -0,0 +1,398 @@ +#!/usr/bin/env node +/** + * Ground-truth repro harness for issue #9993: color-scheme (CSI ?997;1n) replies + * leaking into a child process' stdin under fish. + * + * Simulates Orca's terminal pipeline around a REAL fish shell: + * node-pty -> scanMode2031ReplyDecision() (the real scanner from + * src/shared/terminal-color-scheme-protocol.ts) + * -> reply `CSI ?997;1n` written back to the PTY, optionally after + * a delay that models the renderer IPC round-trip. + * + * Requires Node >= 22.18 (native TypeScript type stripping) so the real .ts + * scanner can be imported without a build step. + * + * Usage: + * node tests/tools/fish-mode2031-repro/capture-fish-mode2031-trace.mjs [options] + * + * --delay= reply delay; 0 = synchronous in-process (default 0) + * --shell= fish binary (default /opt/homebrew/bin/fish) + * --user-config use the caller's real fish config (Tide etc.) instead of + * an isolated minimal config + * --prompt-cmd add an external command substitution to fish_prompt, so + * the prompt itself forces extra tty handoffs + * --no-probes do NOT answer DA1/DA2/CPR/XTVERSION/OSC 10/11 (fish then + * stalls 10s on its DA1 wait) + * --accept-delay= gap between typing the command and pressing Enter (300) + * --paste send command + Enter in a single write (paste-like) + * --no-reply never answer 2031 (control run) + * --json append a machine-readable summary + */ + +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const REPO_ROOT = path.resolve(import.meta.dirname, '../../..') + +const pty = require(path.join(REPO_ROOT, 'node_modules/node-pty')) +const { INITIAL_MODE_2031_REPLY_SCAN_STATE, mode2031SequenceFor, scanMode2031ReplyDecision } = + await import(path.join(REPO_ROOT, 'src/shared/terminal-color-scheme-protocol.ts')) + +// ---------------------------------------------------------------- args + +const argv = process.argv.slice(2) +const flag = (name) => argv.includes(`--${name}`) +const opt = (name, fallback) => { + const hit = argv.find((a) => a.startsWith(`--${name}=`)) + return hit === undefined ? fallback : hit.slice(name.length + 3) +} + +const REPLY_DELAY_MS = Number(opt('delay', '0')) +const SHELL = opt('shell', '/opt/homebrew/bin/fish') +const USE_USER_CONFIG = flag('user-config') +const ANSWER_PROBES = !flag('no-probes') +const REPLY_ENABLED = !flag('no-reply') +const EMIT_JSON = flag('json') +const ACCEPT_DELAY_MS = Number(opt('accept-delay', '300')) +const PASTE = flag('paste') +const PROMPT_CMD = flag('prompt-cmd') +const TYPEAHEAD = flag('typeahead') +const TYPEAHEAD_AT_MS = Number(opt('typeahead-at', '150')) +const SETTLE_MS = Number(opt('settle', '400')) + +const PROMPT_MARK = 'HARNESS> ' +const CHILD_CMD = `python3 -c 'import sys; d=sys.stdin.readline(); print("GOT:", repr(d))'` + +// ---------------------------------------------------------------- tracing + +const t0 = process.hrtime.bigint() +const ms = () => Number(process.hrtime.bigint() - t0) / 1e6 +const stamp = () => ms().toFixed(3).padStart(10, ' ') + +const trace = [] +function log(dir, label, detail) { + const line = `[${stamp()}ms] ${dir.padEnd(12)} ${label}${detail ? ` ${detail}` : ''}` + trace.push(line) + console.log(line) +} + +function esc(s) { + let out = '' + for (const ch of s) { + const c = ch.codePointAt(0) + if (ch === '\x1b') { + out += '\\e' + } else if (ch === '\n') { + out += '\\n' + } else if (ch === '\r') { + out += '\\r' + } else if (ch === '\t') { + out += '\\t' + } else if (c < 0x20 || c === 0x7f) { + out += `\\x${c.toString(16).padStart(2, '0')}` + } else if (c === 0x9b) { + out += '\\x9b' + } else { + out += ch + } + } + return out +} + +const CHUNK_PRINT_CAP = 1200 +function escCapped(s) { + const e = esc(s) + return e.length > CHUNK_PRINT_CAP + ? `${e.slice(0, CHUNK_PRINT_CAP)}…<+${e.length - CHUNK_PRINT_CAP} esc-chars>` + : e +} + +// ---------------------------------------------------------------- fish config + +let configHome = null +const env = { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', LANG: 'en_US.UTF-8' } +delete env.FISH_HISTORY +if (!USE_USER_CONFIG) { + configHome = mkdtempSync(path.join(tmpdir(), 'fish-2031-')) + mkdirSync(path.join(configHome, 'fish'), { recursive: true }) + // Plain prompt: no Tide, no async prompt machinery. fish core still toggles + // DEC 2031 in tty_handoff.rs regardless of the prompt. + const promptBody = PROMPT_CMD + ? `set -l __x (/bin/echo -n ok); printf '${PROMPT_MARK}'` + : `printf '${PROMPT_MARK}'` + writeFileSync( + path.join(configHome, 'fish/config.fish'), + [ + 'set -g fish_greeting ""', + `function fish_prompt; ${promptBody}; end`, + 'function fish_right_prompt; end', + '' + ].join('\n') + ) + env.XDG_CONFIG_HOME = configHome + env.XDG_DATA_HOME = path.join(configHome, 'data') +} + +// ---------------------------------------------------------------- pty + +log( + 'HARNESS', + 'config', + JSON.stringify({ + shell: SHELL, + replyDelayMs: REPLY_DELAY_MS, + acceptDelayMs: ACCEPT_DELAY_MS, + paste: PASTE, + promptCmd: PROMPT_CMD, + replyEnabled: REPLY_ENABLED, + userConfig: USE_USER_CONFIG, + answerProbes: ANSWER_PROBES, + xdgConfigHome: configHome + }) +) + +const term = pty.spawn(SHELL, ['-l', '-i'], { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: REPO_ROOT, + env +}) + +let scanState = INITIAL_MODE_2031_REPLY_SCAN_STATE +let all = '' +let replyCount = 0 +const replyEvents = [] +const decisionEvents = [] +let phase = 'boot' +let chunkSeq = 0 +let lastDecision = null +let staleReplyCount = 0 + +function ptyWrite(bytes, label) { + log('HARNESS->PTY', label, `"${esc(bytes)}"`) + term.write(bytes) +} + +function sendMode2031Reply(reason) { + replyCount += 1 + // Stale = the scanner has already seen fish's `CSI ?2031l` (tty handed to a child) + // by the time this reply actually reaches the PTY. That reply lands in the child's stdin. + const stale = lastDecision === 'unsubscribed' + if (stale) { + staleReplyCount += 1 + } + replyEvents.push({ at: ms(), phase, reason, delayMs: REPLY_DELAY_MS, stale }) + ptyWrite( + mode2031SequenceFor('dark'), + `reply#${replyCount} CSI ?997;1n (${reason}, phase=${phase}${stale ? ', STALE: fish already sent ?2031l' : ''})` + ) +} + +// Modest emulation of the other terminal replies fish may probe for. +function answerProbes(data) { + if (!ANSWER_PROBES) { + return + } + // oxlint-disable no-control-regex -- terminal escape sequences require control chars + if (/\x1b\[(?:0)?c/.test(data)) { + ptyWrite('\x1b[?62;4;6;22c', 'probe reply DA1') + } + if (/\x1b\[>(?:0)?c/.test(data)) { + ptyWrite('\x1b[>1;95;0c', 'probe reply DA2') + } + if (/\x1b\[(?:6n)/.test(data)) { + ptyWrite('\x1b[1;1R', 'probe reply CPR') + } + if (/\x1b\[>q/.test(data)) { + ptyWrite('\x1bP>|orca-harness(1)\x1b\\', 'probe reply XTVERSION') + } + if (/\x1b\]10;\?/.test(data)) { + ptyWrite('\x1b]10;rgb:ffff/ffff/ffff\x1b\\', 'probe reply OSC 10') + } + if (/\x1b\]11;\?/.test(data)) { + ptyWrite('\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\', 'probe reply OSC 11') + } + // oxlint-enable no-control-regex +} + +term.onData((data) => { + const seq = ++chunkSeq + all += data + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + const mode2031ToggleRe = /\x1b\[\?([0-9;]+)([hl])/g + const toggles = [...data.matchAll(mode2031ToggleRe)] + .filter((m) => m[1].split(';').some((p) => Number(p) === 2031)) + .map((m) => `?2031${m[2]}`) + log( + 'PTY->SCANNER', + `chunk#${seq} len=${data.length}${toggles.length ? ` toggles=[${toggles.join(',')}]` : ''}`, + `"${escCapped(data)}"` + ) + + const before = scanState + const result = scanMode2031ReplyDecision(scanState, data) + scanState = result.state + if (result.decision || before.pendingSubscribe !== scanState.pendingSubscribe || scanState.tail) { + log( + 'SCANNER', + `chunk#${seq} decision=${result.decision ?? 'null'}`, + `state={tail:"${esc(scanState.tail)}",pendingSubscribe:${scanState.pendingSubscribe}}` + ) + } + if (result.decision) { + lastDecision = result.decision + decisionEvents.push({ at: ms(), seq, decision: result.decision, phase }) + } + + if (REPLY_ENABLED && result.decision === 'subscribed') { + if (REPLY_DELAY_MS <= 0) { + sendMode2031Reply(`chunk#${seq} sync`) + } else { + setTimeout(() => sendMode2031Reply(`chunk#${seq} +${REPLY_DELAY_MS}ms`), REPLY_DELAY_MS) + } + } + answerProbes(data) +}) + +let exitInfo = null +term.onExit((e) => { + exitInfo = e + log('PTY', 'exit', JSON.stringify(e)) +}) + +// ---------------------------------------------------------------- driving + +const sleep = (n) => new Promise((r) => setTimeout(r, n)) +async function waitFor(pred, timeoutMs, what) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (pred()) { + return true + } + await sleep(10) + } + log('HARNESS', 'TIMEOUT', `waiting for ${what}`) + return false +} + +const countPrompts = () => all.split(PROMPT_MARK).length - 1 + +try { + phase = 'boot' + await waitFor(() => all.includes(PROMPT_MARK), 15000, 'first prompt') + await sleep(SETTLE_MS) + + let promptsBeforeAccept + let repliesBeforeAccept + let decisionsBeforeAccept + let outLenBeforeAccept + + if (TYPEAHEAD) { + // Most realistic leak window: queue the command while an external command + // still owns the tty. fish redraws the prompt (`CSI ?2031h`) and consumes the + // buffered line in the same breath, so the handoff to the child happens + // sub-millisecond after the subscribe. + phase = 'pre-command' + log('HARNESS', 'MARK', '--- running `sleep 0.4` so the next line is typed ahead ---') + ptyWrite('sleep 0.4\r', 'pre-command') + await sleep(TYPEAHEAD_AT_MS) + + promptsBeforeAccept = countPrompts() + repliesBeforeAccept = replyCount + decisionsBeforeAccept = decisionEvents.length + outLenBeforeAccept = all.length + + phase = 'prompt-accept' + log('HARNESS', 'MARK', '--- type-ahead: command + Enter while sleep still owns the tty ---') + ptyWrite(`${CHILD_CMD}\r`, 'typeahead command + Enter') + } else if (PASTE) { + promptsBeforeAccept = countPrompts() + repliesBeforeAccept = replyCount + decisionsBeforeAccept = decisionEvents.length + outLenBeforeAccept = all.length + phase = 'prompt-accept' + log('HARNESS', 'MARK', '--- pasting command + Enter in one write (prompt-accept) ---') + ptyWrite(`${CHILD_CMD}\r`, 'paste command + Enter') + } else { + phase = 'type-command' + log('HARNESS', 'MARK', '--- typing child command (no Enter yet) ---') + ptyWrite(CHILD_CMD, 'type command') + await sleep(ACCEPT_DELAY_MS) + + promptsBeforeAccept = countPrompts() + repliesBeforeAccept = replyCount + decisionsBeforeAccept = decisionEvents.length + outLenBeforeAccept = all.length + + phase = 'prompt-accept' + log('HARNESS', 'MARK', '--- pressing Enter (prompt-accept -> tty handoff to child) ---') + ptyWrite('\r', 'Enter') + } + await sleep(1200) + + const repliesDuringAccept = replyCount - repliesBeforeAccept + const decisionsDuringAccept = decisionEvents.length - decisionsBeforeAccept + + phase = 'child-stdin' + log('HARNESS', 'MARK', '--- typing hello + Enter into the child ---') + ptyWrite('hello\r', 'child stdin') + await sleep(1500) + + phase = 'teardown' + // python's repr renders ESC as the 4 literal chars \x1b, so match on the printed form. + const gotLine = (all.slice(outLenBeforeAccept).match(/GOT: '[^\r\n]*/) ?? [])[0] ?? null + log('HARNESS', 'RESULT GOT-line', gotLine === null ? '' : `"${esc(gotLine)}"`) + const leaked = gotLine !== null && /\\x1b|\\033|\\u001b/.test(gotLine) + const leaked997 = gotLine !== null && gotLine.includes('997;1n') + log('HARNESS', 'RESULT leaked-escape-bytes-into-stdin', String(leaked)) + log('HARNESS', 'RESULT leaked-CSI-997-1n-into-stdin', String(leaked997)) + log( + 'HARNESS', + 'RESULT replies', + JSON.stringify({ + total: replyCount, + duringPromptAccept: repliesDuringAccept, + decisionsDuringPromptAccept: decisionsDuringAccept, + staleReplies: staleReplyCount, + promptsBeforeAccept + }) + ) + + ptyWrite('exit\r', 'exit') + await waitFor(() => exitInfo !== null, 3000, 'shell exit') + try { + term.kill() + } catch {} + + if (EMIT_JSON) { + console.log( + `\n__JSON__${JSON.stringify({ + delayMs: REPLY_DELAY_MS, + mode: TYPEAHEAD ? 'typeahead' : PASTE ? 'paste' : 'typed', + acceptDelayMs: ACCEPT_DELAY_MS, + promptCmd: PROMPT_CMD, + replyEnabled: REPLY_ENABLED, + userConfig: USE_USER_CONFIG, + answerProbes: ANSWER_PROBES, + gotLine, + leaked, + leaked997, + totalReplies: replyCount, + staleReplies: staleReplyCount, + repliesDuringPromptAccept: repliesDuringAccept, + decisions: decisionEvents, + replies: replyEvents + })}` + ) + } +} finally { + if (configHome) { + rmSync(configHome, { recursive: true, force: true }) + } +} + +process.exit(0) diff --git a/tests/tools/google-signin-ua-probe.cjs b/tests/tools/google-signin-ua-probe.cjs index 4fe50a2c122..7adf06bb7a0 100644 --- a/tests/tools/google-signin-ua-probe.cjs +++ b/tests/tools/google-signin-ua-probe.cjs @@ -9,13 +9,27 @@ const MODES = new Set([ 'electron-auth', 'electron-fixed', 'firefox-auth', - 'firefox-fixed' + 'firefox-fixed', + // Replicates the SHIPPED app exactly (setupClientHintsOverride + + // applyGoogleAuthUserAgent): Firefox UA is written to the WebContents on auth + // navs and to the request header only for auth-host URLs; every other request + // keeps whatever UA the WebContents carries. Logs incoming vs outgoing + // identity for ALL requests to expose cross-host mismatches during the flow. + 'app-current', + // Same, but with the STA-3811 fix: the header layer strips client hints on any + // request already carrying the Firefox auth UA, regardless of destination host. + 'app-fixed' ]) const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice('--mode='.length) if (!mode || !MODES.has(mode)) { throw new Error(`Expected --mode=${[...MODES].join('|')}`) } +const headless = process.argv.includes('--headless') +const exitAfterMs = Number( + process.argv.find((arg) => arg.startsWith('--exit-after-ms='))?.slice('--exit-after-ms='.length) +) + const profileRoot = mkdtempSync(join(tmpdir(), `orca-google-signin-${mode}-`)) const partition = `persist:google-signin-${mode}` app.setPath('userData', profileRoot) @@ -58,6 +72,30 @@ function safeUrl(rawUrl) { } } +function hostOf(rawUrl) { + try { + return new URL(rawUrl).hostname.toLowerCase() + } catch { + return '' + } +} + +// A Firefox UA paired with any sec-ch-ua header is the cross-host tell we hunt: +// real Firefox emits no client hints, so the two surfaces contradict each other. +function detectUaHintMismatch(headers) { + const ua = headers['user-agent'] || '' + const isFirefox = /Firefox\/\d/.test(ua) && !/Chrome\//.test(ua) + const hasHints = Object.keys(headers).some((key) => key.startsWith('sec-ch-ua')) + if (isFirefox && hasHints) { + return 'firefox-ua-with-chrome-hints' + } + const isChrome = /Chrome\/\d/.test(ua) + if (isChrome && !hasHints) { + return 'chrome-ua-without-hints' + } + return null +} + function setHeader(headers, name, value) { const existing = Object.keys(headers).find((key) => key.toLowerCase() === name.toLowerCase()) headers[existing ?? name] = value @@ -126,8 +164,47 @@ app.whenReady().then(async () => { identities.cleaned = cleanElectronUserAgent(identities.native) browserSession.setUserAgent(identityForUrl('about:blank', identities)) + const isAppMode = mode === 'app-current' || mode === 'app-fixed' + browserSession.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => { const headers = details.requestHeaders + const incoming = relevantHeaders(headers) + + if (isAppMode) { + // Mirror setupClientHintsOverride: only auth-host URLs get the Firefox UA + // header + hint strip; every other request keeps its incoming UA (which is + // the WebContents UA — Firefox while the auth document is on screen). + if (isGoogleAuthUrl(details.url)) { + setHeader(headers, 'user-agent', identities.firefox) + removeClientHints(headers) + } else { + const currentUa = incoming['user-agent'] + // STA-3811 fix: a request already carrying the Firefox auth UA came from + // the auth document; real Firefox sends no client hints, so strip them + // instead of rewriting to Chrome — keeping UA and hints one story. + if (mode === 'app-fixed' && currentUa === identities.firefox) { + removeClientHints(headers) + } else { + // Real setupClientHintsOverride builds Chrome hints once from the + // session's cleaned UA (a closure), never from the per-request UA. + applyChromeClientHints(headers, identities.cleaned) + } + } + const outgoing = relevantHeaders(headers) + const uaMismatch = detectUaHintMismatch(outgoing) + log('request', { + url: safeUrl(details.url), + host: hostOf(details.url), + resourceType: details.resourceType, + isAuthHost: isGoogleAuthUrl(details.url), + incoming, + outgoing, + uaHintMismatch: uaMismatch + }) + callback({ requestHeaders: headers }) + return + } + const identity = identityForUrl(details.url, identities) setHeader(headers, 'user-agent', identity) if (identity === identities.firefox) { @@ -144,8 +221,15 @@ app.whenReady().then(async () => { callback({ requestHeaders: headers }) }) + if (Number.isFinite(exitAfterMs) && exitAfterMs > 0) { + setTimeout(() => { + log('exit', { reason: 'timeout', exitAfterMs }) + app.quit() + }, exitAfterMs) + } + const window = new BrowserWindow({ - show: true, + show: !headless, title: `Google sign-in UA probe: ${mode}`, width: 980, height: 840, @@ -161,6 +245,24 @@ app.whenReady().then(async () => { if (!isMainFrame) { return } + if (isAppMode) { + // Mirror applyGoogleAuthUserAgent: Firefox UA on auth navs, restore the + // cleaned session UA otherwise. This WebContents UA is what leaks onto + // cross-host subresource requests while the auth document is on screen. + const current = window.webContents.getUserAgent() + if (isGoogleAuthUrl(url)) { + if (current !== identities.firefox) { + window.webContents.setUserAgent(identities.firefox) + } + } else if (current === identities.firefox) { + window.webContents.setUserAgent(identities.cleaned) + } + log('main-frame-navigation', { + url: safeUrl(url), + appliedUserAgent: window.webContents.getUserAgent() + }) + return + } const identity = identityForUrl(url, identities) if (window.webContents.getUserAgent() !== identity) { window.webContents.setUserAgent(identity) diff --git a/tests/tools/win-crash-survival-e2e/cli-args.mjs b/tests/tools/win-crash-survival-e2e/cli-args.mjs index 2a289d7893e..e3533342e4d 100644 --- a/tests/tools/win-crash-survival-e2e/cli-args.mjs +++ b/tests/tools/win-crash-survival-e2e/cli-args.mjs @@ -119,7 +119,7 @@ function validateArgShape(argv) { function takeValue(argv, flag) { const idx = argv.indexOf(flag) - if (idx < 0) { + if (idx === -1) { return undefined } const value = argv[idx + 1] diff --git a/tests/tools/win-update-e2e/cli-args.mjs b/tests/tools/win-update-e2e/cli-args.mjs index 510bac5a899..d6310e8d159 100644 --- a/tests/tools/win-update-e2e/cli-args.mjs +++ b/tests/tools/win-update-e2e/cli-args.mjs @@ -190,7 +190,7 @@ function validate(opts, installDirFlagPresent) { function takeValue(argv, flag) { const idx = argv.indexOf(flag) - if (idx < 0) { + if (idx === -1) { return undefined } const value = argv[idx + 1] diff --git a/tests/tools/win-update-e2e/daemon-processes.mjs b/tests/tools/win-update-e2e/daemon-processes.mjs index f7d43cc9b44..d292c0f24b8 100644 --- a/tests/tools/win-update-e2e/daemon-processes.mjs +++ b/tests/tools/win-update-e2e/daemon-processes.mjs @@ -148,7 +148,7 @@ function normalizeArray(raw) { function parseUserDataArg(argv) { const idx = argv.indexOf('--user-data') - return idx >= 0 && argv[idx + 1] ? argv[idx + 1] : defaultUserDataDir() + return idx !== -1 && argv[idx + 1] ? argv[idx + 1] : defaultUserDataDir() } function runStandalone(argv) { @@ -161,7 +161,7 @@ function runStandalone(argv) { console.log(JSON.stringify(pidFiles, null, 2)) const scopeIdx = argv.indexOf('--scope') - const scope = scopeIdx >= 0 && argv[scopeIdx + 1] ? argv[scopeIdx + 1] : '' + const scope = scopeIdx !== -1 && argv[scopeIdx + 1] ? argv[scopeIdx + 1] : '' const processes = findDaemonProcesses(scope) console.log( `[daemon-processes] live daemon processes${scope ? ` scoped to "${scope}"` : ''} (${processes.length}):`