Merge origin/main into mobile host picker

This commit is contained in:
Brennan Benson
2026-08-05 22:49:03 -07:00
3048 changed files with 265093 additions and 55650 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
/src/renderer/src/i18n/locales/ @brennanb2025
/config/scripts/*localization*.mjs @brennanb2025
/config/scripts/*locale*.mjs @brennanb2025
/i18next.config.ts @brennanb2025
/config/i18next.config.ts @brennanb2025
+2
View File
@@ -10,6 +10,8 @@ body:
value: |
Use this form for Orca bugs. Include enough detail for someone else to reproduce the issue.
Please write your issue in English.
- type: dropdown
id: os
attributes:
@@ -10,6 +10,8 @@ body:
value: |
Use this form for new features or workflow improvements.
Please write your issue in English.
- type: textarea
id: problem
attributes:
+5
View File
@@ -2,6 +2,11 @@ name: Other
description: Report another Orca issue type
title: '[Other]: '
body:
- type: markdown
attributes:
value: |
Please write your issue in English.
- type: textarea
id: details
attributes:
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 2 ]]; then
echo "Usage: $0 <base-sha> <head-sha>" >&2
exit 2
fi
base_sha=$1
head_sha=$2
git rev-parse --verify "${base_sha}^{tree}" >/dev/null
git rev-parse --verify "${head_sha}^{tree}" >/dev/null
declare -A base_entries=()
while IFS= read -r -d '' entry; do
base_entries["$entry"]=1
done < <(git ls-tree -z --name-only "$base_sha")
blocked_entries=()
while IFS= read -r -d '' entry; do
if [[ -z "${base_entries[$entry]+present}" ]]; then
blocked_entries+=("$entry")
fi
done < <(git ls-tree -z --name-only "$head_sha")
if (( ${#blocked_entries[@]} == 0 )); then
echo "Root directory guard passed: no new root-level files or folders."
exit 0
fi
echo "::error title=Root-level additions blocked::New root-level files or folders bloat the GitHub landing page."
echo "Root directory guard failed."
echo "New root-level files or folders are not allowed because they bloat the GitHub landing page."
echo "Move each new entry under an existing top-level directory."
printf 'Blocked entries:\n'
printf ' %s\n' "${blocked_entries[@]}"
exit 1
+418
View File
@@ -0,0 +1,418 @@
name: Adhoc macOS Dev Build
# Why: lets anyone cut a signed macOS build of an unlanded branch so the team can
# actually run an experimental feature for a few days, instead of reasoning about
# it from a diff. Hourly covers main; this covers everything that is not main yet.
#
# Deliberately narrow scope, same trade as hourly:
# - macOS only. Other platforms keep using RC/stable.
# - No tests, no lint, no e2e. PR CI and release-cut remain the gates.
# - Signed AND notarized, exactly like a release. macOS anchors a notarized
# app's TCC grants on identifier + team rather than on its cdhash, so those
# grants survive an update; an unnotarized build reads as a new client and
# silently loses file access under Documents/Desktop/Downloads.
#
# Artifacts publish to stablyai/orca-adhoc — separate from both orca and
# orca-hourly. Separate from orca because the main repo's releases atom feed
# exposes only its 10 newest entries. Separate from orca-hourly because a build
# from someone's branch must never be picked up by a developer who only meant to
# ride main; the two are different levels of "unvetted".
#
# From the Actions tab: pick this workflow, "Run workflow", leave "Use workflow
# from" on main, and type your branch in the first field. Or from the CLI:
#
# gh workflow run adhoc-mac-build.yml --ref main -f ref=my-branch -f label=wasm-terminal
#
# Leaving the ref field empty builds whatever "Use workflow from" is set to, which
# is what someone who only touched that picker means. Naming the branch explicitly
# is still better: the workflow file is always read from the dispatch ref, so a
# branch carrying a stale copy of this file would otherwise run that copy.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the
# same GitHub App as hourly, additionally installed on orca-adhoc with
# Contents: Read and write. The secret names below are historical — one App, one
# private key, both dev-channel repos — and rotating it stays a single operation.
# Provision with `bash config/scripts/setup-hourly-release-token.sh`.
#
# The requested ref is vetted before checkout: it must be a branch or tag of this
# repo, or a commit reachable from one. PR refs are refused outright — this
# workflow runs the checked-out code next to MAC_CERTS and the notary password,
# so "just build that community PR" must not become a way to hand fork code the
# release identity. A branch here always belongs to someone with write access,
# which is the same trust the dispatch button itself already requires.
on:
workflow_dispatch:
inputs:
ref:
# Why optional: the Actions UI already shows its own "Use workflow from"
# branch picker directly above this field, and picking a branch there is
# what most people will read as "build this". Defaulting to that branch
# makes the obvious action correct. Fill this in only to build a ref other
# than the one the workflow file itself is read from — normally leave the
# picker on main and name your branch here, so a stale copy of this
# workflow on an old branch is not what runs.
description: 'Branch, tag, or SHA to build — must live in stablyai/orca; PR refs are refused (default: the branch selected above)'
required: false
default: ''
type: string
label:
description: 'Short name shown in the release title (default: the ref)'
required: false
default: ''
type: string
permissions:
contents: read
concurrency:
# Why keyed on the ref rather than global: two people cutting builds from two
# different branches at the same time is the ordinary case here, and serialising
# them would make each wait out the other's notary queue. Re-dispatching the
# *same* branch still queues, so a push mid-build cannot race itself.
group: adhoc-mac-build-${{ inputs.ref || github.ref_name }}
cancel-in-progress: false
env:
ADHOC_REPO: stablyai/orca-adhoc
# Why age and not a count like hourly: this channel is low-volume and bursty, so
# a count would either hold one week's experiments forever or evict a build
# someone is still running after a busy afternoon. A month is well past the "few
# days" these exist for, and by then the branch has landed or been abandoned.
ADHOC_RETAIN_DAYS: 30
jobs:
build-adhoc-mac:
if: github.repository == 'stablyai/orca'
# Why an environment: it gives the signing/notary/App secrets somewhere to
# live that a stale copy of this workflow on an old branch cannot reach.
# Referencing it is a no-op until repo settings give it teeth; the intended
# follow-up is to move MAC_CERTS, MAC_CERTS_PASSWORD, APPLE_ID,
# APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID, HOURLY_RELEASE_APP_ID, and
# HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch
# policy to main so only main's copy of this file can read them.
environment: adhoc-mac-build
runs-on: blacksmith-6vcpu-macos-15
# Why 150: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
# the job is killed mid-retry and no cleanup step runs at all.
timeout-minutes: 150
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
# Why vet before checkout: everything after this step runs the checked-out
# code with release signing credentials in reach. Branches and tags of this
# repo are the intended audience; refs/pull/* would smuggle in fork code,
# and a raw SHA is only accepted when some branch or tag of this repo can
# actually reach it. Resolving to a pinned SHA here also means the commit
# that was vetted is the commit that gets checked out — a push to the
# branch between the two steps cannot swap it.
- name: Vet the requested ref
id: vetted
shell: bash
env:
REQUESTED_REF: ${{ inputs.ref || github.ref_name }}
REPO_URL: https://github.com/${{ github.repository }}
run: |
set -euo pipefail
case "$REQUESTED_REF" in
refs/pull/*|pull/*)
echo "::error::Refusing to build PR ref '$REQUESTED_REF': this workflow signs with release credentials, so it only builds branches, tags, or commits of stablyai/orca. Push the code to a branch of this repo instead."
exit 1
;;
esac
# Bare: a work-tree repo refuses to fetch over its own checked-out
# branch. tree:0 keeps the fetch to the commit graph — no trees, no
# blobs — so this stays cheap next to the build it fronts.
scratch="$RUNNER_TEMP/vet-requested-ref"
git init -q --bare "$scratch"
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
# Branch first to keep actions/checkout's old tie-break: bare
# rev-parse would prefer the tag when a branch shares its name.
sha=""
for cand in "refs/heads/$REQUESTED_REF" "refs/tags/$REQUESTED_REF" "$REQUESTED_REF"; do
if sha="$(git -C "$scratch" rev-parse --verify --quiet "$cand^{commit}")"; then
break
fi
sha=""
done
if [[ -z "$sha" ]]; then
echo "::error::'$REQUESTED_REF' does not resolve to a branch, tag, or commit of stablyai/orca."
exit 1
fi
# The object resolving locally is not proof a branch or tag reaches
# it: a partial clone can lazily fetch a bare SHA on demand, and
# GitHub serves PR-only commits by SHA. Reachability is the actual
# trust test.
if [[ -z "$(git -C "$scratch" for-each-ref --contains "$sha" refs/heads refs/tags | head -1)" ]]; then
echo "::error::Commit $REQUESTED_REF is not reachable from any branch or tag of stablyai/orca; refusing to build it with release credentials."
exit 1
fi
echo "Vetted $REQUESTED_REF -> $sha"
echo "sha=$sha" >>"$GITHUB_OUTPUT"
- name: Checkout the requested ref
uses: actions/checkout@v6
with:
# Why an input at all rather than just github.ref: the whole point is to
# build code that has not landed, and the workflow definition itself
# always comes from the dispatch ref — naming the branch here instead
# applies main's current copy of this file to an arbitrary branch.
ref: ${{ steps.vetted.outputs.sha }}
fetch-depth: 0
# This job only reads stablyai/orca and never pushes; every write goes
# to the adhoc repo through a minted App token passed by env. Not
# persisting the checkout credential shrinks the blast radius if a build
# step is compromised (zizmor: artipacked).
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-mac-
- name: Install dependencies
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: signing is what makes an adhoc build installable over an existing
# Orca, so a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
run: node config/scripts/verify-macos-release-env.mjs
env:
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Compute adhoc version
id: adhoc
shell: bash
env:
REF: ${{ inputs.ref || github.ref_name }}
LABEL: ${{ inputs.label }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Why this check: the version scripts are read from the branch being built,
# not from main, so a branch cut before the adhoc channel landed has no
# copy of them. Say that plainly instead of failing with a module-not-found.
for script in adhoc-build-version dev-channel-base-version; do
if [[ ! -f "config/scripts/$script.mjs" ]]; then
echo "::error::$REF has no config/scripts/$script.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
done
echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
# Why the main repo's tags: package.json on a branch is as stale as the
# main it forked from, and stable patches never merge back into it.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \
node config/scripts/adhoc-build-version.mjs \
>"$RUNNER_TEMP/adhoc-identity.txt"
if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then
echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow."
exit 1
fi
cat "$RUNNER_TEMP/adhoc-identity.txt" >>"$GITHUB_OUTPUT"
- name: Build app
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: adhoc builds are not an official channel — telemetry's transport
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
# silent, which is correct for unvetted branch artifacts.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
# Why the token is minted here and not at the top: installation tokens live
# one hour, everything before this point writes nothing, and the notary round
# trip inside the publish step can be tens of minutes. Minting after the build
# starts the clock at the first call that actually uses it.
- name: Mint adhoc repo token
id: app_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-adhoc
- name: Create adhoc release
id: release
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: v${{ steps.adhoc.outputs.version }}
NAME: ${{ steps.adhoc.outputs.name }}
SHA: ${{ steps.adhoc.outputs.head_sha }}
REF: ${{ inputs.ref || github.ref_name }}
# Via env, not inline `${{ }}`: both land inside a shell string, and an
# expression expanded there is substituted before bash parses the line
# (zizmor: template-injection).
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
short_sha="${SHA:0:12}"
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
#
# Why --draft: everything between here and the manifest check is a window
# where the release exists but has no installable assets. A draft is
# absent from the releases list and from listReleaseBuilds, so a job that
# dies in that window — including a hard kill by the job timeout, which
# runs no cleanup step at all — leaves something invisible rather than a
# tag the picker offers and the download 404s on.
gh release create "$TAG" \
--repo "$ADHOC_REPO" \
--title "$NAME" \
--draft \
--notes "Adhoc macOS dev build of \`$REF\` at commit \`$short_sha\`.
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA), cut by @$ACTOR.
**Unlanded and unvetted.** This is somebody's branch, not main. No tests
ran. Signed and notarized like a release, so it installs through Orca's
in-app updater and opens without a Gatekeeper prompt — but the branch may
never merge, and this build is deleted after $ADHOC_RETAIN_DAYS days."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish adhoc macOS artifacts
uses: nick-fields/retry@v4
with:
# Why 45: an attempt is pack + notarize + upload, and the notary queue is
# the unbounded part. Two attempts, because a failed adhoc build has a
# person waiting on it who can simply dispatch again.
timeout_minutes: 45
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_ADHOC=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
env:
# Why: electron-builder's github publisher targets the repo named in the
# config; the token must therefore carry write access to orca-adhoc.
GH_TOKEN: ${{ steps.app_token.outputs.token }}
ORCA_ADHOC_BUILD_VERSION: ${{ steps.adhoc.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.adhoc.outputs.commit }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# Why all three: electron-builder's notarize step authenticates to the
# Apple notary service with the app-specific password, not with the
# signing cert. Omitting them fails the build rather than skipping it.
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
# release missing that manifest is a tag the picker offers and the download
# 404s on, so fail loudly instead of leaving a broken entry.
- name: Verify update manifest published
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "$ADHOC_REPO" --json assets --jq '.assets[].name')"
echo "Published assets:"
echo "$assets"
# Why exit 1 without deleting here: the release is still a draft, so it is
# already invisible to users, and the failure handler below owns cleanup.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Adhoc draft $TAG is missing $required; the updater could not install it."
exit 1
fi
done
if ! grep -q '\.zip$' <<<"$assets"; then
echo "::error::Adhoc draft $TAG has no ZIP artifact for the updater to download."
exit 1
fi
# Why this is the last mutating step: publishing the draft is what makes the
# build visible to listReleaseBuilds. Doing it only after the manifest check
# means the picker can never offer a release whose assets are incomplete.
- name: Publish the verified release
id: publish_live
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
NAME: ${{ steps.adhoc.outputs.name }}
run: |
set -euo pipefail
# --title again: electron-builder resolves this draft by tag and may
# rewrite its title on upload. Re-asserting here means the name the
# picker reads is the one composed above, whatever it did in between.
gh release edit "$TAG" --repo "$ADHOC_REPO" --draft=false --prerelease --title "$NAME"
echo "Published $TAG as \"$NAME\""
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name. Gated on publish_live not having succeeded so a later
# failure (the prune step) cannot delete a release that already went live and
# that people may already be installing. Why cancelled() too: a run stopped
# from the Actions UI is not a failure(), so without it a manual cancel
# mid-publish would strand the draft.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
steps.publish_live.outcome != 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -uo pipefail
# No --cleanup-tag: an unpublished draft never created a git tag.
echo "Run failed before publish; discarding draft $TAG"
gh release delete "$TAG" --repo "$ADHOC_REPO" --yes ||
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune expired adhoc releases
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
# Why compute the cutoff in bash rather than with jq's `now`: this runs
# once per dispatch, and a fixed epoch makes the threshold visible in the
# log when someone asks where their build went.
cutoff=$(( $(date -u +%s) - ADHOC_RETAIN_DAYS * 86400 ))
echo "Pruning adhoc releases created before $(date -u -r "$cutoff" '+%Y-%m-%dT%H:%M:%SZ')"
# --cleanup-tag so pruning does not leave orphan tags with no release or
# assets attached. Drafts are excluded: a stale draft is the failure
# path's business, not the retention window's.
stale="$(gh release list --repo "$ADHOC_REPO" --limit 200 --json tagName,createdAt,isDraft \
--jq "map(select(.isDraft | not)) | map(select((.createdAt | fromdateiso8601) < $cutoff)) | .[].tagName")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune."
exit 0
fi
while read -r tag; do
[[ -n "$tag" ]] || continue
echo "Pruning $tag"
gh release delete "$tag" --repo "$ADHOC_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"
+42 -10
View File
@@ -9,18 +9,25 @@ on:
- 'config/scripts/build-windows-cli-launcher.mjs'
- 'config/scripts/build-windows-cli-launcher.test.mjs'
- 'config/scripts/computer-e2e-workflow.test.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-processes.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'
- 'config/scripts/computer-use-modifier-safety.test.mjs'
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
- 'config/scripts/computer-use-smoke.test.mjs'
- 'config/scripts/daemon-boot-smoke.mjs'
- 'config/scripts/daemon-endpoint-handover-smoke.mjs'
- 'config/scripts/windows-daemon-workspace-close-repro.mjs'
- 'config/scripts/verify-computer-native.mjs'
# Why: the native-smoke job boots the built terminal daemon under plain
# Node, so any change to the daemon bundle graph or the main build must
# re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI).
- 'electron.vite.config.ts'
- 'build-plugins/**'
- 'config/build-plugins/**'
- 'src/main/daemon/**'
- 'native/computer-use-macos/**'
- 'native/computer-use-linux/**'
@@ -59,6 +66,8 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
@@ -85,6 +94,8 @@ jobs:
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
config/scripts/macos-computer-helper-owner-loss-processes.test.mjs
config/scripts/computer-use-modifier-safety.test.mjs
config/scripts/computer-use-skill-guidance.test.mjs
config/scripts/computer-use-smoke.test.mjs
@@ -125,20 +136,41 @@ jobs:
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
- name: Daemon boot smoke
run: node config/scripts/daemon-boot-smoke.mjs
# Why: a daemon that lost its endpoint name used to delete the replacement's
# socket on exit, stranding a live daemon that hosts PTYs nothing can reach.
# Only two real processes racing the same endpoint reproduce it.
- name: Daemon endpoint handover smoke
run: node config/scripts/daemon-endpoint-handover-smoke.mjs
# Why: workspace removal overlaps graceful renderer teardown with the
# forced main-process sweep; exercise that exact pair against real ConPTY.
- name: Windows daemon workspace-close repro
if: runner.os == 'Windows'
run: node config/scripts/windows-daemon-workspace-close-repro.mjs
- if: runner.os == 'Linux'
env:
ORCA_COMPUTER_E2E: '1'
ACCESSIBILITY_ENABLED: '1'
run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
- if: runner.os == 'Windows'
env:
ORCA_COMPUTER_E2E: '1'
run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts
mac-native-owner-smoke:
if: github.event_name == 'pull_request'
runs-on: macos-15
permissions:
contents: read
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
- name: Owner-loss benchmark process cleanup
run: >-
pnpm vitest run
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
config/scripts/macos-computer-helper-owner-loss-processes.test.mjs
- name: Authenticated helper owner-loss smoke
run: pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1
- name: Swift tests and signed universal helper verification
run: pnpm verify:computer-native
mac:
# macOS Accessibility and Screen Recording require user-granted TCC entries.
@@ -13,7 +13,7 @@ on:
branches:
- Jinwoo-H/windows-update-survival
paths:
- 'tools/daemon-relocation-spike/**'
- 'tests/tools/daemon-relocation-spike/**'
- '.github/workflows/daemon-relocation-spike.yml'
workflow_dispatch: {}
@@ -76,7 +76,7 @@ jobs:
# --keep-work-dir so the per-tier daemon stdout/stderr logs survive
# for the artifact upload (the spike otherwise removes work-dir),
# which is what diagnoses a tier that fails to reach ready.
node tools/daemon-relocation-spike/spike.mjs `
node tests/tools/daemon-relocation-spike/spike.mjs `
--app-dir dist/win-unpacked `
--work-dir "$work" `
--tier $tier `
+70 -4
View File
@@ -13,6 +13,10 @@ on:
description: Ref to check out (defaults to the calling workflow's ref)
required: false
type: string
test_files:
description: JSON array of changed specs; empty runs the full suite
required: false
type: string
workflow_dispatch:
inputs:
ref:
@@ -64,11 +68,14 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: building once avoids five parallel electron-vite builds inside
# Playwright globalSetup, which otherwise contends for CPU/RAM on OSS
# runners before the sharded tests even start.
# Why: building here avoids parallel builds inside Playwright globalSetup;
# paired-browser specs also need the standalone web bundle.
- name: Build Electron app for E2E
run: npx electron-vite build --mode e2e
env:
VITE_EXPOSE_STORE: 'true'
run: |
npx electron-vite build --mode e2e
pnpm run build:web-from-renderer
- name: Upload E2E build output
uses: actions/upload-artifact@v7
@@ -81,6 +88,7 @@ jobs:
e2e:
name: e2e ${{ matrix.shard_name }}
needs: build
if: inputs.test_files == ''
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
@@ -180,9 +188,67 @@ jobs:
retention-days: 7
if-no-files-found: ignore
changed-e2e:
name: changed e2e specs
needs: build
if: inputs.test_files != ''
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
with:
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
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Use external node-gyp to avoid pnpm's bundled copy
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download E2E build output
uses: actions/download-artifact@v8
with:
name: e2e-build-out
path: out/
- name: Run changed E2E specs
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 \
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1
- name: Upload Playwright traces
if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-traces-changed
path: test-results/
retention-days: 7
if-no-files-found: ignore
ssh-docker-watcher-isolation:
name: ssh docker watcher isolation
needs: build
if: inputs.test_files == ''
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.
+1 -15
View File
@@ -1,18 +1,6 @@
name: Golden E2E Experiment
on:
pull_request:
paths:
- '.github/workflows/golden-e2e-experiment.yml'
- 'package.json'
- 'tests/e2e/golden-core-flows.spec.ts'
- 'tests/e2e/fixtures/terminal-emoji-table.md'
- 'tests/e2e/helpers/**'
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
- 'tests/e2e/terminal-webgl-atlas-budget.spec.ts'
- 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
- 'src/renderer/src/lib/pane-manager/**'
workflow_dispatch:
inputs:
ref:
@@ -44,9 +32,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
# Why: pull_request merge refs disappear after merge, but experiment
# reruns still need a stable commit to checkout.
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }}
ref: ${{ inputs.ref || github.ref }}
- name: Install native build tools
if: runner.os == 'Linux'
+404
View File
@@ -0,0 +1,404 @@
name: Hourly macOS Dev Build
# Why: gives developers a signed macOS build of main every hour that the in-app
# updater can install directly, without waiting for an RC cut.
#
# Deliberately narrow scope:
# - macOS only. Other platforms keep using RC/stable.
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
# and release-cut remain the gates that matter.
# - Signed AND notarized, exactly like a release. The notary round trip is the
# one slow step kept: macOS anchors a notarized app's TCC grants on identifier
# + team rather than on its cdhash, so those grants survive an update. Without
# a ticket every hourly reads as a new client and silently loses file access
# under Documents/Desktop/Downloads — 24 times a day.
#
# Artifacts publish to stablyai/orca-hourly, never to stablyai/orca: the main
# repo's releases atom feed exposes only its 10 newest entries, so 24 hourly
# tags a day would evict every stable/RC entry and break updates for real users.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use a
# GitHub App installed on orca-hourly with Contents: Read and write. Its private
# key does not expire, unlike a PAT — nothing here needs yearly rotation, and the
# credential belongs to the org rather than to whoever created it.
#
# Provision the two secrets with `bash config/scripts/setup-hourly-release-token.sh`:
# HOURLY_RELEASE_APP_ID the App's numeric id
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
#
# Installation tokens live one hour, which is why this mints twice. Install and
# build need no token at all, and notarization can hold the publish step for tens
# of minutes; minting again once the build is done starts the clock at the first
# call that actually uses it rather than burning a third of it on `pnpm install`.
# A pathological retry can still outrun the second token, but the release is an
# unpublished draft until the manifest check passes, so the damage is a stranded
# invisible draft — and the build-number query counts drafts, so it holds its
# number and the next run does not reuse it.
on:
schedule:
# Top of every hour. Skipped automatically when main has not moved.
- cron: '0 * * * *'
workflow_dispatch:
inputs:
force:
description: Build even if main has not moved since the last hourly
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: hourly-mac-build
cancel-in-progress: false
env:
HOURLY_REPO: stablyai/orca-hourly
# Keep ~3 days of history so a regression can be bisected across a weekend.
HOURLY_RETAIN_COUNT: 72
jobs:
build-hourly-mac:
if: github.repository == 'stablyai/orca'
runs-on: blacksmith-6vcpu-macos-15
# Why 150: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
# the job is killed mid-retry and no cleanup step runs at all. A typical run
# is far shorter — this is the notary queue's tail, not its median.
timeout-minutes: 150
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the hourly repo through a minted App token passed by env.
# Not persisting the checkout credential shrinks the blast radius if a
# build step is compromised (zizmor: artipacked).
persist-credentials: false
- name: Mint hourly repo token
id: app_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-hourly
# Why: main is often idle overnight. Rebuilding an unchanged commit burns a
# runner hour and adds a redundant tag to the retention window.
- name: Check whether main moved since the last hourly
id: freshness
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
run: |
set -euo pipefail
head_sha="$(git rev-parse HEAD)"
echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT"
if [[ "$FORCED" == "true" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "Forced dispatch; building $head_sha."
exit 0
fi
# The previous hourly records its source commit in the release body.
# Drafts are excluded: an unpublished leftover never shipped, so treating
# it as "the last build" would skip a build that never actually happened.
last_body="$(gh release list --repo "$HOURLY_REPO" --limit 20 --json tagName,isDraft \
--jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)"
if [[ -z "$last_body" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "No prior hourly release found; building $head_sha."
exit 0
fi
last_sha="$(gh release view "$last_body" --repo "$HOURLY_REPO" --json body \
--jq '.body | capture("commit `(?<sha>[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)"
if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then
echo "should_build=false" >>"$GITHUB_OUTPUT"
echo "main is unchanged since $last_body ($last_sha); skipping."
else
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "main moved to $head_sha (last hourly built $last_sha); building."
fi
- name: Setup pnpm
if: steps.freshness.outputs.should_build == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
if: steps.freshness.outputs.should_build == 'true'
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
if: steps.freshness.outputs.should_build == 'true'
uses: actions/cache@v5
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-mac-
- name: Install dependencies
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: signing is what makes an hourly installable over an existing Orca, so
# a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
if: steps.freshness.outputs.should_build == 'true'
run: node config/scripts/verify-macos-release-env.mjs
env:
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Compute hourly version
id: hourly
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Existing titles, which carry the build number this series continues
# from. The script picks the number, because it restarts per base version
# and only the script knows which base this build resolved to.
#
# Why drafts count here but not in the freshness check: that check asks
# "did this commit ship", where a draft is a no. This one asks "is the
# number free", where a stranded draft still holds one.
names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \
--jq '.[].name // empty')"
# Why the main repo's tags decide the base version rather than
# package.json: main's version only moves on `release:` commits, and
# stable patches are cut from release branches that never merge back, so
# package.json can sit several patches behind what users are running. A
# separate token because GH_TOKEN above is the App's, scoped to the
# hourly repo. Empty on failure — the script then falls back to
# package.json, which is stale but never wrong enough to fail a build.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
echo "Highest published tag seen: $(head -1 <<<"$published")"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \
node config/scripts/hourly-build-version.mjs \
>"$RUNNER_TEMP/hourly-identity.txt"
grep -E '^(version|build_number)=' "$RUNNER_TEMP/hourly-identity.txt"
# Why check rather than trust: the checkout above pins `ref: main`, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
# branch that edits this step while main still has the old script yields
# an empty name and an untitled release — silent, and only visible once
# someone opens the releases page. Fail here instead.
if ! grep -q '^name=' "$RUNNER_TEMP/hourly-identity.txt"; then
echo "::error::hourly-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync."
exit 1
fi
cat "$RUNNER_TEMP/hourly-identity.txt" >>"$GITHUB_OUTPUT"
- name: Build app
if: steps.freshness.outputs.should_build == 'true'
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: hourly builds are not an official channel — telemetry's transport
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
# silent, which is correct for unvetted dev artifacts.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
# Why a second mint: everything from here on writes to the hourly repo, and
# the notary round trip inside the publish step can be tens of minutes. The
# token minted at the top has already spent install + build of its one hour
# on steps that never touched it; restarting the clock here gives the slow
# part the full budget.
- name: Re-mint hourly repo token for publish
id: app_token_publish
if: steps.freshness.outputs.should_build == 'true'
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-hourly
- name: Create hourly release
id: release
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: v${{ steps.hourly.outputs.version }}
NAME: ${{ steps.hourly.outputs.name }}
SHA: ${{ steps.freshness.outputs.head_sha }}
run: |
set -euo pipefail
# Kept at 12 even though the title shows 7: the freshness check above
# parses this back out of the body to decide whether main has moved.
short_sha="${SHA:0:12}"
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
#
# Why --draft: everything between here and the manifest check is a window
# where the release exists but has no installable assets. A draft is
# absent from the releases list and from listReleaseBuilds, so a job that
# dies in that window — including a hard kill by the job timeout, which
# runs no cleanup step at all — leaves something invisible rather than a
# tag the picker offers and the download 404s on. It is flipped live only
# after the manifest is verified.
gh release create "$TAG" \
--repo "$HOURLY_REPO" \
--title "$NAME" \
--draft \
--notes "Automated hourly macOS dev build from commit \`$short_sha\`.
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA).
**Unvetted.** No tests ran. Signed and notarized like a release, so it
installs through Orca's in-app updater and opens from a manual download
without a Gatekeeper prompt — but nothing here has been reviewed."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish hourly macOS artifacts
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
# Why 45 like the release pipeline: an attempt is pack + notarize +
# upload, and the notary queue is the unbounded part. Why 2 attempts and
# not 3: a missed hourly costs an hour, and the next cron picks the same
# commit up, so a third attempt buys less than it costs in runner time.
timeout_minutes: 45
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_HOURLY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
env:
# Why: electron-builder's github publisher targets the repo named in the
# config; the token must therefore carry write access to orca-hourly.
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.hourly.outputs.commit }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# Why all three: electron-builder's notarize step authenticates to the
# Apple notary service with the app-specific password, not with the
# signing cert. Omitting them fails the build rather than skipping it,
# since `notarize` is now on for this path.
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
# release missing that manifest is a tag the picker offers and the download
# 404s on, so fail loudly instead of leaving a broken entry.
- name: Verify update manifest published
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "$HOURLY_REPO" --json assets --jq '.assets[].name')"
echo "Published assets:"
echo "$assets"
# Why exit 1 without deleting here: the release is still a draft, so it is
# already invisible to users, and the failure handler below owns cleanup.
# Deleting inline under `set -e` would also let the delete's exit code
# preempt this explicit failure.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Hourly draft $TAG is missing $required; the updater could not install it."
exit 1
fi
done
if ! grep -q '\.zip$' <<<"$assets"; then
echo "::error::Hourly draft $TAG has no ZIP artifact for the updater to download."
exit 1
fi
# Why this is the last mutating step: publishing the draft is what makes the
# build visible to listReleaseBuilds. Doing it only after the manifest check
# means the picker can never offer a release whose assets are incomplete.
- name: Publish the verified release
id: publish_live
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
NAME: ${{ steps.hourly.outputs.name }}
run: |
set -euo pipefail
# --title again: electron-builder resolves this draft by tag and may
# rewrite its title on upload. Re-asserting here means the name the
# picker reads is the one composed above, whatever it did in between.
gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease --title "$NAME"
echo "Published $TAG as \"$NAME\""
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name, so the next run for the same minute would collide.
#
# Why it is gated on publish_live not having succeeded: a later failure (the
# prune step) must not delete a release that already went live and that users
# may already be installing. A job killed by the outer timeout runs no steps
# at all — which is exactly why the release stays a draft until verified.
# Why cancelled() too: a run stopped from the Actions UI is not a failure(),
# so without it a manual cancel mid-publish would strand the draft.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
steps.publish_live.outcome != 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -uo pipefail
# No --cleanup-tag: an unpublished draft never created a git tag.
echo "Run failed before publish; discarding draft $TAG"
gh release delete "$TAG" --repo "$HOURLY_REPO" --yes ||
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune old hourly releases
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
run: |
set -euo pipefail
# Why: --cleanup-tag so pruning does not leave orphan tags behind that
# keep showing up in tag lists with no release or assets attached.
# Drafts are excluded so retention counts shipped builds only; a stale
# draft is handled by the failure path, not by the retention window.
stale="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName,createdAt,isDraft \
--jq "map(select(.isDraft | not)) | sort_by(.createdAt) | reverse | .[${HOURLY_RETAIN_COUNT}:] | .[].tagName")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune; at or under $HOURLY_RETAIN_COUNT retained builds."
exit 0
fi
while read -r tag; do
[[ -n "$tag" ]] || continue
echo "Pruning $tag"
gh release delete "$tag" --repo "$HOURLY_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"
@@ -1,20 +1,6 @@
name: Linux Wayland GPU Sandbox
on:
pull_request:
paths:
- .github/workflows/linux-wayland-gpu-sandbox.yml
- config/scripts/linux-wayland-renderer-diagnostics.mjs
- config/scripts/linux-wayland-terminal-exercise.mjs
- config/scripts/linux-wayland-validation-watchdog.mjs
- config/scripts/verify-linux-wayland-gpu-sandbox.mjs
- src/main/startup/configure-process.ts
- src/main/startup/configure-process.test.ts
- src/preload/api-types.ts
- src/preload/index.ts
- src/renderer/src/components/terminal-pane/pty-connection.ts
- src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts
- src/renderer/src/web/web-preload-api.ts
workflow_dispatch:
jobs:
@@ -85,34 +71,6 @@ jobs:
done
test -S "$XDG_RUNTIME_DIR/wayland-1"
- name: Reproduce terminal input freeze without the workaround
id: reproduce_base
if: github.event_name == 'pull_request'
# Why: still collect fixed-path Wayland evidence when the base repro
# stops reproducing; the final gate below fails the job in that case.
continue-on-error: true
timeout-minutes: 10
run: |
set -euo pipefail
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.sha }}"
# Why: once the PR base includes this workaround, the partial
# checkout cannot reconstruct an unfixed Wayland negative control.
if git show "${{ github.event.pull_request.base.sha }}:src/main/startup/configure-process.ts" | grep -Eq "appendSwitch\\(['\"]disable-gpu-sandbox['\"]\\)"; then
echo "The base build already contains the Linux Wayland GPU sandbox workaround; skipping unfixed-path reproduction."
exit 0
fi
# Why: keep the new verifier scripts, but run them against the
# unfixed production terminal/GPU path instead of a hybrid checkout.
git checkout "${{ github.event.pull_request.base.sha }}" -- \
src/main/startup/configure-process.ts \
src/preload/api-types.ts \
src/preload/index.ts \
src/renderer/src/components/terminal-pane/pty-connection.ts \
src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts \
src/renderer/src/web/web-preload-api.ts
rm -rf out
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs --mode=expect-repro
- name: Verify terminal input under Wayland GPU sandbox workaround
if: ${{ !cancelled() }}
timeout-minutes: 10
@@ -122,12 +80,6 @@ jobs:
rm -rf out
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs
- name: Require base reproduction
if: ${{ github.event_name == 'pull_request' && steps.reproduce_base.outcome != 'success' }}
run: |
echo "The unfixed base build did not reproduce the Wayland terminal input failure."
exit 1
- name: Upload Weston log
if: always()
uses: actions/upload-artifact@v7
+114 -16
View File
@@ -93,6 +93,23 @@ jobs:
- name: Verify macOS entitlements
run: pnpm verify:macos-entitlements
root_directory_guard:
name: root directory guard
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- name: Reject new root-level files and folders
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: bash .github/scripts/check-root-directory-entries.sh "$BASE_SHA" "$HEAD_SHA"
typecheck:
runs-on: ubuntu-latest
@@ -216,8 +233,32 @@ jobs:
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.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 }}
cross-version-wire:
name: cross-version wire compatibility
runs-on: ubuntu-latest
steps:
# Why fetch-depth 0: the harness extracts the newest release tag to skew
# current code against it. The default shallow clone has no tags, which is
# why this cannot ride along in the sharded `test` job.
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
# A path filter that matches nothing exits 1 ("No test files found"), so this
# lane cannot report success while running zero tests.
- name: Old/new client and server terminal journey
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
package:
name: package
runs-on: ubuntu-latest
@@ -273,11 +314,62 @@ jobs:
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
# Why: regression specs under tests/e2e/** used to merge green without ever
# running — e2e.yml only fired on schedule/release (#10518). Path-filter so
# ordinary PRs stay light; any E2E suite change still gets a full shard run.
- name: Smoke packaged hang watchdog worker
run: xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked
package_windows:
name: package (windows)
runs-on: windows-2022
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-builder-windows-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-windows-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build package inputs
run: pnpm run build:release
- name: Prepare Electron native runtime
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
- name: Package unpacked app
env:
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked
# Why: PR E2E is advisory and only validates changed specs; scheduled and
# release runs retain full-suite coverage.
e2e-paths:
name: detect e2e path changes
name: detect changed e2e specs
runs-on: ubuntu-latest
if: github.event.pull_request.draft != true
# Why: detector only needs to read the checkout; do not inherit repo defaults.
@@ -285,6 +377,7 @@ jobs:
contents: read
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
test_files: ${{ steps.filter.outputs.test_files }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -292,25 +385,22 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Filter E2E-relevant paths
- name: Filter changed E2E specs
id: filter
run: |
set -euo pipefail
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
# Why: capture first so a failed git diff does not look like "no matches"
# (pipeline status in `if` is not aborted by set -e). Merge-base limits the
# list to files this PR introduced, not base-branch drift.
CHANGED="$(git diff --name-only --merge-base "$BASE" "$HEAD")"
# Why: tests/playwright.config.ts sits beside tests/e2e/, not inside it, so
# it needs its own pattern — a bare `playwright.` prefix matches no tracked
# file and would silently skip E2E when the runner config changes.
if printf '%s\n' "$CHANGED" | grep -E '^(tests/e2e/|tests/playwright\.|\.github/workflows/e2e\.yml$)' >/dev/null; then
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
if [ "$TEST_FILES_JSON" != '[]' ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "E2E path changes detected"
echo "Changed E2E specs: $TEST_FILES_JSON"
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "No E2E path changes"
echo "No changed E2E specs"
fi
e2e:
@@ -321,16 +411,20 @@ jobs:
permissions:
contents: read
uses: ./.github/workflows/e2e.yml
with:
test_files: ${{ needs.e2e-paths.outputs.test_files }}
verify:
if: always()
needs:
- static_analysis
- root_directory_guard
- typecheck
- git_compatibility
- shell_contracts
- test
- package
- package_windows
runs-on: ubuntu-latest
steps:
@@ -345,19 +439,23 @@ jobs:
- name: Require successful checks
env:
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
TYPECHECK: ${{ needs.typecheck.result }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
TEST: ${{ needs.test.result }}
PACKAGE: ${{ needs.package.result }}
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
run: |
for result in \
"$STATIC_ANALYSIS" \
"$ROOT_DIRECTORY_GUARD" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$PACKAGE"; do
"$PACKAGE" \
"$PACKAGE_WINDOWS"; do
if [ "$result" != "success" ]; then
exit 1
fi
+21
View File
@@ -1015,6 +1015,27 @@ jobs:
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
# Why: `uses: ./…` resolves from the checked-out tag, not from the workflow
# ref, so cutting from an older/off-main ref whose tree predates a composite
# action would fail the step with "Can't find 'action.yml'". Restore the
# actions directory from the commit this workflow file itself came from.
- name: Restore composite actions from the workflow ref
if: matrix.platform == 'win'
shell: bash
env:
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
action_path=".github/actions/install-signpath-module/action.yml"
if [ -f "$action_path" ]; then
echo "Composite actions already present at the cut ref."
exit 0
fi
echo "Cut ref predates $action_path; restoring it from $WORKFLOW_SHA."
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
git checkout "$WORKFLOW_SHA" -- .github/actions
test -f "$action_path"
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
- name: Setup pnpm
uses: pnpm/action-setup@v6
-20
View File
@@ -1,26 +1,6 @@
name: Terminal IME E2E
on:
pull_request:
paths:
- '.github/workflows/terminal-ime-e2e.yml'
- 'config/patches/@xterm__xterm@6.1.0-beta.287.patch'
- 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
- 'config/scripts/terminal-ime-e2e-workflow.test.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- 'src/renderer/src/components/terminal-pane/keyboard-handlers.ts'
- 'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx'
- 'src/renderer/src/components/terminal-pane/pty-connection.ts'
- 'src/renderer/src/components/terminal-pane/pty-connection.test.ts'
- 'src/renderer/src/components/terminal-pane/terminal-ime-*'
- 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts'
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts'
- 'tests/e2e/chinese-ime-chat-input-repro.spec.ts'
- 'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts'
- 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
- 'tests/e2e/terminal-ime-*.ts'
workflow_dispatch:
schedule:
- cron: '30 9 * * *'
+3 -49
View File
@@ -6,56 +6,10 @@ name: Windows Crash-Survival E2E
# silent-installs it, opens a terminal, force-kills ONLY the app main (no
# tree-kill), and asserts the daemon + shell stay alive, a relaunch adopts the
# same daemon and shell, and post-crash input causes no pwsh FailFast. Targeted
# pull requests keep it as a durable regression gate. A CI runner is the only
# manual runs keep it as a durable regression proof. A CI runner is the only
# safe place to install — see the relocation post-mortem.
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- '.github/workflows/win-crash-survival-e2e.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'electron.vite.config.ts'
- 'build-plugins/**'
- 'config/electron-builder.config.cjs'
- 'config/patches/**'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'config/scripts/verify-packaged-plugin-resources.cjs'
- 'native/**'
# Byte-hashed at package time, and CRLF-sensitive on Windows.
- '.gitattributes'
- 'resources/plugins/**'
- 'resources/win32/**'
- 'src/main/daemon/**'
- 'src/main/index.ts'
- 'src/main/ipc/pty*.ts'
- 'src/main/persistence.ts'
- 'src/main/providers/**'
- 'src/main/pty/**'
- 'src/main/startup/first-window-startup-services.ts'
- 'src/main/window/attach-main-window-services.ts'
- 'src/preload/**'
- 'src/renderer/src/App.tsx'
- 'src/renderer/src/components/terminal-pane/**'
- 'src/renderer/src/hooks/useIpcEvents.ts'
- 'src/renderer/src/lib/pane-manager/**'
- 'src/renderer/src/lib/session-write-subscriber.ts'
- 'src/renderer/src/lib/workspace-session-host-persistence.ts'
- 'src/renderer/src/store/slices/terminals.ts'
- 'src/shared/pty-session-id-format.ts'
- 'tools/win-crash-survival-e2e/**'
- 'tools/win-update-e2e/**'
# Why: source tests and benchmarks do not change the packaged artifact;
# their own verify jobs cover them without spending a Windows build slot.
- '!src/**/*.test.*'
- '!src/**/*.bench.*'
workflow_dispatch:
inputs:
expect:
@@ -123,7 +77,7 @@ jobs:
'!config/reliability-gates.jsonc',
'!config/max-lines-baseline.txt',
'!config/vitest.config.ts',
'build-plugins/**',
'config/build-plugins/**',
'native/**',
'resources/**',
'electron.vite.config.ts',
@@ -181,7 +135,7 @@ jobs:
run: |
New-Item -ItemType Directory -Force artifacts | Out-Null
$log = "artifacts/crash-survival-output.log"
node tools/win-crash-survival-e2e/run.mjs `
node tests/tools/win-crash-survival-e2e/run.mjs `
--expect "$env:EXPECT" `
--exe-path "$env:ORCA_EXE" `
--soak-seconds 8 2>&1 | Tee-Object -FilePath $log
+2 -2
View File
@@ -20,7 +20,7 @@ on:
branches:
- Jinwoo-H/windows-update-survival
paths:
- 'tools/win-update-e2e/**'
- 'tests/tools/win-update-e2e/**'
- '.github/workflows/win-update-e2e.yml'
workflow_dispatch:
inputs:
@@ -125,7 +125,7 @@ jobs:
ORCA_E2E_DIAG_DIR: artifacts/diag
run: |
$log = "artifacts/harness-output.log"
node tools/win-update-e2e/run.mjs `
node tests/tools/win-update-e2e/run.mjs `
--from "$env:FROM_EXE" `
--to "$env:TO_EXE" `
--expect "$env:EXPECT" `
@@ -16,7 +16,7 @@ on:
paths:
- 'src/main/daemon/**'
- 'src/main/pty/**'
- 'tools/win-update-e2e/**'
- 'tests/tools/win-update-e2e/**'
- 'config/electron-builder.config.cjs'
- '.github/workflows/win-update-survival-e2e.yml'
workflow_dispatch:
@@ -97,7 +97,7 @@ jobs:
$exe = "dist/orca-windows-setup.exe"
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
$log = "artifacts/survival-output.log"
node tools/win-update-e2e/run.mjs `
node tests/tools/win-update-e2e/run.mjs `
--from "$exe" `
--to "$exe" `
--expect "$env:EXPECT" `
@@ -1,41 +1,6 @@
name: Windows terminal restart E2E
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- '.github/workflows/windows-terminal-restart-e2e.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'electron.vite.config.ts'
- 'config/patches/**'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'tests/playwright.config.ts'
- 'tests/e2e/global-setup.ts'
- 'tests/e2e/global-teardown.ts'
- 'tests/e2e/helpers/**'
- 'tests/e2e/restart-restore-terminal-input.spec.ts'
- 'tests/e2e/restored-terminal-input-readiness.unit.test.ts'
- 'tests/e2e/terminal-probe-input-sequence.ts'
- 'tests/e2e/terminal-probe-input-sequence.unit.test.ts'
- 'tests/e2e/terminal-restart-persistence.spec.ts'
- 'src/main/daemon/**'
- 'src/main/ipc/pty*.ts'
- 'src/main/providers/**'
- 'src/main/pty/**'
- 'src/main/pty-descendant-termination.ts'
- 'src/main/windows-process-tree-kill.ts'
- 'src/main/windows-pty-root-identity.ts'
- 'src/preload/**'
- 'src/renderer/src/components/terminal-pane/**'
- 'src/renderer/src/lib/pane-manager/**'
- 'src/shared/process-table-snapshot.ts'
- 'src/shared/pty-session-id-format.ts'
workflow_dispatch:
inputs:
ref:
+8 -2
View File
@@ -70,7 +70,7 @@ coverage/
prod-release-scan-*.md
# Benchmark run output
/tools/benchmarks/results/*.json
/tests/tools/benchmarks/results/*.json
/.bench-fixtures/
# Temp
@@ -99,6 +99,9 @@ docs/**
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/remote-wire-compatibility.md
!docs/reference/windows-setup-shell.md
# Stably CLI (only docs/ are tracked)
.stably/*
@@ -132,4 +135,7 @@ src/renderer/src/i18n/locales/.ja-catalog-cache.json
src/renderer/src/i18n/locales/.es-catalog-cache.json
# Bench result JSONs are working artifacts
tools/benchmarks/results/terminal-pipeline-*.json
tests/tools/benchmarks/results/terminal-pipeline-*.json
# Old release trees the cross-version wire harness extracts on demand
tests/e2e/.cross-version-checkouts/
+3 -1
View File
@@ -27,6 +27,8 @@
"app-store-performance/no-identity-selector": "error",
"app-store-performance/no-fresh-selector-result": "error",
"no-fallthrough": "error",
"eslint/no-useless-call": "error",
"oxc/no-accumulating-spread": "error",
"quadratic-buffer-concat/no-loop-carried-concat": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
@@ -148,5 +150,5 @@
}
}
],
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"]
}
+6 -1
View File
@@ -5,7 +5,7 @@ All UI work — layout, color, typography, spacing, component selection, UX beha
# Style
## Concise/Brief Non-obviosu comments ONLY
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
* DO: BE CONCISE. 1 LINE if possible
* BE CONCISE. 1 LINE if possible
## Lint Rules: Do Not Disable Max Lines
@@ -29,6 +29,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
## SSH Use Case
@@ -39,6 +40,10 @@ All changes must consider the SSH use case. Don't assume local-only execution.
All changes must consider folder workspaces as well as git worktrees. Don't assume every workspace is a git worktree.
## Remote Wire Compatibility
Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change.
## Git Binary Compatibility
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk) · [Docs →](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.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk)
- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
@@ -238,10 +238,9 @@ Pair with your desktop app to monitor and steer your agents from your phone.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
- **WeChat:** If group 5 is full, you can join group 6.
- **WeChat:** Scan to join the Orca community WeChat group 7.
<img src="docs/assets/wechat-qr.jpg" alt="WeChat group 5 QR code for the Orca community" width="160" style="margin-right: 16px;" />
<img src="docs/assets/wechat-qr-group6.jpg" alt="WeChat group 6 QR code for the Orca community" width="160" />
<img src="docs/assets/wechat-qr-group7.jpg" alt="WeChat group 7 QR code for the Orca community" width="160" />
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy &amp; telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
@@ -0,0 +1,107 @@
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
export const BOOTSTRAP_FATAL_LOG_ENV_VAR = 'ORCA_BOOTSTRAP_FATAL_LOG'
export const BOOTSTRAP_FATAL_LOG_FILE_NAME = 'bootstrap-fatal.log'
const BOOTSTRAP_FATAL_LOG_MAX_BYTES = 262_144
export function createBootstrapFatalExitBanner(): string {
// Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement.
// Suppressing that dialog also removes the only account of the failure, so record one first:
// a partially copied resources tree otherwise exits silently on every launch, with nothing
// for the user or a support log to name the module that was missing.
return `
;(() => {
const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)}
if (typeof globalThis[guardKey] === 'function') {
return
}
const describeBootstrapError = (error) => {
try {
const detail = error && typeof error === 'object' && error.stack ? error.stack : error
return String(detail).split('\\n').slice(0, 12).join(' | ').slice(0, 4000)
} catch {
return '<unprintable error>'
}
}
const readBootstrapFatalLogOverride = () => {
const override = typeof process.env === 'object' && process.env ? process.env.${BOOTSTRAP_FATAL_LOG_ENV_VAR} : undefined
return typeof override === 'string' && override.length > 0 ? override : undefined
}
const resolveDefaultBootstrapFatalLogPath = () => {
let directory
try {
directory = require('electron').app.getPath('userData')
} catch {
// Why: a bootstrap fault can predate a usable app object; temp still outlives the process.
directory = require('node:os').tmpdir()
}
return require('node:path').join(directory, ${JSON.stringify(BOOTSTRAP_FATAL_LOG_FILE_NAME)})
}
const appendBootstrapFatalLine = (fs, logPath, entry) => {
try {
// Why: an override can name a directory nothing has created yet, and a missing
// parent would drop the only account of the failure.
fs.mkdirSync(require('node:path').dirname(logPath), { recursive: true })
// Why: a broken install repeats this on every relaunch; keep the trail bounded.
let flags = 'a'
try {
flags = fs.statSync(logPath).size > ${BOOTSTRAP_FATAL_LOG_MAX_BYTES} ? 'w' : 'a'
} catch {
flags = 'a'
}
const descriptor = fs.openSync(logPath, flags, 0o600)
try {
fs.writeSync(descriptor, entry)
} finally {
fs.closeSync(descriptor)
}
return true
} catch {
return false
}
}
const recordBootstrapFailure = (error) => {
const line = '[bootstrap] fatal-exit pid=' + process.pid + ' error=' + describeBootstrapError(error) + '\\n'
let fs
try {
fs = require('node:fs')
} catch {
fs = undefined
}
try {
fs.writeSync(2, line)
} catch {
try {
process.stderr.write(line)
} catch {
// Diagnostics must never replace the exit below.
}
}
try {
const entry = new Date().toISOString() + ' ' + line
const override = readBootstrapFatalLogOverride()
// Why: an unwritable override must cost the user a location, not the diagnostic.
if (!override || !appendBootstrapFatalLine(fs, override, entry)) {
appendBootstrapFatalLine(fs, resolveDefaultBootstrapFatalLogPath(), entry)
}
} catch {
// Diagnostics must never replace the exit below.
}
}
let exitScheduled = false
const exitAfterBootstrapFailure = (error) => {
if (exitScheduled) {
return
}
exitScheduled = true
recordBootstrapFailure(error)
process.exitCode = 1
setImmediate(() => process.exit(1))
}
globalThis[guardKey] = () => {
process.off('uncaughtException', exitAfterBootstrapFailure)
delete globalThis[guardKey]
}
process.once('uncaughtException', exitAfterBootstrapFailure)
})();
`
}
@@ -21,12 +21,28 @@ type OutputChunk = Rollup.OutputChunk
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'main-thread-hang-watchdog-entry',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls',
'codex/codex-app-server-grant-entry'
] as const
// Entries executed as worker threads of the main process. Electron's module is
// not registered on worker threads, so require("electron") throws
// "Cannot find module 'electron'" there too (verified on Electron 43) and kills
// the worker at startup. These carry hand-written "must stay electron-free"
// comments, which is convention, not enforcement — and the port-scan worker in
// particular sits one import away from a client module that deliberately does
// require electron.
const WORKER_THREAD_ENTRY_NAMES = [
'stt-worker',
'warp-theme-parser-worker',
'session-scanner-opencode-sqlite-worker-entry',
'main-thread-hang-watchdog-entry',
'port-scan-command-worker-entry'
] as const
type EntryRuntime = 'plain-Node process' | 'worker thread'
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
function collectReachableChunks(
@@ -57,13 +73,14 @@ function collectReachableChunks(
function assertNoElectronRequire(
entryName: string,
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
byFileName: Map<string, OutputChunk>,
runtime: EntryRuntime = 'plain-Node process'
): void {
for (const chunk of collectReachableChunks(entry, byFileName)) {
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
throw new Error(
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
`requires electron. "${entryName}" runs as a plain-Node process, where ` +
`requires electron. "${entryName}" runs as a ${runtime}, where ` +
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
)
@@ -126,7 +143,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(entryName, entry, byFileName)
assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process')
}
}
for (const entryName of WORKER_THREAD_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(entryName, entry, byFileName, 'worker thread')
}
}
+43 -11
View File
@@ -14,10 +14,27 @@ const {
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
// Why: dev-channel builds must carry the *release* identity — same bundle id,
// Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to
// swap them over an installed Orca and macOS treats each build as a new app.
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1'
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacAdhoc
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
const devChannelBuildVersion = isMacHourly
? process.env.ORCA_HOURLY_BUILD_VERSION
: isMacAdhoc
? process.env.ORCA_ADHOC_BUILD_VERSION
: undefined
// Why each dev channel gets its own repo rather than tagging into the main one:
// the releases atom feed exposes only the 10 newest entries, so 24 hourly tags a
// day would evict every stable/RC entry and strand users on a feed with nothing
// to install. Keeping adhoc separate from hourly too means a branch build cannot
// be picked up by someone who only meant to ride main.
const devChannelRepo = isMacHourly ? 'orca-hourly' : isMacAdhoc ? 'orca-adhoc' : null
const appId = 'com.stablyai.orca'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',
@@ -64,7 +81,11 @@ const winSpeechNativeResource = {
module.exports = {
appId,
productName: 'Orca',
...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}),
...(devChannelBuildVersion
? { extraMetadata: { version: devChannelBuildVersion } }
: localBuildVersion
? { extraMetadata: { version: localBuildVersion } }
: {}),
directories: {
buildResources: 'resources/build'
},
@@ -143,21 +164,16 @@ module.exports = {
'out/main/claude/**',
'out/main/claude-accounts/keychain.js',
'out/main/codex/**',
'out/main/codex-cli/command.js',
'out/main/copilot/**',
'out/main/cursor/**',
'out/main/droid/**',
'out/main/gemini/**',
'out/main/grok/**',
'out/main/hermes/**',
'out/main/ipc/local-agent-install-dir-detection.js',
'out/main/ipc/tui-agent-detection-commands.js',
'out/main/win32-utils.js',
'out/main/daemon-entry.js',
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
'out/main/main-thread-hang-watchdog-entry.js',
'out/main/chunks/**',
'resources/**',
'node_modules/ws/**',
@@ -183,7 +199,7 @@ module.exports = {
)
: join(context.appOutDir, 'resources')
if (!existsSync(resourcesDir)) {
return
throw new Error(`Missing packaged resources directory: ${resourcesDir}`)
}
if (context.electronPlatformName === 'darwin') {
const architectureByEnum = { 1: 'x64', 3: 'arm64' }
@@ -213,7 +229,16 @@ module.exports = {
// arm64=3, universal=4 (universal contains the host slice, so run it).
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
const hostArchEnum = archEnumByNodeArch[process.arch]
if (context.arch === hostArchEnum || context.arch === 4) {
const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4
verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, {
executeCommands: canExecuteTargetArch
})
if (!canExecuteTargetArch) {
console.log(
`[verify-skills-cli-runtime] skipped command probes on cross-arch slice (target ${context.arch}, host ${process.arch})`
)
}
if (canExecuteTargetArch) {
verifyPackagedDaemonEntryBoots(resourcesDir)
} else {
// Why: a cross-arch slice can't be booted by the host Node, but the
@@ -316,6 +341,13 @@ module.exports = {
// explicit release path so production artifacts remain strict while dev
// artifacts do not fail with broken ad-hoc launch behavior.
hardenedRuntime: isMacRelease,
// Why dev builds notarize too, despite the ~10min notary round trip: TCC
// anchors a notarized Developer ID app's permission grants on identifier +
// team, which is cdhash-independent and so survives an update. Without a
// ticket there is no such stable identity, so every build reads as a
// different client — the grant row stays but stops matching, and file access
// under Documents/Desktop/Downloads fails with EPERM and no re-prompt. At 24
// builds a day that revokes the user's grants faster than they can re-grant.
notarize: isMacRelease,
extraResources: [
...commonExtraResources,
@@ -456,8 +488,8 @@ module.exports = {
publish: {
provider: 'github',
owner: 'stablyai',
repo: 'orca',
releaseType: 'release'
repo: devChannelRepo ?? 'orca',
releaseType: devChannelRepo ? 'prerelease' : 'release'
}
}
+524
View File
@@ -0,0 +1,524 @@
# I18n Translation Source and Automation
Date: 2026-07-29 (revised 2026-07-30)
Status: accepted architecture decision; PR A
([#8512](https://github.com/stablyai/orca/pull/8512)) merged; PR B not yet
implemented
Revision note: the original decision selected a constrained XLIFF 2.0 profile
at 80% confidence, with gettext PO as the named fallback. A tooling,
contributor-workflow, and repository-evidence review on 2026-07-30 resolved
that uncertainty against XLIFF 2.0 before PR B was built. The architecture is
unchanged; the canonical format is now gettext PO. The evidence is recorded
under "Alternatives considered".
## Goal
Reduce the recurring engineering and human work required to localize Orca
without presenting missing, copied-English, or stale values as completed
translations.
The format is not the goal. The intended steady-state workflow is:
- a feature developer adds a stable message ID and current English copy once;
- extraction discovers new and changed messages automatically;
- feature work does not wait for every target locale;
- translation automation processes only missing and stale units;
- humans review a small, risk-prioritized delta rather than whole catalogs;
- missing or stale translations safely fall back to current English; and
- deterministic, offline runtime bundles ship with the app.
## Decision
Use gettext PO as Orca's canonical bilingual translation source, under a
constrained profile:
- `msgctxt` — the stable Orca message ID;
- `msgid` — the English source, current as of the entry's last
reconciliation;
- `msgstr` — the target translation;
- `#, fuzzy` — not approved for runtime, whether stale or newly
machine-translated; never compiled into runtime bundles;
- `#| msgid` — the prior English source associated with the retained
translation;
- `#.` — translator context and notes;
- `#:` — source references (optional, informational); and
- `#~` — retired entries, kept only under the retention policy below.
Product source owns stable message IDs and current English defaults. Maintained
extraction produces the current English translation source. Each target locale
owns a sparse PO file containing only translations that exist, each carrying
the English source it is keyed against.
Responsibilities are split so target catalogs never change in feature work:
- **Feature PRs** change English only: source code, inline defaults, and the
extracted English source.
- **A read-only compiler** joins the current English source with each target
PO file by `msgctxt` and generates the existing i18next JSON bundle shape.
An entry whose stored `msgid` differs from current English is stale; one
whose `msgid` is current but fuzzy is pending approval. Both are omitted so
i18next falls back to current English.
The compiler never writes to PO files. Generated runtime JSON is disposable
and must not be hand-edited.
- **Post-merge localization automation** owns all PO mutations. Reconciliation
finds entries affected by merged English changes, updates `msgid` to current
English, preserves the old source in `#| msgid`, and sets `#, fuzzy` — so
translators and tools see the current source, the previous source, and the
prior translation in one unit. Reconciliation lands in localization-only
changes, never in the originating feature change.
The reconciler — not gettext's own merge tooling — owns the join. `msgmerge`
keys entries on the `(msgctxt, msgid)` pair, so under stable IDs a source
change orphans the entry instead of marking it fuzzy. Reconciliation must join
by `msgctxt` alone.
Absence of `#, fuzzy` means approved to ship, whether approval came from a
human or an explicitly configured low-risk automation policy. Machine or human
provenance is recorded separately in a documented comment or flag; it must not
be conflated with runtime eligibility.
PO is an authoring and translation-workflow boundary, not a runtime
dependency. Orca must not require network access or a translation service to
display localized UI.
## Why this solves the work problem
The system is organized around translating only the delta:
```text
feature change: stable ID + English
|
v
extraction and validation
|
v
missing/changed translation queue
|
v
machine translation + glossary/context
|
v
placeholder and policy validation
|
v
sparse locale bundle or English fallback
```
Feature developers do not synchronize target catalogs, copy English into
untranslated locales, run whole-catalog repair, or wait for translation
completion. A source change invalidates only the affected target units.
Unchanged translations remain byte-stable.
The bilingual source gives the compiler enough information to distinguish:
- **missing**: the locale has no entry for the ID;
- **stale**: the stored `msgid` differs from current English — true the
moment the English change merges, before any reconciliation runs;
- **pending approval**: the stored `msgid` is current, a target exists, and
`#, fuzzy` is set; and
- **current/approved**: the stored `msgid` is current, a target exists, and
`#, fuzzy` is absent.
Missing, stale, and pending-approval entries are omitted from generated
target bundles so i18next uses current English. Staleness must not be inferred from Git history,
file timestamps, copied-English ratios, or a second hand-maintained database.
## Constrained PO profile
Orca will support only the subset needed for its message model. PR B must
define and validate that subset rather than accepting arbitrary PO documents.
Each supported entry must carry:
- one stable Orca message ID (`msgctxt`), unique within the file;
- the English source (`msgid`), current as of the entry's last
reconciliation;
- an optional target value (`msgstr`) in a target-locale file;
- translator notes or UI context when provided (`#.`);
- runtime workflow state (`#, fuzzy`), with provenance recorded in the
documented separate comment or flag; and
- the information needed to validate interpolation placeholders.
Plurals use Orca's existing per-key convention: each i18next plural-suffixed
key (`…_one`, `…_other`) is its own PO entry. The profile must not use
`msgid_plural`/`msgstr[n]`, which would collide with i18next's own CLDR plural
selection and gettext's `nplurals` model.
The compiler must reject:
- duplicate or unstable IDs (including two entries sharing a `msgctxt`);
- empty targets represented as completed translations;
- unsupported states or constructs;
- malformed source/target entries;
- nondeterministic ordering or output — the profile must document exact
serialization normalization (entry order by `msgctxt`, fixed line folding,
LF endings, escaping rules) so every tool in the pipeline emits identical
bytes for identical content; and
- locale files whose declared language headers are incorrect.
Placeholder validation is state-dependent (an entry is current only when its
stored `msgid` matches current English and it carries no fuzzy flag):
- **stale or pending-approval entries**: a placeholder mismatch is expected
mid-flight; the entry is already omitted from bundles, so validation
reports it without failing;
- **current/approved entries**: a placeholder mismatch is a hard validation
failure; and
- **automation and import pipelines**: a produced target with mismatched
placeholders is demoted to fuzzy rather than committed as current.
Source comparison is a semantic comparison of parsed source strings after
documented text normalization. Line folding, wrapping, and PO escaping are
serialization concerns and must never affect comparison. Reconciliation must
never update an entry's stored source without either confirming or replacing
the target, or marking the entry fuzzy.
If translator notes or placeholder semantics can change the required
translation without changing visible English, the source-signature contract
must include that information or explicitly invalidate the entry.
Retired entries (`#~`) are kept only while they provide useful translation
memory for automation; a deterministic cleanup policy removes them rather than
letting them accumulate indefinitely.
## Message ID and placeholder policy
The current catalog undermines the stable-ID premise in two ways the format
alone cannot fix: 8,981 of 11,564 keys (77.7%) are codemod-generated content
hashes, and 825 of 954 interpolated strings use positional placeholders
(`{{value0}}`) that carry no meaning for a translator or a machine-translation
prompt.
Policy:
- hashed IDs already in the desktop catalog as of this decision (2026-07-30)
are grandfathered as opaque stable IDs; the generating codemod must not be
rerun against existing keys, and no mass rename happens during this
migration;
- newly minted hash IDs — including in-flight bridge catalogs not yet merged
or merged after this decision — are **not** grandfathered: they are renamed
to intent-named IDs in a dedicated change adjacent to their landing, before
PO becomes canonical for that surface, never buried inside an otherwise
reviewed feature PR;
- new keys must use intent-named IDs and named interpolation placeholders;
- converting positional placeholders to named ones requires per-message
semantic judgment (the name is part of the translatable contract), not a
mechanical rename; and
- legacy IDs and positional placeholders are improved opportunistically when
the copy itself is touched, never in bulk.
## Runtime and source ownership
The ownership chain is:
```text
product code and explicit dynamic declarations
|
v
extracted current English source
|
v
sparse canonical target-locale PO files
|
v
deterministic i18next JSON bundles
|
v
lazy-loaded offline app resources
```
Inline English defaults remain readable at call sites and provide extraction
input. Static extraction and explicit declarations for genuinely dynamic keys
must reconcile to one current English translation source. CI must reject
missing declarations and incompatible defaults rather than creating a
permanent exception database.
Renderer, Electron main, web, SSH, WSL, and packaged execution continue to
consume the same logical runtime bundles. The compiler and build paths must use
cross-platform path handling and deterministic line endings.
Two additional surfaces are in scope:
- **Mobile.** The mobile app is acquiring its own catalog tree with its own
key scheme. Mobile must adopt the same canonical contract, ID policy, and
compiler — with its own PO files — rather than a second bespoke pipeline.
Mobile keys must not be folded into the desktop catalogs, and in-flight
mobile catalog JSON is a bridge the migration replaces, not a canonical
source. Mobile requires **two deterministic projections** from the same PO
source: the mobile i18next JSON bundle, and the native metadata resources
(iOS `InfoPlist.strings`, Android resources) that render before the JS
runtime exists. i18next fallback cannot cover pre-JS surfaces, so for a
missing, stale, or pending-approval native entry the compiler must either
omit the locale-specific native key with proven OS/base-locale fallback on
both platforms, or emit current English under a documented platform rule.
The intentional locale-ID mapping (JS `zh` vs native `zh-Hans`) is part of
the projection contract. The migration covers both projections.
- **Plugin language packs.** Language packs remain external overrides
validated against compiled core keys. They are consumers of the contract,
not another canonical source, and the compiler must leave their runtime
path intact.
## Translation automation
The architecture is incomplete until translation work is delta-driven.
After the compiler and migration are stable, automation should:
1. reconcile entries affected by merged English changes (update `msgid`,
preserve `#| msgid`, set fuzzy) and find target entries that are missing
or stale;
2. batch only those entries into a translation change;
3. supply translator notes, glossary terms, placeholder rules, the previous
source from `#| msgid`, and relevant existing translations;
4. preserve all unaffected target entries byte-for-byte;
5. mark runtime eligibility and provenance honestly;
6. run placeholder, terminology, formatting, and catalog validation, demoting
invalid output to fuzzy; and
7. fall back to English rather than shipping an invalid target.
Human review should be risk-based. Destructive actions, authentication,
billing, privacy, security, legal copy, OS permission prompts (camera,
microphone, photos, local network), native app metadata, and other sensitive
flows require review. Routine labels and descriptions may use validated machine translation
under the adopted release policy. A model-provided confidence score alone is
not sufficient release evidence.
Catalog QA should include an identical-to-English guard implemented as a
ratchet with reviewed exemptions, not a blanket prohibition: brand names,
commands, and terms that legitimately remain English live in the exemption
inputs, and the untranslated ratio for everything else must not rise.
Glossary and forbidden-translation rules belong in stable QA inputs. The
existing repair-script policy data (glossaries, never-translate lists,
per-locale overrides) is converted into those inputs. They must replace
historical whole-catalog repair scripts rather than coexist with them as
another source of product behavior.
## Rollout
### PR A: decouple feature copy — merged
PR #8512:
- permits sparse target catalogs;
- makes English runtime fallback authoritative for missing values;
- makes catalog synchronization update English only;
- preserves existing target values and rejects placeholder incompatibility;
- adds maintained extraction and CI verification; and
- removes whole-catalog translation and repair commands from ordinary product
workflows.
PR A is independently useful and format-neutral. It stops new parity work, but
it does not permanently model reviewed or stale translation state.
PR A also created a deletion ratchet: extra target-locale keys hard-fail
verification and sync no longer edits targets, so an English key cannot be
retired without hand-editing every target catalog. Orphaned English keys
(2,055 unreferenced at time of writing) grow with every rename until PR C
lands. PR C should not wait long.
### PR B: define the source and compiler
PR B must remain a small architecture and implementation proof. It should add:
1. the constrained PO profile and its serialization normalization;
2. extraction into the current English source;
3. sparse source/target parsing via maintained PO tooling;
4. deterministic, read-only i18next bundle compilation, omitting entries
whose stored source mismatches current English or that are fuzzy — with a
compiler shape that admits additional per-locale projections (the mobile
native-resource output) even though PR B implements only the i18next
bundle;
5. a separate reconciliation command owning all PO mutations (`msgid`
update, `#| msgid` preservation, `#, fuzzy`);
6. state-dependent placeholder validation;
7. representative fixtures for interpolation, plural-suffixed keys, multiline
copy, translator notes, and source changes; and
8. packaged-build integration across local and remote execution boundaries.
Acceptance tests must assert every workflow field — state, previous source,
notes, retirement — field-by-field through parse → modify → serialize cycles.
Byte-identical round-trip alone is not evidence: a lossy parser faithfully
reproduces its own losses, so a round-trip gate can pass while the fields that
justify the format are silently destroyed. This failure mode was demonstrated
against the dominant XLIFF library during the format review.
It must not migrate the complete locale inventory. That keeps the format and
compiler independently reviewable and makes rejection inexpensive.
### PR C: migrate existing translations once
The migration must:
- export all current stable English IDs and values;
- import real target values without retranslating them;
- preserve known reviewed corrections;
- mark unproven values as pending approval, with imported provenance —
bridge catalogs in particular mix reused desktop translations, machine
translation, and manual corrections, and must be classified rather than
assumed approved;
- remove copied-English parity filler unless it is intentionally English,
seeding the identical-to-English exemption inputs from the existing policy
data;
- turn terminology and known mistranslations into glossary and QA inputs;
- drain the orphaned-English-key ratchet and delete the retired bootstrap and
repair script files; and
- explain every difference between current and compiled runtime bundles.
Every existing target value must be byte-preserved, explicitly retired, or
classified as filler. No Git-history inference should remain in the permanent
runtime or verification path.
### PR D: switch ownership and delete the bridge
After bundle equivalence is proven:
- PO becomes the only editable target translation source;
- generated runtime JSON stops being a canonical input;
- development, test, build, and packaging compile the bundles
deterministically;
- a JSON→PO importer ships for contributors, so existing full-catalog
community PRs can be converted instead of abandoned;
- legacy bootstrap, repair, override, and migration-only bookkeeping is
removed; and
- ordinary CI runs one focused extraction and compile verification path.
### Translation automation
The first automation follow-up should create or update localization-only
changes containing only missing and stale entries. It should not rewrite a
whole locale, modify feature code, or block the originating feature change.
## Alternatives considered
### Constrained XLIFF 2.0 — original choice, reversed 2026-07-30
XLIFF 2.0 has the best abstract schema fit: stable IDs, source and target in
one unit, standardized workflow state, and categorized notes. The decision was
reversed when the stated uncertainties resolved against it:
- the dominant JS XLIFF library silently discards `state`, `subState`, and
note categories — the fields that motivate the format — while reporting a
clean round-trip, so the planned acceptance gate would have passed
vacuously; the only spec-faithful JS alternative has ~90 weekly downloads,
a single maintainer, and EPL-1.0 licensing;
- XLIFF 2.0 core defines no previous-source mechanism, so the "what changed"
payload for translation automation requires a custom extension that breaks
the interoperability that justified the format (leaving the old `<source>`
in the target file and comparing against a separate current-English file is
possible, but then translators and tools see the wrong current source
unless another projection is built — PO represents the joined working state
natively);
- tool and platform support for XLIFF 2.x remains weaker than 1.2 more than a
decade after standardization; no verified case of XLIFF 2.0 as an in-repo
canonical source was found, and the closest public precedent for this exact
compiler design chose 1.2; and
- XML is the format most likely to let a hand-edited community PR fail on a
malformed entity, and there is no cross-tool XML formatting convention, so
external editors produce whole-file diffs; Orca's translation contributions
arrive overwhelmingly as direct catalog PRs.
If a translation provider requires XLIFF, Orca should add a deterministic
import/export adapter at the boundary rather than change the canonical
representation.
### XLIFF 1.2
Broader legacy tool support than 2.0, but more tool-specific dialects and a
less coherent data model, and it shares XML's contributor and formatting
costs. Adapter-only, as above.
### Direct JSON or TypeScript catalogs
These are simple runtime inputs, but they do not standardize source snapshots,
translator context, or review state. Meeting Orca's requirements would require
custom sidecars or object schemas and synchronization rules. That recreates
much of a bilingual standard while retaining the parity and stale-state risks
the migration is intended to remove.
### Bespoke bilingual JSON — the current fallback
A JSON schema with embedded per-locale state, source snapshots, and notes is a
proven model (a major platform vendor ships exactly this shape, with per-key
extraction state including staleness and per-locale review state) and would
add no parsing dependency. It is second to PO because PO's workflow fields are
standard rather than bespoke, existing translation editors and open tooling
understand them, and its diffs are the smallest of the three formats. If the
PR B PO proof fails, adopt this — not XLIFF — before PR C.
### Platform-native catalogs
Platform-native string catalogs provide strong translation state on their
own platform but are not a suitable authority for Orca's macOS, Linux,
Windows, web, WSL, and SSH surfaces.
### Proprietary translation platform
A translation platform may later operate on the PO boundary, but it should
not become Orca's sole source of truth or a runtime dependency. Selecting one
before the repository contract exists would create premature vendor coupling.
### Peer practice
Inspectable applications validate the architecture more strongly than any one
format:
- mature systems use stable keys or source messages, authoritative English,
sparse targets, and runtime fallback;
- applications that keep canonical translations in-repo with PR-based
contribution use PO or stateful JSON catalogs; none inspected uses XLIFF
2.0 as a canonical source, and the only XLIFF observed was 1.2 as
interchange into an external pipeline;
- the delta-driven machine-translation model this document targets is already
shipping in peer practice, keyed by per-entry source tracking;
- structural key parity is not a completeness signal — peer catalogs exist
with perfect parity and majority copied-English content, which is what the
identical-to-English ratchet detects; and
- gates that are written but not enforced decay — every verification in this
plan must run in CI from the PR that introduces it.
Closed-source applications do not publish enough of their authoring pipelines
to establish a format decision. Orca therefore chooses based on its own
requirements rather than presumed competitor internals.
## Confidence and decision gate
Confidence is:
- **97%** in the overall sparse-source, source-snapshot, deterministic-compiler,
and English-fallback architecture;
- **90%** in PO over constrained XLIFF 2.0;
- **85%** in PO over bespoke bilingual JSON; and
- **95%** that a migration-free PR B prototype on PO is the correct next step.
The remaining PO uncertainty concerns exact serialization normalization
(folding, escaping) across the tools contributors use, the provenance
convention recorded alongside `#, fuzzy`, and the `#~` retention policy.
PR B closes the decision gate only if its representative fixtures preserve
every workflow field through parse → modify → serialize verified
field-by-field, output is deterministic, diffs remain reviewable, and the
compiler/build integration is maintainable. If that proof fails, adopt bespoke
bilingual JSON before PR C. Do not fall back to XLIFF 2.0, add compatibility
layers, or create custom sidecar state merely to preserve a format choice.
This is a deliberate, reversible senior-engineering decision — and it has
already been exercised once: the original XLIFF 2.0 selection was reversed
when evidence resolved its stated uncertainties, at zero migration cost
because the staged rollout deferred format commitment until PR B.
## Explicit non-goals
The completed system must not:
- require every target locale to contain every English key;
- copy English into target catalogs to simulate coverage;
- infer permanent translation state from Git history;
- retranslate or rewrite complete catalogs for ordinary copy changes;
- make generated runtime JSON another hand-edited source;
- rename existing hashed message IDs in bulk;
- treat plugin language packs as a canonical source;
- block feature PRs while translations catch up;
- require runtime network access; or
- maintain multiple canonical translation databases.
+46
View File
@@ -0,0 +1,46 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": [
"src/main/index.ts",
"src/preload/index.ts",
"src/preload/browser-window-close.ts",
"src/main/daemon/daemon-entry.ts",
"src/main/plugins/plugin-host-entry.ts",
"src/main/computer/sidecar-entry.ts",
"src/main/speech/stt-worker.ts",
"src/main/warp-themes/warp-theme-parser-worker.ts",
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
"src/main/ports/port-scan-command-worker-entry.ts",
"src/main/ipc/parcel-watcher-process-entry.ts",
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
"src/main/codex/codex-app-server-grant-entry.ts",
"src/main/agent-hooks/managed-agent-hook-controls.ts",
"src/main/claude-accounts/keychain.ts",
"src/renderer/src/main.tsx",
"src/renderer/src/popout.tsx",
"src/renderer/src/web/main.tsx",
"src/renderer/src/**/*.worker.ts",
"src/cli/index.ts",
"src/relay/relay.ts",
"src/relay/wsl-agent-hook-relay.ts",
"config/scripts/**/*.{mjs,cjs,js,ts}",
"config/build-plugins/**/*.ts",
"electron.vite.config.ts",
"vite.web.config.ts",
"config/vitest.config.ts",
"config/i18next.config.ts",
"config/electron-builder.config.cjs",
"config/oxlint-plugins/**/*.{js,mjs,ts}",
"tests/**/*.{ts,tsx,mjs}",
"**/*.test.{ts,tsx}"
],
"project": [
"src/**/*.{ts,tsx}",
"config/scripts/**/*.{mjs,cjs,js,ts}",
"config/build-plugins/**/*.ts"
],
"ignore": ["mobile/**", "out/**", "dist/**", "node_modules/**", "resources/**"],
"ignoreDependencies": ["electron", "@types/*"],
"ignoreExportsUsedInFile": true,
"includeEntryExports": false
}
+8 -3
View File
@@ -4,6 +4,9 @@ This is the pre-work artifact for migrating Orca to a localized UI. The goal is
to make coverage repeatable: every detected user-facing string is either moved
behind the localization layer or explicitly excluded with a reason.
The accepted translation-source architecture and staged migration are recorded
in [`i18n-translation-source.md`](./i18n-translation-source.md).
## Coverage Contract
Coverage means all strings matching the audit scope below are accounted for:
@@ -75,9 +78,11 @@ have no package-script entry points. Ordinary product and localization work must
not invoke tools that can overwrite an entire target catalog.
The coverage gate compares current candidates against
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
new candidates fail the check and must be localized or added with a reviewed
reason in the same change.
`config/localization-coverage-allowlist.json`. The committed allowlist is
small (10 reviewed entries — one test fixture title, five non-English
language-name search keywords, and four reviewed product-name search
keywords): new candidates fail the check and must be localized or added with
a reviewed reason in the same change.
The script scans `src/renderer/src` by default. That is the primary UI surface.
Use `--source-root src` for a wider audit when checking renderer-adjacent shared
-1
View File
@@ -2,7 +2,6 @@
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
inline mobile/src/constants/marine-creatures.ts
inline src/cli/handlers/automations.ts
inline src/cli/handlers/orchestration.ts
inline src/cli/help.ts
+71 -20
View File
@@ -44,6 +44,14 @@ const PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM = {
linux: 'watcher-linux',
win32: 'watcher-win32'
}
const ELECTRON_ARCHITECTURE_BY_ENUM = {
0: 'ia32',
1: 'x64',
2: 'arm',
3: 'arm64',
4: 'universal'
}
const PACKAGED_NATIVE_ARCHITECTURES = new Set(['ia32', 'x64', 'arm', 'arm64'])
const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/
const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/
@@ -170,7 +178,7 @@ function collectPackagedRuntimePackages(electronPlatformName = process.platform)
// optionalDependency (e.g. @parcel/watcher-linux-x64-glibc) that the
// dependencies graph above never reaches. Include the ones installed for the
// build's supported architectures; afterPack pruning trims non-target
// platforms. Without this the packaged main bundle's import of
// platform/architecture variants. Without this the packaged main bundle's import of
// '@parcel/watcher' resolves at runtime but throws loading its binary.
const parcelWatcherDir = packages.get('@parcel/watcher')
if (parcelWatcherDir) {
@@ -246,13 +254,44 @@ function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/a
}
function normalizeNodePtyWindowsArch(electronArch) {
if (electronArch === 'x64' || electronArch === 1) {
return 'x64'
const architecture = normalizeElectronArchitecture(electronArch)
if (architecture !== 'x64' && architecture !== 'arm64') {
throw new Error(`Unsupported packaged node-pty Windows architecture: ${architecture}`)
}
if (electronArch === 'arm64' || electronArch === 3) {
return 'arm64'
return architecture
}
function normalizeElectronArchitecture(electronArch) {
const architecture =
typeof electronArch === 'number'
? ELECTRON_ARCHITECTURE_BY_ENUM[electronArch]
: electronArch === 'armv7l'
? 'arm'
: electronArch
if (!PACKAGED_NATIVE_ARCHITECTURES.has(architecture)) {
throw new Error(`Unsupported packaged runtime architecture: ${String(electronArch)}`)
}
return architecture
}
function pruneNodePtyNativeDirectories(directory, platformPrefix, electronArch, allowsSuffix) {
if (!existsSync(directory)) {
return
}
const architecture = normalizeElectronArchitecture(electronArch)
const targetPrefix = `${platformPrefix}${architecture}`
const platformPrefixes = Object.values(NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM)
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (!entry.isDirectory() || !platformPrefixes.some((prefix) => entry.name.startsWith(prefix))) {
continue
}
const matchesTarget =
entry.name.startsWith(platformPrefix) &&
(entry.name === targetPrefix || (allowsSuffix && entry.name.startsWith(`${targetPrefix}-`)))
if (!matchesTarget) {
rmSync(join(directory, entry.name), { recursive: true, force: true })
}
}
return process.arch === 'arm64' ? 'arm64' : 'x64'
}
function findNodePtyConptySourceDir(nodePtyDir, windowsArch) {
@@ -308,14 +347,19 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
if (allowedPrebuildPrefix) {
const prebuildsDir = join(nodePtyDir, 'prebuilds')
if (existsSync(prebuildsDir)) {
for (const entry of readdirSync(prebuildsDir, { withFileTypes: true })) {
if (entry.isDirectory() && !entry.name.startsWith(allowedPrebuildPrefix)) {
rmSync(join(prebuildsDir, entry.name), { recursive: true, force: true })
}
}
}
pruneNodePtyNativeDirectories(
join(nodePtyDir, 'prebuilds'),
allowedPrebuildPrefix,
electronArch,
false
)
// Why: sequential cross-arch rebuilds accumulate ABI-tagged outputs here.
pruneNodePtyNativeDirectories(
join(nodePtyDir, 'bin'),
allowedPrebuildPrefix,
electronArch,
true
)
}
if (electronPlatformName === 'win32') {
@@ -328,7 +372,7 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
}
}
function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
function prunePackagedParcelWatcher(resourcesDir, electronPlatformName, electronArch) {
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
if (!existsSync(parcelDir)) {
return
@@ -336,9 +380,11 @@ function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
// Why: we package every installed @parcel/watcher-<platform> optional
// subpackage (supportedArchitectures fetches all), but each build only needs
// its own platform's binary. Keep the core package and the matching platform
// subpackages; drop the rest so a Linux serve doesn't ship macOS/Windows .node.
// its own platform/architecture binaries. Keep the core package and matching
// native variants; drop the rest.
const keepPrefix = PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM[electronPlatformName]
const architecture = normalizeElectronArchitecture(electronArch)
const targetPrefix = keepPrefix ? `${keepPrefix}-${architecture}` : null
for (const entry of readdirSync(parcelDir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === 'watcher') {
continue
@@ -348,7 +394,11 @@ function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
if (!entry.name.startsWith('watcher-')) {
continue
}
if (keepPrefix && entry.name.startsWith(keepPrefix)) {
if (
keepPrefix &&
entry.name.startsWith(keepPrefix) &&
(entry.name === targetPrefix || entry.name.startsWith(`${targetPrefix}-`))
) {
continue
}
rmSync(join(parcelDir, entry.name), { recursive: true, force: true })
@@ -395,8 +445,9 @@ function prunePackagedZodSources(resourcesDir) {
}
function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName, electronArch) {
prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
prunePackagedParcelWatcher(resourcesDir, electronPlatformName)
const architecture = normalizeElectronArchitecture(electronArch)
prunePackagedNodePty(resourcesDir, electronPlatformName, architecture)
prunePackagedParcelWatcher(resourcesDir, electronPlatformName, architecture)
prunePackagedRuntimeTypeDeclarations(resourcesDir)
prunePackagedSherpaOnnx(resourcesDir, electronPlatformName)
prunePackagedZodSources(resourcesDir)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** Long enough to name a feature, short enough that a picker row stays readable. */
export const ADHOC_LABEL_MAX_LENGTH = 32
/**
* `1.4.160-adhoc.20260728140533` — UTC to the second, so tags sort
* chronologically by semver and every build is uniquely versioned.
*
* Why seconds when hourly uses minutes: hourly runs under a concurrency group and
* cannot overlap itself. Adhoc builds are dispatched on demand, so two people
* cutting from different branches in the same minute is ordinary — and a
* minute-resolution tag would collide and fail the second build after its whole
* pack-and-notarize run.
*/
export function createAdhocBuildVersion(baseVersion, date) {
const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion)
if (!match) {
throw new Error(`Package version is not valid semver: ${baseVersion}`)
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Adhoc build timestamp is invalid.')
}
const pad = (value, width = 2) => String(value).padStart(width, '0')
const stamp = [
pad(date.getUTCFullYear(), 4),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes()),
pad(date.getUTCSeconds())
].join('')
// Why: drop any -rc.N tail, same as hourly. Keeping it would make every adhoc
// build semver-NEWER than the RC it was cut from, letting an ordinary
// RC-channel check offer an unreviewed branch build to RC users. Stripping to
// the base parks adhoc below rc.N, hourly, and stable ('adhoc' sorts first
// alphabetically), reachable only by an explicit pinned jump.
return `${match[1]}-adhoc.${stamp}`
}
/**
* Turns a dispatch input into a label safe to put in a release title.
*
* The input is free text from whoever ran the workflow, so it cannot be trusted
* to stay inside the title's shape: a stray `•` would forge a field separator,
* and a newline would break the `$GITHUB_OUTPUT` line the workflow parses. Both
* collapse to `-` here, which is why this replaces rather than rejects.
*/
export function normalizeAdhocLabel(label) {
const cleaned = String(label ?? '')
// `refs/heads/x` and `origin/x` are what a ref input tends to arrive as; the
// prefix is noise in a title where every row is already a branch build.
.replace(/^(?:refs\/heads\/|origin\/)/, '')
.replace(/[^\p{L}\p{N}._/-]+/gu, ' ')
.trim()
.replace(/\s+/g, '-')
.slice(0, ADHOC_LABEL_MAX_LENGTH)
// Truncation can land mid-separator, leaving a title ending in `-` or `/`.
.replace(/[-._/]+$/, '')
if (!cleaned) {
throw new Error(`Adhoc label has no usable characters: ${JSON.stringify(label)}`)
}
return cleaned
}
/**
* `1.4.163 • wasm-terminal • Aug 1, 2:25PM • abc1234` — the human-facing release
* title, shown verbatim in both the GitHub releases list and the build picker.
*
* Why the label sits where hourly puts its build number: several adhoc builds
* from different branches coexist in the channel, so the picker needs the branch
* to tell them apart. A counter would say nothing about which one to pick.
*/
export function formatAdhocReleaseName(version, label, commit, date) {
return [
version.split('-')[0],
normalizeAdhocLabel(label),
formatReleaseTitleTimestamp(date),
commit.slice(0, 7)
].join(' • ')
}
export function getAdhocBuildIdentity(now = new Date(), label = '', publishedVersions = []) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions)
const version = createAdhocBuildVersion(base, now)
return {
commit,
version,
label: normalizeAdhocLabel(label),
name: formatAdhocReleaseName(version, label, commit, now)
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getAdhocBuildIdentity(
new Date(),
process.env.ORCA_ADHOC_LABEL ?? '',
readPublishedVersionsFromEnv()
)
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n`
)
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import {
createAdhocBuildVersion,
formatAdhocReleaseName,
normalizeAdhocLabel
} from './adhoc-build-version.mjs'
import { createHourlyBuildVersion } from './hourly-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createAdhocBuildVersion', () => {
it('stamps the version with a zero-padded UTC timestamp to the second', () => {
expect(createAdhocBuildVersion('1.4.160', new Date('2026-07-28T04:05:09Z'))).toBe(
'1.4.160-adhoc.20260728040509'
)
})
// Why seconds matter: adhoc builds are dispatched on demand, so two people
// cutting from different branches inside the same minute is ordinary. At
// hourly's resolution the second one would collide on the tag and die after its
// whole pack-and-notarize run.
it('distinguishes two builds cut in the same minute', () => {
const first = createAdhocBuildVersion('1.4.160', new Date('2026-07-28T14:05:02Z'))
const second = createAdhocBuildVersion('1.4.160', new Date('2026-07-28T14:05:41Z'))
expect(first).not.toBe(second)
expect(compareAppVersions(first, second)).toBeLessThan(0)
})
it('drops an in-flight rc tail so adhoc builds never outrank the rc series', () => {
const version = createAdhocBuildVersion('1.4.160-rc.3', new Date('2026-07-28T14:00:00Z'))
expect(version).toBe('1.4.160-adhoc.20260728140000')
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
expect(compareAppVersions(version, '1.4.160')).toBeLessThan(0)
})
// Why this ordering is load-bearing: an adhoc build is somebody's unlanded
// branch. It must sit below every other channel of the same base version so no
// routine check can walk a developer onto one — only an explicit pinned jump.
it('sorts below the hourly build of the same base version', () => {
expect(
compareAppVersions(
createAdhocBuildVersion('1.4.160', new Date('2026-07-28T23:59:59Z')),
createHourlyBuildVersion('1.4.160', new Date('2026-07-28T00:00:00Z'))
)
).toBeLessThan(0)
})
it('rejects invalid input', () => {
expect(() => createAdhocBuildVersion('nope', new Date())).toThrow(/valid semver/)
expect(() => createAdhocBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
})
})
describe('normalizeAdhocLabel', () => {
it('keeps an ordinary branch name intact', () => {
expect(normalizeAdhocLabel('wasm-terminal')).toBe('wasm-terminal')
expect(normalizeAdhocLabel('nwparker/wasm-terminal')).toBe('nwparker/wasm-terminal')
})
it('strips the ref prefixes a dispatch input tends to arrive with', () => {
expect(normalizeAdhocLabel('refs/heads/wasm-terminal')).toBe('wasm-terminal')
expect(normalizeAdhocLabel('origin/wasm-terminal')).toBe('wasm-terminal')
})
// Why replaced rather than rejected: the label is free text from whoever ran
// the workflow. A `•` would forge the title's field separator and a newline
// would break the `$GITHUB_OUTPUT` line the workflow parses.
it('neutralizes characters that would corrupt the title or the output line', () => {
expect(normalizeAdhocLabel('a • b')).toBe('a-b')
expect(normalizeAdhocLabel('a\nname=evil')).toBe('a-name-evil')
expect(normalizeAdhocLabel(' spaced out ')).toBe('spaced-out')
})
it('truncates without leaving a trailing separator', () => {
expect(normalizeAdhocLabel('a'.repeat(80))).toHaveLength(32)
expect(normalizeAdhocLabel(`${'a'.repeat(31)}-tail`)).toBe('a'.repeat(31))
})
it('rejects a label with nothing usable in it', () => {
expect(() => normalizeAdhocLabel('')).toThrow(/no usable characters/)
expect(() => normalizeAdhocLabel(' ')).toThrow(/no usable characters/)
expect(() => normalizeAdhocLabel('•••')).toThrow(/no usable characters/)
expect(() => normalizeAdhocLabel(null)).toThrow(/no usable characters/)
})
})
describe('formatAdhocReleaseName', () => {
const name = (iso, label = 'wasm-terminal', commit = 'e698241abcde') =>
formatAdhocReleaseName('1.4.163-adhoc.x', label, commit, new Date(iso))
it('renders version, label, Pacific timestamp, and short sha', () => {
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • Jul 31, 1:54PM • e698241')
})
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
// the offset between them is not a constant. A test pinned to one season would
// pass all summer and start failing in November.
it('follows the Pacific offset across DST', () => {
expect(name('2026-01-15T02:30:00Z')).toContain(' Jan 14, 6:30PM ')
expect(name('2026-07-31T07:00:00Z')).toContain(' Jul 31, 12:00AM ')
})
it('sanitizes the label before it reaches the title', () => {
expect(name('2026-07-31T20:54:00Z', 'refs/heads/fix • now')).toBe(
'1.4.163 • fix-now • Jul 31, 1:54PM • e698241'
)
})
it('rejects an invalid timestamp', () => {
expect(() => formatAdhocReleaseName('1.4.163', 'x', 'abcdefg', new Date('nope'))).toThrow(
/invalid/
)
})
})
@@ -1,4 +1,12 @@
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
@@ -6,6 +14,21 @@ import { describe, expect, it } from 'vitest'
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
const WINDOWS_LOCK_CODES = ['EBUSY', 'ENOTEMPTY', 'EPERM']
// Why: Windows releases the image handle on a just-executed exe (and finishes the
// AV scan of the freshly compiled one) after the process exits, so tearing down the
// fixture races those locks. Retry, then leave the temp tree rather than reporting a
// teardown lock as a launcher failure.
function removeFixtureTree(path) {
try {
rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
} catch (error) {
if (process.platform !== 'win32' || !WINDOWS_LOCK_CODES.includes(error?.code)) {
throw error
}
}
}
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
// keep the larger allowance scoped to the real compiler integration test.
function itWindows(name, test) {
@@ -27,10 +50,24 @@ describe('Windows CLI launcher', () => {
expect(result.stderr).toContain('Windows CLI launcher')
expect(result.stderr).toContain('Windows host')
} finally {
rmSync(outputRoot, { recursive: true, force: true })
removeFixtureTree(outputRoot)
}
})
itCrossHost('never materializes the child environment block from ProcessStartInfo', () => {
// Why: both ProcessStartInfo env properties copy the process block into a case-insensitive
// dictionary that throws when the inherited block holds PATH and Path (stablyai/orca#12046).
const source = readFileSync(
join(projectRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs'),
'utf8'
)
const code = source.replace(/^\s*\/\/.*$/gm, '')
expect(code).not.toContain('EnvironmentVariables')
expect(code).not.toContain('startInfo.Environment')
expect(code).toContain('Environment.SetEnvironmentVariable')
})
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
try {
@@ -86,7 +123,73 @@ describe('Windows CLI launcher', () => {
orcaNodeOptions: '--no-warnings'
})
} finally {
rmSync(appRoot, { recursive: true, force: true })
removeFixtureTree(appRoot)
}
})
itWindows('survives an inherited environment block containing PATH and Path', () => {
const appRoot = mkdtempSync(join(tmpdir(), 'orca duplicate path launcher '))
try {
const resourcesPath = join(appRoot, 'resources')
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
const outputPath = join(appRoot, 'child-result.json')
const harnessSourcePath = join(
projectRoot,
'config',
'scripts',
'fixtures',
'DuplicatePathProcessLauncher.cs'
)
const harnessPath = join(appRoot, 'DuplicatePathLauncher.exe')
mkdirSync(dirname(launcherPath), { recursive: true })
mkdirSync(dirname(cliPath), { recursive: true })
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
writeFileSync(
cliPath,
`require('node:fs').writeFileSync(process.env.ORCA_TEST_OUTPUT, JSON.stringify({
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
pathKeys: Object.keys(process.env).filter((key) => key.toLowerCase() === 'path')
}))\n`,
'utf8'
)
const build = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
const compiler = findFrameworkCompiler()
expect(compiler).not.toBeNull()
const compileHarness = spawnSync(
compiler,
['/nologo', '/target:exe', `/out:${harnessPath}`, harnessSourcePath],
{ encoding: 'utf8' }
)
expect(compileHarness.status, `${compileHarness.stdout}\n${compileHarness.stderr}`).toBe(0)
const launch = spawnSync(harnessPath, [launcherPath, outputPath], { encoding: 'utf8' })
expect(launch.status, `${launch.stdout}\n${launch.stderr}`).toBe(0)
expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({
electronRunAsNode: '1',
pathKeys: ['PATH', 'Path']
})
} finally {
removeFixtureTree(appRoot)
}
})
})
function findFrameworkCompiler() {
const windowsDirectory = process.env.WINDIR ?? process.env.SystemRoot
if (!windowsDirectory) {
return null
}
return (
[
join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe')
].find((candidate) => existsSync(candidate)) ?? null
)
}
@@ -0,0 +1,99 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const guardScript = join(projectDir, '.github/scripts/check-root-directory-entries.sh')
const tempDirs = []
function git(cwd, args) {
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim()
}
function makeFixture() {
const root = mkdtempSync(join(tmpdir(), 'orca-root-directory-guard-'))
tempDirs.push(root)
git(root, ['init', '--quiet'])
git(root, ['config', 'user.email', 'root-directory-guard-test@example.com'])
git(root, ['config', 'user.name', 'Root Directory Guard Test'])
mkdirSync(join(root, 'config'), { recursive: true })
writeFileSync(join(root, 'config', 'base.txt'), 'base\n')
git(root, ['add', '-A'])
git(root, ['commit', '--quiet', '-m', 'base'])
return { root, base: git(root, ['rev-parse', 'HEAD']) }
}
function commitFiles(root, files) {
for (const [relativePath, contents] of files) {
const target = join(root, relativePath)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, contents)
}
git(root, ['add', '-A'])
git(root, ['commit', '--quiet', '-m', 'head'])
return git(root, ['rev-parse', 'HEAD'])
}
function runGuard({ root, base, head }) {
return spawnSync('bash', [guardScript, base, head], {
cwd: root,
encoding: 'utf8'
})
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('root directory guard', () => {
it('allows additions inside an existing top-level directory', () => {
const fixture = makeFixture()
const head = commitFiles(fixture.root, [['config/new.txt', 'nested\n']])
const result = runGuard({ ...fixture, head })
expect(result.status).toBe(0)
expect(result.stdout).toContain('no new root-level files or folders')
})
it('rejects a new root-level file with the landing-page message', () => {
const fixture = makeFixture()
const head = commitFiles(fixture.root, [['new-root.md', 'too prominent\n']])
const result = runGuard({ ...fixture, head })
const output = `${result.stdout}\n${result.stderr}`
expect(result.status).toBe(1)
expect(output).toContain('bloat the GitHub landing page')
expect(output).toContain('new-root.md')
})
it('rejects a new top-level directory', () => {
const fixture = makeFixture()
const head = commitFiles(fixture.root, [['new-folder/file.txt', 'too prominent\n']])
const result = runGuard({ ...fixture, head })
const output = `${result.stdout}\n${result.stderr}`
expect(result.status).toBe(1)
expect(output).toContain('new-folder')
})
it('is wired into the PR verify gate', () => {
const workflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const guardJob = workflow.jobs.root_directory_guard
const guardStep = guardJob.steps.find(
(step) => step.name === 'Reject new root-level files and folders'
)
expect(guardJob.name).toBe('root directory guard')
expect(guardJob.steps[0].with['fetch-depth']).toBe(0)
expect(guardStep.run).toContain('.github/scripts/check-root-directory-entries.sh')
expect(workflow.jobs.verify.needs).toContain('root_directory_guard')
})
})
+68 -12
View File
@@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => {
expect(triggerPaths).toEqual(
expect.arrayContaining([
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
'config/scripts/computer-use-skill-guidance.test.mjs',
'config/scripts/computer-use-smoke.mjs',
@@ -70,9 +71,14 @@ describe('computer-use e2e workflow', () => {
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const checkout = workflow.jobs['native-smoke'].steps.find(
(step) => step.uses === 'actions/checkout@v6'
)
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
const expectedRegressionFiles = [
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
'config/scripts/computer-use-skill-guidance.test.mjs',
'config/scripts/computer-use-smoke.test.mjs',
@@ -106,12 +112,68 @@ describe('computer-use e2e workflow', () => {
'src/shared/remote-runtime-client.test.ts'
]
expect(checkout.with['persist-credentials']).toBe(false)
expect(regressionRun).toBeTruthy()
for (const file of expectedRegressionFiles) {
expect(regressionRun).toContain(file)
}
})
it('builds and tests the macOS helper on pull requests without TCC e2e', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const job = workflow.jobs['mac-native-owner-smoke']
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
expect(job.if).toBe("github.event_name == 'pull_request'")
expect(job['runs-on']).toBe('macos-15')
expect(checkout.with['persist-credentials']).toBe(false)
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
const cleanupRun = runs.find((run) =>
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
)
expect(cleanupRun).toContain(
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs'
)
expect(runs).toContain('pnpm verify:computer-native')
expect(runs.join('\n')).not.toContain('test:e2e:computer')
expect(workflow.on.pull_request.paths).toEqual(
expect.arrayContaining([
'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs',
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-metrics.mjs',
'config/scripts/macos-computer-helper-owner-loss-processes.mjs',
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'
])
)
})
it('runs deterministic macOS owner-loss benchmark cleanup coverage', () => {
const benchmark = readFileSync(
join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'),
'utf8'
)
const cleanup = readFileSync(
join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'),
'utf8'
)
expect(benchmark).toContain('spawnBenchmarkProcess(executable, [launcherDir]')
expect(benchmark).toContain("stdio: ['ignore', stdoutDescriptor, stderrDescriptor]")
expect(benchmark).toContain('cleanupOwnerLossTrial({')
const parseIndex = benchmark.indexOf('parseBenchmarkTrialResult(serializedResult)')
const cleanupIndex = benchmark.indexOf('cleanupOwnerLossTrial({')
expect(parseIndex).toBeGreaterThanOrEqual(0)
expect(cleanupIndex).toBeGreaterThanOrEqual(0)
expect(parseIndex).toBeLessThan(cleanupIndex)
expect(benchmark).toContain('trialCleanupSha256: artifactSha256(trialCleanupPath)')
expect(cleanup).toContain('killRecordedAndMatchingProcesses(options.recordPath')
expect(cleanup).toContain("signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL'")
})
it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
@@ -157,26 +219,21 @@ describe('computer-use e2e workflow', () => {
'config/scripts/daemon-boot-smoke.mjs',
'config/scripts/windows-daemon-workspace-close-repro.mjs',
'electron.vite.config.ts',
'build-plugins/**',
'config/build-plugins/**',
'src/main/daemon/**'
])
)
})
it('runs Linux computer-use e2e in the PR native-smoke job under Xvfb', () => {
it('does not run computer-use e2e in PR smoke jobs', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const installRun = nativeSmokeRuns.find((run) => run.includes('apt-get install'))
expect(installRun).toContain('gedit')
expect(installRun).toContain('xvfb')
expect(nativeSmokeRuns).toContain(
'xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts'
)
expect(nativeSmokeRuns.join('\n')).not.toContain('test:e2e:computer')
})
it('builds Electron main output before every computer-use e2e run', () => {
@@ -205,7 +262,7 @@ describe('computer-use e2e workflow', () => {
}
})
it('runs core Windows computer-use e2e in the PR native-smoke job', () => {
it('keeps computer-use e2e in scheduled jobs only', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
@@ -221,9 +278,8 @@ describe('computer-use e2e workflow', () => {
.filter((run) => typeof run === 'string')
]
expect(nativeSmokeRuns).toContain(
'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts'
)
expect(nativeSmokeRuns.join('\n')).not.toContain('test:e2e:computer')
expect(allRuns.join('\n')).toContain('test:e2e:computer')
expect(allRuns.join('\n')).not.toContain('test:e2e:computer -- --reporter')
})
@@ -28,7 +28,10 @@ describe('computer-use modifier safety', () => {
)
expect(mouseInput).toContain('event.flags = flags')
expect(clickInput.match(/flags: flags/gu)).toHaveLength(3)
// Every click event flows through the shared delivery plan and carries
// the modifier flags on the mouse event itself.
expect(clickInput).toContain('SyntheticMouseClickDelivery.steps(clickCount: count)')
expect(clickInput).toContain('event.flags = flags')
expect(clickInput).not.toContain('down: true')
})
+105 -35
View File
@@ -18,7 +18,7 @@
import { fork } from 'node:child_process'
import { connect } from 'node:net'
import { randomUUID } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
@@ -54,30 +54,27 @@ function makeSocketPath(userDataDir) {
return join(userDataDir, 'daemon.sock')
}
// Best-effort end-to-end PTY check over the daemon's own control-socket RPC.
// Connects a single control socket, completes the hello handshake, and calls
// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves
// true on success, false on any failure — never throws.
function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
return new Promise((resolveCheck) => {
function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs) {
return new Promise((resolveRpc, rejectRpc) => {
let settled = false
let buffer = ''
const socket = connect(socketPath)
const finish = (ok, reason) => {
const finish = (error, response) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
socket.destroy()
if (!ok && reason) {
log(`PTY spawn health check skipped (best-effort): ${reason}`)
if (error) {
rejectRpc(error)
} else {
resolveRpc(response)
}
resolveCheck(ok)
}
const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS)
const timer = setTimeout(() => finish(new Error(`${request.type} timed out`)), timeoutMs)
socket.on('error', (err) => finish(false, err.message))
socket.on('error', (error) => finish(error))
socket.on('connect', () => {
const token = readFileSync(tokenPath, 'utf8').trim()
socket.write(
@@ -100,19 +97,19 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
try {
msg = JSON.parse(line)
} catch {
finish(false, 'invalid response line')
finish(new Error('invalid response line'))
return
}
if (msg.type === 'hello') {
if (!msg.ok) {
finish(false, `hello rejected: ${msg.error ?? 'unknown'}`)
finish(new Error(`hello rejected: ${msg.error ?? 'unknown'}`))
return
}
socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`)
} else if (msg.id === 'health-1') {
socket.write(`${JSON.stringify(request)}\n`)
} else if (msg.id === request.id) {
finish(
msg.ok === true,
msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed')
msg.ok === true ? undefined : new Error(msg.error ?? `${request.type} failed`),
msg
)
return
}
@@ -122,28 +119,61 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
})
}
// Best-effort: constrained CI runners can make node-pty spawn flaky.
async function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
try {
await runDaemonRpc(
socketPath,
tokenPath,
protocolVersion,
{ id: 'health-1', type: 'ptySpawnHealth' },
PTY_HEALTH_TIMEOUT_MS
)
return true
} catch (error) {
log(`PTY spawn health check skipped (best-effort): ${error.message}`)
return false
}
}
async function main() {
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-'))
const socketPath = makeSocketPath(userDataDir)
const tokenPath = join(userDataDir, 'daemon.token')
const pidPath = join(userDataDir, 'daemon.pid')
const launchNonce = randomUUID()
const protocolVersion = readProtocolVersion()
log(`forking ${entryPath} under plain Node (${process.execPath})`)
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
// CI, and this is exactly the runtime where a leaked `require("electron")`
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
})
const child = fork(
entryPath,
[
'--socket',
socketPath,
'--token',
tokenPath,
'--pid-record',
pidPath,
'--launch-nonce',
launchNonce,
'--entry-path',
entryPath,
'--app-version',
'daemon-boot-smoke'
],
{
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
// CI, and this is exactly the runtime where a leaked `require("electron")`
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
}
)
let stderr = ''
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString('utf8')
})
child.stdout?.on('data', (chunk) => {
process.stdout.write(chunk)
})
const cleanup = () => {
if (child.exitCode === null && child.signalCode === null && child.pid) {
@@ -185,6 +215,25 @@ async function main() {
})
})
log('daemon signaled ready')
const pidRecord = JSON.parse(readFileSync(pidPath, 'utf8'))
if (
pidRecord.pid !== child.pid ||
pidRecord.launchNonce !== launchNonce ||
pidRecord.entryPath !== entryPath ||
pidRecord.appVersion !== 'daemon-boot-smoke'
) {
throw new Error('daemon readiness did not publish the expected PID ownership record')
}
log('PID ownership record matches the ready daemon')
if (!existsSync(socketPath)) {
throw new Error('daemon did not publish its endpoint at the canonical socket path')
}
log('endpoint published at the canonical socket path')
// Production releases startup-only handles after ready; they can pin the child on Windows.
// Diagnostics past this point therefore carry the tail captured up to readiness only.
child.stderr?.destroy()
stderr += '[boot-smoke] stderr released at readiness, mirroring production\n'
child.disconnect()
const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion)
if (ptyHealthy) {
@@ -193,18 +242,39 @@ async function main() {
await new Promise((resolveExit, rejectExit) => {
const timer = setTimeout(() => {
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`))
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of shutdown RPC`))
}, SHUTDOWN_TIMEOUT_MS)
child.on('exit', (code, signal) => {
clearTimeout(timer)
log(`daemon exited after signal (code=${code}, signal=${signal})`)
log(`daemon exited after shutdown RPC (code=${code}, signal=${signal})`)
resolveExit()
})
// Why: SIGTERM is the graceful stop on POSIX (the daemon handles it);
// Windows has no POSIX signal delivery, so Node maps this to process
// termination. Either way the hard assertion is "it stops, no hang".
child.kill('SIGTERM')
void runDaemonRpc(
socketPath,
tokenPath,
protocolVersion,
{
id: 'shutdown-1',
type: 'shutdown',
payload: { killSessions: false }
},
SHUTDOWN_TIMEOUT_MS
).catch((error) => {
clearTimeout(timer)
rejectExit(error)
})
})
if (existsSync(pidPath)) {
throw new Error('daemon left its PID ownership record behind after shutdown')
}
if (process.platform !== 'win32' && existsSync(socketPath)) {
throw new Error('daemon left its endpoint socket behind after shutdown')
}
// The private bind name is consumed by the publish; nothing may linger in the runtime dir.
const leaked = readdirSync(userDataDir).filter((entry) => entry.startsWith('.b'))
if (leaked.length > 0) {
throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`)
}
log('PASS: daemon booted, served, and shut down under plain Node')
} finally {
@@ -0,0 +1,164 @@
/**
* Endpoint handover smoke — guards the split-brain failure with real daemon processes.
*
* The failure it reproduces: a daemon whose endpoint name is reclaimed while it is still
* alive used to delete the *replacement's* socket when it finally exited, because libuv
* unlinks the pathname a server bound to with no ownership check. The replacement stayed
* alive hosting PTYs that nothing could reach — terminals that acknowledge input and never
* run it, and that a user cannot fix by restarting the app.
*
* Unix only: Windows named pipes are not directory entries, so the mechanism cannot occur.
*
* Usage: node config/scripts/daemon-endpoint-handover-smoke.mjs
*/
import { fork } from 'node:child_process'
import { connect } from 'node:net'
import { randomUUID } from 'node:crypto'
import { existsSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const repoRoot = resolve(import.meta.dirname, '..', '..')
const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js')
const READY_TIMEOUT_MS = 20_000
const EXIT_TIMEOUT_MS = 15_000
const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`)
function bootDaemon(tag, dir, socketPath) {
const tokenPath = join(dir, `${tag}.token`)
const pidPath = join(dir, `${tag}.pid`)
const child = fork(
entryPath,
[
'--socket',
socketPath,
'--token',
tokenPath,
'--pid-record',
pidPath,
'--launch-nonce',
randomUUID(),
'--entry-path',
entryPath,
'--app-version',
'endpoint-handover-smoke'
],
{
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
env: { ...process.env, ORCA_USER_DATA_PATH: dir }
}
)
let stderr = ''
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString('utf8')
})
return new Promise((resolveReady, rejectReady) => {
const timer = setTimeout(
() => rejectReady(new Error(`daemon ${tag} never signaled ready.\nstderr:\n${stderr}`)),
READY_TIMEOUT_MS
)
child.on('message', (msg) => {
if (msg && typeof msg === 'object' && msg.type === 'ready') {
clearTimeout(timer)
resolveReady({ child, tokenPath, pidPath })
}
})
child.on('exit', (code) => {
clearTimeout(timer)
rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`))
})
})
}
function isReachable(socketPath) {
return new Promise((resolveReachable) => {
const socket = connect({ path: socketPath })
socket.on('connect', () => {
socket.destroy()
resolveReachable(true)
})
socket.on('error', () => resolveReachable(false))
})
}
function killAndWait(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve()
}
return new Promise((resolveExit, rejectExit) => {
const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS)
child.on('exit', () => {
clearTimeout(timer)
resolveExit()
})
child.kill('SIGTERM')
})
}
async function main() {
if (process.platform === 'win32') {
log('SKIP: named pipes are not filesystem entries, so endpoint handover cannot occur')
return
}
if (!existsSync(entryPath)) {
throw new Error(`missing ${entryPath} — run \`pnpm build\` first`)
}
const dir = mkdtempSync(join(tmpdir(), 'orca-endpoint-handover-'))
const socketPath = join(dir, 'daemon.sock')
let replaced
let replacement
try {
replaced = await bootDaemon('replaced', dir, socketPath)
const replacedInode = statSync(socketPath).ino
log('daemon A published the endpoint')
// Reclaim the endpoint name the way daemon replacement does, while A is still alive.
unlinkSync(socketPath)
replacement = await bootDaemon('replacement', dir, socketPath)
const replacementInode = statSync(socketPath).ino
if (replacedInode === replacementInode) {
throw new Error('daemon B did not publish a distinct endpoint')
}
if (!(await isReachable(socketPath))) {
throw new Error('daemon B is not reachable through the canonical endpoint')
}
log('daemon B took over the endpoint and is reachable')
// A exits long after losing the endpoint. This is the step that used to break B.
await killAndWait(replaced.child)
await new Promise((r) => setTimeout(r, 300))
if (!existsSync(socketPath) || statSync(socketPath).ino !== replacementInode) {
throw new Error("daemon A's late exit deleted daemon B's endpoint")
}
if (!(await isReachable(socketPath))) {
throw new Error('daemon B became unreachable after daemon A exited')
}
if (!existsSync(replacement.pidPath)) {
throw new Error("daemon A's late exit removed daemon B's ownership record")
}
if (existsSync(replaced.pidPath)) {
throw new Error('daemon A left its own ownership record behind')
}
log('PASS: the endpoint owner and the session host stayed the same daemon')
} finally {
for (const daemon of [replaced, replacement]) {
if (daemon && daemon.child.exitCode === null && daemon.child.signalCode === null) {
try {
daemon.child.kill('SIGKILL')
} catch {
// already gone
}
}
}
rmSync(dir, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(`[endpoint-handover-smoke] FAIL: ${error.message}`)
process.exit(1)
})
@@ -0,0 +1,66 @@
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
function parseVersionTriple(value) {
const match = SEMVER.exec(String(value ?? '').trim())
if (!match) {
return null
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] ?? null
}
}
function compareTriples(a, b) {
return a.major - b.major || a.minor - b.minor || a.patch - b.patch
}
/**
* The base `X.Y.Z` an hourly or adhoc build should carry.
*
* Why not package.json alone: main's version only moves on `release:` commits, and
* stable patches are cut from release branches that never merge back. On
* 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and
* 1.4.167 all shipped — so hourlies built from that main claimed 1.4.165 while
* carrying code newer than 1.4.167, and sorted *below* the stable their user was
* already running. Published tags are the only honest answer to "what number is
* taken"; package.json is a floor, not a source of truth.
*/
export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) {
const fromPackage = parseVersionTriple(packageVersion)
if (!fromPackage) {
throw new Error(`Package version is not valid semver: ${packageVersion}`)
}
// Unparseable tags are skipped rather than fatal: the main repo carries old tags
// that predate the current scheme, and one of them must not fail every build.
const published = publishedVersions.map(parseVersionTriple).filter(Boolean)
let base = fromPackage
if (published.length > 0) {
const highest = published.reduce((best, entry) =>
compareTriples(entry, best) > 0 ? entry : best
)
// A shipped stable owns its number, so the next dev build belongs on the patch
// above it. A bare prerelease does not — rc.1 of 1.4.168 means 1.4.168 is still
// the version being worked toward, which is exactly what main is building.
const shipped = published.some(
(entry) => !entry.prerelease && compareTriples(entry, highest) === 0
)
const next = shipped ? { ...highest, patch: highest.patch + 1 } : highest
if (compareTriples(next, base) > 0) {
base = next
}
}
return `${base.major}.${base.minor}.${base.patch}`
}
/** Tag list the workflow reads out of the main repo, newline separated. */
export function readPublishedVersionsFromEnv(value = process.env.ORCA_PUBLISHED_VERSIONS) {
return String(value ?? '')
.split(/\s+/)
.filter(Boolean)
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
describe('dev channel base version', () => {
it('falls back to package.json when no tags are supplied', () => {
expect(resolveDevChannelBaseVersion('1.4.168-rc.1')).toBe('1.4.168')
expect(resolveDevChannelBaseVersion('1.4.168', [])).toBe('1.4.168')
})
// The bug this exists for: main sat at 1.4.165-rc.0 for twenty hours while three
// stables shipped, so hourlies claimed a version their users had already passed.
it('climbs past stables that shipped while main stood still', () => {
expect(
resolveDevChannelBaseVersion('1.4.165-rc.0', [
'v1.4.165',
'v1.4.166',
'v1.4.167',
'v1.4.165-rc.0'
])
).toBe('1.4.168')
})
// Why +1 on a stable but not on a prerelease: 1.4.167 is spent, so the next dev
// build is 1.4.168. But 1.4.168-rc.1 means 1.4.168 is still being built toward,
// which is what main holds — claiming 1.4.169 would jump a release nobody cut.
it('takes the patch above a shipped stable and holds at an open prerelease', () => {
expect(resolveDevChannelBaseVersion('1.4.167', ['v1.4.167'])).toBe('1.4.168')
expect(resolveDevChannelBaseVersion('1.4.168-rc.1', ['v1.4.168-rc.1'])).toBe('1.4.168')
})
// Why max and not most-recent: a hotfix on an old line published today would
// otherwise drag every subsequent hourly backwards.
it('reads the highest tag, not the last one listed', () => {
expect(resolveDevChannelBaseVersion('1.4.160', ['v1.4.167', 'v1.3.99', 'v1.4.120'])).toBe(
'1.4.168'
)
})
it('treats package.json as a floor when it leads the tags', () => {
expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0')
})
// Why skipped rather than fatal: the main repo carries legacy tags, and one
// unparseable entry must not fail every hourly build.
it('ignores tags it cannot parse', () => {
expect(resolveDevChannelBaseVersion('1.4.160', ['nightly', '', 'v1.4.167', 'latest'])).toBe(
'1.4.168'
)
})
it('rejects a package version that is not semver', () => {
expect(() => resolveDevChannelBaseVersion('not-a-version')).toThrow(/not valid semver/)
})
it('carries major and minor rollovers through the bump', () => {
expect(resolveDevChannelBaseVersion('1.4.0', ['v2.0.0'])).toBe('2.0.1')
})
it('splits an env tag list on any whitespace', () => {
expect(readPublishedVersionsFromEnv('v1.4.167\nv1.4.166\n')).toEqual(['v1.4.167', 'v1.4.166'])
expect(readPublishedVersionsFromEnv('')).toEqual([])
expect(readPublishedVersionsFromEnv(undefined)).toEqual([])
})
})
+215 -35
View File
@@ -19,6 +19,42 @@ const {
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
const MUTABLE_BUILD_ENV = [
'ORCA_MAC_HOURLY',
'ORCA_MAC_ADHOC',
'ORCA_MAC_RELEASE',
'ORCA_HOURLY_BUILD_VERSION',
'ORCA_ADHOC_BUILD_VERSION',
'ORCA_LOCAL_BUILD_VERSION'
]
/** Re-requires the config under a temporary env, then restores env and module cache. */
function withEnv(env, assert) {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
try {
for (const key of MUTABLE_BUILD_ENV) {
delete process.env[key]
}
Object.assign(process.env, env)
delete require.cache[configPath]
assert(require('../electron-builder.config.cjs'))
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
}
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert)
describe('electron-builder config', () => {
it('keeps the packaged app identity aligned with local-build validation', () => {
expect(electronBuilderConfig.appId).toBe(
@@ -158,11 +194,7 @@ describe('electron-builder config', () => {
'out/package.json',
'out/cli/**',
'out/shared/**',
'out/main/claude-accounts/keychain.js',
'out/main/codex-cli/command.js',
'out/main/ipc/local-agent-install-dir-detection.js',
'out/main/ipc/tui-agent-detection-commands.js',
'out/main/win32-utils.js'
'out/main/claude-accounts/keychain.js'
])
)
})
@@ -175,11 +207,9 @@ describe('electron-builder config', () => {
)
})
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
it('unpacks the forked main-thread hang-watchdog entry', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
it('keeps the worker-thread hang watchdog inside app.asar', () => {
expect(electronBuilderConfig.asarUnpack).not.toContain(
'out/main/main-thread-hang-watchdog-entry.js'
)
})
@@ -273,6 +303,89 @@ describe('electron-builder config', () => {
}
})
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
// same bundle id and a valid Developer ID signature. A hourly built on the local
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
// Orca — the whole point of the channel.
it('builds hourly artifacts with the release signing identity', () => {
withHourlyEnv((config) => {
expect(config.mac.appId).toBeUndefined()
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.forceCodeSigning).toBe(true)
})
})
// Why hourly must notarize despite the round trip: TCC anchors a notarized
// Developer ID app's grants on identifier + team, not on its cdhash, so they
// survive an update. An unnotarized hourly reads as a new client every build
// and loses file access under Documents/Desktop/Downloads with no re-prompt.
it('notarizes hourly builds like releases, and neither locally', () => {
withHourlyEnv((config) => {
expect(config.mac.notarize).toBe(true)
})
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
expect(config.mac.notarize).toBe(true)
})
expect(electronBuilderConfig.mac.notarize).toBe(false)
})
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
// break update checks for every real user.
it('publishes hourly builds to the separate hourly repo', () => {
withHourlyEnv((config) => {
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
})
expect(electronBuilderConfig.publish).toMatchObject({
repo: 'orca',
releaseType: 'release'
})
})
it('stamps hourly packages with the hourly version', () => {
withEnv(
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
}
)
})
// Why adhoc carries the identical mac identity to hourly: it installs over a
// real Orca through the same updater path, so the same signing and the same TCC
// argument apply. Only the destination repo differs.
it('builds adhoc artifacts with the release identity and its own repo', () => {
withAdhocEnv((config) => {
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.mac.notarize).toBe(true)
expect(config.forceCodeSigning).toBe(true)
expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' })
})
})
it('stamps adhoc packages with the adhoc version', () => {
withEnv(
{ ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' })
}
)
})
// Why: the two dev channels share every packaging decision except where they
// publish, so a future edit that collapses them must not also collapse the
// repos — a branch build landing in orca-hourly would be offered to everyone
// riding main.
it('keeps the two dev channels on separate repos', () => {
withHourlyEnv((hourly) => {
withAdhocEnv((adhoc) => {
expect(hourly.publish.repo).not.toBe(adhoc.publish.repo)
})
})
})
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)
@@ -307,40 +420,42 @@ describe('electron-builder config', () => {
expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js')
})
it('prunes non-target node-pty prebuilds from packaged runtime resources', async () => {
it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
try {
const prebuildsDir = join(resourcesDir, 'node_modules', 'node-pty', 'prebuilds')
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
const prebuildsDir = join(nodePtyDir, 'prebuilds')
const binDir = join(nodePtyDir, 'bin')
await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true })
await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party', 'conpty'), {
recursive: true
})
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps', 'winpty'), {
await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true })
await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true })
await mkdir(join(nodePtyDir, 'third_party', 'conpty'), {
recursive: true
})
await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true })
prunePackagedNodePty(resourcesDir, 'darwin')
prunePackagedNodePty(resourcesDir, 'darwin', 3)
await expect(readdir(prebuildsDir).then((entries) => entries.sort())).resolves.toEqual([
'darwin-arm64',
'darwin-x64'
])
await expect(
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party'))
).resolves.toEqual([])
await expect(
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps'))
).resolves.toEqual([])
await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64'])
await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148'])
await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([])
await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([])
expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow(
'Unsupported packaged runtime architecture: 4'
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => {
for (const arch of ['x64', 'arm64']) {
for (const [arch, electronArch] of [
['x64', 1],
['arm64', 3]
]) {
const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`))
try {
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
@@ -359,7 +474,7 @@ describe('electron-builder config', () => {
)
}
prunePackagedNodePty(resourcesDir, 'win32', arch)
prunePackagedNodePty(resourcesDir, 'win32', electronArch)
await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe(
`dll payload ${arch}`
@@ -387,7 +502,7 @@ describe('electron-builder config', () => {
).toBe(true)
})
it('prunes non-target @parcel/watcher platform subpackages from packaged runtime resources', async () => {
it('prunes non-target @parcel/watcher architecture subpackages', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
try {
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
@@ -398,13 +513,15 @@ describe('electron-builder config', () => {
await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true })
prunePackagedParcelWatcher(resourcesDir, 'linux')
prunePackagedParcelWatcher(resourcesDir, 'linux', 'arm64')
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'watcher',
'watcher-linux-arm64-glibc',
'watcher-linux-x64-glibc'
'watcher-linux-arm64-glibc'
])
expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow(
'Unsupported packaged runtime architecture: universal'
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
@@ -420,7 +537,7 @@ describe('electron-builder config', () => {
// A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage.
await mkdir(join(parcelDir, 'transformer-js'), { recursive: true })
prunePackagedParcelWatcher(resourcesDir, 'linux')
prunePackagedParcelWatcher(resourcesDir, 'linux', 1)
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'transformer-js',
@@ -486,6 +603,20 @@ describe('electron-builder config', () => {
}
})
it('fails when the packaged resources directory is missing', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
try {
await expect(
electronBuilderConfig.afterPack({
appOutDir: root,
electronPlatformName: 'win32'
})
).rejects.toThrow(/Missing packaged resources directory/)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it.skipIf(process.platform === 'win32')(
'marks packaged Unix CLI launchers executable',
async () => {
@@ -509,11 +640,25 @@ describe('electron-builder config', () => {
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n',
'utf8'
)
const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true })
await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8')
await writeFile(
join(unpackedCliDir, 'index.js'),
[
'const args = process.argv.slice(2)',
"if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))",
"else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)",
'else console.log(JSON.stringify({ executed: false }))'
].join('\n'),
'utf8'
)
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
await electronBuilderConfig.afterPack({
appOutDir: join(root, 'linux-unpacked'),
electronPlatformName: 'linux'
electronPlatformName: 'linux',
arch: 1
})
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
@@ -522,4 +667,39 @@ describe('electron-builder config', () => {
}
}
)
// Why: the .deb/.rpm update-recovery path keys entirely off the resources/package-type marker that
// app-builder-lib's FpmTarget writes. If packaging silently stops shipping an fpm target, or adds
// one the recovery path does not cover, getLinuxRootPackageType() returns null, autoInstallOnAppQuit
// quietly goes back to true, and no unit test notices.
describe('linux root-package update recovery contract', () => {
// FpmTarget writes resources/package-type only for targets it supports auto-update for.
const MARKER_TARGETS = new Set(['deb', 'rpm', 'pacman'])
const RECOVERABLE_TARGETS = new Set(['deb', 'rpm'])
const linuxTargets = electronBuilderConfig.linux.target.map((entry) =>
typeof entry === 'string' ? entry : entry.target
)
it('still ships an AppImage plus at least one root-package target', () => {
expect(linuxTargets).toContain('AppImage')
expect(linuxTargets.some((target) => MARKER_TARGETS.has(target))).toBe(true)
})
it('ships no root-package target the recovery path cannot recover', () => {
const unrecoverable = linuxTargets.filter(
(target) => MARKER_TARGETS.has(target) && !RECOVERABLE_TARGETS.has(target)
)
expect(unrecoverable).toEqual([])
})
it('accepts exactly the markers electron-updater maps to a root-package updater', async () => {
const source = await readFile(
new URL('../../src/main/linux-update-package-type.ts', import.meta.url),
'utf8'
)
for (const target of linuxTargets.filter((entry) => RECOVERABLE_TARGETS.has(entry))) {
expect(source).toContain(`value === '${target}'`)
}
})
})
})
@@ -1,13 +1,72 @@
import { readFileSync } from 'node:fs'
import * as nodeFs from 'node:fs'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import * as nodePath from 'node:path'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { EventEmitter } from 'node:events'
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { createBootstrapFatalExitBanner, electronViteConfig } from '../../electron.vite.config'
import {
BOOTSTRAP_FATAL_LOG_ENV_VAR,
BOOTSTRAP_FATAL_LOG_FILE_NAME,
createBootstrapFatalExitBanner
} from '../build-plugins/bootstrap-fatal-exit-banner'
import { electronViteConfig } from '../../electron.vite.config'
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
type BootstrapProcessMock = EventEmitter & {
env: Record<string, string>
pid: number
exit: (code: number) => void
exitCode?: number
}
/** Runs the banner in a bare context and raises the bootstrap fault it guards against. */
function failBootstrapWithBanner(options: {
env: Record<string, string>
tmpdir?: string
stderrWrites?: string[]
}): BootstrapProcessMock {
const processMock = new EventEmitter() as BootstrapProcessMock
processMock.env = options.env
processMock.pid = 4242
processMock.exit = () => {}
const fsShim = {
...nodeFs,
writeSync: (descriptor: number, data: string) => {
if (descriptor === 2) {
options.stderrWrites?.push(data)
return data.length
}
return nodeFs.writeSync(descriptor, data)
}
}
const context = {
process: processMock,
setImmediate: () => {},
require: (specifier: string) => {
if (specifier === 'node:fs') {
return fsShim
}
if (specifier === 'node:path') {
return nodePath
}
if (specifier === 'node:os' && options.tmpdir !== undefined) {
return { tmpdir: () => options.tmpdir }
}
// Electron's own module is unreachable from a bootstrap fault this early.
throw new Error(`unexpected require: ${specifier}`)
}
}
runInNewContext(createBootstrapFatalExitBanner(), context)
processMock.emit('uncaughtException', new Error("Cannot find module 'ws'"))
return processMock
}
describe('Electron Vite output contract', () => {
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
const output = electronViteConfig.main?.build?.rollupOptions?.output
@@ -20,7 +79,7 @@ describe('Electron Vite output contract', () => {
expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js')
})
it('externalizes packaged dependencies but bundles the daemon xterm graph', () => {
it('externalizes packaged dependencies but bundles self-contained main dependencies', () => {
const external = electronViteConfig.main?.build?.rollupOptions?.external
if (typeof external !== 'function') {
throw new Error('Expected main-process external predicate')
@@ -32,7 +91,9 @@ describe('Electron Vite output contract', () => {
expect(external('node:fs', undefined, false)).toBe(true)
expect(external('@xterm/headless', undefined, false)).toBe(false)
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
expect(external('psl', undefined, false)).toBe(false)
expect(external('zod', undefined, false)).toBe(false)
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl')
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
})
@@ -40,12 +101,20 @@ describe('Electron Vite output contract', () => {
const processMock = new EventEmitter() as EventEmitter & {
exit: (code: number) => void
exitCode?: number
stderr: { write: (chunk: string) => boolean }
}
let scheduledExit: (() => void) | null = null
let exitedWith: number | null = null
const stderrWrites: string[] = []
processMock.exit = (code) => {
exitedWith = code
}
processMock.stderr = {
write: (chunk) => {
stderrWrites.push(chunk)
return true
}
}
const context = {
process: processMock,
setImmediate: (callback: () => void) => {
@@ -61,6 +130,63 @@ describe('Electron Vite output contract', () => {
scheduledExit?.()
expect(exitedWith).toBe(1)
expect(context).toHaveProperty(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)
expect(stderrWrites.join('')).toContain("Cannot find module 'zod'")
})
it('records the bootstrap failure it exits on, since the guard hides Electron dialog', () => {
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
const logPath = join(logDirectory, 'fatal.log')
const stderrWrites: string[] = []
try {
const processMock = failBootstrapWithBanner({
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath },
stderrWrites
})
expect(stderrWrites.join('')).toContain("Cannot find module 'ws'")
const recorded = readFileSync(logPath, 'utf8')
expect(recorded).toContain("Cannot find module 'ws'")
expect(recorded).toContain('pid=4242')
expect(processMock.exitCode).toBe(1)
} finally {
rmSync(logDirectory, { recursive: true, force: true })
}
})
it('creates the parent directory an overridden log path names but does not have', () => {
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
const logPath = join(logDirectory, 'nested', 'diagnostics', 'fatal.log')
try {
const processMock = failBootstrapWithBanner({
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath }
})
expect(readFileSync(logPath, 'utf8')).toContain("Cannot find module 'ws'")
expect(processMock.exitCode).toBe(1)
} finally {
rmSync(logDirectory, { recursive: true, force: true })
}
})
it('falls back to the default location when the overridden log path is unwritable', () => {
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
const fallbackDirectory = join(logDirectory, 'fallback')
try {
const processMock = failBootstrapWithBanner({
// A directory can never be opened as the log file, so the override must yield.
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logDirectory },
tmpdir: fallbackDirectory
})
const recorded = readFileSync(join(fallbackDirectory, BOOTSTRAP_FATAL_LOG_FILE_NAME), 'utf8')
expect(recorded).toContain("Cannot find module 'ws'")
expect(processMock.exitCode).toBe(1)
} finally {
rmSync(logDirectory, { recursive: true, force: true })
}
})
it('isolates renderer entry side effects behind strict facades', () => {
@@ -0,0 +1,123 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
internal static class DuplicatePathProcessLauncher
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct StartupInfo
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct ProcessInformation
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CreateProcess(
string applicationName,
StringBuilder commandLine,
IntPtr processAttributes,
IntPtr threadAttributes,
bool inheritHandles,
uint creationFlags,
IntPtr environment,
string currentDirectory,
ref StartupInfo startupInfo,
out ProcessInformation processInformation
);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
[DllImport("kernel32.dll")]
private static extern bool CloseHandle(IntPtr handle);
private static int Main(string[] args)
{
List<string> entries = new List<string>();
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
{
if (!String.Equals((string)entry.Key, "PATH", StringComparison.OrdinalIgnoreCase))
{
entries.Add((string)entry.Key + "=" + (string)entry.Value);
}
}
entries.Add("PATH=C:\\live");
entries.Add("Path=C:\\shadowed");
entries.Add("ORCA_TEST_OUTPUT=" + args[1]);
IntPtr environment = Marshal.StringToHGlobalUni(String.Join("\0", entries.ToArray()) + "\0\0");
StartupInfo startupInfo = new StartupInfo();
startupInfo.cb = Marshal.SizeOf(startupInfo);
ProcessInformation processInformation;
try
{
bool started = CreateProcess(
args[0],
new StringBuilder("\"" + args[0] + "\""),
IntPtr.Zero,
IntPtr.Zero,
false,
0x00000400,
environment,
null,
ref startupInfo,
out processInformation
);
if (!started)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
}
finally
{
Marshal.FreeHGlobal(environment);
}
try
{
WaitForSingleObject(processInformation.hProcess, 0xffffffff);
uint exitCode;
if (!GetExitCodeProcess(processInformation.hProcess, out exitCode))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
return (int)exitCode;
}
finally
{
CloseHandle(processInformation.hThread);
CloseHandle(processInformation.hProcess);
}
}
}
@@ -0,0 +1,443 @@
#!/usr/bin/env node
import { execFileSync, fork, spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { Worker } from 'node:worker_threads'
import {
childRssBytes,
median,
physicalFootprintBytes,
sampleMemory,
sampleProductionPerformance
} from './hang-watchdog-process-metrics.mjs'
const INTERNAL_ENV = 'ORCA_HANG_WATCHDOG_BENCH_INTERNAL'
const BOUNDARY_ENV = 'ORCA_HANG_WATCHDOG_BENCH_BOUNDARY'
const RESULT_PREFIX = 'ORCA_HANG_WATCHDOG_BENCH_RESULT='
const DEFAULT_TRIALS = 7
const SETTLE_MS = 2_000
const SAMPLE_COUNT = 5
const SAMPLE_INTERVAL_MS = 200
const VERIFY_TIMEOUT_MS = 500
const VERIFY_CHECK_INTERVAL_MS = 50
const VERIFY_BLOCK_MS = 1_200
const PRODUCTION_HEARTBEAT_INTERVAL_MS = 2_000
const PRODUCTION_TIMEOUT_MS = 45_000
const PRODUCTION_CHECK_INTERVAL_MS = 5_000
const PRODUCTION_SAMPLE_MS = 30_000
const MAX_LAUNCH_ATTEMPTS = 3
const MIB = 1024 * 1024
const scriptPath = import.meta.filename
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
const entryPath = path.join(repoRoot, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function forceGc() {
if (typeof global.gc !== 'function') {
throw new Error('Electron did not expose GC; keep --js-flags=--expose-gc in the harness')
}
global.gc()
global.gc()
}
function blockMainThread(ms) {
const startedAt = Date.now()
while (Date.now() - startedAt < ms) {
// Intentional synchronous stall.
}
return { startedAt, endedAt: Date.now() }
}
function readMarker(markerPath) {
try {
return JSON.parse(readFileSync(markerPath, 'utf8'))
} catch {
return null
}
}
async function verifyBlockedMainDetection(markerPath, sendHeartbeat) {
const block = blockMainThread(VERIFY_BLOCK_MS)
const detected = readMarker(markerPath)
sendHeartbeat()
const deadline = Date.now() + VERIFY_TIMEOUT_MS
let resolved
do {
resolved = readMarker(markerPath)
if (resolved?.selfRecovered === true) {
break
}
await sleep(VERIFY_CHECK_INTERVAL_MS)
} while (Date.now() < deadline)
const verified =
detected?.detectedAt >= block.startedAt &&
detected.detectedAt <= block.endedAt &&
detected.selfRecovered === false &&
resolved?.selfRecovered === true
if (!verified) {
throw new Error(
`Built watchdog failed blocked-main verification: ${JSON.stringify({ detected, resolved })}`
)
}
return true
}
function startChild(markerPath, timeoutMs, checkIntervalMs) {
const startedAt = process.hrtime.bigint()
const child = fork(entryPath, [], {
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid),
ORCA_HANG_WATCHDOG_MARKER_PATH: markerPath,
ORCA_HANG_WATCHDOG_TIMEOUT_MS: String(timeoutMs),
ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS: String(checkIntervalMs)
}
})
const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6
return {
pids: [process.pid, child.pid],
startupMs,
sendHeartbeat: () => child.send?.({ type: 'heartbeat' }),
shutdown: async () => {
if (child.exitCode !== null) {
return
}
const exitPromise = new Promise((resolve) => child.once('exit', resolve))
child.send?.({ type: 'shutdown' })
if (child.connected) {
child.disconnect()
}
await exitPromise
}
}
}
function startWorker(markerPath, timeoutMs, checkIntervalMs) {
const startedAt = process.hrtime.bigint()
const worker = new Worker(entryPath, {
workerData: {
parentPid: process.pid,
markerPath,
timeoutMs,
checkIntervalMs
}
})
const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6
return {
pids: [process.pid],
startupMs,
sendHeartbeat: () => worker.postMessage({ type: 'heartbeat' }),
shutdown: async () => {
if (worker.threadId === -1) {
return
}
const exitPromise = new Promise((resolve) => worker.once('exit', resolve))
worker.postMessage({ type: 'shutdown' })
await exitPromise
}
}
}
async function verifyBoundary(markerPath, startBoundary) {
const boundary = startBoundary(markerPath, VERIFY_TIMEOUT_MS, VERIFY_CHECK_INTERVAL_MS)
const heartbeat = setInterval(boundary.sendHeartbeat, 100)
try {
await sleep(SETTLE_MS)
return await verifyBlockedMainDetection(markerPath, boundary.sendHeartbeat)
} finally {
clearInterval(heartbeat)
await boundary.shutdown()
}
}
async function measureChild(markerPath) {
forceGc()
await sleep(SETTLE_MS)
forceGc()
const before = await sampleMemory(
() => process.memoryUsage().rss,
() => physicalFootprintBytes([process.pid]),
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
)
const child = startChild(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS)
let measurements
try {
await sleep(SETTLE_MS)
forceGc()
const childRss = await sampleMemory(
() => childRssBytes(child.pids[1]),
() => physicalFootprintBytes([child.pids[1]]),
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
)
const total = await sampleMemory(
() => process.memoryUsage().rss + childRssBytes(child.pids[1]),
() => physicalFootprintBytes(child.pids),
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
)
const performance = await sampleProductionPerformance(child, {
heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
sampleMs: PRODUCTION_SAMPLE_MS,
sleep
})
measurements = {
rssBytes: childRss.rssBytes,
summedProcessRssDeltaBytes: Math.max(0, total.rssBytes - before.rssBytes),
physicalFootprintDeltaBytes: Math.max(
0,
total.physicalFootprintBytes - before.physicalFootprintBytes
),
startupMs: child.startupMs,
...performance
}
} finally {
await child.shutdown()
}
rmSync(markerPath, { force: true })
return {
...measurements,
blockedMainThreadVerified: await verifyBoundary(markerPath, startChild)
}
}
async function measureWorker(markerPath) {
forceGc()
await sleep(SETTLE_MS)
forceGc()
const before = await sampleMemory(
() => process.memoryUsage().rss,
() => physicalFootprintBytes([process.pid]),
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
)
const worker = startWorker(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS)
let measurements
try {
await sleep(SETTLE_MS)
forceGc()
const after = await sampleMemory(
() => process.memoryUsage().rss,
() => physicalFootprintBytes([process.pid]),
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
)
const performance = await sampleProductionPerformance(worker, {
heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
sampleMs: PRODUCTION_SAMPLE_MS,
sleep
})
const rssBytes = Math.max(0, after.rssBytes - before.rssBytes)
measurements = {
rssBytes,
summedProcessRssDeltaBytes: rssBytes,
physicalFootprintDeltaBytes: Math.max(
0,
after.physicalFootprintBytes - before.physicalFootprintBytes
),
startupMs: worker.startupMs,
...performance
}
} finally {
await worker.shutdown()
}
rmSync(markerPath, { force: true })
return {
...measurements,
blockedMainThreadVerified: await verifyBoundary(markerPath, startWorker)
}
}
async function runInternal() {
if (process.platform !== 'darwin') {
throw new Error('The production watchdog is macOS-only; run this benchmark on macOS')
}
const { app } = await import('electron')
const boundary = process.env[BOUNDARY_ENV]
const profileDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-'))
app.setPath('userData', profileDir)
try {
await app.whenReady()
const markerPath = path.join(profileDir, 'main-thread-hang.json')
const result =
boundary === 'child'
? await measureChild(markerPath)
: boundary === 'worker'
? await measureWorker(markerPath)
: (() => {
throw new Error(`Unsupported boundary: ${boundary}`)
})()
process.stdout.write(`${RESULT_PREFIX}${JSON.stringify(result)}\n`)
} finally {
app.quit()
rmSync(profileDir, { recursive: true, force: true })
}
}
function parseArgs(argv) {
const options = { boundary: '', trials: DEFAULT_TRIALS, output: '' }
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = argv[index + 1]
if (arg === '--boundary' || arg === '--trials' || arg === '--output') {
if (!value) {
throw new Error(`Missing value for ${arg}`)
}
options[arg.slice(2)] = arg === '--trials' ? Number(value) : value
index += 1
} else {
throw new Error(`Unknown argument: ${arg}`)
}
}
if (!['child', 'worker'].includes(options.boundary)) {
throw new Error('--boundary must be child or worker')
}
if (!Number.isInteger(options.trials) || options.trials < 1) {
throw new Error('--trials must be a positive integer')
}
return options
}
function electronPath() {
const requirePath = import.meta.resolve('electron')
const electronModulePath = fileURLToPath(requirePath)
return execFileSync(
process.execPath,
['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`],
{
encoding: 'utf8'
}
)
}
function runTrial(executable, boundary) {
for (let attempt = 1; attempt <= MAX_LAUNCH_ATTEMPTS; attempt += 1) {
const env = { ...process.env, [INTERNAL_ENV]: '1', [BOUNDARY_ENV]: boundary }
delete env.ELECTRON_RUN_AS_NODE
const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-launcher-'))
writeFileSync(
path.join(launcherDir, 'package.json'),
JSON.stringify({ name: 'orca-watchdog-benchmark', main: 'main.cjs' })
)
writeFileSync(
path.join(launcherDir, 'main.cjs'),
`import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => {
console.error(error)
process.exitCode = 1
})\n`
)
let result
try {
result = spawnSync(executable, ['--js-flags=--expose-gc', launcherDir], {
cwd: repoRoot,
env,
encoding: 'utf8',
timeout: 90_000
})
} finally {
rmSync(launcherDir, { recursive: true, force: true })
}
if (result.status !== 0) {
throw new Error(
`Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n` +
`${result.stderr || result.stdout}`
)
}
const line = result.stdout.split('\n').find((candidate) => candidate.startsWith(RESULT_PREFIX))
if (line) {
return { ...JSON.parse(line.slice(RESULT_PREFIX.length)), launchAttempts: attempt }
}
if (attempt === MAX_LAUNCH_ATTEMPTS || result.stderr || result.stdout) {
throw new Error(`Electron trial did not report a result (status ${result.status})`)
}
}
throw new Error('Electron trial exhausted launcher attempts')
}
function runBenchmark() {
if (process.platform !== 'darwin') {
throw new Error('The production watchdog is macOS-only; run this benchmark on macOS')
}
if (!existsSync(entryPath)) {
throw new Error(`Missing ${entryPath}; run pnpm exec electron-vite build first`)
}
const options = parseArgs(process.argv.slice(2))
const builtEntry = readFileSync(entryPath, 'utf8')
const hasChildContract = builtEntry.includes('ORCA_HANG_WATCHDOG_PARENT_PID')
const hasWorkerContract = builtEntry.includes('workerData') && builtEntry.includes('parentPort')
if (
(options.boundary === 'child' && !hasChildContract) ||
(options.boundary === 'worker' && !hasWorkerContract)
) {
throw new Error(
`Built watchdog does not implement the requested ${options.boundary} boundary; rebuild the matching revision`
)
}
const executable = electronPath()
const results = Array.from({ length: options.trials }, () =>
runTrial(executable, options.boundary)
)
const rssBytes = results.map((result) => result.rssBytes)
const summedProcessRssDeltaBytes = results.map((result) => result.summedProcessRssDeltaBytes)
const physicalFootprintDeltaBytes = results.map((result) => result.physicalFootprintDeltaBytes)
const startupMs = results.map((result) => result.startupMs)
const cpuMs = results.map((result) => result.cpuMs)
const eventLoopDelayP95Ms = results.map((result) => result.eventLoopDelayP95Ms)
const eventLoopDelayP99Ms = results.map((result) => result.eventLoopDelayP99Ms)
const eventLoopDelayMaxMs = results.map((result) => result.eventLoopDelayMaxMs)
const report = {
benchmark: 'hang-watchdog-memory',
boundary: options.boundary,
revision: execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: repoRoot,
encoding: 'utf8'
}).trim(),
electron: execFileSync(executable, ['-e', 'process.stdout.write(process.versions.electron)'], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
encoding: 'utf8'
}).trim(),
settleMs: SETTLE_MS,
samplesPerTrial: SAMPLE_COUNT,
productionHeartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
productionCheckIntervalMs: PRODUCTION_CHECK_INTERVAL_MS,
productionSampleMs: PRODUCTION_SAMPLE_MS,
trials: options.trials,
rssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))),
medianRssMiB: Number((median(rssBytes) / MIB).toFixed(2)),
summedProcessRssDeltaMiB: summedProcessRssDeltaBytes.map((value) =>
Number((value / MIB).toFixed(2))
),
medianSummedProcessRssDeltaMiB: Number((median(summedProcessRssDeltaBytes) / MIB).toFixed(2)),
physicalFootprintDeltaMiB: physicalFootprintDeltaBytes.map((value) =>
Number((value / MIB).toFixed(2))
),
medianPhysicalFootprintDeltaMiB: Number((median(physicalFootprintDeltaBytes) / MIB).toFixed(2)),
startupMs: startupMs.map((value) => Number(value.toFixed(3))),
medianStartupMs: Number(median(startupMs).toFixed(3)),
cpuMs: cpuMs.map((value) => Number(value.toFixed(2))),
medianCpuMs: Number(median(cpuMs).toFixed(2)),
eventLoopDelayP95Ms: eventLoopDelayP95Ms.map((value) => Number(value.toFixed(3))),
medianEventLoopDelayP95Ms: Number(median(eventLoopDelayP95Ms).toFixed(3)),
eventLoopDelayP99Ms: eventLoopDelayP99Ms.map((value) => Number(value.toFixed(3))),
medianEventLoopDelayP99Ms: Number(median(eventLoopDelayP99Ms).toFixed(3)),
eventLoopDelayMaxMs: eventLoopDelayMaxMs.map((value) => Number(value.toFixed(3))),
medianEventLoopDelayMaxMs: Number(median(eventLoopDelayMaxMs).toFixed(3)),
heartbeatCounts: results.map((result) => result.heartbeatCount),
launchAttempts: results.map((result) => result.launchAttempts),
blockedMainThreadVerified: results.every((result) => result.blockedMainThreadVerified)
}
const serialized = `${JSON.stringify(report, null, 2)}\n`
process.stdout.write(serialized)
if (options.output) {
writeFileSync(path.resolve(options.output), serialized)
}
}
if (process.env[INTERNAL_ENV] === '1') {
await runInternal()
} else {
runBenchmark()
}
@@ -0,0 +1,108 @@
import { execFileSync } from 'node:child_process'
import { monitorEventLoopDelay } from 'node:perf_hooks'
export function median(values) {
const sorted = [...values].sort((left, right) => left - right)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
export async function sampleMemory(readRss, readPhysicalFootprint, options) {
const rssSamples = []
const physicalFootprintSamples = []
for (let index = 0; index < options.sampleCount; index += 1) {
rssSamples.push(readRss())
physicalFootprintSamples.push(readPhysicalFootprint())
await options.sleep(options.sampleIntervalMs)
}
return {
rssBytes: median(rssSamples),
physicalFootprintBytes: median(physicalFootprintSamples)
}
}
export function childRssBytes(pid) {
const raw = execFileSync('ps', ['-o', 'rss=', '-p', String(pid)], {
encoding: 'utf8'
}).trim()
const rssKiB = Number(raw)
if (!Number.isFinite(rssKiB) || rssKiB <= 0) {
throw new Error(`Could not read watchdog child RSS for PID ${pid}`)
}
return rssKiB * 1024
}
export function parsePhysicalFootprintBytes(output, processCount) {
const match =
processCount > 1
? output.match(/^Summary Footprint:\s+(\d+) B$/m)
: output.match(/^[^\s].*\sFootprint:\s+(\d+) B/m)
const bytes = Number(match?.[1])
return Number.isFinite(bytes) && bytes > 0 ? bytes : null
}
export function physicalFootprintBytes(pids) {
const pidArgs = pids.flatMap((pid) => ['--pid', String(pid)])
const output = execFileSync(
'/usr/bin/footprint',
[...pidArgs, '--format', 'bytes', '--noCategories'],
{ encoding: 'utf8' }
)
const bytes = parsePhysicalFootprintBytes(output, pids.length)
if (bytes === null) {
throw new Error(`Could not read physical footprint for PIDs ${pids.join(', ')}`)
}
return bytes
}
export function parseProcessCpuTimeMs(raw) {
if (!raw.trim()) {
return null
}
const parts = raw.split(':').map(Number)
if (!parts.length || parts.some((part) => !Number.isFinite(part))) {
return null
}
const seconds = parts.reduce((total, part) => total * 60 + part, 0)
const milliseconds = seconds * 1_000
return milliseconds >= 0 ? milliseconds : null
}
function processCpuTimeMs(pid) {
const raw = execFileSync('ps', ['-o', 'time=', '-p', String(pid)], {
encoding: 'utf8'
}).trim()
const milliseconds = parseProcessCpuTimeMs(raw)
if (milliseconds === null) {
throw new Error(`Could not read CPU time for PID ${pid}`)
}
return milliseconds
}
function combinedCpuTimeMs(pids) {
return pids.reduce((total, pid) => total + processCpuTimeMs(pid), 0)
}
export async function sampleProductionPerformance(boundary, options) {
const loopDelay = monitorEventLoopDelay({ resolution: 10 })
let heartbeatCount = 0
const heartbeat = setInterval(() => {
heartbeatCount += 1
boundary.sendHeartbeat()
}, options.heartbeatIntervalMs)
const cpuBefore = combinedCpuTimeMs(boundary.pids)
loopDelay.enable()
try {
await options.sleep(options.sampleMs)
} finally {
loopDelay.disable()
clearInterval(heartbeat)
}
return {
cpuMs: Math.max(0, combinedCpuTimeMs(boundary.pids) - cpuBefore),
heartbeatCount,
eventLoopDelayP95Ms: loopDelay.percentile(95) / 1e6,
eventLoopDelayP99Ms: loopDelay.percentile(99) / 1e6,
eventLoopDelayMaxMs: loopDelay.max / 1e6
}
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
parsePhysicalFootprintBytes,
parseProcessCpuTimeMs
} from './hang-watchdog-process-metrics.mjs'
describe('hang watchdog process metrics', () => {
it('uses the de-duplicated summary for multiple processes', () => {
const output = `
Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page)
phys_footprint: 5100000 B
Electron Helper [102]: 64-bit Footprint: 2000000 B (16384 bytes per page)
phys_footprint: 2100000 B
Summary Footprint: 6259264 B
`
expect(parsePhysicalFootprintBytes(output, 2)).toBe(6_259_264)
})
it('uses the process footprint rather than auxiliary accounting for one process', () => {
const output = `
Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page)
phys_footprint: 5100000 B
`
expect(parsePhysicalFootprintBytes(output, 1)).toBe(5_000_000)
})
it('rejects missing or zero footprint summaries', () => {
expect(parsePhysicalFootprintBytes('phys_footprint: 100 B', 2)).toBeNull()
expect(parsePhysicalFootprintBytes('Summary Footprint: 0 B', 2)).toBeNull()
})
it.each([
['0:00.04', 40],
['1:02.50', 62_500],
['2:01:02.50', 7_262_500]
])('parses ps CPU time %s', (value, expected) => {
expect(parseProcessCpuTimeMs(value)).toBe(expected)
})
it('rejects invalid CPU times', () => {
expect(parseProcessCpuTimeMs('')).toBeNull()
expect(parseProcessCpuTimeMs('not-a-time')).toBeNull()
expect(parseProcessCpuTimeMs('-1:00')).toBeNull()
})
})
@@ -0,0 +1,72 @@
/** @vitest-environment happy-dom */
import { describe, expect, it } from 'vitest'
import {
installHappyDomMutationObserverRetention,
retainedMutationCallbackCount
} from './happy-dom-mutation-observer-retention'
function readListenerCallbacks(target: Node): unknown[] {
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
(candidate) => candidate.description === 'mutationListeners'
)
const listeners = listenersSymbol
? (target as unknown as Record<symbol, unknown>)[listenersSymbol]
: []
if (!Array.isArray(listeners)) {
return []
}
return listeners.map((listener: { callback?: { deref: () => unknown } }) =>
listener.callback?.deref()
)
}
describe('happy-dom MutationObserver retention', () => {
it('pins the internal callback that happy-dom only holds weakly', () => {
expect(installHappyDomMutationObserverRetention()).toBe(true)
const target = document.createElement('div')
document.body.append(target)
const observer = new MutationObserver(() => {})
observer.observe(target, { childList: true, subtree: true })
const callbacks = readListenerCallbacks(target)
expect(callbacks.length).toBe(1)
expect(callbacks[0]).toBeTypeOf('function')
expect(retainedMutationCallbackCount(observer)).toBe(1)
observer.disconnect()
expect(retainedMutationCallbackCount(observer)).toBe(0)
target.remove()
})
it('keeps delivering records after the weak callback would have been collected', async () => {
installHappyDomMutationObserverRetention()
const target = document.createElement('div')
document.body.append(target)
let deliveries = 0
const observer = new MutationObserver(() => {
deliveries += 1
})
observer.observe(target, { childList: true, subtree: true })
target.replaceChildren(document.createElement('div'))
await Promise.resolve()
expect(deliveries).toBe(1)
const collectGarbage = (globalThis as { gc?: () => void }).gc
if (collectGarbage) {
for (let round = 0; round < 5; round += 1) {
await new Promise((resolve) => setTimeout(resolve, 1))
collectGarbage()
}
await new Promise((resolve) => setTimeout(resolve, 1))
}
target.replaceChildren(document.createElement('span'))
await Promise.resolve()
expect(deliveries).toBe(2)
observer.disconnect()
target.remove()
})
})
@@ -0,0 +1,81 @@
// Why: happy-dom stores each MutationObserver's internal callback in a WeakRef, so any GC pause
// under parallel test load silently and permanently stops a still-connected observer. Browsers
// keep that callback reachable for as long as the observer observes; mirror that lifetime here so
// DOM-driven tests never lose mutation records mid-run.
type HappyDomMutationListener = {
callback?: { deref: () => unknown }
}
type PatchableMutationObserver = {
observe: (target: Node, options?: MutationObserverInit) => void
disconnect: () => void
}
const MUTATION_LISTENERS_SYMBOL_DESCRIPTION = 'mutationListeners'
const RETENTION_INSTALLED = Symbol.for('orca.happyDomMutationObserverRetention')
const retainedCallbacks = new WeakMap<object, Set<unknown>>()
function readMutationListeners(target: Node): HappyDomMutationListener[] {
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
(candidate) => candidate.description === MUTATION_LISTENERS_SYMBOL_DESCRIPTION
)
if (!listenersSymbol) {
return []
}
const listeners = (target as unknown as Record<symbol, unknown>)[listenersSymbol]
return Array.isArray(listeners) ? (listeners as HappyDomMutationListener[]) : []
}
/** Number of internal callbacks pinned for `observer`; drops to 0 once it disconnects. */
export function retainedMutationCallbackCount(observer: MutationObserver): number {
return retainedCallbacks.get(observer)?.size ?? 0
}
export function installHappyDomMutationObserverRetention(): boolean {
const observerClass = (globalThis as { MutationObserver?: typeof MutationObserver })
.MutationObserver
if (!observerClass) {
return false
}
const prototype = observerClass.prototype as unknown as PatchableMutationObserver &
Record<symbol, unknown>
if (prototype[RETENTION_INSTALLED] === true) {
return true
}
const observe = prototype.observe
const disconnect = prototype.disconnect
prototype.observe = function patchedObserve(
this: object,
target: Node,
options?: MutationObserverInit
): void {
const existing = new Set(readMutationListeners(target))
observe.call(this as unknown as PatchableMutationObserver, target, options)
const pinned = retainedCallbacks.get(this) ?? new Set<unknown>()
for (const listener of readMutationListeners(target)) {
if (existing.has(listener)) {
continue
}
const callback = listener.callback?.deref()
if (callback) {
pinned.add(callback)
}
}
if (pinned.size > 0) {
retainedCallbacks.set(this, pinned)
}
}
prototype.disconnect = function patchedDisconnect(this: object): void {
disconnect.call(this as unknown as PatchableMutationObserver)
retainedCallbacks.delete(this)
}
prototype[RETENTION_INSTALLED] = true
return true
}
installHappyDomMutationObserverRetention()
+109
View File
@@ -0,0 +1,109 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
export function createHourlyBuildVersion(baseVersion, date) {
const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion)
if (!match) {
throw new Error(`Package version is not valid semver: ${baseVersion}`)
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Hourly build timestamp is invalid.')
}
const pad = (value, width = 2) => String(value).padStart(width, '0')
const stamp = [
pad(date.getUTCFullYear(), 4),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes())
].join('')
// Why: drop any -rc.N tail. Keeping it makes every hourly semver-NEWER than the
// RC it was cut from (1.4.160-rc.3-hourly.X > 1.4.160-rc.3), which would let an
// ordinary RC-channel check offer untested hourly builds to RC users. Stripping
// to the base parks hourlies below both rc.N and stable ('hourly' < 'rc'
// alphabetically), reachable only by an explicit pinned jump.
return `${match[1]}-hourly.${stamp}`
}
/**
* The next build number for `baseVersion`, counting from the titles of existing
* hourly releases.
*
* Why the series restarts at 01 on every base version: the number answers "which
* build of 1.4.163 is this", so a counter shared across versions makes it
* meaningless — 1.4.164 would open at 38 for no reason a reader can see.
*
* Why the maximum rather than a count: the prune step trims to
* HOURLY_RETAIN_COUNT, so a count would roll backwards and reissue a number
* already in use. Titles that predate this naming simply do not match, which is
* how the first build of a version lands on 01.
*/
export function nextHourlyBuildNumber(baseVersion, releaseNames = []) {
const prefix = `${baseVersion} • `
const highest = releaseNames.reduce((max, entry) => {
const name = String(entry ?? '')
if (!name.startsWith(prefix)) {
return max
}
const match = /^(\d+) • /.exec(name.slice(prefix.length))
return match ? Math.max(max, Number(match[1])) : max
}, 0)
return highest + 1
}
/**
* `1.4.163 • 01 • Jul 31, 1:54PM • e698241` — the human-facing release title,
* shown verbatim in both the GitHub releases list and the in-app build picker.
*/
export function formatHourlyReleaseName(version, buildNumber, commit, date) {
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
throw new Error(`Hourly build number must be a positive integer: ${buildNumber}`)
}
return [
version.split('-')[0],
String(buildNumber).padStart(2, '0'),
formatReleaseTitleTimestamp(date),
commit.slice(0, 7)
].join(' • ')
}
// Why the number is derived here rather than passed in: it counts builds of the
// base version, and the base is only known once the published tags have been
// resolved just above. Computing it outside meant numbering against whatever
// version the caller guessed.
export function getHourlyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? [])
const version = createHourlyBuildVersion(base, now)
const buildNumber = nextHourlyBuildNumber(base, releaseNames ?? [])
return {
commit,
version,
buildNumber,
name: formatHourlyReleaseName(version, buildNumber, commit, now)
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getHourlyBuildIdentity(new Date(), {
publishedVersions: readPublishedVersionsFromEnv(),
// Titles are newline separated and contain spaces, so this cannot reuse the
// whitespace split the version list gets.
releaseNames: (process.env.ORCA_HOURLY_RELEASE_NAMES ?? '').split('\n').filter(Boolean)
})
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n`
)
}
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import {
createHourlyBuildVersion,
formatHourlyReleaseName,
nextHourlyBuildNumber
} from './hourly-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createHourlyBuildVersion', () => {
it('stamps the version with a zero-padded UTC timestamp', () => {
expect(createHourlyBuildVersion('1.4.160', new Date('2026-07-28T04:05:00Z'))).toBe(
'1.4.160-hourly.202607280405'
)
})
// Why: main's package.json carries the in-flight RC tail. Keeping it would make
// every hourly semver-NEWER than the RC it was cut from (1.4.160-rc.3-hourly.X >
// 1.4.160-rc.3), so an ordinary RC-channel check would offer untested hourly
// builds to RC users. Dropping it parks hourlies below both rc.N and stable,
// reachable only by an explicit pinned jump.
it('drops an in-flight rc tail so hourlies never outrank the rc series', () => {
const version = createHourlyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T14:00:00Z'))
expect(version).toBe('1.4.160-hourly.202607281400')
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
expect(compareAppVersions('1.4.160-rc.3-hourly.202607281400', '1.4.160-rc.3')).toBeGreaterThan(
0
)
})
it('rejects invalid input', () => {
expect(() => createHourlyBuildVersion('nope', new Date())).toThrow(/valid semver/)
expect(() => createHourlyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
})
})
describe('formatHourlyReleaseName', () => {
const name = (iso, buildNumber = 1, commit = 'e698241abcde') =>
formatHourlyReleaseName('1.4.163-hourly.x', buildNumber, commit, new Date(iso))
it('renders version, number, Pacific timestamp, and short sha', () => {
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • Jul 31, 1:54PM • e698241')
})
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
// the offset between them is not a constant. A test pinned to one season would
// pass all summer and start failing in November.
it('follows the Pacific offset across DST', () => {
expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • Jan 14, 6:30PM • e698241')
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • Jul 31, 12:00AM • e698241')
})
// Why pinned: 12-hour clocks are where off-by-twelve bugs live, and midnight in
// particular renders as 0:00 or 24:00 on a formatter set up carelessly.
it('renders both noon and midnight as 12', () => {
expect(name('2026-07-31T07:00:00Z')).toContain('12:00AM')
expect(name('2026-07-31T19:00:00Z')).toContain('12:00PM')
})
// Recent ICU puts U+202F before AM/PM; the title must carry no separator at all.
it('joins the meridiem with no whitespace of any kind', () => {
expect(name('2026-07-31T20:54:00Z')).toMatch(/\d:\d{2}(AM|PM) •/)
expect(name('2026-07-31T20:54:00Z')).not.toMatch(/\s[AP]M/u)
})
it('pads to two digits and grows past them', () => {
expect(name('2026-07-31T20:54:00Z', 9)).toContain(' • 09 • ')
expect(name('2026-07-31T20:54:00Z', 42)).toContain(' • 42 • ')
expect(name('2026-07-31T20:54:00Z', 1234)).toContain(' • 1234 • ')
})
it('rejects a build number that is not a positive integer', () => {
expect(() => name('2026-07-31T20:54:00Z', 0)).toThrow(/positive integer/)
expect(() => name('2026-07-31T20:54:00Z', -1)).toThrow(/positive integer/)
expect(() => name('2026-07-31T20:54:00Z', 1.5)).toThrow(/positive integer/)
expect(() => name('2026-07-31T20:54:00Z', Number.NaN)).toThrow(/positive integer/)
})
it('rejects an invalid timestamp', () => {
expect(() => formatHourlyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow(
/invalid/
)
})
})
describe('nextHourlyBuildNumber', () => {
const titles = [
'1.4.163 • 01 • Jul 31, 1:54PM • e698241',
'1.4.163 • 02 • Jul 31, 2:54PM • aaaaaaa',
'1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'
]
it('continues the series for the version being built', () => {
expect(nextHourlyBuildNumber('1.4.163', titles)).toBe(38)
})
// The point of the change: 1.4.164 opens its own series at 01 rather than
// inheriting 1.4.163's count, which said nothing about 1.4.164.
it('restarts at 1 when the base version moves', () => {
expect(nextHourlyBuildNumber('1.4.164', titles)).toBe(1)
expect(
nextHourlyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 8:00AM • ccccccc'])
).toBe(2)
})
// Why max and not count: pruning trims to HOURLY_RETAIN_COUNT, so counting
// would roll backwards and reissue a number already used.
it('takes the highest number, not the count', () => {
expect(nextHourlyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 1:54PM • e698241'])).toBe(10)
})
it('starts at 1 with no history at all', () => {
expect(nextHourlyBuildNumber('1.4.163')).toBe(1)
expect(nextHourlyBuildNumber('1.4.163', [])).toBe(1)
})
// Legacy titles were the raw tag, and a prefix match must not treat 1.4.16 as
// a prefix of 1.4.163's series.
it('ignores titles that are not this version', () => {
expect(nextHourlyBuildNumber('1.4.16', ['1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'])).toBe(1)
expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1)
})
})
@@ -0,0 +1,38 @@
export class BoundedLiveFreezeHistory {
#entries = []
#limit
#nextIndex = 0
#totalCount = 0
constructor(limit) {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error(`History limit must be a positive integer, got ${limit}`)
}
this.#limit = limit
}
add(entry) {
this.#totalCount += 1
if (this.#entries.length < this.#limit) {
this.#entries.push(entry)
return
}
this.#entries[this.#nextIndex] = entry
this.#nextIndex = (this.#nextIndex + 1) % this.#limit
}
get retainedCount() {
return this.#entries.length
}
get totalCount() {
return this.#totalCount
}
values() {
if (this.#entries.length < this.#limit || this.#nextIndex === 0) {
return [...this.#entries]
}
return [...this.#entries.slice(this.#nextIndex), ...this.#entries.slice(0, this.#nextIndex)]
}
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
describe('BoundedLiveFreezeHistory', () => {
it('retains the newest entries in insertion order and counts the full run', () => {
const history = new BoundedLiveFreezeHistory(3)
for (let value = 1; value <= 7; value += 1) {
history.add(value)
}
expect(history.values()).toEqual([5, 6, 7])
expect(history.retainedCount).toBe(3)
expect(history.totalCount).toBe(7)
})
it('rejects invalid retention limits', () => {
expect(() => new BoundedLiveFreezeHistory(0)).toThrow('positive integer')
expect(() => new BoundedLiveFreezeHistory(1.5)).toThrow('positive integer')
})
})
@@ -0,0 +1,262 @@
/**
* Pure metrics helpers for the live remote bulk-open freeze harness.
* Kept separate so unit tests can drive the same code the repro uses.
*/
export const DEFAULT_SOFT_MS = 2000
export const DEFAULT_HARD_MS = 5000
export function readFreezeNumberEnv(name, fallback) {
const raw = process.env[name]
if (raw == null || raw.trim() === '') {
return fallback
}
const value = Number(raw)
if (!Number.isFinite(value)) {
throw new Error(`Invalid ${name}: expected a finite number, got ${JSON.stringify(raw)}`)
}
return value
}
export function extractTerminalHandle(result) {
if (!result || typeof result !== 'object') {
return null
}
const candidates = [
result.handle,
result.terminalHandle,
result.agentTerminalHandle,
typeof result.terminal === 'string' ? result.terminal : result.terminal?.handle,
result.startupTerminal?.handle,
result.tab?.terminal,
result.tab?.handle
]
for (const value of candidates) {
if (typeof value === 'string' && value.startsWith('term_')) {
return value
}
}
for (const value of Object.values(result)) {
if (typeof value === 'string' && value.startsWith('term_')) {
return value
}
if (value && typeof value === 'object') {
for (const nested of Object.values(value)) {
if (typeof nested === 'string' && nested.startsWith('term_')) {
return nested
}
}
}
}
return null
}
export function worktreeSelector(wt) {
if (typeof wt?.id === 'string' && wt.id.length > 0) {
return `id:${wt.id}`
}
if (typeof wt?.path === 'string' && wt.path.length > 0) {
return `path:${wt.path}`
}
return null
}
/**
* Peak stall across individual switch latency and concurrent batch wall.
* Hard freeze when peak >= hardMs (default 5000).
*/
export function evaluateFreezeSignals({
maxSwitchMs = 0,
maxBatchWallMs = 0,
statusProbeMs = 0,
memoryProbeMs = null,
softMs = DEFAULT_SOFT_MS,
hardMs = DEFAULT_HARD_MS
}) {
const peakLatencyMs = Math.max(maxSwitchMs, maxBatchWallMs)
const softFreeze =
peakLatencyMs >= softMs ||
statusProbeMs >= softMs ||
(memoryProbeMs != null && memoryProbeMs >= softMs)
const hardFreeze =
peakLatencyMs >= hardMs ||
statusProbeMs >= hardMs ||
(memoryProbeMs != null && memoryProbeMs >= hardMs)
return { peakLatencyMs, softFreeze, hardFreeze }
}
export function shouldCapSwitchTargets(maxSwitchTargets) {
return Number.isFinite(maxSwitchTargets) && maxSwitchTargets > 0
}
export function applySwitchTargetCap(targets, maxSwitchTargets) {
if (!shouldCapSwitchTargets(maxSwitchTargets)) {
return targets
}
return targets.slice(0, maxSwitchTargets)
}
/** Scenarios that model real user recovery, not concurrent CLI pileup. */
export const REALISTIC_SCENARIOS = [
'idle-backlog-open',
'idle-backlog-reconnect-open',
'restart-proxy',
/** Idle + flood + reconnect storm overlapped with concurrent open fan-out. */
'lockup-storm'
]
/**
* Permanent lockup: app/host stops making progress — not a single recovered timeout.
* Distinct from multi-second hard stall that still recovers (status answers, most opens ok).
*/
export function evaluatePermanentLockup({
timedOutOps = 0,
statusHangMs = 0,
consecutiveSwitchFailures = 0,
openFailed = 0,
openTotal = 0,
permanentTimeoutMs = 60_000,
/** Fraction of opens that must fail to count as lockup without status hang. */
failRateThreshold = 0.25,
minTimedOutOps = 3
}) {
const failRate = openTotal > 0 ? openFailed / openTotal : 0
const permanentLockup =
statusHangMs >= permanentTimeoutMs ||
timedOutOps >= minTimedOutOps ||
consecutiveSwitchFailures >= 5 ||
(openTotal >= 8 && failRate >= failRateThreshold)
return {
permanentLockup,
timedOutOps,
statusHangMs,
consecutiveSwitchFailures,
failRate,
recoveredHardStallCandidate:
!permanentLockup && timedOutOps < minTimedOutOps && statusHangMs < permanentTimeoutMs
}
}
/**
* Peak across open latencies + optional reconnect-refresh wall + probes.
* Used by the naturalistic harness (no parallel switch amp).
*/
export function evaluateRealisticFreezeSignals({
maxOpenMs = 0,
firstOpenMs = 0,
reconnectRefreshMs = 0,
statusProbeMs = 0,
memoryProbeMs = null,
softMs = DEFAULT_SOFT_MS,
hardMs = DEFAULT_HARD_MS
}) {
const peakLatencyMs = Math.max(maxOpenMs, firstOpenMs, reconnectRefreshMs)
return evaluateFreezeSignals({
maxSwitchMs: peakLatencyMs,
maxBatchWallMs: 0,
statusProbeMs,
memoryProbeMs,
softMs,
hardMs
})
}
export function humanPaceDelayMs(baseMs, jitterMs = 0) {
const base = Math.max(0, baseMs)
const jitter = Math.max(0, jitterMs)
if (jitter === 0) {
return base
}
return base + Math.floor(Math.random() * (jitter + 1))
}
/** Full-app forever freeze: host RPC dead for a continuous window, not a recovered stall. */
export const DEFAULT_FOREVER_WINDOW_MS = 30_000
export const DEFAULT_STATUS_SLOW_MS = 15_000
/**
* Analyze mid-storm status samples for a continuous unhealthy window.
* Sample: { tMs, ms, ok, hang }
*/
export function evaluateFullAppFreeze({
statusSamples = [],
statusSummary = {},
foreverWindowMs = DEFAULT_FOREVER_WINDOW_MS,
statusSlowMs = DEFAULT_STATUS_SLOW_MS,
killOnlyRecovery = false
}) {
const infrastructureErrors = statusSamples.filter((sample) => sample.infrastructureError)
const infrastructureErrorCount = Math.max(
infrastructureErrors.length,
statusSummary.infrastructureErrorCount ?? 0
)
const maxStatusMs = Math.max(
0,
...statusSamples.map((s) => s.ms || 0),
statusSummary.maxStatusMs ?? 0
)
if (killOnlyRecovery) {
return {
foreverUiLockupObserved: true,
longestUnhealthyWindowMs: foreverWindowMs,
maxStatusMs,
unhealthySampleCount: statusSummary.sampleCount ?? statusSamples.length,
infrastructureErrorCount,
reason: 'kill-only recovery documented'
}
}
const unhealthy = statusSamples.map((s) => {
const hang = !s.infrastructureError && (Boolean(s.hang) || s.ok === false)
const slow = !s.infrastructureError && (s.ms || 0) >= statusSlowMs
return { ...s, unhealthy: hang || slow }
})
let longest = statusSummary.longestUnhealthyWindowMs ?? 0
let runStart = null
for (const s of unhealthy) {
if (s.unhealthy) {
runStart ??= s.tMs ?? 0
const end = (s.tMs ?? 0) + (s.ms || 0)
longest = Math.max(longest, end - runStart)
} else {
runStart = null
}
}
// If timestamps missing, fall back to consecutive unhealthy count * assumed interval.
if (longest === 0 && unhealthy.some((s) => s.unhealthy)) {
let run = 0
for (const s of unhealthy) {
if (s.unhealthy) {
run += 1
longest = Math.max(longest, run)
} else {
run = 0
}
}
// Without wall clock, consecutive count alone is not a ms window.
longest = 0
}
const foreverUiLockupObserved = longest >= foreverWindowMs
const unhealthySampleCount = Math.max(
unhealthy.filter((s) => s.unhealthy).length,
statusSummary.unhealthySampleCount ?? 0
)
return {
foreverUiLockupObserved,
longestUnhealthyWindowMs: longest,
maxStatusMs,
unhealthySampleCount,
infrastructureErrorCount,
reason: foreverUiLockupObserved
? `status unhealthy ≥${foreverWindowMs}ms continuous`
: infrastructureErrorCount > 0
? `status watchdog infrastructure errors: ${infrastructureErrorCount}`
: maxStatusMs >= statusSlowMs
? `status slow peak ${maxStatusMs}ms but no ≥${foreverWindowMs}ms window`
: 'status remained healthy through storm'
}
}
@@ -0,0 +1,239 @@
import { describe, expect, it } from 'vitest'
import {
applySwitchTargetCap,
evaluateFreezeSignals,
evaluateFullAppFreeze,
evaluatePermanentLockup,
evaluateRealisticFreezeSignals,
extractTerminalHandle,
humanPaceDelayMs,
readFreezeNumberEnv,
REALISTIC_SCENARIOS,
shouldCapSwitchTargets,
worktreeSelector
} from './live-remote-bulk-open-freeze-metrics.mjs'
describe('live-remote-bulk-open-freeze-metrics', () => {
it('extracts term_ handles from nested create payloads', () => {
expect(extractTerminalHandle({ handle: 'term_abc' })).toBe('term_abc')
expect(extractTerminalHandle({ terminal: { handle: 'term_nested' } })).toBe('term_nested')
expect(extractTerminalHandle({ tab: { terminal: 'term_tab' } })).toBe('term_tab')
expect(extractTerminalHandle({ startupTerminal: { handle: 'term_start' } })).toBe('term_start')
expect(extractTerminalHandle({ junk: { deep: 'term_deep' } })).toBe('term_deep')
expect(extractTerminalHandle({ handle: 'not-a-term' })).toBeNull()
expect(extractTerminalHandle(null)).toBeNull()
})
it('builds worktree selectors from id/path', () => {
expect(
worktreeSelector({ id: 'repo::C:/Users/neil/orca/orca', path: 'C:/Users/neil/orca/orca' })
).toBe('id:repo::C:/Users/neil/orca/orca')
expect(worktreeSelector({ path: '/tmp/x' })).toBe('path:/tmp/x')
expect(worktreeSelector({})).toBeNull()
})
it('does not cap switch targets when max is 0 (regression for Math.max(2,0) bug)', () => {
expect(shouldCapSwitchTargets(0)).toBe(false)
expect(shouldCapSwitchTargets(-1)).toBe(false)
expect(shouldCapSwitchTargets(2)).toBe(true)
const many = Array.from({ length: 111 }, (_, i) => `term_${i}`)
expect(applySwitchTargetCap(many, 0)).toHaveLength(111)
expect(applySwitchTargetCap(many, 2)).toHaveLength(2)
})
it('classifies hard freeze at >=5000ms peak (individual or batch wall)', () => {
expect(evaluateFreezeSignals({ maxSwitchMs: 3874, maxBatchWallMs: 3874 }).hardFreeze).toBe(
false
)
expect(evaluateFreezeSignals({ maxSwitchMs: 3874, maxBatchWallMs: 3874 }).softFreeze).toBe(true)
const hardIndividual = evaluateFreezeSignals({ maxSwitchMs: 19954, maxBatchWallMs: 1000 })
expect(hardIndividual.hardFreeze).toBe(true)
expect(hardIndividual.peakLatencyMs).toBe(19954)
const hardBatch = evaluateFreezeSignals({ maxSwitchMs: 900, maxBatchWallMs: 20201 })
expect(hardBatch.hardFreeze).toBe(true)
expect(hardBatch.peakLatencyMs).toBe(20201)
})
it('evaluates naturalistic peaks without requiring parallel batch amp', () => {
expect(REALISTIC_SCENARIOS).toContain('idle-backlog-open')
expect(REALISTIC_SCENARIOS).toContain('idle-backlog-reconnect-open')
expect(REALISTIC_SCENARIOS).toContain('lockup-storm')
const soft = evaluateRealisticFreezeSignals({
maxOpenMs: 3200,
firstOpenMs: 2800,
reconnectRefreshMs: 900
})
expect(soft.softFreeze).toBe(true)
expect(soft.hardFreeze).toBe(false)
expect(soft.peakLatencyMs).toBe(3200)
const hardFromReconnect = evaluateRealisticFreezeSignals({
maxOpenMs: 800,
firstOpenMs: 700,
reconnectRefreshMs: 6200
})
expect(hardFromReconnect.hardFreeze).toBe(true)
expect(hardFromReconnect.peakLatencyMs).toBe(6200)
})
it('flags full-app freeze only for continuous unhealthy status window ≥30s', () => {
const healthy = evaluateFullAppFreeze({
statusSamples: [
{ tMs: 0, ms: 150, ok: true },
{ tMs: 2000, ms: 180, ok: true },
{ tMs: 4000, ms: 140, ok: true }
],
foreverWindowMs: 30_000,
statusSlowMs: 15_000
})
expect(healthy.foreverUiLockupObserved).toBe(false)
const forever = evaluateFullAppFreeze({
statusSamples: [
{ tMs: 0, ms: 16_000, ok: true },
{ tMs: 16_000, ms: 16_000, ok: true },
{ tMs: 32_000, ms: 16_000, ok: false, hang: true }
],
foreverWindowMs: 30_000,
statusSlowMs: 15_000
})
expect(forever.foreverUiLockupObserved).toBe(true)
expect(forever.longestUnhealthyWindowMs).toBeGreaterThanOrEqual(30_000)
expect(
evaluateFullAppFreeze({ statusSamples: [], killOnlyRecovery: true }).foreverUiLockupObserved
).toBe(true)
})
it('does not classify watchdog infrastructure errors as an app freeze', () => {
const result = evaluateFullAppFreeze({
statusSamples: Array.from({ length: 25 }, (_, index) => ({
tMs: index * 1500,
ms: 1,
ok: false,
infrastructureError: true,
error: 'spawn ENOENT'
}))
})
expect(result.foreverUiLockupObserved).toBe(false)
expect(result.unhealthySampleCount).toBe(0)
expect(result.infrastructureErrorCount).toBe(25)
})
it('preserves full-run watchdog peaks after sample retention rotates', () => {
const result = evaluateFullAppFreeze({
statusSamples: [{ tMs: 60_000, ms: 100, ok: true }],
statusSummary: {
sampleCount: 40,
maxStatusMs: 16_000,
unhealthySampleCount: 3,
infrastructureErrorCount: 2,
longestUnhealthyWindowMs: 32_000
},
foreverWindowMs: 30_000,
statusSlowMs: 15_000
})
expect(result.foreverUiLockupObserved).toBe(true)
expect(result.longestUnhealthyWindowMs).toBe(32_000)
expect(result.maxStatusMs).toBe(16_000)
expect(result.unhealthySampleCount).toBe(3)
expect(result.infrastructureErrorCount).toBe(2)
})
it('rejects invalid numeric environment values', () => {
process.env.ORCA_FREEZE_TEST_NUMBER = 'not-a-number'
expect(() => readFreezeNumberEnv('ORCA_FREEZE_TEST_NUMBER', 5)).toThrow(
'Invalid ORCA_FREEZE_TEST_NUMBER'
)
delete process.env.ORCA_FREEZE_TEST_NUMBER
expect(readFreezeNumberEnv('ORCA_FREEZE_TEST_NUMBER', 5)).toBe(5)
})
it('distinguishes recovered hard stall from permanent lockup', () => {
// Single reveal timeout with healthy status is NOT permanent app lockup.
expect(
evaluatePermanentLockup({
timedOutOps: 1,
statusHangMs: 0,
consecutiveSwitchFailures: 1,
openFailed: 1,
openTotal: 64
}).permanentLockup
).toBe(false)
expect(
evaluatePermanentLockup({
timedOutOps: 3,
statusHangMs: 0,
consecutiveSwitchFailures: 0,
openFailed: 3,
openTotal: 64
}).permanentLockup
).toBe(true)
expect(
evaluatePermanentLockup({
timedOutOps: 0,
statusHangMs: 60_000,
consecutiveSwitchFailures: 0,
permanentTimeoutMs: 60_000
}).permanentLockup
).toBe(true)
expect(
evaluatePermanentLockup({
timedOutOps: 0,
statusHangMs: 0,
consecutiveSwitchFailures: 5
}).permanentLockup
).toBe(true)
expect(
evaluatePermanentLockup({
timedOutOps: 0,
openFailed: 20,
openTotal: 40
}).permanentLockup
).toBe(true)
})
it('human pace delay stays within base+jitter', () => {
for (let i = 0; i < 20; i += 1) {
const d = humanPaceDelayMs(250, 150)
expect(d).toBeGreaterThanOrEqual(250)
expect(d).toBeLessThanOrEqual(400)
}
expect(humanPaceDelayMs(100, 0)).toBe(100)
})
it('reads the real hard-freeze lab report when present', async () => {
const { readdirSync, readFileSync, existsSync } = await import('node:fs')
const { resolve } = await import('node:path')
const reportDir = resolve(process.cwd(), 'test-results/freeze-repro')
if (!existsSync(reportDir)) {
// Local clones without lab artifacts still pass pure metrics tests above.
return
}
const reportName = readdirSync(reportDir).find(
(name) => name.startsWith('live-bulk-open-freeze-') && name.endsWith('.json')
)
if (reportName == null) {
return
}
const report = JSON.parse(readFileSync(resolve(reportDir, reportName), 'utf8'))
const evaluated = evaluateFreezeSignals({
maxSwitchMs: report.maxSwitchMs,
maxBatchWallMs: report.maxBatchWallMs ?? 0,
statusProbeMs: report.statusProbeMs ?? 0,
memoryProbeMs: report.memoryProbeMs,
softMs: report.softMs,
hardMs: report.hardMs
})
expect(evaluated.hardFreeze).toBe(report.hardFreeze)
expect(evaluated.peakLatencyMs).toBeGreaterThanOrEqual(5000)
expect(typeof report.environment).toBe('string')
expect(report.environment.length).toBeGreaterThan(0)
expect(report.switchTargets).toBeGreaterThan(50)
expect(report.parallel).toBeGreaterThanOrEqual(8)
})
})
@@ -0,0 +1,358 @@
#!/usr/bin/env node
/**
* Live freeze repro against a running Orca desktop + paired remote runtime.
*
* Models bulk-open of remote sessions under multi-worktree load.
*
* Usage:
* node config/scripts/live-remote-bulk-open-freeze-repro.mjs
* ORCA_FREEZE_ENV=paired-remote ORCA_FREEZE_CREATE=12 ORCA_FREEZE_SWITCH_PASSES=5 \
* ORCA_FREEZE_PARALLEL=8 node config/scripts/live-remote-bulk-open-freeze-repro.mjs
*/
import { spawnSync } from 'node:child_process'
import { mkdirSync, writeFileSync, copyFileSync } from 'node:fs'
import path from 'node:path'
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
import {
applySwitchTargetCap,
DEFAULT_HARD_MS,
DEFAULT_SOFT_MS,
evaluateFreezeSignals,
extractTerminalHandle,
readFreezeNumberEnv,
shouldCapSwitchTargets,
worktreeSelector
} from './live-remote-bulk-open-freeze-metrics.mjs'
import { createOrcaRpc } from './live-remote-freeze-rpc.mjs'
const root = path.resolve(import.meta.dirname, '../..')
const reportDir = path.join(root, 'test-results', 'freeze-repro')
const envName = process.env.ORCA_FREEZE_ENV || 'paired-remote'
const createCount = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_CREATE', 0))
const switchPasses = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_SWITCH_PASSES', 3))
const parallel = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_PARALLEL', 1))
// 0 = no cap (use all live terminals). Only positive env values limit targets.
const maxSwitchTargets = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_MAX_SWITCH_TARGETS', 0))
const softMs = readFreezeNumberEnv('ORCA_FREEZE_SOFT_MS', DEFAULT_SOFT_MS)
const hardMs = readFreezeNumberEnv('ORCA_FREEZE_HARD_MS', DEFAULT_HARD_MS)
const createWorktreeSpan = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_CREATE_WT_SPAN', 16))
const preFloodMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PRE_FLOOD_MS', 3000))
const scratchDir = process.env.ORCA_FREEZE_SCRATCH || ''
const { orcaJsonSync, orcaJsonAsync } = createOrcaRpc({ envName })
async function mapPool(items, concurrency, worker) {
const results = Array.from({ length: items.length })
let next = 0
async function run() {
while (next < items.length) {
const index = next
next += 1
results[index] = await worker(items[index], index)
}
}
const runners = Array.from({ length: Math.min(concurrency, items.length) }, () => run())
await Promise.all(runners)
return results
}
function sampleOrcaIfPossible() {
if (process.platform !== 'darwin') {
return null
}
try {
const status = orcaJsonSync(['status'], { local: true }).result
const pid = status?.app?.pid
if (!pid) {
return null
}
const out = path.join(reportDir, `orca-sample-${Date.now()}.txt`)
const sampled = spawnSync('sample', [String(pid), '5', '-file', out], {
timeout: 20_000,
stdio: 'ignore'
})
return sampled.status === 0 ? out : null
} catch {
return null
}
}
function floodCommand(marker) {
// Continuous 2KB frames @ ~8ms — agent-like remote output.
const script =
"const m=process.argv[1];process.stdout.write('READY:'+m+'\\n');let f=0;const c='A'.repeat(2048);setInterval(()=>{f++;process.stdout.write('BG:'+m+':'+f+':'+c+'\\n')},8);process.stdin.resume()"
return `node -e ${JSON.stringify(script)} ${JSON.stringify(marker)}`
}
async function main() {
mkdirSync(reportDir, { recursive: true })
const notes = []
const timings = new BoundedLiveFreezeHistory(120)
const amplificationSteps = []
console.log(
`[live-freeze] env=${envName} create=${createCount} passes=${switchPasses} parallel=${parallel}`
)
const status = orcaJsonSync(['status'])
notes.push(
`remote version=${status.result?.runtime?.appVersion} state=${status.result?.runtime?.state}`
)
const local = orcaJsonSync(['status'], { local: true })
notes.push(`local version=${local.result?.runtime?.appVersion} pid=${local.result?.app?.pid}`)
const worktrees = orcaJsonSync(['worktree', 'list']).result
const wtList = worktrees?.worktrees || worktrees?.items || worktrees || []
if (!Array.isArray(wtList) || wtList.length === 0) {
throw new Error(`No worktrees on environment ${envName}`)
}
notes.push(`remote worktrees=${wtList.length}`)
amplificationSteps.push(`baseline worktrees=${wtList.length}`)
const targets = wtList.slice(0, Math.min(createWorktreeSpan, wtList.length))
const created = []
// Parallel flood-terminal creates across many worktrees.
if (createCount > 0) {
amplificationSteps.push(`create=${createCount} parallel=${Math.min(parallel, createCount)}`)
const createJobs = Array.from({ length: createCount }, (_, i) => i)
await mapPool(createJobs, Math.min(parallel, createCount), async (i) => {
const wt = targets[i % targets.length]
const selector = worktreeSelector(wt)
if (!selector) {
notes.push(`create ${i} skipped: no selector`)
return
}
const marker = `LIVE_BULK_${Date.now()}_${i}`
try {
const createdTerm = await orcaJsonAsync(
[
'terminal',
'create',
'--worktree',
selector,
'--title',
`freeze-repro-${i}`,
'--command',
floodCommand(marker)
],
{ timeoutMs: 180_000 }
)
timings.add({ op: 'terminal.create', ms: createdTerm.elapsedMs, ok: true, index: i })
const handle = extractTerminalHandle(createdTerm.result)
if (handle) {
created.push({ handle, marker, worktree: selector })
console.log(
`[live-freeze] created ${handle} on ${selector} in ${createdTerm.elapsedMs.toFixed(0)}ms`
)
} else {
notes.push(
`create ${i} missing handle: ${JSON.stringify(createdTerm.result).slice(0, 400)}`
)
}
} catch (error) {
timings.add({ op: 'terminal.create', ms: null, ok: false, error: String(error), index: i })
notes.push(`create ${i} failed: ${String(error).slice(0, 300)}`)
console.warn(`[live-freeze] create failed: ${String(error)}`)
}
})
}
if (preFloodMs > 0 && created.length > 0) {
amplificationSteps.push(`preFloodMs=${preFloodMs}`)
await new Promise((r) => setTimeout(r, preFloodMs))
}
let live = []
try {
const listed = orcaJsonSync(['terminal', 'list'])
const terms = listed.result?.terminals || []
live = terms
.filter(
(t) => typeof t.handle === 'string' && t.handle.startsWith('term_') && t.connected !== false
)
.map((t) => ({ handle: t.handle, title: t.title, worktreeId: t.worktreeId }))
notes.push(`live terminals listed=${live.length}`)
} catch (error) {
notes.push(`terminal list failed: ${String(error).slice(0, 200)}`)
}
let switchTargets = [...created.map((c) => c.handle), ...live.map((t) => t.handle)].filter(
(v, i, a) => typeof v === 'string' && a.indexOf(v) === i
)
if (shouldCapSwitchTargets(maxSwitchTargets) && switchTargets.length > maxSwitchTargets) {
switchTargets = applySwitchTargetCap(switchTargets, maxSwitchTargets)
amplificationSteps.push(`capped switchTargets=${maxSwitchTargets}`)
}
if (switchTargets.length < 2) {
throw new Error(
`Need ≥2 terminals to bulk-switch; got ${switchTargets.length}. notes=${notes.join('; ')}`
)
}
amplificationSteps.push(
`switchTargets=${switchTargets.length} passes=${switchPasses} parallel=${parallel}`
)
console.log(
`[live-freeze] bulk-switching ${switchTargets.length} terminals × ${switchPasses} passes (parallel=${parallel})`
)
let maxSwitchMs = 0
let maxBatchWallMs = 0
let sumSwitchMs = 0
let switchCount = 0
const switchStarted = performance.now()
for (let pass = 0; pass < switchPasses; pass += 1) {
// Chunk targets into concurrent batches — piles load onto client/UI path.
for (let offset = 0; offset < switchTargets.length; offset += parallel) {
const batch = switchTargets.slice(offset, offset + parallel)
const batchStarted = performance.now()
const batchResults = await Promise.all(
batch.map(async (handle) => {
try {
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
timeoutMs: 90_000
})
return { handle, ms: sw.elapsedMs, ok: true }
} catch (error) {
return { handle, error: String(error), ok: false }
}
})
)
const batchWall = performance.now() - batchStarted
maxBatchWallMs = Math.max(maxBatchWallMs, batchWall)
for (const item of batchResults) {
if (item.ok) {
maxSwitchMs = Math.max(maxSwitchMs, item.ms)
sumSwitchMs += item.ms
switchCount += 1
timings.add({ op: 'terminal.switch', handle: item.handle, ms: item.ms, batchWall })
if (item.ms >= softMs) {
console.warn(`[live-freeze] SOFT lag on switch ${item.handle}: ${item.ms.toFixed(0)}ms`)
}
if (item.ms >= hardMs) {
console.warn(`[live-freeze] HARD lag on switch ${item.handle}: ${item.ms.toFixed(0)}ms`)
}
} else {
timings.add({ op: 'terminal.switch', handle: item.handle, error: item.error })
notes.push(`switch ${item.handle} failed: ${String(item.error).slice(0, 200)}`)
}
}
if (batchWall >= softMs) {
console.warn(`[live-freeze] SOFT batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`)
}
if (batchWall >= hardMs) {
console.warn(`[live-freeze] HARD batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`)
}
}
}
const bulkWallMs = performance.now() - switchStarted
const avgSwitchMs = switchCount ? sumSwitchMs / switchCount : 0
const statusProbe = orcaJsonSync(['status'], { local: true })
let memoryProbeMs = null
try {
const mem = orcaJsonSync(['diagnostics', 'memory'], { local: true, timeoutMs: 120_000 })
memoryProbeMs = mem.elapsedMs
notes.push(`memory diagnostic ms=${mem.elapsedMs.toFixed(0)}`)
} catch (error) {
notes.push(`memory diagnostic failed: ${String(error).slice(0, 200)}`)
}
const { peakLatencyMs, softFreeze, hardFreeze } = evaluateFreezeSignals({
maxSwitchMs,
maxBatchWallMs,
statusProbeMs: statusProbe.elapsedMs,
memoryProbeMs,
softMs,
hardMs
})
let samplePath = null
if (softFreeze || hardFreeze) {
samplePath = sampleOrcaIfPossible()
if (samplePath) {
notes.push(`sample=${samplePath}`)
} else {
notes.push('sample unavailable')
}
}
const report = {
topology: 'live-paired-remote',
environment: envName,
localVersion: local.result?.runtime?.appVersion,
remoteVersion: status.result?.runtime?.appVersion,
remoteWorktreeCount: wtList.length,
createdTerminals: created.length,
switchTargets: switchTargets.length,
switchPasses,
parallel,
maxSwitchMs,
maxBatchWallMs,
peakLatencyMs,
avgSwitchMs,
bulkWallMs,
statusProbeMs: statusProbe.elapsedMs,
memoryProbeMs,
softFreeze,
hardFreeze,
softMs,
hardMs,
amplificationSteps,
notes,
timingCount: timings.totalCount,
timings: timings.values()
}
const outPath = path.join(reportDir, `live-bulk-open-freeze-${envName}.json`)
writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`)
// Also write a stamped peak report so amplification runs don't overwrite history.
const stamped = path.join(reportDir, `live-bulk-open-freeze-${envName}-peak-${Date.now()}.json`)
writeFileSync(stamped, `${JSON.stringify(report, null, 2)}\n`)
console.log(`[live-freeze] report ${outPath}`)
console.log(`[live-freeze] stamped ${stamped}`)
console.log(JSON.stringify(report, null, 2))
if (scratchDir) {
try {
mkdirSync(scratchDir, { recursive: true })
copyFileSync(outPath, path.join(scratchDir, 'live-bulk-open-freeze-report.json'))
writeFileSync(
path.join(scratchDir, 'live-freeze-amplify-summary.json'),
`${JSON.stringify(
{
peakLatencyMs,
hardFreeze,
softFreeze,
amplificationSteps,
stamped
},
null,
2
)}\n`
)
} catch (error) {
notes.push(`scratch copy failed: ${String(error).slice(0, 200)}`)
}
}
if (hardFreeze) {
process.exitCode = 2
console.error('[live-freeze] HARD FREEZE SIGNAL')
} else if (softFreeze) {
process.exitCode = 1
console.error('[live-freeze] SOFT FREEZE SIGNAL')
} else {
console.log('[live-freeze] no freeze signal under thresholds')
}
}
main().catch((error) => {
console.error('[live-freeze] failed', error)
process.exit(3)
})
+241
View File
@@ -0,0 +1,241 @@
import { spawn, spawnSync } from 'node:child_process'
import path from 'node:path'
export const MAX_ORCA_RPC_OUTPUT_BYTES = 20 * 1024 * 1024
export function appendOrcaRpcOutput(output, chunk, bytes, limit = MAX_ORCA_RPC_OUTPUT_BYTES) {
const nextBytes = bytes + Buffer.byteLength(chunk)
return {
output: nextBytes > limit ? output : output + chunk,
bytes: nextBytes,
exceeded: nextBytes > limit
}
}
export function resolveOrcaCliCommand({ env = process.env, platform = process.platform } = {}) {
if (env.ORCA_CLI_COMMAND?.trim()) {
return env.ORCA_CLI_COMMAND.trim()
}
if (env.ORCA_DEV_REPO_ROOT) {
return 'orca-dev'
}
return platform === 'linux' ? 'orca-ide' : 'orca'
}
export function resolveOrcaCliInvocation({
env = process.env,
platform = process.platform,
nodeExecutable = process.execPath
} = {}) {
const command = resolveOrcaCliCommand({ env, platform })
const commandName = platform === 'win32' ? path.win32.basename(command).toLowerCase() : command
if (
platform === 'win32' &&
env.ORCA_DEV_REPO_ROOT &&
(commandName === 'orca-dev' || commandName === 'orca-dev.cmd')
) {
const defaultUserDataPath = path.win32.join(
env.APPDATA ?? path.win32.join(env.USERPROFILE ?? '', 'AppData', 'Roaming'),
'orca-dev'
)
return {
command: nodeExecutable,
prefixArgs: [path.win32.join(env.ORCA_DEV_REPO_ROOT, 'out', 'cli', 'index.js')],
env: {
...env,
ORCA_USER_DATA_PATH:
env.ORCA_USER_DATA_PATH ?? env.ORCA_DEV_USER_DATA_PATH ?? defaultUserDataPath,
ORCA_DEV_CLI_INVOCATION: '1',
ORCA_APP_EXECUTABLE:
env.ORCA_APP_EXECUTABLE ??
path.win32.join(
env.ORCA_DEV_REPO_ROOT,
'node_modules',
'electron',
'dist',
'electron.exe'
),
ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1'
}
}
}
return { command, prefixArgs: [] }
}
export function createOrcaRpc({
envName,
cliCommand,
env = process.env,
platform = process.platform
}) {
const cliInvocation = cliCommand
? { command: cliCommand, prefixArgs: [] }
: resolveOrcaCliInvocation({ env, platform })
const commandLabel = cliCommand ?? resolveOrcaCliCommand({ env, platform })
const commandArgs = (args, local) => [
...cliInvocation.prefixArgs,
...args,
...(local ? [] : ['--environment', envName]),
'--json'
]
function orcaJsonSync(args, opts = {}) {
const started = performance.now()
const result = spawnSync(cliInvocation.command, commandArgs(args, opts.local), {
encoding: 'utf8',
env: cliInvocation.env,
maxBuffer: MAX_ORCA_RPC_OUTPUT_BYTES,
timeout: opts.timeoutMs ?? 120_000
})
const elapsedMs = performance.now() - started
if (result.error) {
throw new Error(`${commandLabel} ${args.join(' ')} failed to start: ${String(result.error)}`)
}
if (result.status !== 0) {
throw new Error(
`${commandLabel} ${args.join(' ')} failed (${result.status}): ${result.stderr || result.stdout}`
)
}
const parsed = JSON.parse(result.stdout)
if (parsed.ok === false) {
throw new Error(`${commandLabel} ${args.join(' ')} ok=false: ${JSON.stringify(parsed)}`)
}
return { parsed, elapsedMs, result: parsed.result }
}
function orcaJsonAsync(args, opts = {}) {
const started = performance.now()
return new Promise((resolve, reject) => {
const child = spawn(cliInvocation.command, commandArgs(args, opts.local), {
env: cliInvocation.env,
stdio: ['ignore', 'pipe', 'pipe']
})
let stdout = ''
let stderr = ''
let outputBytes = 0
let settled = false
let timer
const fail = (error) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
reject(error)
}
const append = (stream, chunk) => {
if (settled) {
return stream
}
const appended = appendOrcaRpcOutput(stream, chunk, outputBytes)
outputBytes = appended.bytes
if (appended.exceeded) {
child.kill('SIGKILL')
fail(new Error(`${commandLabel} ${args.join(' ')} exceeded 20 MiB output limit`))
return stream
}
return appended.output
}
timer = setTimeout(() => {
child.kill('SIGKILL')
fail(
new Error(
`${commandLabel} ${args.join(' ')} timed out after ${opts.timeoutMs ?? 120_000}ms`
)
)
}, opts.timeoutMs ?? 120_000)
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk) => {
stdout = append(stdout, chunk)
})
child.stderr.on('data', (chunk) => {
stderr = append(stderr, chunk)
})
child.on('error', fail)
child.on('close', (code) => {
if (settled) {
return
}
clearTimeout(timer)
const elapsedMs = performance.now() - started
if (code !== 0) {
fail(
new Error(
`${commandLabel} ${args.join(' ')} failed (${code}): ${stderr || stdout}`.slice(
0,
800
)
)
)
return
}
try {
const parsed = JSON.parse(stdout)
if (parsed.ok === false) {
fail(
new Error(
`${commandLabel} ${args.join(' ')} ok=false: ${JSON.stringify(parsed)}`.slice(
0,
800
)
)
)
return
}
settled = true
resolve({ parsed, elapsedMs, result: parsed.result })
} catch (error) {
fail(
new Error(
`${commandLabel} parse failed: ${String(error)}; stdout=${stdout.slice(0, 400)}`
)
)
}
})
})
}
async function runReconnectRefreshStorm(notes) {
const started = performance.now()
const jobs = [
() => orcaJsonAsync(['status'], { timeoutMs: 90_000 }),
() => orcaJsonAsync(['worktree', 'list'], { timeoutMs: 120_000 }),
() => orcaJsonAsync(['terminal', 'list'], { timeoutMs: 120_000 }),
() => orcaJsonAsync(['status'], { local: true, timeoutMs: 60_000 }),
() => orcaJsonAsync(['worktree', 'list'], { timeoutMs: 120_000 }),
() => orcaJsonAsync(['terminal', 'list'], { timeoutMs: 120_000 })
]
const results = await Promise.all(
jobs.map(async (job, index) => {
try {
const result = await job()
return { index, ok: true, ms: result.elapsedMs }
} catch (error) {
notes.push(`reconnect-refresh job ${index} failed: ${String(error).slice(0, 200)}`)
return { index, ok: false, ms: null, error: String(error) }
}
})
)
const wallMs = performance.now() - started
const maxJobMs = Math.max(0, ...results.map((result) => result.ms || 0))
notes.push(
`reconnect-refresh wall=${wallMs.toFixed(0)}ms maxJob=${maxJobMs.toFixed(0)}ms ok=${results.filter((result) => result.ok).length}/${results.length}`
)
return { wallMs, maxJobMs, results }
}
async function runRestartProxy(notes) {
const started = performance.now()
try {
const opened = await orcaJsonAsync(['open'], { local: true, timeoutMs: 120_000 })
notes.push(`orca open ms=${opened.elapsedMs.toFixed(0)}`)
} catch (error) {
notes.push(`orca open failed: ${String(error).slice(0, 200)}`)
}
const storm = await runReconnectRefreshStorm(notes)
return { wallMs: performance.now() - started, storm }
}
return { orcaJsonSync, orcaJsonAsync, runReconnectRefreshStorm, runRestartProxy }
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import {
appendOrcaRpcOutput,
resolveOrcaCliCommand,
resolveOrcaCliInvocation
} from './live-remote-freeze-rpc.mjs'
describe('live remote freeze RPC', () => {
it('resolves the Orca CLI for managed, dev, Linux, and default runtimes', () => {
expect(resolveOrcaCliCommand({ env: { ORCA_CLI_COMMAND: 'custom-orca' } })).toBe('custom-orca')
expect(resolveOrcaCliCommand({ env: { ORCA_DEV_REPO_ROOT: '/repo' } })).toBe('orca-dev')
expect(resolveOrcaCliCommand({ env: {}, platform: 'linux' })).toBe('orca-ide')
expect(resolveOrcaCliCommand({ env: {}, platform: 'win32' })).toBe('orca')
})
it('bypasses the Windows dev cmd shim with the built Node CLI', () => {
const invocation = resolveOrcaCliInvocation({
env: {
APPDATA: 'C:\\Users\\dev\\AppData\\Roaming',
ORCA_CLI_COMMAND: 'C:\\repo\\out\\bin\\orca-dev.cmd',
ORCA_DEV_REPO_ROOT: 'C:\\repo'
},
platform: 'win32',
nodeExecutable: 'C:\\Program Files\\nodejs\\node.exe'
})
expect(invocation).toMatchObject({
command: 'C:\\Program Files\\nodejs\\node.exe',
prefixArgs: ['C:\\repo\\out\\cli\\index.js'],
env: {
ORCA_USER_DATA_PATH: 'C:\\Users\\dev\\AppData\\Roaming\\orca-dev',
ORCA_DEV_CLI_INVOCATION: '1',
ORCA_APP_EXECUTABLE: 'C:\\repo\\node_modules\\electron\\dist\\electron.exe',
ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1'
}
})
})
it('caps combined asynchronous output before retaining the overflow chunk', () => {
const first = appendOrcaRpcOutput('', '1234', 0, 5)
expect(first).toEqual({ output: '1234', bytes: 4, exceeded: false })
const overflow = appendOrcaRpcOutput(first.output, '67', first.bytes, 5)
expect(overflow).toEqual({ output: '1234', bytes: 6, exceeded: true })
})
})
@@ -0,0 +1,645 @@
#!/usr/bin/env node
/**
* Naturalistic freeze repro — idle/reconnect recovery stories on large remotes.
*
* Unlike the bulk parallel-switch amplifier, this models:
* 1) agents streaming on remote while user is idle (backlog builds)
* 2) user returns and opens sessions one-by-one (or after reconnect refresh)
*
* Scenarios:
* idle-backlog-open — idle with flood, then human-paced sequential open
* idle-backlog-reconnect-open — same + wake-like metadata refresh storm, then open
* restart-proxy — idle, then orca open + status/list storm + open
* (does NOT kill the desktop; proxies restore work)
*
* Usage:
* ORCA_FREEZE_ENV=paired-remote ORCA_FREEZE_SCENARIO=idle-backlog-open \
* node config/scripts/live-remote-realistic-freeze-repro.mjs
*
* pnpm run repro:live-remote-realistic-freeze
*/
import { spawnSync } from 'node:child_process'
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { createOrcaRpc } from './live-remote-freeze-rpc.mjs'
import { startStatusWatchdog } from './live-remote-status-watchdog.mjs'
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
import {
DEFAULT_FOREVER_WINDOW_MS,
DEFAULT_HARD_MS,
DEFAULT_SOFT_MS,
DEFAULT_STATUS_SLOW_MS,
evaluateFullAppFreeze,
evaluatePermanentLockup,
evaluateRealisticFreezeSignals,
extractTerminalHandle,
humanPaceDelayMs,
readFreezeNumberEnv,
REALISTIC_SCENARIOS,
worktreeSelector
} from './live-remote-bulk-open-freeze-metrics.mjs'
const root = path.resolve(import.meta.dirname, '../..')
const reportDir = path.join(root, 'test-results', 'freeze-repro')
const envName = process.env.ORCA_FREEZE_ENV || 'paired-remote'
const scenario = process.env.ORCA_FREEZE_SCENARIO || 'idle-backlog-open'
const createCount = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_CREATE', 0))
const openCount = Math.max(2, readFreezeNumberEnv('ORCA_FREEZE_OPEN_COUNT', 20))
const idleMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_IDLE_MS', 45_000))
const paceMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PACE_MS', 250))
const paceJitterMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PACE_JITTER_MS', 150))
const createWorktreeSpan = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_CREATE_WT_SPAN', 12))
const softMs = readFreezeNumberEnv('ORCA_FREEZE_SOFT_MS', DEFAULT_SOFT_MS)
const hardMs = readFreezeNumberEnv('ORCA_FREEZE_HARD_MS', DEFAULT_HARD_MS)
/** Concurrent opens during lockup-storm (wake refresh overlaps fan-out). */
const stormParallel = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_STORM_PARALLEL', 16))
/** Kill a switch if it exceeds this — counts toward permanent lockup. */
const opTimeoutMs = Math.max(10_000, readFreezeNumberEnv('ORCA_FREEZE_OP_TIMEOUT_MS', 60_000))
const permanentTimeoutMs = Math.max(15_000, readFreezeNumberEnv('ORCA_FREEZE_PERMANENT_MS', 60_000))
const foreverWindowMs = Math.max(
10_000,
readFreezeNumberEnv('ORCA_FREEZE_FOREVER_WINDOW_MS', DEFAULT_FOREVER_WINDOW_MS)
)
const statusSlowMs = Math.max(
5_000,
readFreezeNumberEnv('ORCA_FREEZE_STATUS_SLOW_MS', DEFAULT_STATUS_SLOW_MS)
)
const watchdogIntervalMs = Math.max(
500,
readFreezeNumberEnv('ORCA_FREEZE_WATCHDOG_INTERVAL_MS', 1500)
)
const scratchDir = process.env.ORCA_FREEZE_SCRATCH || ''
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
const rpc = createOrcaRpc({ envName })
const { orcaJsonSync, orcaJsonAsync, runReconnectRefreshStorm, runRestartProxy } = rpc
async function mapPool(items, concurrency, worker) {
const results = Array.from({ length: items.length })
let next = 0
async function run() {
while (next < items.length) {
const index = next
next += 1
results[index] = await worker(items[index], index)
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, Math.max(items.length, 1)) }, () => run())
)
return results
}
function floodCommand(marker) {
const script =
"const m=process.argv[1];process.stdout.write('READY:'+m+'\n');let f=0;const c='A'.repeat(2048);setInterval(()=>{f++;process.stdout.write('BG:'+m+':'+f+':'+c+'\n')},8);process.stdin.resume()"
return `node -e ${JSON.stringify(script)} ${JSON.stringify(marker)}`
}
function sampleOrcaIfPossible() {
if (process.platform !== 'darwin') {
return null
}
try {
const status = orcaJsonSync(['status'], { local: true }).result
const pid = status?.app?.pid
if (!pid) {
return null
}
const out = path.join(reportDir, `orca-sample-realistic-${Date.now()}.txt`)
const sampled = spawnSync('sample', [String(pid), '5', '-file', out], {
timeout: 20_000,
stdio: 'ignore'
})
return sampled.status === 0 ? out : null
} catch {
return null
}
}
function listLiveTerminalHandles() {
const listed = orcaJsonSync(['terminal', 'list'])
const terms = listed.result?.terminals || []
return terms
.filter((t) => typeof t.handle === 'string' && t.handle.startsWith('term_'))
.map((t) => ({
handle: t.handle,
title: t.title,
worktreeId: t.worktreeId,
connected: t.connected
}))
}
async function main() {
if (!REALISTIC_SCENARIOS.includes(scenario)) {
throw new Error(
`Unknown ORCA_FREEZE_SCENARIO=${scenario}. Expected one of: ${REALISTIC_SCENARIOS.join(', ')}`
)
}
mkdirSync(reportDir, { recursive: true })
const notes = []
const phases = []
const openTimings = new BoundedLiveFreezeHistory(100)
console.log(
`[realistic-freeze] scenario=${scenario} env=${envName} create=${createCount} idleMs=${idleMs} openCount=${openCount} paceMs=${paceMs}`
)
const local = orcaJsonSync(['status'], { local: true })
const remote = orcaJsonSync(['status'])
notes.push(
`local version=${local.result?.runtime?.appVersion} pid=${local.result?.app?.pid}`,
`remote version=${remote.result?.runtime?.appVersion} state=${remote.result?.runtime?.state}`
)
const worktrees = orcaJsonSync(['worktree', 'list']).result
const wtList = worktrees?.worktrees || worktrees?.items || worktrees || []
if (!Array.isArray(wtList) || wtList.length === 0) {
throw new Error(`No worktrees on environment ${envName}`)
}
notes.push(`remote worktrees=${wtList.length}`)
phases.push({ phase: 'baseline', worktrees: wtList.length })
// --- Phase: seed flood terminals (agent-like backlog sources) ---
const created = []
if (createCount > 0) {
const targets = wtList.slice(0, Math.min(createWorktreeSpan, wtList.length))
await mapPool(
Array.from({ length: createCount }, (_, i) => i),
Math.min(4, createCount),
async (i) => {
const wt = targets[i % targets.length]
const selector = worktreeSelector(wt)
if (!selector) {
return
}
const marker = `REALISTIC_${Date.now()}_${i}`
try {
const createdTerm = await orcaJsonAsync(
[
'terminal',
'create',
'--worktree',
selector,
'--title',
`realistic-freeze-${i}`,
'--command',
floodCommand(marker)
],
{ timeoutMs: 180_000 }
)
const handle = extractTerminalHandle(createdTerm.result)
if (handle) {
created.push({ handle, marker, worktree: selector })
console.log(
`[realistic-freeze] flood terminal ${handle} (${createdTerm.elapsedMs.toFixed(0)}ms)`
)
} else {
notes.push(
`create ${i} missing handle: ${JSON.stringify(createdTerm.result).slice(0, 300)}`
)
}
} catch (error) {
notes.push(`create ${i} failed: ${String(error).slice(0, 250)}`)
console.warn(`[realistic-freeze] create failed: ${String(error)}`)
}
}
)
phases.push({ phase: 'seed-flood', created: created.length })
}
// Prefer created floods for open pass; fill with existing live terminals.
let live = []
try {
live = listLiveTerminalHandles()
notes.push(`live terminals listed=${live.length}`)
} catch (error) {
notes.push(`terminal list failed: ${String(error).slice(0, 200)}`)
}
const openTargets = [...created.map((c) => c.handle), ...live.map((t) => t.handle)].filter(
(v, i, a) => typeof v === 'string' && a.indexOf(v) === i
)
if (openTargets.length < 2) {
throw new Error(`Need ≥2 terminals; got ${openTargets.length}. ${notes.join('; ')}`)
}
const openList = openTargets.slice(0, Math.min(openCount, openTargets.length))
// --- Phase: park — leave one session focused, rest accumulate flood while "away" ---
try {
const parkHandle = openList[0]
const parked = await orcaJsonAsync(['terminal', 'switch', '--terminal', parkHandle], {
timeoutMs: 60_000
})
notes.push(`park switch ms=${parked.elapsedMs.toFixed(0)} handle=${parkHandle}`)
} catch (error) {
notes.push(`park switch failed: ${String(error).slice(0, 200)}`)
}
console.log(`[realistic-freeze] idle ${idleMs}ms while remotes stream (user away / asleep)`)
const idleStarted = performance.now()
await sleep(idleMs)
phases.push({ phase: 'idle', idleMs, actualMs: performance.now() - idleStarted })
// --- Phase: recovery trigger ---
let reconnectRefreshMs = 0
let timedOutOps = 0
let consecutiveSwitchFailures = 0
let maxConsecutiveSwitchFailures = 0
if (scenario === 'idle-backlog-reconnect-open' || scenario === 'lockup-storm') {
console.log(
'[realistic-freeze] wake/reconnect proxy: parallel status/worktree/terminal refresh'
)
const storm = await runReconnectRefreshStorm(notes)
reconnectRefreshMs = Math.max(storm.wallMs, storm.maxJobMs)
phases.push({
phase: 'reconnect-refresh',
wallMs: storm.wallMs,
maxJobMs: storm.maxJobMs
})
} else if (scenario === 'restart-proxy') {
console.log('[realistic-freeze] restart proxy: orca open + refresh storm (no process kill)')
const restart = await runRestartProxy(notes)
reconnectRefreshMs = Math.max(restart.wallMs, restart.storm.wallMs, restart.storm.maxJobMs)
phases.push({
phase: 'restart-proxy',
wallMs: restart.wallMs,
reconnectWallMs: restart.storm.wallMs
})
}
// --- Phase: open sessions ---
// lockup-storm: overlap a second reconnect storm with concurrent switch fan-out
// (models wake + bulk session restore, not human serial clicks).
let maxOpenMs = 0
let firstOpenMs = 0
let sumOpenMs = 0
let openOk = 0
let maxBatchWallMs = 0
const openStarted = performance.now()
let statusWatch = null
if (scenario === 'lockup-storm') {
console.log(
`[realistic-freeze] LOCKUP STORM: concurrent open parallel=${stormParallel} + overlapping reconnect refresh (timeout=${opTimeoutMs}ms); mid-storm status watchdog every ${watchdogIntervalMs}ms`
)
statusWatch = startStatusWatchdog({
intervalMs: watchdogIntervalMs,
timeoutMs: Math.min(permanentTimeoutMs, foreverWindowMs),
statusSlowMs
})
// Fire reconnect storm again concurrently with first open wave.
const overlapStormPromise = runReconnectRefreshStorm(notes)
for (let offset = 0; offset < openList.length; offset += stormParallel) {
const batch = openList.slice(offset, offset + stormParallel)
const batchStarted = performance.now()
const batchResults = await Promise.all(
batch.map(async (handle, batchIndex) => {
const index = offset + batchIndex
try {
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
timeoutMs: opTimeoutMs
})
return { handle, index, ms: sw.elapsedMs, ok: true, timedOut: false }
} catch (error) {
const msg = String(error)
const timedOut = /timed out/i.test(msg)
return { handle, index, error: msg, ok: false, timedOut }
}
})
)
const batchWall = performance.now() - batchStarted
maxBatchWallMs = Math.max(maxBatchWallMs, batchWall)
for (const item of batchResults) {
if (item.ok) {
openOk += 1
sumOpenMs += item.ms
maxOpenMs = Math.max(maxOpenMs, item.ms)
if (item.index === 0 || firstOpenMs === 0) {
firstOpenMs = item.ms
}
consecutiveSwitchFailures = 0
openTimings.add({
handle: item.handle,
ms: item.ms,
index: item.index,
batchWall
})
if (item.ms >= hardMs) {
console.warn(
`[realistic-freeze] HARD open #${item.index} ${item.handle}: ${item.ms.toFixed(0)}ms`
)
}
} else {
if (item.timedOut) {
timedOutOps += 1
}
consecutiveSwitchFailures += 1
maxConsecutiveSwitchFailures = Math.max(
maxConsecutiveSwitchFailures,
consecutiveSwitchFailures
)
openTimings.add({
handle: item.handle,
error: item.error,
index: item.index,
timedOut: item.timedOut
})
notes.push(
`open ${item.handle} failed${item.timedOut ? ' (TIMEOUT)' : ''}: ${String(item.error).slice(0, 160)}`
)
console.warn(
`[realistic-freeze] open FAIL #${item.index}${item.timedOut ? ' TIMEOUT' : ''}: ${item.handle}`
)
}
}
if (batchWall >= hardMs) {
console.warn(
`[realistic-freeze] HARD batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`
)
}
}
try {
const overlap = await overlapStormPromise
reconnectRefreshMs = Math.max(reconnectRefreshMs, overlap.wallMs, overlap.maxJobMs)
phases.push({
phase: 'overlap-reconnect-refresh',
wallMs: overlap.wallMs,
maxJobMs: overlap.maxJobMs
})
} catch (error) {
notes.push(`overlap reconnect failed: ${String(error).slice(0, 200)}`)
}
phases.push({
phase: 'lockup-storm-open',
count: openList.length,
ok: openOk,
maxOpenMs,
firstOpenMs,
maxBatchWallMs,
timedOutOps,
parallel: stormParallel
})
} else {
console.log(
`[realistic-freeze] human-paced open of ${openList.length} sessions (pace≈${paceMs}ms + jitter)`
)
for (let i = 0; i < openList.length; i += 1) {
const handle = openList[i]
try {
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
timeoutMs: opTimeoutMs
})
openOk += 1
sumOpenMs += sw.elapsedMs
maxOpenMs = Math.max(maxOpenMs, sw.elapsedMs)
if (i === 0) {
firstOpenMs = sw.elapsedMs
}
consecutiveSwitchFailures = 0
openTimings.add({ handle, ms: sw.elapsedMs, index: i })
if (sw.elapsedMs >= softMs) {
console.warn(`[realistic-freeze] SOFT open #${i} ${handle}: ${sw.elapsedMs.toFixed(0)}ms`)
}
if (sw.elapsedMs >= hardMs) {
console.warn(`[realistic-freeze] HARD open #${i} ${handle}: ${sw.elapsedMs.toFixed(0)}ms`)
}
} catch (error) {
const msg = String(error)
const timedOut = /timed out/i.test(msg)
if (timedOut) {
timedOutOps += 1
}
consecutiveSwitchFailures += 1
maxConsecutiveSwitchFailures = Math.max(
maxConsecutiveSwitchFailures,
consecutiveSwitchFailures
)
openTimings.add({ handle, error: msg, index: i, timedOut })
notes.push(`open ${handle} failed${timedOut ? ' (TIMEOUT)' : ''}: ${msg.slice(0, 200)}`)
}
if (i < openList.length - 1) {
await sleep(humanPaceDelayMs(paceMs, paceJitterMs))
}
}
phases.push({
phase: 'human-paced-open',
count: openList.length,
ok: openOk,
maxOpenMs,
firstOpenMs,
openWallMs: performance.now() - openStarted
})
}
const openWallMs = performance.now() - openStarted
let midStormWatch = {
samples: [],
durationMs: 0,
sampleCount: 0,
maxStatusMs: 0,
unhealthySampleCount: 0,
infrastructureErrorCount: 0,
longestUnhealthyWindowMs: 0
}
if (statusWatch) {
midStormWatch = await statusWatch.stop()
notes.push(
`mid-storm status samples=${midStormWatch.sampleCount} durationMs=${midStormWatch.durationMs.toFixed(0)}`
)
phases.push({
phase: 'mid-storm-status-watchdog',
samples: midStormWatch.sampleCount,
durationMs: midStormWatch.durationMs,
maxStatusMs: midStormWatch.maxStatusMs
})
}
// Post-storm health: does local status still answer?
let statusProbeMs = null
let statusHangMs = 0
const statusStarted = performance.now()
try {
const statusProbe = await orcaJsonAsync(['status'], {
local: true,
timeoutMs: permanentTimeoutMs
})
statusProbeMs = statusProbe.elapsedMs
} catch (error) {
statusHangMs = performance.now() - statusStarted
notes.push(
`status probe FAILED after ${statusHangMs.toFixed(0)}ms: ${String(error).slice(0, 200)}`
)
console.error(`[realistic-freeze] status probe failed — possible permanent lockup`)
}
let memoryProbeMs = null
try {
const mem = await orcaJsonAsync(['diagnostics', 'memory'], {
local: true,
timeoutMs: permanentTimeoutMs
})
memoryProbeMs = mem.elapsedMs
notes.push(`memory diagnostic ms=${mem.elapsedMs.toFixed(0)}`)
} catch (error) {
notes.push(`memory diagnostic failed: ${String(error).slice(0, 200)}`)
}
const peakForSignals = Math.max(maxOpenMs, firstOpenMs, maxBatchWallMs)
const signals = evaluateRealisticFreezeSignals({
maxOpenMs: peakForSignals,
firstOpenMs,
reconnectRefreshMs,
statusProbeMs: statusProbeMs ?? 0,
memoryProbeMs,
softMs,
hardMs
})
const lockup = evaluatePermanentLockup({
timedOutOps,
statusHangMs,
consecutiveSwitchFailures: maxConsecutiveSwitchFailures,
openFailed: openList.length - openOk,
openTotal: openList.length,
permanentTimeoutMs
})
const fullApp = evaluateFullAppFreeze({
statusSamples: midStormWatch.samples,
statusSummary: midStormWatch,
foreverWindowMs,
statusSlowMs
})
const watchdogInfrastructureErrorCount = midStormWatch.infrastructureErrorCount
if (statusHangMs >= foreverWindowMs) {
fullApp.foreverUiLockupObserved = true
fullApp.longestUnhealthyWindowMs = Math.max(fullApp.longestUnhealthyWindowMs, statusHangMs)
fullApp.reason = `post-storm status hang ${statusHangMs.toFixed(0)}ms`
}
const recoveredHardStall = signals.hardFreeze && !fullApp.foreverUiLockupObserved && openOk > 0
let samplePath = null
if (signals.softFreeze || signals.hardFreeze || fullApp.foreverUiLockupObserved) {
samplePath = sampleOrcaIfPossible()
if (samplePath) {
notes.push(`sample=${samplePath}`)
} else {
notes.push('sample unavailable')
}
}
const storyByScenario = {
'idle-backlog-open': 'User away while remotes stream; returns and opens sessions one-by-one.',
'idle-backlog-reconnect-open':
'User away; wake-like reconnect metadata storm; then opens sessions.',
'restart-proxy': 'User away; restart-proxy discovery; then opens sessions.',
'lockup-storm':
'Idle flood + reconnect refresh + concurrent open + mid-storm status watchdog (full-app freeze bar).'
}
const report = {
topology: 'live-paired-remote-realistic',
scenario,
story: storyByScenario[scenario] || scenario,
environment: envName,
localVersion: local.result?.runtime?.appVersion,
remoteVersion: remote.result?.runtime?.appVersion,
remoteWorktreeCount: wtList.length,
createdFloodTerminals: created.length,
openTargets: openList.length,
idleMs,
paceMs,
paceJitterMs,
stormParallel: scenario === 'lockup-storm' ? stormParallel : 1,
firstOpenMs,
maxOpenMs,
maxBatchWallMs,
avgOpenMs: openOk ? sumOpenMs / openOk : 0,
openWallMs,
openOk,
openFailed: openList.length - openOk,
reconnectRefreshMs,
peakLatencyMs: Math.max(signals.peakLatencyMs, maxBatchWallMs),
statusProbeMs,
statusHangMs,
memoryProbeMs,
softFreeze: signals.softFreeze,
hardFreeze: signals.hardFreeze,
recoveredHardStall,
permanentLockup: lockup.permanentLockup,
foreverUiLockupObserved: fullApp.foreverUiLockupObserved,
foreverFreeze: fullApp,
midStormStatusSamples: midStormWatch.samples,
midStormStatusSampleCount: midStormWatch.sampleCount,
watchdogInfrastructureErrorCount,
timedOutOps,
maxConsecutiveSwitchFailures,
softMs,
hardMs,
foreverWindowMs,
statusSlowMs,
permanentTimeoutMs,
opTimeoutMs,
phases,
notes,
openTimingCount: openTimings.totalCount,
openTimings: openTimings.values()
}
const outPath = path.join(reportDir, `live-realistic-freeze-${envName}-${scenario}.json`)
const stamped = path.join(
reportDir,
`live-realistic-freeze-${envName}-${scenario}-peak-${Date.now()}.json`
)
writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`)
writeFileSync(stamped, `${JSON.stringify(report, null, 2)}\n`)
console.log(`[realistic-freeze] report ${outPath}`)
console.log(JSON.stringify(report, null, 2))
if (scratchDir) {
try {
mkdirSync(scratchDir, { recursive: true })
copyFileSync(outPath, path.join(scratchDir, 'live-realistic-freeze-report.json'))
} catch (error) {
console.warn(`[realistic-freeze] scratch copy failed: ${String(error)}`)
}
}
if (watchdogInfrastructureErrorCount > 0) {
process.exitCode = 3
console.error('[realistic-freeze] WATCHDOG INFRASTRUCTURE FAILURE')
} else if (fullApp.foreverUiLockupObserved) {
process.exitCode = 5
console.error('[realistic-freeze] FULL-APP FOREVER FREEZE (status unhealthy ≥ forever window)')
} else if (lockup.permanentLockup) {
process.exitCode = 4
console.error(
'[realistic-freeze] PERMANENT LOCKUP HEURISTIC (timeouts/fail-rate) — check foreverUiLockupObserved'
)
} else if (signals.hardFreeze) {
process.exitCode = 2
console.error(
'[realistic-freeze] HARD FREEZE SIGNAL (recovered multi-second stall — not forever lockup)'
)
} else if (signals.softFreeze) {
process.exitCode = 1
console.error('[realistic-freeze] SOFT FREEZE SIGNAL')
} else {
console.log('[realistic-freeze] no freeze signal under thresholds')
}
}
main().catch((error) => {
console.error('[realistic-freeze] failed', error)
process.exit(3)
})
@@ -0,0 +1,147 @@
/**
* Mid-storm host health samples for forever-freeze detection.
* Polls `orca status --json` on an interval while a load storm runs.
*/
import { spawn } from 'node:child_process'
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
import { resolveOrcaCliInvocation } from './live-remote-freeze-rpc.mjs'
/**
* @param {{ intervalMs?: number, timeoutMs?: number, cliCommand?: string, sampleHistoryLimit?: number, statusSlowMs?: number }} opts
*/
export function startStatusWatchdog(opts = {}) {
const intervalMs = opts.intervalMs ?? 2000
const timeoutMs = opts.timeoutMs ?? 30_000
const cliInvocation = opts.cliCommand
? { command: opts.cliCommand, prefixArgs: [] }
: resolveOrcaCliInvocation()
const samples = new BoundedLiveFreezeHistory(opts.sampleHistoryLimit ?? 240)
const statusSlowMs = opts.statusSlowMs ?? 15_000
let stopped = false
let inFlight = false
let infrastructureErrorCount = 0
let longestUnhealthyWindowMs = 0
let maxStatusMs = 0
let runStartMs = null
let unhealthySampleCount = 0
const startedAt = performance.now()
const record = (sample) => {
samples.add(sample)
maxStatusMs = Math.max(maxStatusMs, sample.ms || 0)
if (sample.infrastructureError) {
infrastructureErrorCount += 1
}
const unhealthy =
!sample.infrastructureError &&
(Boolean(sample.hang) || sample.ok === false || (sample.ms || 0) >= statusSlowMs)
if (!unhealthy) {
runStartMs = null
return
}
unhealthySampleCount += 1
runStartMs ??= sample.tMs ?? 0
longestUnhealthyWindowMs = Math.max(
longestUnhealthyWindowMs,
(sample.tMs ?? 0) + (sample.ms || 0) - runStartMs
)
}
const summary = () => ({
sampleCount: samples.totalCount,
maxStatusMs,
unhealthySampleCount,
infrastructureErrorCount,
longestUnhealthyWindowMs
})
const probe = () =>
new Promise((resolve) => {
const t0 = performance.now()
const child = spawn(
cliInvocation.command,
[...cliInvocation.prefixArgs, 'status', '--json'],
{
env: cliInvocation.env,
stdio: ['ignore', 'pipe', 'pipe']
}
)
let settled = false
const finish = (result) => {
if (settled) {
return
}
settled = true
resolve(result)
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
finish({
tMs: t0 - startedAt,
ms: performance.now() - t0,
ok: false,
hang: true
})
}, timeoutMs)
child.stdout.on('data', () => {})
child.stderr.on('data', () => {})
child.on('error', (error) => {
clearTimeout(timer)
finish({
tMs: t0 - startedAt,
ms: performance.now() - t0,
ok: false,
hang: false,
infrastructureError: true,
error: String(error)
})
})
child.on('close', (code) => {
clearTimeout(timer)
finish({
tMs: t0 - startedAt,
ms: performance.now() - t0,
ok: code === 0,
hang: false
})
})
})
const tick = async ({ force = false } = {}) => {
if ((!force && stopped) || inFlight) {
return
}
inFlight = true
try {
const sample = await probe()
record(sample)
} finally {
inFlight = false
}
}
const interval = setInterval(() => {
void tick()
}, intervalMs)
void tick()
return {
stop: async () => {
stopped = true
clearInterval(interval)
// Wait for in-flight probe, then force one final sample.
const deadline = performance.now() + timeoutMs + 1000
while (inFlight && performance.now() < deadline) {
await new Promise((r) => setTimeout(r, 20))
}
await tick({ force: true })
return {
samples: samples.values(),
...summary(),
durationMs: performance.now() - startedAt
}
},
getSamples: () => samples.values(),
getSummary: summary
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { startStatusWatchdog } from './live-remote-status-watchdog.mjs'
describe('startStatusWatchdog', () => {
it('collects status samples and stops cleanly', async () => {
// Real path: actually invokes `orca status --json` (must be available in CI/dev with Orca or fail soft).
const watch = startStatusWatchdog({ intervalMs: 50, timeoutMs: 5_000 })
await new Promise((r) => setTimeout(r, 180))
const result = await watch.stop()
expect(result.samples.length).toBeGreaterThanOrEqual(1)
expect(result.durationMs).toBeGreaterThan(0)
for (const s of result.samples) {
expect(typeof s.ms).toBe('number')
expect(typeof s.ok).toBe('boolean')
expect(typeof s.hang).toBe('boolean')
}
})
it('marks CLI spawn failures as infrastructure errors', async () => {
const watch = startStatusWatchdog({
intervalMs: 50,
timeoutMs: 1000,
cliCommand: 'orca-freeze-watchdog-missing-command'
})
const result = await watch.stop()
expect(result.samples.length).toBeGreaterThanOrEqual(1)
expect(result.samples.every((sample) => sample.infrastructureError === true)).toBe(true)
expect(result.samples.every((sample) => sample.hang === false)).toBe(true)
})
it('bounds retained samples while preserving full-run counters', async () => {
const watch = startStatusWatchdog({
intervalMs: 50,
timeoutMs: 1000,
cliCommand: 'orca-freeze-watchdog-missing-command',
sampleHistoryLimit: 1
})
const result = await watch.stop()
expect(result.samples).toHaveLength(1)
expect(result.sampleCount).toBeGreaterThan(result.samples.length)
expect(result.infrastructureErrorCount).toBe(result.sampleCount)
expect(result.maxStatusMs).toBeGreaterThanOrEqual(0)
})
})
@@ -134,5 +134,30 @@ export const CROSS_LOCALE_KEY_OVERRIDES = {
'auto.hooks.useSettingsNavigationMetadata.40d80bad8a': {
zh: '测试版',
ja: 'ベータ'
},
// Issue/PR state picker; it sits beside 已关闭, so 进行中 is a different state.
'auto.components.PullRequestPage.7b8f6bf6d8': {
zh: '开放'
},
// This "Open" is the button that opens the config file, not an issue state.
'auto.components.settings.McpConfigFileRow.e720c139cd': {
zh: '打开'
},
// Terminal cursor-color group: the on-screen cursor, not the Cursor editor.
'auto.components.settings.TerminalWindowSection.c9e1fdf42f': {
ko: '커서',
zh: '光标'
},
'auto.components.onboarding.ThemeStep.ab2a583a97': {
ko: '커서',
zh: '光标'
},
// Tailwind swatch labels: bare 天空 (the sky), 锌 and 空 (the metal, the sky) are not colors.
'auto.components.sidebar.workspace.status.6437a8c253': {
zh: '天蓝',
ja: '空色'
},
'auto.components.sidebar.workspace.status.caabd5ca85': {
zh: '锌灰'
}
}
+102
View File
@@ -0,0 +1,102 @@
// Ordinary UI vocabulary that reads like a brand but is not one. Every locale should translate
// these, so the policy must neither pin the whole value to English nor revert the translation
// inside a sentence. The product-name homonyms (Continue.dev, the agent entries) stay Latin
// through ENGLISH_ONLY_KEY_PREFIXES, which matches on the catalog key rather than the word.
// Renderings a locale is expected to use. They are translations, never machine-translation
// errors, so a brand revert must leave them alone. Nonsense forms that share the same English
// term (zh 回购 "repurchase agreement", ja 端子 "electrical connector", es "Comprometerse")
// stay in BRAND_MISTRANSLATIONS and keep getting reverted.
const GENERIC_TERM_FAMILIES = [
{
terms: ['Agent', 'Agents', 'agent', 'agents'],
renderings: {
ko: ['에이전트'],
ja: ['エージェント'],
zh: ['代理', '智能体'],
es: ['Agente', 'agente', 'Agentes', 'agentes']
}
},
{
terms: ['Commit', 'Commits', 'commit', 'commits'],
renderings: {
ko: ['커밋'],
ja: ['コミット'],
zh: ['提交'],
es: [
'Confirmación',
'confirmación',
'Confirmaciones',
'confirmaciones',
'Confirmar',
'confirmar'
]
}
},
{
terms: ['Continue'],
renderings: {
ko: ['계속하다', '계속'],
ja: ['続ける', '続行'],
zh: ['继续'],
es: ['Continuar', 'continuar']
}
},
{
terms: ['Repo', 'Repos', 'repo', 'repos'],
renderings: {
ko: ['저장소', '레포'],
ja: ['リポジトリ', 'リポ'],
zh: ['存储库', '仓库'],
es: ['Repositorio', 'repositorio', 'Repositorios', 'repositorios']
}
},
{
terms: ['Terminal', 'Terminals', 'terminal', 'terminals'],
renderings: {
ko: ['터미널'],
ja: ['ターミナル'],
zh: ['终端'],
es: ['Terminales', 'terminales']
}
}
]
export const LOCALIZABLE_GENERIC_TERMS = new Set(
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms)
)
const RENDERINGS_BY_TERM = new Map(
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms.map((term) => [term, family.renderings]))
)
export function isLocalizableGenericTerm(term) {
return LOCALIZABLE_GENERIC_TERMS.has(term)
}
export function isCanonicalGenericRendering(term, locale, form) {
return RENDERINGS_BY_TERM.get(term)?.[locale]?.includes(form) ?? false
}
export function canonicalGenericRenderings(locale) {
return GENERIC_TERM_FAMILIES.flatMap((family) =>
(family.renderings[locale] ?? []).map((form) => ({ form, terms: family.terms }))
)
}
function spansOf(value, needle) {
const spans = []
for (let at = value.indexOf(needle); at !== -1; at = value.indexOf(needle, at + 1)) {
spans.push([at, at + needle.length])
}
return spans
}
// Why: zh 终端子进程 ("terminal sub-process") contains 端子, so a blind revert of the
// electrical-connector mistranslation would cut a valid word in half.
export function overlapsCanonicalRendering(term, locale, value, start, end) {
const renderings = RENDERINGS_BY_TERM.get(term)?.[locale] ?? []
return renderings.some((form) =>
spansOf(value, form).some(([from, to]) => start < to && from < end)
)
}
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import { LOCALIZABLE_GENERIC_TERMS } from './locale-generic-ui-terms.mjs'
import { NEVER_TRANSLATE_VALUES, repairTranslatedValue } from './locale-translation-policy.mjs'
// Why: #12113 — repair-locale-catalog rewrote ~2000 translated values back to English because
// generic UI words (agent, terminal, commit, repo, Continue) were treated as brands. These pin
// the boundary: generic words stay translated, real brand names stay Latin.
describe('locale generic UI terms', () => {
it('keeps a translated generic term when the whole value is that term', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.AppearancePane.terminalTitle',
enValue: 'Terminal',
localeValue: '터미널',
locale: 'ko'
})
).toBe('터미널')
expect(
repairTranslatedValue({
key: 'auto.components.settings.QuickCommandsPane.4ccc63da87',
enValue: 'Agent',
localeValue: '에이전트',
locale: 'ko'
})
).toBe('에이전트')
expect(
repairTranslatedValue({
key: 'auto.components.mobile.MobileHero.a8fb43cf1c',
enValue: 'Continue',
localeValue: '继续',
locale: 'zh'
})
).toBe('继续')
})
it('keeps a translated generic term inside a sentence', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.EditorFontSection.matchTerminal',
enValue: 'Match terminal font',
localeValue: '터미널 글꼴과 동일',
locale: 'ko'
})
).toBe('터미널 글꼴과 동일')
expect(
repairTranslatedValue({
key: 'auto.components.settings.ExperimentalPane.agentDashboard.toggleLabel',
enValue: 'Toggle Agent Dashboard',
localeValue: '에이전트 대시보드 전환',
locale: 'ko'
})
).toBe('에이전트 대시보드 전환')
expect(
repairTranslatedValue({
key: 'components.agentSessionContinuation.dialogTitle',
enValue: 'Continue in New Session',
localeValue: '新しいセッションで続ける',
locale: 'ja'
})
// 新しい → 新規 is an unrelated phrase fix; 続ける is what must survive.
).toBe('新規セッションで続ける')
})
it('still reverts real brand names that machine translation localized', () => {
expect(
repairTranslatedValue({
key: 'auto.stats.StatsPane.7d26110cea',
enValue: 'Codex',
localeValue: '사본',
locale: 'ko'
})
).toBe('Codex')
expect(
repairTranslatedValue({
key: 'auto.components.settings.appearance.search.9ae151b26b',
enValue: 'linear',
localeValue: '线性',
locale: 'zh'
})
).toBe('Linear')
expect(
repairTranslatedValue({
key: 'auto.components.settings.NetworkPane.tailscale',
enValue: 'Connect over Tailscale',
localeValue: '通过尾鳞连接',
locale: 'zh'
})
).toBe('通过 Tailscale 连接')
})
it('still reverts nonsense renderings of a generic term', () => {
// 端子 is an electrical connector, 回购 a repurchase agreement, Comprometerse "to pledge".
expect(
repairTranslatedValue({
key: 'auto.components.agent.AgentCombobox.986f946354',
enValue: 'Blank Terminal',
localeValue: '空白端子',
locale: 'zh'
})
).toBe('空白 Terminal')
expect(
repairTranslatedValue({
key: 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
enValue: 'Commit',
localeValue: 'Comprometerse',
locale: 'es'
})
).toBe('Commit')
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
enValue: 'Markdown',
localeValue: '가격 인하',
locale: 'ko'
})
).toBe('Markdown')
})
it('does not cut a valid word that contains a nonsense rendering', () => {
// 终端子进程 is "terminal sub-process" — 端子 sits inside 终端 + 子.
expect(
repairTranslatedValue({
key: 'auto.components.settings.AdvancedNetworkSettingsSection.823e0f15b1',
enValue: 'Proxy URL for Orca network requests and local terminal subprocesses.',
localeValue: '用于 Orca 网络请求和本地终端子进程的代理 URL。',
locale: 'zh'
})
).toBe('用于 Orca 网络请求和本地终端子进程的代理 URL。')
})
it('still fills in a translation when the catalog holds English', () => {
expect(
repairTranslatedValue({
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
enValue: 'Terminal 1',
localeValue: 'Terminal 1',
locale: 'ko'
})
).toBe('터미널 1')
expect(
repairTranslatedValue({
key: 'auto.store.slices.worktrees.889487d8bb',
enValue: 'Dismiss',
localeValue: '해고하다',
locale: 'ko'
})
).toBe('닫기')
})
it('keeps agent catalog product names in English by key, not by word', () => {
expect(
repairTranslatedValue({
key: 'auto.lib.agent.catalog.9e2a9bb87b',
enValue: 'Continue',
localeValue: '继续',
locale: 'zh'
})
).toBe('Continue')
expect(
repairTranslatedValue({
key: 'auto.lib.agent.catalog.760bc6883d',
enValue: 'Codex',
localeValue: '사본',
locale: 'ko'
})
).toBe('Codex')
})
it('does not list a generic term as never-translatable', () => {
const pinned = [...LOCALIZABLE_GENERIC_TERMS].filter((term) => NEVER_TRANSLATE_VALUES.has(term))
expect(pinned).toEqual([])
})
})
+2 -2
View File
@@ -94,7 +94,7 @@ export const JA_VALUE_OVERRIDES = {
'Launch plan': '起動プラン',
'Launch agent': 'エージェントを起動',
'Launch {{value0}} in a new terminal': '新規ターミナルで {{value0}} を起動',
Play: 'Play',
Play: '再生',
Action: '操作',
Actions: '操作',
action: '操作',
@@ -135,7 +135,7 @@ export const JA_VALUE_OVERRIDES = {
'Pick a base branch below': '以下のベースブランチを選択',
'Choose floating workspace directory': 'フローティング ワークスペース ディレクトリを選択',
'Local project, Git repo, or folder with many repos':
'ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー',
'ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
'Enter passphrase': 'パスフレーズを入力',
'Enter password': 'パスワードを入力',
'Enter the passphrase for': 'のパスフレーズを入力',
+6 -5
View File
@@ -162,8 +162,8 @@ const BASE_LOCALE_KEY_OVERRIDES = {
ja: 'クローズ'
},
'auto.components.GitHubItemDialog.dc1ca081a8': {
ko: '진행 중',
zh: '进行中',
ko: '열림',
zh: '开放',
ja: 'オープン'
},
'auto.components.tab.bar.TabBarCreateEntry.b27864279e': {
@@ -176,10 +176,11 @@ const BASE_LOCALE_KEY_OVERRIDES = {
zh: '打开 Linear 任务',
ja: 'Linear タスクを開く'
},
// Onboarding pill beside Orca Mobile: "New" marks a new feature, not a create action.
'auto.components.sidebar.SidebarNav.c86d83b5c3': {
ko: '새로 만들기',
zh: '新建',
ja: '新規'
ko: '신규',
zh: '新功能',
ja: '新機能'
},
'auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b': {
ko: 'Discord',
+54 -3
View File
@@ -752,6 +752,21 @@
"auto.components.contextual.tours.ContextualTourProgressDots.dcd6e6b03e": {
"ko": "{{value1}}단계 중 {{value0}}단계"
},
"auto.components.contextual.tours.ContextualTourOverlaySurface.complete": {
"ko": "완료"
},
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.body": {
"ko": "자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요."
},
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.title": {
"ko": "자동화란 무엇인가요?"
},
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.body": {
"ko": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다."
},
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.title": {
"ko": "결과 확인"
},
"auto.components.crash.report.CrashReportDialog.88fea8e84e": {
"ko": "보내지 않음"
},
@@ -1868,6 +1883,9 @@
"auto.components.right.sidebar.SourceControl.3a231c845b": {
"ko": "알 수 없음"
},
"auto.components.right.sidebar.SourceControl.6d7f2a47e5": {
"ko": "폴더의 변경 사항 취소"
},
"auto.components.right.sidebar.SourceControl.812cb992ee": {
"ko": "{{value0}}에서 열기"
},
@@ -1880,6 +1898,9 @@
"auto.components.right.sidebar.SourceControl.9bb062a886": {
"ko": "commit 되지 않음"
},
"auto.components.right.sidebar.SourceControl.a5e5a11090": {
"ko": "모든 변경 사항 취소 실패 — 취소 전에 파일의 스테이징을 해제하지 못했습니다."
},
"auto.components.right.sidebar.SourceControl.aaf1451654": {
"ko": "초안 {{value0}} 만들기"
},
@@ -1994,6 +2015,9 @@
"auto.components.right.sidebar.source.control.discard.confirmation.2ae5a785b3": {
"ko": "스테이징되지 않은 변경 사항을 모두 삭제하시겠습니까?"
},
"auto.components.right.sidebar.source.control.discard.confirmation.40e9357b2a": {
"ko": "이렇게 하면 HEAD에서 파일을 복원하고 파일 삭제를 취소합니다. 이 작업은 취소할 수 없습니다."
},
"auto.components.right.sidebar.source.control.discard.confirmation.5ddd8cac7f": {
"ko": "스테이징된 변경 사항을 모두 삭제하시겠습니까?"
},
@@ -2015,6 +2039,9 @@
"auto.components.right.sidebar.source.control.primary.action.1d47e850cf": {
"ko": "연결된 리뷰 브랜치에 업데이트 푸시"
},
"auto.components.right.sidebar.source.control.primary.action.5a477d80cb": {
"ko": "모든 변경 사항 스테이징"
},
"auto.components.right.sidebar.source.control.primary.action.a5d1f7b036": {
"ko": "{{value0}}을(를) 만들기 전에 commits을 게시하세요."
},
@@ -2162,6 +2189,9 @@
"auto.components.settings.AutoRenameBranchFromWorkSetting.a4fa380b67": {
"ko": "{assistantMessage}"
},
"auto.components.settings.AutoRenameBranchFromWorkSetting.d9b65054ef": {
"ko": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca가 직접 이름 붙인 브랜치만 이름을 바꾸며, 푸시된 후에는 이름을 바꾸지 않습니다."
},
"auto.components.settings.AutoRenameBranchPromptEditor.ebb942a2ec": {
"ko": "fix-login-flow"
},
@@ -2228,6 +2258,9 @@
"auto.components.settings.DefaultWindowsProjectRuntimeSetting.wslUnavailable": {
"ko": "WSL을 사용할 수 없습니다. WSL을 상속하는 프로젝트는 복구가 필요합니다."
},
"auto.components.settings.DevToolsPane.orcaCloudDescription": {
"ko": "자사 클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다."
},
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
"ko": "마이크"
},
@@ -2255,6 +2288,12 @@
"auto.components.settings.ExperimentalPane.newWorktreeCardStyle.description": {
"ko": "업데이트된 워크트리 카드 레이아웃, 메타데이터 배치, 카드 표시 메뉴 옵션 및 상태 표시를 미리 봅니다."
},
"auto.components.settings.EphemeralVmsPane.recipes": {
"ko": "레시피"
},
"auto.components.settings.EphemeralVmsPane.whatTitle": {
"ko": "이 스킬로 함께 하는 작업"
},
"auto.components.settings.GeneralEditorSettingsSection.45c6e85c4d": {
"ko": "편집기"
},
@@ -2727,7 +2766,7 @@
"ko": "이 SSH 대상의 원격 릴레이를 강제로 중지합니다. 이 대상의 활성 원격 terminals과 포트 포워딩이 종료됩니다."
},
"auto.components.settings.SshTargetForm.137e88ce8d": {
"ko": "연결 해제 후 릴레이가 terminals을 활성 상태로 유지하는 기간입니다. 기본값: 10800(3시간). 최대:"
"ko": "Orca가 이 호스트에서 연결 해제된 뒤에도 원격 터미널은 계속 실행됩니다."
},
"auto.components.settings.SshTargetForm.2ee9bcd2e8": {
"ko": "server, deploy@server:2222, ssh://server"
@@ -3710,6 +3749,9 @@
"auto.components.settings.ssh.search.f9493b80c0": {
"ko": "서버"
},
"auto.components.settings.source.control.action.recipe.options.commitMessage": {
"ko": "스테이징된 변경 사항에서 commit 메시지를 생성합니다."
},
"auto.components.settings.task.tracker.integration.cards.1a12e33fe5": {
"ko": "Linear 액세스 추가"
},
@@ -4683,7 +4725,7 @@
"ko": "중지 중"
},
"auto.components.status.bar.WorkspaceSpaceCompactPanel.8ff597593d": {
"ko": "Space"
"ko": "저장 공간"
},
"auto.components.status.bar.WorkspaceSpaceCompactPanel.9be86c46a0": {
"ko": "확보 가능"
@@ -4953,7 +4995,7 @@
"ko": "선택한 리포지토리와 일치하는 비활성 워크스페이스가 없습니다."
},
"auto.components.workspace.space.WorkspaceSpacePage.45f6302dbc": {
"ko": "Space"
"ko": "저장 공간"
},
"auto.components.workspace.space.WorkspaceSpacePage.8d0048e1cb": {
"ko": "워크스페이스 디스크 사용량과 회수 가능한 워크트리 저장 공간."
@@ -5038,5 +5080,14 @@
},
"settings.appearance.statusBar.sshToggleDescription": {
"ko": "사용 가능한 SSH 및 원격 Orca 호스트가 있으면 표시합니다."
},
"auto.components.right.sidebar.AiVaultPanelControls.selectAllAgents": {
"ko": "모두 선택"
},
"auto.components.right.sidebar.AiVaultPanelControls.clearAgents": {
"ko": "모두 해제"
},
"auto.components.right.sidebar.AiVaultPanel.noAgentsSelected": {
"ko": "선택된 에이전트가 없습니다"
}
}
+1 -1
View File
@@ -209,7 +209,7 @@ export const KO_VALUE_OVERRIDES = {
'Install the Orca skill so agents know to use the Orca CLI.':
'에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.',
'Local project, Git repo, or folder with many repos':
'로컬 프로젝트, Git repo 또는 repos가 많은 폴더',
'로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings > Integrations.':
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea 및 Jira는 설정 > 연동에 있습니다.',
'changed since you last approved. Re-review before it runs':
@@ -1,15 +1,21 @@
export const MACOS_TCC_KEY_OVERRIDES = {
'auto.hooks.useMacosTccPromptNotice.title': {
es: '¿Ves avisos de “Orca quiere acceder…”?',
ja: '「Orca がアクセスしようとしています…」という確認が表示されますか?',
ko: '“Orca에서 접근하려고 합니다…” 권한 요청이 표시되나요?',
zh: '看到“Orca 想要访问…”提示?'
},
'auto.hooks.useMacosTccPromptNotice.description': {
es: 'macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.',
ja: 'エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。',
ko: '에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.',
zh: 'macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。'
es: 'Los mensajes de permisos de macOS pueden aparecer cuando un agente o una herramienta de terminal que se ejecuta en Orca intenta acceder a archivos protegidos. Concede acceso total al disco en Ajustes para reducir estos avisos.',
ja: 'Orca で実行中のエージェントやターミナルツールが保護されたファイルにアクセスしようとすると、macOS の権限メッセージが表示されることがあります。これらの確認を減らすには、設定でフルディスクアクセスを許可してください。',
ko: 'Orca에서 실행 중인 에이전트나 터미널 도구가 보호된 파일에 접근하려고 하면 macOS 권한 메시지가 표시될 수 있습니다. 이러한 요청을 줄이려면 설정에서 전체 디스크 접근 권한을 허용하세요.',
zh: '当 Orca 中运行的代理或终端工具尝试访问受保护的文件时,macOS 可能会显示权限信息。请在“设置”中授予“完全磁盘访问权限”,以减少此类提示。'
},
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': {
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.',
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。',
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.',
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。'
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca para reducir esos avisos. Después, cierra y vuelve a abrir Orca.',
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca にこの権限を許可してください。その後、Orca を終了して再度開いてください。',
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca에 이 권한을 허용하세요. 그런 다음 Orca를 종료했다가 다시 여세요.',
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 授予此权限,以减少此类提示。然后退出并重新打开 Orca。'
},
'auto.components.settings.DeveloperPermissionsPane.c566bca278': {
ko: '전체 디스크 접근 권한',
+4 -3
View File
@@ -371,9 +371,10 @@ export const LOCALE_PHRASE_FIXES = {
{ pattern: /指挥进展/g, replacement: 'Conductor 进度', whenEnIncludes: 'Conductor Progress' },
{ pattern: /指挥评论/g, replacement: 'Conductor 评审', whenEnIncludes: 'Conductor Review' },
{ pattern: /指挥完成/g, replacement: 'Conductor 完成', whenEnIncludes: 'Conductor Done' },
{ pattern: /琥珀色/g, replacement: 'Amber', whenEnIncludes: 'Amber' },
{ pattern: /蓝色的/g, replacement: 'Blue', whenEnIncludes: 'Blue' },
{ pattern: /中性的/g, replacement: 'Neutral', whenEnIncludes: 'Neutral' },
// Swatch labels want the bare color noun, not the adjectival 的 form — and not English.
{ pattern: /琥珀色/g, replacement: '琥珀', whenEnIncludes: 'Amber' },
{ pattern: /蓝色的/g, replacement: '蓝色', whenEnIncludes: 'Blue' },
{ pattern: /中性的/g, replacement: '中性', whenEnIncludes: 'Neutral' },
{ pattern: /破坏性的/g, replacement: 'destructive', whenEnIncludes: 'destructive' },
{ pattern: /注解/g, replacement: '批注', whenEnIncludes: 'Annotation' },
...ZH_PHRASE_FIXES_ROUND5
@@ -1,19 +0,0 @@
const LOCALIZED_PROSE_TERM_KEYS = new Set([
'auto.hooks.useMacosTccPromptNotice.description',
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8'
])
const LOCALIZABLE_PROSE_TERMS = new Set([
'Agent',
'Agents',
'agent',
'agents',
'Terminal',
'Terminals',
'terminal',
'terminals'
])
export function isLocalizedProseTermContext(term, key) {
return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term)
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { repairCatalog } from './locale-translation-policy.mjs'
// Regression: en.json routinely carries keys a locale catalog has not been bootstrapped with yet
// (~190 per locale at the time of writing), which crashed the whole repair run before it did any work.
describe('repairCatalog with un-bootstrapped keys', () => {
const enCatalog = {
auto: {
lib: { agent: { catalog: { '760bc6883d': 'Codex' } } },
components: { untranslated: 'Continue', nested: { alsoMissing: 'Refresh' } }
}
}
const translatedOnly = () => ({ auto: { lib: { agent: { catalog: { '760bc6883d': '사본' } } } } })
it('skips leaves the locale catalog is missing instead of throwing', () => {
for (const locale of ['ko', 'ja', 'zh', 'es']) {
const localeCatalog = translatedOnly()
expect(() => repairCatalog(enCatalog, localeCatalog, locale), locale).not.toThrow()
expect(localeCatalog.auto.components, locale).toBeUndefined()
}
})
it('still repairs the leaves that are present', () => {
const localeCatalog = translatedOnly()
expect(repairCatalog(enCatalog, localeCatalog, 'ko')).toBe(1)
expect(localeCatalog.auto.lib.agent.catalog['760bc6883d']).toBe('Codex')
})
})
@@ -45,7 +45,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '에이전트가 Orca CLI 사용 방법을 알 수 있도록 Orca 기술을 설치합니다.',
locale: 'ko'
})
).toBe('agents가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
).toBe('에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
expect(
repairTranslatedValue({
key: 'auto.components.editor.MarkdownPreview.322afab6ff',
@@ -92,7 +92,8 @@ describe('locale-translation-policy ko round 5', () => {
).toBe('파이프라인')
})
it('keeps protected workflow terms in English', () => {
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Korean.
it('keeps brand names English and generic workflow terms translated', () => {
expect(
repairTranslatedValue({
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
@@ -100,7 +101,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '터미널 1',
locale: 'ko'
})
).toBe('Terminal 1')
).toBe('터미널 1')
expect(
repairTranslatedValue({
key: 'auto.components.skills.SkillsPage.38e0951c3a',
@@ -108,7 +109,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '에이전트 스킬',
locale: 'ko'
})
).toBe('Agent 스킬')
).toBe('에이전트 스킬')
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
@@ -124,7 +125,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '푸시되지 않은 커밋',
locale: 'ko'
})
).toBe('푸시되지 않은 commits')
).toBe('푸시되지 않은 커밋')
expect(
repairTranslatedValue({
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
@@ -132,7 +133,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '레포',
locale: 'ko'
})
).toBe('Repo')
).toBe('레포')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
@@ -140,7 +141,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
locale: 'ko'
})
).toBe('로컬 프로젝트, Git repo 또는 repos가 많은 폴더')
).toBe('로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더')
})
it('re-glues Korean particles after Latin terms without gluing content words', () => {
@@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
describe('locale-translation-policy es round 5', () => {
it('keeps protected workflow terms in English', () => {
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Spanish.
it('keeps brand names English and generic workflow terms translated', () => {
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
@@ -43,7 +44,7 @@ describe('locale-translation-policy es round 5', () => {
localeValue: 'mensaje de confirmación',
locale: 'es'
})
).toBe('mensaje de Commit')
).toBe('mensaje de confirmación')
expect(
repairTranslatedValue({
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
@@ -51,7 +52,7 @@ describe('locale-translation-policy es round 5', () => {
localeValue: 'repositorio',
locale: 'es'
})
).toBe('Repo')
).toBe('repositorio')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
@@ -59,6 +60,6 @@ describe('locale-translation-policy es round 5', () => {
localeValue: 'Proyecto local, repositorio de Git o carpeta con muchos repositorios',
locale: 'es'
})
).toBe('Proyecto local, repo de Git o carpeta con muchos repos')
).toBe('Proyecto local, repositorio de Git o carpeta con muchos repositorios')
})
})
@@ -27,7 +27,7 @@ describe('locale-translation-policy ja round 5', () => {
localeValue: '遊ぶ',
locale: 'ja'
})
).toBe('Play')
).toBe('再生')
expect(
repairTranslatedValue({
key: 'auto.components.TaskPage.8396825a14',
@@ -102,7 +102,8 @@ describe('locale-translation-policy ja round 5', () => {
).toBe('まずプロジェクトを追加')
})
it('keeps protected workflow terms in English', () => {
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Japanese.
it('keeps brand names English and generic workflow terms translated', () => {
expect(
repairTranslatedValue({
key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3',
@@ -118,7 +119,7 @@ describe('locale-translation-policy ja round 5', () => {
localeValue: 'エージェントのスキル',
locale: 'ja'
})
).toBe('Agent のスキル')
).toBe('エージェントのスキル')
expect(
repairTranslatedValue({
key: 'auto.components.tab.bar.TabBar.3d5d6c960d',
@@ -134,7 +135,7 @@ describe('locale-translation-policy ja round 5', () => {
localeValue: 'コミット',
locale: 'ja'
})
).toBe('commits')
).toBe('コミット')
expect(
repairTranslatedValue({
key: 'auto.components.mobile.slides.WorktreeListSlide.22971156df',
@@ -142,7 +143,7 @@ describe('locale-translation-policy ja round 5', () => {
localeValue: 'リポ',
locale: 'ja'
})
).toBe('Repo')
).toBe('リポ')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
@@ -151,6 +152,6 @@ describe('locale-translation-policy ja round 5', () => {
'ローカル プロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
locale: 'ja'
})
).toBe('ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー')
).toBe('ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー')
})
})
+30 -22
View File
@@ -1,5 +1,8 @@
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
import {
isCanonicalGenericRendering,
overlapsCanonicalRendering
} from './locale-generic-ui-terms.mjs'
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
@@ -17,9 +20,10 @@ const OPEN_IN_APP_CATALOG_PREFIX = 'auto.lib.open.in.app.catalog.'
// Why: product names and agent labels stay Latin — MT reads them as common words (Codex→copy, Gemini→zodiac).
export const ENGLISH_ONLY_KEY_PREFIXES = [AGENT_CATALOG_PREFIX, OPEN_IN_APP_CATALOG_PREFIX]
// Only genuine brand, product, and code tokens belong here. Ordinary UI words that happen to
// name a product (agent, terminal, commit, repo, Continue) live in locale-generic-ui-terms.mjs
// and are translated; their product sense is pinned by ENGLISH_ONLY_KEY_PREFIXES instead.
export const NEVER_TRANSLATE_VALUES = new Set([
'Agent',
'Agents',
'Aider',
'Amp',
'Android',
@@ -33,7 +37,6 @@ export const NEVER_TRANSLATE_VALUES = new Set([
'Codebuff',
'Codex',
'Command Code',
'Continue',
'Cursor',
'Droid',
'Devin',
@@ -60,30 +63,16 @@ export const NEVER_TRANSLATE_VALUES = new Set([
'Pi',
'PostHog',
'Qwen Code',
'Repo',
'Repos',
'Rovo Dev',
'Commit',
'Commits',
'Markdown',
'Terminal',
'Terminals',
'VS Code',
'Warp',
'Zed',
'agent',
'agents',
'android',
'codex',
'commit',
'commits',
'gemini',
'claude',
'markdown',
'repo',
'repos',
'terminal',
'terminals',
'gh',
'idle',
'anthropic',
@@ -368,6 +357,18 @@ function includesPreservedLatinTerm(value, term) {
return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value)
}
function replaceMistranslatedForm(value, wrong, brand, locale) {
let result = ''
let cursor = 0
for (let at = value.indexOf(wrong); at !== -1; at = value.indexOf(wrong, cursor)) {
const end = at + wrong.length
result += value.slice(cursor, at)
result += overlapsCanonicalRendering(brand, locale, value, at, end) ? wrong : brand
cursor = end
}
return result + value.slice(cursor)
}
function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
let result = localeValue
const mistranslations = BRAND_MISTRANSLATIONS[locale] ?? {}
@@ -383,9 +384,6 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
if (isScreenCursorContext(brand, enValue, key)) {
continue
}
if (isLocalizedProseTermContext(brand, key)) {
continue
}
if (includesPreservedLatinTerm(result, brand)) {
continue
}
@@ -393,11 +391,16 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
if (!result.includes(wrong)) {
continue
}
// Why: #12113 — a generic term's correct translation is not a mistranslation; reverting it
// rewrote ~2000 translated values back to English on every repair run.
if (isCanonicalGenericRendering(brand, locale, wrong)) {
continue
}
// Why: "Copy identifier" legitimately uses 사본/复制 — only swap when English names the brand.
if (brand === 'Codex' && /\bCopy\b/i.test(enValue)) {
continue
}
result = result.replaceAll(wrong, brand)
result = replaceMistranslatedForm(result, wrong, brand, locale)
}
}
@@ -548,6 +551,11 @@ export function repairCatalog(enCatalog, localeCatalog, locale) {
for (const leaf of leaves) {
const current = leaf.key.split('.').reduce((cursor, part) => cursor?.[part], localeCatalog)
// Why: en.json carries keys the locale catalog has not been bootstrapped with yet; repair only
// rewrites values that already exist, so skip instead of crashing on undefined.
if (typeof current !== 'string') {
continue
}
const next = repairTranslatedValue({
key: leaf.key,
enValue: leaf.value,
@@ -80,7 +80,7 @@ describe('locale-translation-policy', () => {
localeValue: '壊れた小切手に対して AI エージェントを開始しました。',
locale: 'ja'
})
).toBe('失敗したチェックに対して AI agent を開始しました。')
).toBe('失敗したチェックに対して AI エージェントを開始しました。')
expect(
repairTranslatedValue({
key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94',
@@ -88,7 +88,7 @@ describe('locale-translation-policy', () => {
localeValue: '電話機からターミナルとエージェントを制御します。',
locale: 'ja'
})
).toBe('スマートフォンから terminals と agents を操作')
).toBe('スマートフォンからターミナルとエージェントを操作')
expect(
repairTranslatedValue({
key: 'auto.components.GitHubItemDialog.934add88b6',
@@ -267,7 +267,7 @@ describe('locale-translation-policy', () => {
localeValue: '未已检测代理',
locale: 'zh'
})
).toBe('未检测到 agents')
).toBe('未检测到代理')
expect(
repairTranslatedValue({
key: 'auto.components.skills.SkillsPage.38e0951c3a',
@@ -275,7 +275,7 @@ describe('locale-translation-policy', () => {
localeValue: '代理技巧',
locale: 'zh'
})
).toBe('Agent 技能')
).toBe('代理技能')
expect(
repairTranslatedValue({
key: 'auto.components.settings.appearance.search.9ae151b26b',
@@ -358,7 +358,7 @@ describe('locale-translation-policy', () => {
localeValue: 'コミットするものは何もありません。 PR はすでに統合されています。',
locale: 'ja'
})
).toBe('commit するものはありません。PR はすでにマージされています。')
).toBe('コミットするものはありません。PR はすでにマージされています。')
expect(
repairTranslatedValue({
key: 'auto.components.settings.integrations.search.581844769a',
@@ -546,7 +546,7 @@ describe('locale-translation-policy', () => {
localeValue: '玩',
locale: 'zh'
})
).toBe('Play')
).toBe('播放')
expect(
repairTranslatedValue({
key: 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e',
@@ -86,7 +86,9 @@ describe('locale-translation-policy zh round 5', () => {
).toBe('集成')
})
it('keeps Terminal as a product surface term', () => {
// Why: #12113 — 终端 is the correct rendering and survives; only the 端子 ("electrical
// connector") mistranslation still reverts to Latin.
it('keeps terminal translated but reverts the 端子 mistranslation', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.Settings.3de4bbb841',
@@ -94,7 +96,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '终端',
locale: 'zh'
})
).toBe('Terminal')
).toBe('终端')
expect(
repairTranslatedValue({
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
@@ -102,7 +104,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '终端 1',
locale: 'zh'
})
).toBe('Terminal 1')
).toBe('终端 1')
expect(
repairTranslatedValue({
key: 'auto.components.agent.AgentCombobox.986f946354',
@@ -118,7 +120,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '分体式端子右',
locale: 'zh'
})
).toBe('向右拆分 Terminal')
).toBe('向右拆分终端')
expect(
repairTranslatedValue({
key: 'auto.components.settings.TerminalAppearanceSection.abcb4dd019',
@@ -126,7 +128,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '终端Cursor',
locale: 'zh'
})
).toBe('Terminal Cursor')
).toBe('终端 Cursor')
expect(
repairTranslatedValue({
key: 'auto.components.terminal.FloatingTerminalPanel.3215fc73e9',
@@ -137,7 +139,8 @@ describe('locale-translation-policy zh round 5', () => {
).toBe('新 Terminal')
})
it('keeps workflow terms in English', () => {
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Chinese.
it('keeps brand names English and generic workflow terms translated', () => {
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.SidebarNav.9c95e1ce91',
@@ -145,7 +148,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '代理',
locale: 'zh'
})
).toBe('Agents')
).toBe('代理')
expect(
repairTranslatedValue({
key: 'auto.components.GitHubItemDialog.28986b3747',
@@ -153,7 +156,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '已启动 AI 代理处理失败的检查。',
locale: 'zh'
})
).toBe('已启动 AI agent 处理失败的检查。')
).toBe('已启动 AI 代理处理失败的检查。')
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
@@ -177,7 +180,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '次提交',
locale: 'zh'
})
).toBe('commits')
).toBe('次提交')
expect(
repairTranslatedValue({
key: 'auto.store.slices.worktrees.d1d78a7baa',
@@ -187,7 +190,7 @@ describe('locale-translation-policy zh round 5', () => {
'Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。',
locale: 'zh'
})
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地 commits。')
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。')
})
it('does not confuse proxy copy with Agent terminology', () => {
@@ -234,7 +237,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '本地项目、Git 存储库或包含多个存储库的文件夹',
locale: 'zh'
})
).toBe('本地项目、Git repo 或包含多个 repos 的文件夹')
).toBe('本地项目、Git 仓库或包含多个仓库的文件夹')
})
it('keeps product, provider, code, and shell tokens untranslated', () => {
+3 -2
View File
@@ -8,7 +8,7 @@ export const LOCALE_VALUE_OVERRIDES = {
'OpenCode Go': 'OpenCode Go',
'Open in Cursor': 'Abrir en Cursor',
'Local project, Git repo, or folder with many repos':
'Proyecto local, repo de Git o carpeta con muchos repos'
'Proyecto local, repositorio de Git o carpeta con muchos repositorios'
},
ko: {
Save: '저장',
@@ -305,7 +305,8 @@ export const LOCALE_VALUE_OVERRIDES = {
teams: '团队',
passing: '通过',
'opened this issue': '创建了此议题',
Open: '进行中',
// No blanket "Open" override: it is a verb on buttons (打开) and a state next to 已关闭 (开放),
// so a single value-wide mapping is wrong for half the call sites. Per-key catalog values decide.
'Join Discord': '加入 Discord',
'Pull request merged': '拉取请求已合并',
'Agent Skills': '代理技能',
+17 -16
View File
@@ -67,8 +67,8 @@ export const ZH_VALUE_OVERRIDES = {
Inline: '内联',
thread: '帖子串',
destructive: 'destructive',
sheet: 'sheet',
page: 'page',
sheet: '面板',
page: '页面',
Annotation: '批注',
'check #': '检查 #',
by: '由',
@@ -92,16 +92,18 @@ export const ZH_VALUE_OVERRIDES = {
'仓库操作方案。在此仓库自定义之前,将使用全局设置。',
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.':
'为源代码管理的提交、拉取请求、分支命名和修复操作添加操作方案。',
Play: 'Play',
Flag: 'Flag',
Zinc: 'Zinc',
Rose: 'Rose',
Emerald: 'Emerald',
Amber: 'Amber',
Violet: 'Violet',
Sky: 'Sky',
Blue: 'Blue',
Neutral: 'Neutral',
// Workspace status icon and color swatch labels; siblings below are translated, so English
// pins here would leave the picker half-Chinese on the next catalog regeneration.
Play: '播放',
Flag: '标记',
Zinc: '锌灰',
Rose: '玫瑰',
Emerald: '翡翠',
Amber: '琥珀',
Violet: '紫罗兰',
Sky: '天蓝',
Blue: '蓝色',
Neutral: '中性',
Dashed: '虚线',
Dot: '圆点',
Circle: '圆形',
@@ -201,8 +203,7 @@ export const ZH_VALUE_OVERRIDES = {
'每个已连接的 Linear 工作区都有一个由活动运行时存储的密钥。全权限密钥可覆盖密钥所有者可访问的所有团队;受限密钥可随时更换。',
'Show Linear in the Tasks source picker and sidebar shortcuts.':
'在任务源选择器和侧边栏快捷方式中显示 Linear。',
'Local project, Git repo, or folder with many repos':
'本地项目、Git repo 或包含多个 repos 的文件夹',
'Local project, Git repo, or folder with many repos': '本地项目、Git 仓库或包含多个仓库的文件夹',
'Staged Changes': '已暂存的更改',
Changes: '更改',
'Untracked Files': '未跟踪文件',
@@ -210,8 +211,8 @@ export const ZH_VALUE_OVERRIDES = {
'Split Down': '向下拆分',
'Split Left': '向左拆分',
'Split Right': '向右拆分',
'Split Terminal Down': '向下拆分 Terminal',
'Split Terminal Right': '向右拆分 Terminal',
'Split Terminal Down': '向下拆分终端',
'Split Terminal Right': '向右拆分终端',
'Optional account switching for Claude while preserving shared chat context.':
'Claude 的可选账户切换,同时保留共享聊天上下文。',
'Countdown timer showing time until prompt cache expires (Claude agents).':
@@ -0,0 +1,614 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { execFileSync, fork } from 'node:child_process'
import { existsSync, mkdtempSync, openSync, readFileSync, writeFileSync } from 'node:fs'
import net from 'node:net'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
median,
percentile,
processSnapshot,
sampleProcess
} from './macos-computer-helper-owner-loss-metrics.mjs'
import {
benchmarkTrialNeedsCleanup,
parseBenchmarkTrialResult,
processIdentityIsCurrent,
signalProcessIdentity,
spawnBenchmarkProcess,
throwBenchmarkTrialFailures,
writeProcessRecord
} from './macos-computer-helper-owner-loss-processes.mjs'
import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs'
const INTERNAL_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_INTERNAL'
const EXPECTATION_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_EXPECTATION'
const HELPER_RECORD_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_HELPER_RECORD_PATH'
const RESULT_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_RESULT_PATH'
const ACTIVE_REQUEST_COUNT = 100_000
const DEFAULT_TRIALS = 3
const OWNER_HOLD_MS = 31_000
const PROCESS_EXIT_TIMEOUT_MS = 5_000
const RETAIN_PROOF_MS = 3_000
const TRIAL_TIMEOUT_MS = OWNER_HOLD_MS + 4 * PROCESS_EXIT_TIMEOUT_MS + 120_000
const MIB = 1024 * 1024
const scriptPath = import.meta.filename
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
const metricsPath = path.join(import.meta.dirname, 'macos-computer-helper-owner-loss-metrics.mjs')
const processCleanupPath = path.join(
import.meta.dirname,
'macos-computer-helper-owner-loss-processes.mjs'
)
const trialCleanupPath = path.join(
import.meta.dirname,
'macos-computer-helper-owner-loss-trial-cleanup.mjs'
)
const sidecarPath = path.join(repoRoot, 'out', 'main', 'computer-sidecar.js')
const helperAppPath = path.join(
repoRoot,
'native',
'computer-use-macos',
'.build',
'release',
'Orca Computer Use.app'
)
const helperPath = path.join(helperAppPath, 'Contents', 'MacOS', 'orca-computer-use-macos')
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function waitForProcessExit(identity, timeoutMs) {
const startedAt = performance.now()
while (performance.now() - startedAt < timeoutMs) {
if (!processIdentityIsCurrent(identity)) {
return performance.now() - startedAt
}
await sleep(50)
}
return null
}
async function stopProcess(identity) {
if (!identity || !processIdentityIsCurrent(identity)) {
return
}
signalProcessIdentity(identity, helperPath, 'SIGTERM')
if ((await waitForProcessExit(identity, 2_000)) !== null) {
return
}
signalProcessIdentity(identity, helperPath, 'SIGKILL')
await waitForProcessExit(identity, 2_000)
}
function startSidecar() {
const errors = []
const child = fork(sidecarPath, [], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
ORCA_COMPUTER_SIDECAR: '1',
ORCA_COMPUTER_MACOS_HELPER_APP_PATH: helperAppPath
}
})
child.on('error', (error) => errors.push(error.stack ?? error.message))
child.stderr?.on('data', (chunk) => errors.push(String(chunk)))
return { child, errors }
}
function requestSidecar(sidecar, id, method) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup()
reject(new Error(`Sidecar ${method} request timed out: ${sidecar.errors.join('')}`))
}, 10_000)
const onMessage = (message) => {
if (message?.id !== id) {
return
}
cleanup()
if (message.ok) {
resolve(message.result)
} else {
reject(new Error(`Sidecar ${method} failed: ${JSON.stringify(message.error)}`))
}
}
const onExit = (code, signal) => {
cleanup()
reject(
new Error(
`Sidecar exited during ${method}: ${JSON.stringify({ code, signal, stderr: sidecar.errors.join('') })}`
)
)
}
const onError = (error) => {
cleanup()
reject(new Error(`Sidecar ${method} process error: ${error.message}`))
}
const cleanup = () => {
clearTimeout(timeout)
sidecar.child.off('message', onMessage)
sidecar.child.off('exit', onExit)
sidecar.child.off('error', onError)
}
sidecar.child.on('message', onMessage)
sidecar.child.once('exit', onExit)
sidecar.child.once('error', onError)
try {
sidecar.child.send({ id, method, params: {} })
} catch (error) {
onError(error)
}
})
}
async function waitForHelper(sidecarPid) {
const deadline = Date.now() + 10_000
while (Date.now() < deadline) {
const output = execFileSync('ps', ['-axo', 'pid=,ppid=,pgid=,command='], {
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
for (const line of output.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)
if (
match &&
Number(match[2]) === sidecarPid &&
Number(match[3]) === Number(match[1]) &&
match[4].includes(helperPath) &&
match[4].includes(' --agent ')
) {
return { pid: Number(match[1]), pgid: Number(match[3]), command: match[4] }
}
}
await sleep(50)
}
throw new Error(`Could not find helper owned by sidecar ${sidecarPid}`)
}
function socketPathFromCommand(command) {
const match = command.match(/ --agent (.+?) --token-file /)
if (!match) {
throw new Error(`Could not read helper socket path from command: ${command}`)
}
return match[1]
}
function connectInvalidPeer(socketPath) {
return new Promise((resolve, reject) => {
const socket = net.createConnection(socketPath)
let accepted = false
const timeout = setTimeout(() => {
socket.destroy()
reject(new Error('Invalid-peer connection timed out'))
}, 5_000)
let buffer = ''
const cleanup = () => {
clearTimeout(timeout)
socket.off('error', onError)
socket.off('data', onData)
}
const onError = (error) => {
if (accepted) {
return
}
cleanup()
reject(error)
}
const onData = (chunk) => {
buffer += chunk
const newline = buffer.indexOf('\n')
if (newline < 0) {
return
}
let response
try {
response = JSON.parse(buffer.slice(0, newline))
} catch (error) {
cleanup()
socket.destroy()
reject(error)
return
}
if (response.ok !== false || response.error?.code !== 'permission_denied') {
cleanup()
socket.destroy()
reject(new Error(`Invalid peer was not rejected: ${JSON.stringify(response)}`))
return
}
clearTimeout(timeout)
socket.off('data', onData)
accepted = true
resolve(socket)
}
socket.setEncoding('utf8')
socket.on('error', onError)
socket.on('data', onData)
socket.once('connect', () => {
socket.write(
`${JSON.stringify({ id: 991, method: 'handshake', params: {}, token: 'invalid' })}\n`
)
})
})
}
async function waitForChildExit(child, timeoutMs) {
if (child.exitCode !== null || child.signalCode !== null) {
return true
}
return await Promise.race([
new Promise((resolve) => child.once('exit', () => resolve(true))),
sleep(timeoutMs).then(() => false)
])
}
async function startAuthenticatedSession() {
const sidecar = startSidecar()
let helper
try {
const capabilitiesResult = requestSidecar(sidecar, 1, 'capabilities').then(
(capabilities) => ({ capabilities }),
(error) => ({ error })
)
helper = await waitForHelper(sidecar.child.pid)
const helperRecordPath = process.env[HELPER_RECORD_PATH_ENV]
if (!helperRecordPath) {
throw new Error('Missing helper process record path')
}
writeProcessRecord(helperRecordPath, helper)
const { capabilities, error } = await capabilitiesResult
if (error) {
throw error
}
if (capabilities?.protocolVersion !== 1) {
throw new Error(`Unexpected helper handshake: ${JSON.stringify(capabilities)}`)
}
return { authenticated: capabilities.protocolVersion === 1, sidecar, helper }
} catch (error) {
sidecar.child.kill('SIGKILL')
await stopProcess(helper)
throw error
}
}
async function exerciseActiveRequests(sidecar) {
const latencies = []
const startedAt = performance.now()
for (let index = 0; index < ACTIVE_REQUEST_COUNT; index += 1) {
const requestStartedAt = performance.now()
const result = await requestSidecar(sidecar, 10_000 + index, 'listApps')
if (!Array.isArray(result?.apps)) {
throw new Error(`Unexpected listApps response: ${JSON.stringify(result)}`)
}
latencies.push(performance.now() - requestStartedAt)
}
const totalMs = performance.now() - startedAt
return {
totalMs,
requestsPerSecond: (ACTIVE_REQUEST_COUNT * 1_000) / totalMs,
medianLatencyMs: median(latencies),
p95LatencyMs: percentile(latencies, 0.95),
maxLatencyMs: Math.max(...latencies)
}
}
async function verifyGracefulClose() {
const { sidecar, helper } = await startAuthenticatedSession()
try {
const startedAt = performance.now()
sidecar.child.disconnect()
if (!(await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS))) {
throw new Error('Sidecar did not exit after graceful IPC close')
}
const helperExitMs = await waitForProcessExit(helper, PROCESS_EXIT_TIMEOUT_MS)
if (helperExitMs === null) {
throw new Error('Helper did not exit after graceful owner close')
}
return Math.round(performance.now() - startedAt)
} finally {
sidecar.child.kill('SIGKILL')
await stopProcess(helper)
}
}
async function runInternalTrial(expectation) {
let sidecar
let helper
let invalidPeer
let invalidPeerRejected = false
try {
const session = await startAuthenticatedSession()
sidecar = session.sidecar
helper = session.helper
const authenticatedAt = performance.now()
const initial = await sampleProcess(helper.pid)
const activeRequests = await exerciseActiveRequests(sidecar)
const remainingHoldMs = Math.max(0, OWNER_HOLD_MS - (performance.now() - authenticatedAt))
await sleep(remainingHoldMs)
const connected = await sampleProcess(helper.pid)
const invalidSocketPath = socketPathFromCommand(helper.command)
invalidPeer = await connectInvalidPeer(invalidSocketPath)
invalidPeerRejected = true
const survivedClaimDeadline =
performance.now() - authenticatedAt >= OWNER_HOLD_MS && isProcessAlive(helper.pid)
sidecar.child.kill('SIGKILL')
await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS)
const abruptExitMs = await waitForProcessExit(
helper,
expectation === 'reaped' ? PROCESS_EXIT_TIMEOUT_MS : RETAIN_PROOF_MS
)
const helperExitedAfterAbruptLoss = abruptExitMs !== null
if (expectation === 'reaped' && !helperExitedAfterAbruptLoss) {
throw new Error('Expected helper to exit after abrupt authenticated owner loss')
}
if (expectation === 'retained' && helperExitedAfterAbruptLoss) {
throw new Error('Expected baseline helper to remain after abrupt owner loss')
}
const postLossRssBytes = helperExitedAfterAbruptLoss ? 0 : processSnapshot(helper.pid).rssBytes
await stopProcess(helper)
helper = null
invalidPeer.destroy()
invalidPeer = null
const gracefulExitMs = await verifyGracefulClose()
return {
authenticated: session.authenticated,
survivedClaimDeadline,
invalidPeerRejectedAndDidNotRetain: invalidPeerRejected && helperExitedAfterAbruptLoss,
connectedRssBytes: connected.rssBytes,
connectedCpuMilliseconds: Math.max(
0,
Math.round((connected.cpuTimeSeconds - initial.cpuTimeSeconds) * 1_000)
),
activeRequests,
cpuSampleMs: Math.round(performance.now() - authenticatedAt),
helperExitedAfterAbruptLoss,
abruptExitMs: abruptExitMs === null ? null : Math.round(abruptExitMs),
postLossRssBytes,
gracefulExitMs
}
} finally {
invalidPeer?.destroy()
sidecar?.child.kill('SIGKILL')
await stopProcess(helper)
}
}
function parseArgs(argv) {
const options = { expect: '', trials: DEFAULT_TRIALS, output: '' }
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = argv[index + 1]
if (arg === '--expect' || arg === '--trials' || arg === '--output') {
if (!value) {
throw new Error(`Missing value for ${arg}`)
}
options[arg.slice(2)] = arg === '--trials' ? Number(value) : value
index += 1
} else {
throw new Error(`Unknown argument: ${arg}`)
}
}
if (!['retained', 'reaped'].includes(options.expect)) {
throw new Error('--expect must be retained or reaped')
}
if (!Number.isInteger(options.trials) || options.trials < 1) {
throw new Error('--trials must be a positive integer')
}
return options
}
function electronPath() {
const electronModulePath = fileURLToPath(import.meta.resolve('electron'))
return execFileSync(
process.execPath,
['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`],
{ encoding: 'utf8' }
)
}
function buildArtifacts() {
execFileSync('pnpm', ['exec', 'electron-vite', 'build'], {
cwd: repoRoot,
stdio: 'inherit'
})
execFileSync('pnpm', ['build:computer-macos'], {
cwd: repoRoot,
stdio: 'inherit'
})
}
function artifactSha256(artifactPath) {
return createHash('sha256').update(readFileSync(artifactPath)).digest('hex')
}
function runTrial(executable, expectation) {
let launcherDir
let trialTempDir
let helperRecordPath
let resultPath
let stderrPath
let stdoutPath
let result
let serializedResult
let parsedResult
let parsedResultAvailable = false
let trialError
let cleanupError
let stderrDescriptor
let stdoutDescriptor
let trialOutput = ''
try {
launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-helper-owner-bench-launcher-'))
trialTempDir = mkdtempSync(path.join(path.sep, 'tmp', 'orca-owner-bench-'))
helperRecordPath = path.join(launcherDir, 'helper.json')
resultPath = path.join(launcherDir, 'result.json')
stderrPath = path.join(launcherDir, 'stderr.log')
stdoutPath = path.join(launcherDir, 'stdout.log')
writeFileSync(
path.join(launcherDir, 'package.json'),
JSON.stringify({ name: 'orca-helper-owner-benchmark', main: 'main.cjs' })
)
writeFileSync(
path.join(launcherDir, 'main.cjs'),
`import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => {
console.error(error)
process.exitCode = 1
})\n`
)
const env = {
...process.env,
TMPDIR: trialTempDir,
[INTERNAL_ENV]: '1',
[EXPECTATION_ENV]: expectation,
[HELPER_RECORD_PATH_ENV]: helperRecordPath,
[RESULT_PATH_ENV]: resultPath
}
delete env.ELECTRON_RUN_AS_NODE
stderrDescriptor = openSync(stderrPath, 'w')
stdoutDescriptor = openSync(stdoutPath, 'w')
result = spawnBenchmarkProcess(executable, [launcherDir], {
cwd: repoRoot,
env,
stdio: ['ignore', stdoutDescriptor, stderrDescriptor],
timeout: TRIAL_TIMEOUT_MS
})
if (result.status === 0 && existsSync(resultPath)) {
serializedResult = readFileSync(resultPath, 'utf8')
parsedResult = parseBenchmarkTrialResult(serializedResult)
parsedResultAvailable = true
}
} catch (error) {
trialError = error
} finally {
const failedTrial = benchmarkTrialNeedsCleanup(result, parsedResultAvailable)
const trialMarker = trialTempDir ? `TMPDIR=${trialTempDir}` : undefined
const cleanup = cleanupOwnerLossTrial({
failed: failedTrial,
pid: result?.pid,
marker: trialMarker,
recordPath: helperRecordPath,
helperPath,
tempDir: trialTempDir,
stderrDescriptor,
stdoutDescriptor,
outputPaths: [stderrPath, stdoutPath],
launcherDir
})
cleanupError = cleanup.error
trialOutput = cleanup.output
}
if (!trialError && result?.status !== 0) {
trialError = new Error(
`Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n${trialOutput}`
)
}
if (!trialError && !serializedResult) {
trialError = new Error(`Electron trial did not write a result:\n${trialOutput}`)
}
throwBenchmarkTrialFailures(trialError, cleanupError)
return parsedResult
}
function runBenchmark() {
if (process.platform !== 'darwin') {
throw new Error('The computer-use helper owner benchmark is macOS-only')
}
const options = parseArgs(process.argv.slice(2))
const dirty = execFileSync('git', ['status', '--porcelain'], {
cwd: repoRoot,
encoding: 'utf8'
}).trim()
if (dirty) {
throw new Error('Commit or stash changes before running the provenance-bound benchmark')
}
buildArtifacts()
if (!existsSync(sidecarPath) || !existsSync(helperPath)) {
throw new Error('Fresh production sidecar/helper build did not produce the expected artifacts')
}
const executable = electronPath()
const results = Array.from({ length: options.trials }, () => runTrial(executable, options.expect))
const rssBytes = results.map((result) => result.connectedRssBytes)
const cpuMilliseconds = results.map((result) => result.connectedCpuMilliseconds)
const activeRequestTotals = results.map((result) => result.activeRequests.totalMs)
const activeRequestRates = results.map((result) => result.activeRequests.requestsPerSecond)
const activeRequestMedians = results.map((result) => result.activeRequests.medianLatencyMs)
const activeRequestP95s = results.map((result) => result.activeRequests.p95LatencyMs)
const activeRequestMaxes = results.map((result) => result.activeRequests.maxLatencyMs)
const postLossRssBytes = results.map((result) => result.postLossRssBytes)
const report = {
benchmark: 'macos-computer-helper-authenticated-owner-loss',
revision: execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: repoRoot,
encoding: 'utf8'
}).trim(),
artifacts: {
sidecarSha256: artifactSha256(sidecarPath),
helperSha256: artifactSha256(helperPath)
},
sources: {
benchmarkSha256: artifactSha256(scriptPath),
metricsSha256: artifactSha256(metricsPath),
processCleanupSha256: artifactSha256(processCleanupPath),
trialCleanupSha256: artifactSha256(trialCleanupPath)
},
expectation: options.expect,
trials: options.trials,
ownerHoldMs: OWNER_HOLD_MS,
activeRequestCount: ACTIVE_REQUEST_COUNT,
authenticated: results.every((result) => result.authenticated),
survivedClaimDeadline: results.every((result) => result.survivedClaimDeadline),
invalidPeerRejectedAndDidNotRetain: results.every(
(result) => result.invalidPeerRejectedAndDidNotRetain
),
connectedRssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))),
medianConnectedRssMiB: Number((median(rssBytes) / MIB).toFixed(2)),
connectedCpuMilliseconds: cpuMilliseconds,
medianConnectedCpuMilliseconds: median(cpuMilliseconds),
activeRequestTotalMs: activeRequestTotals.map((value) => Number(value.toFixed(3))),
medianActiveRequestTotalMs: Number(median(activeRequestTotals).toFixed(3)),
activeRequestsPerSecond: activeRequestRates.map((value) => Number(value.toFixed(2))),
medianActiveRequestsPerSecond: Number(median(activeRequestRates).toFixed(2)),
activeRequestMedianLatencyMs: activeRequestMedians.map((value) => Number(value.toFixed(3))),
medianActiveRequestMedianLatencyMs: Number(median(activeRequestMedians).toFixed(3)),
activeRequestP95LatencyMs: activeRequestP95s.map((value) => Number(value.toFixed(3))),
medianActiveRequestP95LatencyMs: Number(median(activeRequestP95s).toFixed(3)),
activeRequestMaxLatencyMs: activeRequestMaxes.map((value) => Number(value.toFixed(3))),
helperExitedAfterAbruptLoss: results.map((result) => result.helperExitedAfterAbruptLoss),
abruptExitMs: results.map((result) => result.abruptExitMs),
postLossRssMiB: postLossRssBytes.map((value) => Number((value / MIB).toFixed(2))),
medianPostLossRssMiB: Number((median(postLossRssBytes) / MIB).toFixed(2)),
gracefulExitMs: results.map((result) => result.gracefulExitMs)
}
const serialized = `${JSON.stringify(report, null, 2)}\n`
process.stdout.write(serialized)
if (options.output) {
writeFileSync(path.resolve(options.output), serialized)
}
}
if (process.env[INTERNAL_ENV] === '1') {
const { app } = await import('electron')
await app.whenReady()
try {
const result = await runInternalTrial(process.env[EXPECTATION_ENV])
writeFileSync(process.env[RESULT_PATH_ENV], JSON.stringify(result))
} finally {
app.quit()
}
} else {
runBenchmark()
}
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { signalValidatedProcessGroup } from './macos-computer-helper-owner-loss-processes.mjs'
describe('macOS helper owner-loss benchmark group recovery', () => {
it('retains uncertain stop state across cleanup stage failures', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
const groupState = { stopped: false }
let scanCount = 0
let continueAttempts = 0
const operations = {
processIdentities: () => {
scanCount += 1
if (scanCount >= 3) {
throw new Error(
scanCount === 3 ? 'post-stop inspection failed' : 'final inspection failed'
)
}
return members
},
signalProcess: (pid, signal) => {
if (pid === -41 && signal === 'SIGCONT') {
continueAttempts += 1
if (continueAttempts === 1) {
throw new Error('first compensation failed')
}
}
}
}
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
).toThrow('Benchmark process group signal recovery failed')
expect(groupState.stopped).toBe(true)
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
).toThrow('final inspection failed')
expect(continueAttempts).toBe(2)
expect(groupState.stopped).toBe(false)
})
it('retains an uncertain anchor stop across cleanup stage failures', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
const groupState = { stopped: false, anchorPid: null }
let scanCount = 0
let continueAttempts = 0
const operations = {
processIdentities: () => {
scanCount += 1
if (scanCount >= 2) {
throw new Error(
scanCount === 2 ? 'post-anchor inspection failed' : 'final inspection failed'
)
}
return members
},
signalProcess: (pid, signal) => {
if (pid === 41 && signal === 'SIGCONT') {
continueAttempts += 1
if (continueAttempts === 1) {
throw new Error('first anchor compensation failed')
}
}
}
}
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
).toThrow('Benchmark process group signal recovery failed')
expect(groupState.anchorPid).toBe(41)
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
).toThrow('final inspection failed')
expect(continueAttempts).toBe(2)
expect(groupState.anchorPid).toBeNull()
})
it('recovers a retained anchor before selecting a new one', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const firstAnchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
const finalAnchor = { pid: 42, pgid: 41, command: `/child ${marker}` }
const groupState = { stopped: false, anchorPid: null }
let scanCount = 0
let firstAnchorContinues = 0
const operations = {
processIdentities: () => {
scanCount += 1
if (scanCount === 2) {
throw new Error('post-anchor inspection failed')
}
return scanCount === 1 ? [firstAnchor] : [finalAnchor]
},
signalProcess: (pid, signal) => {
if (pid === 41 && signal === 'SIGCONT') {
firstAnchorContinues += 1
if (firstAnchorContinues === 1) {
throw new Error('first anchor compensation failed')
}
}
}
}
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
).toThrow('Benchmark process group signal recovery failed')
expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(true)
expect(firstAnchorContinues).toBe(2)
expect(groupState).toEqual({ stopped: false, anchorPid: null })
})
it('preserves group authority errors when compensation fails', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const ownershipError = 'Benchmark process group no longer belongs to this trial'
let thrown
try {
signalValidatedProcessGroup(
41,
marker,
'SIGKILL',
{ stopped: true },
{
processIdentities: () => [{ pid: 41, pgid: 41, command: '/unrelated' }],
signalProcess: () => {
throw new Error('resume denied')
}
}
)
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(AggregateError)
expect(thrown.errors.map((error) => error.message)).toEqual([ownershipError, 'resume denied'])
})
})
@@ -0,0 +1,62 @@
import { execFileSync } from 'node:child_process'
const SAMPLE_COUNT = 5
const SAMPLE_INTERVAL_MS = 200
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function median(values) {
const sorted = [...values].sort((left, right) => left - right)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
export function percentile(values, fraction) {
const sorted = [...values].sort((left, right) => left - right)
return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)]
}
function parseCpuTimeSeconds(value) {
const [dayOrTime, clock] = value.includes('-') ? value.split('-', 2) : [null, value]
const days = dayOrTime === null ? 0 : Number(dayOrTime)
const parts = clock.split(':').map(Number)
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
throw new Error(`Invalid process CPU time: ${value}`)
}
const seconds = parts.pop() ?? 0
const minutes = parts.pop() ?? 0
const hours = parts.pop() ?? 0
return days * 86_400 + hours * 3_600 + minutes * 60 + seconds
}
export function processSnapshot(pid) {
const raw = execFileSync(
'ps',
['-o', 'rss=', '-o', 'time=', '-o', 'command=', '-p', String(pid)],
{ encoding: 'utf8' }
).trim()
const match = raw.match(/^(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
throw new Error(`Could not inspect process ${pid}: ${raw}`)
}
return {
rssBytes: Number(match[1]) * 1024,
cpuTimeSeconds: parseCpuTimeSeconds(match[2]),
command: match[3]
}
}
export async function sampleProcess(pid) {
const samples = []
for (let index = 0; index < SAMPLE_COUNT; index += 1) {
samples.push(processSnapshot(pid))
await sleep(SAMPLE_INTERVAL_MS)
}
return {
rssBytes: median(samples.map((sample) => sample.rssBytes)),
cpuTimeSeconds: samples.at(-1).cpuTimeSeconds,
command: samples.at(-1).command
}
}
@@ -0,0 +1,418 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
const PROCESS_EXIT_TIMEOUT_MS = 2_000
const PROCESS_POLL_MS = 25
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4))
const processIdentityOperations = {
executePs: execFileSync,
signalProcess: process.kill.bind(process)
}
export function processIdentity(pid, operations = processIdentityOperations) {
if (!Number.isInteger(pid) || pid <= 0) {
return null
}
try {
const output = operations
.executePs('ps', ['-p', String(pid), '-o', 'pid=,pgid=,command='], {
encoding: 'utf8'
})
.trim()
const match = output.match(/^(\d+)\s+(\d+)\s+(.+)$/)
if (!match) {
throw new Error(`Could not parse process identity for ${pid}`)
}
return { pid: Number(match[1]), pgid: Number(match[2]), command: match[3] }
} catch (error) {
try {
operations.signalProcess(pid, 0)
} catch (lookupError) {
if (lookupError?.code === 'ESRCH') {
return null
}
}
throw error
}
}
function matchingDetachedProcesses(identities, expectedCommandFragments) {
return identities.filter(
(identity) =>
identity.pgid === identity.pid &&
expectedCommandFragments.every((fragment) => identity.command.includes(fragment))
)
}
const matchingProcessOperations = {
processIdentities,
signalProcessIdentity,
waitForIdentityExit
}
export function killProcessMatchingCommand(
expectedCommandFragments,
operations = matchingProcessOperations
) {
const matches = matchingDetachedProcesses(
operations.processIdentities(),
expectedCommandFragments
)
if (matches.length === 0) {
return false
}
const errors = []
for (const match of matches) {
try {
if (operations.signalProcessIdentity(match, expectedCommandFragments[0], 'SIGKILL')) {
operations.waitForIdentityExit(match)
}
} catch (error) {
errors.push(error)
}
}
try {
const remaining = matchingDetachedProcesses(
operations.processIdentities(),
expectedCommandFragments
)
if (remaining.length > 0) {
errors.push(
new Error(
`Benchmark helper cleanup left matching processes: ${remaining
.map((identity) => identity.pid)
.join(', ')}`
)
)
}
} catch (error) {
errors.push(error)
}
if (errors.length === 1) {
throw errors[0]
}
if (errors.length > 1) {
throw new AggregateError(errors, 'Benchmark exact-command cleanup failed')
}
return true
}
function sleepSync(milliseconds) {
Atomics.wait(sleepBuffer, 0, 0, milliseconds)
}
function validateDetachedIdentity(identity, expectedCommandFragment) {
if (
!Number.isInteger(identity?.pid) ||
identity.pid <= 0 ||
identity.pgid !== identity.pid ||
typeof identity.command !== 'string' ||
!identity.command.includes(expectedCommandFragment)
) {
throw new Error('Recorded benchmark helper identity is invalid')
}
}
function sameIdentity(left, right) {
return left?.pid === right?.pid && left?.pgid === right?.pgid && left?.command === right?.command
}
function processIdentities(includeEnvironment = false) {
const args = includeEnvironment
? ['eww', '-axo', 'pid=,pgid=,command=']
: ['-axo', 'pid=,pgid=,command=']
const output = execFileSync('ps', args, {
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
return output
.split('\n')
.map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/))
.filter(Boolean)
.map((match) => ({
pid: Number(match[1]),
pgid: Number(match[2]),
command: match[3]
}))
}
function waitForIdentityExit(identity) {
const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS
while (Date.now() < deadline) {
if (!processIdentityIsCurrent(identity)) {
return true
}
sleepSync(PROCESS_POLL_MS)
}
throw new Error(`Recorded benchmark helper ${identity.pid} did not exit`)
}
export function spawnBenchmarkProcess(executable, args, options) {
return spawnSync(executable, args, {
...options,
detached: true,
killSignal: 'SIGKILL'
})
}
export function runBenchmarkCleanupStages(stages) {
const errors = []
for (const stage of stages) {
try {
stage()
} catch (error) {
errors.push(error)
}
}
if (errors.length === 1) {
throw errors[0]
}
if (errors.length > 1) {
throw new AggregateError(errors, 'Benchmark trial cleanup failed')
}
}
export function throwBenchmarkTrialFailures(trialError, cleanupError) {
if (trialError && cleanupError) {
throw new AggregateError([trialError, cleanupError], 'Electron trial and cleanup failed')
}
if (trialError) {
throw trialError
}
if (cleanupError) {
throw cleanupError
}
}
export function parseBenchmarkTrialResult(serializedResult) {
return JSON.parse(serializedResult)
}
export function benchmarkTrialNeedsCleanup(spawnResult, parsedResultAvailable) {
return spawnResult?.status !== 0 || !parsedResultAvailable
}
const processGroupSignalOperations = {
processIdentities,
signalProcess: process.kill.bind(process)
}
function compensateStoppedGroup(pgid, groupState, operations) {
const errors = []
const targets = [
groupState.stopped ? [-pgid, 'stopped'] : null,
groupState.anchorPid ? [groupState.anchorPid, 'anchorPid'] : null
].filter(Boolean)
for (const [pid, stateKey] of targets) {
try {
operations.signalProcess(pid, 'SIGCONT')
groupState[stateKey] = stateKey === 'stopped' ? false : null
} catch (error) {
if (error?.code === 'ESRCH') {
groupState[stateKey] = stateKey === 'stopped' ? false : null
} else {
errors.push(error)
}
}
}
return errors
}
export function signalValidatedProcessGroup(
pgid,
environmentFragment,
signal,
groupState = { stopped: false, anchorPid: null },
operations = processGroupSignalOperations
) {
if (!Number.isInteger(pgid) || pgid <= 0) {
return false
}
let members
try {
members = operations.processIdentities(true).filter((identity) => identity.pgid === pgid)
} catch (error) {
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
if (recoveryErrors.length > 0) {
throw new AggregateError(
[error, ...recoveryErrors],
'Benchmark process group recovery failed before validation'
)
}
throw error
}
if (members.length === 0) {
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
if (recoveryErrors.length > 0) {
throw new AggregateError(recoveryErrors, 'Benchmark missing process group recovery failed')
}
return false
}
if (members.some((identity) => !identity.command.includes(environmentFragment))) {
const ownershipError = new Error('Benchmark process group no longer belongs to this trial')
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
if (recoveryErrors.length > 0) {
throw new AggregateError(
[ownershipError, ...recoveryErrors],
'Benchmark process group authority recovery failed'
)
}
throw ownershipError
}
if (groupState.anchorPid) {
try {
operations.signalProcess(groupState.anchorPid, 'SIGCONT')
groupState.anchorPid = null
} catch (error) {
if (error?.code === 'ESRCH') {
groupState.anchorPid = null
} else {
throw new AggregateError([error], 'Benchmark pending anchor recovery failed')
}
}
}
const anchor = members[0]
try {
operations.signalProcess(anchor.pid, 'SIGSTOP')
groupState.anchorPid = anchor.pid
const stoppedAnchor = operations
.processIdentities(true)
.find((identity) => identity.pid === anchor.pid)
if (!sameIdentity(stoppedAnchor, anchor)) {
throw new Error('Benchmark process group anchor changed before signaling')
}
operations.signalProcess(-pgid, 'SIGSTOP')
groupState.stopped = true
groupState.anchorPid = null
const stoppedMembers = operations
.processIdentities(true)
.filter((identity) => identity.pgid === pgid)
if (
stoppedMembers.length === 0 ||
stoppedMembers.some((identity) => !identity.command.includes(environmentFragment))
) {
throw new Error('Benchmark process group changed before signaling')
}
if (signal !== 'SIGSTOP') {
operations.signalProcess(-pgid, signal)
if (signal !== 'SIGKILL') {
operations.signalProcess(-pgid, 'SIGCONT')
}
groupState.stopped = false
groupState.anchorPid = null
}
return true
} catch (error) {
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
if (recoveryErrors.length > 0) {
throw new AggregateError(
[error, ...recoveryErrors],
'Benchmark process group signal recovery failed'
)
}
if (error.code === 'ESRCH') {
return false
}
throw error
}
}
export function writeProcessRecord(recordPath, processIdentity) {
const temporaryPath = `${recordPath}.${process.pid}.tmp`
writeFileSync(temporaryPath, JSON.stringify(processIdentity))
renameSync(temporaryPath, recordPath)
}
export function processIdentityIsCurrent(identity) {
return sameIdentity(processIdentity(identity?.pid), identity)
}
const processSignalOperations = {
processIdentity,
signalProcess: process.kill.bind(process)
}
export function signalProcessIdentity(
identity,
expectedCommandFragment,
signal,
operations = processSignalOperations
) {
validateDetachedIdentity(identity, expectedCommandFragment)
const currentIdentity = operations.processIdentity(identity.pid)
if (!currentIdentity) {
return false
}
if (!sameIdentity(currentIdentity, identity)) {
throw new Error('Recorded benchmark helper PID now belongs to another process')
}
let stopped = false
try {
operations.signalProcess(identity.pid, 'SIGSTOP')
stopped = true
const stoppedIdentity = operations.processIdentity(identity.pid)
if (!sameIdentity(stoppedIdentity, identity)) {
throw new Error('Recorded benchmark helper PID changed before signaling')
}
operations.signalProcess(-identity.pgid, signal)
if (signal !== 'SIGKILL') {
operations.signalProcess(-identity.pgid, 'SIGCONT')
}
stopped = false
return true
} catch (error) {
let resumeError
if (stopped) {
try {
operations.signalProcess(identity.pid, 'SIGCONT')
} catch (caught) {
if (caught?.code !== 'ESRCH') {
resumeError = caught
}
}
}
if (resumeError) {
throw new AggregateError([error, resumeError], 'Benchmark helper signal recovery failed')
}
if (error.code === 'ESRCH') {
return false
}
throw error
}
}
export function killRecordedProcess(recordPath, expectedCommandFragment) {
if (!existsSync(recordPath)) {
return false
}
const record = JSON.parse(readFileSync(recordPath, 'utf8'))
if (!signalProcessIdentity(record, expectedCommandFragment, 'SIGKILL')) {
return false
}
return waitForIdentityExit(record)
}
export function killRecordedAndMatchingProcesses(
recordPath,
recordedCommandFragment,
matchingCommandFragments
) {
const errors = []
try {
killRecordedProcess(recordPath, recordedCommandFragment)
} catch (error) {
errors.push(error)
}
try {
killProcessMatchingCommand(matchingCommandFragments)
} catch (error) {
errors.push(error)
}
if (errors.length === 1) {
throw errors[0]
}
if (errors.length > 1) {
throw new AggregateError(errors, 'Benchmark helper cleanup failed')
}
}
@@ -0,0 +1,558 @@
import { execFileSync, spawn } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
benchmarkTrialNeedsCleanup,
killProcessMatchingCommand,
killRecordedAndMatchingProcesses,
killRecordedProcess,
parseBenchmarkTrialResult,
processIdentity,
runBenchmarkCleanupStages,
signalProcessIdentity,
signalValidatedProcessGroup,
spawnBenchmarkProcess,
throwBenchmarkTrialFailures,
writeProcessRecord
} from './macos-computer-helper-owner-loss-processes.mjs'
import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs'
const describeMacOS = process.platform === 'darwin' ? describe : describe.skip
const spawnedPids = new Set()
const temporaryDirectories = new Set()
afterEach(() => {
for (const pid of spawnedPids) {
try {
process.kill(pid, 'SIGKILL')
} catch {}
}
spawnedPids.clear()
for (const temporaryDirectory of temporaryDirectories) {
rmSync(temporaryDirectory, { recursive: true, force: true })
}
temporaryDirectories.clear()
})
describeMacOS('macOS helper owner-loss benchmark process cleanup', () => {
it('enforces a hard timeout when the trial ignores SIGTERM', () => {
const startedAt = Date.now()
const result = spawnBenchmarkProcess(
process.execPath,
['-e', "process.on('SIGTERM', () => {}); setInterval(() => {}, 1_000)"],
{ stdio: 'ignore', timeout: 100 }
)
expect(result.error?.code).toBe('ETIMEDOUT')
expect(result.signal).toBe('SIGKILL')
expect(Date.now() - startedAt).toBeLessThan(2_000)
expect(() => process.kill(result.pid, 0)).toThrow()
})
it('runs every cleanup stage before aggregating errors', () => {
const completed = []
let thrown
try {
runBenchmarkCleanupStages([
() => {
completed.push(1)
throw new Error('first failure')
},
() => {
completed.push(2)
},
() => {
completed.push(3)
throw new Error('last failure')
}
])
} catch (error) {
thrown = error
}
expect(completed).toEqual([1, 2, 3])
expect(thrown).toBeInstanceOf(AggregateError)
expect(thrown.errors.map((error) => error.message)).toEqual(['first failure', 'last failure'])
})
it('preserves malformed-result and cleanup failures', () => {
let trialError
try {
parseBenchmarkTrialResult('{malformed')
} catch (error) {
trialError = error
}
const cleanupError = new Error('cleanup failed')
let thrown
try {
throwBenchmarkTrialFailures(trialError, cleanupError)
} catch (error) {
thrown = error
}
expect(trialError).toBeInstanceOf(SyntaxError)
expect(thrown).toBeInstanceOf(AggregateError)
expect(thrown.errors).toEqual([trialError, cleanupError])
})
it('cleans up a status-zero trial whose result could not be parsed', () => {
expect(benchmarkTrialNeedsCleanup(undefined, false)).toBe(true)
expect(benchmarkTrialNeedsCleanup({ status: 0 }, false)).toBe(true)
expect(benchmarkTrialNeedsCleanup({ status: 0 }, true)).toBe(false)
expect(benchmarkTrialNeedsCleanup({ status: 1 }, true)).toBe(true)
})
it('removes a launcher directory after partial trial setup', () => {
const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-owner-partial-setup-test-'))
temporaryDirectories.add(launcherDir)
const cleanup = cleanupOwnerLossTrial({
failed: true,
launcherDir,
outputPaths: []
})
expect(cleanup.error).toBeUndefined()
expect(existsSync(launcherDir)).toBe(false)
temporaryDirectories.delete(launcherDir)
})
it('kills a timed-out trial group only after validating its environment', async () => {
const temporaryDirectory = mkdtempSync(path.join(tmpdir(), 'orca-owner-benchmark-group-test-'))
temporaryDirectories.add(temporaryDirectory)
const childPidPath = path.join(temporaryDirectory, 'child.pid')
const environmentName = `ORCA_OWNER_GROUP_${process.pid}`
const environmentValue = `${Date.now()}`
const fixture = `
const { spawn } = require('node:child_process')
const { writeFileSync } = require('node:fs')
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
stdio: 'ignore'
})
writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid))
setInterval(() => {}, 1000)
`
const result = spawnBenchmarkProcess(process.execPath, ['-e', fixture], {
env: { ...process.env, [environmentName]: environmentValue },
stdio: 'ignore',
timeout: 100
})
const childPid = Number(readFileSync(childPidPath, 'utf8'))
spawnedPids.add(childPid)
const environmentFragment = `${environmentName}=${environmentValue}`
const groupState = { stopped: false }
expect(() =>
signalValidatedProcessGroup(result.pid, `${environmentName}=wrong`, 'SIGSTOP')
).toThrow('Benchmark process group no longer belongs to this trial')
expect(() => process.kill(childPid, 0)).not.toThrow()
expect(
signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGSTOP', groupState)
).toBe(true)
expect(
signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGKILL', groupState)
).toBe(true)
await expect
.poll(() => {
try {
process.kill(childPid, 0)
return true
} catch {
return false
}
})
.toBe(false)
spawnedPids.delete(childPid)
})
it('resumes the group after post-stop revalidation fails', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [
{ pid: 41, pgid: 41, command: `/launcher ${marker}` },
{ pid: 42, pgid: 41, command: `/child ${marker}` }
]
const signals = []
let scanCount = 0
expect(() =>
signalValidatedProcessGroup(
41,
marker,
'SIGKILL',
{ stopped: false },
{
processIdentities: () => {
scanCount += 1
if (scanCount === 3) {
throw new Error('transient group inspection failure')
}
return members
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
}
)
).toThrow('transient group inspection failure')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[-41, 'SIGSTOP'],
[-41, 'SIGCONT']
])
})
it('compensates a possible stop after group anchor replacement', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
const replacement = { pid: 41, pgid: 99, command: '/unrelated' }
const signals = []
let scanCount = 0
expect(() =>
signalValidatedProcessGroup(
41,
marker,
'SIGKILL',
{ stopped: false },
{
processIdentities: () => {
scanCount += 1
return scanCount === 1 ? [anchor] : [replacement]
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
}
)
).toThrow('Benchmark process group anchor changed before signaling')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[41, 'SIGCONT']
])
})
it('resumes a previously frozen group when final inspection fails', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
const signals = []
let scanCount = 0
const groupState = { stopped: false }
const operations = {
processIdentities: () => {
scanCount += 1
if (scanCount === 4) {
throw new Error('transient final inspection failure')
}
return members
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
}
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
).toThrow('transient final inspection failure')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[-41, 'SIGSTOP'],
[-41, 'SIGCONT']
])
})
it('resumes a previously frozen group when final anchor stop fails', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [
{ pid: 41, pgid: 41, command: `/launcher ${marker}` },
{ pid: 42, pgid: 41, command: `/child ${marker}` }
]
const signals = []
let finalCall = false
const groupState = { stopped: false }
const missingProcessError = Object.assign(new Error('anchor exited'), { code: 'ESRCH' })
const operations = {
processIdentities: () => members,
signalProcess: (pid, signal) => {
signals.push([pid, signal])
if (finalCall && pid === 41 && signal === 'SIGSTOP') {
throw missingProcessError
}
}
}
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
finalCall = true
expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(false)
expect(signals.at(-1)).toEqual([-41, 'SIGCONT'])
})
it('resumes a previously frozen group after final anchor replacement', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
const child = { pid: 42, pgid: 41, command: `/child ${marker}` }
const replacement = { pid: 41, pgid: 99, command: '/unrelated' }
const signals = []
let scanCount = 0
const groupState = { stopped: false }
const operations = {
processIdentities: () => {
scanCount += 1
return scanCount === 5 ? [replacement, child] : [anchor, child]
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
}
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
).toThrow('Benchmark process group anchor changed before signaling')
expect(signals.slice(-2)).toEqual([
[-41, 'SIGCONT'],
[41, 'SIGCONT']
])
})
it('kills a recorded helper in a separate process group', async () => {
const temporaryDirectory = mkdtempSync(
path.join(tmpdir(), 'orca-owner-benchmark-cleanup-test-')
)
temporaryDirectories.add(temporaryDirectory)
const recordPath = path.join(temporaryDirectory, 'helper.json')
const marker = `orca-owner-cleanup-${process.pid}-${Date.now()}`
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
detached: true,
stdio: 'ignore'
})
spawnedPids.add(helper.pid)
helper.unref()
const exited = new Promise((resolve) => helper.once('exit', resolve))
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
encoding: 'utf8'
}).trim()
const processGroup = Number(
execFileSync('ps', ['-p', String(helper.pid), '-o', 'pgid='], {
encoding: 'utf8'
}).trim()
)
writeProcessRecord(recordPath, { pid: helper.pid, pgid: processGroup, command })
expect(processGroup).toBe(helper.pid)
expect(killRecordedProcess(recordPath, marker)).toBe(true)
await exited
expect(() => process.kill(helper.pid, 0)).toThrow()
spawnedPids.delete(helper.pid)
})
it('kills every unrecorded helper using its unique trial command', async () => {
const marker = `orca-owner-unrecorded-${process.pid}-${Date.now()}`
const helpers = Array.from({ length: 2 }, () =>
spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
detached: true,
stdio: 'ignore'
})
)
for (const helper of helpers) {
spawnedPids.add(helper.pid)
helper.unref()
}
const exited = Promise.all(
helpers.map((helper) => new Promise((resolve) => helper.once('exit', resolve)))
)
expect(killProcessMatchingCommand([process.execPath, marker])).toBe(true)
await exited
for (const helper of helpers) {
expect(() => process.kill(helper.pid, 0)).toThrow()
spawnedPids.delete(helper.pid)
}
})
it('continues exact-match cleanup after an earlier match fails', () => {
const marker = `orca-owner-multiple-${process.pid}-${Date.now()}`
const matches = [
{ pid: 41, pgid: 41, command: `/helper ${marker}` },
{ pid: 42, pgid: 42, command: `/helper ${marker}` }
]
const attempted = []
let scanCount = 0
expect(() =>
killProcessMatchingCommand(['/helper', marker], {
processIdentities: () => {
scanCount += 1
return scanCount === 1 ? matches : [matches[0]]
},
signalProcessIdentity: (identity) => {
attempted.push(identity.pid)
if (identity.pid === matches[0].pid) {
throw new Error('identity changed')
}
return true
},
waitForIdentityExit: () => {}
})
).toThrow('Benchmark exact-command cleanup failed')
expect(attempted).toEqual([41, 42])
})
it('does not treat an identity query failure as process exit', () => {
const queryError = new Error('transient ps failure')
expect(() =>
processIdentity(41, {
executePs: () => {
throw queryError
},
signalProcess: () => {}
})
).toThrow(queryError)
})
it('resumes a helper when post-stop identity inspection fails', () => {
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
const signals = []
let inspectionCount = 0
expect(() =>
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
processIdentity: () => {
inspectionCount += 1
if (inspectionCount === 2) {
throw new Error('transient ps failure after stop')
}
return identity
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
})
).toThrow('transient ps failure after stop')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[41, 'SIGCONT']
])
})
it('compensates a possible stop after helper PID replacement', () => {
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
const replacement = { pid: 41, pgid: 41, command: '/unrelated' }
const signals = []
let inspectionCount = 0
expect(() =>
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
processIdentity: () => {
inspectionCount += 1
return inspectionCount === 1 ? identity : replacement
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
})
).toThrow('Recorded benchmark helper PID changed before signaling')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[41, 'SIGCONT']
])
})
it('preserves helper identity errors when compensation fails', () => {
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
const replacement = { pid: 41, pgid: 41, command: '/unrelated' }
let inspectionCount = 0
let thrown
try {
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
processIdentity: () => {
inspectionCount += 1
return inspectionCount === 1 ? identity : replacement
},
signalProcess: (_pid, signal) => {
if (signal === 'SIGCONT') {
throw new Error('resume denied')
}
}
})
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(AggregateError)
expect(thrown.errors.map((error) => error.message)).toEqual([
'Recorded benchmark helper PID changed before signaling',
'resume denied'
])
})
it('treats a missing PID as process exit after an identity query failure', () => {
const missingProcessError = Object.assign(new Error('missing process'), { code: 'ESRCH' })
expect(
processIdentity(41, {
executePs: () => {
throw new Error('ps found no process')
},
signalProcess: () => {
throw missingProcessError
}
})
).toBeNull()
})
it('runs unique-command cleanup after an invalid process record', async () => {
const temporaryDirectory = mkdtempSync(
path.join(tmpdir(), 'orca-owner-benchmark-fallback-test-')
)
temporaryDirectories.add(temporaryDirectory)
const recordPath = path.join(temporaryDirectory, 'helper.json')
const marker = `orca-owner-invalid-record-${process.pid}-${Date.now()}`
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
detached: true,
stdio: 'ignore'
})
spawnedPids.add(helper.pid)
helper.unref()
const exited = new Promise((resolve) => helper.once('exit', resolve))
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
encoding: 'utf8'
}).trim()
writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command })
expect(() =>
killRecordedAndMatchingProcesses(recordPath, marker, [process.execPath, marker])
).toThrow('Recorded benchmark helper identity is invalid')
await exited
expect(() => process.kill(helper.pid, 0)).toThrow()
spawnedPids.delete(helper.pid)
})
it('rejects a record that is not a detached process-group identity', () => {
const temporaryDirectory = mkdtempSync(
path.join(tmpdir(), 'orca-owner-benchmark-identity-test-')
)
temporaryDirectories.add(temporaryDirectory)
const recordPath = path.join(temporaryDirectory, 'helper.json')
const marker = `orca-owner-invalid-identity-${process.pid}-${Date.now()}`
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
stdio: 'ignore'
})
spawnedPids.add(helper.pid)
helper.unref()
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
encoding: 'utf8'
}).trim()
writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command })
expect(() => killRecordedProcess(recordPath, marker)).toThrow(
'Recorded benchmark helper identity is invalid'
)
expect(() => process.kill(helper.pid, 0)).not.toThrow()
})
})
@@ -0,0 +1,63 @@
import { closeSync, existsSync, readFileSync, rmSync } from 'node:fs'
import {
killRecordedAndMatchingProcesses,
runBenchmarkCleanupStages,
signalValidatedProcessGroup
} from './macos-computer-helper-owner-loss-processes.mjs'
export function cleanupOwnerLossTrial(options) {
const groupState = { stopped: false, anchorPid: null }
let error
let output = ''
try {
runBenchmarkCleanupStages([
() => {
if (options.failed && Number.isInteger(options.pid) && options.marker) {
signalValidatedProcessGroup(options.pid, options.marker, 'SIGSTOP', groupState)
}
},
() => {
if (options.failed && options.recordPath && options.tempDir) {
killRecordedAndMatchingProcesses(options.recordPath, options.helperPath, [
options.helperPath,
options.tempDir
])
}
},
() => {
if (options.failed && Number.isInteger(options.pid) && options.marker) {
signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL', groupState)
}
},
() => {
if (options.stderrDescriptor !== undefined) {
closeSync(options.stderrDescriptor)
}
},
() => {
if (options.stdoutDescriptor !== undefined) {
closeSync(options.stdoutDescriptor)
}
},
() => {
output = (options.outputPaths ?? [])
.filter((outputPath) => outputPath && existsSync(outputPath))
.map((outputPath) => readFileSync(outputPath, 'utf8'))
.join('')
},
() => {
if (options.launcherDir) {
rmSync(options.launcherDir, { recursive: true, force: true })
}
},
() => {
if (options.tempDir) {
rmSync(options.tempDir, { recursive: true, force: true })
}
}
])
} catch (caught) {
error = caught
}
return { error, output }
}
@@ -1,16 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
function readCreatureNames(path) {
const source = readFileSync(path, 'utf8')
return Array.from(source.matchAll(/^ '([^']+)',?$/gm), (match) => match[1])
}
describe('marine creature corpus mirrors', () => {
it('keeps the mobile mirror in parity with the shared corpus', () => {
const sharedNames = readCreatureNames('src/shared/marine-creatures.ts')
const mobileNames = readCreatureNames('mobile/src/constants/marine-creatures.ts')
expect(mobileNames).toEqual(sharedNames)
})
})
@@ -227,12 +227,89 @@ describe('orchestration skill guidance', () => {
expect(agentGuidance).toContain('After sending `worker_done`, end your turn')
expect(agentGuidance).toContain('idle at the agent prompt')
expect(agentGuidance).toContain('Do not poll or keep calling `orca orchestration check`')
expect(agentGuidance).toContain(
'do not start more work, poll, or attempt to close the terminal yourself'
)
expect(agentGuidance).toContain('fresh preamble + TASK block delivered as new terminal input')
expect(skill).not.toContain('post-completion polling messages')
expect(skill).not.toContain('every 2 minutes')
})
it('makes settled worker terminal release an explicit coordinator step', () => {
const skill = readSkill()
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
const agentGuidance = getSection(skill, 'Agent Guidance')
const nextAction = getSection(skill, 'Next Action')
expect(workerLoop).toContain(
'# Process every message. For each accepted worker_done that is not immediately reused:\n' +
'orca orchestration worker-release --dispatch <dispatch_id> --json'
)
expect(workerLoop).toContain(
'Acknowledge only after every message and required release decision is handled'
)
expect(workerLoop).toContain(
'read the `worker.agent_terminal_handle` field of `worker-show --dispatch <dispatch_id> --json`'
)
expect(workerLoop).toContain(
'orca orchestration worker-start --task <next_task_id> --terminal <handle> --json` so Orca ' +
'transfers cleanup ownership to the new Dispatch'
)
expect(workerLoop).toContain(
'Run `worker-release` after both succeeded and failed `worker_done` reports unless the user ' +
'explicitly asked to keep that worker live.'
)
expect(workerLoop).toContain('Release is post-completion cleanup, not cancellation')
expect(workerLoop).toContain('orca orchestration worker-retain --dispatch <dispatch_id> --json')
expect(workerLoop).toContain(
'the same Dispatch can be passed to `worker-release`, which clears the requested retention'
)
expect(agentGuidance).toContain(
'Coordinators must account for every settled worker terminal before waiting again or ending ' +
'the turn'
)
expect(agentGuidance).toContain('released workers remain readable through `worker-read`')
expect(nextAction).toContain(
'After every accepted `worker_done`, either transfer the exact terminal to an immediate ' +
'follow-up Dispatch or run `worker-release` before the next wait.'
)
})
it('documents per-invocation model and effort for supervised workers', () => {
const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop')
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('`launch.requested` and `launch.effective`')
})
it('never authorizes release from idle, timeout, or worker-side triggers', () => {
const skill = readSkill()
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
const agentGuidance = getSection(skill, 'Agent Guidance')
// The prohibition sentence is the guard the negative patterns below rely on.
expect(workerLoop).toContain(
'Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, ' +
'escalation, or rejected/stale `worker_done`.'
)
expect(workerLoop).toContain(
'do not substitute `terminal close`; follow the exact recovery action in the receipt'
)
expect(skill).not.toMatch(
/release[^.]*\bon (?:a |the )?(?:tui-?idle|idle|timeout|heartbeat|question|escalation)\b/iu
)
expect(skill).not.toMatch(
/\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu
)
expect(agentGuidance).toContain(
'do not start more work, poll, or attempt to close the terminal yourself'
)
expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu)
})
it('documents @grok in the Messaging group address list', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
@@ -487,7 +487,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 fast golden E2E gate', () => {
it('keeps terminal rendering regressions in the manual golden E2E workflow', () => {
const packageScripts = packageJson.scripts
const goldenWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8')
@@ -511,7 +511,6 @@ describe('Electron runtime package contract', () => {
return steps.find((step) => step.name === `Run golden E2E tests on ${label}`)
})
const pullRequestPaths = goldenWorkflow.on.pull_request.paths
const releaseGoldenJob = releaseWorkflow.jobs['terminal-rendering-golden']
const releaseEvidenceJob = releaseWorkflow.jobs['terminal-rendering-release-evidence']
const releaseBuildNeeds = releaseWorkflow.jobs.build.needs
@@ -540,11 +539,8 @@ describe('Electron runtime package contract', () => {
for (const runStep of goldenRunSteps) {
expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
}
expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts')
expect(pullRequestPaths).toContain('tests/e2e/terminal-webgl-atlas-budget.spec.ts')
expect(pullRequestPaths).toContain('config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch')
expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md')
expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**')
expect(goldenWorkflow.on.pull_request).toBeUndefined()
expect(goldenWorkflow.on.workflow_dispatch).toBeDefined()
expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden')
expect(releaseBuildNeeds).not.toContain('terminal-rendering-release-evidence')
expect(publishReleaseNeeds).toContain('terminal-rendering-golden')
@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
describe('packaged hang watchdog worker contract', () => {
it('boots the worker from app.asar in PR checks', () => {
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const smokeSource = readFileSync(
'config/scripts/smoke-packaged-hang-watchdog-worker.mjs',
'utf8'
)
const smokeStep = workflow.jobs.package.steps.find(
(step) => step.name === 'Smoke packaged hang watchdog worker'
)
expect(smokeStep.run).toBe(
'xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked'
)
expect(smokeSource).toContain(
"process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]"
)
})
// Why: Electron ignores process.exitCode, so the gate needs app.exit plus a stdout assertion.
it('fails the smoke when the packaged worker never reports success', () => {
const smokeSource = readFileSync(
'config/scripts/smoke-packaged-hang-watchdog-worker.mjs',
'utf8'
)
expect(smokeSource).toContain('app.exit(1)')
expect(smokeSource).not.toContain('app.quit()')
expect(smokeSource).toContain('if (!result.stdout.includes(SUCCESS_LINE))')
})
})
+89 -1
View File
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Plugin, Rollup } from 'vite'
import { afterEach, describe, expect, it } from 'vitest'
import { createPlainNodeEntryGuardPlugin } from '../../build-plugins/plain-node-entry-guard'
import { createPlainNodeEntryGuardPlugin } from '../build-plugins/plain-node-entry-guard'
let outputDir: string | undefined
@@ -85,3 +85,91 @@ describe('plain Node entry guard', () => {
)
})
})
// Why (#11161): Electron's module is not registered on worker threads either —
// require("electron") throws "Cannot find module 'electron'" inside a
// main-process worker and kills it at startup. The worker entries carried only
// hand-written "must stay electron-free" comments, and the port-scan worker sits
// one import away from a client that deliberately does require electron.
describe('worker thread entry guard', () => {
function runWorkerWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void {
const hook = plugin.writeBundle
if (typeof hook !== 'function') {
throw new Error('Expected writeBundle hook')
}
hook.call(
{ meta: { watchMode: false } } as never,
{ dir: createOutputDir() } as Rollup.NormalizedOutputOptions,
bundle
)
}
function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk {
return {
type: 'chunk',
code,
dynamicImports: [],
fileName: `${name}.js`,
imports,
isEntry: true,
name
} as Rollup.OutputChunk
}
it('rejects an Electron require reachable from a worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'port-scan-command-worker-entry.js': workerChunk(
'port-scan-command-worker-entry',
'require("electron")'
)
} as Rollup.OutputBundle
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('requires electron')
})
it('names the worker-thread runtime so the failure is actionable', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'stt-worker.js': workerChunk('stt-worker', 'require("electron")')
} as Rollup.OutputBundle
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('runs as a worker thread')
})
// The real risk is transitive: a worker entry importing a shared chunk that
// reaches the electron-requiring client, not a direct import anyone would spot.
it('follows shared chunks out of a worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'session-scanner-opencode-sqlite-worker-entry.js': workerChunk(
'session-scanner-opencode-sqlite-worker-entry',
'require("./chunks/shared.js")',
['chunks/shared.js']
),
'chunks/shared.js': {
type: 'chunk',
code: 'require("electron")',
dynamicImports: [],
fileName: 'chunks/shared.js',
imports: [],
isEntry: false,
name: 'shared'
} as Rollup.OutputChunk
} as Rollup.OutputBundle
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js')
})
it('passes a clean worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'warp-theme-parser-worker.js': workerChunk(
'warp-theme-parser-worker',
'require("node:worker_threads")'
)
} as Rollup.OutputBundle
expect(() => runWorkerWriteBundle(plugin, bundle)).not.toThrow()
})
})
+39 -9
View File
@@ -5,9 +5,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 filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
(step) => step.name === 'Filter E2E-relevant paths'
(step) => step.name === 'Filter changed E2E specs'
)
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
@@ -25,7 +26,7 @@ describe('PR E2E gate contract', () => {
expect(verifyStep.run).not.toContain('$E2E')
})
it('runs E2E only when the detector says the PR touches E2E paths', () => {
it('passes only changed specs to the reusable E2E workflow', () => {
// Why: without this the job could lose its filter and run on every PR — the
// cost the path filter exists to avoid — while the gate assertions above
// stay green.
@@ -34,6 +35,10 @@ describe('PR E2E gate contract', () => {
expect(prWorkflow.jobs['e2e-paths'].outputs.should_run).toBe(
'${{ steps.filter.outputs.should_run }}'
)
expect(prWorkflow.jobs['e2e-paths'].outputs.test_files).toBe(
'${{ steps.filter.outputs.test_files }}'
)
expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.e2e-paths.outputs.test_files }}')
})
it('enforces every job verify depends on', () => {
@@ -49,16 +54,41 @@ describe('PR E2E gate contract', () => {
}
})
it('matches the Playwright config where it actually lives', () => {
// Why: the config is tests/playwright.config.ts, beside tests/e2e/ rather
// than inside it. A bare `playwright.` prefix matches no tracked file, so
// editing the runner config would silently skip E2E.
expect(filterStep.run).toContain('tests/playwright\\.')
expect(filterStep.run).not.toMatch(/\(\^?\|\|]tests\/e2e\/\|playwright\\\./)
it('selects modified Playwright specs without running deleted tests', () => {
expect(filterStep.run).toContain('--diff-filter=AMCR')
expect(filterStep.run).toContain("'^tests/e2e/.*\\.spec\\.ts$'")
expect(filterStep.run).not.toContain('tests/playwright\\.')
})
it('uses one runner for changed specs and keeps full runs sharded', () => {
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('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1')
})
it('keeps dedicated E2E workflows out of pull request CI', () => {
const dedicatedWorkflows = [
'golden-e2e-experiment.yml',
'linux-wayland-gpu-sandbox.yml',
'terminal-ime-e2e.yml',
'win-crash-survival-e2e.yml',
'windows-terminal-restart-e2e.yml'
]
for (const file of dedicatedWorkflows) {
const workflow = parse(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
expect(workflow.on.pull_request, file).toBeUndefined()
}
})
it('scopes detection to the PR range so base drift cannot false-trigger', () => {
expect(filterStep.run).toContain('git diff --name-only --merge-base "$BASE" "$HEAD"')
expect(filterStep.run).toContain('--merge-base "$BASE" "$HEAD"')
expect(filterStep.run).toContain('set -euo pipefail')
})
})
@@ -22,7 +22,7 @@ const testFilePatterns = [
'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
'src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
'tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}'
'tests/tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}'
]
const realZshUsage =
/(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath/
@@ -173,11 +173,13 @@ describe('PR workflow parallelism', () => {
it('keeps verify as the aggregate required check', () => {
expect(workflow.jobs.verify.needs).toEqual([
'static_analysis',
'root_directory_guard',
'typecheck',
'git_compatibility',
'shell_contracts',
'test',
'package'
'package',
'package_windows'
])
})
})
+125 -26
View File
@@ -1,5 +1,6 @@
import { build } from 'esbuild'
import { spawn } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { createRequire } from 'node:module'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
@@ -9,6 +10,12 @@ import { dirname, join, resolve } from 'node:path'
const WAIT_TIMEOUT_MS = 20_000
const require = createRequire(import.meta.url)
// Why: endpoint credentials are 32–256 base64url chars; openClient requires an authenticated
// transport, and #12746 admits pty.data only after a consumer grant.
function writeEndpointCredential(path) {
return writeFile(path, randomBytes(32).toString('base64url'), 'utf8')
}
function withTimeout(promise, label, stderr) {
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => {
@@ -68,14 +75,59 @@ async function loadProtocol(bundleDir) {
return require(outfile)
}
function attachProcessStreams(proc) {
let stderr = ''
proc.stderr.on('data', (chunk) => {
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
})
return {
stderr: () => stderr
}
}
function waitForStdoutSentinel(proc, protocol, stderr) {
let stdoutBuffer = Buffer.alloc(0)
return withTimeout(
new Promise((resolvePromise, rejectPromise) => {
let settled = false
const onData = (chunk) => {
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
const index = stdoutBuffer.indexOf(sentinel)
if (index < 0) {
return
}
settled = true
proc.stdout.off('data', onData)
proc.off('exit', onExit)
resolvePromise(stdoutBuffer.subarray(index + sentinel.length))
}
const onExit = (code, signal) => {
if (settled) {
return
}
settled = true
proc.stdout.off('data', onData)
rejectPromise(
new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`)
)
}
proc.stdout.on('data', onData)
proc.once('exit', onExit)
}),
'relay sentinel',
stderr
)
}
function createRelayClient(entryPath, args, env, protocol) {
const proc = spawn(process.execPath, [entryPath, ...args], {
cwd: dirname(entryPath),
env,
stdio: ['pipe', 'pipe', 'pipe']
})
const streams = attachProcessStreams(proc)
const messages = []
let stderr = ''
let nextSequence = 1
let stdoutBuffer = Buffer.alloc(0)
let ready = false
@@ -88,9 +140,6 @@ function createRelayClient(entryPath, args, env, protocol) {
messages.push(protocol.parseJsonRpcMessage(frame.payload))
}
})
proc.stderr.on('data', (chunk) => {
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
})
proc.stdout.on('data', (chunk) => {
if (ready) {
decoder.feed(chunk)
@@ -111,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) {
})
const waitForMessage = (startIndex, predicate, label) =>
pollUntil(
() => messages.slice(startIndex).find(predicate),
label,
() => stderr
)
pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr)
const request = async (method, params = {}) => {
const id = nextSequence++
@@ -141,7 +186,7 @@ function createRelayClient(entryPath, args, env, protocol) {
proc,
request,
notify,
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr),
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', streams.stderr),
messageCount: () => messages.length,
waitForNotification: (startIndex, method, predicate = () => true) =>
waitForMessage(
@@ -149,7 +194,20 @@ function createRelayClient(entryPath, args, env, protocol) {
(message) => message.method === method && predicate(message.params ?? {}),
`${method} notification`
),
stderr: () => stderr
stderr: streams.stderr
}
}
async function stopProcess(proc, stderr, label) {
if (!proc || proc.exitCode !== null || proc.signalCode !== null) {
return
}
proc.kill('SIGTERM')
try {
await withTimeout(waitForExit(proc), `${label} shutdown`, stderr)
} catch {
proc.kill('SIGKILL')
await withTimeout(waitForExit(proc), `forced ${label} shutdown`, stderr)
}
}
@@ -184,24 +242,64 @@ async function main() {
}
let tempRoot
let daemon
let daemonStreams
let relay
try {
tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-'))
const watchRoot = await realpath(tempRoot)
const pidFile = join(tempRoot, 'watcher.pid')
const credentialFile = join(tempRoot, 'endpoint.credential')
const protocol = await loadProtocol(tempRoot)
const socketPath =
process.platform === 'win32'
? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}`
: join(tempRoot, 'relay.sock')
await writeEndpointCredential(credentialFile)
// Why detached + --connect: the daemon primary stdio is unproved, so it cannot open a consumer
// session; only an endpoint-credential socket client is admitted for pty.data after #12746.
daemon = spawn(
process.execPath,
[
relayEntry,
'--detached',
'--grace-time',
'0',
'--sock-path',
socketPath,
'--endpoint-dir',
join(tempRoot, 'agent-hooks'),
'--credential-file',
credentialFile
],
{
cwd: dirname(relayEntry),
env: { ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
stdio: ['ignore', 'pipe', 'pipe']
}
)
daemonStreams = attachProcessStreams(daemon)
await waitForStdoutSentinel(daemon, protocol, daemonStreams.stderr)
relay = createRelayClient(
relayEntry,
['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')],
{ ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
['--connect', '--sock-path', socketPath, '--credential-file', credentialFile],
process.env,
protocol
)
await relay.sentinelReceived
// Why no outputFlowControl: legacy owner grant admits plain pty.data without delivery tokens.
const grant = await relay.request('pty.openClient', {
protocolVersion: 1,
clientInstanceId: `relay-watcher-fault-${process.pid}`,
requestedRole: 'session-owner'
})
if (grant?.role !== 'session-owner') {
throw new Error(`expected session-owner grant, got ${JSON.stringify(grant)}`)
}
const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot })
const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}`
let startIndex = relay.messageCount()
@@ -213,7 +311,11 @@ async function main() {
)
await relay.request('fs.watch', { rootPath: watchRoot })
const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr)
const firstWatcherPid = await waitForWatcherPid(
pidFile,
undefined,
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
)
const beforePath = join(watchRoot, 'before.txt')
startIndex = relay.messageCount()
await writeFile(beforePath, 'before')
@@ -224,7 +326,11 @@ async function main() {
const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV'
startIndex = relay.messageCount()
process.kill(firstWatcherPid, faultSignal)
const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr)
const replacementWatcherPid = await waitForWatcherPid(
pidFile,
firstWatcherPid,
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
)
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
Array.isArray(params.events)
? params.events.some(
@@ -234,7 +340,7 @@ async function main() {
)
const status = await relay.request('relay.status')
if (status.pid !== relay.proc.pid) {
if (status.pid !== daemon.pid) {
throw new Error('relay.status did not come from the original surviving relay process')
}
const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}`
@@ -257,7 +363,7 @@ async function main() {
await relay.request('pty.shutdown', { id: spawned.id })
console.log(
JSON.stringify({
relayPid: relay.proc.pid,
relayPid: daemon.pid,
killedWatcherPid: firstWatcherPid,
replacementWatcherPid,
faultSignal,
@@ -268,15 +374,8 @@ async function main() {
})
)
} finally {
if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) {
relay.proc.kill('SIGTERM')
try {
await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr)
} catch {
relay.proc.kill('SIGKILL')
await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr)
}
}
await stopProcess(relay?.proc, relay?.stderr ?? (() => ''), 'connect bridge')
await stopProcess(daemon, daemonStreams?.stderr ?? (() => ''), 'relay daemon')
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true })
}
@@ -34,4 +34,14 @@ describe('release E2E dispatch contract', () => {
expect(refInput.type).toBe('string')
expect(refInput.required).toBe(false)
})
it('includes the paired-runtime web client in the shared E2E build artifact', () => {
const buildStep = e2eWorkflow.jobs.build.steps.find(
(step) => step.name === 'Build Electron app for E2E'
)
expect(buildStep.run).toContain('electron-vite build --mode e2e')
expect(buildStep.env.VITE_EXPOSE_STORE).toBe('true')
expect(buildStep.run).toContain('pnpm run build:web-from-renderer')
})
})

Some files were not shown because too many files have changed in this diff Show More