Merge origin/main into workspace cleanup dialog

This commit is contained in:
Brennan Benson
2026-08-13 01:19:26 -07:00
1111 changed files with 60172 additions and 10101 deletions
+5 -7
View File
@@ -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
+40 -17
View File
@@ -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
View File
@@ -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
+13 -4
View File
@@ -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()
+56 -4
View File
@@ -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
+22 -10
View File
@@ -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
+6
View File
@@ -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/
+10
View File
@@ -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",
+1 -1
View File
@@ -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>'}`)
}
-1
View File
@@ -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": [
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', () => {
+1 -1
View File
@@ -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"
},
+1 -3
View File
@@ -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' },
+1 -3
View File
@@ -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()
+27 -1
View File
@@ -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
+152 -262
View File
@@ -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)
}
+2 -2
View File
@@ -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 }
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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))
+4 -4
View File
@@ -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

+18 -3
View File
@@ -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> &nbsp;
<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> &nbsp;
<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> &nbsp;
<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> &nbsp;
<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> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<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> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<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> &nbsp;
<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> &nbsp;
<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> &nbsp;
<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> &nbsp;
<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> &nbsp;
<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> &nbsp;
@@ -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.
+274
View File
@@ -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[]
+55 -17
View File
@@ -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)
}
+3
View File
@@ -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',
+2 -1
View File
@@ -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 &&
+1 -1
View File
@@ -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
})
})
})
+18 -9
View File
@@ -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', () => {
+13 -3
View File
@@ -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
}
+10 -1
View File
@@ -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 })
}
+41 -13
View File
@@ -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)
})
})
+21 -6
View File
@@ -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,
+10 -14
View File
@@ -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 }
+4 -4
View File
@@ -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()
+10 -1
View File
@@ -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'))
+1 -1
View File
@@ -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 &&
+1
View File
@@ -18,6 +18,7 @@ export type RpcRequest = {
deviceToken: string
method: string
params?: unknown
expectedRuntimeId?: string
}
export type RpcSuccess = {
+3 -1
View File
@@ -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",
+81 -81
View File
@@ -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: {}
+1 -1
View File
@@ -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