diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml
index 30c98070f312..7bf1d8cd43b6 100644
--- a/.github/actions/install-node-dependencies/action.yml
+++ b/.github/actions/install-node-dependencies/action.yml
@@ -2,6 +2,10 @@ name: Install Node dependencies
description: Installs the Node toolchain and repository dependencies for CI jobs, with optional Electron archive caching.
inputs:
+ cache-pnpm-verification:
+ description: Restore pnpm's policy-checked lockfile verification record on Linux.
+ required: false
+ default: 'true'
native-runtime:
description: Native runtime to prepare after the script-free install (none, node, or electron).
required: false
@@ -24,6 +28,15 @@ inputs:
default: 'false'
outputs:
+ verification-cache-hit:
+ description: Whether pnpm's verification record was restored.
+ value: ${{ steps.verification-cache-restore.outputs.cache-hit }}
+ verification-cache-path:
+ description: The small pnpm-owned verification record, without registry metadata.
+ value: ${{ steps.verification-cache.outputs.path }}
+ verification-cache-key:
+ description: Exact verification record key, also used by the isolated PR benchmark.
+ value: ${{ steps.verification-cache.outputs.key }}
node-version:
description: Resolved Node.js version used for the install.
value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}
@@ -50,8 +63,9 @@ runs:
uses: actions/setup-node@v6
with:
node-version-file: package.json
- cache: pnpm
+ cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }}
cache-dependency-path: ${{ inputs.cache-dependency-path }}
+ package-manager-cache: false
- name: Setup requested Node.js
id: requested-node
@@ -59,8 +73,50 @@ runs:
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
- cache: pnpm
+ cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }}
cache-dependency-path: ${{ inputs.cache-dependency-path }}
+ package-manager-cache: false
+
+ # PR-local stores compete with reusable build caches for the repository quota.
+ - name: Resolve pnpm download store
+ id: pnpm-store
+ if: github.event_name == 'pull_request'
+ shell: bash
+ env:
+ LOCKFILE_HASH: ${{ hashFiles(inputs.cache-dependency-path) }}
+ run: |
+ test -n "$LOCKFILE_HASH"
+ cache_path="$(pnpm store path --silent)"
+ test -n "$cache_path"
+ printf 'path=%s\n' "$cache_path" >> "$GITHUB_OUTPUT"
+ printf 'arch=%s\n' "$(node -p 'require("node:os").arch()')" >> "$GITHUB_OUTPUT"
+
+ # Match setup-node's key and path so existing default-branch stores remain reusable.
+ - name: Restore pnpm download store without saving
+ if: github.event_name == 'pull_request'
+ uses: actions/cache/restore@v5
+ with:
+ path: ${{ steps.pnpm-store.outputs.path }}
+ key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }}
+
+ - name: Resolve pnpm verification cache
+ id: verification-cache
+ if: runner.os == 'Linux' && inputs.cache-pnpm-verification == 'true'
+ shell: bash
+ env:
+ POLICY_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc') }}
+ run: |
+ printf 'path=%s/lockfile-verified.jsonl\n' "$(pnpm cache path)" >> "$GITHUB_OUTPUT"
+ printf 'key=pnpm-verification-v1-%s-%s-%s-%s\n' "$RUNNER_OS" "$RUNNER_ARCH" "$(pnpm --version)" "$POLICY_HASH" >> "$GITHUB_OUTPUT"
+
+ - name: Restore pnpm verification record
+ id: verification-cache-restore
+ if: steps.verification-cache.outputs.key != ''
+ continue-on-error: true
+ uses: actions/cache/restore@v5
+ with:
+ path: ${{ steps.verification-cache.outputs.path }}
+ key: ${{ steps.verification-cache.outputs.key }}
- name: Validate native runtime
shell: bash
@@ -96,6 +152,15 @@ runs:
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml
fi
+ # pnpm checks the cached record's policy and validity; never bypass verification.
+ - name: Save pnpm verification record on main
+ if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache-restore.outputs.cache-hit != 'true'
+ continue-on-error: true
+ uses: actions/cache/save@v5
+ with:
+ path: ${{ steps.verification-cache.outputs.path }}
+ key: ${{ steps.verification-cache.outputs.key }}
+
- name: Resolve Electron package cache
id: electron-package-cache
if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true'
@@ -116,12 +181,19 @@ runs:
printf 'version=%s\n' "$(node -p "require('./node_modules/electron/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Electron package archive
- if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true'
+ if: (github.event_name != 'pull_request' || runner.os != 'Linux') && steps.electron-package-cache.outputs.version != ''
uses: actions/cache@v5
with:
path: ${{ steps.electron-package-cache.outputs.cache-root }}
key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }}
+ - name: Restore Electron package archive without saving
+ if: github.event_name == 'pull_request' && runner.os == 'Linux' && steps.electron-package-cache.outputs.version != ''
+ uses: actions/cache/restore@v5
+ with:
+ path: ${{ steps.electron-package-cache.outputs.cache-root }}
+ key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }}
+
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
diff --git a/.github/actions/prepare-git-compatibility/action.yml b/.github/actions/prepare-git-compatibility/action.yml
new file mode 100644
index 000000000000..058d31a064c7
--- /dev/null
+++ b/.github/actions/prepare-git-compatibility/action.yml
@@ -0,0 +1,32 @@
+name: Prepare baseline Git compatibility binary
+description: Restore or build the pinned Linux Git binary for the compatibility contract.
+
+runs:
+ using: composite
+ steps:
+ # The default-branch warmer shares this key across PRs; a PR save is private to its merge ref.
+ # Cache eviction remains possible, so keep the checksum-verified cold build below.
+ - name: Cache baseline Git build
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/orca-git-compat/git-2.25.5
+ key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5
+
+ # Finish the CPU-heavy build before any timed compatibility lanes start.
+ - name: Build the baseline Git binary
+ shell: bash
+ run: |
+ archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
+ source="$HOME/.cache/orca-git-compat/git-2.25.5"
+ if [ -x "$source/git" ]; then
+ exit 0
+ fi
+ curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
+ echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
+ | sha256sum --check
+ mkdir -p "$source"
+ tar -xzf "$archive" -C "$source" --strip-components=1
+ make -C "$source" -j"$(nproc)" \
+ NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
+ # Object files are no longer needed after linking the cached binary.
+ find "$source" -name '*.o' -delete
diff --git a/.github/actions/prepare-linux-package-fixture/action.yml b/.github/actions/prepare-linux-package-fixture/action.yml
new file mode 100644
index 000000000000..a84568b3e062
--- /dev/null
+++ b/.github/actions/prepare-linux-package-fixture/action.yml
@@ -0,0 +1,63 @@
+name: Prepare cached Linux package fixture
+description: Restore Docker build layers; only the main warmer builds and saves missing images.
+inputs:
+ fixture:
+ description: Directory name below config/docker.
+ required: true
+ save-cache:
+ description: Build and save a missing fixture on the default branch.
+ default: 'false'
+outputs:
+ image:
+ description: Loaded image to use as a Docker build cache, or empty on a cache miss.
+ value: ${{ steps.image.outputs.image }}
+runs:
+ using: composite
+ steps:
+ - name: Restore fixture image
+ id: cache
+ uses: actions/cache/restore@v5
+ continue-on-error: true
+ with:
+ path: ${{ runner.temp }}/orca-package-fixtures/${{ inputs.fixture }}.tar
+ key: linux-package-fixture-v1-${{ runner.os }}-${{ runner.arch }}-linux-amd64-${{ inputs.fixture }}-${{ hashFiles(format('config/docker/{0}/**', inputs.fixture), '.github/actions/prepare-linux-package-fixture/action.yml') }}
+
+ - name: Load or seed fixture build cache
+ id: image
+ shell: bash
+ env:
+ FIXTURE: ${{ inputs.fixture }}
+ SAVE_CACHE: ${{ inputs.save-cache }}
+ CACHE_HIT: ${{ steps.cache.outputs.cache-hit }}
+ run: |
+ set -euo pipefail
+ case "$FIXTURE" in
+ headless-serve-shutdown|cli-launch-contract) ;;
+ *) echo "Unsupported package fixture: $FIXTURE" >&2; exit 1 ;;
+ esac
+ archive="$RUNNER_TEMP/orca-package-fixtures/$FIXTURE.tar"
+ image="orca-package-fixture-$FIXTURE:cache"
+ if [[ "$CACHE_HIT" == true ]] && docker load --input "$archive"; then
+ if docker image inspect "$image" > /dev/null; then
+ echo "image=$image" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ fi
+ if [[ "$SAVE_CACHE" != true ]]; then
+ echo 'No usable fixture cache; the package runner will build normally.'
+ exit 0
+ fi
+ docker build --platform linux/amd64 --build-arg BUILDKIT_INLINE_CACHE=1 \
+ --tag "$image" --file "config/docker/$FIXTURE/Dockerfile" "config/docker/$FIXTURE"
+ mkdir -p "$(dirname "$archive")"
+ docker save --output "$archive" "$image"
+ echo "image=$image" >> "$GITHUB_OUTPUT"
+ echo 'built=true' >> "$GITHUB_OUTPUT"
+
+ - name: Save fixture image
+ if: inputs.save-cache == 'true' && steps.cache.outputs.cache-hit != 'true' && steps.image.outputs.built == 'true'
+ uses: actions/cache/save@v5
+ continue-on-error: true
+ with:
+ path: ${{ runner.temp }}/orca-package-fixtures/${{ inputs.fixture }}.tar
+ key: ${{ steps.cache.outputs.cache-primary-key }}
diff --git a/.github/actions/prepare-xterm-dependencies/action.yml b/.github/actions/prepare-xterm-dependencies/action.yml
new file mode 100644
index 000000000000..8fa54bd85ae4
--- /dev/null
+++ b/.github/actions/prepare-xterm-dependencies/action.yml
@@ -0,0 +1,33 @@
+name: Restore xterm build dependencies
+description: Reuse installed dependencies for the pinned upstream checkout.
+outputs:
+ cache-key:
+ description: Exact dependency cache key.
+ value: ${{ steps.restore.outputs.cache-primary-key }}
+ cache-hit:
+ description: Whether the exact installed dependency cache was restored.
+ value: ${{ steps.restore.outputs.cache-hit }}
+runs:
+ using: composite
+ steps:
+ - id: runtime
+ shell: bash
+ run: |
+ . /etc/os-release
+ echo "image=$ID-$VERSION_ID" >> "$GITHUB_OUTPUT"
+ echo "node=$(node --version)" >> "$GITHUB_OUTPUT"
+ - id: restore
+ uses: actions/cache/restore@v5
+ with:
+ path: |
+ ${{ runner.temp }}/xterm-patch-build/upstream/.git
+ ${{ runner.temp }}/xterm-patch-build/upstream/node_modules
+ key: xterm-dependencies-v1-${{ runner.os }}-${{ steps.runtime.outputs.image }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-${{ hashFiles('config/patches/xterm-upstream.json', 'config/scripts/regenerate-xterm-patches.mjs', 'config/scripts/xterm-patch-text.mjs', '.github/actions/prepare-xterm-dependencies/action.yml') }}
+ - name: Restore download cache on an installed-dependency miss
+ if: steps.restore.outputs.cache-hit != 'true'
+ uses: actions/cache/restore@v5
+ with:
+ path: |
+ ~/.npm
+ ${{ runner.temp }}/xterm-patch-build/upstream/.git
+ key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 9d7a73a6b88a..326de25e1531 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -12,7 +12,8 @@
## Linked Issue
-
+
+
Fixes #
diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml
new file mode 100644
index 000000000000..5fae18f2f84e
--- /dev/null
+++ b/.github/workflows/bun-profile-tests.yml
@@ -0,0 +1,149 @@
+name: Bun profile persistence
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
+ paths:
+ - 'src/**'
+ - 'config/**'
+ - 'native/**'
+ - 'tests/**'
+ - 'resources/**'
+ - 'package.json'
+ - 'pnpm-lock.yaml'
+ - 'pnpm-workspace.yaml'
+ - 'tsconfig.json'
+ - '.npmrc'
+ - '.pnpmfile.cjs'
+ - '.github/actions/install-node-dependencies/**'
+ - '.github/workflows/bun-profile-tests.yml'
+ workflow_dispatch:
+ schedule:
+ - cron: '30 11 * * *'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: bun-profile-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ changes:
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ should_run: ${{ steps.scope.outputs.should_run }}
+ qualification: ${{ steps.scope.outputs.qualification }}
+ runners: ${{ steps.scope.outputs.runners }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 2
+ persist-credentials: false
+ - uses: ./.github/actions/install-node-dependencies
+ - name: Detect Bun build and test inputs
+ id: scope
+ shell: bash
+ run: |
+ # Compare the tested merge with its base, retaining both sides of renames.
+ if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/bun-changes"; then
+ node config/scripts/bun-profile-change-scope.mjs "$RUNNER_TEMP/bun-changes"
+ else
+ echo 'should_run=true' >> "$GITHUB_OUTPUT"
+ fi
+
+ persistence:
+ needs: changes
+ # Missing/failed detection runs the full matrix; manual runs remain unconditional.
+ if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: ${{ fromJSON(needs.changes.outputs.runners || '["ubuntu-22.04","ubuntu-24.04-arm","macos-14","macos-15-intel","windows-2022","windows-11-arm"]') }}
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 20
+ env:
+ ORCA_BACKGROUND_LAUNCH: '1'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: ${{ runner.os == 'Windows' && 'node' || 'none' }}
+ # build:orcad reuses the host addon validated by native-runtime preparation.
+ - run: pnpm build:orcad
+ - run: pnpm test:bun:profile --artifact
+ - uses: actions/setup-node@v6
+ if: runner.arch == 'X64'
+ with:
+ node-version: '18'
+ - name: Verify Node 18 loads and hands off to bundled Bun
+ if: runner.arch == 'X64'
+ run: |
+ node out/orcad/orcad.js --orcad-smoke-load-check
+ node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018
+
+ linux_glibc_floor:
+ needs: [changes, persistence]
+ # A failed smoke already blocks qualification; missing scope still selects every platform.
+ if: >-
+ ${{ !cancelled() && needs.persistence.result == 'success' &&
+ needs.changes.outputs.should_run != 'false' &&
+ needs.changes.outputs.qualification != 'false' }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-22.04, ubuntu-24.04-arm]
+ runs-on: ${{ matrix.os }}
+ container: ubuntu:20.04
+ timeout-minutes: 20
+ env:
+ ORCA_BACKGROUND_LAUNCH: '1'
+ steps:
+ - name: Install Ubuntu 20.04 prerequisites
+ run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - name: Trust the checked-out workspace
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+ - uses: ./.github/actions/install-node-dependencies
+ - run: pnpm build:orcad
+ - run: pnpm test:bun:profile --artifact
+
+ linux_musl:
+ needs: [changes, persistence]
+ # A failed smoke already blocks qualification; missing scope still selects every platform.
+ if: >-
+ ${{ !cancelled() && needs.persistence.result == 'success' &&
+ needs.changes.outputs.should_run != 'false' &&
+ needs.changes.outputs.qualification != 'false' }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-22.04, ubuntu-24.04-arm]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 20
+ env:
+ ORCA_BACKGROUND_LAUNCH: '1'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - name: Verify native Alpine artifact and persistence
+ run: |
+ docker run --rm --init -i \
+ -e ORCA_BACKGROUND_LAUNCH=1 \
+ -v "$GITHUB_WORKSPACE:/work" -w /work \
+ node:24-alpine3.23 sh -s <<'BUN_QUALIFICATION'
+ set -eu
+ apk add --no-cache bash git libstdc++ python3 make g++
+ git config --global --add safe.directory /work
+ npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")"
+ pnpm install --frozen-lockfile --ignore-scripts
+ pnpm build:orcad
+ pnpm test:bun:profile --artifact
+ BUN_QUALIFICATION
diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml
new file mode 100644
index 000000000000..7309f7e39123
--- /dev/null
+++ b/.github/workflows/ci-cache-warmup.yml
@@ -0,0 +1,131 @@
+name: Warm shared CI caches
+
+on:
+ schedule:
+ - cron: '41 * * * *'
+ workflow_dispatch:
+ push:
+ branches: [main]
+ paths:
+ - '.github/workflows/ci-cache-warmup.yml'
+ - '.github/actions/install-node-dependencies/**'
+ - '.github/actions/prepare-git-compatibility/**'
+ - '.github/actions/prepare-linux-package-fixture/**'
+ - 'config/docker/headless-serve-shutdown/**'
+ - 'config/docker/cli-launch-contract/**'
+ - 'package.json'
+ - 'pnpm-lock.yaml'
+ - 'pnpm-workspace.yaml'
+ - 'config/tsconfig*.json'
+ - 'config/scripts/ensure-native-runtime.mjs'
+ - 'config/scripts/rebuild-native-deps.mjs'
+ - 'config/patches/node-pty@1.1.0.patch'
+ - 'config/patches/@vscode__windows-process-tree@0.8.0.patch'
+ - 'native/windows-registry/**'
+ pull_request:
+ paths:
+ - '.github/workflows/ci-cache-warmup.yml'
+ - '.github/actions/prepare-git-compatibility/**'
+ - '.github/actions/prepare-linux-package-fixture/**'
+ - 'config/docker/headless-serve-shutdown/**'
+ - 'config/docker/cli-launch-contract/**'
+ - 'config/scripts/ci-cache-warmup-workflow.test.mjs'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ warm:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ env:
+ ORCA_BACKGROUND_LAUNCH: '1'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ # Default-branch caches can be restored by every PR; PR caches cannot.
+ - uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: node
+ node-version: '24'
+ cache-electron-package: 'true'
+
+ - name: Populate shared Electron archive
+ run: node config/scripts/install-electron-package-binary.mjs
+
+ - uses: ./.github/actions/prepare-git-compatibility
+
+ - name: Cache TypeScript incremental state
+ id: typecheck-cache
+ uses: actions/cache@v5
+ with:
+ path: config/*.tsbuildinfo
+ key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }}
+ restore-keys: |
+ tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-
+
+ - name: Refresh TypeScript state for this commit
+ if: steps.typecheck-cache.outputs.cache-hit != 'true'
+ run: pnpm run typecheck
+
+ warm-linux-arm:
+ runs-on: ubuntu-24.04-arm
+ timeout-minutes: 10
+ env:
+ ORCA_BACKGROUND_LAUNCH: '1'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: node
+ node-version: '24'
+ cache-electron-package: 'true'
+ - name: Populate shared Electron archive
+ run: node config/scripts/install-electron-package-binary.mjs
+ - name: Verify native cache is usable
+ run: node config/scripts/ensure-native-runtime.mjs --check-only
+
+ warm-windows:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-2022, windows-11-arm]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 20
+ env:
+ ORCA_BACKGROUND_LAUNCH: '1'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: node
+ - name: Verify native cache is usable
+ run: node config/scripts/ensure-native-runtime.mjs --check-only
+
+ warm-linux-package-fixtures:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ env:
+ ORCA_BACKGROUND_LAUNCH: '1'
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - uses: ./.github/actions/prepare-linux-package-fixture
+ with:
+ fixture: headless-serve-shutdown
+ save-cache: ${{ github.event_name != 'pull_request' }}
+ - uses: ./.github/actions/prepare-linux-package-fixture
+ with:
+ fixture: cli-launch-contract
+ save-cache: ${{ github.event_name != 'pull_request' }}
diff --git a/.github/workflows/ci-closed-pr-caches.yml b/.github/workflows/ci-closed-pr-caches.yml
new file mode 100644
index 000000000000..38cf5803a146
--- /dev/null
+++ b/.github/workflows/ci-closed-pr-caches.yml
@@ -0,0 +1,33 @@
+name: Clean closed PR caches
+
+on:
+ pull_request_target:
+ types: [closed]
+
+permissions:
+ actions: write
+
+jobs:
+ clean:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ # No checkout: this runs trusted default-branch code, including for fork PRs.
+ - uses: actions/github-script@v8
+ with:
+ script: |
+ const ref = `refs/pull/${context.payload.pull_request.number}/merge`
+ const caches = await github.paginate(github.rest.actions.getActionsCacheList, {
+ ...context.repo, ref, per_page: 100
+ })
+ for (const cache of caches) {
+ if (cache.ref !== ref) throw new Error(`Unexpected cache ref: ${cache.ref}`)
+ try {
+ await github.rest.actions.deleteActionsCacheById({
+ ...context.repo, cache_id: cache.id
+ })
+ } catch (error) {
+ if (error.status !== 404) throw error
+ }
+ }
+ core.info(`Removed ${caches.length} caches scoped to ${ref}`)
diff --git a/.github/workflows/ci-pnpm-verification-pilot.yml b/.github/workflows/ci-pnpm-verification-pilot.yml
new file mode 100644
index 000000000000..2a35a52fcd0e
--- /dev/null
+++ b/.github/workflows/ci-pnpm-verification-pilot.yml
@@ -0,0 +1,65 @@
+name: pnpm verification cache pilot
+
+on:
+ pull_request:
+ paths: ['.github/workflows/ci-pnpm-verification-pilot.yml']
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: pnpm-verification-pilot-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+env:
+ ORCA_BACKGROUND_LAUNCH: '1'
+
+jobs:
+ seed:
+ strategy:
+ matrix:
+ runner: [ubuntu-latest, ubuntu-24.04-arm]
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - uses: ./.github/actions/install-node-dependencies
+ id: deps
+ # PR caches are scoped to the PR merge ref; main never restores them.
+ - uses: actions/cache/save@v5
+ if: steps.deps.outputs.verification-cache-hit != 'true'
+ with:
+ path: ${{ steps.deps.outputs.verification-cache-path }}
+ key: ${{ steps.deps.outputs.verification-cache-key }}
+
+ measure:
+ needs: seed
+ name: measure ${{ matrix.runner }} sample ${{ matrix.sample }} cache ${{ matrix.cache }}
+ strategy:
+ fail-fast: false
+ max-parallel: 4
+ matrix:
+ runner: [ubuntu-latest, ubuntu-24.04-arm]
+ sample: [1, 2, 3]
+ cache: ['false', 'true']
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - uses: ./.github/actions/install-node-dependencies
+ id: deps
+ with:
+ cache-pnpm-verification: ${{ matrix.cache }}
+ - name: Validate treatment and frozen install
+ env:
+ CACHE_ENABLED: ${{ matrix.cache }}
+ CACHE_HIT: ${{ steps.deps.outputs.verification-cache-hit }}
+ run: |
+ if [ "$CACHE_ENABLED" = true ]; then test "$CACHE_HIT" = true; fi
+ git diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml
+ node -e "require.resolve('vitest'); require.resolve('electron')"
diff --git a/.github/workflows/ci-runner-demand.yml b/.github/workflows/ci-runner-demand.yml
new file mode 100644
index 000000000000..966813a65e40
--- /dev/null
+++ b/.github/workflows/ci-runner-demand.yml
@@ -0,0 +1,33 @@
+name: CI runner demand
+
+on:
+ schedule:
+ - cron: '23 4 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ actions: read
+
+concurrency:
+ group: ci-runner-demand
+ cancel-in-progress: false
+
+jobs:
+ report:
+ runs-on: ubuntu-slim
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ sparse-checkout: config/scripts
+ persist-credentials: false
+ - name: Measure the previous complete 24 hours
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: node config/scripts/ci-runner-demand.mjs
+ - uses: actions/upload-artifact@v7
+ with:
+ name: ci-runner-demand-${{ github.run_id }}
+ path: ci-demand/
+ retention-days: 30
diff --git a/.github/workflows/ci-xterm-cache.yml b/.github/workflows/ci-xterm-cache.yml
new file mode 100644
index 000000000000..fada7ea8cab4
--- /dev/null
+++ b/.github/workflows/ci-xterm-cache.yml
@@ -0,0 +1,55 @@
+name: Shared xterm dependency cache
+on:
+ push:
+ branches: [main]
+ paths:
+ - '.github/workflows/ci-xterm-cache.yml'
+ - '.github/actions/prepare-xterm-dependencies/**'
+ - 'config/patches/xterm-upstream.json'
+ - 'config/scripts/regenerate-xterm-patches.mjs'
+ - 'config/scripts/xterm-patch-text.mjs'
+ - 'package.json'
+ # Refresh after Node patch releases or cache eviction without rebuilding on hits.
+ schedule:
+ - cron: '17 5 * * *'
+ workflow_dispatch:
+permissions:
+ contents: read
+concurrency:
+ group: xterm-cache-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+env:
+ ORCA_BACKGROUND_LAUNCH: '1'
+jobs:
+ seed:
+ if: github.ref == 'refs/heads/main'
+ runs-on: ubuntu-24.04-arm
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: package.json
+ package-manager-cache: false
+ - uses: ./.github/actions/prepare-xterm-dependencies
+ id: cache
+ - name: Verify and populate dependencies
+ if: steps.cache.outputs.cache-hit != 'true'
+ run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$RUNNER_TEMP/xterm-patch-build"
+ - uses: actions/cache/save@v5
+ if: steps.cache.outputs.cache-hit != 'true'
+ with:
+ path: |
+ ${{ runner.temp }}/xterm-patch-build/upstream/.git
+ ${{ runner.temp }}/xterm-patch-build/upstream/node_modules
+ key: ${{ steps.cache.outputs.cache-key }}
+ - name: Preserve fallback downloads
+ if: steps.cache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@v5
+ with:
+ path: |
+ ~/.npm
+ ${{ runner.temp }}/xterm-patch-build/upstream/.git
+ key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
diff --git a/.github/workflows/cloud-verify.yml b/.github/workflows/cloud-verify.yml
index 6191552dab9a..6c4cb9a53a12 100644
--- a/.github/workflows/cloud-verify.yml
+++ b/.github/workflows/cloud-verify.yml
@@ -32,9 +32,30 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
- fetch-depth: 0
+ fetch-depth: 1
+
+ - name: Pull Gitleaks image
+ id: gitleaks-image
+ background: true
+ run: docker pull zricethezav/gitleaks@sha256:cdbb7c955abce02001a9f6c9f602fb195b7fadc1e812065883f695d1eeaba854
+
+ - name: Pull TruffleHog image
+ id: trufflehog-image
+ background: true
+ run: docker pull trufflesecurity/trufflehog@sha256:5dc064868ba7933601b5cbaea6954954d524ddd5dc6222a9667acea70068bf7d
+
+ # Scan every ancestor of HEAD without downloading unrelated branch/tag histories.
+ - name: Fetch complete scan history
+ working-directory: .
+ run: |
+ git fetch --no-tags --unshallow origin "$GITHUB_SHA"
+ test "$(git rev-parse --is-shallow-repository)" = false
+
+ - wait: [gitleaks-image, trufflehog-image]
- name: Scan Cloud history reachable from HEAD with Gitleaks
+ id: history-scan
+ background: true
run: >-
docker run --rm
--volume "${GITHUB_WORKSPACE}:/repo:ro"
@@ -51,6 +72,8 @@ jobs:
--include-paths=/repo/cloud/.trufflehog-include-paths.txt
--exclude-paths=/repo/cloud/.trufflehog-exclude-paths.txt
+ - wait: history-scan
+
# Compiles the workspace. No Postgres service: nothing here reaches a
# database, and the service container costs ~13s of startup.
build:
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index f42d1184ce93..4177b141d8a4 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -32,9 +32,8 @@ on:
required: false
type: string
schedule:
- # Why: GitHub cron uses UTC; these slots map to 10am and 3pm
- # America/Phoenix for the default-branch E2E run.
- - cron: '0 17,22 * * *'
+ # One complete daily reference run; targeted PR coverage remains unchanged.
+ - cron: '0 17 * * *'
jobs:
build:
@@ -62,11 +61,26 @@ jobs:
status=0
pnpm run build:relay &
relay_pid=$!
- npx electron-vite build --mode e2e || status=1
- pnpm run build:web-from-renderer || status=1
+ pnpm run build:electron-vite:parallel --mode e2e || status=1
wait "$relay_pid" || status=1
exit "$status"
+ # The CLI emits into out/main too; start only after Electron finishes clearing that tree.
+ - name: Build shared E2E CLI
+ id: e2e-cli
+ background: true
+ run: |
+ if node -e 'process.exit(require("./package.json").scripts["prepare:cli-output"] ? 0 : 1)'; then
+ pnpm run build:cli
+ else
+ echo "Older ref: let each consumer build and install its CLI."
+ fi
+
+ - name: Project shared E2E web client
+ run: pnpm run build:web-from-renderer
+
+ - wait: e2e-cli
+
- name: Upload E2E build output
uses: actions/upload-artifact@v7
with:
@@ -186,7 +200,14 @@ jobs:
node config/scripts/ci-e2e-shard-plan.mjs --verify ci-shards/assignment.json ci-shards/selected-discovery.json
- name: Run E2E tests (${{ matrix.shard_name }})
- run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt
+ env:
+ PLAYWRIGHT_JSON_OUTPUT_FILE: ci-shards/results.json
+ run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt --reporter=list,json
+
+ - name: Summarize E2E failures
+ if: always()
+ continue-on-error: true
+ run: node config/scripts/ci-e2e-failure-summary.mjs ci-shards/results.json
- name: Upload E2E shard assignment
if: always()
@@ -221,8 +242,6 @@ jobs:
needs: [build, prepare-native-cache]
if: inputs.test_files != ''
runs-on: ubuntu-latest
- # Why 45: pr.yml now maps SSH source edits onto Docker-backed specs, so this lane can
- # pay a container image build plus ~22 serial SSH tests on top of the changed specs.
timeout-minutes: 45
steps:
@@ -256,14 +275,38 @@ jobs:
# ORCA_E2E_NATIVE_IBUS_HANGUL, which this lane cannot set because it has no ibus
# session. Running it here reported a green skip as coverage.
mapfile -t TEST_FILES < <(jq -r '.[] | select(
+ . != "tests/e2e/local-ssh-browser-routing.spec.ts" and
+ . != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and
+ . != "tests/e2e/pty-input-write-queue-ssh.spec.ts" and
+ . != "tests/e2e/ssh-ai-vault-session-history.spec.ts" and
+ . != "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts" and
+ . != "tests/e2e/ssh-cold-activation-restore.spec.ts" and
+ . != "tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts" and
+ . != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and
+ . != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and
+ . != "tests/e2e/ssh-docker-half-open-link.spec.ts" and
+ . != "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts" and
+ . != "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts" and
+ . != "tests/e2e/ssh-docker-relay-stall-credential.spec.ts" and
+ . != "tests/e2e/ssh-docker-resource-accumulation.spec.ts" and
+ . != "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts" and
+ . != "tests/e2e/ssh-external-image-preview.spec.ts" and
+ . != "tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts" and
+ . != "tests/e2e/ssh-pi-compatible-agent-title.spec.ts" and
+ . != "tests/e2e/ssh-port-forward-lifecycle.spec.ts" and
+ . != "tests/e2e/ssh-reconnect-tab-destruction.spec.ts" and
+ . != "tests/e2e/ssh-restart-tab-accumulation.spec.ts" and
+ . != "tests/e2e/ssh-skill-installation.spec.ts" and
+ . != "tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts" and
+ . != "tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts" and
+ . != "tests/e2e/terminal-inline-images-ssh.spec.ts" and
+ . != "tests/e2e/ssh-docker-watcher-isolation.spec.ts" and
+ . != "tests/e2e/ssh-terminal-parking.spec.ts" and
+ . != "tests/e2e/terminal-retention-budget.spec.ts" and
. != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and
. != "tests/e2e/paired-startup-exec-readiness.spec.ts" and
- . != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and
- . != "tests/e2e/local-ssh-browser-routing.spec.ts" and
. != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and
. != "tests/e2e/ssh-localhost.spec.ts" and
- . != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and
- . != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and
. != "tests/e2e/terminal-ibus-hangul-native.spec.ts"
)' <<<"$TEST_FILES_JSON")
if [ "${#TEST_FILES[@]}" -eq 0 ]; then
@@ -295,25 +338,47 @@ jobs:
if-no-files-found: ignore
ssh-docker-watcher-isolation:
- name: ssh docker watcher isolation
+ name: ssh docker watcher isolation (${{ matrix.shard }}/4)
needs: [build, prepare-native-cache]
- # effect of one route listing a startup-readiness spec — pruning that spec would have
- # silently retired the whole lane. The signal is now derived from the SSH routes directly.
- # The explicit spec clauses stay for their honest purpose: changed-e2e hands these specs to this
- # lane, so editing one must still run it here.
+ # Each excluded changed spec must trigger its dedicated owner.
if: >-
inputs.test_files == '' ||
inputs.ssh_source_changed == 'true' ||
- contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/local-ssh-browser-routing.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts') ||
- contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/pty-input-write-queue-ssh.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-ai-vault-session-history.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-cold-activation-restore.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-docker-half-open-link.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-docker-resource-accumulation.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-external-image-preview.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-port-forward-lifecycle.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-restart-tab-accumulation.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-skill-installation.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/terminal-inline-images-ssh.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-docker-watcher-isolation.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-terminal-parking.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/terminal-retention-budget.spec.ts') ||
+ contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
runs-on: ubuntu-latest
- # Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average
- # ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35
- # — and the sharded lanes already show that a lane which times out is a lane nobody trusts.
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3, 4]
timeout-minutes: 60
steps:
@@ -338,13 +403,14 @@ jobs:
# Why: this is the release-path proof that the deployed Linux relay keeps
# its PTY and explorer live across a real watcher SIGSEGV.
- name: Run Docker SSH watcher isolation E2E
+ if: matrix.shard == 1
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
# Why: Playwright empties test-results/ when it starts, so each step here used to
# destroy the previous step's traces. Only the last lane's failure was ever
# diagnosable from the artifact; set each lane aside before the next one runs.
- name: Keep watcher-isolation traces
- if: always()
+ if: always() && matrix.shard == 1
run: |
if [ -d test-results ]; then
mkdir -p e2e-traces
@@ -354,24 +420,21 @@ jobs:
# Why always(): this lane gates SSH parking/retention plus startup-exec
# readiness across live SSH, headed paired, and headless serve topologies.
- name: Run Docker SSH terminal parking + startup readiness E2E
- if: always()
+ if: always() && matrix.shard == 1
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
- name: Keep terminal-parking traces
- if: always()
+ if: always() && matrix.shard == 1
run: |
if [ -d test-results ]; then
mkdir -p e2e-traces
mv test-results "e2e-traces/terminal-parking"
fi
- # Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every
- # spec below skipped itself while the shard still reported green. Running them on this one
- # VM pays the fixture image build once instead of ten times, and keeps an SSH regression
- # legible as an SSH-named failure.
+ # Separate VMs isolate destructive SSH fixtures while keeping one worker per shard.
- name: Run remaining Docker SSH E2E
if: always()
- run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
+ run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker --shard=${{ matrix.shard }}/4
- name: Keep remaining-ssh-docker traces
if: always()
@@ -385,7 +448,7 @@ jobs:
if: failure()
uses: actions/upload-artifact@v7
with:
- name: playwright-traces-ssh-docker-watcher-isolation
+ name: playwright-traces-ssh-docker-watcher-isolation-${{ matrix.shard }}
path: e2e-traces/
retention-days: 7
if-no-files-found: ignore
diff --git a/.github/workflows/git-command-termination-runtime.yml b/.github/workflows/git-command-termination-runtime.yml
index 1602bae956a9..6b49177ed560 100644
--- a/.github/workflows/git-command-termination-runtime.yml
+++ b/.github/workflows/git-command-termination-runtime.yml
@@ -7,6 +7,9 @@ on:
workflow_dispatch:
permissions:
contents: read
+concurrency:
+ group: git-command-termination-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
windows-exit:
runs-on: windows-latest
diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml
index 11cfa866c676..0b80662d7166 100644
--- a/.github/workflows/golden-e2e-experiment.yml
+++ b/.github/workflows/golden-e2e-experiment.yml
@@ -66,10 +66,10 @@ jobs:
- name: Run golden E2E tests on Linux
if: runner.os == 'Linux'
run: |
- xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
+ xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-core-flows.spec.ts
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
- xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
+ xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
fi
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
@@ -80,10 +80,10 @@ jobs:
- name: Run golden E2E tests on macOS
if: runner.os == 'macOS'
run: |
- env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
+ env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-core-flows.spec.ts
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
- env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
+ env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
fi
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
@@ -104,7 +104,7 @@ jobs:
pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) {
- pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
+ pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
pnpm run --if-present test:e2e:source-control-golden
diff --git a/.github/workflows/issue-os-labeler.yaml b/.github/workflows/issue-os-labeler.yaml
index c437db7f21a5..cf0ee59a8425 100644
--- a/.github/workflows/issue-os-labeler.yaml
+++ b/.github/workflows/issue-os-labeler.yaml
@@ -12,7 +12,7 @@ permissions:
jobs:
apply-os-label:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-slim
timeout-minutes: 5
steps:
- name: Apply OS label from issue form
diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml
index aae50234bf94..7ec322b02036 100644
--- a/.github/workflows/mobile.yml
+++ b/.github/workflows/mobile.yml
@@ -6,7 +6,6 @@ on:
- opened
- synchronize
- reopened
- - ready_for_review
paths:
- 'mobile/**'
# Mobile launch contracts exercise the real host dispatcher and durable receipt store.
@@ -38,6 +37,10 @@ on:
- '.github/workflows/mobile.yml'
- '.github/actions/install-node-dependencies/**'
- '.github/workflows/mobile-ios-release.yml'
+ - 'config/scripts/mobile-release-check-scope*'
+ - 'config/scripts/mobile-test-change-scope*'
+ - 'config/scripts/pr-code-change-scope.mjs'
+ - 'config/scripts/mobile-recording-pin-checkout.test.mjs'
# Why main too: a behaviour-change branch legitimately pins its own last fenced commit, and that
# commit only stops being reachable when the branch squash-merges. The pull_request run cannot
# see that; this one is where the pin guard finds it.
@@ -58,7 +61,10 @@ concurrency:
jobs:
verify:
if: github.event_name == 'pull_request'
- runs-on: ubuntu-latest
+ # Why ARM: 209s of this job is Vitest and nothing here needs x86: no Android SDK, emulator, gradle,
+ # Hermes or Watchman, no docker, and no artifacts. The Gemfile.lock lists the generic `ruby`
+ # platform, so frozen bundler installs without an aarch64-linux entry.
+ runs-on: ubuntu-24.04-arm
env:
# Why: an unfrozen bundler silently re-resolves when Gemfile.lock drifts
@@ -73,6 +79,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
+ with:
+ fetch-depth: 2
- uses: ./.github/actions/install-node-dependencies
with:
@@ -80,10 +88,23 @@ jobs:
pnpm-lock.yaml
mobile/pnpm-lock.yaml
+ - name: Detect Ruby release inputs
+ id: ruby-scope
+ shell: bash
+ working-directory: .
+ run: |
+ # Keep deletions when release files move into an application directory.
+ if ! git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/mobile-release-changes"; then
+ echo 'should_run=true' >> "$GITHUB_OUTPUT"
+ elif ! node config/scripts/mobile-release-check-scope.mjs "$RUNNER_TEMP/mobile-release-changes"; then
+ echo 'should_run=true' >> "$GITHUB_OUTPUT"
+ fi
+
# bundler-cache installs mobile/Gemfile.lock, so this job is also what
# proves the pinned fastlane the release workflow depends on still
# resolves — before a release run finds out.
- name: Setup Ruby and fastlane
+ if: steps.ruby-scope.outputs.should_run != 'false'
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
@@ -93,7 +114,10 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
+ # Both compilers are read-only; finish them before starting the test workers.
- name: Typecheck
+ id: production-types
+ background: true
run: pnpm typecheck
# Why a ratchet and not the raw typecheck: mobile/tsconfig.json excludes test files, so until
@@ -103,19 +127,37 @@ jobs:
- name: Typecheck tests (ratchet)
run: pnpm run check:tests-typecheck
+ - wait: production-types
+
# This includes the bridged replay of the whole recording corpus, which used to be a second
# step of its own behind RPC_FOUNDATION_BRIDGE=1. A gate nobody can forget to set is the point:
# it fails when a divergence class grows, when a divergence lands in no class at all, or when
# one of the 103 goldens inside the C1 page closure changes the verdict it is pinned to. It is
# ~3 min of test time on its own, and Vitest runs it on a worker beside the rest of the suite,
# so folding it in costs a fraction of that in wall time and one step less to skip.
+ - name: Detect mobile test inputs
+ id: test-scope
+ shell: bash
+ working-directory: .
+ run: |
+ if ! git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/mobile-test-changes"; then
+ echo 'should_run=true' >> "$GITHUB_OUTPUT"
+ elif ! node config/scripts/mobile-test-change-scope.mjs "$RUNNER_TEMP/mobile-test-changes"; then
+ echo 'should_run=true' >> "$GITHUB_OUTPUT"
+ fi
+
- name: Test
+ if: steps.test-scope.outputs.should_run != 'false'
+ env:
+ ORCA_BACKGROUND_LAUNCH: '1'
run: pnpm test
- name: Test iOS release version resolution
+ if: steps.ruby-scope.outputs.should_run != 'false'
run: ruby fastlane/ios_release_version_test.rb
- name: Test TestFlight lane arguments
+ if: steps.ruby-scope.outputs.should_run != 'false'
run: ruby fastlane/fastfile_testflight_arguments_test.rb
# Why: nothing else in CI loads the Fastfile, so a syntax error, a broken
@@ -124,6 +166,7 @@ jobs:
# loads and lists, so it needs no App Store Connect credentials and makes
# no network calls to Apple.
- name: Smoke-check the Fastfile
+ if: steps.ruby-scope.outputs.should_run != 'false'
env:
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_OPT_OUT_USAGE: '1'
@@ -137,7 +180,8 @@ jobs:
recording-pin:
name: RPC recording pin
- runs-on: ubuntu-latest
+ # Why ARM: pure Node plus git; the golden comparison masks `platform`.
+ runs-on: ubuntu-24.04-arm
defaults:
run:
@@ -152,6 +196,8 @@ jobs:
# answer at all rather than reporting a pass it has no evidence for -- and the pinned tree
# below has to be checkable out.
fetch-depth: 0
+ # Ancestry needs commits; the pinned worktree fetches its historical blobs on demand.
+ filter: blob:none
- uses: ./.github/actions/install-node-dependencies
with:
diff --git a/.github/workflows/node-next-compat.yml b/.github/workflows/node-next-compat.yml
index 43556c864dc1..cb0d303f8c5d 100644
--- a/.github/workflows/node-next-compat.yml
+++ b/.github/workflows/node-next-compat.yml
@@ -1,8 +1,8 @@
-name: Node next compatibility
+name: Scheduled x86 unit compatibility
on:
schedule:
- # Full future-runtime coverage is useful, but not worth doubling every PR matrix.
+ # Keep current and future Node coverage on x86 while PR unit shards use ARM.
- cron: '0 10 * * *'
workflow_dispatch:
@@ -14,9 +14,13 @@ permissions:
contents: read
jobs:
- # A cold cache would otherwise make all eight Node 26 shards compile the same native addons.
+ # Prime each Node ABI before its shards restore native modules.
test_native_cache:
- name: prepare test native cache node 26
+ name: prepare test native cache node ${{ matrix.node }}
+ strategy:
+ fail-fast: false
+ matrix:
+ node: ['24', '26']
runs-on: ubuntu-latest
steps:
@@ -28,10 +32,10 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- node-version: '26'
+ node-version: ${{ matrix.node }}
test:
needs: [test_native_cache]
uses: ./.github/workflows/unit-tests.yml
with:
- node_versions: '["26"]'
+ node_versions: '["24", "26"]'
diff --git a/.github/workflows/packaged-browser-e2e.yml b/.github/workflows/packaged-browser-e2e.yml
index 2a23ac58988c..7d36e7fd1b13 100644
--- a/.github/workflows/packaged-browser-e2e.yml
+++ b/.github/workflows/packaged-browser-e2e.yml
@@ -39,7 +39,11 @@ jobs:
root=pathlib.Path(os.environ['RUNNER_TEMP'])/'old-orca'
package=root/'orca-ide_1.4.188_amd64.deb'
expected='uGONFUDfinYggxcT9ac72wnnlofLQaqasDDeP0HWOSqarBwTi1Ax3khmzKUY3vUnvuYOpSCEmsH4InzLZ2vg6g=='
- assert base64.b64encode(hashlib.sha512(package.read_bytes()).digest()).decode()==expected
+ digest=hashlib.sha512()
+ with package.open('rb') as archive:
+ for block in iter(lambda: archive.read(1024*1024), b''):
+ digest.update(block)
+ assert base64.b64encode(digest.digest()).decode()==expected
extracted=root/'extracted'
subprocess.run(['dpkg-deb','-x',str(package),str(extracted)],check=True)
executable=extracted/'opt'/'Orca'/'orca-ide'
diff --git a/.github/workflows/pi-owner-runtime.yml b/.github/workflows/pi-owner-runtime.yml
index 9f9df4f9b0b5..592bc75bb5e8 100644
--- a/.github/workflows/pi-owner-runtime.yml
+++ b/.github/workflows/pi-owner-runtime.yml
@@ -9,6 +9,9 @@ on:
workflow_dispatch:
permissions:
contents: read
+concurrency:
+ group: pi-owner-runtime-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
runtime:
strategy:
diff --git a/.github/workflows/pi-provider-runtime.yml b/.github/workflows/pi-provider-runtime.yml
index 837c38baf9a8..310467374994 100644
--- a/.github/workflows/pi-provider-runtime.yml
+++ b/.github/workflows/pi-provider-runtime.yml
@@ -7,6 +7,9 @@ on:
- '.github/workflows/pi-provider-runtime.yml'
permissions:
contents: read
+concurrency:
+ group: pi-provider-runtime-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
runtime:
strategy:
diff --git a/.github/workflows/pr-test-loc.yml b/.github/workflows/pr-test-loc.yml
index a884aedc6361..340c6c7b70d1 100644
--- a/.github/workflows/pr-test-loc.yml
+++ b/.github/workflows/pr-test-loc.yml
@@ -6,7 +6,6 @@ on:
- opened
- synchronize
- reopened
- - ready_for_review
concurrency:
group: pr-test-loc-${{ github.event.pull_request.number }}
@@ -19,7 +18,8 @@ permissions:
jobs:
loc:
name: test vs non-test LoC
- runs-on: ubuntu-latest
+ # API calls and a small Node script fit the free single-CPU container runner.
+ runs-on: ubuntu-slim
timeout-minutes: 2
steps:
# Why no checkout: the Files API already has per-file additions/deletions.
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 1b52aac7571d..27fc5215044d 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -1,4 +1,5 @@
name: PR Checks
+run-name: "PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}"
on:
pull_request:
@@ -23,25 +24,32 @@ jobs:
# Per-job outputs also skip git-compat/xterm/packaging/shell when those
# inputs are unchanged; empty diffs fail closed and run everything.
code_paths:
- name: detect code-relevant changes
- runs-on: ubuntu-latest
+ name: detect changes and check repository guards
+ # Reuse one lightweight checkout for detection and the always-required guards.
+ runs-on: ubuntu-slim
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ actions: read
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
- native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
+ reused_run_id: ${{ steps.readiness.outputs.run_id }}
+ # A proven success masks required work only; advisory routing still uses the full diff.
+ native_cache_changed: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.native_cache_changed }}
mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }}
- mobile_web_app: ${{ steps.filter.outputs.mobile_web_app }}
- static_analysis: ${{ steps.filter.outputs.static_analysis }}
- typecheck: ${{ steps.filter.outputs.typecheck }}
- git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
- codex_index_heal_contract: ${{ steps.filter.outputs.codex_index_heal_contract }}
- xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
- shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
- test: ${{ steps.filter.outputs.test }}
- orcad_browser: ${{ steps.filter.outputs.orcad_browser }}
- cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }}
- managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }}
- package: ${{ steps.filter.outputs.package }}
- package_windows: ${{ steps.filter.outputs.package_windows }}
+ mobile_web_app: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.mobile_web_app }}
+ static_analysis: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.static_analysis }}
+ typecheck: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.typecheck }}
+ git_compatibility: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.git_compatibility }}
+ codex_index_heal_contract: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.codex_index_heal_contract }}
+ xterm_patch_sync: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.xterm_patch_sync }}
+ shell_contracts: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.shell_contracts }}
+ test: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.test }}
+ orcad_browser: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.orcad_browser }}
+ cross-version-wire: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.cross-version-wire }}
+ managed_hook_node18: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.managed_hook_node18 }}
+ package: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }}
+ package_windows: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }}
e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }}
test_files: ${{ steps.e2e_filter.outputs.test_files }}
ssh_source_changed: ${{ steps.e2e_filter.outputs.ssh_source_changed }}
@@ -51,23 +59,59 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
- # Why blob:none: full history is needed for the merge-base diff, but historical
- # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
- # few this job actually reads on demand.
- fetch-depth: 0
+ # Why depth 50 and not 0: every diff below resolves to HEAD^1, so only the merge commit
+ # and a little slack are needed. Fetching all 8127 refs' commit graph cost ~20s here and
+ # is charged to the start of all 22 jobs, since they all need this one.
+ # Why blob:none stays: the sparse tree below is ~7 files, so there are no blobs to
+ # materialize and no promisor refetch. Measured 9.5s -> 1.6s against 20.7s today.
+ fetch-depth: 50
filter: blob:none
+ sparse-checkout: |
+ /config/scripts/git-pull-request-diff-base.mjs
+ /package.json
+ /README.md
+ /docs/readme/
+ /.github/scripts/check-root-directory-entries.mjs
+ /config/scripts/check-readme-local-links.mjs
+ /config/scripts/pr-code-change-scope.mjs
+ /config/scripts/pr-e2e-source-routing.mjs
+ /config/scripts/pr-ready-check-reuse.mjs
+ sparse-checkout-cone-mode: false
persist-credentials: false
+ - name: Reject new root-level files and folders
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
+ node .github/scripts/check-root-directory-entries.mjs "$DIFF_BASE" HEAD
+
+ # The full Git index retains link targets outside the sparse working tree.
+ - name: Check README local links
+ run: node config/scripts/check-readme-local-links.mjs
+
+ # Readiness changes eligibility for advisory tests, not the already-tested source.
+ - name: Find identical successful required checks
+ id: readiness
+ if: github.event.action == 'ready_for_review'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_CHECK_WORKFLOW_SHA: ${{ github.workflow_sha }}
+ run: node config/scripts/pr-ready-check-reuse.mjs
+
- name: Classify changed paths
id: filter
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
+ # Why HEAD^1 and not --merge-base: HEAD is the pull request merge commit, so its first
+ # parent is the base side already. Computing a merge base instead would require the
+ # payload base SHA to be in the graph, which is what forced a full-history checkout.
+ DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
# Why --no-renames: name-only rename detection can report only the destination.
# A code file moved under docs/ must still expose its code-side deletion.
- CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")"
+ CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)"
echo "Changed paths:"
printf '%s\n' "$CHANGED"
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT"
@@ -79,8 +123,8 @@ jobs:
run: |
set -euo pipefail
BASE="${{ github.event.pull_request.base.sha }}"
- HEAD="${{ github.event.pull_request.head.sha }}"
- CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
+ DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE")"
+ CHANGED="$(git diff --name-only --diff-filter=AMCR "$DIFF_BASE" HEAD)"
# Source routes are executable contracts so a test can prove exact
# authorities, exclusions, and sentinels without evaluating workflow shell.
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
@@ -94,7 +138,7 @@ jobs:
# trigger on IME source rather than on a spec name in some route's list.
NATIVE_IME_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --native-ime-source)"
echo "native_ime_source_changed=$NATIVE_IME_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
- WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE" "$HEAD")"
+ WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)"
WSL_SOURCE_CHANGED="$(printf '%s\n' "$WSL_CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --wsl-source)"
echo "wsl_source_changed=$WSL_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED"
@@ -111,38 +155,63 @@ jobs:
name: static analysis
needs: [code_paths]
if: needs.code_paths.outputs.static_analysis == 'true'
- runs-on: ubuntu-latest
+ # Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster --
+ # type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke
+ # 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so
+ # the whole toolchain resolves. Free for public repositories, same as the typecheck job.
+ runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v6
with:
- # Why blob:none: full history is needed for the merge-base diff, but historical
- # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
- # few this job actually reads on demand.
- fetch-depth: 0
- filter: blob:none
+ # Why depth 50: the gates below diff against HEAD^1, so no merge base is computed and
+ # the payload base SHA need not be in the graph.
+ # Why no blob:none here, unlike code_paths: this job checks out all 30,226 files, and
+ # the filter then forces a second promisor fetch of nearly every blob. Measured 23s
+ # blobless against ~11s without it.
+ fetch-depth: 50
persist-credentials: false
+ # Why two guarded installs: the mixed root+mobile store entry is 537 MB against
+ # 321 MB for root alone, and restoring it costs 8.6s against 4.6s. Most PRs skip the
+ # mobile install below, so they were paying 216 MB for packages they never link. The
+ # root-only key is also the one the hourly warmer reseeds. Only one of these runs.
- uses: ./.github/actions/install-node-dependencies
+ if: needs.code_paths.outputs.mobile_dependencies != 'true'
+ with:
+ native-runtime: node
+
+ - uses: ./.github/actions/install-node-dependencies
+ if: needs.code_paths.outputs.mobile_dependencies == 'true'
with:
native-runtime: node
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
+ # Keep each check in its own log while sharing this runner.
- name: Lint
+ id: root-lint
+ background: true
run: pnpm exec oxlint --format github
- name: Reject low-evidence patterns
run: pnpm run audit:anti-slop
+ - wait: root-lint
+
- name: Enforce focused code-quality plugins
+ id: native-code-quality
+ background: true
run: pnpm run audit:code-quality:native
- name: Enforce type-aware code-quality baseline
run: pnpm run audit:code-quality:type-aware
+ # Mobile installation changes import resolution for the native cycle check.
+ - wait: native-code-quality
+
# Why here: the changed-code gate lints mobile files too, and its type-aware pass
# resolves types from mobile/node_modules. Without the install every mobile type
# degrades to an `error` type — reported as phantom findings against the changed lines.
@@ -150,11 +219,15 @@ jobs:
if: needs.code_paths.outputs.mobile_dependencies == 'true'
- name: Enforce changed-code quality
+ id: changed-code-quality
+ background: true
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
- name: Enforce React Doctor on changed lines
run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}"
+ - wait: changed-code-quality
+
- name: Check Zustand selector fan-out budget
run: pnpm run check:zustand-selector-fanout
@@ -167,9 +240,9 @@ jobs:
- name: Check VM runtime rollback compatibility
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
- if git diff --quiet --merge-base "$BASE_SHA" "$HEAD_SHA" -- \
+ DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
+ if git diff --quiet "$DIFF_BASE" HEAD -- \
src/shared/ephemeral-vm-runtime-store.ts \
src/shared/ephemeral-vm-runtime-feature-store.ts \
src/shared/ephemeral-vm-runtime-rollback-projection.ts \
@@ -194,11 +267,39 @@ jobs:
- name: Enforce runtime Electron-import ratchet
run: pnpm run check:runtime-electron-ratchet
+ # Why: extraction writes sorted evidence to an isolated temporary path,
+ # so feature PRs need one normalized AST pass rather than a three-OS matrix.
+ - name: Verify localization extraction
+ id: localization-extraction
+ background: true
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ # Detection failures run the full check; renames retain the removed input path.
+ DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
+ if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" &&
+ scope="$(node config/scripts/localization-extraction-change-scope.mjs "$RUNNER_TEMP/localization-changes")" && [ "$scope" = false ]; then
+ echo "Localization extraction inputs are unchanged."
+ else
+ pnpm run verify:localization-extraction
+ fi
+
# Why both: the ratchet proves nothing reachable from the runtime imports electron,
# which is a property of the import graph. This proves the Node artifact it enables
# actually boots, pairs, creates a worktree and round-trips a real PTY.
- name: Boot orcad and round-trip a terminal
- run: pnpm run smoke:orcad-terminal
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ ORCA_BACKGROUND_LAUNCH: '1'
+ run: |
+ # Detection failures run the smoke; renames retain the removed input path.
+ DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
+ if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/orcad-smoke-changes" &&
+ scope="$(node config/scripts/orcad-terminal-smoke-change-scope.mjs "$RUNNER_TEMP/orcad-smoke-changes")" && [ "$scope" = false ]; then
+ echo "Orcad terminal smoke inputs are unchanged."
+ else
+ pnpm run smoke:orcad-terminal
+ fi
- name: Verify the generated RPC params catalog
run: pnpm run verify:rpc-params-catalog
@@ -209,23 +310,16 @@ jobs:
- name: Verify skill freshness manifest
run: pnpm run verify:skill-bundle-manifest
- - name: Verify localization catalog
- run: pnpm run verify:localization-catalog
-
- # Why: the renderer ships only the English entries i18next cannot rebuild
- # from each call site's inline default, so the generated subset has to
- # track en.json and those defaults.
- - name: Verify runtime-required localization catalog
- run: pnpm run verify:localization-runtime-catalog
-
- # Why: extraction writes sorted evidence to an isolated temporary path,
- # so feature PRs need one normalized AST pass rather than a three-OS matrix.
- - name: Verify localization extraction
- run: pnpm run verify:localization-extraction
+ - name: Verify localization catalogs
+ id: localization-catalogs
+ background: true
+ run: pnpm run verify:localization-catalogs
- name: Verify localization coverage
run: pnpm run verify:localization-coverage
+ - wait: [localization-catalogs, localization-extraction]
+
# Why: project-owned type declarations must live in .ts so tsc
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names
@@ -249,37 +343,11 @@ 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:
- # Why blob:none: full history is needed for the merge-base diff, but historical
- # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
- # few this job actually reads on demand.
- fetch-depth: 0
- filter: blob:none
- 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: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA"
-
- # Why here: the READMEs embed media owned by docs/site and resources/onboarding,
- # and the classifier skips static_analysis for docs-only diffs. This job runs
- # on every PR and needs no install.
- - name: Check README local links
- run: node config/scripts/check-readme-local-links.mjs
-
typecheck:
needs: [code_paths]
if: needs.code_paths.outputs.typecheck == 'true'
- runs-on: ubuntu-latest
+ # Typechecking uses no native runtime, so it can use the free public ARM runner.
+ runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
@@ -317,39 +385,7 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
- # Why: the 2.25.5 lane is a source build of a pinned tarball, so it produced the
- # same binary on every PR for minutes of runner time. The key carries the version
- # because that is the only input; the sha256 assertion below still guards the
- # tarball on the miss path that actually builds. Only this PR's own later pushes
- # can restore it — GitHub scopes a cache written from a pull_request run to that
- # ref — so a first push always takes the build path below.
- - name: Cache baseline Git build
- uses: actions/cache@v5
- with:
- path: ~/.cache/orca-git-compat/git-2.25.5
- key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5
-
- # Why its own step: this is `make -j$(nproc)` on every core, and the lanes below
- # spend their wall clock waiting on container starts, not on Git. Sharing a runner
- # with the build stretched one ~1.5s boundary case past Vitest's 30s timeout, so
- # the build has to finish before anything timed starts.
- - name: Build the baseline Git binary
- run: |
- archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
- source="$HOME/.cache/orca-git-compat/git-2.25.5"
- if [ -x "$source/git" ]; then
- exit 0
- fi
- curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
- echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
- | sha256sum --check
- mkdir -p "$source"
- tar -xzf "$archive" -C "$source" --strip-components=1
- make -C "$source" -j"$(nproc)" \
- NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
- # Why: the linked binaries are what the next run needs; the objects that
- # produced them are most of the tree and would bloat the cache entry.
- find "$source" -name '*.o' -delete
+ - uses: ./.github/actions/prepare-git-compatibility
- name: Verify Git binary compatibility matrix
run: |
@@ -399,7 +435,8 @@ jobs:
name: Codex index-heal contract
needs: [code_paths]
if: needs.code_paths.outputs.codex_index_heal_contract == 'true'
- runs-on: ubuntu-latest
+ # Why ARM: @openai/codex ships @openai/codex-linux-arm64.
+ runs-on: ubuntu-24.04-arm
env:
CODEX_CLI_VERSION: '0.150.1'
@@ -434,7 +471,10 @@ jobs:
name: xterm patch sync
needs: [code_paths]
if: needs.code_paths.outputs.xterm_patch_sync == 'true'
- runs-on: ubuntu-latest
+ # Why ARM: the patch check rebuilds 4 packages x 2 builds and byte-compares against the
+ # checked-in bundles. Those were generated on Linux x64 and reproduce byte-for-byte on
+ # darwin-arm64, so the output is neither host-arch nor host-OS dependent.
+ runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
@@ -442,21 +482,14 @@ jobs:
with:
persist-credentials: false
- - uses: ./.github/actions/install-node-dependencies
-
- # Why: the check rebuilds every package in the manifest from a pinned upstream
- # commit — @xterm/xterm and its three addons, each built twice (once unmodified to
- # prove the toolchain still reproduces the published bundles, once patched). Caching
- # the npm metadata and the shallow clone keeps the repeated cost to the builds
- # themselves; the key is the manifest, so a commit, package or toolchain bump
- # invalidates it.
- - name: Restore upstream xterm build inputs
- uses: actions/cache@v5
+ # The generator uses Node built-ins and installs its own upstream toolchain.
+ - uses: actions/setup-node@v6
with:
- path: |
- ~/.npm
- ${{ runner.temp }}/xterm-patch-build/upstream/.git
- key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
+ node-version-file: package.json
+ package-manager-cache: false
+
+ # Rebuild both pristine and patched bundles; reuse only the pinned toolchain.
+ - uses: ./.github/actions/prepare-xterm-dependencies
- name: Verify xterm patches match the pinned upstream build
env:
@@ -467,7 +500,8 @@ jobs:
name: shell contracts
needs: [code_paths]
if: needs.code_paths.outputs.shell_contracts == 'true'
- runs-on: ubuntu-latest
+ # Why ARM: fish 4.x is published for noble/arm64 and zsh is in the arm64 archive.
+ runs-on: ubuntu-24.04-arm
# Why: this job's cost is almost entirely package download, and a stalled mirror has
# no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this
# is generous; it exists so a wedge fails the job instead of holding the whole run
@@ -493,6 +527,8 @@ jobs:
# fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships
# 3.7, so the PPA is what makes that lane real.
- name: Install zsh and fish
+ id: shells
+ background: true
run: |
# Why the update/PPA/fish steps are tolerant: a repo the runner image already
# ships can lack a Release file for this suite, and a failed add-apt-repository
@@ -541,6 +577,12 @@ jobs:
# first install command in this step to prove the lane really installs them.
timeout 300 sudo apt-get install -y zsh fish
+ - uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: node
+
+ - wait: shells
+
# Separate from the install so the failure names the contract, not an apt error.
# ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds
# instead of after a full dependency install.
@@ -556,10 +598,6 @@ jobs:
exit 1
fi
- - uses: ./.github/actions/install-node-dependencies
- with:
- native-runtime: node
-
- name: Test real shell contracts
run: |
pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \
@@ -588,7 +626,7 @@ jobs:
name: prepare test native cache node 24
needs: [code_paths]
if: needs.code_paths.outputs.native_cache_changed == 'true'
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
@@ -602,14 +640,19 @@ jobs:
node-version: '24'
test:
- needs: [code_paths, test_native_cache]
+ needs: [code_paths, test_native_cache, static_analysis, typecheck]
+ # Honor cancellation while allowing the optional native-cache primer to skip.
if: >-
- always() &&
+ !cancelled() &&
needs.code_paths.outputs.test == 'true' &&
+ needs.static_analysis.result == 'success' &&
+ needs.typecheck.result == 'success' &&
(needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
uses: ./.github/workflows/unit-tests.yml
with:
node_versions: '["24"]'
+ runner: ubuntu-24.04-arm
+ selection_mode: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }}
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
# would pay for it on every shard to run one file in whichever shard it landed in.
@@ -673,9 +716,22 @@ jobs:
pnpm-lock.yaml
mobile/pnpm-lock.yaml
+ # The drawer check runs on WebKit as well as Chrome, because the shell's iOS WebView is
+ # WebKit and the Chrome above cannot stand in for it. Downloaded rather than resolved from
+ # the runner: Ubuntu ships no WebKit build to point at.
+ - name: Install WebKit for the drawer check
+ id: webkit
+ background: true
+ run: pnpm exec playwright install --with-deps webkit
+
# The entry lives in mobile/ so one React resolves; without this every RN import is nothing.
- uses: ./.github/actions/install-mobile-dependencies
+ - name: Prepare mobile route snapshot
+ id: mobile-routes
+ background: true
+ run: node config/scripts/run-mobile-web-app-checks.mjs --prepare-route-snapshot "$RUNNER_TEMP/mobile-routes.json"
+
# Why the runner's Google Chrome and not a downloaded chromium: same reason as the orcad
# browser job -- Ubuntu 24.04 only ships an AppArmor userns profile for the Chrome .deb.
# Why fail instead of skip: a silently skipped render check is the failure this job exists
@@ -691,15 +747,12 @@ jobs:
"$chrome" --version
echo "ORCA_MOBILE_WEB_RENDER_BROWSER=$chrome" >> "$GITHUB_ENV"
- # The drawer check runs on WebKit as well as Chrome, because the shell's iOS WebView is
- # WebKit and the Chrome above cannot stand in for it. Downloaded rather than resolved from
- # the runner: Ubuntu ships no WebKit build to point at.
- - name: Install WebKit for the drawer check
- run: pnpm exec playwright install --with-deps webkit
-
- name: Build and verify the app bundle
run: pnpm run build:mobile-web
+ # Browser checks must observe both a finished install and the built bundle.
+ - wait: [webkit, mobile-routes]
+
# The bundling tests skip themselves where mobile dependencies are absent, which is how they
# stay green in the sharded `test` job. This is the job that installs them, so here a missing
# install has to fail rather than skip everything the job exists to run.
@@ -709,23 +762,20 @@ jobs:
# one `pr-code-change-scope.mjs` fires this job on, so naming a test into the family is all
# it takes to have it run. Quoted because these are vitest filename filters, matched as
# substrings against the discovered files, and the shell must not touch them.
- #
- # Cost: 18 files in 25-30s wall, of which the frame-budget sweep is 2.5s. That sweep encodes
- # 111 noise JPEGs in Chromium, so it is the one step here whose cost grows with its viewport
- # set; adding rows to that set is a decision about this job's runtime.
+ # Route censuses share fresh dependency lists for this invocation; scratch builds stay independent.
- name: Builder, override census and render checks
env:
ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1'
+ ORCA_MOBILE_WEB_PREPARED_ROUTE_SNAPSHOT: ${{ runner.temp }}/mobile-routes.json
run: |
- pnpm exec vitest run --config config/vitest.config.ts \
- 'config/scripts/mobile-web-app-' \
- 'config/scripts/build-mobile-web-app-bundle.test.mjs'
+ node config/scripts/run-mobile-web-app-checks.mjs
cross-version-wire:
name: cross-version wire compatibility
needs: [code_paths]
if: needs.code_paths.outputs.cross-version-wire == 'true'
- runs-on: ubuntu-latest
+ # Why ARM: source-only: tagged checkout plus in-process vitest, no docker or browser.
+ runs-on: ubuntu-24.04-arm
steps:
# Why fetch-depth 0: the harness extracts the newest release tag to skew
@@ -757,12 +807,16 @@ jobs:
tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts
tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts
+ tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts
+ tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts
+ tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts
managed_hook_node18:
name: managed hooks on Node 18
needs: [code_paths]
if: needs.code_paths.outputs.managed_hook_node18 == 'true'
- runs-on: ubuntu-latest
+ # Why ARM: Node 18 publishes linux-arm64; the per-platform runtime files are read as data.
+ runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
@@ -785,7 +839,7 @@ jobs:
package:
name: package
- needs: [code_paths]
+ needs: [code_paths, static_analysis, typecheck]
if: needs.code_paths.outputs.package == 'true'
runs-on: ubuntu-latest
# Let the serial Docker gates reach their own deadlines and report cleanup failures.
@@ -798,7 +852,7 @@ jobs:
persist-credentials: false
- name: Cache electron-builder downloads
- uses: actions/cache@v5
+ uses: actions/cache/restore@v5
with:
path: ~/.cache/electron-builder
key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }}
@@ -835,6 +889,24 @@ jobs:
src/main/browser/browser-route-h3-egress.electron.test.ts
src/main/browser/browser-route-dns-prefetch.electron.test.ts
+ # Restore independent fixtures during builds; keep native lifecycle probes serial.
+ - uses: ./.github/actions/prepare-linux-package-fixture
+ id: shutdown-fixture-cache
+ background: true
+ with:
+ fixture: headless-serve-shutdown
+
+ - uses: ./.github/actions/prepare-linux-package-fixture
+ id: cli-fixture-cache
+ background: true
+ with:
+ fixture: cli-launch-contract
+
+ - name: Install Linux package tooling
+ id: linux-package-tools
+ background: true
+ run: sudo apt-get update && sudo apt-get install -y cpio rpm
+
- name: Build package inputs
run: |
status=0
@@ -853,6 +925,8 @@ jobs:
exit "$status"
- name: Project web client from renderer build
+ id: web-client
+ background: true
run: pnpm run build:web-from-renderer
# Why here and not inside "Build package inputs": this job assembles packaging inputs step by
@@ -864,17 +938,16 @@ jobs:
- name: Build native components
run: pnpm run build:native
- - name: Install Linux package tooling
- run: sudo apt-get update && sudo apt-get install -y cpio rpm
+ - wait: [linux-package-tools, web-client]
- name: Package unpacked app
env:
+ ORCA_BACKGROUND_LAUNCH: '1'
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
- # PR artifacts are only inspected locally; gzip avoids release-size xz compression.
- run: >-
- pnpm exec electron-builder --config config/electron-builder.config.cjs
- --linux AppImage deb rpm --x64 --publish never
- --config.deb.compression=gz --config.rpm.compression=gzip
+ # Prepare once with every hook, then isolate the format-specific mutations.
+ run: |
+ pnpm exec electron-builder --config config/electron-builder.config.cjs --linux dir --x64 --publish never
+ node config/scripts/package-linux-formats.mjs
- name: Verify root-package marker payloads
run: |
@@ -889,13 +962,21 @@ jobs:
[[ "$deb_marker" == deb ]] || { echo "Expected deb marker, got: $deb_marker"; exit 1; }
[[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; }
+ - wait: shutdown-fixture-cache
+
- name: Verify headless serve signal shutdown
+ env:
+ ORCA_SHUTDOWN_FIXTURE_CACHE_IMAGE: ${{ steps.shutdown-fixture-cache.outputs.image }}
run: >-
node config/scripts/run-headless-serve-shutdown-docker.mjs
--appimage dist/orca-linux.AppImage --all-entrypoints
# A default container reproduces the hostile AppImage launch environment.
+ - wait: cli-fixture-cache
+
- name: Verify Linux CLI launch contract
+ env:
+ ORCA_CLI_FIXTURE_CACHE_IMAGE: ${{ steps.cli-fixture-cache.outputs.image }}
run: node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage
- name: Smoke packaged CLI
@@ -906,7 +987,7 @@ jobs:
package_windows:
name: package (windows)
- needs: [code_paths]
+ needs: [code_paths, static_analysis, typecheck]
if: needs.code_paths.outputs.package_windows == 'true'
runs-on: windows-2022
timeout-minutes: 30
@@ -918,14 +999,15 @@ jobs:
persist-credentials: false
- name: Cache electron-builder downloads
- uses: actions/cache@v5
+ uses: actions/cache/restore@v5
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
- key: electron-builder-windows-${{ hashFiles('pnpm-lock.yaml') }}
+ # Release builds seed this exact path set; PR-local copies cannot serve other PRs.
+ key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
- electron-builder-windows-
+ electron-builder-win-
# Why persist-native-cache false: this job later rebuilds the same path for
# Electron. A post-job save would store the Electron ABI under the Node key.
@@ -954,6 +1036,12 @@ jobs:
# vitest runs here directly rather than through `pnpm test`, so the addon
# assertions only hold once install-node-dependencies has rebuilt natives.
+ - name: Test Windows installer process probe
+ # Keep cold CIM startup out of the concurrent Electron/native process workload.
+ run: >-
+ pnpm exec vitest run --config config/vitest.config.ts
+ config/scripts/nsis-process-check.test.mjs
+
- name: Test Windows-specific boundaries
run: >-
pnpm exec vitest run --config config/vitest.config.ts
@@ -989,6 +1077,7 @@ jobs:
src/main/windows/windows-host-job.win32.test.ts
src/main/windows/windows-process-tree-command-line-patch.test.ts
src/main/windows/windows-process-table-native-addon.win32.test.ts
+ src/main/persistence/profile-state/profile-state-access-windows-native.win32.test.ts
src/main/windows-live-tree-kill.win32.test.ts
src/main/wsl/wsl-runner.test.ts
src/main/wsl/wsl-guest-environment.test.ts
@@ -1088,11 +1177,10 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
verify:
- if: always()
+ if: ${{ !cancelled() }}
needs:
- code_paths
- static_analysis
- - root_directory_guard
- typecheck
- git_compatibility
- codex_index_heal_contract
@@ -1105,7 +1193,9 @@ jobs:
- managed_hook_node18
- package
- package_windows
- runs-on: ubuntu-latest
+ # Evaluating job results only needs the lightweight container runner.
+ runs-on: ubuntu-slim
+ timeout-minutes: 5
steps:
# Why: e2e is deliberately absent from needs. The suite is currently red on
@@ -1122,7 +1212,6 @@ jobs:
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
- ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
TYPECHECK: ${{ needs.typecheck.result }}
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
@@ -1151,9 +1240,6 @@ jobs:
if [ "$CODE_PATHS" != "success" ]; then
exit 1
fi
- if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
- exit 1
- fi
if [ "$SHOULD_RUN" != "true" ]; then
echo "Docs-only change; expensive PR checks skipped."
fi
diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml
index d5030252f88d..2fc81c887bba 100644
--- a/.github/workflows/pullfrog.yml
+++ b/.github/workflows/pullfrog.yml
@@ -1,6 +1,6 @@
-# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
+# Explicit review identities share workflow concurrency; legacy names use ordered cancellation.
name: Pullfrog
-run-name: ${{ inputs.name || github.workflow }}
+run-name: ${{ inputs.name || github.workflow }}${{ inputs.pull_request_number && format(' | PR {0}', inputs.pull_request_number) || '' }}
on:
workflow_dispatch:
inputs:
@@ -11,21 +11,71 @@ on:
type: string
description: Run name
+ pull_request_number:
+ type: string
+ description: Optional PR identity for cancelling superseded reviews
+ head_sha:
+ type: string
+ description: Optional expected PR head; stale reviews are skipped
+
permissions:
contents: read
+ pull-requests: read
+
+concurrency:
+ group: ${{ inputs.pull_request_number && format('pullfrog-pr-{0}', inputs.pull_request_number) || format('pullfrog-run-{0}', github.run_id) }}
+ cancel-in-progress: true
jobs:
+ review_scope:
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ pull-requests: read
+ actions: write
+ outputs:
+ current: ${{ steps.scope.outputs.current }}
+ number: ${{ steps.scope.outputs.number }}
+ head: ${{ steps.scope.outputs.head }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ sparse-checkout: config/scripts/pullfrog-review-scope.cjs
+ sparse-checkout-cone-mode: false
+ persist-credentials: false
+ - uses: actions/github-script@v8
+ id: scope
+ with:
+ script: |
+ const { reviewScope } = require('./config/scripts/pullfrog-review-scope.cjs')
+ await reviewScope({ github, context, core })
+
pullfrog:
+ needs: review_scope
+ if: needs.review_scope.outputs.current == 'true'
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
+ pull-requests: read
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
+ - name: Recheck review head before starting agent
+ id: freshness
+ if: needs.review_scope.outputs.number != ''
+ uses: actions/github-script@v8
+ env:
+ REVIEW_NUMBER: ${{ needs.review_scope.outputs.number }}
+ REVIEW_HEAD: ${{ needs.review_scope.outputs.head }}
+ with:
+ script: |
+ const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: Number(process.env.REVIEW_NUMBER) })
+ core.setOutput('current', pr.state === 'open' && pr.head.sha === process.env.REVIEW_HEAD)
- name: Run agent
+ if: needs.review_scope.outputs.number == '' || steps.freshness.outputs.current == 'true'
uses: pullfrog/pullfrog@v0
with:
prompt: ${{ inputs.prompt }}
diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index b2ac03e2b085..2490d39af39b 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -1314,6 +1314,16 @@ jobs:
restore-keys: |
electron-builder-${{ matrix.platform }}-
+ # PRs cache tools separately from Electron; identical paths preserve their cache version.
+ - name: Seed shared Linux packaging downloads
+ if: matrix.platform == 'linux-x64' && github.ref == 'refs/heads/main'
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/electron-builder
+ key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }}
+ # The release cache above restores the same tools, so this only needs to save on a miss.
+ lookup-only: true
+
# Why: pnpm install triggers electron's postinstall, which downloads the
# Electron binary from GitHub release assets. GitHub's download CDN
# occasionally returns 504s that fail the whole release. Retry on
diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml
index 8014145939ba..e6ab98f0d0d2 100644
--- a/.github/workflows/release-policy.yml
+++ b/.github/workflows/release-policy.yml
@@ -16,7 +16,7 @@ concurrency:
jobs:
enforce:
if: github.repository == 'stablyai/orca'
- runs-on: ubuntu-latest
+ runs-on: ubuntu-slim
timeout-minutes: 5
steps:
- name: Enforce release policy
diff --git a/.github/workflows/terminal-ime-e2e.yml b/.github/workflows/terminal-ime-e2e.yml
index bd6be26bd272..168280f9ac2f 100644
--- a/.github/workflows/terminal-ime-e2e.yml
+++ b/.github/workflows/terminal-ime-e2e.yml
@@ -49,7 +49,7 @@ jobs:
run: >-
xvfb-run --auto-servernum
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
- pnpm run test:e2e --
+ pnpm run test:e2e
tests/e2e/terminal-ime-exact-byte.spec.ts
--workers=1
diff --git a/.github/workflows/track-community-prs.yaml b/.github/workflows/track-community-prs.yaml
index 5a5dcb670c2d..7a588663fb20 100644
--- a/.github/workflows/track-community-prs.yaml
+++ b/.github/workflows/track-community-prs.yaml
@@ -15,7 +15,8 @@ permissions:
jobs:
track-community-pr:
- runs-on: ubuntu-latest
+ # API-only bookkeeping fits the free single-CPU container runner.
+ runs-on: ubuntu-slim
timeout-minutes: 5
steps:
- name: Generate bufo-bot token
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 4ed5063e318a..2691f4907bcc 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -8,19 +8,54 @@ on:
required: true
type: string
+ runner:
+ description: Hosted runner for the unit shards; relay integration keeps its x86 host.
+ required: false
+ default: ubuntu-latest
+ type: string
+
+ selection_mode:
+ description: Shadow validates selection; selected applies it only to draft PRs.
+ required: false
+ default: shadow
+ type: string
+
permissions:
contents: read
jobs:
- test:
- name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
+ plan:
runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ shards: ${{ steps.plan.outputs.shards }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 2
+ persist-credentials: false
+ - uses: ./.github/actions/install-node-dependencies
+ - name: Plan unit selection
+ id: plan
+ env:
+ ORCA_UNIT_SELECTION_MODE: ${{ inputs.selection_mode }}
+ run: node config/scripts/ci-unit-plan.mjs
+ - uses: actions/upload-artifact@v7
+ continue-on-error: true
+ with:
+ name: unit-selection-attempt-${{ github.run_attempt }}
+ path: ci-shards/unit-selection.json
+ retention-days: 14
+
+ test:
+ needs: plan
+ name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }}
+ runs-on: ${{ inputs.runner }}
strategy:
fail-fast: false
matrix:
node: ${{ fromJSON(inputs.node_versions) }}
- shard: [1, 2, 3, 4, 5, 6, 7, 8]
- shard_total: [8]
+ shard: ${{ fromJSON(needs.plan.outputs.shards) }}
steps:
- name: Checkout
@@ -33,13 +68,16 @@ jobs:
native-runtime: node
node-version: ${{ matrix.node }}
cache-electron-package: 'true'
- cache-dependency-path: |
- pnpm-lock.yaml
- cloud/pnpm-lock.yaml
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
+ - uses: actions/download-artifact@v8
+ continue-on-error: true
+ with:
+ name: unit-selection-attempt-${{ github.run_attempt }}
+ path: ci-shards/
+
- name: Test shard
env:
ORCA_BALANCE_UNIT_SHARDS: '1'
@@ -47,27 +85,7 @@ jobs:
run: |
export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)"
pnpm exec vitest run --config config/vitest.config.ts \
- --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
- --exclude=src/main/daemon/shell-ready.test.ts \
- --exclude=src/main/daemon/node-pty-fd-leak.test.ts \
- --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
- --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
- --exclude=src/main/pty/omp-shell-wrapper-alias-safety.test.ts \
- --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
- --exclude=src/main/shell-startup-feature-channel.test.ts \
- --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
- --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
- --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
- --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
- --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
- --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
- --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
- --exclude=src/shared/startup-shell-portability.live-shell.test.ts \
- --exclude=src/shared/posix-command-path-lookup.test.ts \
- --exclude=tests/e2e/relay-region-compatibility.unit.test.ts \
- --exclude=tests/e2e/relay-region-correction.unit.test.ts \
- --exclude=tests/e2e/cross-version-wire/** \
- --shard=${{ matrix.shard }}/${{ matrix.shard_total }}
+ --shard=${{ matrix.shard.index }}/${{ matrix.shard.count }}
- name: Upload unit shard assignment
if: always()
@@ -75,13 +93,46 @@ jobs:
continue-on-error: true
uses: actions/upload-artifact@v7
with:
- name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
+ name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard.index }}-attempt-${{ github.run_attempt }}
path: ci-shards/
retention-days: 14
if-no-files-found: warn
+ selection_evidence:
+ needs: test
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ runs-on: ubuntu-slim
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ sparse-checkout: config/scripts/ci-unit-selection-review.mjs
+ sparse-checkout-cone-mode: false
+ persist-credentials: false
+ - uses: actions/setup-node@v6
+ with:
+ node-version: '24'
+ - uses: actions/download-artifact@v8
+ with:
+ pattern: unit-shard-node-*-attempt-${{ github.run_attempt }}
+ path: unit-evidence/
+ - name: Compare selection with full results
+ continue-on-error: true
+ run: node config/scripts/ci-unit-selection-review.mjs unit-evidence
+ - uses: actions/upload-artifact@v7
+ if: always()
+ continue-on-error: true
+ with:
+ name: unit-selection-review-attempt-${{ github.run_attempt }}
+ path: unit-evidence/selection-review.json
+ retention-days: 30
+
relay_integration:
- name: relay integration node ${{ fromJSON(inputs.node_versions)[0] }}
+ name: relay integration node ${{ matrix.node }}
+ strategy:
+ fail-fast: false
+ matrix:
+ node: ${{ fromJSON(inputs.node_versions) }}
runs-on: ubuntu-latest
steps:
@@ -93,7 +144,7 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- node-version: ${{ fromJSON(inputs.node_versions)[0] }}
+ node-version: ${{ matrix.node }}
cache-electron-package: 'true'
cache-dependency-path: |
pnpm-lock.yaml
diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml
index e0d7b80d0473..53c231111dbf 100644
--- a/.github/workflows/win-crash-survival-e2e.yml
+++ b/.github/workflows/win-crash-survival-e2e.yml
@@ -35,6 +35,7 @@ jobs:
timeout-minutes: 50
env:
EXPECT: ${{ inputs.expect || 'survival' }}
+ ORCA_BACKGROUND_LAUNCH: '1'
steps:
- name: Checkout
diff --git a/.github/workflows/win-update-survival-e2e.yml b/.github/workflows/win-update-survival-e2e.yml
index 50c38f2e3ad5..b709f82b7885 100644
--- a/.github/workflows/win-update-survival-e2e.yml
+++ b/.github/workflows/win-update-survival-e2e.yml
@@ -29,6 +29,21 @@ on:
options:
- survival
- cold-restore
+ trace_installer:
+ description: Trace installer process checks in this disposable build
+ type: boolean
+ default: false
+ policy:
+ description: Execution policy inherited by harness child processes
+ type: choice
+ default: inherited
+ options:
+ - inherited
+ - Restricted
+ from_release:
+ description: Optional base release tag; blank uses this branch build
+ type: string
+ default: ''
permissions:
contents: read
@@ -39,7 +54,7 @@ concurrency:
jobs:
survival:
- name: survival (branch build over itself)
+ name: update ${{ inputs.from_release || 'branch' }} to branch (${{ inputs.expect || 'survival' }}, policy=${{ inputs.policy || 'inherited' }})
runs-on: windows-2022
timeout-minutes: 50
env:
@@ -62,18 +77,23 @@ jobs:
with:
install: false
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
# Why: cache the built installer keyed on the inputs that affect it, so a
# harness-only edit skips the ~20 min electron-builder build. The daemon
# relocation code lives under src/, so changing it correctly rebuilds.
+ # Hash before installation creates native build artifacts under native/.
- name: Cache branch installer
id: cache-installer
- uses: actions/cache@v4
+ uses: actions/cache/restore@v4
with:
path: dist/orca-windows-setup.exe
- key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }}
+ key: branch-installer-${{ inputs.trace_installer && format('trace-{0}-', hashFiles('tests/tools/win-update-e2e/trace-installer-branches.mjs')) || '' }}${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/**', 'mobile/**', 'patches/**', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml') }}
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Instrument disposable installer and uninstaller
+ if: inputs.trace_installer && steps.cache-installer.outputs.cache-hit != 'true'
+ run: node tests/tools/win-update-e2e/trace-installer-branches.mjs
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules. Gated with the
@@ -88,26 +108,46 @@ jobs:
pnpm run build:desktop
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never
- # Why: install the branch build, open a terminal, update the SAME build
- # over it, and assert the relocated daemon survives. Same build on both
- # sides isolates the survival mechanism (NSIS kill sweep + userData daemon
- # + adoption) from cross-version staleness policy. No --install-dir: a CI
- # runner has no real Orca to protect.
+ - name: Cache successfully built installer before survival verification
+ if: steps.cache-installer.outputs.cache-hit != 'true'
+ uses: actions/cache/save@v4
+ with:
+ path: dist/orca-windows-setup.exe
+ key: ${{ steps.cache-installer.outputs.cache-primary-key }}
+
+ # A released base also exercises its old uninstaller; default runs isolate this branch.
- name: Run survival harness
id: harness
shell: pwsh
env:
ORCA_E2E_DIAG_DIR: artifacts/diag
+ ORCA_BACKGROUND_LAUNCH: '1'
+ DEBUG: 'pw:browser'
+ ORCA_E2E_NSIS_TRACE: ${{ github.workspace }}\artifacts\nsis-process-trace.log
+ ORCA_E2E_PROCESS_POLICY: ${{ inputs.policy || 'inherited' }}
+ FROM_RELEASE: ${{ inputs.from_release }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
New-Item -ItemType Directory -Force artifacts | Out-Null
$exe = "dist/orca-windows-setup.exe"
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
$log = "artifacts/survival-output.log"
- node tests/tools/win-update-e2e/run.mjs `
- --from "$exe" `
- --to "$exe" `
- --expect "$env:EXPECT" `
- --soak-seconds 60 2>&1 | Tee-Object -FilePath $log
+ $harnessArgs = @('--to', $exe, '--expect', $env:EXPECT, '--soak-seconds', '60')
+ if ($env:FROM_RELEASE) {
+ $harnessArgs += @('--from-release', $env:FROM_RELEASE)
+ } else {
+ $harnessArgs += @('--from', $exe)
+ }
+ # Only the harness and its children inherit the test policy.
+ $launch = @'
+ if (process.env.ORCA_E2E_PROCESS_POLICY === 'Restricted') {
+ process.env.PSExecutionPolicyPreference = 'Restricted'
+ }
+ console.log('Harness child process policy: ' + (process.env.PSExecutionPolicyPreference || 'inherited'))
+ process.argv.splice(1, 0, 'tests/tools/win-update-e2e/run.mjs')
+ await import('./tests/tools/win-update-e2e/run.mjs')
+ '@
+ node --input-type=module -e $launch -- @harnessArgs 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
- name: Upload survival output
@@ -117,6 +157,7 @@ jobs:
name: win-update-survival-output
path: |
artifacts/survival-output.log
+ artifacts/nsis-process-trace.log
artifacts/diag/**
retention-days: 7
if-no-files-found: warn
diff --git a/.github/workflows/windows-terminal-restart-e2e.yml b/.github/workflows/windows-terminal-restart-e2e.yml
index abb8dc89aa81..d576ee650499 100644
--- a/.github/workflows/windows-terminal-restart-e2e.yml
+++ b/.github/workflows/windows-terminal-restart-e2e.yml
@@ -22,6 +22,7 @@ jobs:
timeout-minutes: 30
env:
NODE_OPTIONS: --max-old-space-size=4096
+ ORCA_BACKGROUND_LAUNCH: '1'
steps:
- name: Checkout
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index 86931f1f9ec6..0a6625f6fe7e 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -6,6 +6,7 @@
"trailingComma": "none",
"ignorePatterns": [
"cloud/**",
+ "resources/licenses/**",
".github/actions/cloud-sql-rollout-lease/**",
".anti-slop-plugin/**"
]
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 7de99d4461ef..7a0753c06e27 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -143,7 +143,26 @@
{
"files": ["src/renderer/src/**/*.{ts,tsx}"],
"rules": {
- "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error"
+ "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error",
+ "no-restricted-properties": [
+ "error",
+ {
+ "property": "randomUUID",
+ "message": "crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP), which white-screens the app. Use createBrowserUuid() from '@/lib/browser-uuid'."
+ }
+ ]
+ }
+ },
+ {
+ "files": ["src/shared/**/*.ts"],
+ "rules": {
+ "no-restricted-properties": [
+ "error",
+ {
+ "property": "randomUUID",
+ "message": "src/shared is compiled into the web bundle, where crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP). Use createNonSecureContextUuid() from './non-secure-context-uuid', or import randomUUID from 'node:crypto' in main-only code."
+ }
+ ]
}
},
{
diff --git a/Casks/orca.rb b/Casks/orca.rb
index 6b97b27d52e6..1abc9b3a9124 100644
--- a/Casks/orca.rb
+++ b/Casks/orca.rb
@@ -5,8 +5,7 @@
sha256 arm: "fc707f290ff3b631b7b7947bf339885b61a43d2e89475997c125b61268ed4966",
intel: "5f677c13a08f7a5740442e29d388285a86488c8c1f7aa5f10a8721a2c6ede8e4"
- url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg",
- verified: "github.com/stablyai/orca/"
+ url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg"
name "Orca"
desc "IDE for orchestrating AI coding agents across terminals and worktrees"
homepage "https://onorca.dev/"
diff --git a/Casks/orca@rc.rb b/Casks/orca@rc.rb
index 63bc843b6407..08eaeeca459f 100644
--- a/Casks/orca@rc.rb
+++ b/Casks/orca@rc.rb
@@ -5,8 +5,7 @@
sha256 arm: "563b6b14323fc9d5489299c82442d514bc12cabffc9d06d3964ed572af4b3955",
intel: "457088c7021f07de1a419197f7b2bd00092741ad4727d4fef3d86af38a6831e7"
- url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg",
- verified: "github.com/stablyai/orca/"
+ url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg"
name "Orca RC"
desc "IDE for orchestrating AI coding agents across terminals and worktrees"
homepage "https://onorca.dev/"
diff --git a/README.md b/README.md
index 98efc301c2f9..e8722108c200 100644
--- a/README.md
+++ b/README.md
@@ -179,6 +179,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
Cursor
GitHub Copilot
Muse
+
DeepSeek Harness
ZCode
OpenCode
MiMo Code
@@ -195,6 +196,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
Charm
Cline
Codebuff
+
Freebuff
Command Code
Continue
Droid
diff --git a/cloud/apps/push/src/durable-push-claim.test.ts b/cloud/apps/push/src/durable-push-claim.test.ts
index f43588bd2c3d..c7a228a05595 100644
--- a/cloud/apps/push/src/durable-push-claim.test.ts
+++ b/cloud/apps/push/src/durable-push-claim.test.ts
@@ -157,7 +157,7 @@ it('keeps claim, exclusion and prune correct without the queue index', async ()
for (const claim of leased) await store.finish(claim)
expect((await store.claim())?.notification.notificationSeq).toBe(2)
advance(10 * 60_000)
- expect(await store.prune()).toBe(1)
+ expect(await store.prune()).toEqual({ deleted: 1, saturated: false })
expect(await batchCount(db)).toBe(0)
})
@@ -211,11 +211,11 @@ it('prunes a large backlog in bounded calls without touching live or leased work
[JSON.stringify(notification(9)), now - 1, now - 1, now + 1000, now - 1]
)
await store.accept('host', 'phone-live', notification(1))
- expect(await store.prune()).toBe(perCall)
- expect(await store.prune()).toBe(500)
- expect(await store.prune()).toBe(0)
+ expect(await store.prune()).toEqual({ deleted: perCall, saturated: true })
+ expect(await store.prune()).toEqual({ deleted: 500, saturated: false })
+ expect(await store.prune()).toEqual({ deleted: 0, saturated: false })
advance(1000)
- expect(await store.prune()).toBe(1)
+ expect(await store.prune()).toEqual({ deleted: 1, saturated: false })
expect(await batchCount(db)).toBe(1)
expect(await store.pendingCount('phone-live')).toBe(1)
})
diff --git a/cloud/apps/push/src/durable-push-store.ts b/cloud/apps/push/src/durable-push-store.ts
index 61fed2350184..02d26f20b3e1 100644
--- a/cloud/apps/push/src/durable-push-store.ts
+++ b/cloud/apps/push/src/durable-push-store.ts
@@ -12,6 +12,8 @@ const CLAIM_CANDIDATE_ATTEMPTS = 4
export const PRUNE_BATCH_ROWS = 2_000
export const PRUNE_MAX_BATCHES = 50
export const DELIVERY_LEASE_MS = 30_000
+// `saturated` means the batch budget ran out with rows still matching, so a backlog remains.
+export type PushPruneSweep = { deleted: number; saturated: boolean }
export type QueuedPushDelivery = {
id: string
registrationId: string
@@ -200,10 +202,10 @@ export class DurablePushStore {
}
// Bounded per call and per statement, so it drains any backlog on its own without holding locks.
- async prune(): Promise {
+ async prune(): Promise {
const now = this.now()
// Also clears terminal rows older revisions kept, since each carries a past expires_at.
- let deleted = await this.deleteInBatches(
+ let { deleted, saturated } = await this.deleteInBatches(
'push_delivery_batches',
'batch_id',
'expires_at <= ? AND lease_until <= ?',
@@ -215,9 +217,11 @@ export class DurablePushStore {
['push_event_recipients', 'event_id, registration_id'],
['push_events', 'event_id']
] as const) {
- deleted += await this.deleteInBatches(table, key, 'created_at < ?', [now - RETENTION_MS])
+ const sweep = await this.deleteInBatches(table, key, 'created_at < ?', [now - RETENTION_MS])
+ deleted += sweep.deleted
+ saturated ||= sweep.saturated
}
- return deleted
+ return { deleted, saturated }
}
private async deleteInBatches(
@@ -225,7 +229,7 @@ export class DurablePushStore {
key: string,
where: string,
params: unknown[]
- ): Promise {
+ ): Promise {
const lockRows = this.background.dialect === 'postgres' ? ' FOR UPDATE SKIP LOCKED' : ''
let total = 0
for (let batch = 0; batch < PRUNE_MAX_BATCHES; batch++) {
@@ -235,8 +239,9 @@ export class DurablePushStore {
)
const changes = Number(result?.changes ?? 0)
total += changes
- if (changes < PRUNE_BATCH_ROWS) break
+ // A short batch drained the predicate; only a full last batch leaves rows behind.
+ if (changes < PRUNE_BATCH_ROWS) return { deleted: total, saturated: false }
}
- return total
+ return { deleted: total, saturated: true }
}
}
diff --git a/cloud/apps/push/src/push-background.test.ts b/cloud/apps/push/src/push-background.test.ts
new file mode 100644
index 000000000000..21598ef67a21
--- /dev/null
+++ b/cloud/apps/push/src/push-background.test.ts
@@ -0,0 +1,174 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { startPushBackground } from './push-background.js'
+import { createPushServerHarness } from './push-server-harness.test-fixture.js'
+import { reserveRequestConnection } from './push-background-database.js'
+import { DurablePushStore, PRUNE_BATCH_ROWS, type PushPruneSweep } from './durable-push-store.js'
+import type { PushDatabase } from './push-database.js'
+
+const cleanups: (() => Promise)[] = []
+afterEach(async () => {
+ for (const cleanup of cleanups.splice(0)) await cleanup()
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+})
+
+async function fixture(mode: 'active' | 'validation' = 'active') {
+ const harness = await createPushServerHarness()
+ const runtime = harness.server
+ const challenges = vi.spyOn(runtime.challenges, 'pruneExpired').mockResolvedValue(0)
+ const sessions = vi.spyOn(runtime.sessions, 'pruneExpired').mockResolvedValue(0)
+ const deliveries = vi
+ .spyOn(runtime.deliveryStore, 'prune')
+ .mockResolvedValue({ deleted: 0, saturated: false })
+ vi.spyOn(runtime.worker, 'start').mockImplementation(() => {})
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ vi.useFakeTimers()
+ const stop = startPushBackground({ mode }, runtime)
+ cleanups.push(async () => {
+ await stop()
+ await harness.close()
+ })
+ return { stop, challenges, sessions, deliveries, warn }
+}
+
+it('keeps one slow sweep per store while other stores keep their cadence', async () => {
+ const h = await fixture()
+ let finish!: (sweep: PushPruneSweep) => void
+ h.deliveries.mockImplementationOnce(
+ () => new Promise((resolve) => (finish = resolve))
+ )
+ try {
+ await vi.advanceTimersByTimeAsync(10 * 60_000)
+ expect(h.deliveries).toHaveBeenCalledTimes(1)
+ expect(h.challenges).toHaveBeenCalledTimes(10)
+ expect(h.sessions).toHaveBeenCalledTimes(1)
+ } finally {
+ finish({ deleted: 100_000, saturated: false })
+ }
+ await vi.advanceTimersByTimeAsync(60_000)
+ expect(h.deliveries).toHaveBeenCalledTimes(2)
+ await h.stop()
+ await vi.advanceTimersByTimeAsync(10 * 60_000)
+ expect(h.deliveries).toHaveBeenCalledTimes(2)
+ expect(h.challenges).toHaveBeenCalledTimes(11)
+ expect(h.sessions).toHaveBeenCalledTimes(1)
+})
+
+it('reports a sweep that is still running a full interval after it started', async () => {
+ const h = await fixture()
+ let finish!: (sweep: PushPruneSweep) => void
+ h.deliveries.mockImplementationOnce(
+ () => new Promise((resolve) => (finish = resolve))
+ )
+ try {
+ await vi.advanceTimersByTimeAsync(119_999)
+ expect(h.warn).not.toHaveBeenCalled()
+ await vi.advanceTimersByTimeAsync(1)
+ expect(h.warn).toHaveBeenCalledWith(
+ JSON.stringify({ event: 'orca_push_prune_overdue', target: 'deliveries' })
+ )
+ } finally {
+ finish({ deleted: 0, saturated: false })
+ }
+ // One report per sweep: the settled sweep clears its own watchdog.
+ await vi.advanceTimersByTimeAsync(10 * 60_000)
+ expect(h.warn).toHaveBeenCalledTimes(1)
+})
+
+it('releases a failed sweep so the next scheduled sweep can recover', async () => {
+ const h = await fixture()
+ h.deliveries.mockRejectedValueOnce(new Error('database unavailable'))
+ await vi.advanceTimersByTimeAsync(60_000)
+ expect(h.deliveries).toHaveBeenCalledTimes(1)
+ expect(h.warn).toHaveBeenCalledWith(
+ JSON.stringify({ event: 'orca_push_prune_failed', target: 'deliveries', error: 'Error' })
+ )
+ await vi.advanceTimersByTimeAsync(60_000)
+ expect(h.deliveries).toHaveBeenCalledTimes(2)
+ expect(h.warn).toHaveBeenCalledTimes(1)
+})
+
+it('resumes a saturated sweep at once rather than waiting out the interval', async () => {
+ const h = await fixture()
+ let backlogSweeps = 3
+ h.deliveries.mockImplementation(async () => {
+ const saturated = backlogSweeps-- > 0
+ return { deleted: saturated ? PRUNE_BATCH_ROWS : 0, saturated }
+ })
+ await vi.advanceTimersByTimeAsync(60_000)
+ expect(h.deliveries).toHaveBeenCalledTimes(1)
+ // Three saturated sweeps resume within milliseconds instead of costing an interval each.
+ await vi.advanceTimersByTimeAsync(10)
+ expect(h.deliveries).toHaveBeenCalledTimes(4)
+ await vi.advanceTimersByTimeAsync(59_000)
+ expect(h.deliveries).toHaveBeenCalledTimes(4)
+ await vi.advanceTimersByTimeAsync(1_000)
+ expect(h.deliveries).toHaveBeenCalledTimes(5)
+})
+
+it('keeps a delivery claim behind one statement while a backlog drains back to back', async () => {
+ const h = await fixture()
+ let backlog = true
+ let finishedDeletes = 0
+ const database: PushDatabase = {
+ dialect: 'postgres',
+ query: async (sql) => {
+ if (!sql.startsWith('DELETE')) return []
+ await new Promise((resolve) => setTimeout(resolve, 4_000))
+ finishedDeletes++
+ // Only deliveries hold a backlog, so each sweep spends its whole budget there and returns.
+ if (!backlog || !sql.includes('push_delivery_batches')) return [{ changes: 0 }]
+ return [{ changes: PRUNE_BATCH_ROWS }]
+ },
+ transaction: (operation) => operation(database),
+ lockQuotaScope: async () => {},
+ tryLockScope: async () => true,
+ tryLockSharedScope: async () => true,
+ close: async () => {}
+ }
+ const store = new DurablePushStore(database, Date.now, reserveRequestConnection(database, 2))
+ const sweeps: Promise[] = []
+ let inFlight = 0
+ let concurrentSweeps = 0
+ h.deliveries.mockImplementation(() => {
+ concurrentSweeps = Math.max(concurrentSweeps, ++inFlight)
+ const sweep = store.prune().finally(() => void inFlight--)
+ sweeps.push(sweep)
+ return sweep
+ })
+ try {
+ await vi.advanceTimersByTimeAsync(10 * 60_000)
+ const queuedAt = finishedDeletes
+ const startedAt = Date.now()
+ let statementsAhead: number | undefined
+ let claimDelay: number | undefined
+ const claim = store.claim().then(() => {
+ statementsAhead = finishedDeletes - queuedAt
+ claimDelay = Date.now() - startedAt
+ })
+ await vi.advanceTimersByTimeAsync(44_000)
+ await claim
+ // Sweeping serially parks one statement ahead of the claim no matter how long the drain runs.
+ expect({ statementsAhead, concurrentSweeps }).toEqual({
+ statementsAhead: 1,
+ concurrentSweeps: 1
+ })
+ expect(claimDelay).toBeLessThanOrEqual(4_000)
+ // Each sweep exhausts its 50-batch budget, so the drain continues instead of idling out the tick.
+ expect(sweeps.length).toBeGreaterThan(1)
+ } finally {
+ await h.stop()
+ backlog = false
+ await vi.advanceTimersByTimeAsync(10 * 60_000)
+ await Promise.all(sweeps)
+ }
+})
+
+it('keeps validation mode free of sweeps and timers', async () => {
+ const h = await fixture('validation')
+ await vi.advanceTimersByTimeAsync(20 * 60_000)
+ expect(h.challenges).not.toHaveBeenCalled()
+ expect(h.sessions).not.toHaveBeenCalled()
+ expect(h.deliveries).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(0)
+})
diff --git a/cloud/apps/push/src/push-background.ts b/cloud/apps/push/src/push-background.ts
index 8925fd41fedd..9fad4700e7b5 100644
--- a/cloud/apps/push/src/push-background.ts
+++ b/cloud/apps/push/src/push-background.ts
@@ -1,26 +1,55 @@
import type { PushConfig } from './config.js'
+import type { PushPruneSweep } from './durable-push-store.js'
import type { createPushServer } from './push-server.js'
const CHALLENGE_PRUNE_INTERVAL_MS = 60_000
const SESSION_PRUNE_INTERVAL_MS = 10 * 60_000
const DELIVERY_PRUNE_INTERVAL_MS = 60_000
-function prune(label: string, run: () => Promise, intervalMs: number): NodeJS.Timeout {
- const timer = setInterval(() => {
- void run().catch((error: unknown) => {
- console.warn(
- JSON.stringify({
- event: 'orca_push_prune_failed',
- target: label,
- error: error instanceof Error ? error.name : 'unknown'
- })
- )
- })
- }, intervalMs)
- timer.unref()
- return timer
+// Chained rather than periodic, so a sweep spanning many bounded statements never overlaps itself and
+// one that exhausted its batch budget resumes at once instead of idling out the rest of the interval.
+function prune(label: string, run: () => Promise, intervalMs: number): () => void {
+ let timer: NodeJS.Timeout | undefined
+ let overdue: NodeJS.Timeout | undefined
+ let stopped = false
+ function schedule(delayMs: number): void {
+ if (stopped) return
+ timer = setTimeout(tick, delayMs)
+ timer.unref()
+ }
+ function tick(): void {
+ // Admission waits are untimed, so a lost slot release would otherwise stall retention silently.
+ overdue = setTimeout(() => {
+ console.warn(JSON.stringify({ event: 'orca_push_prune_overdue', target: label }))
+ }, intervalMs)
+ overdue.unref()
+ void run()
+ .then((sweep) => schedule(sweep.saturated ? 0 : intervalMs))
+ .catch((error: unknown) => {
+ console.warn(
+ JSON.stringify({
+ event: 'orca_push_prune_failed',
+ target: label,
+ error: error instanceof Error ? error.name : 'unknown'
+ })
+ )
+ schedule(intervalMs)
+ })
+ .finally(() => clearTimeout(overdue))
+ }
+ schedule(intervalMs)
+ return () => {
+ stopped = true
+ clearTimeout(timer)
+ clearTimeout(overdue)
+ }
}
+// One DELETE under a statement timeout: there is no batch budget for it to exhaust.
+const unbatchedSweep =
+ (run: () => Promise) =>
+ async (): Promise => ({ deleted: await run(), saturated: false })
+
export function startPushBackground(
config: Pick,
runtime: Pick<
@@ -30,14 +59,22 @@ export function startPushBackground(
): () => Promise {
if (config.mode === 'validation') return async () => {}
const { challenges, sessions, deliveryStore, worker } = runtime
- const timers = [
- prune('challenges', () => challenges.pruneExpired(), CHALLENGE_PRUNE_INTERVAL_MS),
- prune('sessions', () => sessions.pruneExpired(), SESSION_PRUNE_INTERVAL_MS),
+ const stops = [
+ prune(
+ 'challenges',
+ unbatchedSweep(() => challenges.pruneExpired()),
+ CHALLENGE_PRUNE_INTERVAL_MS
+ ),
+ prune(
+ 'sessions',
+ unbatchedSweep(() => sessions.pruneExpired()),
+ SESSION_PRUNE_INTERVAL_MS
+ ),
prune('deliveries', () => deliveryStore.prune(), DELIVERY_PRUNE_INTERVAL_MS)
]
worker.start()
return async () => {
- for (const timer of timers) clearInterval(timer)
+ for (const stop of stops) stop()
await worker.stop()
}
}
diff --git a/cloud/apps/push/src/push-database.ts b/cloud/apps/push/src/push-database.ts
index b1a7415297b1..9957a7c28b52 100644
--- a/cloud/apps/push/src/push-database.ts
+++ b/cloud/apps/push/src/push-database.ts
@@ -85,15 +85,17 @@ class SqliteDatabase extends SqliteTransaction {
let release!: () => void
this.tail = new Promise((resolve) => (release = resolve))
await previous
- this.database.exec('BEGIN IMMEDIATE')
- const transaction = new SqliteTransaction(this.database)
try {
- const result = await operation(transaction)
- this.database.exec('COMMIT')
- return result
- } catch (error) {
- this.database.exec('ROLLBACK')
- throw error
+ this.database.exec('BEGIN IMMEDIATE')
+ const transaction = new SqliteTransaction(this.database)
+ try {
+ const result = await operation(transaction)
+ this.database.exec('COMMIT')
+ return result
+ } catch (error) {
+ this.database.exec('ROLLBACK')
+ throw error
+ }
} finally {
release()
}
diff --git a/cloud/apps/push/src/sqlite-transaction-queue.test.ts b/cloud/apps/push/src/sqlite-transaction-queue.test.ts
new file mode 100644
index 000000000000..1ee5e8b3d52a
--- /dev/null
+++ b/cloud/apps/push/src/sqlite-transaction-queue.test.ts
@@ -0,0 +1,101 @@
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
+import { setImmediate } from 'node:timers/promises'
+import { expect, it, vi } from 'vitest'
+import { openPushDatabase } from './push-database.js'
+
+it('releases queued work and close after a SQLite transaction cannot acquire its lock', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'orca-push-sqlite-queue-'))
+ let connection: DatabaseSync | undefined
+ const prepare = DatabaseSync.prototype.prepare
+ // Keep the native handle reachable for cleanup even if a queue regression strands close().
+ const capture = vi
+ .spyOn(DatabaseSync.prototype, 'prepare')
+ .mockImplementation(function (this: DatabaseSync, sql) {
+ connection = this
+ return prepare.call(this, sql)
+ })
+ const database = await openPushDatabase({ dataDir })
+ capture.mockRestore()
+ const blocker = new DatabaseSync(join(dataDir, 'orca-push.sqlite'))
+ try {
+ await database.query('CREATE TABLE queue_progress (value INTEGER)')
+ await database.query('INSERT INTO queue_progress VALUES (0)')
+ blocker.exec('BEGIN IMMEDIATE')
+ const operation = vi.fn(async () => undefined)
+ await expect(database.transaction(operation)).rejects.toThrow('database is locked')
+ let lockFailures = 0
+ const blocked = Array.from({ length: 5 }, () =>
+ database.transaction(operation).catch(() => {
+ lockFailures += 1
+ })
+ )
+ await setImmediate()
+ expect(lockFailures).toBe(5)
+ await Promise.all(blocked)
+ expect(operation).not.toHaveBeenCalled()
+ blocker.exec('ROLLBACK')
+ let completed = 0
+ const pending = Array.from({ length: 100 }, () =>
+ database.transaction(async (transaction) => {
+ await transaction.query('UPDATE queue_progress SET value = value + 1')
+ completed += 1
+ })
+ )
+ for (const request of pending) void request.catch(() => undefined)
+ await setImmediate()
+ expect(completed).toBe(100)
+ await Promise.all(pending)
+ expect(await database.query('SELECT value FROM queue_progress')).toEqual([{ value: 100 }])
+ let closed = false
+ const closing = database.close().then(() => {
+ closed = true
+ })
+ await setImmediate()
+ expect(closed).toBe(true)
+ await closing
+ } finally {
+ capture.mockRestore()
+ blocker.close()
+ if (connection?.isOpen) connection.close()
+ rmSync(dataDir, { recursive: true, force: true })
+ }
+})
+
+it('does not roll back a transaction when BEGIN failed before taking ownership', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'orca-push-sqlite-owner-'))
+ let connection: DatabaseSync | undefined
+ const prepare = DatabaseSync.prototype.prepare
+ const capture = vi
+ .spyOn(DatabaseSync.prototype, 'prepare')
+ .mockImplementation(function (this: DatabaseSync, sql) {
+ connection = this
+ return prepare.call(this, sql)
+ })
+ const database = await openPushDatabase({ dataDir })
+ capture.mockRestore()
+ try {
+ await database.query('CREATE TABLE queue_owner (value INTEGER)')
+ await database.query('BEGIN IMMEDIATE')
+ await database.query('INSERT INTO queue_owner VALUES (7)')
+ await expect(database.transaction(async () => undefined)).rejects.toThrow(
+ 'cannot start a transaction within a transaction'
+ )
+ expect(connection?.isTransaction).toBe(true)
+ let rows: unknown
+ void database.query('SELECT value FROM queue_owner').then((result) => {
+ rows = result
+ })
+ await setImmediate()
+ expect(rows).toEqual([{ value: 7 }])
+ await database.query('ROLLBACK')
+ expect(await database.query('SELECT value FROM queue_owner')).toEqual([])
+ await database.close()
+ } finally {
+ capture.mockRestore()
+ if (connection?.isOpen) connection.close()
+ rmSync(dataDir, { recursive: true, force: true })
+ }
+})
diff --git a/cloud/apps/relay/src/assignment-headroom-scope.test.ts b/cloud/apps/relay/src/assignment-headroom-scope.test.ts
new file mode 100644
index 000000000000..9862849d8e0e
--- /dev/null
+++ b/cloud/apps/relay/src/assignment-headroom-scope.test.ts
@@ -0,0 +1,129 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { RelayAssignmentStore } from './assignment-store.js'
+import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js'
+
+const NOW = 100_000
+const CELL_COUNT = 32
+const identity = { userId: 'headroom-user', relayHostId: 'headroomhost0001' }
+
+function observeHeadroom(database: RelayDatabase, rowCounts: number[]): RelayDatabase {
+ return {
+ dialect: database.dialect,
+ async query(sql, params) {
+ const rows = await database.query(sql, params)
+ if (sql.includes('FROM relay_cell_connection_limits limits')) rowCounts.push(rows.length)
+ return rows
+ },
+ queryLocked: (sql, params, options) => database.queryLocked(sql, params, options),
+ transaction: (operation, options) =>
+ database.transaction((transaction) => operation(observeHeadroom(transaction, rowCounts)), options),
+ close: () => database.close()
+ }
+}
+
+describe('assignment headroom query scope', () => {
+ let database: RelayDatabase | undefined
+ afterEach(async () => await database?.close())
+
+ async function setup() {
+ database = await openInMemoryRelayDatabase()
+ const rowCounts: number[] = []
+ const store = new RelayAssignmentStore(observeHeadroom(database, rowCounts), () => NOW, {
+ requireLiveCells: true,
+ heartbeatTtlMs: 45_000
+ })
+ const cells = Array.from({ length: CELL_COUNT }, (_, index) => ({
+ id: `cell-${String(index).padStart(2, '0')}`,
+ url: `https://cell-${index}.example.com`,
+ capacityRequests: 1_000,
+ connectionHardCap: 600 as const,
+ connectionUnobservedBound: 50
+ }))
+ await store.reconcileCells(cells)
+ for (const cell of cells) {
+ await store.recordCellHeartbeat({
+ cellId: cell.id,
+ cellUrl: cell.url,
+ cellIncarnation: '11111111-1111-4111-8111-111111111111',
+ startedAt: NOW - 10,
+ ready: true,
+ observedRequests: 0,
+ totalConnections: 0,
+ inFlightConnections: 0,
+ reservedConnectionUnits: 0,
+ enforcedConnectionUnits: 0,
+ connectionHardCap: 600,
+ connectionUnobservedBound: 50
+ })
+ }
+ await database.query(
+ `INSERT INTO relay_assignments
+ (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at,
+ last_activity_at, reserved_controls, reserved_splices, reserved_invites,
+ pending_installs, pending_confirmations, migration_leases)
+ VALUES (?, ?, 'cell-00', 1, ?, ?, 0, 0, 0, 0, 0, 0)`,
+ [identity.userId, identity.relayHostId, NOW + 90_000, NOW]
+ )
+ await store.acquireActivity(identity, {
+ activityId: 'existing-splice',
+ kind: 'splice',
+ cellId: 'cell-00'
+ })
+ rowCounts.length = 0
+ return { store, rowCounts, database }
+ }
+
+ it('reads one cell for a sticky assignment even when the capped fleet grows', async () => {
+ const { store, rowCounts } = await setup()
+ await expect(store.assign(identity)).resolves.toMatchObject({
+ ...identity,
+ cellId: 'cell-00',
+ assignmentEpoch: 1
+ })
+ console.info('sticky headroom rows', { fleetCells: CELL_COUNT, rowCounts })
+ expect(rowCounts).toEqual([1])
+ })
+
+ it('still considers the full fleet for a new placement', async () => {
+ const { store, rowCounts } = await setup()
+ await expect(
+ store.assign({ userId: 'new-user', relayHostId: 'newheadroomhost1' })
+ ).resolves.toMatchObject({ cellId: 'cell-01', assignmentEpoch: 1 })
+ expect(rowCounts).toEqual([CELL_COUNT])
+ })
+
+ it.each([
+ ['missing snapshot', `DELETE FROM relay_cell_connection_snapshots WHERE cell_id = 'cell-00'`],
+ ['expired snapshot', `UPDATE relay_cell_connection_snapshots SET snapshot_at = ${NOW - 45_000} WHERE cell_id = 'cell-00'`],
+ ['old incarnation', `UPDATE relay_cell_connection_snapshots SET cell_incarnation = 'old' WHERE cell_id = 'cell-00'`],
+ ['capacity boundary', `UPDATE relay_cell_connection_snapshots SET enforced_connection_units = 450 WHERE cell_id = 'cell-00'`]
+ ])('rejects the pinned active host with %s', async (_name, sql) => {
+ const { store, database } = await setup()
+ await database.query(sql)
+ await expect(store.assign(identity)).rejects.toThrow('relay_connection_headroom_exhausted')
+ })
+
+ it('preserves admission for cells without a connection limit', async () => {
+ const { store, rowCounts, database } = await setup()
+ await database.query(`DELETE FROM relay_cell_connection_limits WHERE cell_id = 'cell-00'`)
+ await expect(store.assign(identity)).resolves.toMatchObject({ cellId: 'cell-00' })
+ expect(rowCounts).toEqual([0])
+ })
+
+ it('counts outstanding reservations at the admission boundary', async () => {
+ const { store, database } = await setup()
+ await database.query(
+ `UPDATE relay_cell_connection_snapshots SET enforced_connection_units = 449
+ WHERE cell_id = 'cell-00'`
+ )
+ await database.query(
+ `INSERT INTO relay_control_connection_reservations
+ (reservation_id, idempotency_key, user_id, relay_host_id, assignment_epoch,
+ cell_id, state, created_at, timeout_at, updated_at)
+ VALUES ('pending', 'pending', 'other-user', 'other-host', 1,
+ 'cell-00', 'reserved', ?, ?, ?)`,
+ [NOW, NOW + 90_000, NOW]
+ )
+ await expect(store.assign(identity)).rejects.toThrow('relay_connection_headroom_exhausted')
+ })
+})
diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts
index 3cb5dcfeee9f..3572a69299cc 100644
--- a/cloud/apps/relay/src/assignment-store.ts
+++ b/cloud/apps/relay/src/assignment-store.ts
@@ -7466,10 +7466,16 @@ export class RelayAssignmentStore {
}
private async connectionHeadroomByCell(
- database: RelayDatabase
+ database: RelayDatabase,
+ cellId?: string
): Promise