mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
Merge origin/main into workspace cleanup dialog
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -1,29 +1,52 @@
|
||||
## Summary
|
||||
## ELI5
|
||||
|
||||
Describe the user-visible change.
|
||||
<!-- Simple high-level explanation -->
|
||||
|
||||
## 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`.
|
||||
<!-- Describe the change clearly and keep scope tight. -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- What problem does this solve, and why is this approach right? -->
|
||||
|
||||
## Linked Issue
|
||||
|
||||
<!-- Link the issue this PR addresses, there should ALWAYS be one -->
|
||||
|
||||
Fixes #
|
||||
|
||||
## Visual Proof
|
||||
<!-- REQUIRED for UI / behavior changes. Please attach a BEFORE and AFTER that can easily tabbed/switched. Use videos for when appropriate over screenshots -->
|
||||
<!-- If there is truly no visual or interaction change, write exactly: `N/A` and briefly say why. -->
|
||||
<!-- For attachments NEVER add directly to the PR files (do not commit to files), use `gh image` extension or drag + drop (works for any attachment) -->
|
||||
|
||||
## 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
|
||||
<!-- How did you verify this? Steps a reviewer can follow. Which platforms did you actually test (macOS / Linux / Windows / SSH)? -->
|
||||
|
||||
## 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
|
||||
<!-- DO NOT FILL IN IF YOU ARE STABLYAI TEAM MEMBER (INTERNAL CONTRIBUTOR), IGNORE SECTION: -->
|
||||
<!-- Which AI model if anyone was used, please state the details -->
|
||||
|
||||
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
|
||||
<!-- Optional but appreciated -->
|
||||
|
||||
+22
-13
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:-<fish not installed>}"
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ?? '<unset>'}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
+1067
-42
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ function readRuntimeArg() {
|
||||
}
|
||||
|
||||
const runtimeIndex = process.argv.indexOf('--runtime')
|
||||
if (runtimeIndex >= 0) {
|
||||
if (runtimeIndex !== -1) {
|
||||
return process.argv[runtimeIndex + 1]
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
})
|
||||
|
||||
@@ -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()')
|
||||
) {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.':
|
||||
|
||||
@@ -164,7 +164,6 @@ export const SEARCH_KEYWORD_OVERRIDES = {
|
||||
gitlab: 'GitLab',
|
||||
gitea: 'Gitea',
|
||||
oauth: 'OAuth',
|
||||
attribution: '归因',
|
||||
skill: '技能',
|
||||
skills: '技能',
|
||||
phone: '手机',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 更改?',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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`')
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME
|
||||
|
||||
@@ -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 <n> Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms <n> Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms <n> Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees <n> 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 <path> Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners <n> Measurement-only: add visible working spinners\n --synthetic-spinner-animation <smooth|steps> Spinner animation style (default smooth)\n --synthetic-spinner-steps <n> 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 <n> Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms <n> Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms <n> Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees <n> Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --lineage-depth <n> Nest all worktrees under one expanded lineage, up to this depth\n --agents-per-worktree <n> Seed this many visible inline agent rows per worktree\n --zustand-publications <n> Publish exactly this many store updates during sampling\n --zustand-publication-interval-ms <n> 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 <path> Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners <n> Measurement-only: add visible working spinners\n --synthetic-spinner-animation <smooth|steps> Spinner animation style (default smooth)\n --synthetic-spinner-steps <n> 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
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, '...')
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 18m">
|
||||
<title>downloads: 18m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 19m">
|
||||
<title>downloads: 19m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">18m</text>
|
||||
<text x="90" y="14">18m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">19m</text>
|
||||
<text x="90" y="14">19m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -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)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -179,12 +179,14 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Devin logo" width="16" valign="middle" /> Devin</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
@@ -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에 참여하세요.
|
||||
|
||||
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 커뮤니티 WeChat 그룹 7 QR 코드" width="160" />
|
||||
|
||||
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](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
|
||||
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca 기여자" />
|
||||
</a>
|
||||
|
||||
<p align="center">
|
||||
<img src="../assets/star-history.png" alt="stablyai/orca GitHub Star 히스토리 차트" width="880" />
|
||||
</p>
|
||||
|
||||
## 서명된 빌드
|
||||
|
||||
Windows 코드 서명은 [SignPath.io](https://signpath.io)가 후원·제공하며, 인증서는 [SignPath Foundation](https://signpath.org)에서 제공합니다.
|
||||
|
||||
## 라이선스
|
||||
|
||||
Orca는 [MIT 라이선스](../../LICENSE)에 따라 자유롭게 사용할 수 있는 오픈 소스입니다.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
|
||||
| existence of `repoPath` | main checkout deleted |
|
||||
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
|
||||
| `<commonDir>/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/<branch>` 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/<name>/{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 <worktree>` | ≤ 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 `<commonDir>/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
|
||||
@@ -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[]
|
||||
|
||||
@@ -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<Map<string, () => void>>(new Map())
|
||||
const subscribingHandlesRef = useRef<Set<string>>(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<Set<string>>(new Set())
|
||||
const initializedHandlesRef = useRef<Set<string>>(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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -191,7 +191,7 @@ function closingMarkupTagNames(value: string): Set<string> {
|
||||
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
|
||||
|
||||
@@ -39,6 +39,21 @@ describe('MobileFilePreviewScreen', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats native-chat artifact grants as read-only', () => {
|
||||
expect(
|
||||
isEditableMobileTerminalArtifactPreview(
|
||||
{
|
||||
status: 'ready',
|
||||
kind: 'text',
|
||||
content: '<h1>Result</h1>',
|
||||
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',
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: '<h1>Result</h1>', 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
|
||||
},
|
||||
'<h1>Changed</h1>'
|
||||
)
|
||||
).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')])
|
||||
|
||||
|
||||
@@ -116,6 +116,9 @@ export async function saveMobileTerminalArtifactPreview(
|
||||
content: string,
|
||||
options: TerminalArtifactSaveOptions = {}
|
||||
): Promise<MobileFilePreviewResult | { status: 'saved' }> {
|
||||
if (source.readOnly) {
|
||||
return previewError('This file is read-only')
|
||||
}
|
||||
let writeSource = source
|
||||
if (typeof options.baseContent === 'string') {
|
||||
const verified = await verifyTerminalArtifactBaseContent(
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,7 +37,7 @@ export function useActiveWorktreeScroll<T extends WithId, S>(
|
||||
(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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<NodeJS.Platform>([
|
||||
'aix',
|
||||
@@ -169,12 +174,13 @@ export async function resumeAiVaultSessionInTerminal(
|
||||
worktreeId: string,
|
||||
launch: MobileAiVaultResumeLaunch & { clientMutationId?: string }
|
||||
): Promise<MobileReviewTerminalTab> {
|
||||
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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<T extends FileTapSessionTab> = {
|
||||
terminalHandle?: string | null
|
||||
pathText: string
|
||||
cwd?: string | null
|
||||
nativeChatContext?: RuntimeNativeChatFileContext | null
|
||||
line: number | null
|
||||
column: number | null
|
||||
pushPreviewRoute: (href: ReturnType<typeof createMobileFilePreviewHref>) => void
|
||||
@@ -80,7 +82,8 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
|
||||
...(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<T extends FileTapSessionTab>(
|
||||
...(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) } : {}),
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<RpcClient, 'sendRequest'>
|
||||
): Promise<MobileTerminalAttributionAuthority> {
|
||||
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 })
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void> {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -10,6 +10,7 @@ type MobileFileTapHandlerOptions<T extends FileTapSessionTab> = {
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
worktreeName?: string
|
||||
nativeChatSessionId: string | null
|
||||
activeHandleRef: MutableRefObject<string | null>
|
||||
terminalCwdRef: MutableRefObject<Map<string, string>>
|
||||
openBrowser: (url: string) => void
|
||||
@@ -48,6 +49,7 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
|
||||
getActiveSessionTabType,
|
||||
getSessionTabs,
|
||||
hostId,
|
||||
nativeChatSessionId,
|
||||
openBrowser,
|
||||
scheduleDelayedAction,
|
||||
reportChatTapFailure,
|
||||
@@ -71,6 +73,7 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
|
||||
getActiveSessionTabType,
|
||||
getSessionTabs,
|
||||
hostId,
|
||||
nativeChatSessionId,
|
||||
openBrowser,
|
||||
scheduleDelayedAction,
|
||||
reportChatTapFailure,
|
||||
@@ -87,6 +90,7 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
|
||||
getActiveSessionTabType,
|
||||
getSessionTabs,
|
||||
hostId,
|
||||
nativeChatSessionId,
|
||||
openBrowser,
|
||||
router,
|
||||
scheduleDelayedAction,
|
||||
@@ -144,12 +148,18 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
|
||||
return
|
||||
}
|
||||
const activationSeq = ++activationSeqRef.current
|
||||
const nativeChatSessionId = current.nativeChatSessionId
|
||||
const nativeChatTabId = current.getActiveSessionTabId()
|
||||
openMobileNativeChatFileTap<T>({
|
||||
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,
|
||||
|
||||
@@ -8,10 +8,25 @@ describe('useMobileNativeChatTerminalStream', () => {
|
||||
let harnessRenderCount = 0
|
||||
const subscriptionsRef = { current: new Map<string, () => void>() }
|
||||
const subscribingRef = { current: new Set<string>() }
|
||||
const leaseOnlyRef = { current: new Set<string>() }
|
||||
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<string>): 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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ export function useMobileNativeChatTerminalStream(args: {
|
||||
streamRevision: number
|
||||
subscriptionsRef: MutableRefObject<Map<string, () => void>>
|
||||
subscribingRef: MutableRefObject<Set<string>>
|
||||
/** Handles whose current subscribe went out as an input lease only. */
|
||||
leaseOnlyRef: MutableRefObject<Set<string>>
|
||||
webReadyRef: MutableRefObject<Set<string>>
|
||||
initializedRef: MutableRefObject<Set<string>>
|
||||
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<string>) => {
|
||||
// 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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
await mutateStoredHosts((hosts) => {
|
||||
const index = hosts.findIndex((h) => h.id === hostId)
|
||||
if (index < 0) {
|
||||
if (index === -1) {
|
||||
return hosts
|
||||
}
|
||||
const next = hosts.slice()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -18,6 +18,7 @@ export type RpcRequest = {
|
||||
deviceToken: string
|
||||
method: string
|
||||
params?: unknown
|
||||
expectedRuntimeId?: string
|
||||
}
|
||||
|
||||
export type RpcSuccess = {
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
Generated
+81
-81
@@ -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: {}
|
||||
|
||||
@@ -195,7 +195,7 @@ orca orchestration worker-start --task <task_b> --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 <task_id> --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json
|
||||
orca orchestration worker-start --task <task_id> --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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user