mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge remote-tracking branch 'origin/main' into session-search
# Conflicts: # src/cli/help.ts
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
name: Set up WSL test runtime
|
||||
description: Install a checksum-pinned Ubuntu WSL1 guest with executable Node and Git for real terminal tests.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Provision Ubuntu WSL1
|
||||
shell: pwsh
|
||||
run: '& "${{ github.action_path }}/setup.ps1"'
|
||||
@@ -0,0 +1,32 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $IsWindows) { throw 'WSL test provisioning requires a Windows runner' }
|
||||
|
||||
$rootfs = Join-Path $env:RUNNER_TEMP 'noble-rootfs.tar.gz'
|
||||
Invoke-WebRequest 'https://releases.ubuntu.com/24.04.4/ubuntu-24.04.4-wsl-amd64.wsl' -OutFile $rootfs
|
||||
if ((Get-FileHash $rootfs -Algorithm SHA256).Hash.ToLowerInvariant() -ne '9b2f7730dc68227dd04a9f3e5eab86ad85caf556b8606ad94f1f29ff5c4fd3f5') { throw 'Ubuntu rootfs checksum mismatch' }
|
||||
$distroDir = Join-Path $env:RUNNER_TEMP 'orca-wsl-ubuntu'
|
||||
wsl.exe --import Ubuntu $distroDir $rootfs --version 1
|
||||
if ($LASTEXITCODE -ne 0) { throw "WSL import failed: $LASTEXITCODE" }
|
||||
wsl.exe --distribution Ubuntu --user root --exec /usr/bin/true
|
||||
if ($LASTEXITCODE -ne 0) { throw "WSL guest did not start: $LASTEXITCODE" }
|
||||
wsl.exe --distribution Ubuntu --user root --exec /usr/bin/apt-get update
|
||||
if ($LASTEXITCODE -ne 0) { throw "WSL apt update failed: $LASTEXITCODE" }
|
||||
wsl.exe --distribution Ubuntu --user root --exec /usr/bin/apt-get install --yes git curl xz-utils
|
||||
if ($LASTEXITCODE -ne 0) { throw "WSL git install failed: $LASTEXITCODE" }
|
||||
$kernelMsi = Join-Path $env:RUNNER_TEMP 'wsl_update_x64.msi'
|
||||
Invoke-WebRequest 'https://wslstorestorage.blob.core.windows.net/wslblob/wsl_update_x64.msi' -OutFile $kernelMsi
|
||||
if ((Get-FileHash $kernelMsi -Algorithm SHA256).Hash.ToLowerInvariant() -ne '4d09c776c8d45f70a202281d18e19be1118f53159b0c217a5274a31ce18525fe') { throw 'WSL kernel installer checksum mismatch' }
|
||||
$installer = Start-Process msiexec.exe -ArgumentList @('/i', $kernelMsi, '/quiet', '/norestart') -Wait -PassThru
|
||||
if ($installer.ExitCode -ne 0) { throw "WSL kernel installation failed: $($installer.ExitCode)" }
|
||||
wsl.exe --status
|
||||
if ($LASTEXITCODE -ne 0) { throw "WSL status failed: $LASTEXITCODE" }
|
||||
wsl.exe --distribution Ubuntu --user root --exec /usr/bin/curl --fail --silent --show-error --location https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-x64.tar.xz --output /tmp/orca-node.tar.xz
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Node download failed' }
|
||||
$nodeHash = wsl.exe --distribution Ubuntu --user root --exec /usr/bin/sha256sum /tmp/orca-node.tar.xz
|
||||
if ($LASTEXITCODE -ne 0 -or -not ($nodeHash -match '^69b09dba5c8dcb05c4e4273a4340db1005abeafe3927efda2bc5b249e80437ec')) { throw 'Node checksum mismatch' }
|
||||
wsl.exe --distribution Ubuntu --user root --exec /usr/bin/tar -xJf /tmp/orca-node.tar.xz -C /usr/local --strip-components=1
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Node extraction failed' }
|
||||
wsl.exe --distribution Ubuntu --user root --exec /usr/local/bin/node --version
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Node cannot execute in WSL' }
|
||||
wsl.exe --list --verbose
|
||||
if ($LASTEXITCODE -ne 0) { throw "WSL enumeration failed: $LASTEXITCODE" }
|
||||
@@ -227,6 +227,11 @@ jobs:
|
||||
mapfile -t TEST_FILES < <(jq -r '.[] | select(
|
||||
. != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and
|
||||
. != "tests/e2e/paired-startup-exec-readiness.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
|
||||
@@ -262,12 +267,15 @@ jobs:
|
||||
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 two spec clauses stay for their honest purpose: changed-e2e hands these specs to this
|
||||
# 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.
|
||||
if: >-
|
||||
inputs.test_files == '' ||
|
||||
inputs.ssh_source_changed == 'true' ||
|
||||
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/ssh-docker-bulk-open-freeze-repro.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
|
||||
@@ -348,3 +356,87 @@ jobs:
|
||||
path: e2e-traces/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
ssh-browser-network-route:
|
||||
name: ssh browser network route
|
||||
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
- name: Install SSH client
|
||||
run: sudo apt-get update && sudo apt-get install -y openssh-client
|
||||
- name: Run Docker SSH browser network route journeys
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
ORCA_RUN_DOCKER_SSH_BROWSER_E2E: '1'
|
||||
run: node_modules/.bin/vitest run --config config/vitest.config.ts tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts
|
||||
|
||||
ssh-localhost:
|
||||
name: localhost SSH terminal and hooks
|
||||
needs: [build, prepare-native-cache]
|
||||
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-localhost.spec.ts')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
- name: Install SSH server and headless tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client openssh-server python3 ripgrep xvfb zsh openbox x11-utils
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: electron
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: e2e-build-out
|
||||
path: out/
|
||||
- name: Start isolated localhost SSH server
|
||||
shell: bash
|
||||
run: |
|
||||
# Bare shells install Pi extensions only for an existing agent home.
|
||||
mkdir -p "$HOME/.pi/agent"
|
||||
fixture="$RUNNER_TEMP/orca-localhost-sshd"
|
||||
mkdir -p "$fixture"
|
||||
ssh-keygen -q -t ed25519 -N '' -f "$fixture/host_key"
|
||||
ssh-keygen -q -t ed25519 -N '' -f "$fixture/client_key"
|
||||
cat > "$fixture/sshd_config" <<EOF
|
||||
Port 22222
|
||||
ListenAddress 127.0.0.1
|
||||
HostKey $fixture/host_key
|
||||
PidFile $fixture/sshd.pid
|
||||
AuthorizedKeysFile $fixture/client_key.pub
|
||||
StrictModes no
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
UsePAM yes
|
||||
AllowUsers $(id -un)
|
||||
Subsystem sftp internal-sftp
|
||||
EOF
|
||||
sudo mkdir -p /run/sshd
|
||||
sudo /usr/sbin/sshd -f "$fixture/sshd_config" -E "$fixture/sshd.log"
|
||||
ssh -i "$fixture/client_key" -p 22222 -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null 127.0.0.1 true || { sudo cat "$fixture/sshd.log"; exit 1; }
|
||||
{
|
||||
echo "ORCA_E2E_SSH_PORT=22222"
|
||||
echo "ORCA_E2E_SSH_USER=$(id -un)"
|
||||
echo "ORCA_E2E_SSH_IDENTITY_FILE=$fixture/client_key"
|
||||
} >> "$GITHUB_ENV"
|
||||
- name: Run localhost SSH terminal and hook journey
|
||||
env:
|
||||
SKIP_BUILD: '1'
|
||||
ORCA_E2E_SSH_LOCALHOST: '1'
|
||||
ORCA_FEATURE_REMOTE_AGENT_HOOKS: '1'
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1'
|
||||
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-localhost.spec.ts --project=electron-headless --workers=1
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: localhost-ssh-traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -45,6 +45,7 @@ jobs:
|
||||
test_files: ${{ steps.e2e_filter.outputs.test_files }}
|
||||
ssh_source_changed: ${{ steps.e2e_filter.outputs.ssh_source_changed }}
|
||||
native_ime_source_changed: ${{ steps.e2e_filter.outputs.native_ime_source_changed }}
|
||||
wsl_source_changed: ${{ steps.e2e_filter.outputs.wsl_source_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -92,6 +93,9 @@ 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_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"
|
||||
SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --reusable-workflow)"
|
||||
if [ "$SHOULD_RUN" = true ]; then
|
||||
@@ -944,6 +948,16 @@ jobs:
|
||||
contents: read
|
||||
uses: ./.github/workflows/terminal-ime-e2e.yml
|
||||
|
||||
windows_wsl:
|
||||
name: real WSL terminal
|
||||
needs: code_paths
|
||||
if: needs.code_paths.outputs.wsl_source_changed == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/windows-wsl-e2e.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
verify:
|
||||
if: always()
|
||||
needs:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Windows WSL terminal E2E
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Commit to validate
|
||||
type: string
|
||||
required: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
type: string
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: windows-wsl-e2e-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
wsl-terminal:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-wsl-test-runtime
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: electron
|
||||
- name: Build relay and Electron
|
||||
run: |
|
||||
pnpm run build:relay
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Relay build failed' }
|
||||
pnpm exec electron-vite build --mode e2e
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Electron build failed' }
|
||||
- name: Exercise real WSL launch and paste
|
||||
env:
|
||||
SKIP_BUILD: '1'
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1'
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/wsl-results.json
|
||||
run: >-
|
||||
pnpm exec playwright test
|
||||
tests/e2e/golden-tab-bar-agent-launch.spec.ts
|
||||
tests/e2e/terminal-windows-shell-paste-ownership.spec.ts
|
||||
--config tests/playwright.config.ts
|
||||
--project=electron-headless
|
||||
--grep "WSL"
|
||||
--repeat-each=3
|
||||
--workers=1
|
||||
--reporter=list,json
|
||||
- name: Require all nine WSL executions
|
||||
if: always()
|
||||
run: node config/scripts/verify-wsl-e2e-participation.mjs test-results/wsl-results.json
|
||||
- name: Upload WSL participation report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: windows-wsl-participation-report
|
||||
path: test-results/wsl-results.json
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: windows-wsl-terminal-traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
@@ -4,18 +4,18 @@ Companion to [`relay-improvement-roadmap-2026-09.md`](./relay-improvement-roadma
|
||||
match). This file answers three questions per item: what are the concrete steps, what can run in parallel,
|
||||
and will a user notice.
|
||||
|
||||
## Status as of 2026-09-04 22:30Z
|
||||
## Status as of 2026-09-06 16:30Z
|
||||
|
||||
Three buckets. "Merged" means the code is on `main` and nothing in production has changed yet. "Deployed" means users are already getting it. "Awaiting owner" means I will not touch production without a go.
|
||||
|
||||
**Deployed to production**
|
||||
- Roll 2 relay image `4916ed67` (stablyai/orca #18959 + #18722 + #18720 flag unset): director since 2026-09-06 01:02Z, all 19 general cells by 16:29Z. Control lease 6 h ± 30 min, accept abandonment, per-cell inventory locks, pool `statement_timeout`. Record: findings doc, "Roll 2" section.
|
||||
- Auth instance cap 20 + dead-family audit fix (orca-cloud #474) as revision `orca-cloud-auth-00031-tox`.
|
||||
- Dynamic NAT ports in both regions (stablyai/orca #18693). Zero drops and zero proxy dial errors since.
|
||||
- Nine alert policies with log metrics: 4 auth (#475), 3 relay Cloud SQL/NAT (#18693), 1 cell process-exit (#18717), all on the relay Slack channel.
|
||||
|
||||
**Merged, ships with the next relay cell image roll (Roll 1 carries `519f4914`; Roll 2 needs a fresh image build)**
|
||||
- Per-cell inventory locks, delta counters, pool `statement_timeout` (#18722). Roll 2.
|
||||
- Cells dial Cloud SQL with `--private-ip` when configured (#18720). Inert until 2.1 applies.
|
||||
**Merged, not yet live**
|
||||
- Cells dial Cloud SQL with `--private-ip` when configured (#18720). Deployed in Roll 2 with the flag unset; inert until 2.1 applies.
|
||||
- Phone shows a clear "sign in on the desktop again" state when the desktop is signed out (#18698).
|
||||
|
||||
**Merged, ships with the next auth deploy**
|
||||
@@ -158,9 +158,10 @@ independent. (2.2 deferred; if revived, do it after 2.1 so the new instance is p
|
||||
- [ ] Production: announce a window; same steps; verify `orca_relay_runtime_metrics` controls recover to pre-cutover count.
|
||||
- [ ] Update `production-cloud-sql-app-consumers` budget test and both alert policies' `database_id`.
|
||||
|
||||
### 2.3 Relay pool statement timeout (merged stablyai/orca #18722; ships Roll 2)
|
||||
### 2.3 Relay pool statement timeout (deployed in Roll 2, 2026-09-06)
|
||||
- [x] `statement_timeout` on the relay `pg.Pool` (5 s, env-configurable; schema pool untimed; `57014` retryable), below the control-renewal deadline; DDL on an untimed connection (same pattern as auth #476).
|
||||
- [x] Postgres test on 55440: a held lock fails the query fast and the bounded retry takes over.
|
||||
- [x] Deployed fleet-wide in Roll 2 (`4916ed67`), 2026-09-06.
|
||||
|
||||
### 3.1 Refresh rotation grace window (orca-cloud #478 merged 2026-09-04; deploy pending owner go)
|
||||
- [ ] Fix the deploy-script env strip for `ORCA_CLOUD_REFRESH_TOKEN_TTL_DAYS` (pre-existing; found by #478).
|
||||
@@ -169,14 +170,16 @@ independent. (2.2 deferred; if revived, do it after 2.1 so the new instance is p
|
||||
- [x] Tests: replay inside window returns same successor; outside revokes; concurrent double-present yields one successor.
|
||||
- [x] Deploy via `deploy-auth-production` (candidate → smoke → promote). Deployed 2026-09-04 23:15Z as `orca-cloud-auth-00035-gos`, cap 20 kept, 0 5xx; `successor_material` column present; sealed successors being written. (candidate → smoke → promote).
|
||||
|
||||
### 3.2 / 4.3 Desktop (merged stablyai/orca #18719; ships next desktop release)
|
||||
### 3.2 / 4.3 Desktop (merged stablyai/orca #18719; ships next desktop release; relay side of 4.3 deployed in Roll 2)
|
||||
- [x] 3.2: on refresh timeout, re-read stored session before retrying; do not re-send a token already rotated locally.
|
||||
- [x] 4.3: ±10 % jitter on control lease renewal; unit test on the distribution; wire-compatible (server accepts early renewals already).
|
||||
- [x] 4.3 relay side: control lease 55 min → 6 h ± 30 min (#18959), deployed in Roll 2, 2026-09-06.
|
||||
|
||||
### 4.1 Lock contention (partial: stablyai/orca #18722 merged; ships Roll 2)
|
||||
### 4.1 Lock contention (partial: stablyai/orca #18722 deployed in Roll 2, 2026-09-06)
|
||||
- [x] Replace the global `FOR UPDATE` over `relay_cells` with per-cell row locks; counters delta-only. Remaining: `assignOnce` placement lock is still global (optimistic snapshot follow-up). with per-cell row locks or `pg_advisory_xact_lock(cell)`; counters delta-only.
|
||||
- [x] Postgres tests on 55440 with concurrent probes (in #18722). Staging load run still owed; `postgres_retries` per hour drops in staging load run.
|
||||
- [ ] Ships in Roll 2; then 4.4 recalibrates the retries bar from a week of data.
|
||||
- [x] Shipped in Roll 2 (2026-09-06). Director retries first 6 h on the new image: 13 vs 85 on the predecessor's prior 6 h.
|
||||
- [ ] 4.4: recalibrate the retries bar from a week of data (after 2026-09-13).
|
||||
|
||||
### 4.2 Region preference
|
||||
- [ ] Director: honor requested region when the preferred region has headroom, else sticky. Behind the existing flag.
|
||||
|
||||
@@ -999,4 +999,34 @@ Owner: "sure, feel free to drive these." Sequence chosen: Roll 1 first (highest
|
||||
| Image publish | run 34002233801 → `sha256:4916ed676d8389f694a648e750f1112d9002d68c84a1e0c7af828d5af129de62`; mirrored to staging (run 34002326150). | |
|
||||
| Staging cell smoke | **Dropped.** Staging C4 is pinned to the Asia launch digest by `relay-staging-c4-refresh-workflow.test.mjs` (with production c27–c29 tfvars and the C4 recovery workflow) and the only C4 image-refresh path pins its accepted predecessor to an older digest. Re-pinning all of it for a smoke widens into the Asia launch machinery; #18969 closed. Roll 2 follows the Roll 1 path: director first, c7 as the rehearsal cell. | |
|
||||
| Director deploy | run 34002673626 **success** 01:02Z: serving `orca-cloud-relay-00575-leq` on `4916ed67`, `00574-wag` (same image) tagged `selector-rollback`, `00569-ret` (`519f4914`) still deployable. Baseline before: 1 director Postgres retry in the prior hour, 0 `container die`. | |
|
||||
| c7 `verify` (read-only) | run 34002885408 dispatched 01:03Z, target `4916ed67`, rollback `85bf6799`, protocol 1, gen 148. | |
|
||||
| c7 `verify` (read-only) | run 34002885408 **success** (gate success, cell_1 rollout success, release_lease success), target `4916ed67`, rollback `85bf6799`, protocol 1, gen 148. | |
|
||||
| Director go/no-go (01:02Z–07:00Z, 6 h on `00575-leq`) | **Go.** Presence confirmed (13.8k assign 200s, 410 cell + 90 director `runtime_metrics` rows/30 min). Postgres retries 13 (all `55P03` lock_timeout) vs 85 on `00570-siv` in the prior 6 h. `/v1/assign` mix 200/401/503 = 13820/5557/623 vs 14081/5256/663 before the deploy; 503s are the placement/sticky admission `Retry-After` path and cluster by source (top source 351), same shape as before. 0 `container die`, cell `sqlFailuresDelta` sum 0. The earlier all-zero read at 01:28Z was a dead gcloud credential, not a quiet fleet, and was discarded. | |
|
||||
| Monitor dry-run (Roll 2 gate 1) | run 34018071984 dispatched 07:03Z at gen 148, **green** 07:18Z at `1326d6b40c`; main had moved to `b51bbf3fc6` with identical trusted code. | |
|
||||
| c7 `canary-apply` (run 34018804481) | **Succeeded** 07:18–07:31Z, protocol 1, rollback `85bf6799`: gate, rollout, seal_canary, release_lease all success. Template `…-20260906072156…` on `4916ed67`; selector gen 148 → 150. Four `container die` at 07:29:16–25Z were the new container exiting during boot (`applyPostgresSchema`/`backfillRelayCellRegions` → `Connection terminated due to connection timeout`, exit 1, 2 s runtime each) while the `cloud-sql-proxy` sidecar warmed up; fifth start at 07:29:26 listening, readiness check passed 07:29:27. Same boot-order race as c13 in Roll 1 batch 1, no serving impact (cell was still drained). 139 controls by 07:34Z and climbing, `sqlFailuresDelta` 0, `sqlLatencyMsMax` ~40 ms. | |
|
||||
| Monitor dry-run (Roll 2 gate 2) | run 34019568779 dispatched 07:36Z at gen 150, **green** 07:51Z at `57e34c7f03` (main `6494f2a4f0`, identical trusted code). | |
|
||||
| c8 `canary-apply` (run 34020284092) | **Succeeded** 07:52–08:09Z, protocol 1, rollback `519f4914`: all jobs success. Template `…-20260906075820…` on `4916ed67`; gen 150 → 152. One boot-race `container die` at 08:05:51Z (2 s, exit 1), next start served. 101 controls by 08:10Z, `sqlFailuresDelta` 0. | |
|
||||
| Monitor dry-run (Roll 2 gate 3) | run 34021119905 dispatched 08:11Z at gen 152, **green** 08:26Z at `ffbf35e0d2`. | |
|
||||
| Batch 1 `batch-apply` c9,c10,c13,c14 (run 34021868303, canary 34020284092) | **Failed on cell 3 (c13); c9 and c10 succeeded.** c9 08:27–08:43Z → gen 154, c10 08:43–08:58Z → gen 156, both trust-proven and restored general. c13: isolate → gen 157, drain, template `…-20260906090225…` on `4916ed67`, one boot-race exit 09:09:50Z, readiness 09:09:51Z, transition verifier passed at migration-only 09:11:17Z (2 680 assignments, heartbeat fresh, image `4916ed67`), then `probe-relay-rehome-trust` got **409** from the director at 09:11:18Z (157 ms; c9/c10 got 200 in ~178 ms). Failsafe re-asserted migration-only at gen 157 (no change). c14 skipped, lease released. c13 is **serving on the new image but isolated**: 151 controls by 09:18Z, `sqlFailuresDelta` 0, no exits fleet-wide after 09:12Z. The probe script prints only the status, not the director's `error` body, and neither the director nor c13 logs the 409 reason; candidates are the director's source check (`runtime.ready`/`heartbeatFresh`/incarnation read ~1 s after the verifier passed) or c13's `host-drain` rejecting the probe (incarnation mismatch, shared-runtime-identity proof, or the probe host unexpectedly present). Monitor residual: the probe should print the error body. | |
|
||||
| Monitor dry-run (Roll 2 gate 4) + c13 recovery | Gate run 34024459585 dispatched 09:26Z at gen 157 with c13 in migration-only. On green: `mode=rollback` for c13 with rollback digest `4916ed67` (what it already runs) and target `519f4914`, protocol 1 both ways: `ROLLBACK_RESUME=true` path, no restart, verify + trust probe + restore general. As in Roll 1 (c8 recovery), the rollback mode seals no canary authority, so c14 runs as its own `canary-apply` and the next batch is c15,c16,c19,c20 behind that. | |
|
||||
| c13 recovery (run 34025225328, `mode=rollback`) | Gate 4 **green** 09:38Z. Recovery **succeeded** 09:38–09:42Z: `ROLLBACK_RESUME=true`, no restart, verifier passed at migration-only (2 679 assignments, heartbeat fresh, `4916ed67`), **trust probe passed** (`host-not-connected` ×2, idempotent, shared runtime identity rejected), activate → **gen 158**, c13 general, verifier passed again. 154 controls, `sqlFailuresDelta` 0, no exits fleet-wide since 09:12Z. The 09:11Z 409 was therefore transient: same cell, same incarnation, same image, ~30 min later the identical probe passed. Most likely the director's source check reading the runtime row within ~1 s of the verifier's pass (a `ready`/heartbeat edge), which a retry in the workflow step would absorb. Residual: retry the trust probe once on 409 and print the error body. | |
|
||||
| Monitor dry-run (Roll 2 gate 5) | run 34025450523 dispatched 09:44Z at gen 158, **green** 09:59Z at `6933fd70d7` (main `d19be485d3`, identical trusted code). | |
|
||||
| c14 `canary-apply` (run 34026157631) | **Succeeded** 09:59–10:20Z, protocol 1: trust-proven, gen 158 → 160, canary authority sealed. No boot exits, 102 controls by 10:22Z, fleet `sqlFailuresDelta` 0 over 30 min. | |
|
||||
| Monitor dry-run (Roll 2 gate 6) | run 34027238190 dispatched 10:23Z at gen 160, **green** 10:38Z at `ec64df335e` (main `adcc30be3b`, identical trusted code). | |
|
||||
| Batch 2 `batch-apply` c15,c16,c19,c20 (run 34027985784, canary 34026157631) | **All four succeeded** 10:38–11:31Z, protocol 1, four trust proofs, gen 160 → 168. Boot-race exits only: 3 at 10:50Z (c16) and 5 at 11:02Z (c19), all 2–4 s, exit 1, next start served. Controls at 11:32Z: c15 160, c16 164, c19 164, c20 87 (still refilling). Fleet `sqlFailuresDelta` 1 over 30 min. | |
|
||||
| Monitor dry-run (Roll 2 gate 7) | run 34030557166 dispatched 11:33Z at gen 168, **green** 11:48Z at `adcc30be3b`. | |
|
||||
| c22 `canary-apply` (run 34031304526) | **Succeeded** 11:48–12:02Z, protocol 1, trust-proven, gen 168 → 170, canary authority sealed. No boot exits, 134 controls by 12:03Z. One correlated 1 s lock-timeout blip at 11:35:17–27Z (c10, c13, c19, c25, c28: one `sqlFailuresDelta` each, `sqlLatencyMsMax` ≈1 000 ms) spanning old and new images, the known lock-wait shape, not roll-related. Director retries 4 in the last hour. | |
|
||||
| Monitor dry-run (Roll 2 gate 8) | run 34032011250 dispatched 12:05Z at gen 170, **green** 12:20Z at `adcc30be3b`. | |
|
||||
| Batch 3 `batch-apply` c23,c24,c25,c26 (run 34032799574, canary 34031304526) | **Failed on cell 4 (c26); c23, c24, c25 succeeded** (12:20–13:11Z, gen 170 → 176, three trust proofs). c26: isolate → gen 177, drain, template `…-20260906131159…` on `4916ed67`, one boot-race exit 13:19:17Z, readiness 13:19:19Z, transition verifier passed at migration-only 13:20:42Z (2 604 assignments, heartbeat fresh, `4916ed67`), then the very next call, `admin_post target-runtime` to `c26.relay.onorca.dev/v1/admin/runtime-status`, got **503 `unconditional drop overload`** (27-byte body) and the step failed. That string is not in the relay codebase and c26 logged nothing at 13:20:42Z (readiness at 13:19:19Z, metrics steady), so it is a front-end/LB shed on one request; curl's `--retry 3` logged no retry attempt. Failsafe re-asserted migration-only at gen 177 (no change). c26 is serving on the new image but isolated: 166 controls by 13:25Z and climbing, `sqlFailuresDelta` 0. Residual: the post-apply `admin_post` should retry on 503 (the pre-apply one already tolerates a transient 5xx by comment). | |
|
||||
| c26 recovery (run 34036875433, `mode=rollback`) | Gate 9 (run 34036059275) **green** 13:41Z at gen 177 with c26 migration-only. Recovery **succeeded** 13:42–13:46Z: `ROLLBACK_RESUME=true`, no restart, verifier + trust probe passed, activate → **gen 178**, c26 general. 176 controls, `sqlFailuresDelta` 0, no exits since 13:25Z. **All 16 US general cells are on `4916ed67`.** | |
|
||||
| Monitor dry-run (Roll 2 gate 10) | run 34037169783 dispatched 13:48Z at gen 178, **green** 14:03Z at `f952f1ac96`. | |
|
||||
| c27 `canary-apply` (run 34037973681, Asia, protocol 0) | **Succeeded** 14:03–14:19Z, gen 178 → 180, canary authority sealed (unused; Asia cells roll as single canaries). Template on `4916ed67`, no boot exits, 51 controls by 14:20Z (Asia cell, refilling), `sqlFailuresDelta` 0, `sqlLatencyMsMax` ~1 040 ms (cross-region baseline, c28 on the old image reads ~1 055 ms). Fleet `sqlFailuresDelta` 5 over 30 min: c28 ×3 (~1.17 s), c8 and c9 ×1 (1 s bar), the known lock-wait singles. | |
|
||||
| Monitor dry-run (Roll 2 gate 11) | run 34038869552 dispatched 14:21Z at gen 180, **green** 14:36Z at `f952f1ac96`. | |
|
||||
| c28 `canary-apply` (run 34039710735, Asia, protocol 0) | **Succeeded** 14:36–14:53Z, gen 180 → 182. Template on `4916ed67`, no boot exits, 37 controls by 14:55Z (refilling), `sqlFailuresDelta` 0, `sqlLatencyMsMax` ~1 045 ms. Fleet `sqlFailuresDelta` 3 over 30 min. | |
|
||||
| Monitor dry-run (Roll 2 gate 12) | run 34040698172 dispatched 14:56Z at gen 182, **green** 15:12Z at `1d2e00819f`. | |
|
||||
| c29 `canary-apply` (run 34041558414, Asia, protocol 0) | **Succeeded** 15:12–15:28Z, gen 182 → 184. No boot exits, 55 controls by 15:29Z. | |
|
||||
| Census 15:29Z | MIG templates: 18 of 19 general cells on `4916ed67`; **c21 still on `519f4914`**. When c13's recovery re-sealed the canary at c14, batch 2 took c15,c16,c19,c20 and c21 dropped out of the plan's wave (`c15 canary + c16,c19,c20,c21`). Fleet 23 cells, 2 971 controls. Roll 2 exits since 07:00Z: 20, all boot-race (<10 s), 0 serving. Director retries 5 in the last hour. c21 rolls next as a single canary. | |
|
||||
| Monitor dry-run (Roll 2 gate 13) | run 34042460176 dispatched 15:30Z at gen 184, **green** 15:45Z at `3631f886a7`. | |
|
||||
| c21 `canary-apply` (run 34043296422, protocol 1) | **Failed at the same post-apply step as c26.** Isolate → gen 185, drain, template `…-20260906155550…` on `4916ed67`, verifier passed at migration-only 16:04:46Z (2 607 assignments, heartbeat fresh, `4916ed67`), then `admin_post target-runtime` to c21 got **503 `unconditional drop overload`** again (27-byte body, ~160 ms after the verifier's own successful read). Failsafe held migration-only at gen 185. c21 serving on the new image, isolated, 111 controls by 16:07Z. Second occurrence in ~3 h on two different cells, both ~1.3 min after readiness: consistent with an edge shed on the first admin request after the LB backend flips healthy. The step needs the same transient-5xx tolerance as the pre-apply read. | |
|
||||
| Monitor dry-run (Roll 2 gate 14) + c21 recovery | Gate run 34044440616 dispatched 16:08Z at gen 185 with c21 migration-only. On green: `mode=rollback` resume for c21 (rollback digest `4916ed67`, protocol 1). | |
|
||||
| c21 recovery (run 34045296151, `mode=rollback`) | Gate 14 **green** 16:23Z. Recovery **succeeded** 16:24–16:28Z: no restart, verifier + trust probe passed, activate → **gen 186**, c21 general. 164 controls, `sqlFailuresDelta` 0. | |
|
||||
| **Roll 2 complete** 16:29Z | **All 19 general cells on `4916ed67`** (c7–c10, c13–c16, c19–c29); existing-only c1–c6, c11, c12 and migration-only c17, c18 untouched. Selector gen 148 → 186. Fleet 23 cells, 2 927 controls. Container exits 07:00–16:29Z: 20, every one a boot-race exit (<10 s, `cloud-sql-proxy` sidecar not yet listening), **0 serving-process exits**. Director on `00575-leq` (`4916ed67`) since 01:02Z: Postgres retries 0 in the last hour (13 over the first 6 h vs 85 on the predecessor), 5xx in the last hour 104 `/v1/assign` 503s (admission `Retry-After` path, at the pre-roll rate). Three waves needed the no-restart `mode=rollback` resume (c13: transient trust-probe 409; c26 and c21: post-apply `runtime-status` 503 `unconditional drop overload`), each recovered in ~4 min with no drain. 14 monitor gates, 14 green, 0 freezes. | |
|
||||
|
||||
@@ -133,6 +133,11 @@ Record every gate and wave in the findings doc as in Roll 1.
|
||||
dropped.
|
||||
- **Monitor residuals** already in the checklist: `probeEndpointHealth` retry decision still uses the
|
||||
flat 2 000 ms bar; operator protocol unbound for Asia; `probe-relay-rehome-trust` regex.
|
||||
- **Same-cap job residuals found in Roll 2** (three of eleven mutating runs needed the resume path):
|
||||
the post-apply `admin_post target-runtime` read has no transient-5xx tolerance and failed twice on a
|
||||
one-request 503 `unconditional drop overload` from the edge ~80 s after readiness (c26, c21); and
|
||||
`probe-relay-rehome-trust` prints only the status on a 409, so the transient c13 failure left no
|
||||
reason on record. Retry both once and print the error body.
|
||||
- Update the checklist status header; tick 2.3, 4.1, 4.3 relay-side as deployed.
|
||||
|
||||
## Deferred, owner decision required
|
||||
|
||||
@@ -14,6 +14,7 @@ const projectDir = resolve(__dirname, '..')
|
||||
const requireFromProject = createRequire(join(projectDir, 'package.json'))
|
||||
|
||||
const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'@anthropic-ai/claude-agent-sdk',
|
||||
'@electron-toolkit/utils',
|
||||
'@linear/sdk',
|
||||
'@parcel/watcher',
|
||||
@@ -56,6 +57,11 @@ const ELECTRON_ARCHITECTURE_BY_ENUM = {
|
||||
4: 'universal'
|
||||
}
|
||||
const PACKAGED_NATIVE_ARCHITECTURES = new Set(['ia32', 'x64', 'arm', 'arm64'])
|
||||
const PACKAGED_MAIN_REQUIRED_FILES = [
|
||||
'out/main/index.js',
|
||||
'out/main/agent-hooks/managed-agent-hook-controls.js'
|
||||
]
|
||||
const PACKAGED_MAIN_SOURCE_RE = /^out\/main\/.+\.js$/
|
||||
const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/
|
||||
const JS_SOURCE_MAP_ARTIFACT_RE = /\.(?:c|m)?js\.map$/
|
||||
const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/
|
||||
@@ -223,22 +229,39 @@ function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/a
|
||||
return
|
||||
}
|
||||
|
||||
const mainFiles = ['out/main/index.js', 'out/main/agent-hooks/managed-agent-hook-controls.js']
|
||||
const entries = asar.listPackage(asarPath)
|
||||
const missing = new Set()
|
||||
|
||||
for (const file of mainFiles) {
|
||||
const entry = findAsarEntry(entries, file)
|
||||
if (!entry) {
|
||||
for (const file of PACKAGED_MAIN_REQUIRED_FILES) {
|
||||
if (!findAsarEntry(entries, file)) {
|
||||
throw new Error(`Packaged main file ${file} was not found in ${asarPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
const missing = new Set()
|
||||
// Why every emitted main file rather than the entry points alone: rolldown hoists
|
||||
// modules shared by two entries into out/main/chunks, so an entry's own bare imports
|
||||
// move out from under a fixed file list and silently stop being checked.
|
||||
for (const entry of entries) {
|
||||
if (!PACKAGED_MAIN_SOURCE_RE.test(normalizeAsarEntryPath(entry))) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Why: @electron/asar lists entries with host separators; Windows returns
|
||||
// backslashes, and extractFile expects that same host-style path.
|
||||
const internalPath = entry.replace(/^[\\/]+/, '')
|
||||
const source = asar.extractFile(asarPath, internalPath).toString('utf8')
|
||||
for (const match of source.matchAll(/require\(["']([^"']+)["']\)/g)) {
|
||||
const specifier = match[1]
|
||||
// Why the lookbehind: Orca has its own registry methods named `require`, so a
|
||||
// minified `registry.require('some-id')` must not read as a bare specifier.
|
||||
// Why it readmits `...`: a dot that ends a spread is not member access, and
|
||||
// the two error directions are not symmetric -- a false positive fails the
|
||||
// release build loudly, a false negative is this guard going blind.
|
||||
// Known limit: a specifier inside an embedded source string counts too, and
|
||||
// ssh-relay-deploy's remote probe names node-pty that way. A remote-only
|
||||
// dependency added to that script would fail desktop packaging here; telling
|
||||
// the two apart needs a parser, not a wider pattern.
|
||||
for (const match of source.matchAll(
|
||||
/(?:(?<![.\w])|(?<=\.\.\.))(?:require|import)\s*\(\s*(["'`])([^"'`$]+)\1\s*\)/g
|
||||
)) {
|
||||
const specifier = match[2]
|
||||
if (!isPackagedExternalSpecifier(specifier)) {
|
||||
continue
|
||||
}
|
||||
|
||||
+411
-90
File diff suppressed because one or more lines are too long
@@ -44,6 +44,135 @@ describe('packaged runtime resources', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies literal dynamic imports from the packaged main bundle', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-dynamic-imports-'))
|
||||
try {
|
||||
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
|
||||
|
||||
// The first is the exact shape oxc emits for the memoized SDK import in a
|
||||
// shipped build; the second is the spaced variant the pattern also accepts.
|
||||
const sources = new Map([
|
||||
[
|
||||
'out/main/index.js',
|
||||
'let p=null;function q(){return p??=import(`@anthropic-ai/claude-agent-sdk`),p}'
|
||||
],
|
||||
[
|
||||
'out/main/agent-hooks/managed-agent-hook-controls.js',
|
||||
'import (`@anthropic-ai/claude-agent-sdk`)'
|
||||
]
|
||||
])
|
||||
const asar = {
|
||||
listPackage: () => [...sources.keys()].map((entry) => `/${entry}`),
|
||||
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
|
||||
}
|
||||
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(
|
||||
/@anthropic-ai\/claude-agent-sdk/
|
||||
)
|
||||
|
||||
await mkdir(join(resourcesDir, 'node_modules', '@anthropic-ai', 'claude-agent-sdk'), {
|
||||
recursive: true
|
||||
})
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('still fails when a required packaged main entry is missing entirely', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-missing-entry-'))
|
||||
try {
|
||||
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
|
||||
|
||||
const asar = {
|
||||
listPackage: () => ['/out/main/index.js'],
|
||||
extractFile: () => Buffer.from('', 'utf8')
|
||||
}
|
||||
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(
|
||||
/managed-agent-hook-controls\.js was not found/
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies bare imports that rolldown hoisted into a shared main chunk', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-chunk-imports-'))
|
||||
try {
|
||||
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
|
||||
|
||||
// The entry points themselves carry no specifier; only the shared chunk does.
|
||||
const sources = new Map([
|
||||
['out/main/index.js', ''],
|
||||
['out/main/agent-hooks/managed-agent-hook-controls.js', ''],
|
||||
['out/main/chunks/managed-agent-hook-controls-CWf8D-KR.js', 'require(`jsonc-parser`)']
|
||||
])
|
||||
// Real listPackage emits directory nodes too, and extractFile throws on them,
|
||||
// so the `.js` anchor is load-bearing -- keep the mock able to catch that.
|
||||
const directories = ['/out', '/out/main', '/out/main/chunks']
|
||||
const asar = {
|
||||
listPackage: () => [...directories, ...[...sources.keys()].map((entry) => `/${entry}`)],
|
||||
extractFile: (_asarPath, internalPath) => {
|
||||
const source = sources.get(internalPath)
|
||||
if (source === undefined) {
|
||||
throw new Error(`Expected to find file at: ${internalPath} but found a directory`)
|
||||
}
|
||||
return Buffer.from(source, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(/jsonc-parser/)
|
||||
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'jsonc-parser'), { recursive: true })
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a spread require, whose leading dots are not member access', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-spread-require-'))
|
||||
try {
|
||||
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
|
||||
|
||||
const sources = new Map([
|
||||
['out/main/index.js', 'const all=[...require("jsonc-parser")]'],
|
||||
['out/main/agent-hooks/managed-agent-hook-controls.js', '']
|
||||
])
|
||||
const asar = {
|
||||
listPackage: () => [...sources.keys()].map((entry) => `/${entry}`),
|
||||
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
|
||||
}
|
||||
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(/jsonc-parser/)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores member calls onto Orca methods that are themselves named require', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-member-require-'))
|
||||
try {
|
||||
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
|
||||
|
||||
// electron-sidecar-tab-registry and browser-execution-host-grant-registry both
|
||||
// expose require(key); a literal key must never read as a packaged specifier.
|
||||
const sources = new Map([
|
||||
['out/main/index.js', 'registry.require("public-a");grants.require(`host-key`)'],
|
||||
['out/main/agent-hooks/managed-agent-hook-controls.js', 'state.import("android-sdk")']
|
||||
])
|
||||
const asar = {
|
||||
listPackage: () => [...sources.keys()].map((entry) => `/${entry}`),
|
||||
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
|
||||
}
|
||||
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes host-specific asar entry separators', () => {
|
||||
expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe(
|
||||
'\\out\\main\\index.js'
|
||||
@@ -134,6 +263,15 @@ describe('packaged runtime resources', () => {
|
||||
expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile'))
|
||||
})
|
||||
|
||||
it('includes the Claude agent SDK in every desktop package plan', () => {
|
||||
for (const platform of ['darwin', 'linux', 'win32']) {
|
||||
const packagedTargets = createPackagedRuntimeNodeModuleResources(platform).map(
|
||||
(resource) => resource.to
|
||||
)
|
||||
expect(packagedTargets).toContain(join('node_modules', '@anthropic-ai', 'claude-agent-sdk'))
|
||||
}
|
||||
})
|
||||
|
||||
it('prunes non-target @parcel/watcher architecture subpackages', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
|
||||
try {
|
||||
|
||||
@@ -101,29 +101,112 @@ function constantName(name) {
|
||||
return `${name.replace(/-/g, '_').toUpperCase()}_MARKDOWN`
|
||||
}
|
||||
|
||||
function serializeEmbeddedModule(guides) {
|
||||
const markdownConstants = guides
|
||||
function fullConstantName(name) {
|
||||
return `${name.replace(/-/g, '_').toUpperCase()}_FULL_MARKDOWN`
|
||||
}
|
||||
|
||||
function referenceConstantName(guideName, referenceName) {
|
||||
return `${`${guideName}_${referenceName}`.replace(/-/g, '_').toUpperCase()}_REFERENCE_MARKDOWN`
|
||||
}
|
||||
|
||||
function composeFullMarkdown(markdown, references) {
|
||||
if (references.length === 0) {
|
||||
return markdown
|
||||
}
|
||||
const packageHeader =
|
||||
'\n\n---\n\n# Bundled references\n\n' +
|
||||
'These references belong to the version-matched guide above. Read only the documents ' +
|
||||
'named by its action gates.\n'
|
||||
const documents = references
|
||||
.map(
|
||||
(guide) =>
|
||||
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
|
||||
({ relativePath, markdown: referenceMarkdown }) =>
|
||||
`\n<!-- bundled-reference: ${relativePath} -->\n\n${referenceMarkdown.trimEnd()}\n`
|
||||
)
|
||||
.join('')
|
||||
return `${markdown.trimEnd()}${packageHeader}${documents}`
|
||||
}
|
||||
|
||||
function serializeEmbeddedModule(guides) {
|
||||
const referenceConstants = guides.flatMap((guide) =>
|
||||
guide.references.map((reference) => referenceConstantName(guide.name, reference.name))
|
||||
)
|
||||
// Why: the constant name flattens guide and reference names, so two topics could otherwise
|
||||
// produce one identifier and silently serve the wrong reference.
|
||||
if (new Set(referenceConstants).size !== referenceConstants.length) {
|
||||
throw new Error(`Guide reference constant names collide: ${referenceConstants.join(', ')}`)
|
||||
}
|
||||
const markdownConstants = guides
|
||||
.flatMap((guide) => {
|
||||
const constants = [
|
||||
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
|
||||
]
|
||||
if (guide.fullMarkdown !== guide.markdown) {
|
||||
constants.push(
|
||||
`// oxfmt-ignore\nconst ${fullConstantName(guide.name)} = ${JSON.stringify(guide.fullMarkdown)}`
|
||||
)
|
||||
}
|
||||
for (const reference of guide.references) {
|
||||
constants.push(
|
||||
`// oxfmt-ignore\nconst ${referenceConstantName(guide.name, reference.name)} = ${JSON.stringify(reference.markdown)}`
|
||||
)
|
||||
}
|
||||
return constants
|
||||
})
|
||||
.join('\n\n')
|
||||
const guideEntries = guides
|
||||
.map((guide) => {
|
||||
const markdownConstant = constantName(guide.name)
|
||||
const referenceEntries = guide.references
|
||||
.map(
|
||||
(reference) =>
|
||||
`{ name: ${JSON.stringify(reference.name)}, markdown: ${referenceConstantName(guide.name, reference.name)} }`
|
||||
)
|
||||
.join(', ')
|
||||
return [
|
||||
' {',
|
||||
` name: ${JSON.stringify(guide.name)},`,
|
||||
` description: ${JSON.stringify(guide.description)},`,
|
||||
` markdown: ${markdownConstant},`,
|
||||
` fullMarkdown: ${markdownConstant},`,
|
||||
` aliases: ${JSON.stringify(guide.aliases)}`,
|
||||
` fullMarkdown: ${guide.fullMarkdown === guide.markdown ? markdownConstant : fullConstantName(guide.name)},`,
|
||||
` aliases: ${JSON.stringify(guide.aliases)},`,
|
||||
` references: [${referenceEntries}]`,
|
||||
' }'
|
||||
].join('\n')
|
||||
})
|
||||
.join(',\n')
|
||||
|
||||
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n}\n\n${markdownConstants}\n\n// Why: no current guide has bundled reference documents, so --full is byte-identical for now.\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
|
||||
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuideReference = {\n readonly name: string\n readonly markdown: string\n}\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n readonly references: readonly BundledSkillGuideReference[]\n}\n\n${markdownConstants}\n\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
|
||||
}
|
||||
|
||||
async function readGuideReferences(repoRoot, guideName) {
|
||||
const referenceRoot = path.join(repoRoot, 'skill-guides', guideName, 'references')
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(referenceRoot, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const unsupported = entries.find((entry) => !entry.isFile() || !entry.name.endsWith('.md'))
|
||||
if (unsupported) {
|
||||
throw new Error(
|
||||
`Guide references must be Markdown files: skill-guides/${guideName}/references/${unsupported.name}`
|
||||
)
|
||||
}
|
||||
return Promise.all(
|
||||
entries
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
||||
.map(async (entry) => {
|
||||
const sourcePath = path.join(referenceRoot, entry.name)
|
||||
const markdown = normalizeMarkdown(await readFile(sourcePath, 'utf8'))
|
||||
if (!markdown.trim()) {
|
||||
throw new Error(`Guide reference is empty: ${toPosixRelativePath(repoRoot, sourcePath)}`)
|
||||
}
|
||||
return { name: entry.name.slice(0, -3), relativePath: `references/${entry.name}`, markdown }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function assertAliasContract(guides) {
|
||||
@@ -204,9 +287,22 @@ async function buildArtifacts(repoRoot = REPO_ROOT) {
|
||||
throw new Error(`Guide source ${name}.md declares mismatched name ${frontmatter.name}`)
|
||||
}
|
||||
const aliases = GUIDE_ALIASES[name]
|
||||
const references = await readGuideReferences(repoRoot, name)
|
||||
// Why: the embedded table always carries the full guide (served by `skills get`);
|
||||
// only the installable projection thins to a stub once a topic is in STUB_TOPICS.
|
||||
guides.push({ name, description: frontmatter.description, markdown, aliases })
|
||||
guides.push({
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
markdown,
|
||||
fullMarkdown: composeFullMarkdown(markdown, references),
|
||||
aliases,
|
||||
// Why: `skills get --reference` serves one of these alone, so it keeps the
|
||||
// per-file identity that fullMarkdown's concatenation erases.
|
||||
references: references.map(({ name: referenceName, markdown: referenceMarkdown }) => ({
|
||||
name: referenceName,
|
||||
markdown: referenceMarkdown
|
||||
}))
|
||||
})
|
||||
const stubPath = path.join(repoRoot, 'skill-stubs', `${name}.md`)
|
||||
const content = stubTopics.has(name)
|
||||
? composeStubProjection(markdown, await readFile(stubPath, 'utf8'), `skill-stubs/${name}.md`)
|
||||
@@ -273,6 +369,7 @@ export {
|
||||
STUB_TOPICS,
|
||||
assertAliasContract,
|
||||
buildArtifacts,
|
||||
composeFullMarkdown,
|
||||
composeStubProjection,
|
||||
frontmatterBlock,
|
||||
normalizeMarkdown,
|
||||
|
||||
@@ -22,6 +22,15 @@ import {
|
||||
const projectDir = path.resolve(import.meta.dirname, '..', '..')
|
||||
const temporaryDirectories = []
|
||||
const execFileAsync = promisify(execFile)
|
||||
const ORCHESTRATION_REFERENCES = [
|
||||
'coordinator-loop.md',
|
||||
'legacy-contract-migration.md',
|
||||
'low-level-topology.md',
|
||||
'messaging-and-gates.md',
|
||||
'placement-and-remote.md',
|
||||
'recovery-and-cleanup.md',
|
||||
'worker-contract.md'
|
||||
]
|
||||
|
||||
async function createFixture() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
|
||||
@@ -181,7 +190,7 @@ describe('bundled skill guide generator', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
|
||||
it('embeds compact guides, version-matched reference packages, and append-only aliases', async () => {
|
||||
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
|
||||
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
|
||||
)
|
||||
@@ -194,8 +203,46 @@ describe('bundled skill guide generator', () => {
|
||||
const frontmatter = parseFrontmatter(source, `${guide.name}.md`)
|
||||
expect(guide.description).toBe(frontmatter.description)
|
||||
expect(guide.markdown).toBe(source)
|
||||
expect(guide.fullMarkdown).toBe(source)
|
||||
expect(guide.aliases).toEqual(GUIDE_ALIASES[guide.name])
|
||||
if (guide.name !== 'orchestration') {
|
||||
expect(guide.fullMarkdown).toBe(source)
|
||||
expect(guide.references).toEqual([])
|
||||
continue
|
||||
}
|
||||
// Why: the per-reference selector serves these verbatim, so an entry that
|
||||
// drifts from the file on disk ships a stale reference to every agent.
|
||||
expect(guide.references.map((reference) => reference.name)).toEqual(
|
||||
ORCHESTRATION_REFERENCES.map((reference) => reference.replace(/\.md$/u, ''))
|
||||
)
|
||||
for (const reference of guide.references) {
|
||||
expect(reference.markdown).toBe(
|
||||
normalizeMarkdown(
|
||||
await readFile(
|
||||
path.join(
|
||||
projectDir,
|
||||
'skill-guides',
|
||||
'orchestration',
|
||||
'references',
|
||||
`${reference.name}.md`
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
expect(guide.fullMarkdown).not.toBe(guide.markdown)
|
||||
expect(guide.fullMarkdown.length).toBeGreaterThan(guide.markdown.length)
|
||||
expect(guide.fullMarkdown.startsWith(source.trimEnd())).toBe(true)
|
||||
for (const reference of ORCHESTRATION_REFERENCES) {
|
||||
const marker = `<!-- bundled-reference: references/${reference} -->`
|
||||
expect(guide.fullMarkdown.split(marker)).toHaveLength(2)
|
||||
expect(guide.fullMarkdown).toContain(
|
||||
await readFile(
|
||||
path.join(projectDir, 'skill-guides', 'orchestration', 'references', reference),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -237,6 +284,17 @@ describe('bundled skill guide generator', () => {
|
||||
const stubSource = await readFile(stubPath, 'utf8')
|
||||
await writeFile(stubPath, stubSource.replaceAll('\n', '\r\n'))
|
||||
}
|
||||
for (const reference of ORCHESTRATION_REFERENCES) {
|
||||
const referencePath = path.join(
|
||||
root,
|
||||
'skill-guides',
|
||||
'orchestration',
|
||||
'references',
|
||||
reference
|
||||
)
|
||||
const source = await readFile(referencePath, 'utf8')
|
||||
await writeFile(referencePath, source.replaceAll('\n', '\r\n'))
|
||||
}
|
||||
|
||||
const actual = await buildArtifacts(root)
|
||||
expect(actual.map((artifact) => artifact.content)).toEqual(
|
||||
@@ -303,4 +361,15 @@ describe('bundled skill guide generator', () => {
|
||||
])
|
||||
).toThrow('collides with canonical name')
|
||||
})
|
||||
|
||||
it('rejects non-Markdown and empty bundled references', async () => {
|
||||
const root = await createFixture()
|
||||
const referenceRoot = path.join(root, 'skill-guides', 'orchestration', 'references')
|
||||
|
||||
await writeFile(path.join(referenceRoot, 'notes.txt'), 'not a reference\n')
|
||||
await expect(buildArtifacts(root)).rejects.toThrow('Guide references must be Markdown files')
|
||||
await rm(path.join(referenceRoot, 'notes.txt'))
|
||||
await writeFile(path.join(referenceRoot, 'empty.md'), '\n')
|
||||
await expect(buildArtifacts(root)).rejects.toThrow('Guide reference is empty')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,14 @@ const guidePath = join(projectDir, 'skill-guides', 'orca-cli.md')
|
||||
const stubPath = join(projectDir, 'skills', 'orca-cli', 'SKILL.md')
|
||||
// Why: orchestration and orca-emulator also ship hybrid stubs now, so their version-sensitive
|
||||
// command guidance lives in the guide sources — read the cross-guide worktree-id contract there.
|
||||
const orchestrationSkillPath = join(projectDir, 'skill-guides', 'orchestration.md')
|
||||
// Why: the worktree-selector rule lives in the orchestration placement reference, not the kernel.
|
||||
const orchestrationPlacementPath = join(
|
||||
projectDir,
|
||||
'skill-guides',
|
||||
'orchestration',
|
||||
'references',
|
||||
'placement-and-remote.md'
|
||||
)
|
||||
const emulatorSkillPath = join(projectDir, 'skill-guides', 'orca-emulator.md')
|
||||
|
||||
function readSkill(path = guidePath) {
|
||||
@@ -95,7 +102,7 @@ describe('orca CLI skill guidance', () => {
|
||||
|
||||
it('requires full worktree ids across bundled agent guidance', () => {
|
||||
const cliSkill = readSkill()
|
||||
const orchestrationSkill = readSkill(orchestrationSkillPath)
|
||||
const orchestrationSkill = readSkill(orchestrationPlacementPath)
|
||||
const emulatorSkill = readSkill(emulatorSkillPath)
|
||||
|
||||
for (const skill of [cliSkill, orchestrationSkill, emulatorSkill]) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ORCHESTRATION_COMMAND_SPECS } from '../../src/cli/specs/orchestration'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const guideRoot = join(projectDir, 'skill-guides', 'orchestration')
|
||||
const guidePaths = [
|
||||
join(projectDir, 'skill-guides', 'orchestration.md'),
|
||||
...readdirSync(join(guideRoot, 'references')).map((name) => join(guideRoot, 'references', name))
|
||||
]
|
||||
|
||||
function documentedInvocations() {
|
||||
return guidePaths.flatMap((path) => {
|
||||
const text = readFileSync(path, 'utf8')
|
||||
return [...text.matchAll(/ORCA orchestration ([a-z-]+)([^`\n]*)/gu)].map((match) => ({
|
||||
path,
|
||||
verb: match[1],
|
||||
flags: [...match[2].matchAll(/(?:^|\s)--([a-z][a-z-]*)/gu)].map((flag) => flag[1])
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
describe('orchestration guide command contract', () => {
|
||||
it('documents only orchestration verbs and flags accepted by the CLI specs', () => {
|
||||
const specs = new Map(
|
||||
ORCHESTRATION_COMMAND_SPECS.map((spec) => [spec.path[1], new Set(spec.allowedFlags)])
|
||||
)
|
||||
|
||||
for (const invocation of documentedInvocations()) {
|
||||
const allowed = specs.get(invocation.verb)
|
||||
expect(allowed, `${invocation.path}: ${invocation.verb}`).toBeDefined()
|
||||
for (const flag of invocation.flags) {
|
||||
expect(allowed, `${invocation.path}: ${invocation.verb} --${flag}`).toContain(flag)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,32 +1,58 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
// Why: orchestration now ships a hybrid discovery stub, so its version-sensitive command
|
||||
// guidance lives in the authoritative guide source — assert that content there. The
|
||||
// installable stub projection is checked separately below.
|
||||
const guidePath = join(projectDir, 'skill-guides', 'orchestration.md')
|
||||
const referenceRoot = join(projectDir, 'skill-guides', 'orchestration', 'references')
|
||||
const stubPath = join(projectDir, 'skills', 'orchestration', 'SKILL.md')
|
||||
|
||||
function readSkill() {
|
||||
function readKernel() {
|
||||
return readFileSync(guidePath, 'utf8')
|
||||
}
|
||||
|
||||
function getSection(markdown, heading) {
|
||||
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = markdown.match(
|
||||
new RegExp(`## ${escapedHeading}\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |$)`)
|
||||
)
|
||||
|
||||
expect(match).not.toBeNull()
|
||||
|
||||
return match?.[1] ?? ''
|
||||
function readReference(name) {
|
||||
return readFileSync(join(referenceRoot, name), 'utf8')
|
||||
}
|
||||
|
||||
describe('orchestration skill guidance', () => {
|
||||
function frontmatter(text) {
|
||||
return /^---\n[\s\S]*?\n---\n/u.exec(text)?.[0]
|
||||
}
|
||||
|
||||
function squash(text) {
|
||||
return text.replace(/\s+/gu, ' ').trim()
|
||||
}
|
||||
|
||||
// Routing lives in the frontmatter description alone; the body must not satisfy these.
|
||||
function readDescription() {
|
||||
return squash(frontmatter(readKernel()))
|
||||
}
|
||||
|
||||
describe('orchestration skill routing', () => {
|
||||
it('keeps the verbatim routing triggers a model matches the skill on', () => {
|
||||
const description = readDescription()
|
||||
|
||||
for (const trigger of [
|
||||
'threaded messages',
|
||||
'worker_done/escalation waits',
|
||||
'decision gates',
|
||||
'decomposing work across agents',
|
||||
'"hand off"',
|
||||
'"handoff"',
|
||||
'"handover"',
|
||||
'"give this to another agent"',
|
||||
'"another worktree"',
|
||||
'lightweight terminal prompts',
|
||||
'shell commands',
|
||||
'Orca worktree management',
|
||||
'reading or waiting on terminals'
|
||||
]) {
|
||||
expect(description).toContain(trigger)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps external browser routing at the OS/page boundary', () => {
|
||||
const description = readFileSync(guidePath, 'utf8').replace(/\s+/gu, ' ')
|
||||
const description = readDescription()
|
||||
|
||||
expect(description).toContain(
|
||||
"Use Computer Use for external browser windows, webviews, Orca app UI, or desktop UI outside Orca's embedded browser only when the task requires OS/window-level control such as focus, menus, dialogs, coordinates, or screenshots."
|
||||
@@ -35,383 +61,444 @@ describe('orchestration skill guidance', () => {
|
||||
"`orca-cli` for Orca's embedded pages and a page-automation tool such as Playwright or CDP for external pages."
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('requires Orca runtime state before claiming a worker was orchestrated', () => {
|
||||
const skill = readSkill()
|
||||
const toolBoundary = getSection(skill, 'Tool Boundary')
|
||||
describe('orchestration kernel', () => {
|
||||
it('keeps the always-loaded guide compact and ordered around the normal protocol', () => {
|
||||
const kernel = readKernel()
|
||||
const headings = [
|
||||
'## Outcome',
|
||||
'## Classify the role',
|
||||
'## Authority and safety floor',
|
||||
'## Worker obligations',
|
||||
'## Canonical supervised loop',
|
||||
'## Task-spec contract',
|
||||
'## Completion accounting',
|
||||
'## Conditional references'
|
||||
]
|
||||
|
||||
expect(toolBoundary).toContain('must create or bind a Run')
|
||||
expect(toolBoundary).toContain('create the Task with `orca orchestration task-create`')
|
||||
expect(toolBoundary).toContain('preferred `orca orchestration worker-start` composition')
|
||||
expect(toolBoundary).toContain('low-level `orca orchestration dispatch --inject` path')
|
||||
expect(toolBoundary).not.toContain('or `orca orchestration run`')
|
||||
expect(skill).toContain(
|
||||
'`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands'
|
||||
)
|
||||
expect(toolBoundary).toContain(
|
||||
'Do not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features'
|
||||
)
|
||||
expect(toolBoundary).toContain('do not create Orca task/dispatch provenance')
|
||||
expect(toolBoundary).toContain('injected lifecycle preambles')
|
||||
expect(toolBoundary).toContain('`worker_done` authority')
|
||||
expect(toolBoundary).toContain('decision gates')
|
||||
expect(toolBoundary).toContain('orca orchestration task-list --json')
|
||||
expect(toolBoundary).toContain('orca orchestration dispatch-show --task <task_id> --json')
|
||||
expect(toolBoundary).toContain(
|
||||
'do not retroactively describe the external worker as orchestrated'
|
||||
)
|
||||
})
|
||||
|
||||
it('teaches attested adoption without reviving the retired scheduler', () => {
|
||||
const skill = readSkill()
|
||||
const migration = getSection(skill, 'Contract Migration')
|
||||
|
||||
expect(migration).toContain(
|
||||
'adopts a live pre-update orchestration assignment into an ordinary Run'
|
||||
)
|
||||
expect(migration).toContain(
|
||||
'preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch'
|
||||
)
|
||||
expect(migration).toContain('never restarts or replaces the worker')
|
||||
expect(migration).toContain('The retired scheduler is not revived')
|
||||
expect(migration).toContain('[LEGACY COMPATIBILITY]')
|
||||
expect(migration).toContain('[LEGACY READ-ONLY]')
|
||||
expect(migration).toContain(
|
||||
'Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.'
|
||||
)
|
||||
expect(migration).toContain(
|
||||
'It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal.'
|
||||
)
|
||||
expect(migration).not.toContain('task-list --run run_legacy_local')
|
||||
expect(migration).toContain('run_legacy_local is an empty audit tombstone')
|
||||
expect(migration).toContain('Recovered orchestration work from a contract update')
|
||||
expect(migration).toContain('run-show --id <adopted_run_id>')
|
||||
expect(migration).toContain('task-list --run <adopted_run_id>')
|
||||
expect(migration).toContain('Legacy inspection remains available without consuming mail')
|
||||
expect(migration).toContain('run-use --id <adopted_run_id> --takeover-legacy')
|
||||
expect(migration).toContain('Takeover fences only the old coordinator')
|
||||
expect(migration).toContain('Live legacy workers keep their original Tasks, Dispatches')
|
||||
expect(migration).toContain(
|
||||
'keep the original worker as the only editor until it reaches a stable handoff point'
|
||||
)
|
||||
expect(migration).toContain('a conflict-free placement for any remaining work')
|
||||
})
|
||||
|
||||
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
|
||||
const skill = readSkill()
|
||||
|
||||
expect(skill).toContain('Treat a `check --wait` timeout or `{count:0}` as a checkpoint')
|
||||
expect(skill).toContain('Do not stop, close, kill, or restart a worker')
|
||||
expect(skill).toContain('keep waiting instead of retrying the task')
|
||||
expect(skill).not.toContain(
|
||||
'If `check --wait` times out with no `worker_done` or `escalation`, fall back to `terminal wait --for tui-idle`, then `terminal read`.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps full handoffs out of dispatch lifecycle and off the active branch base', () => {
|
||||
const skill = readSkill()
|
||||
const fullHandoffs = getSection(skill, 'Full Handoffs')
|
||||
|
||||
expect(skill).toContain('Full handoff means ownership transfer, not supervised dispatch.')
|
||||
expect(fullHandoffs).toContain(
|
||||
'Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs.'
|
||||
)
|
||||
expect(fullHandoffs).toContain(
|
||||
'`task-create` is also forbidden because it records coordinator-owned tracking state'
|
||||
)
|
||||
expect(fullHandoffs).toContain('Do not create a `taskId`/`dispatchId`')
|
||||
expect(fullHandoffs).toContain(
|
||||
'read the worker terminal after prompt delivery except to avoid losing the initial prompt'
|
||||
)
|
||||
expect(skill).toContain(
|
||||
'`--no-parent` only controls Orca lineage; it does not choose the Git base.'
|
||||
)
|
||||
expect(skill).toContain(
|
||||
'never base it on the current feature branch unless the user explicitly asks'
|
||||
)
|
||||
expect(skill).toContain(
|
||||
'orca worktree create --name <task-name> --no-parent --agent codex --prompt'
|
||||
)
|
||||
expect(fullHandoffs).toContain(
|
||||
'Before creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level'
|
||||
)
|
||||
expect(fullHandoffs).toContain(
|
||||
'Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree'
|
||||
)
|
||||
expect(fullHandoffs).toContain(
|
||||
'For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`'
|
||||
)
|
||||
expect(fullHandoffs).toContain('If the work should start from the repo default base')
|
||||
expect(fullHandoffs).toContain('omit `--base-branch`')
|
||||
})
|
||||
|
||||
it('classifies handoff wording as ownership transfer unless supervision is explicit', () => {
|
||||
const skill = readSkill()
|
||||
const fullHandoffs = getSection(skill, 'Full Handoffs')
|
||||
|
||||
for (const phrase of [
|
||||
'hand off',
|
||||
'handoff',
|
||||
'handover',
|
||||
'give this to another agent',
|
||||
'give this to another worktree',
|
||||
'another agent',
|
||||
'another worktree'
|
||||
]) {
|
||||
expect(fullHandoffs).toContain(phrase)
|
||||
// Why: 202 is the budget after the anti-loop nextAction rule; the kernel is always in context.
|
||||
expect(kernel.split('\n').length).toBeLessThanOrEqual(202)
|
||||
for (let index = 1; index < headings.length; index += 1) {
|
||||
expect(kernel.indexOf(headings[index])).toBeGreaterThan(kernel.indexOf(headings[index - 1]))
|
||||
}
|
||||
expect(kernel).not.toContain('## Contract Migration')
|
||||
expect(kernel).not.toContain('## Full Handoffs')
|
||||
expect(kernel).not.toContain('## Worker Terminals')
|
||||
})
|
||||
|
||||
for (const supervisionPhrase of [
|
||||
'supervise',
|
||||
'monitor',
|
||||
'wait for worker_done',
|
||||
'wait for results',
|
||||
'track completion',
|
||||
'DAG',
|
||||
'decision gate',
|
||||
'ask/reply'
|
||||
it('classifies coordinator, dispatched worker, handoff, compatibility, and ordinary roles', () => {
|
||||
const kernel = readKernel()
|
||||
|
||||
expect(kernel).toContain('explicitly asks to supervise, monitor, wait for results')
|
||||
expect(kernel).toContain('live injected preamble with Task and Dispatch IDs')
|
||||
expect(kernel).toContain('Handoff owner')
|
||||
expect(kernel).toContain('create no Run, Task, or Dispatch and do not monitor completion')
|
||||
expect(kernel).toContain('Compatibility operator')
|
||||
expect(kernel).toContain('Ordinary terminal agent')
|
||||
expect(kernel).toContain('Model or effort selection does not make a handoff supervised')
|
||||
expect(squash(kernel)).toContain('Never substitute a non-Orca subagent tool')
|
||||
})
|
||||
|
||||
it('makes Dispatch identity, remote uncertainty, folders, and mixed versions a safety floor', () => {
|
||||
const kernel = readKernel()
|
||||
|
||||
expect(kernel).toContain('A Dispatch is one authoritative Task attempt')
|
||||
expect(kernel).toContain('Lifecycle authority comes from the active Dispatch')
|
||||
expect(kernel).toContain('execution host owns')
|
||||
expect(squash(kernel)).toContain('`live` / `unverifiable` / `exited`')
|
||||
expect(kernel).toContain('contact loss is not process death')
|
||||
expect(kernel).toContain('Folder workspaces are valid')
|
||||
expect(squash(kernel)).toContain('Treat unknown optional fields as absent')
|
||||
expect(kernel).toContain('new stream operation requires advertised capability')
|
||||
expect(kernel).toContain('Never fall back to local execution')
|
||||
})
|
||||
|
||||
it('puts exactly-once worker completion and post-completion idle before coordinator mechanics', () => {
|
||||
const kernel = readKernel()
|
||||
|
||||
expect(kernel.indexOf('## Worker obligations')).toBeLessThan(
|
||||
kernel.indexOf('## Canonical supervised loop')
|
||||
)
|
||||
expect(kernel).toContain('The injected preamble is authoritative')
|
||||
expect(kernel).toContain('Send `worker_done` exactly once')
|
||||
expect(kernel).toContain('three-sentence executive summary')
|
||||
expect(kernel).toContain('`--outcome succeeded` or `--outcome failed`')
|
||||
// Why: the runnable worker_done command is the preamble's; its flag spellings are pinned
|
||||
// on worker-contract.md by 'keeps heartbeat and worker_done recipes bound to the injected
|
||||
// capability', so the kernel carries the obligations as prose and no third copy.
|
||||
expect(kernel).not.toContain('--type worker_done')
|
||||
expect(kernel).toContain('After `worker_done`, end the dispatched turn and idle')
|
||||
expect(kernel).toContain('Do not reuse the settled lifecycle IDs')
|
||||
})
|
||||
|
||||
it('teaches worker-start as the only normal-path launch and starts the wave before waiting', () => {
|
||||
const kernel = readKernel()
|
||||
const firstStart = kernel.indexOf('worker-start --spec "<worker A task>"')
|
||||
const secondStart = kernel.indexOf('worker-start --spec "<worker B task>"')
|
||||
const firstWait = kernel.indexOf('check --wait')
|
||||
|
||||
expect(firstStart).toBeGreaterThan(kernel.indexOf('run-create'))
|
||||
expect(secondStart).toBeGreaterThan(firstStart)
|
||||
expect(firstWait).toBeGreaterThan(secondStart)
|
||||
expect(squash(kernel)).toContain('start the full independent wave before waiting')
|
||||
expect(kernel).toContain('`worker-start` is the normal path')
|
||||
expect(squash(kernel)).toContain(
|
||||
"If `worker-start` exits non-zero, do not relaunch. Read the receipt's `failedStage` and `residualResources`"
|
||||
)
|
||||
expect(kernel).toContain('operator-created process unsupervised')
|
||||
expect(kernel).not.toMatch(/^ORCA terminal create/mu)
|
||||
})
|
||||
|
||||
it('makes worker-start --spec the default and keeps task-create for planned fan-out', () => {
|
||||
const kernel = squash(readKernel())
|
||||
|
||||
expect(kernel).toContain('`worker-start --spec` creates the Task and its attempt in one call')
|
||||
expect(kernel).toContain('Use `task-create` plus `worker-start --task <task_id>`')
|
||||
})
|
||||
|
||||
it('gives the supervised loop an exit condition for a live terminal with a dead agent', () => {
|
||||
const kernel = squash(readKernel())
|
||||
|
||||
expect(kernel).toContain("`worker-list`'s `projection.liveness` is the fleet verdict")
|
||||
expect(kernel).toContain("`worker-show`'s `observation.status` is PTY liveness only")
|
||||
expect(kernel).toContain('After three consecutive empty waits')
|
||||
expect(kernel).toContain('`ORCA orchestration worker-list --include-remote --json`')
|
||||
expect(kernel).toContain('defaults to the bound Run; `--run <run_id>` overrides')
|
||||
expect(kernel).toContain(
|
||||
'`projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv'
|
||||
)
|
||||
expect(kernel).toContain(
|
||||
'An `inspect` `nextAction` on a `live` row with `attention.requiresAction` false is informational, not a command to re-run: keep waiting with `check --wait`'
|
||||
)
|
||||
expect(kernel).toContain('choose `worker-stop` or `worker-abandon`')
|
||||
})
|
||||
|
||||
it('lets only positive evidence of exit end a wait', () => {
|
||||
const kernel = squash(readKernel())
|
||||
|
||||
expect(kernel).toContain('Leave the wait only on positive proof the agent stopped')
|
||||
expect(kernel).toContain('`exited` liveness')
|
||||
expect(kernel).toContain("the worker's own observation of process exit")
|
||||
expect(kernel).toContain('transcript whose final agent turn sent no `worker_done`')
|
||||
expect(kernel).toContain(
|
||||
'`unverifiable` is absence, including when `worker-show` reports `agentWait` null. Absence never authorizes stop, abandon, retry, or release'
|
||||
)
|
||||
})
|
||||
|
||||
it('names --terminal, never --from, as the check caller flag', () => {
|
||||
const kernel = squash(readKernel())
|
||||
|
||||
expect(kernel).toContain('`check` names its caller with `--terminal <handle>`, never `--from`')
|
||||
expect(kernel).not.toContain('check --from')
|
||||
})
|
||||
|
||||
it('makes a dispatched worker read coordinator follow-ups on a cadence', () => {
|
||||
const kernel = squash(readKernel())
|
||||
|
||||
expect(kernel).toContain('Read coordinator follow-ups at each natural checkpoint')
|
||||
expect(kernel).toContain('once more immediately before `worker_done`')
|
||||
expect(kernel).toContain('`ORCA orchestration check --terminal <your_handle> --json`')
|
||||
})
|
||||
|
||||
it('requires full Delivery processing and settled-terminal accounting before ack', () => {
|
||||
const kernel = readKernel()
|
||||
|
||||
expect(squash(kernel)).toContain(
|
||||
'oldest FIFO Delivery and replays that batch until acknowledged'
|
||||
)
|
||||
expect(squash(kernel)).toContain('Process every message')
|
||||
expect(squash(kernel)).toContain("decide each settled terminal's next owner before the ack")
|
||||
expect(squash(kernel)).toContain('reused, explicitly retained, or released')
|
||||
expect(squash(kernel)).toContain(
|
||||
'the turn ends only when the report to that user names, per Task, its outcome, the evidence behind it, and any unresolved blocker'
|
||||
)
|
||||
expect(kernel).toContain('worker-release --dispatch <dispatch_id>')
|
||||
expect(kernel).toContain('check --ack <delivery_id> --wait')
|
||||
expect(squash(kernel)).toContain(
|
||||
'`worker-list --run <run_id> --terminal-state reclaimable --json`'
|
||||
)
|
||||
expect(squash(kernel)).toContain('do not follow it with `task-update --status completed`')
|
||||
})
|
||||
|
||||
it('treats long waits and release uncertainty as safe checkpoints', () => {
|
||||
const kernel = readKernel()
|
||||
|
||||
// Why: e92d7812d91 and c78f40fdd0b protect one rule; `## Outcome` states it once and each
|
||||
// gate cites it, so these pin the condition rather than a per-gate list of non-proofs.
|
||||
expect(squash(kernel)).toContain(
|
||||
'Only positive proof of exit authorizes stop, abandon, or retry, and only an accepted settlement authorizes release. Every other observation, absence included, is a checkpoint'
|
||||
)
|
||||
expect(squash(kernel)).toContain('A timeout or empty result is a checkpoint, not a failure')
|
||||
expect(squash(kernel)).toContain('Do not stop, retry, release, or launch a duplicate editor')
|
||||
expect(squash(kernel)).toContain('without the positive proof `## Outcome` requires')
|
||||
expect(squash(kernel)).toContain(
|
||||
'Only an accepted settlement authorizes it; no other observation does'
|
||||
)
|
||||
expect(kernel).toContain('never substitute `terminal close`')
|
||||
})
|
||||
|
||||
it('defines self-contained task specs and honest send attention semantics', () => {
|
||||
const kernel = readKernel()
|
||||
|
||||
for (const field of [
|
||||
'**Target:**',
|
||||
'**Change:**',
|
||||
'**Constraints:**',
|
||||
'**Ownership:**',
|
||||
'**Observable acceptance:**'
|
||||
]) {
|
||||
expect(fullHandoffs).toContain(supervisionPhrase)
|
||||
expect(kernel).toContain(field)
|
||||
}
|
||||
expect(kernel).toContain('successful `orchestration send` proves durable enqueue')
|
||||
expect(kernel).toContain('best-effort attention only')
|
||||
expect(squash(kernel)).toContain('does not prove the recipient read or accepted it')
|
||||
})
|
||||
})
|
||||
|
||||
describe('owned orchestration references', () => {
|
||||
it('routes every conditional read to exactly one shipped reference', () => {
|
||||
const kernel = readKernel()
|
||||
const routed = [...kernel.matchAll(/`references\/([^`]+\.md)`/gu)].map((match) => match[1])
|
||||
const shipped = readdirSync(referenceRoot)
|
||||
.filter((name) => name.endsWith('.md'))
|
||||
.sort()
|
||||
|
||||
const tableRoutes = [...kernel.matchAll(/^\|.*`references\/([^`]+\.md)`.*\|$/gmu)].map(
|
||||
(match) => match[1]
|
||||
)
|
||||
|
||||
expect([...new Set(routed)].sort()).toEqual(shipped)
|
||||
// Why the table and not every mention: prose may cite a reference the gate table already routes.
|
||||
expect(tableRoutes.sort()).toEqual(shipped)
|
||||
expect(kernel).toContain('ORCA skills get orchestration --full')
|
||||
// Why: the selector is the cheap path, so the kernel must teach it first and keep
|
||||
// `--full` only as the fallback for a CLI build that predates it.
|
||||
expect(squash(kernel)).toContain(
|
||||
'run `ORCA skills get orchestration --reference references/<file>.md`'
|
||||
)
|
||||
expect(squash(kernel)).toContain(
|
||||
'If the CLI rejects `--reference`, run `ORCA skills get orchestration --full`'
|
||||
)
|
||||
expect(squash(kernel)).toContain('If an older CLI rejects `--full`')
|
||||
})
|
||||
|
||||
it('documents custom model and effort handoffs without completion monitoring', () => {
|
||||
const skill = readSkill()
|
||||
const fullHandoffs = getSection(skill, 'Full Handoffs')
|
||||
it('owns expanded waves, launch preferences, reuse, and review boundaries', () => {
|
||||
const reference = readReference('coordinator-loop.md')
|
||||
|
||||
expect(fullHandoffs).toContain('Custom Codex model/effort handoff')
|
||||
expect(fullHandoffs).toContain(
|
||||
'does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments'
|
||||
)
|
||||
expect(fullHandoffs).toContain('codex --model gpt-5.5 -c model_reasoning_effort="xhigh"')
|
||||
expect(fullHandoffs).toContain(
|
||||
'Wait only for `tui-idle` when needed to avoid losing the prompt.'
|
||||
)
|
||||
expect(fullHandoffs).toContain('Do not monitor task completion.')
|
||||
})
|
||||
|
||||
it('clarifies sidebar lineage for same-worktree orchestrated workers', () => {
|
||||
const skill = readSkill()
|
||||
const workerTerminals = getSection(skill, 'Worker Terminals')
|
||||
|
||||
expect(workerTerminals).toContain(
|
||||
'Sidebar lineage and orchestration lifecycle are related but not identical.'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'A same-worktree worker may appear as a peer under that worktree in the sidebar'
|
||||
)
|
||||
expect(workerTerminals).toContain('while remaining a child dispatch in orchestration state')
|
||||
expect(workerTerminals).toContain(
|
||||
'only an actual child worktree creates visible parent/child worktree lineage'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree'
|
||||
)
|
||||
expect(workerTerminals).toContain('use `--no-parent` when it is not stacked')
|
||||
})
|
||||
|
||||
it('keeps review-only completions and named next-owner fixes in their lanes', () => {
|
||||
const skill = readSkill()
|
||||
|
||||
expect(skill).toContain(
|
||||
'A review-only `worker_done` reports findings; it does not authorize coordinator file edits.'
|
||||
)
|
||||
expect(skill).toContain('unless the user explicitly asked the coordinator to own fixes')
|
||||
expect(skill).toContain('dispatch or hand off fixes')
|
||||
expect(skill).toContain(
|
||||
"If the user's plan names a next owner agent " +
|
||||
'(for example, "then use opencode to create a PR")'
|
||||
)
|
||||
expect(skill).toContain('post-review corrections and PR prep belong to that named owner')
|
||||
expect(skill).toContain('the named owner edits files and creates the PR')
|
||||
})
|
||||
|
||||
it('keeps post-completion workers idle without subordinating the user', () => {
|
||||
const skill = readSkill()
|
||||
const agentGuidance = getSection(skill, 'Agent Guidance')
|
||||
|
||||
expect(agentGuidance).toContain('After sending `worker_done`, end that dispatched turn')
|
||||
expect(agentGuidance).toContain('idle at the agent prompt')
|
||||
expect(agentGuidance).toContain('Do not autonomously start more work, poll')
|
||||
expect(agentGuidance).toContain('A direct user instruction takes precedence')
|
||||
expect(agentGuidance).toContain('follow it without coordinator approval or a fresh Dispatch')
|
||||
expect(agentGuidance).toContain('never refuse it because of worker/coordinator roles')
|
||||
expect(agentGuidance).toContain("do not reuse the settled Dispatch's lifecycle IDs")
|
||||
expect(agentGuidance).toContain(
|
||||
'A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block'
|
||||
)
|
||||
expect(skill).not.toContain('post-completion polling messages')
|
||||
expect(skill).not.toContain('every 2 minutes')
|
||||
})
|
||||
|
||||
it('makes settled worker terminal release an explicit coordinator step', () => {
|
||||
const skill = readSkill()
|
||||
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
|
||||
const agentGuidance = getSection(skill, 'Agent Guidance')
|
||||
const nextAction = getSection(skill, 'Next Action')
|
||||
|
||||
expect(workerLoop).toContain(
|
||||
'# Process every message. For each accepted worker_done that is not immediately reused:\n' +
|
||||
'orca orchestration worker-release --dispatch <dispatch_id> --json'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'Acknowledge only after every message and required release decision is handled'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'read the `worker.agent_terminal_handle` field of `worker-show --dispatch <dispatch_id> --json`'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'orca orchestration worker-start --task <next_task_id> --terminal <handle> --json` so Orca ' +
|
||||
'transfers cleanup ownership to the new Dispatch'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'Run `worker-release` after both succeeded and failed `worker_done` reports unless the user ' +
|
||||
'explicitly asked to keep that worker live.'
|
||||
)
|
||||
expect(workerLoop).toContain('Release is post-completion cleanup, not cancellation')
|
||||
expect(workerLoop).toContain('orca orchestration worker-retain --dispatch <dispatch_id> --json')
|
||||
expect(workerLoop).toContain(
|
||||
'the same Dispatch can be passed to `worker-release`, which clears the requested retention'
|
||||
)
|
||||
expect(agentGuidance).toContain(
|
||||
'Coordinators must account for every settled worker terminal before waiting again or ending ' +
|
||||
'the turn'
|
||||
)
|
||||
expect(agentGuidance).toContain('released workers remain readable through `worker-read`')
|
||||
expect(nextAction).toContain(
|
||||
'After every accepted `worker_done`, either transfer the exact terminal to an immediate ' +
|
||||
'follow-up Dispatch or run `worker-release` before the next wait.'
|
||||
expect(reference).toContain('task-list --ready --brief --json')
|
||||
expect(reference).toContain('`--effort` requires `--model`')
|
||||
expect(reference).toContain('neither option combines with `--terminal`')
|
||||
expect(reference).toContain('`launch.requested` with `launch.effective`')
|
||||
expect(reference).toContain('worker-start --task <next_task_id> --terminal')
|
||||
expect(reference).toContain('A review-only `worker_done` authorizes synthesis')
|
||||
expect(squash(reference)).toContain(
|
||||
'post-review fixes and PR preparation remain with that owner'
|
||||
)
|
||||
})
|
||||
|
||||
it('documents per-invocation model and effort for supervised workers', () => {
|
||||
const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop')
|
||||
it('owns worker heartbeat, ask resume, escalation, failure, and idle', () => {
|
||||
const reference = readReference('worker-contract.md')
|
||||
|
||||
expect(workerLoop).toContain('opaque provider model id with `--model`')
|
||||
expect(workerLoop).toContain('`--effort` requires `--model`')
|
||||
expect(workerLoop).toContain('neither option can combine with `--terminal`')
|
||||
expect(workerLoop).toContain('--agent claude --model opus --effort high --json')
|
||||
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
|
||||
expect(reference).toContain('--type heartbeat')
|
||||
expect(reference).toContain('--task-id <task_id> --dispatch-id <dispatch_id>')
|
||||
expect(reference).toContain('--phase "<investigating|implementing|reviewing|waiting>"')
|
||||
expect(reference).toContain('--resume <message_id>')
|
||||
expect(reference).toContain('do not create a duplicate question')
|
||||
expect(reference).toContain('--type escalation')
|
||||
expect(reference).toContain('Send exactly one terminal report')
|
||||
expect(reference).toContain('Use `--outcome failed`')
|
||||
expect(reference).toContain('After `worker_done`, end the dispatched turn and idle')
|
||||
expect(squash(reference)).toContain(
|
||||
'ORCA orchestration check --terminal <worker_handle> --json'
|
||||
)
|
||||
expect(squash(reference)).toContain('once more immediately before `worker_done`')
|
||||
expect(squash(reference)).toContain(
|
||||
'`check` names its caller with `--terminal`, never `--from`'
|
||||
)
|
||||
expect(squash(reference)).toContain('If `check` returns `consumer_fenced`')
|
||||
expect(squash(reference)).toContain('An empty `check` never means you were replaced')
|
||||
})
|
||||
|
||||
it('never authorizes release from idle, timeout, or worker-side triggers', () => {
|
||||
const skill = readSkill()
|
||||
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
|
||||
const agentGuidance = getSection(skill, 'Agent Guidance')
|
||||
it('keeps heartbeat and worker_done recipes bound to the injected capability', () => {
|
||||
const reference = readReference('worker-contract.md')
|
||||
const recipes = [...reference.matchAll(/```text\n([\s\S]*?)```/gu)].map((match) => match[1])
|
||||
const heartbeat = recipes.find((recipe) => recipe.includes('--type heartbeat'))
|
||||
const workerDone = recipes.find((recipe) => recipe.includes('--type worker_done'))
|
||||
|
||||
// The prohibition sentence is the guard the negative patterns below rely on.
|
||||
expect(workerLoop).toContain(
|
||||
'Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, ' +
|
||||
'escalation, or rejected/stale `worker_done`.'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'do not substitute `terminal close`; follow the exact recovery action in the receipt'
|
||||
)
|
||||
expect(skill).not.toMatch(
|
||||
/release[^.]*\bon (?:a |the )?(?:tui-?idle|idle|timeout|heartbeat|question|escalation)\b/iu
|
||||
)
|
||||
expect(skill).not.toMatch(
|
||||
/\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu
|
||||
)
|
||||
expect(agentGuidance).toContain(
|
||||
'Do not autonomously start more work, poll, or attempt to close the terminal yourself'
|
||||
)
|
||||
expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu)
|
||||
for (const recipe of [heartbeat, workerDone]) {
|
||||
expect(recipe).toContain('--from <worker_handle>')
|
||||
expect(recipe).toContain('--dispatch-capability <capability>')
|
||||
expect(recipe).toContain('--task-id <task_id> --dispatch-id <dispatch_id>')
|
||||
}
|
||||
expect(workerDone).not.toContain('--files-modified')
|
||||
expect(workerDone).not.toContain('--report-path')
|
||||
expect(squash(reference)).toContain('only when applicable, using actual paths')
|
||||
expect(reference).toContain('Do not send documentation placeholders as metadata')
|
||||
})
|
||||
|
||||
it('documents @grok in the Messaging group address list', () => {
|
||||
const skill = readSkill()
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
it('owns local, folder, worktree, SSH, WSL, remote, and mixed-version placement', () => {
|
||||
const reference = readReference('placement-and-remote.md')
|
||||
|
||||
expect(messaging).toContain('`@grok`')
|
||||
expect(reference).toContain('--worktree current --agent codex')
|
||||
expect(squash(reference)).toContain(
|
||||
'A worktree selector needs the full `<repo-id>::<path>` value Orca returned, passed as `id:<newFullWorktreeId>`; a bare repo id is not a worktree id'
|
||||
)
|
||||
expect(reference).toContain('--worktree new-child')
|
||||
expect(reference).toContain('--worktree new-top-level')
|
||||
expect(reference).toContain('Folder workspaces are first-class')
|
||||
expect(reference).toContain('Remote `current` and `new-child` are invalid')
|
||||
expect(squash(reference)).toContain("`--on` selects only the worker's execution server")
|
||||
expect(squash(reference)).toContain(
|
||||
'route every follow-up, read, stop, and cleanup by Dispatch ID'
|
||||
)
|
||||
expect(reference).toContain('`live`, `unverifiable`, or `exited`')
|
||||
expect(squash(reference)).toContain('unknown stream opcodes can be silently dropped')
|
||||
expect(reference).toContain('printed `orca-ide`')
|
||||
expect(squash(reference)).toContain(
|
||||
'ORCA project setup-existing-folder --project <project_id> --host <host_id> --path <abs_path> --kind folder --json'
|
||||
)
|
||||
expect(squash(reference)).toContain('and rejects a plain directory')
|
||||
expect(reference).toContain(
|
||||
'ORCA orchestration worker-list --run <run_id> --include-remote --json'
|
||||
)
|
||||
expect(squash(reference)).toContain(
|
||||
'enumerate remote workers with `--include-remote` or every one of them reads `unverifiable`'
|
||||
)
|
||||
})
|
||||
|
||||
it('documents @cursor in the Messaging group address list', () => {
|
||||
const skill = readSkill()
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
it('owns FIFO mail, Dispatch addresses, groups, questions, and gates', () => {
|
||||
const reference = readReference('messaging-and-gates.md')
|
||||
|
||||
expect(messaging).toContain('`@cursor`')
|
||||
expect(reference).toContain('oldest FIFO Delivery')
|
||||
expect(squash(reference)).toContain('Process every row')
|
||||
expect(squash(reference)).toContain(
|
||||
'A Delivery therefore always carries the whole FIFO batch whatever its types, and a `check` without `--wait` hands that batch over unfiltered'
|
||||
)
|
||||
expect(reference).toContain('send --to dispatch:<dispatch_id>')
|
||||
for (const group of ['@all', '@grok', '@cursor', '@worktree:<id>']) {
|
||||
expect(reference).toContain(group)
|
||||
}
|
||||
expect(reference).toContain('Dispatch lifecycle messages never target groups')
|
||||
expect(reference).toContain('gate-create --task <task_id>')
|
||||
expect(reference).toContain("Do not create a gate merely to answer a worker's `ask`")
|
||||
expect(reference).toContain('successful `send` proves durable enqueue')
|
||||
expect(squash(reference)).toContain('Wake and nudge are best-effort attention only')
|
||||
expect(squash(reference)).toContain(
|
||||
'`check` names its caller with `--terminal <handle>` and is the only verb that rejects `--from`'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps agent-first launch, handle recovery, and inbox injection distinct', () => {
|
||||
const skill = readSkill()
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
const workerTerminals = getSection(skill, 'Worker Terminals')
|
||||
const agentFirstExample = workerTerminals.match(
|
||||
/```bash\norca worktree create --name <task-name> --agent codex --setup run --json\n[\s\S]*?```/
|
||||
)?.[0]
|
||||
it('owns positive-evidence retry, unknown outcomes, retain/release, and no terminal close', () => {
|
||||
const reference = readReference('recovery-and-cleanup.md')
|
||||
|
||||
expect(workerTerminals).toContain('For an allowed new worktree, use agent-first:')
|
||||
expect(workerTerminals).toContain('fallback shell + agent pair')
|
||||
expect(workerTerminals).toContain(
|
||||
'repo setup and default-terminal settings may add intentional tabs or splits'
|
||||
expect(squash(reference)).toContain('| `ready` or active | Keep waiting')
|
||||
expect(squash(reference)).toContain('| `outcome_unknown` | Inspect')
|
||||
expect(squash(reference)).toContain('| Remote contact lost | Preserve `unverifiable`')
|
||||
expect(reference).toContain('--retry-of <dispatch_id>')
|
||||
expect(squash(reference)).toContain('Placement is never silently inherited')
|
||||
expect(reference).toContain('worker-abandon --dispatch')
|
||||
expect(reference).toContain('worker-retain --dispatch')
|
||||
expect(reference).toContain('worker-release --dispatch')
|
||||
expect(squash(reference)).toContain('`release_pending` or `release_unknown`')
|
||||
expect(squash(reference)).toContain('Never substitute `terminal close`')
|
||||
})
|
||||
|
||||
it('owns the lost-response question and the request-show verdicts', () => {
|
||||
const reference = squash(readReference('recovery-and-cleanup.md'))
|
||||
|
||||
expect(reference).toContain('request-show --request <request_id> --json')
|
||||
expect(reference).toContain('--retry-request <request_id>')
|
||||
expect(reference).toContain('`completed` means the mutation already took effect')
|
||||
expect(reference).toContain('`pending` means the original mutation is still running')
|
||||
expect(reference).toContain('that is not proof nothing happened')
|
||||
expect(reference).toContain('terminal send --wait-submit <seconds>')
|
||||
})
|
||||
|
||||
it('names worker-list as the enumerating command and the agent-liveness authority', () => {
|
||||
const reference = squash(readReference('recovery-and-cleanup.md'))
|
||||
|
||||
expect(reference).toContain('ORCA orchestration worker-list --run <run_id> --json')
|
||||
expect(reference).toContain("`worker-show`'s `observation.status` is PTY liveness only")
|
||||
expect(reference).toContain(
|
||||
'`projection.attention.categories`, `projection.attention.requiresAction`'
|
||||
)
|
||||
expect(workerTerminals).toContain('without configured default tabs')
|
||||
expect(workerTerminals).toContain(
|
||||
'only after `terminal list` or `terminal show` confirms it is an unused shell'
|
||||
expect(reference).toContain('`projection.nextAction` argv')
|
||||
expect(reference).toContain('the fleet verdict decides')
|
||||
expect(reference).toContain(
|
||||
'ORCA orchestration worker-list --run <run_id> --include-remote --json'
|
||||
)
|
||||
expect(reference).toContain('reads `unverifiable` until you enumerate with `--include-remote`')
|
||||
expect(reference).toContain('follow `page.nextCursor` with `--cursor <value>`')
|
||||
})
|
||||
|
||||
it('requires positive evidence of exit before stop, abandon, retry, or release', () => {
|
||||
const reference = squash(readReference('recovery-and-cleanup.md'))
|
||||
|
||||
expect(reference).toContain('Leave the wait only on positive proof the agent stopped')
|
||||
expect(reference).toContain('`unverifiable` is always absence')
|
||||
expect(reference).toContain('Absence never authorizes stop, abandon, retry, or release')
|
||||
expect(reference).toContain(
|
||||
'| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |'
|
||||
)
|
||||
})
|
||||
|
||||
it('owns the custom topology exception without claiming process ownership', () => {
|
||||
const reference = readReference('low-level-topology.md')
|
||||
|
||||
expect(reference).toContain('only when `worker-start` cannot express')
|
||||
expect(reference).toContain('terminal create --worktree active')
|
||||
expect(reference).toContain('dispatch --task <task_id> --to <handle> --inject')
|
||||
expect(reference).toContain('operator-created process unsupervised')
|
||||
expect(squash(reference)).toContain('creates no supervised worker resource row')
|
||||
expect(reference).toContain('Use `worker-start --terminal <handle>`')
|
||||
expect(squash(reference)).toContain('never use it for an ownership handoff')
|
||||
})
|
||||
|
||||
it('owns legacy labels, read-only degradation, exact recovery, and takeover', () => {
|
||||
const reference = readReference('legacy-contract-migration.md')
|
||||
|
||||
expect(reference).toContain('[LEGACY COMPATIBILITY]')
|
||||
expect(reference).toContain('[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]')
|
||||
expect(reference).toContain('[LEGACY READ-ONLY]')
|
||||
expect(squash(reference)).toContain(
|
||||
'degrade to read-only inspection and never fall back to local execution'
|
||||
)
|
||||
expect(squash(reference)).toContain(
|
||||
'must not spawn, write, signal, stop, switch, focus, split, or inject'
|
||||
)
|
||||
expect(reference).toContain('launcher status `75`')
|
||||
expect(reference).toContain('run_legacy_local')
|
||||
expect(reference).toContain('Recovered orchestration work from a contract update')
|
||||
expect(reference).toContain('run-use --id <adopted_run_id> --takeover-legacy')
|
||||
expect(reference).toContain(
|
||||
'Never take over while the original coordinator is actively coordinating'
|
||||
)
|
||||
expect(workerTerminals).not.toContain('bare create opens a default shell')
|
||||
expect(workerTerminals).not.toContain('ends with **one** agent tab')
|
||||
expect(agentFirstExample).toBeDefined()
|
||||
expect(agentFirstExample).not.toContain('orca terminal list')
|
||||
expect(agentFirstExample).toContain('agentTerminalHandle')
|
||||
expect(agentFirstExample).toContain('startupTerminal.handle')
|
||||
expect(messaging).toContain('Prefer `agentTerminalHandle` from the create response')
|
||||
expect(messaging).toContain('Continue with the replacement handle only')
|
||||
expect(messaging).toContain('never writes to terminal input or remotely wakes another terminal')
|
||||
expect(messaging).toContain('Use `orchestration dispatch --inject` to deliver a tracked task')
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration install stub', () => {
|
||||
it('points at the version-matched guide and preserves the safe resolver', () => {
|
||||
it('preserves the safe version-matched resolver and bounded old-binary fallback', () => {
|
||||
const stub = readFileSync(stubPath, 'utf8')
|
||||
|
||||
expect(stub).toContain('discovery stub')
|
||||
expect(stub).toContain('ORCA skills get orchestration')
|
||||
// The safe CLI-resolution contract must survive in the stub, never a bare `orca`.
|
||||
expect(stub).toContain('ORCA_CLI_COMMAND')
|
||||
expect(stub).toContain('orca-dev')
|
||||
expect(stub).toContain('orca-ide')
|
||||
expect(stub).toContain('GNOME Orca screen reader')
|
||||
expect(squash(stub)).toContain('explicitly reports that `skills get` is an unknown command')
|
||||
expect(stub).toContain('do not invent commands')
|
||||
expect(stub).not.toMatch(/^orca /mu)
|
||||
})
|
||||
|
||||
it('does not tell agents to mutate orchestration state before loading the guide', () => {
|
||||
const preGuide = readFileSync(stubPath, 'utf8').split('## Load the full guide')[0]
|
||||
|
||||
expect(preGuide).not.toContain('orca orchestration task-create')
|
||||
expect(preGuide).not.toContain('orca orchestration dispatch')
|
||||
})
|
||||
|
||||
it('gives older binaries a bounded fallback instead of a dead end', () => {
|
||||
const stub = readFileSync(stubPath, 'utf8').replace(/\s+/gu, ' ')
|
||||
|
||||
expect(stub).toContain('explicitly reports that `skills get` is an unknown command')
|
||||
expect(stub).toContain('do not invent commands')
|
||||
expect(stub).toContain('ask the user rather than guessing')
|
||||
})
|
||||
|
||||
it('drops the changing command reference from the installable file', () => {
|
||||
it('performs no orchestration mutation before loading the guide', () => {
|
||||
const stub = readFileSync(stubPath, 'utf8')
|
||||
const preGuide = stub.split('## Load the full guide')[0]
|
||||
|
||||
// Version-sensitive command detail lives in the binary-served guide now, not here.
|
||||
expect(stub).not.toContain('check --wait')
|
||||
expect(stub).not.toContain('dispatch-show')
|
||||
expect(stub.length).toBeLessThan(readFileSync(guidePath, 'utf8').length)
|
||||
})
|
||||
|
||||
it('keeps the routing frontmatter identical to the guide', () => {
|
||||
const frontmatter = (text) => /^---\n[\s\S]*?\n---\n/u.exec(text)[0]
|
||||
|
||||
expect(frontmatter(readFileSync(stubPath, 'utf8'))).toBe(
|
||||
frontmatter(readFileSync(guidePath, 'utf8'))
|
||||
)
|
||||
expect(preGuide).not.toContain('orchestration task-create')
|
||||
expect(preGuide).not.toContain('orchestration dispatch')
|
||||
expect(frontmatter(stub)).toBe(frontmatter(readKernel()))
|
||||
expect(stub.length).toBeLessThan(readKernel().length)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -168,6 +168,7 @@ describe('PR E2E gate contract', () => {
|
||||
expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}')
|
||||
expect(changedRun.run).toContain('. != "tests/e2e/ssh-startup-exec-readiness.spec.ts"')
|
||||
expect(changedRun.run).toContain('. != "tests/e2e/paired-startup-exec-readiness.spec.ts"')
|
||||
expect(changedRun.run).toContain('. != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts"')
|
||||
expect(changedRun.run).toContain('if [ "${#TEST_FILES[@]}" -eq 0 ]')
|
||||
expect(changedRun.run).toContain('grep -l \'@headful\' "${TEST_FILES[@]}"')
|
||||
expect(changedRun.run).toContain('E2E_PROJECT_ARGS+=(--project=electron-headful)')
|
||||
@@ -375,14 +376,10 @@ describe('PR E2E gate contract', () => {
|
||||
// that no runner names runs nowhere and still reports green — the silent skip this file
|
||||
// exists to prevent. Asserting reachability rather than a literal keeps that true when
|
||||
// the lanes move.
|
||||
// Why these two are exempt: each needs something CI cannot give it, recorded in
|
||||
// The remaining exemption needs performance validation before routine CI, recorded in
|
||||
// run-ssh-docker-e2e.mjs so the gap stays legible rather than looking like coverage.
|
||||
const unreachableSpecs = new Set([
|
||||
'tests/e2e/ssh-docker-relay-perf.spec.ts',
|
||||
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
|
||||
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts'
|
||||
])
|
||||
// Why comments are stripped: this file's own runner lists the two exempt specs by name in a
|
||||
const unreachableSpecs = new Set(['tests/e2e/ssh-docker-relay-perf.spec.ts'])
|
||||
// Why comments are stripped: the runner documents the exempt spec by name in a
|
||||
// prose comment. A substring scan over raw text would count any spec merely *discussed* in a
|
||||
// runner as claimed by it -- the silent skip this assertion exists to catch, re-entering
|
||||
// through the documentation.
|
||||
|
||||
@@ -13,6 +13,38 @@ const NATIVE_IME_HARNESS =
|
||||
/^(?:config\/scripts\/(?:run-terminal-ibus-hangul-e2e|terminal-ime-engagement-receipt)\.mjs$|tests\/e2e\/terminal-ime-(?:boundary-probe|byte-reader|engagement-receipt)\.ts$|tests\/e2e\/terminal-(?:ibus-hangul|hangul-terminating-digit|macos-2set-korean)-native\.spec\.ts$)/
|
||||
|
||||
export const PR_E2E_SOURCE_ROUTES = [
|
||||
{
|
||||
id: 'ssh.localhost-agent-hooks',
|
||||
specs: ['tests/e2e/ssh-localhost.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^src\/(?:relay\/(?:agent-hook|relay-agent-hook-runtime|plugin-overlay)|main\/(?:agent-hooks\/|ssh\/ssh-relay-session\.ts$)|shared\/agent-hook)/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'browser-network.ssh-docker-route',
|
||||
specs: ['tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts'],
|
||||
matches: (file) =>
|
||||
file === 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts' ||
|
||||
/^tests\/e2e\/helpers\/docker-ssh-relay-(?:image|target)\.ts$/.test(file) ||
|
||||
(isProductSource(file) &&
|
||||
/^src\/main\/(?:browser\/(?:ssh-browser-network-execution-route|browser-network-deferred-socket|browser-network-execution-route|system-ssh-socks-client-socket)|ssh\/system-ssh-dynamic-forward-process)\.ts$/.test(
|
||||
file
|
||||
))
|
||||
},
|
||||
{
|
||||
id: 'terminal.windows-wsl-launch-and-paste',
|
||||
specs: [
|
||||
'tests/e2e/golden-tab-bar-agent-launch.spec.ts',
|
||||
'tests/e2e/terminal-windows-shell-paste-ownership.spec.ts'
|
||||
],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:config\/scripts\/verify-wsl-e2e-participation\.mjs$|src\/main\/(?:wsl[/-]|pty\/.*wsl|providers\/wsl)|src\/shared\/(?:wsl-|windows-terminal-shell)|src\/renderer\/src\/.*(?:terminal-paste|pty-paste)|tests\/e2e\/(?:golden-tab-bar-agent-launch\.spec|terminal-windows-shell-paste-ownership\.spec|helpers\/(?:wsl-golden-stub-agent|golden-stub-agent))|\.github\/(?:actions\/setup-wsl-test-runtime\/|workflows\/windows-wsl-e2e\.yml))/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'ephemeral-vm-runtime.rollback-readable-sidecar',
|
||||
specs: ['tests/e2e/ephemeral-vm-provisioned-root.spec.ts'],
|
||||
@@ -25,9 +57,11 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
id: 'ssh-terminal-source',
|
||||
specs: [
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-half-open-link.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-relay-stall-credential.spec.ts',
|
||||
'tests/e2e/ssh-docker-resource-accumulation.spec.ts',
|
||||
'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
|
||||
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
|
||||
@@ -227,6 +261,13 @@ export function shouldRunReusablePrE2e(changedPaths) {
|
||||
)
|
||||
}
|
||||
|
||||
export function hasWslSourceChange(changedPaths) {
|
||||
const route = PR_E2E_SOURCE_ROUTES.find(
|
||||
(candidate) => candidate.id === 'terminal.windows-wsl-launch-and-paste'
|
||||
)
|
||||
return changedPaths.some(route.matches)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
let input = ''
|
||||
process.stdin.setEncoding('utf8')
|
||||
@@ -238,6 +279,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||
process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`)
|
||||
} else if (process.argv.includes('--reusable-workflow')) {
|
||||
process.stdout.write(`${shouldRunReusablePrE2e(changedPaths)}\n`)
|
||||
} else if (process.argv.includes('--wsl-source')) {
|
||||
process.stdout.write(`${hasWslSourceChange(changedPaths)}\n`)
|
||||
} else if (process.argv.includes('--native-ime-source')) {
|
||||
process.stdout.write(`${hasNativeImeSourceChange(changedPaths)}\n`)
|
||||
} else {
|
||||
|
||||
@@ -12,6 +12,8 @@ const EXPECTED_MATRIX = {
|
||||
'.github/workflows/e2e.yml#changed-e2e': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#e2e': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#ssh-browser-network-route': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#ssh-localhost': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' },
|
||||
'.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' },
|
||||
'.github/workflows/release-mac-build.yml#build-mac': { contents: 'write' },
|
||||
|
||||
@@ -29,7 +29,7 @@ const result = spawnSync(
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'electron-headful',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
|
||||
@@ -6,6 +6,8 @@ const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1',
|
||||
ORCA_E2E_LOCAL_SSH_BROWSER: '1',
|
||||
ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER: '1',
|
||||
ORCA_E2E_WEB_CLIENT: '1'
|
||||
}
|
||||
|
||||
@@ -31,33 +33,8 @@ if (runtime.status !== 0) {
|
||||
// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in
|
||||
// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at
|
||||
// all. Recorded as a real gap, not as coverage living somewhere else.
|
||||
// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI
|
||||
// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all.
|
||||
// ssh-docker-bulk-open-freeze-repro.spec.ts — un-rotted and now measurable, and marked
|
||||
// `test.fixme` because its oracle cannot gate. Absent from this list AND skipped, so the
|
||||
// two cannot drift: it is also reachable from the changed-specs lane whenever the spec
|
||||
// itself is edited, and a wall-clock oracle that fails there is worth no more than one
|
||||
// that fails here.
|
||||
// The rot (#16764) is fixed: the stale call sites are repaired, it connects after session
|
||||
// restore instead of before, and readiness keys on the repeating flood marker rather than
|
||||
// a one-shot READY line the flood buries within ~16ms. It runs end to end and prints a
|
||||
// measurement instead of dying on a call site.
|
||||
// What it is NOT is portable. Three runs of the same measurement path:
|
||||
// developer workstation: hiddenFlood 2.1ms bulkOpen 41.5ms interaction 53.6ms
|
||||
// GitHub ubuntu runner A: hiddenFlood 1.5ms bulkOpen 2575.6ms interaction 3464.2ms
|
||||
// GitHub ubuntu runner B: hiddenFlood 0.2ms bulkOpen 397.4ms interaction 3386.7ms
|
||||
// bulkOpen swings 6.5x between two CI runs of the same code, so a fixed threshold on it is
|
||||
// a coin flip; interaction sits stably ~64x over the workstation figure because it times a
|
||||
// view remount, not the renderer freeze the issue reports, and only shares the budget
|
||||
// constant because both are milliseconds. Every failure so far is the soft budget; hard
|
||||
// has never tripped, and the relay was still streaming each time — the budget failed, not
|
||||
// the product. Same rule as ssh-docker-relay-perf above. Gating needs a distribution
|
||||
// first, then a host-relative oracle; a bigger constant, or a ratio picked from three
|
||||
// samples, is the same arbitrary number in different clothes.
|
||||
// COVERAGE GAP, recorded as such: 5 simultaneously flooding SSH panes exercise writer
|
||||
// saturation, ACK/credit accounting and per-pane polling together, and nothing else covers
|
||||
// that combination. Flip `test.fixme` back to `test` to run it. Tracked in
|
||||
// stablyai/orca#16764.
|
||||
// The bulk-open frame probe runs headed: headless Linux compositing schedules idle RAFs
|
||||
// roughly 1s apart, so it cannot measure foreground interaction against the same budget.
|
||||
//
|
||||
// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
|
||||
// grep-inverts away.
|
||||
@@ -69,27 +46,28 @@ if (runtime.status !== 0) {
|
||||
// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on
|
||||
// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment
|
||||
// has the exact wiring to flip it, and the gate contract asserts the current state.
|
||||
// - Five specs and one unit test are gated on env vars no workflow sets, so they run nowhere
|
||||
// - Two specs are gated on env vars no workflow sets, so they run nowhere
|
||||
// and are not Docker-gated, which puts them outside this file's contract:
|
||||
// local-ssh-browser-routing (ORCA_E2E_LOCAL_SSH_BROWSER)
|
||||
// ssh-client-hosted-browser-drop-reconnect (ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER)
|
||||
// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH)
|
||||
// ssh-localhost (ORCA_E2E_SSH_LOCALHOST)
|
||||
// ssh-browser-network-execution-route.docker.unit.test.ts (ORCA_RUN_DOCKER_SSH_BROWSER_E2E)
|
||||
// Runner scripts for the first four sit unused in package.json; no workflow calls them.
|
||||
// The nested-runtime runner remains unused by CI.
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/local-ssh-browser-routing.spec.ts',
|
||||
'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts',
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-ai-vault-session-history.spec.ts',
|
||||
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
|
||||
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts',
|
||||
'tests/e2e/ssh-docker-half-open-link.spec.ts',
|
||||
'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-relay-stall-credential.spec.ts',
|
||||
'tests/e2e/ssh-docker-resource-accumulation.spec.ts',
|
||||
'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
|
||||
'tests/e2e/ssh-external-image-preview.spec.ts',
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parse } from 'yaml'
|
||||
import { expect, it } from 'vitest'
|
||||
import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
|
||||
|
||||
const root = resolve(import.meta.dirname, '../..')
|
||||
const workflow = parse(readFileSync(join(root, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
const runner = readFileSync(join(root, 'config/scripts/run-ssh-docker-e2e.mjs'), 'utf8')
|
||||
|
||||
it('routes SSH browser specs to a lane that enables their opt-ins', () => {
|
||||
const changedRun = workflow.jobs['changed-e2e'].steps.find(
|
||||
(step) => step.name === 'Run changed E2E specs'
|
||||
)
|
||||
for (const [spec, flag] of [
|
||||
['tests/e2e/local-ssh-browser-routing.spec.ts', 'ORCA_E2E_LOCAL_SSH_BROWSER'],
|
||||
[
|
||||
'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts',
|
||||
'ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER'
|
||||
]
|
||||
]) {
|
||||
expect(runner).toContain(`'${spec}'`)
|
||||
expect(runner).toContain(`${flag}: '1'`)
|
||||
expect(workflow.jobs['ssh-docker-watcher-isolation'].if).toContain(spec)
|
||||
expect(changedRun.run).toContain(`. != "${spec}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it('executes both Docker network routes in a Node job with their opt-in enabled', () => {
|
||||
const spec = 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts'
|
||||
const job = workflow.jobs['ssh-browser-network-route']
|
||||
const install = job.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const run = job.steps.find(
|
||||
(step) => step.name === 'Run Docker SSH browser network route journeys'
|
||||
)
|
||||
expect(job['runs-on']).toBe('ubuntu-latest')
|
||||
expect(job.if).toContain("inputs.test_files == ''")
|
||||
expect(job.if).toContain(spec)
|
||||
expect(install.with['native-runtime']).toBe('node')
|
||||
expect(run.env.ORCA_RUN_DOCKER_SSH_BROWSER_E2E).toBe('1')
|
||||
expect(run.run).toContain(`vitest run --config config/vitest.config.ts ${spec}`)
|
||||
expect(run['continue-on-error']).toBeUndefined()
|
||||
expect(
|
||||
workflow.jobs['changed-e2e'].steps.find((step) => step.name === 'Run changed E2E specs').run
|
||||
).toContain(`. != "${spec}"`)
|
||||
for (const changed of [
|
||||
spec,
|
||||
'src/main/browser/ssh-browser-network-execution-route.ts',
|
||||
'src/main/browser/browser-network-deferred-socket.ts',
|
||||
'src/main/browser/browser-network-execution-route.ts',
|
||||
'src/main/browser/system-ssh-socks-client-socket.ts',
|
||||
'src/main/ssh/system-ssh-dynamic-forward-process.ts',
|
||||
'tests/e2e/helpers/docker-ssh-relay-target.ts',
|
||||
'tests/e2e/helpers/docker-ssh-relay-image.ts'
|
||||
]) {
|
||||
expect(selectPrE2eSpecs([changed])).toContain(spec)
|
||||
}
|
||||
expect(selectPrE2eSpecs(['src/renderer/src/components/Unrelated.tsx'])).not.toContain(spec)
|
||||
expect(selectPrE2eSpecs(['tests/e2e/helpers/docker-ssh-relay-terminal-tabs.ts'])).not.toContain(
|
||||
spec
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { parse } from 'yaml'
|
||||
import { expect, it } from 'vitest'
|
||||
import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
|
||||
|
||||
const workflow = parse(
|
||||
readFileSync(resolve(import.meta.dirname, '../../.github/workflows/e2e.yml'), 'utf8')
|
||||
)
|
||||
|
||||
it('gives the localhost SSH journey its same-filesystem server and agent prerequisite', () => {
|
||||
const spec = 'tests/e2e/ssh-localhost.spec.ts'
|
||||
const job = workflow.jobs['ssh-localhost']
|
||||
expect(job.if).toContain("inputs.test_files == ''")
|
||||
expect(job.if).toContain(spec)
|
||||
expect(job['runs-on']).toBe('ubuntu-latest')
|
||||
expect(job.needs).toEqual(['build', 'prepare-native-cache'])
|
||||
const setup = job.steps.find((step) => step.name === 'Start isolated localhost SSH server')
|
||||
expect(setup.run).toContain('ListenAddress 127.0.0.1')
|
||||
expect(setup.run).toContain('PasswordAuthentication no')
|
||||
expect(setup.run).toContain('UsePAM yes')
|
||||
expect(setup.run).toContain('mkdir -p "$HOME/.pi/agent"')
|
||||
for (const key of ['ORCA_E2E_SSH_PORT', 'ORCA_E2E_SSH_USER', 'ORCA_E2E_SSH_IDENTITY_FILE']) {
|
||||
expect(setup.run).toContain(key)
|
||||
}
|
||||
const run = job.steps.find((step) => step.name === 'Run localhost SSH terminal and hook journey')
|
||||
expect(run.env.ORCA_E2E_SSH_LOCALHOST).toBe('1')
|
||||
expect(run.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS).toBe('1')
|
||||
expect(run.run).toContain(spec)
|
||||
expect(run.run).toContain('--project=electron-headless')
|
||||
expect(run.run).not.toContain('--retries')
|
||||
expect(run['continue-on-error']).toBeUndefined()
|
||||
expect(
|
||||
workflow.jobs['changed-e2e'].steps.find((step) => step.name === 'Run changed E2E specs').run
|
||||
).toContain(`. != "${spec}"`)
|
||||
})
|
||||
|
||||
it('selects the localhost journey for its remote hook authorities', () => {
|
||||
const spec = 'tests/e2e/ssh-localhost.spec.ts'
|
||||
for (const file of [
|
||||
'src/relay/relay-agent-hook-runtime.ts',
|
||||
'src/relay/agent-hook-server.ts',
|
||||
'src/relay/plugin-overlay.ts',
|
||||
'src/main/agent-hooks/server.ts',
|
||||
'src/main/ssh/ssh-relay-session.ts',
|
||||
'src/shared/agent-hook-relay.ts'
|
||||
]) {
|
||||
expect(existsSync(resolve(import.meta.dirname, '../..', file)), file).toBe(true)
|
||||
expect(selectPrE2eSpecs([file])).toContain(spec)
|
||||
}
|
||||
expect(selectPrE2eSpecs(['src/renderer/src/components/Unrelated.tsx'])).not.toContain(spec)
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
export const WSL_TEST_TITLES = [
|
||||
'tab-bar + menu launches an agent inside WSL @tab-bar-agent-launch-golden',
|
||||
'WSL terminal keyboard paste preserves Linux shell content with one PTY owner',
|
||||
'existing WSL terminal keeps paste runtime after default shell changes'
|
||||
]
|
||||
|
||||
export function verifyWslParticipation(report) {
|
||||
const stats = report?.stats
|
||||
if (
|
||||
!stats ||
|
||||
stats.expected !== 9 ||
|
||||
stats.skipped !== 0 ||
|
||||
stats.unexpected !== 0 ||
|
||||
stats.flaky !== 0 ||
|
||||
report.errors?.length
|
||||
) {
|
||||
throw new Error(`WSL participation failed: ${JSON.stringify(stats)}`)
|
||||
}
|
||||
const counts = new Map(WSL_TEST_TITLES.map((title) => [title, 0]))
|
||||
const visit = (suites) => {
|
||||
for (const suite of suites ?? []) {
|
||||
for (const spec of suite.specs ?? []) {
|
||||
if (!counts.has(spec.title)) {
|
||||
throw new Error(`Unexpected WSL scenario: ${spec.title}`)
|
||||
}
|
||||
for (const test of spec.tests ?? []) {
|
||||
if (
|
||||
test.expectedStatus !== 'passed' ||
|
||||
test.results?.length !== 1 ||
|
||||
test.results[0].status !== 'passed'
|
||||
) {
|
||||
throw new Error(`WSL scenario did not pass without retries: ${spec.title}`)
|
||||
}
|
||||
counts.set(spec.title, counts.get(spec.title) + 1)
|
||||
}
|
||||
}
|
||||
visit(suite.suites)
|
||||
}
|
||||
}
|
||||
visit(report.suites)
|
||||
for (const [title, count] of counts) {
|
||||
if (count !== 3) {
|
||||
throw new Error(`WSL scenario requires three executions: ${title} (${count})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
verifyWslParticipation(JSON.parse(readFileSync(process.argv[2], 'utf8')))
|
||||
console.log('All three WSL scenarios passed three times without skips or retries.')
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { verifyWslParticipation, WSL_TEST_TITLES } from './verify-wsl-e2e-participation.mjs'
|
||||
|
||||
function report() {
|
||||
return {
|
||||
stats: { expected: 9, skipped: 0, unexpected: 0, flaky: 0 },
|
||||
suites: [
|
||||
{
|
||||
suites: [
|
||||
{
|
||||
specs: WSL_TEST_TITLES.map((title) => ({
|
||||
title,
|
||||
tests: Array.from({ length: 3 }, () => ({
|
||||
expectedStatus: 'passed',
|
||||
results: [{ status: 'passed' }]
|
||||
}))
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
describe('WSL participation', () => {
|
||||
it('accepts all three named scenarios executed three times', () => {
|
||||
expect(() => verifyWslParticipation(report())).not.toThrow()
|
||||
})
|
||||
it.each(['skipped', 'unexpected', 'flaky'])('rejects a nonzero %s result', (key) => {
|
||||
const value = report()
|
||||
value.stats[key] = 1
|
||||
expect(() => verifyWslParticipation(value)).toThrow('participation failed')
|
||||
})
|
||||
it('rejects missing scenarios even when aggregate counts claim nine passes', () => {
|
||||
const value = report()
|
||||
value.suites[0].suites[0].specs.pop()
|
||||
expect(() => verifyWslParticipation(value)).toThrow('requires three executions')
|
||||
})
|
||||
it('rejects an unrelated scenario substituted for an expected scenario', () => {
|
||||
const value = report()
|
||||
value.suites[0].suites[0].specs[0].title = 'native shell passes'
|
||||
expect(() => verifyWslParticipation(value)).toThrow('Unexpected WSL scenario')
|
||||
})
|
||||
it('rejects a pass obtained after a failed attempt', () => {
|
||||
const value = report()
|
||||
value.suites[0].suites[0].specs[0].tests[0].results.unshift({ status: 'failed' })
|
||||
expect(() => verifyWslParticipation(value)).toThrow('without retries')
|
||||
})
|
||||
it('rejects missing report content', () => {
|
||||
expect(() => verifyWslParticipation({})).toThrow('participation failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { hasWslSourceChange, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
|
||||
|
||||
const read = (path) => readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8')
|
||||
|
||||
describe('real WSL terminal lane', () => {
|
||||
it.each([
|
||||
'config/scripts/verify-wsl-e2e-participation.mjs',
|
||||
'src/main/wsl-availability.ts',
|
||||
'src/main/wsl/wsl-runner.ts',
|
||||
'src/main/pty/wsl-orca-env.ts',
|
||||
'src/shared/wsl-login-shell-command.ts',
|
||||
'src/shared/windows-terminal-shell.ts',
|
||||
'tests/e2e/helpers/wsl-golden-stub-agent.ts',
|
||||
'tests/e2e/golden-tab-bar-agent-launch.spec.ts',
|
||||
'tests/e2e/terminal-windows-shell-paste-ownership.spec.ts',
|
||||
'.github/actions/setup-wsl-test-runtime/setup.ps1',
|
||||
'.github/workflows/windows-wsl-e2e.yml'
|
||||
])('routes %s to both WSL sentinels', (path) => {
|
||||
expect(hasWslSourceChange([path])).toBe(true)
|
||||
expect(selectPrE2eSpecs([path])).toEqual(
|
||||
expect.arrayContaining([
|
||||
'tests/e2e/golden-tab-bar-agent-launch.spec.ts',
|
||||
'tests/e2e/terminal-windows-shell-paste-ownership.spec.ts'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'docs/reference/wsl-command-execution.md',
|
||||
'src/main/wsl-availability.test.ts',
|
||||
'src/main/ssh/connection.ts'
|
||||
])('excludes unrelated or unit-only change %s', (path) => {
|
||||
expect(hasWslSourceChange([path])).toBe(false)
|
||||
})
|
||||
|
||||
it('runs the reusable lane at the immutable PR head', () => {
|
||||
const pr = parse(read('.github/workflows/pr.yml'))
|
||||
expect(pr.jobs.windows_wsl.if).toBe("needs.code_paths.outputs.wsl_source_changed == 'true'")
|
||||
expect(pr.jobs.windows_wsl.with.ref).toBe('${{ github.event.pull_request.head.sha }}')
|
||||
const detector = pr.jobs['code_paths'].steps.find(
|
||||
(step) => step.name === 'Filter changed E2E specs'
|
||||
)
|
||||
expect(detector.run).toContain(
|
||||
'WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR'
|
||||
)
|
||||
expect(detector.run).toContain(
|
||||
'"$WSL_CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --wsl-source'
|
||||
)
|
||||
const workflow = parse(read('.github/workflows/windows-wsl-e2e.yml'))
|
||||
const steps = workflow.jobs['wsl-terminal'].steps
|
||||
expect(steps[0].with.ref).toBe('${{ inputs.ref || github.sha }}')
|
||||
expect(steps.some((step) => step.uses === './.github/actions/setup-wsl-test-runtime')).toBe(
|
||||
true
|
||||
)
|
||||
const exercise = steps.find((step) => step.name === 'Exercise real WSL launch and paste')
|
||||
expect(exercise.run.split(/\s+/).filter((arg) => arg.startsWith('--repeat-each='))).toEqual([
|
||||
'--repeat-each=3'
|
||||
])
|
||||
expect(exercise.run).toContain('--grep "WSL"')
|
||||
const receipt = steps.find((step) => step.name === 'Require all nine WSL executions')
|
||||
expect(receipt.if).toBe('always()')
|
||||
expect(receipt.run).toBe(
|
||||
'node config/scripts/verify-wsl-e2e-participation.mjs test-results/wsl-results.json'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,7 @@
|
||||
"../src/main/codex/codex-app-server-capability-cache.ts",
|
||||
"../src/main/codex/codex-app-server-capability-signal.ts",
|
||||
"../src/main/codex/codex-app-server-client.ts",
|
||||
"../src/main/codex/codex-app-server-record-reader.ts",
|
||||
"../src/main/codex/codex-app-server-session.ts",
|
||||
"../src/main/codex/codex-config-mirror.ts",
|
||||
"../src/main/codex/codex-config-path-reference-rewrite.ts",
|
||||
|
||||
@@ -145,7 +145,7 @@ orca orchestration ask \
|
||||
--json
|
||||
```
|
||||
|
||||
With `--json`, `ask` prints a single JSON object so workers can pipe it to `jq -r .answer`.
|
||||
With `--json`, `ask` prints the standard `{id, ok, result, _meta}` envelope, so workers read the answer with `jq -r .result.answer`.
|
||||
|
||||
## Decision gates
|
||||
|
||||
|
||||
@@ -290,6 +290,8 @@ List bundled guides, print a version-matched guide, or install/update hybrid ski
|
||||
```bash
|
||||
orca skills list
|
||||
orca skills get orca-cli
|
||||
orca skills get orchestration --references
|
||||
orca skills get orchestration --reference recovery-and-cleanup
|
||||
orca skills get orchestration --full
|
||||
orca skills install --skill orca-cli --skill orchestration
|
||||
orca skills install --all --dry-run
|
||||
|
||||
@@ -39,10 +39,14 @@ After `npx skills add`, agents see a short stub that says:
|
||||
```bash
|
||||
orca skills list
|
||||
orca skills get orca-cli
|
||||
orca skills get orchestration --references
|
||||
orca skills get orchestration --reference recovery-and-cleanup
|
||||
orca skills get orchestration --full
|
||||
orca skills get orca-linear --json
|
||||
```
|
||||
|
||||
A guide's action gates name conditional references. `--reference <name>` prints one of them alone, so an agent pays for the kernel plus that document instead of the whole package; `--references` lists the names. The name may be bare (`recovery-and-cleanup`) or spelled as the guide writes it (`references/recovery-and-cleanup.md`). `--full` still prints the kernel plus every reference.
|
||||
|
||||
Add `--json` when an agent needs deterministic output for automation. `skills show` is an alias for `skills get`.
|
||||
|
||||
## Keep skills up to date
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 15
|
||||
"versionCode": 16
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -131,17 +131,17 @@
|
||||
"name": "orchestration",
|
||||
"sourcePath": "skills/orchestration",
|
||||
"releaseRevision": 29,
|
||||
"packageDigest": "689e31d84256aded123c801eaa87413474943a9a30d96bff9a19d0a321aefb54",
|
||||
"gitTreeSha": "902cc33dd65730b32ac234dd0ae7166d75498b46",
|
||||
"packageDigest": "894d6f421cb96c2777e73055df867e2fdfca8dd05f0340d50a93cb33a8e85e3a",
|
||||
"gitTreeSha": "da5b5c3f78634bbe12922e526ea227509faa9de0",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 4398,
|
||||
"size": 4539,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18",
|
||||
"textNormalizedSha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18",
|
||||
"identitySha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18"
|
||||
"exactSha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954",
|
||||
"textNormalizedSha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954",
|
||||
"identitySha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1046,17 +1046,17 @@
|
||||
},
|
||||
{
|
||||
"releaseRevision": 29,
|
||||
"packageDigest": "689e31d84256aded123c801eaa87413474943a9a30d96bff9a19d0a321aefb54",
|
||||
"gitTreeSha": "902cc33dd65730b32ac234dd0ae7166d75498b46",
|
||||
"packageDigest": "894d6f421cb96c2777e73055df867e2fdfca8dd05f0340d50a93cb33a8e85e3a",
|
||||
"gitTreeSha": "da5b5c3f78634bbe12922e526ea227509faa9de0",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 4398,
|
||||
"size": 4539,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18",
|
||||
"textNormalizedSha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18",
|
||||
"identitySha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18"
|
||||
"exactSha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954",
|
||||
"textNormalizedSha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954",
|
||||
"identitySha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ ORCA terminal read --terminal <handle> --json
|
||||
ORCA terminal read --terminal <handle> --cursor <cursor> --limit 1000 --json
|
||||
ORCA terminal read --json
|
||||
ORCA terminal send --terminal <handle> --text "continue" --enter --json
|
||||
ORCA terminal send --terminal <handle> --text "continue" --enter --wait-submit 10 --json
|
||||
ORCA terminal send --text "echo hello" --enter --json
|
||||
ORCA terminal wait --terminal <handle> --for exit --timeout-ms 5000 --json
|
||||
ORCA terminal wait --terminal <handle> --for tui-idle --timeout-ms 300000 --json
|
||||
@@ -204,7 +205,11 @@ Terminal rules:
|
||||
- `terminal list --json` omits `visualLayouts` to keep the common agent payload bounded. Add `--include-visual-layouts` only when tab and pane topology is required.
|
||||
- Use `terminal read` before `terminal send` unless the next input is obvious.
|
||||
- Use `terminal send` only for direct terminal input or one-off prompts where no task state, inbox, or reply tracking is needed.
|
||||
- For structured coordination, invoke the `orchestration` skill; it uses `orca orchestration ...` commands for messages, handoffs, task DAGs, dispatches, inbox/reply flows, and coordinator loops. A receiving agent can run `orca orchestration check --unread --format` to render its unread mail in agent-readable form; this checks the caller's inbox and does not remotely deliver input to another terminal.
|
||||
- A text-plus-Enter agent prompt returns a durable request ID and additive stages: `input_accepted`, then `turn_started` once the agent's turn is proven. Raw text-only, bare Enter, interrupt, and terminal query replies keep their existing direct-input behavior.
|
||||
- A default send observes for 0 seconds, so a receipt that stops at `input_accepted` is expected and its warning means "unproven", not "failed". Pass `--wait-submit` when you need proof of submission.
|
||||
- `--wait-submit <seconds>` only observes the same accepted prompt. A timeout returns queued/input-accepted truth without resending; after an ambiguous transport failure, repeat the exact command with the reported `--retry-request <id>`. Both text and `--json` receipts carry the same `warnings`.
|
||||
- An older host reports a legacy `old-host` fallback for an ordinary send and refuses `--wait-submit` or `--retry-request` before input, because it cannot provide durable replay.
|
||||
- For structured coordination, invoke the `orchestration` skill; it uses `orca orchestration ...` commands for messages, handoffs, task DAGs, dispatches, inbox/reply flows, and coordinator loops. A receiving agent can run `orca orchestration check --peek --format --json` to render its unread mail in agent-readable form; this checks the caller's inbox and does not remotely deliver input to another terminal.
|
||||
- Use `terminal create --worktree active --command "<agent>"` for a fresh agent in the current worktree. Use `worktree create --agent <agent>` only for a separate checkout (agent in the first terminal — do not also `terminal create` the same agent).
|
||||
- Use `terminal wait --for tui-idle` for agent CLIs such as Claude Code, Gemini, Codex, OMP, Pi, and Grok; always pass `--timeout-ms`.
|
||||
- Terminal handles are runtime-scoped. Use `startupTerminal.handle` as the sole agent handle when `worktree create --agent` returns it; if Orca restarts, omits the handle, or returns `terminal_handle_stale`, reacquire with `terminal list` and continue with the replacement only.
|
||||
|
||||
+182
-430
@@ -1,449 +1,201 @@
|
||||
---
|
||||
name: orchestration
|
||||
description: >-
|
||||
Use Orca orchestration for structured multi-agent coordination: threaded
|
||||
messages, blocking ask/reply flows, task dispatch, worker_done/escalation
|
||||
waits, task DAGs, decision gates, or coordinator loops. Use `orca-cli`
|
||||
instead for full ownership handoffs, including requests phrased as "hand
|
||||
off", "handoff", "handover", "give this to another agent", or "another
|
||||
worktree" when the user did not explicitly ask to supervise, monitor, wait
|
||||
for results, or coordinate a DAG. Use `orca-cli` for terminal control,
|
||||
lightweight terminal prompts, shell commands, Orca worktree management,
|
||||
reading or waiting on terminals, and the Orca embedded browser. Use Computer
|
||||
Use for external browser windows, webviews, Orca app UI, or desktop UI
|
||||
outside Orca's embedded browser only when the task requires OS/window-level
|
||||
control such as focus, menus, dialogs, coordinates, or screenshots. Use
|
||||
`orca-cli` for Orca's embedded pages and a page-automation tool such as
|
||||
Playwright or CDP for external pages.
|
||||
Coordinate supervised Orca workers: threaded messages, blocking ask/reply,
|
||||
task dispatch, worker_done/escalation waits, task DAGs, decision gates,
|
||||
coordinator loops, and decomposing work across agents. Use `orca-cli` for full
|
||||
ownership handoffs — "hand off", "handoff", "handover", "give this to another
|
||||
agent", "another worktree" — unless asked to supervise, monitor, or coordinate
|
||||
a DAG, and for terminal control, lightweight terminal prompts, shell commands,
|
||||
Orca worktree management, and reading or waiting on terminals. Use Computer
|
||||
Use for external browser windows, webviews, Orca app UI, or desktop UI outside
|
||||
Orca's embedded browser only when the task requires OS/window-level control
|
||||
such as focus, menus, dialogs, coordinates, or screenshots. Use `orca-cli` for
|
||||
Orca's embedded pages and a page-automation tool such as Playwright or CDP for
|
||||
external pages.
|
||||
---
|
||||
|
||||
# Orca Inter-Agent Orchestration
|
||||
# Orca orchestration
|
||||
|
||||
Orchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.
|
||||
Orchestration is Orca's structured coordination layer. It records who owns work,
|
||||
which attempt is authoritative, and when supervised work has settled.
|
||||
|
||||
Use this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.
|
||||
## Outcome
|
||||
|
||||
## Tool Boundary
|
||||
**Result:** every in-scope Task has one explicit outcome and every settled worker
|
||||
terminal has a next owner or cleanup decision. **Next consumer:** the user who
|
||||
requested supervision. **Done:** all expected Dispatches have settled, every
|
||||
delivered message was processed before acknowledgment, each settled worker was
|
||||
reused, explicitly retained, or released, and the turn ends only when the report
|
||||
to that user names, per Task, its outcome, the evidence behind it, and any
|
||||
unresolved blocker.
|
||||
|
||||
If a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.
|
||||
**Safe failure:** preserve work and authority and report the state as unknown or
|
||||
`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,
|
||||
and only an accepted settlement authorizes release. Every other observation,
|
||||
absence included, is a checkpoint.
|
||||
|
||||
Do not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.
|
||||
## Classify the role
|
||||
|
||||
Before claiming a worker was orchestrated, verify the task/dispatch exists:
|
||||
| Current context | Role | Route |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |
|
||||
| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |
|
||||
| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |
|
||||
| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |
|
||||
| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |
|
||||
| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |
|
||||
|
||||
```bash
|
||||
orca orchestration task-list --json
|
||||
orca orchestration dispatch-show --task <task_id> --json
|
||||
Model or effort selection does not make a handoff supervised. Never substitute a
|
||||
non-Orca subagent tool when Orca orchestration provenance was requested.
|
||||
|
||||
## Authority and safety floor
|
||||
|
||||
- A Run is a durable namespace and coordinator inbox; it does not schedule or
|
||||
place workers. A Task is work. A Dispatch is one authoritative Task attempt.
|
||||
- Lifecycle authority comes from the active Dispatch, not a terminal title,
|
||||
copied ID, old database row, provider transcript, or visible pane.
|
||||
- Workers use the exact executable, handle, capability, Task ID, and Dispatch ID
|
||||
in the live preamble. Never reconstruct, translate, or broaden those arguments.
|
||||
- After remote start, address the worker by Dispatch ID. The execution host owns
|
||||
process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts
|
||||
`live` / `unverifiable` / `exited`; contact loss is not process death.
|
||||
- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict
|
||||
for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live
|
||||
terminal can still hold a dead or stuck agent.
|
||||
- Folder workspaces are valid; never require Git or assume a worktree.
|
||||
- Clients and remote servers update independently. Treat unknown optional fields
|
||||
as absent. A new stream operation requires advertised capability because old
|
||||
decoders may silently drop unknown opcodes. Never fall back to local execution
|
||||
when remote authority or capability is unproven.
|
||||
- Use the executable you used to run `skills get` for the entire run. In the
|
||||
examples below, replace `ORCA` with it; do not create a shell variable or run
|
||||
`ORCA` literally. If it fails, report that exact error instead of switching.
|
||||
- A successful `orchestration send` proves durable enqueue; its wake or nudge is
|
||||
best-effort attention only and does not prove the recipient read or accepted it.
|
||||
|
||||
## Worker obligations
|
||||
|
||||
The injected preamble is authoritative. A dispatched worker must:
|
||||
|
||||
1. Do only the current Task and use the preamble's `ask` command for a blocking
|
||||
coordinator question. Never open a local question TUI the coordinator cannot
|
||||
answer. Resume the same message ID after an ask timeout.
|
||||
2. Send heartbeats only at the cadence in the preamble. A heartbeat proves
|
||||
liveness, not completion.
|
||||
3. Read coordinator follow-ups at each natural checkpoint — before starting a
|
||||
new file, after a test run — and once more immediately before `worker_done`:
|
||||
`ORCA orchestration check --terminal <your_handle> --json`.
|
||||
4. Send `worker_done` exactly once, from the dispatched terminal, with a
|
||||
three-sentence executive summary, both lifecycle IDs, and explicit
|
||||
`--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.
|
||||
5. Append `--files-modified` and `--report-path` only with real values when
|
||||
applicable. After `worker_done`, end the dispatched turn and idle; do not poll
|
||||
or start new work.
|
||||
|
||||
A direct user instruction after completion starts new user-owned work and takes
|
||||
precedence over the idle rule. Do not reuse the settled lifecycle IDs.
|
||||
|
||||
## Canonical supervised loop
|
||||
|
||||
Confirm the runtime, bind one Run, and start the full independent wave before
|
||||
waiting. `worker-start --spec` creates the Task and its attempt in one call:
|
||||
|
||||
```text
|
||||
ORCA status --json
|
||||
ORCA orchestration run-create --objective "<objective>" --json
|
||||
ORCA orchestration worker-start --spec "<worker A task>" --worktree current --agent codex --json
|
||||
ORCA orchestration worker-start --spec "<worker B task>" --worktree current --agent claude --json
|
||||
ORCA orchestration check --wait --types "worker_done,escalation,question" --timeout-ms 900000 --json
|
||||
```
|
||||
|
||||
If the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.
|
||||
If `worker-start` exits non-zero, do not relaunch. Read the receipt's
|
||||
`failedStage` and `residualResources`, then load
|
||||
`references/recovery-and-cleanup.md`.
|
||||
|
||||
## When To Use
|
||||
Use `task-create` plus `worker-start --task <task_id>` for planned fan-out with
|
||||
dependencies or a retry of a known Task. Use dependencies only for real ordering
|
||||
and prefer parallel waves over chains deeper than three or four steps; nested
|
||||
workers obey the depth limit, and a new Run does not reset the caller's depth.
|
||||
|
||||
- Send/reply/ask between agent terminals with persistent messages.
|
||||
- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.
|
||||
- Track task DAGs with dependencies.
|
||||
- Run coordinator loops or decision gates.
|
||||
A consuming `check` names its caller with `--terminal <handle>`, never `--from`;
|
||||
omit it inside the coordinator's own Orca terminal. It returns the bound Run's
|
||||
oldest FIFO Delivery and replays that batch until acknowledged. Process every
|
||||
message: reply to questions, validate each `worker_done` against the expected
|
||||
active Dispatch, and decide each settled terminal's next owner before the ack:
|
||||
|
||||
Do not use orchestration merely because the user says "hand off", "handoff", "handover", "give this to another agent", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- `orca status --json` should show a running runtime.
|
||||
- `orca` must be on PATH (`orca-ide` on Linux).
|
||||
- The orchestration experimental feature must be enabled in Settings > Experimental.
|
||||
- `orca orchestration` commands are RPC calls to the running Orca runtime.
|
||||
|
||||
## Contract Migration
|
||||
|
||||
Orca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.
|
||||
|
||||
Treat the authority label on injected or formatted messages as definitive:
|
||||
|
||||
- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.
|
||||
- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.
|
||||
- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.
|
||||
- An unlabeled current message uses the current guide and current grammar.
|
||||
|
||||
An explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.
|
||||
|
||||
Database provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.
|
||||
|
||||
Compatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.
|
||||
|
||||
When a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.
|
||||
|
||||
On packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume <message_id>` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.
|
||||
|
||||
Legacy inspection remains available without consuming mail:
|
||||
|
||||
```bash
|
||||
orca orchestration run-list --json
|
||||
# run_legacy_local is an empty audit tombstone after adoption.
|
||||
orca orchestration run-show --id run_legacy_local --json
|
||||
# In run-list, find the ordinary Run whose objective is:
|
||||
# "Recovered orchestration work from a contract update"
|
||||
orca orchestration run-show --id <adopted_run_id> --json
|
||||
orca orchestration task-list --run <adopted_run_id> --json
|
||||
orca orchestration inbox --full --json
|
||||
orca orchestration check --terminal <legacy_handle> --peek --format --json
|
||||
orca terminal read --terminal <legacy_handle> --json
|
||||
orca terminal wait --terminal <legacy_handle> --for tui-idle --timeout-ms 60000 --json
|
||||
```text
|
||||
ORCA orchestration reply --id <message_id> --body "<answer>" --json
|
||||
ORCA orchestration worker-release --dispatch <dispatch_id> --json
|
||||
ORCA orchestration check --ack <delivery_id> --wait --types "worker_done,escalation,question" --timeout-ms 900000 --json
|
||||
```
|
||||
|
||||
If the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:
|
||||
|
||||
```bash
|
||||
orca orchestration run-use --id <adopted_run_id> --takeover-legacy --json
|
||||
orca orchestration check --run <adopted_run_id> --json
|
||||
```
|
||||
|
||||
Takeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.
|
||||
|
||||
Do not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.
|
||||
|
||||
## Ownership
|
||||
|
||||
New orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.
|
||||
|
||||
Classify inherited context before sending lifecycle messages:
|
||||
|
||||
- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.
|
||||
- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.
|
||||
- Classify requests containing "hand off", "handoff", "handover", "give this to another agent", "give this to another worktree", "another agent", or "another worktree" as full handoffs by default, even when the user names a custom model or reasoning effort.
|
||||
- Use supervised orchestration only when the user explicitly asks you to "supervise", "monitor", "wait", "track completion", "wait for worker_done", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.
|
||||
- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.
|
||||
- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.
|
||||
- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.
|
||||
- If the user's plan names a next owner agent (for example, "then use opencode to create a PR"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.
|
||||
|
||||
If unclear, inspect orchestration state before sending lifecycle messages:
|
||||
|
||||
```bash
|
||||
orca orchestration task-list --json
|
||||
orca terminal list --json
|
||||
# If inherited context includes a task id:
|
||||
orca orchestration dispatch-show --task <task_id> --json
|
||||
```
|
||||
|
||||
## Messaging
|
||||
|
||||
```bash
|
||||
orca orchestration send --subject <text> [--to <run:id|dispatch:id|legacy_handle>] [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--json]
|
||||
orca orchestration check [--terminal <handle>] [--ack <delivery_id>] [--peek|--all] [--types <type,...>] [--format] [--wait] [--timeout-ms <n>] [--json]
|
||||
orca orchestration reply --id <msg_id> --body <text> [--from <handle>] [--json]
|
||||
orca orchestration ask (--question <text>|--resume <msg_id>) [--options <csv>] [--timeout-ms <n>] [--from <handle>] [--json]
|
||||
orca orchestration inbox [--limit <n>] [--json]
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.
|
||||
- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack <delivery_id>`. Process every message before acknowledging; `check --ack <id> --wait` acknowledges, checks, and waits in one operation.
|
||||
- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.
|
||||
- Use `dispatch:<id>` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.
|
||||
- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.
|
||||
- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.
|
||||
- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.
|
||||
- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms <n>` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id <msg_id> --body <answer> --json`, then acknowledge and keep waiting.
|
||||
- `check --json` prints exactly one JSON document on stdout. While `--wait` blocks it also prints keepalive lines (`{"_keepalive":true,...}`) to stderr so you can tell the process is alive; those are never on stdout. Do not merge the streams before a parser — `check --wait --json 2>&1 | <parser>` fails with "Extra data: line 2". Pipe stdout only.
|
||||
- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.
|
||||
- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.
|
||||
- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.
|
||||
- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.
|
||||
- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:<id>`.
|
||||
- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.
|
||||
- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.
|
||||
- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.
|
||||
- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.
|
||||
- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.
|
||||
|
||||
## Tasks And Dispatch
|
||||
|
||||
A Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.
|
||||
|
||||
```bash
|
||||
orca orchestration run-create --objective <text> --json
|
||||
orca orchestration task-create --spec <text> [--deps <json_array>] [--parent <task_id>] [--json]
|
||||
orca orchestration task-list [--status <status>] [--ready] [--brief] [--json]
|
||||
orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--json]
|
||||
orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--inject] [--json]
|
||||
orca orchestration dispatch-show --task <task_id> [--json]
|
||||
```
|
||||
|
||||
Task statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.
|
||||
|
||||
Dispatch rules:
|
||||
|
||||
- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.
|
||||
- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal <handle> --text <prompt> --enter --json`.
|
||||
- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.
|
||||
- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.
|
||||
|
||||
`dispatch` and `worker-start` refuse the following preflight cases with a stable `error.code`; read it before choosing a recovery, and treat `error.data.nextSteps` as the exact recovery text. Older hosts may omit `data`, so treat every field as optional.
|
||||
|
||||
| Code | Meaning | Recovery |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |
|
||||
| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |
|
||||
| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |
|
||||
| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |
|
||||
|
||||
## How deep workers can nest
|
||||
|
||||
A dispatched worker normally cannot dispatch sub-workers. Attempting it fails with
|
||||
`nested_worker_depth_exceeded` and a message telling the worker to complete the task
|
||||
itself. Do that — do not try to route around it.
|
||||
|
||||
The limit is a number, not an on/off switch. `Settings -> Orchestration -> Nested worker depth`
|
||||
sets how many generations are allowed:
|
||||
|
||||
- `1` (default): a coordinator dispatches workers; those workers do not dispatch.
|
||||
- `2`: workers may dispatch one further generation.
|
||||
|
||||
Depth is counted from the terminal that issues the command, not from the Run. Creating a
|
||||
new Run does not reset it — a worker that runs `run-create` then `worker-start` is still a
|
||||
worker, and still counted. This is the part that changed: the old behaviour rejected
|
||||
sub-dispatch only because a worker's terminal was not bound to a Run, so creating a Run was
|
||||
enough to slip past it.
|
||||
|
||||
Two limits worth knowing:
|
||||
|
||||
- **It is a guardrail, not a security boundary.** A caller that declares another terminal's
|
||||
handle while its own launch evidence is unverifiable (an ordinary restored terminal, for
|
||||
example) can be counted as that terminal instead. Orca does not treat workers as hostile.
|
||||
- **It applies while a Dispatch is active.** After `worker_done`, or after a coordinator
|
||||
settles the task, the terminal is no longer a worker and is counted as a root again. The
|
||||
process may still be alive; that is the documented boundary, not an accident.
|
||||
|
||||
## Preferred Supervised Worker Loop
|
||||
|
||||
Use `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.
|
||||
|
||||
Create the Run and every independent Task first, then start all independent workers before waiting:
|
||||
|
||||
```bash
|
||||
orca orchestration run-create --objective "<objective>" --json
|
||||
orca orchestration task-create --spec "<worker A task>" --json
|
||||
orca orchestration task-create --spec "<worker B task>" --json
|
||||
orca orchestration worker-start --task <task_a> --worktree current --agent codex --json
|
||||
orca orchestration worker-start --task <task_b> --worktree current --agent claude --json
|
||||
```
|
||||
|
||||
`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal <handle>`.
|
||||
|
||||
For a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:
|
||||
|
||||
```bash
|
||||
orca orchestration worker-start --task <task_id> --worktree current --agent claude --model opus --effort high --json
|
||||
```
|
||||
|
||||
`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.
|
||||
|
||||
For a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:
|
||||
|
||||
```bash
|
||||
orca orchestration worker-start --task <task_id> --worktree new-child --name <name> --agent codex --setup run --json
|
||||
# Independent/top-level:
|
||||
orca orchestration worker-start --task <task_id> --worktree new-top-level --name <name> --agent codex --setup run --json
|
||||
```
|
||||
|
||||
Setup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.
|
||||
|
||||
Read the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.
|
||||
|
||||
To run the worker on another connected Orca server, add `--on <saved-environment>`. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:
|
||||
|
||||
```bash
|
||||
# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)
|
||||
orca orchestration worker-start --task <task_id> --on windows --worktree new-top-level --repo <exact_remote_repo_selector> --name <name> --agent codex --setup run --json
|
||||
orca orchestration worker-show --dispatch <dispatch_id> --json
|
||||
orca orchestration worker-read --dispatch <dispatch_id> --limit 50 --json
|
||||
orca orchestration send --to dispatch:<dispatch_id> --subject "Follow-up" --body "<attempt-specific guidance>" --json
|
||||
```
|
||||
|
||||
Remote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.
|
||||
|
||||
The follow-up is structured inbox mail, not prompt injection. The worker's next
|
||||
`orchestration check` receives it even when the Dispatch is on another connected Orca server.
|
||||
|
||||
`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: "terminal"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.
|
||||
|
||||
Wait until every expected Dispatch settles, not for a fixed number of batches:
|
||||
|
||||
```bash
|
||||
orca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json
|
||||
# Process every message. For each accepted worker_done that is not immediately reused:
|
||||
orca orchestration worker-release --dispatch <dispatch_id> --json
|
||||
# Acknowledge only after every message and required release decision is handled:
|
||||
orca orchestration check --ack <delivery_id> --wait --types worker_done,escalation,question --timeout-ms 900000 --json
|
||||
```
|
||||
|
||||
After processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch <dispatch_id> --json`, then run `orca orchestration worker-start --task <next_task_id> --terminal <handle> --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch <dispatch_id> --json`.
|
||||
|
||||
Run `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch <dispatch_id> --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.
|
||||
|
||||
Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.
|
||||
|
||||
Workers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:
|
||||
|
||||
```bash
|
||||
orca orchestration send --type worker_done --subject "<status>" --body "<what changed, findings, and what remains>" --task-id <task_id> --dispatch-id <dispatch_id> --outcome succeeded --files-modified "path/a,path/b" --json
|
||||
# On failure, use --outcome failed; never encode failure only in prose.
|
||||
```
|
||||
|
||||
A worker question defaults to its owning Run. Timeout leaves it pending:
|
||||
|
||||
```bash
|
||||
orca orchestration ask --question "<question>" --options "yes,no" --timeout-ms 600000 --json
|
||||
orca orchestration ask --resume <message_id> --timeout-ms 600000 --json
|
||||
# Coordinator:
|
||||
orca orchestration reply --id <message_id> --body "<answer>" --json
|
||||
```
|
||||
|
||||
Recovery is conditional, never a fixed destructive sequence:
|
||||
|
||||
- The response was lost and named no Dispatch: run `orca orchestration request-show --request <request_id> --json` first. It is read-only. `completed` means the mutation already took effect. `pending` means the original mutation is still running or Orca restarted before recording its outcome. For either state, replaying the original command with `--retry-request <request_id>` reuses the same operation identity so Orca can replay, join, or safely recover it without starting a separate duplicate. `absent` means this runtime holds no receipt under your caller identity and is not proof that nothing happened; inspect the affected state before deciding whether to retry.
|
||||
- `worker-show --dispatch <id>` says `ready`: keep waiting or read bounded output.
|
||||
- It proves `failed` or `stopped`: start a replacement with `worker-start --task <task> --retry-of <id>` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.
|
||||
- It remains `outcome_unknown`: either `worker-stop --dispatch <id>` and inspect again, or explicitly `worker-abandon --dispatch <id>` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.
|
||||
- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.
|
||||
|
||||
Low-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.
|
||||
|
||||
`dispatch --inject` deliberately keeps an operator-started terminal unsupervised: it never creates a `worker_dispatches` row and `worker-stop`/`worker-abandon` never close that process. The dispatch context is still authoritative, so `worker-show`, `worker-read`, and `worker-list` report it as `unsupervised`; settled `worker-retain` and `worker-release` report `retained` with `no_owned_resource` and take no process action. Use `worker-start --terminal <handle>` when supervision and worker lifecycle state are required.
|
||||
|
||||
## Gates And Legacy Inspection
|
||||
|
||||
```bash
|
||||
orca orchestration gate-create --task <task_id> --question <text> [--options <json_array>] [--json]
|
||||
orca orchestration gate-resolve --id <gate_id> --resolution <text> [--json]
|
||||
orca orchestration gate-list [--task <task_id>] [--status <status>] [--json]
|
||||
```
|
||||
|
||||
Use `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.
|
||||
|
||||
`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.
|
||||
|
||||
Recovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.
|
||||
|
||||
## Full Handoffs
|
||||
|
||||
For full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.
|
||||
|
||||
Treat these as full handoff requests by default: "hand off", "handoff", "handover", "give this to another agent", "give this to another worktree", "send this to another agent", "another agent", "another worktree", or "launch another agent to own this." Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.
|
||||
|
||||
Supervised orchestration remains available only when the user explicitly asks for supervision or coordination: "supervise", "monitor", "wait for worker_done", "wait for results", "track completion", "DAG", "decision gate", "ask/reply", or "coordinate workers."
|
||||
|
||||
Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.
|
||||
|
||||
New top-level worktree handoff:
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --no-parent --agent codex --prompt "<task brief>" --setup run --json
|
||||
```
|
||||
|
||||
Before creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.
|
||||
|
||||
Existing terminal handoff:
|
||||
|
||||
```bash
|
||||
orca terminal send --terminal <handle> --text "<task brief>" --enter --json
|
||||
```
|
||||
|
||||
Custom Codex model/effort handoff:
|
||||
|
||||
`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.
|
||||
|
||||
The two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.
|
||||
|
||||
Note: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.
|
||||
|
||||
Use the exact full `<repo-id>::<path>` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --no-parent --setup run --json
|
||||
orca terminal create --worktree id:<newFullWorktreeId> --title <task-name> --command 'codex --model gpt-5.5 -c model_reasoning_effort="xhigh"' --json
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca terminal send --terminal <handle> --text "<task brief>" --enter --json
|
||||
```
|
||||
|
||||
Wait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.
|
||||
|
||||
`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo <selector> --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or "branch from current". Put current-branch context in the prompt instead.
|
||||
|
||||
## Worker Terminals
|
||||
|
||||
Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:
|
||||
|
||||
```bash
|
||||
orca terminal create --worktree active --title <task-name> --command "codex" --json
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
```
|
||||
|
||||
Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.
|
||||
|
||||
When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.
|
||||
|
||||
For every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --agent codex --setup run --json
|
||||
# or: --agent claude | omp | pi | grok | ...
|
||||
# Read <handle> from agentTerminalHandle, falling back to startupTerminal.handle.
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
```
|
||||
|
||||
For new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo <selector>`.
|
||||
|
||||
**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command <agent>` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.
|
||||
|
||||
Use `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.
|
||||
|
||||
Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.
|
||||
|
||||
Other terminal commands coordinators often need:
|
||||
|
||||
```bash
|
||||
orca terminal list [--worktree <selector>] [--include-visual-layouts] [--json]
|
||||
orca terminal create [--worktree <selector>] [--title <text>] [--command <cmd>] [--json]
|
||||
orca terminal split --terminal <handle> [--direction horizontal|vertical] [--command <cmd>] [--json]
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms <n> --json
|
||||
orca terminal read --terminal <handle> --json
|
||||
orca terminal send --terminal <handle> --text <text> --enter --json
|
||||
```
|
||||
|
||||
If an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree <selector> --command "codex" --json` or `--command "claude"`.
|
||||
|
||||
Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:
|
||||
`orca orchestration send --type worker_done --subject "<short status>" --body "<3-sentence summary: what you did, what you found, what's left>" --task-id <task_id> --dispatch-id <dispatch_id> --outcome succeeded --files-modified "path/a" --report-path "<optional>" --json`
|
||||
- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.
|
||||
- After sending `worker_done`, end that dispatched turn and idle at the agent prompt. Do not autonomously start more work, poll, or attempt to close the terminal yourself. A direct user instruction takes precedence and starts ordinary user-owned work: follow it without coordinator approval or a fresh Dispatch, never refuse it because of worker/coordinator roles, and do not reuse the settled Dispatch's lifecycle IDs. A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block.
|
||||
- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:
|
||||
`orca orchestration send --type heartbeat --subject "alive" --payload '{"taskId":"<task_id>","dispatchId":"<dispatch_id>","phase":"implementing"}' --json`
|
||||
- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.
|
||||
- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.
|
||||
- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.
|
||||
- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
orca terminal create --worktree active --title login-css-worker --command "claude" --json
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca orchestration task-create --spec "Fix the login button CSS" --json
|
||||
orca orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
orca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json
|
||||
```
|
||||
|
||||
## Next Action
|
||||
|
||||
Coordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.
|
||||
|
||||
Worker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.
|
||||
Keep waiting until every expected Dispatch settles. A timeout or empty result is
|
||||
a checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate
|
||||
editor without the positive proof `## Outcome` requires.
|
||||
|
||||
After three consecutive empty waits, stop waiting blindly and enumerate with
|
||||
`ORCA orchestration worker-list --include-remote --json` (defaults to the bound
|
||||
Run; `--run <run_id>` overrides; the receipt's `scope` names which), acting on
|
||||
each row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.
|
||||
An `inspect` `nextAction` on a `live` row with `attention.requiresAction` false
|
||||
is informational, not a command to re-run: keep waiting with `check --wait`.
|
||||
Leave the wait only on positive proof the agent stopped: `exited` liveness, the
|
||||
worker's own observation of process exit, or a transcript whose final agent turn
|
||||
sent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose
|
||||
`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,
|
||||
including when `worker-show` reports `agentWait` null. Absence never authorizes
|
||||
stop, abandon, retry, or release; keep waiting or inspect.
|
||||
|
||||
`worker-start` is the normal path, composing placement, terminal readiness,
|
||||
prompt injection, and supervised resource ownership. `dispatch --inject` leaves
|
||||
an operator-created process unsupervised and is only for an expressiveness gap.
|
||||
|
||||
## Task-spec contract
|
||||
|
||||
Every Task spec must be self-contained and name:
|
||||
|
||||
- **Target:** the files, component, or environment in scope.
|
||||
- **Change:** the concrete result to produce.
|
||||
- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.
|
||||
- **Ownership:** what this worker may edit and any coordination boundary.
|
||||
- **Observable acceptance:** the test, output, or evidence that proves completion.
|
||||
|
||||
## Completion accounting
|
||||
|
||||
After an accepted success or failure report, immediately do exactly one:
|
||||
|
||||
1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.
|
||||
2. Record user-requested retention with `worker-retain`.
|
||||
3. Run `worker-release`.
|
||||
|
||||
Release is post-settlement cleanup, not cancellation. Only an accepted
|
||||
settlement authorizes it; no other observation does. If release is uncertain,
|
||||
follow its exact recovery receipt and never substitute `terminal close`.
|
||||
|
||||
A valid `worker_done` settles the Task and Dispatch automatically; do not follow
|
||||
it with `task-update --status completed`. Enumerate the terminals still owing a
|
||||
decision with `worker-list --run <run_id> --terminal-state reclaimable --json`,
|
||||
and do not end the coordinator turn until it returns none.
|
||||
|
||||
## Conditional references
|
||||
|
||||
This compact guide is sufficient for the normal local loop. At an action gate
|
||||
below, run `ORCA skills get orchestration --reference references/<file>.md` and
|
||||
read only that document; `--references` lists the names. If the CLI rejects
|
||||
`--reference`, run `ORCA skills get orchestration --full` once instead: it
|
||||
returns this exact kernel and every reference, so read only the named one. If an
|
||||
older CLI rejects `--full`, keep this kernel's safety floor, use that command's
|
||||
`--help`, and never guess newer flags.
|
||||
|
||||
| Action gate | Bundled reference |
|
||||
| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
|
||||
| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |
|
||||
| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |
|
||||
| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |
|
||||
| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |
|
||||
| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |
|
||||
| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |
|
||||
| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |
|
||||
|
||||
Retired scheduler commands are not aliases for Run creation. Recovery commands
|
||||
must provide their exact next action; follow it with the same selected executable.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Coordinator loop
|
||||
|
||||
Load this reference for expanded DAG waves, per-invocation launch preferences,
|
||||
same-terminal reuse, or review ownership. The compact guide remains the source
|
||||
of truth for the loop order and completion boundary.
|
||||
|
||||
## Ready waves
|
||||
|
||||
Create independent Tasks before the first wait. Encode only real dependencies,
|
||||
then use the ready view as external memory:
|
||||
|
||||
```text
|
||||
ORCA orchestration task-create --spec "<dependent work>" --deps <json_array> --json
|
||||
ORCA orchestration task-list --ready --brief --json
|
||||
```
|
||||
|
||||
`--brief` collapses whitespace and caps echoed specs at 160 characters;
|
||||
`spec_truncated` identifies shortened rows. Omit it when full specs are needed or
|
||||
when an older CLI rejects the flag. A nested worker must respect
|
||||
`nested_worker_depth_exceeded`; creating another Run does not reset depth.
|
||||
|
||||
## Launch preferences
|
||||
|
||||
For a fresh Claude, Codex, or Cursor terminal, `--model` accepts an opaque
|
||||
provider model ID. Pass it only when the user named a model; otherwise omit it
|
||||
so the worker inherits the user's configured agent default. Add `--effort` only
|
||||
when that model supports it:
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-start --task <task_id> --worktree current --agent claude --model opus --effort high --json
|
||||
```
|
||||
|
||||
`--effort` requires `--model`; neither option combines with `--terminal`. A
|
||||
connected worker server must advertise launch-preference support before Orca
|
||||
forwards either field. Compare `launch.requested` with `launch.effective`; never
|
||||
claim a model or effort from requested arguments alone.
|
||||
|
||||
## Reuse after settlement
|
||||
|
||||
Choose the terminal's next owner before acknowledging the Delivery. When the
|
||||
same exact agent has immediate follow-up work, recover the proven handle and
|
||||
transfer cleanup ownership to the new Dispatch:
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-show --dispatch <dispatch_id> --json
|
||||
ORCA orchestration worker-start --task <next_task_id> --terminal <agent_terminal_handle> --json
|
||||
```
|
||||
|
||||
Otherwise explicitly retain or release the settled worker. Do not leave it live
|
||||
only to inspect output; archived output remains available through `worker-read`.
|
||||
|
||||
## Review ownership
|
||||
|
||||
A review-only `worker_done` authorizes synthesis of findings, not coordinator
|
||||
file edits. Dispatch or hand off fixes unless the user explicitly assigned them
|
||||
to the coordinator. If the user's plan names a next owner, post-review fixes and
|
||||
PR preparation remain with that owner; the coordinator routes and synthesizes.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Legacy contract migration
|
||||
|
||||
Load this reference only for an authority label, adopted Run, compatibility or
|
||||
recovery receipt, or explicit legacy takeover. A newly created attempt always
|
||||
uses the current grammar.
|
||||
|
||||
## Authority labels
|
||||
|
||||
- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported
|
||||
command printed with the message, using the same selected executable and
|
||||
arguments supplied by the original prompt.
|
||||
- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,
|
||||
at-least-once cutover replay. Process it idempotently and acknowledge only
|
||||
through the exact displayed guidance.
|
||||
- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or
|
||||
lifecycle mutation.
|
||||
- An unlabeled current message uses the current guide and grammar.
|
||||
|
||||
An explicitly selected current Run, attested current binding, current Dispatch,
|
||||
or federated attachment takes precedence over legacy fallback. A retained
|
||||
adoption record alone does not grant mutation authority. If liveness, principal
|
||||
ownership, capability, or the exact legacy contract is unproven, degrade to
|
||||
read-only inspection and never fall back to local execution.
|
||||
|
||||
Adoption preserves the live agent process, PTY/session, terminal handle,
|
||||
tab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or
|
||||
replaces the worker and never revives the retired scheduler. Loss of lifecycle
|
||||
authority does not invalidate the existing process, assignment, or filesystem
|
||||
work. Exact recovery may restore the same PTY once in its original inactive
|
||||
background tab; it must not spawn, write, signal, stop, switch, focus, split, or
|
||||
inject a terminal.
|
||||
|
||||
## Compatibility recovery
|
||||
|
||||
When a compatibility response returns structured next-step arguments, execute
|
||||
those exact arguments with the same selected CLI executable. Do not translate
|
||||
from memory, broaden the recipient, or retry as a current mutation unless the
|
||||
receipt explicitly authorizes it.
|
||||
|
||||
A pending ask, reply, final Dispatch settlement, and consuming check have
|
||||
durable recovery identities. Heartbeat and escalation remain at-least-once
|
||||
across a manual contract-boundary retry. If an ask may already have been
|
||||
answered, run the exact non-consuming recovery check printed by Orca before
|
||||
creating any new question. Never guess among identical question threads.
|
||||
|
||||
On packaged Windows, a legacy ask uses a two-step commit/resume protocol. The
|
||||
initial command commits the question, prints its exact
|
||||
`ask --resume <message_id>` command, and exits with launcher status `75`. Run
|
||||
that exact resume after the launcher or update boundary. For an attested WSL
|
||||
launch, preserve the printed `orca-ide` executable and distro route. Older WSL
|
||||
workers without launch proof remain lifecycle read-only even while their
|
||||
terminal and filesystem work continue.
|
||||
|
||||
## Read-only inspection and takeover
|
||||
|
||||
Read-only inspection does not consume mail:
|
||||
|
||||
```text
|
||||
ORCA orchestration run-list --json
|
||||
ORCA orchestration run-show --id run_legacy_local --json
|
||||
ORCA orchestration run-show --id <adopted_run_id> --json
|
||||
ORCA orchestration task-list --run <adopted_run_id> --json
|
||||
ORCA orchestration inbox --full --json
|
||||
ORCA orchestration check --terminal <legacy_handle> --peek --format --json
|
||||
ORCA terminal read --terminal <legacy_handle> --json
|
||||
ORCA terminal wait --terminal <legacy_handle> --for tui-idle --timeout-ms 60000 --json
|
||||
```
|
||||
|
||||
`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary
|
||||
Run whose objective is `Recovered orchestration work from a contract update`.
|
||||
|
||||
Only when the original coordinator is unavailable or cannot prove retained
|
||||
authority may a new live coordinator take over from its own terminal:
|
||||
|
||||
```text
|
||||
ORCA orchestration run-use --id <adopted_run_id> --takeover-legacy --json
|
||||
ORCA orchestration check --run <adopted_run_id> --json
|
||||
```
|
||||
|
||||
Takeover binds the authenticated invoking terminal; `--from` cannot nominate
|
||||
another coordinator. It fences only the old coordinator and moves pending mail
|
||||
into current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.
|
||||
Never take over while the original coordinator is actively coordinating.
|
||||
|
||||
Do not launch a replacement editor merely because Orca updated or authority is
|
||||
unclear. Keep the original worker as the only editor until a stable handoff
|
||||
point, then use a fresh current Dispatch in a conflict-free placement.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Low-level topology
|
||||
|
||||
Load this reference only when `worker-start` cannot express required custom argv
|
||||
or terminal topology. It is not the normal supervised loop and is never a full
|
||||
handoff recipe.
|
||||
|
||||
```text
|
||||
ORCA terminal create --worktree active --title <task_name> --command "<agent_command>" --json
|
||||
ORCA terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
ORCA orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
```
|
||||
|
||||
Wait for readiness only when startup could lose injected input. Prefer
|
||||
agent-first `worker-start` whenever its argv and topology are sufficient.
|
||||
|
||||
`dispatch --inject` creates authoritative Task/Dispatch context but deliberately
|
||||
keeps an operator-created process unsupervised: it creates no supervised worker
|
||||
resource row. `worker-show`, `worker-read`, and `worker-list` report the lane as
|
||||
`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and
|
||||
settled retain/release take no process action.
|
||||
|
||||
Use `worker-start --terminal <handle>` when lifecycle ownership of an existing
|
||||
agent terminal is required. Never imply that low-level dispatch retroactively
|
||||
owns a process, never use it to route around the nested-depth limit, and never
|
||||
use it for an ownership handoff.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Messaging and gates
|
||||
|
||||
Load this reference for inbox replay, attempt-specific guidance, group
|
||||
addresses, blocking questions, or coordinator-managed DAG decisions.
|
||||
|
||||
A successful `send` proves durable enqueue. Wake and nudge are best-effort
|
||||
attention only: neither proves the recipient read the message, began a turn, or
|
||||
accepted steering.
|
||||
|
||||
## Coordinator delivery loop
|
||||
|
||||
`check` names its caller with `--terminal <handle>` and is the only verb that
|
||||
rejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves
|
||||
the caller; pass it explicitly from anywhere else, including a dispatched
|
||||
worker reading coordinator follow-ups.
|
||||
|
||||
A consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,
|
||||
up to 50 messages, and replays that exact batch until acknowledged. Process
|
||||
every row and required terminal ownership decision before `--ack`. Type filters
|
||||
decide when a waiter wakes; they do not authorize skipping older actionable
|
||||
mail. A Delivery therefore always carries the whole FIFO batch whatever its
|
||||
types, and a `check` without `--wait` hands that batch over unfiltered.
|
||||
`--peek` and `--all` are read-only inspection, not progress through the
|
||||
coordinator inbox.
|
||||
|
||||
An empty wait or timeout is a checkpoint. Continue rolling waits until every
|
||||
expected Dispatch settles. Heartbeat or visible activity means alive, not done.
|
||||
|
||||
## Addresses
|
||||
|
||||
Use a stable Dispatch address for attempt-specific coordinator guidance:
|
||||
|
||||
```text
|
||||
ORCA orchestration send --to dispatch:<dispatch_id> --subject "Follow-up" --body "<guidance>" --json
|
||||
```
|
||||
|
||||
Do not substitute a remote terminal handle. Omit `--from` for ordinary
|
||||
coordinator calls; a dispatched worker instead copies the exact `--from` and
|
||||
capability arguments in its preamble. `check` is the exception: it identifies
|
||||
its caller with `--terminal`, never `--from`.
|
||||
|
||||
Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,
|
||||
`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:<id>`. Use them only for
|
||||
intentional fan-out status or questions. `worker_done`, heartbeat, and other
|
||||
Dispatch lifecycle messages never target groups.
|
||||
|
||||
## Questions and gates
|
||||
|
||||
A worker uses `ask`; its timeout leaves one durable question pending, which the
|
||||
worker resumes by message ID. The coordinator answers that message with `reply`.
|
||||
|
||||
Use a gate only for a coordinator-owned Task-DAG decision:
|
||||
|
||||
```text
|
||||
ORCA orchestration gate-create --task <task_id> --question "<decision>" --options <json_array> --json
|
||||
ORCA orchestration gate-resolve --id <gate_id> --resolution "<choice>" --json
|
||||
ORCA orchestration gate-list --task <task_id> --json
|
||||
```
|
||||
|
||||
Pass `json_array` using the quoting rules of the active shell; do not copy POSIX
|
||||
single-quote syntax into PowerShell or `cmd.exe`.
|
||||
|
||||
Do not create a gate merely to answer a worker's `ask`.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Placement and remote execution
|
||||
|
||||
Load this reference before creating a new worktree or placing work through SSH,
|
||||
WSL, or another connected Orca server.
|
||||
|
||||
## Placement choices
|
||||
|
||||
A fresh worker means a fresh agent terminal, not a new Git worktree. Use the
|
||||
current or an exact existing workspace by default. Create a worktree only when
|
||||
the user requested one or a concrete checkout or filesystem conflict makes
|
||||
sharing unsafe.
|
||||
|
||||
```text
|
||||
# Current workspace; setup is not rerun.
|
||||
ORCA orchestration worker-start --task <task_id> --worktree current --agent codex --json
|
||||
|
||||
# Stacked child worktree.
|
||||
ORCA orchestration worker-start --task <task_id> --worktree new-child --name <name> --agent codex --setup run --json
|
||||
|
||||
# Independent top-level worktree.
|
||||
ORCA orchestration worker-start --task <task_id> --worktree new-top-level --name <name> --agent codex --setup run --json
|
||||
```
|
||||
|
||||
Current and exact existing workspaces create a fresh terminal unless
|
||||
`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git
|
||||
or require worktree lineage when the selected workspace is a folder.
|
||||
|
||||
Register a folder workspace through project setup. `repo add --path <dir>`
|
||||
requires a valid Git repository and rejects a plain directory:
|
||||
|
||||
```text
|
||||
ORCA project setup-existing-folder --project <project_id> --host <host_id> --path <abs_path> --kind folder --json
|
||||
```
|
||||
|
||||
Then place work on the returned workspace with an exact selector. A worktree
|
||||
selector needs the full `<repo-id>::<path>` value Orca returned, passed as
|
||||
`id:<newFullWorktreeId>`; a bare repo id is not a worktree id. `new-child` and
|
||||
`new-top-level` are worktree creation and do not apply to a folder.
|
||||
|
||||
New worktrees use agent-first creation and run setup by default. Preserve the
|
||||
repository's startup policy: `start-immediately` can report setup as `running`,
|
||||
while `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,
|
||||
filesystem isolation, coordination parentage, UI grouping, and execution host
|
||||
are separate decisions.
|
||||
|
||||
## Connected servers
|
||||
|
||||
The Run and Tasks remain authoritative on the current server. `--on` selects
|
||||
only the worker's execution server and appears only on `worker-start`:
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-start --task <task_id> --on <environment> --worktree new-top-level --repo <exact_remote_repo_selector> --name <name> --agent codex --setup run --json
|
||||
```
|
||||
|
||||
Remote `current` and `new-child` are invalid because they are ambiguous across
|
||||
servers. Use an exact discovered remote workspace, or `new-top-level` with an
|
||||
exact remote repository selector. After start, route every follow-up, read,
|
||||
stop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote
|
||||
terminal handle.
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-show --dispatch <dispatch_id> --json
|
||||
ORCA orchestration worker-read --dispatch <dispatch_id> --limit 50 --json
|
||||
ORCA orchestration send --to dispatch:<dispatch_id> --subject "Follow-up" --body "<guidance>" --json
|
||||
ORCA orchestration worker-list --run <run_id> --include-remote --json
|
||||
```
|
||||
|
||||
`worker-list` reads local fleet state only; enumerate remote workers with
|
||||
`--include-remote` or every one of them reads `unverifiable`. Scope every list
|
||||
with `--run <run_id>`: unscoped, it reports every Dispatch this runtime has
|
||||
recorded, and the workers you are waiting on are lost in that history.
|
||||
|
||||
## Execution-host and mixed-version floor
|
||||
|
||||
The execution host owns process, filesystem, transcript, stop, and cleanup
|
||||
facts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay
|
||||
absence, missing client inventory, or timeout yields `unverifiable`, never
|
||||
synthetic exit and never a client-local substitute action.
|
||||
|
||||
Clients and servers update independently. Optional response fields may be
|
||||
absent. Forward model/effort, transcript reads, cleanup, or another new remote
|
||||
operation only when the peer advertises the relevant capability; unknown stream
|
||||
opcodes can be silently dropped. A narrow unsupported response may degrade to a
|
||||
documented older path, but must not broaden the target or cross the execution
|
||||
boundary. Changing host-published content reaches old clients even without a
|
||||
wire-shape change, so preserve established semantics or negotiate the behavior.
|
||||
|
||||
For WSL, use the exact executable and arguments returned by Orca so the distro
|
||||
and packaged launcher remain bound. Do not translate a printed `orca-ide`
|
||||
recovery command into a PATH-resolved local command.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Recovery and cleanup
|
||||
|
||||
Load this reference only after a failed/stopped/unknown attempt, explicit retry
|
||||
decision, stop/abandon request, retention request, or uncertain release.
|
||||
|
||||
| Proven state | Safe action |
|
||||
| ----------------------- | ------------------------------------------------------------------ |
|
||||
| `ready` or active | Keep waiting; optionally read bounded output |
|
||||
| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |
|
||||
| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |
|
||||
| Accepted `worker_done` | Reuse, retain, or release |
|
||||
| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |
|
||||
| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |
|
||||
| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |
|
||||
|
||||
## Inspect before acting
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-list --run <run_id> --json
|
||||
ORCA orchestration worker-list --run <run_id> --include-remote --json
|
||||
ORCA orchestration worker-show --dispatch <dispatch_id> --json
|
||||
ORCA orchestration worker-read --dispatch <dispatch_id> --limit 50 --json
|
||||
```
|
||||
|
||||
`worker-list` is the enumerating command and the authority on agent liveness:
|
||||
each row carries `projection.liveness`, `projection.attention.categories`,
|
||||
`projection.attention.requiresAction`, and a literal `projection.nextAction`
|
||||
argv to run. Always scope it with `--run <run_id>`; an unscoped list reports
|
||||
every Dispatch this runtime has ever recorded and buries the live ones.
|
||||
`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal
|
||||
whose agent died at a trust prompt still reads `live` there.
|
||||
|
||||
When the two disagree, the fleet verdict decides — unless the fleet row is
|
||||
`unverifiable` for a reason that names a gap on this client rather than a fact
|
||||
about the worker. `missing_status`, `host_unavailable`, and
|
||||
`capability_unsupported` are such gaps: the first means this runtime holds no
|
||||
status row, the second that it could not ask the execution host at all, and the
|
||||
third that a stale peer answered but lacks the fleet-snapshot capability.
|
||||
Against any of them, a `worker-show` verdict sourced from the execution host is
|
||||
the better evidence and outranks the row. Only `host_unavailable` is contact
|
||||
loss; the other two mean the host was never asked or answered without the
|
||||
capability.
|
||||
|
||||
This never promotes absence. `unverifiable` from either command still authorizes
|
||||
nothing — only a positive `live` or `exited` verdict does.
|
||||
|
||||
A worker started with `--on <environment>` reads `unverifiable` until you
|
||||
enumerate with `--include-remote`, which asks its execution host for the
|
||||
verdict. Past 100 rows the response pages, so follow `page.nextCursor` with
|
||||
`--cursor <value>` until `page.hasMore` is false.
|
||||
|
||||
## Stall needs positive evidence
|
||||
|
||||
Leave the wait only on positive proof the agent stopped: `exited` liveness, the
|
||||
worker's own observation of process exit, or a transcript whose final agent turn
|
||||
sent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.
|
||||
|
||||
`unverifiable` is always absence — `missing_status`, `stale_status`,
|
||||
`restored_unconfirmed`, or a remote worker with no connection — and a null
|
||||
`agentWait` or an unchanged `worker-read` tail is that same absence seen again.
|
||||
Absence never authorizes stop, abandon, retry, or release: keep waiting, or
|
||||
inspect until you hold one of the positive signals above. A `nextAction` that
|
||||
names an inspecting command is asking for evidence, not for cleanup.
|
||||
|
||||
`worker-read --source auto` uses a proven provider transcript when available and
|
||||
otherwise returns bounded terminal output with a typed `fallbackReason`.
|
||||
Continue with its top-level cursor, which is pinned to that source. If Orca
|
||||
reports `source_changed`, restart without the old cursor. A bounded initial
|
||||
transcript tail can return an EOF cursor that follows only newly appended records;
|
||||
read `contentComplete`, `clipping`, and `warnings` before assuming omitted older
|
||||
records are pageable. Never guess a provider session ID, transcript path, or
|
||||
remote terminal handle.
|
||||
|
||||
## Was the mutation applied?
|
||||
|
||||
When a mutation's response was lost and named no Dispatch, do not replay blind.
|
||||
Every orchestration mutation accepts `--retry-request <id>`, which reuses one
|
||||
operation identity so Orca can replay, join, or recover it instead of starting a
|
||||
duplicate. Ask what happened first:
|
||||
|
||||
```text
|
||||
ORCA orchestration request-show --request <request_id> --json
|
||||
```
|
||||
|
||||
`completed` means the mutation already took effect; read its recorded receipt
|
||||
instead of rerunning. `pending` means the original mutation is still running or
|
||||
Orca restarted before recording its outcome; replay the original command with
|
||||
`--retry-request <request_id>`. `absent` means this runtime holds no receipt
|
||||
under your caller identity — that is not proof nothing happened, so inspect the
|
||||
affected Task, Dispatch, and terminal before deciding whether to retry.
|
||||
|
||||
When a worker's terminal accepted input but the submit is unconfirmed, use
|
||||
`terminal send --wait-submit <seconds>`: it observes the accepted prompt for that
|
||||
long and, on timeout, returns the input-accepted receipt without resending.
|
||||
|
||||
## Refused starts
|
||||
|
||||
`dispatch` and `worker-start` refuse the following preflight cases with a stable
|
||||
`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`
|
||||
as the exact recovery text. Older hosts may omit `data`, so treat every field as
|
||||
optional.
|
||||
|
||||
| Code | Meaning | Recovery |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |
|
||||
| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |
|
||||
| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |
|
||||
| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |
|
||||
|
||||
## Retry, stop, and abandon
|
||||
|
||||
Retry only a positively proven failed or stopped attempt. Name the failed Task
|
||||
with `--task`, since `--spec` creates a new one. Placement is never silently
|
||||
inherited:
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-start --task <task_id> --retry-of <dispatch_id> --worktree <explicit_placement> --agent <agent> --json
|
||||
```
|
||||
|
||||
After three consecutive failures for one Task, its dispatch context
|
||||
circuit-breaks and the Task is failed. Do not route around that boundary with a
|
||||
new Run or an unrelated Dispatch.
|
||||
|
||||
For `outcome_unknown`, inspect first, then make an explicit choice:
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-stop --dispatch <dispatch_id> --json
|
||||
ORCA orchestration worker-abandon --dispatch <dispatch_id> --json
|
||||
```
|
||||
|
||||
`worker-stop` closes only the exact proven supervised agent terminal. It never
|
||||
deletes the worktree, setup terminal, configured tabs, or unrelated processes.
|
||||
`worker-abandon` fences orchestration while accepting that resources may remain
|
||||
live; it performs no remote, process, or filesystem action.
|
||||
|
||||
## Retain and release
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-retain --dispatch <dispatch_id> --json
|
||||
ORCA orchestration worker-release --dispatch <dispatch_id> --json
|
||||
```
|
||||
|
||||
Retain only when the user explicitly wants the settled terminal kept live.
|
||||
Release works after succeeded and failed reports, archives readable output, and
|
||||
closes only the exact terminal owned by that settled Dispatch. Replays may call
|
||||
release again safely. Reused, pre-existing, setup, coordinator, active,
|
||||
user-taken-over, and unproven terminals are retained.
|
||||
|
||||
A `worker-start` that failed before its agent was ready still owns the terminal
|
||||
it created. Its receipt names `worker-release`, and `worker-list` reports that
|
||||
row as `reclaimable`; release it there rather than closing the terminal by hand.
|
||||
|
||||
Never release because of timeout, TUI idle, heartbeat, status, question,
|
||||
escalation, or stale/rejected completion. If the receipt says `release_pending`
|
||||
or `release_unknown`, follow its exact recovery action. Never substitute
|
||||
`terminal close`.
|
||||
|
||||
`orchestration reset` is destructive recovery. Do not run it during active
|
||||
coordination unless the user explicitly abandons that state.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Worker contract
|
||||
|
||||
The injected preamble is authoritative. Copy its command rather than
|
||||
reconstructing flags. In particular, preserve the exact executable, worker
|
||||
handle, Dispatch capability, Task ID, and Dispatch ID.
|
||||
|
||||
## Heartbeat
|
||||
|
||||
Send heartbeats only at the cadence required by the live preamble. Skip them
|
||||
while blocked inside `ask` or `check --wait`; those calls are liveness signals.
|
||||
|
||||
```text
|
||||
ORCA orchestration send --from <worker_handle> --dispatch-capability <capability> --type heartbeat --subject "alive" --task-id <task_id> --dispatch-id <dispatch_id> --phase "<investigating|implementing|reviewing|waiting>"
|
||||
```
|
||||
|
||||
Use typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves
|
||||
liveness, never completion.
|
||||
|
||||
## Ask and resume
|
||||
|
||||
Use Orca `ask` whenever the coordinator must answer. Never open a local question
|
||||
TUI the coordinator cannot answer.
|
||||
|
||||
```text
|
||||
ORCA orchestration ask --from <worker_handle> --dispatch-capability <capability> --question "<question>" --options "<choice-a>,<choice-b>" --timeout-ms 600000
|
||||
|
||||
ORCA orchestration ask --from <worker_handle> --dispatch-capability <capability> --resume <message_id> --timeout-ms 600000
|
||||
```
|
||||
|
||||
A timeout or disconnect leaves the original question pending. Resume its
|
||||
message ID; do not create a duplicate question.
|
||||
|
||||
## Reading coordinator follow-ups
|
||||
|
||||
The coordinator steers a running worker with `send --to dispatch:<id>`. That
|
||||
enqueue is durable but does not interrupt you, so nothing arrives unless you
|
||||
look:
|
||||
|
||||
```text
|
||||
ORCA orchestration check --terminal <worker_handle> --json
|
||||
```
|
||||
|
||||
Run it at each natural checkpoint — before starting a new file, after a test
|
||||
run — and once more immediately before `worker_done`, so a redirect or a
|
||||
cancellation lands before the Task settles. `check` names its caller with
|
||||
`--terminal`, never `--from`. Stop checking after `worker_done`.
|
||||
|
||||
If `check` returns `consumer_fenced`, this process no longer owns its Dispatch:
|
||||
the Attempt was re-attached to another worker or settled without you. Stop, do
|
||||
not send `worker_done`, and do not retry the check. An empty `check` never means
|
||||
you were replaced; `consumer_fenced` is the only way you learn that.
|
||||
|
||||
## Escalation
|
||||
|
||||
Escalate only before completion and only when the coordinator must intervene:
|
||||
|
||||
```text
|
||||
ORCA orchestration send --from <worker_handle> --dispatch-capability <capability> --type escalation --subject "Blocked: <reason>" --body "<details>" --task-id <task_id> --dispatch-id <dispatch_id>
|
||||
```
|
||||
|
||||
## Completion
|
||||
|
||||
Send exactly one terminal report. `--body` is three sentences: what changed,
|
||||
what was found, and what remains. Use `--outcome failed` when the requested work
|
||||
is not complete; never hide failure in prose or silently exit.
|
||||
|
||||
Append `--files-modified` or `--report-path` only when applicable, using actual
|
||||
paths. Do not send documentation placeholders as metadata.
|
||||
|
||||
```text
|
||||
ORCA orchestration send --from <worker_handle> --dispatch-capability <capability> --type worker_done --subject "<short status>" --body "<three sentences: work, findings, remaining>" --task-id <task_id> --dispatch-id <dispatch_id> --outcome succeeded
|
||||
```
|
||||
|
||||
After `worker_done`, end the dispatched turn and idle. Do not poll, close your
|
||||
own terminal, or begin unrelated work. A later direct user instruction is new
|
||||
user-owned work and must not reuse settled lifecycle IDs; a supervised follow-up
|
||||
arrives with a fresh preamble and Task block.
|
||||
@@ -32,16 +32,19 @@ same way in POSIX shells, PowerShell, and cmd.exe.
|
||||
If the selected executable cannot run, report its exact error and stop. Do not fall through
|
||||
to another executable, which could silently target a different Orca build.
|
||||
|
||||
## Load the full guide before running Orca commands
|
||||
## Load the version-matched guide before running Orca commands
|
||||
|
||||
```text
|
||||
ORCA skills get orchestration
|
||||
```
|
||||
|
||||
That prints the complete, version-matched guide for the exact binary that will handle your
|
||||
next commands — task creation and dispatch, injected lifecycle preambles, worker_done
|
||||
authority, decision gates, and coordinator loops. Read it first, then run the specific
|
||||
command you need.
|
||||
That prints the compact, version-matched guide for the exact binary that will handle your
|
||||
next commands. It covers the normal local coordinator loop. For a conditional action gate
|
||||
such as remote placement, uncertain release recovery, or expanded DAG work, load only the
|
||||
reference that gate names with
|
||||
`ORCA skills get orchestration --reference references/<file>.md`
|
||||
(`--references` lists the names). If that binary rejects `--reference`, run
|
||||
`ORCA skills get orchestration --full` and read the named bundled reference before acting.
|
||||
|
||||
Don't guess subcommands or flags from memory or from a cached copy of this stub. They
|
||||
change between Orca releases, and this file deliberately no longer lists them. Confirm the
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
---
|
||||
name: orchestration
|
||||
description: >-
|
||||
Use Orca orchestration for structured multi-agent coordination: threaded
|
||||
messages, blocking ask/reply flows, task dispatch, worker_done/escalation
|
||||
waits, task DAGs, decision gates, or coordinator loops. Use `orca-cli`
|
||||
instead for full ownership handoffs, including requests phrased as "hand
|
||||
off", "handoff", "handover", "give this to another agent", or "another
|
||||
worktree" when the user did not explicitly ask to supervise, monitor, wait
|
||||
for results, or coordinate a DAG. Use `orca-cli` for terminal control,
|
||||
lightweight terminal prompts, shell commands, Orca worktree management,
|
||||
reading or waiting on terminals, and the Orca embedded browser. Use Computer
|
||||
Use for external browser windows, webviews, Orca app UI, or desktop UI
|
||||
outside Orca's embedded browser only when the task requires OS/window-level
|
||||
control such as focus, menus, dialogs, coordinates, or screenshots. Use
|
||||
`orca-cli` for Orca's embedded pages and a page-automation tool such as
|
||||
Playwright or CDP for external pages.
|
||||
Coordinate supervised Orca workers: threaded messages, blocking ask/reply,
|
||||
task dispatch, worker_done/escalation waits, task DAGs, decision gates,
|
||||
coordinator loops, and decomposing work across agents. Use `orca-cli` for full
|
||||
ownership handoffs — "hand off", "handoff", "handover", "give this to another
|
||||
agent", "another worktree" — unless asked to supervise, monitor, or coordinate
|
||||
a DAG, and for terminal control, lightweight terminal prompts, shell commands,
|
||||
Orca worktree management, and reading or waiting on terminals. Use Computer
|
||||
Use for external browser windows, webviews, Orca app UI, or desktop UI outside
|
||||
Orca's embedded browser only when the task requires OS/window-level control
|
||||
such as focus, menus, dialogs, coordinates, or screenshots. Use `orca-cli` for
|
||||
Orca's embedded pages and a page-automation tool such as Playwright or CDP for
|
||||
external pages.
|
||||
---
|
||||
|
||||
# Orca Orchestration
|
||||
@@ -51,16 +49,19 @@ same way in POSIX shells, PowerShell, and cmd.exe.
|
||||
If the selected executable cannot run, report its exact error and stop. Do not fall through
|
||||
to another executable, which could silently target a different Orca build.
|
||||
|
||||
## Load the full guide before running Orca commands
|
||||
## Load the version-matched guide before running Orca commands
|
||||
|
||||
```text
|
||||
ORCA skills get orchestration
|
||||
```
|
||||
|
||||
That prints the complete, version-matched guide for the exact binary that will handle your
|
||||
next commands — task creation and dispatch, injected lifecycle preambles, worker_done
|
||||
authority, decision gates, and coordinator loops. Read it first, then run the specific
|
||||
command you need.
|
||||
That prints the compact, version-matched guide for the exact binary that will handle your
|
||||
next commands. It covers the normal local coordinator loop. For a conditional action gate
|
||||
such as remote placement, uncertain release recovery, or expanded DAG work, load only the
|
||||
reference that gate names with
|
||||
`ORCA skills get orchestration --reference references/<file>.md`
|
||||
(`--references` lists the names). If that binary rejects `--reference`, run
|
||||
`ORCA skills get orchestration --full` and read the named bundled reference before acting.
|
||||
|
||||
Don't guess subcommands or flags from memory or from a cached copy of this stub. They
|
||||
change between Orca releases, and this file deliberately no longer lists them. Confirm the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CommandSpec } from './args'
|
||||
import { COMMAND_SPECS } from './specs'
|
||||
import {
|
||||
REPEATED_FLAG_SEPARATOR,
|
||||
findCommandSpec,
|
||||
@@ -325,6 +326,25 @@ describe('validateCommandAndFlags', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('points --from at --terminal on the one verb that renamed the caller flag', () => {
|
||||
const parsed = parseArgs(['orchestration', 'check', '--from', 'term_a'])
|
||||
|
||||
try {
|
||||
validateCommandAndFlags(COMMAND_SPECS, parsed)
|
||||
throw new Error('expected validateCommandAndFlags to throw')
|
||||
} catch (error) {
|
||||
const data = (error as { data?: { suggestions: string[]; nextSteps: string[] } }).data
|
||||
expect(data?.suggestions[0]).toBe('terminal')
|
||||
expect(data?.nextSteps[0]).toContain('--terminal')
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves --from alone where the command actually accepts it', () => {
|
||||
const parsed = parseArgs(['orchestration', 'reply', '--from', 'term_a'])
|
||||
|
||||
expect(() => validateCommandAndFlags(COMMAND_SPECS, parsed)).not.toThrow()
|
||||
})
|
||||
|
||||
it('attaches did-you-mean suggestions to unknown-command errors', () => {
|
||||
const suggestSpecs: CommandSpec[] = [
|
||||
{
|
||||
|
||||
File diff suppressed because one or more lines are too long
+71
-3
@@ -4,15 +4,45 @@ import {
|
||||
stripAutomationOwnerConflictCode
|
||||
} from '../shared/automation-owner-conflict'
|
||||
import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery'
|
||||
import { worktreeSelectorRecovery } from './worktree-selector-recovery'
|
||||
import type { RuntimeRpcFailure } from './runtime-client'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types'
|
||||
|
||||
type CliErrorContext = {
|
||||
export type CliErrorContext = {
|
||||
commandPath?: readonly string[]
|
||||
/** The `--worktree` value this invocation sent; the runtime's error never echoes it. */
|
||||
worktreeSelector?: string
|
||||
}
|
||||
|
||||
function selectorRecovery(code: string | undefined, context: CliErrorContext) {
|
||||
return code === 'selector_not_found' && context.worktreeSelector
|
||||
? worktreeSelectorRecovery(context.worktreeSelector)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function errorData(error: unknown): unknown {
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return error.response.error.data
|
||||
}
|
||||
return error instanceof RuntimeClientError ? error.data : undefined
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return error.response.error.code
|
||||
}
|
||||
return error instanceof RuntimeClientError ? error.code : undefined
|
||||
}
|
||||
|
||||
export function formatCliError(error: unknown, context: CliErrorContext = {}): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const selector = selectorRecovery(errorCode(error), context)
|
||||
if (selector) {
|
||||
return formatMessageWithNextSteps(
|
||||
message,
|
||||
nextStepsFromData(mergeSelectorRecovery(errorData(error), selector))
|
||||
)
|
||||
}
|
||||
if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
|
||||
if (hasOrchestrationRequestId(error.data)) {
|
||||
return message
|
||||
@@ -58,9 +88,25 @@ function hasOrchestrationRequestId(data: unknown): boolean {
|
||||
}
|
||||
|
||||
export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void {
|
||||
const selector = selectorRecovery(errorCode(error), context)
|
||||
if (json) {
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
console.log(JSON.stringify(withAutomationOwnerConflictRecovery(error.response), null, 2))
|
||||
const response = withAutomationOwnerConflictRecovery(error.response)
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
selector
|
||||
? {
|
||||
...response,
|
||||
error: {
|
||||
...response.error,
|
||||
data: mergeSelectorRecovery(response.error.data, selector)
|
||||
}
|
||||
}
|
||||
: response,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
} else {
|
||||
const response: RuntimeRpcFailure = {
|
||||
id: 'local',
|
||||
@@ -111,6 +157,24 @@ function formatMessageWithNextSteps(message: string, nextSteps: readonly string[
|
||||
return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}`
|
||||
}
|
||||
|
||||
/** Why merge: a mutation error already carries its request id, and `??` dropped the selector grammar. */
|
||||
function mergeSelectorRecovery(
|
||||
data: unknown,
|
||||
selector: ReturnType<typeof selectorRecovery>
|
||||
): unknown {
|
||||
if (!selector) {
|
||||
return data
|
||||
}
|
||||
if (data === null || typeof data !== 'object') {
|
||||
return selector
|
||||
}
|
||||
return {
|
||||
...selector,
|
||||
...data,
|
||||
nextSteps: [...selector.nextSteps, ...nextStepsFromData(data)]
|
||||
}
|
||||
}
|
||||
|
||||
function nextStepsFromData(data: unknown): string[] {
|
||||
if (
|
||||
data &&
|
||||
@@ -125,9 +189,13 @@ function nextStepsFromData(data: unknown): string[] {
|
||||
}
|
||||
|
||||
function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
|
||||
const selector = selectorRecovery(errorCode(error), context)
|
||||
// Why: error-specific recovery must win over the generic computer fallback.
|
||||
if (error instanceof RuntimeClientError && error.data !== undefined) {
|
||||
return error.data
|
||||
return mergeSelectorRecovery(error.data, selector)
|
||||
}
|
||||
if (selector) {
|
||||
return selector
|
||||
}
|
||||
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
|
||||
if (conflict) {
|
||||
|
||||
@@ -110,14 +110,24 @@ export type FlagErrorData = {
|
||||
nextSteps: string[]
|
||||
}
|
||||
|
||||
// Why: edit distance cannot recover a rename. `orchestration check` is the one verb
|
||||
// that identifies its caller with `--terminal` while every sibling uses `--from`, so
|
||||
// the near-miss ranking answered `--json`/`--run` and left the caller stuck (#16904).
|
||||
// A synonym only fires where the typed flag is rejected and its partner is accepted.
|
||||
const FLAG_SYNONYMS: Readonly<Record<string, string>> = { from: 'terminal' }
|
||||
|
||||
function suggestFlags(flag: string, validFlags: string[]): string[] {
|
||||
const synonym = FLAG_SYNONYMS[flag]
|
||||
const scored: { label: string; distance: number }[] = []
|
||||
for (const candidate of validFlags) {
|
||||
if (Math.abs(flag.length - candidate.length) <= SUGGESTION_THRESHOLD) {
|
||||
scored.push({ label: candidate, distance: levenshtein(flag, candidate) })
|
||||
}
|
||||
}
|
||||
return rankByDistance(scored)
|
||||
const ranked = rankByDistance(scored)
|
||||
return synonym && validFlags.includes(synonym)
|
||||
? [synonym, ...ranked.filter((name) => name !== synonym)].slice(0, MAX_SUGGESTIONS)
|
||||
: ranked
|
||||
}
|
||||
|
||||
// Why: include the accepted set so agents can recover without another help call.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describeQuoteStrippedJsonFlag } from './quote-stripped-json-flag'
|
||||
|
||||
export function getRequiredStringFlag(flags: Map<string, string | boolean>, name: string): string {
|
||||
const value = flags.get(name)
|
||||
rejectValuelessFlag(value, name)
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return value
|
||||
}
|
||||
@@ -15,6 +16,7 @@ export function getRequiredStringFlagAllowingEmpty(
|
||||
name: string
|
||||
): string {
|
||||
const value = flags.get(name)
|
||||
rejectValuelessFlag(value, name)
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
@@ -26,9 +28,24 @@ export function getOptionalStringFlag(
|
||||
name: string
|
||||
): string | undefined {
|
||||
const value = flags.get(name)
|
||||
rejectValuelessFlag(value, name)
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A valued flag whose value the shell (or a missing variable) ate parses as `true`. Dropping it
|
||||
* silently mints a fresh mutation identity and can deliver a prompt twice (#15180), so every
|
||||
* valued-flag accessor refuses the damaged shape by name.
|
||||
*/
|
||||
export function rejectValuelessFlag(value: string | boolean | undefined, name: string): void {
|
||||
if (value === true) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--${name} requires a value; it was passed with none.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON-valued flag, rejected up front when a native argv boundary stripped its quotes so the
|
||||
* error names the shell instead of the user's value (#16706). The value itself is still parsed
|
||||
@@ -64,6 +81,7 @@ export function getOptionalNumberFlag(
|
||||
name: string
|
||||
): number | undefined {
|
||||
const value = flags.get(name)
|
||||
rejectValuelessFlag(value, name)
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
@@ -131,6 +149,7 @@ export function getOptionalNullableNumberFlag(
|
||||
name: string
|
||||
): number | null | undefined {
|
||||
const value = flags.get(name)
|
||||
rejectValuelessFlag(value, name)
|
||||
if (value === 'null') {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,8 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { formatCliError } from './format'
|
||||
import { formatCliError, reportCliError } from './format'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
|
||||
|
||||
function selectorNotFound(): RuntimeRpcFailureError {
|
||||
return new RuntimeRpcFailureError({
|
||||
id: 'req_selector',
|
||||
ok: false,
|
||||
error: { code: 'selector_not_found', message: 'selector_not_found' },
|
||||
_meta: { runtimeId: 'runtime_local' }
|
||||
})
|
||||
}
|
||||
|
||||
describe('worktree selector recovery', () => {
|
||||
it('names the offending value and the valid forms on a bare repo id', () => {
|
||||
const output = formatCliError(selectorNotFound(), {
|
||||
commandPath: ['orchestration', 'worker-start'],
|
||||
worktreeSelector: 'id:github:stablyai/orca'
|
||||
})
|
||||
|
||||
expect(output).toContain('No Orca workspace matched the worktree selector')
|
||||
expect(output).toContain('id:github:stablyai/orca')
|
||||
expect(output).toContain('Did you mean: id:github:stablyai/orca::<absolute-path>')
|
||||
expect(output).toContain('Valid selector forms:')
|
||||
expect(output).toContain('a bare repository id is not a worktree id')
|
||||
})
|
||||
|
||||
it('carries the same recovery into the --json failure envelope', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
reportCliError(selectorNotFound(), true, {
|
||||
commandPath: ['terminal', 'create'],
|
||||
worktreeSelector: 'path:/nope'
|
||||
})
|
||||
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
|
||||
error: {
|
||||
code: 'selector_not_found',
|
||||
data: { selector: 'path:/nope', validSelectorForms: expect.arrayContaining(['current']) }
|
||||
}
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the selector grammar when the error already carries mutation recovery data', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
reportCliError(
|
||||
new RuntimeRpcFailureError({
|
||||
id: 'req_selector',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'selector_not_found',
|
||||
message: 'selector_not_found',
|
||||
// The mutation-recovery layer already attached its request id.
|
||||
data: { orchestrationRequestId: 'req_abc', nextSteps: ['Run request-show first.'] }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime_local' }
|
||||
}),
|
||||
true,
|
||||
{ commandPath: ['orchestration', 'worker-start'], worktreeSelector: 'bare-repo-id' }
|
||||
)
|
||||
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
|
||||
error: {
|
||||
data: {
|
||||
orchestrationRequestId: 'req_abc',
|
||||
selector: 'bare-repo-id',
|
||||
validSelectorForms: expect.arrayContaining(['current']),
|
||||
nextSteps: expect.arrayContaining(['Run request-show first.'])
|
||||
}
|
||||
}
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps both recoveries in the text message for a local selector error', () => {
|
||||
const output = formatCliError(
|
||||
new RuntimeClientError('selector_not_found', 'selector_not_found', {
|
||||
orchestrationRequestId: 'req_abc',
|
||||
nextSteps: ['Run request-show first.']
|
||||
}),
|
||||
{ worktreeSelector: 'bare-repo-id' }
|
||||
)
|
||||
|
||||
expect(output).toContain('Valid selector forms:')
|
||||
expect(output).toContain('Run request-show first.')
|
||||
})
|
||||
|
||||
it('stays silent when no worktree selector was passed', () => {
|
||||
expect(formatCliError(selectorNotFound(), { commandPath: ['worktree', 'show'] })).toBe(
|
||||
'selector_not_found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CLI error recovery', () => {
|
||||
it('prints did-you-mean next steps for an unknown-command error carrying data', () => {
|
||||
const error = new RuntimeClientError('invalid_argument', 'Unknown command: worktree remov', {
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@ import type { CliStatusResult } from '../shared/runtime-types'
|
||||
import { prepareComputerCliJsonResult } from './computer-format'
|
||||
import type { RuntimeRpcSuccess } from './runtime-client'
|
||||
|
||||
export { formatCliError, reportCliError } from './cli-error'
|
||||
export { formatCliError, reportCliError, type CliErrorContext } from './cli-error'
|
||||
|
||||
export {
|
||||
formatBrowserProfileList,
|
||||
@@ -40,7 +40,8 @@ export {
|
||||
formatTerminalSend,
|
||||
formatTerminalShow,
|
||||
formatTerminalSplit,
|
||||
formatTerminalWait
|
||||
formatTerminalWait,
|
||||
terminalSendWarnings
|
||||
} from './terminal-format'
|
||||
export {
|
||||
formatAutomationList,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
|
||||
export type BundledSkillGuideReference = {
|
||||
name: string
|
||||
markdown: string
|
||||
}
|
||||
|
||||
export type BundledSkillGuide = {
|
||||
name: string
|
||||
description: string
|
||||
markdown: string
|
||||
fullMarkdown: string
|
||||
aliases: readonly string[]
|
||||
references: readonly BundledSkillGuideReference[]
|
||||
}
|
||||
|
||||
function canonicalGuides(guides: readonly BundledSkillGuide[]): BundledSkillGuide[] {
|
||||
return [...guides].sort((left, right) =>
|
||||
left.name < right.name ? -1 : left.name > right.name ? 1 : 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the embedded guide table in canonical order. Deferred because the table is
|
||||
* large and unrelated CLI commands must not pay its module-load cost at startup.
|
||||
*/
|
||||
export async function loadCanonicalGuides(): Promise<BundledSkillGuide[]> {
|
||||
const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js')
|
||||
return canonicalGuides(BUNDLED_SKILL_GUIDES)
|
||||
}
|
||||
|
||||
export function requireTopic(
|
||||
flags: Map<string, string | boolean>,
|
||||
guides: BundledSkillGuide[]
|
||||
): BundledSkillGuide {
|
||||
const availableTopics = guides.map((guide) => guide.name).join(', ')
|
||||
const topic = flags.get('topic')
|
||||
if (typeof topic !== 'string' || topic.length === 0) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Missing skill topic. Available topics: ${availableTopics}`
|
||||
)
|
||||
}
|
||||
// Why: installed stubs may retain an old topic forever, so aliases and canonical
|
||||
// names share one lookup table instead of being treated as transient CLI aliases.
|
||||
const guideByTopic = new Map<string, BundledSkillGuide>(
|
||||
guides.flatMap((guide) => [guide.name, ...guide.aliases].map((name) => [name, guide]))
|
||||
)
|
||||
const guide = guideByTopic.get(topic)
|
||||
if (!guide) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown skill topic "${topic}". Available topics: ${availableTopics}`
|
||||
)
|
||||
}
|
||||
return guide
|
||||
}
|
||||
@@ -5,7 +5,8 @@ const getTerminalHandleMock = vi.hoisted(() => vi.fn())
|
||||
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
const originalPaneKey = process.env.ORCA_PANE_KEY
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
const printResultMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../format', () => ({ printResult: printResultMock }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
@@ -13,6 +14,7 @@ import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
describe('orchestration check identity', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset().mockResolvedValue({ result: { messages: [], count: 0 } })
|
||||
printResultMock.mockReset()
|
||||
getTerminalHandleMock.mockReset()
|
||||
delete process.env.ORCA_TERMINAL_HANDLE
|
||||
delete process.env.ORCA_PANE_KEY
|
||||
@@ -31,12 +33,12 @@ describe('orchestration check identity', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const invokeCheck = (flags: Map<string, string | boolean>) =>
|
||||
const invokeCheck = (flags: Map<string, string | boolean>, json = true) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration check']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
json
|
||||
} as never)
|
||||
|
||||
it('carries the caller pane key when the environment handle may be stale', async () => {
|
||||
@@ -91,4 +93,20 @@ describe('orchestration check identity', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'surfaces a stale --terminal refusal instead of an empty inbox (json=%s)',
|
||||
async (json) => {
|
||||
callMock.mockRejectedValue(
|
||||
Object.assign(new Error('Terminal term_gone has no live pane bound to a Run'), {
|
||||
code: 'stable_pane_required'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
invokeCheck(new Map<string, string | boolean>([['terminal', 'term_gone']]), json)
|
||||
).rejects.toMatchObject({ code: 'stable_pane_required' })
|
||||
expect(printResultMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -164,6 +164,42 @@ it('normalizes compatibility-read failures to operation_unknown', async () => {
|
||||
).rejects.toMatchObject({ code: 'operation_unknown' })
|
||||
})
|
||||
|
||||
it('preserves the worker_done mutation identity when post-verification fails', async () => {
|
||||
callMock
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
message: { id: 'msg_unconfirmed', run_id: 'run_1' },
|
||||
mutation: { requestId: 'mutation_worker_done', replayed: false }
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: { dispatch: { id: 'ctx_1', status: 'dispatched' } }
|
||||
})
|
||||
.mockResolvedValueOnce({ result: { tasks: [] } })
|
||||
|
||||
await expect(
|
||||
ORCHESTRATION_HANDLERS['orchestration send']({
|
||||
flags: new Map([
|
||||
['from', 'term_worker'],
|
||||
['subject', 'done'],
|
||||
['type', 'worker_done'],
|
||||
['task-id', 'task_1'],
|
||||
['dispatch-id', 'ctx_1'],
|
||||
['outcome', 'succeeded']
|
||||
]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
).rejects.toMatchObject({
|
||||
code: 'operation_unknown',
|
||||
data: { orchestrationRequestId: 'mutation_worker_done' },
|
||||
message: expect.stringMatching(
|
||||
/Do not send a new completion.*--retry-request mutation_worker_done/s
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a legacy response only after the authoritative dispatch is terminal', async () => {
|
||||
callMock
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('extracted orchestration worker formatting', () => {
|
||||
expect(
|
||||
formatWorkerRead({
|
||||
source: 'transcript',
|
||||
provider: 'codex',
|
||||
transcript: {
|
||||
messages: [
|
||||
{
|
||||
@@ -74,11 +75,20 @@ describe('extracted orchestration worker formatting', () => {
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
nextCursor: 'owr1_next',
|
||||
limited: false,
|
||||
returnedMessageCount: 1
|
||||
},
|
||||
cursor: 'owr1_next',
|
||||
fallbackReason: null,
|
||||
warnings: []
|
||||
} as never)
|
||||
).toBe(
|
||||
'[assistant] working\n[tool inspect] [unserializable input]\n[tool result error] failed\n[image] https://example.test/proof.png'
|
||||
'Source: transcript (provider=codex)\n' +
|
||||
'Archived: false\n' +
|
||||
'Continuation cursor (opaque; pass unchanged to --cursor): owr1_next\n\n' +
|
||||
'[assistant] working\n[tool inspect] [unserializable input]\n[tool result error] failed\n[image] https://example.test/proof.png'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
|
||||
// Why: isolate the handler's flag-to-param mapping; printResult only writes output.
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
import { printResult } from '../format'
|
||||
|
||||
async function runTaskListBrief(): Promise<{
|
||||
result: { tasks: { spec: string; spec_truncated: boolean }[] }
|
||||
}> {
|
||||
vi.mocked(printResult).mockClear()
|
||||
await ORCHESTRATION_HANDLERS['orchestration task-list']({
|
||||
flags: new Map([['brief', true]]),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
return vi.mocked(printResult).mock.calls[0]?.[0] as {
|
||||
result: { tasks: { spec: string; spec_truncated: boolean }[] }
|
||||
}
|
||||
}
|
||||
|
||||
describe('orchestration task-list brief output', () => {
|
||||
it('requests server-side brief and falls back client-side for older runtimes', async () => {
|
||||
callMock.mockReset().mockResolvedValue({
|
||||
result: {
|
||||
// No spec_truncated field — the pre-brief-runtime signature.
|
||||
tasks: [{ id: 'task_1', spec: `First line\n${'detail '.repeat(40)}`, status: 'ready' }],
|
||||
count: 1
|
||||
}
|
||||
})
|
||||
|
||||
const response = await runTaskListBrief()
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.taskList',
|
||||
expect.objectContaining({ brief: true })
|
||||
)
|
||||
expect(response.result.tasks[0].spec).toHaveLength(160)
|
||||
expect(response.result.tasks[0].spec_truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('passes server-abbreviated rows through untouched', async () => {
|
||||
const serverTasks = [
|
||||
{ id: 'task_1', spec: 'already brief…', status: 'ready', spec_truncated: true }
|
||||
]
|
||||
callMock.mockReset().mockResolvedValue({ result: { tasks: serverTasks, count: 1 } })
|
||||
|
||||
const response = await runTaskListBrief()
|
||||
|
||||
// Why: re-abbreviating a server-truncated spec would flip spec_truncated
|
||||
// back to false (the truncated text fits the cap).
|
||||
expect(response.result.tasks).toBe(serverTasks)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
const originalExitCode = process.exitCode
|
||||
const originalCliCommand = process.env.ORCA_CLI_COMMAND
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
|
||||
@@ -21,6 +23,17 @@ describe('orchestration timeout flag validation', () => {
|
||||
callMock.mockReset()
|
||||
delete process.env.ORCA_TERMINAL_HANDLE
|
||||
delete process.env.ORCA_PANE_KEY
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode
|
||||
if (originalCliCommand === undefined) {
|
||||
delete process.env.ORCA_CLI_COMMAND
|
||||
} else {
|
||||
process.env.ORCA_CLI_COMMAND = originalCliCommand
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const invokeCheck = (flags: Map<string, string | boolean>) =>
|
||||
@@ -39,6 +52,14 @@ describe('orchestration timeout flag validation', () => {
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
const invokePlainAsk = (flags: Map<string, string | boolean>) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration ask']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => {
|
||||
await expect(
|
||||
invokeCheck(
|
||||
@@ -221,6 +242,37 @@ describe('orchestration timeout flag validation', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('prints the pending message ID and exact capability-bound resume command on timeout', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
process.env.ORCA_CLI_COMMAND = 'orca-dev'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
answer: null,
|
||||
messageId: 'msg_question',
|
||||
threadId: 'thread_question',
|
||||
timedOut: true,
|
||||
timeoutMs: 30_000
|
||||
}
|
||||
})
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await invokePlainAsk(
|
||||
new Map<string, string | boolean>([
|
||||
['question', 'Proceed?'],
|
||||
['dispatch-capability', 'dcap_secret'],
|
||||
['timeout-ms', '30000']
|
||||
])
|
||||
)
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'ask timeout after 30000ms; question is still pending (messageId: msg_question). ' +
|
||||
'Resume waiting; do not ask again:\n' +
|
||||
'orca-dev orchestration ask --from term_worker --dispatch-capability dcap_secret ' +
|
||||
'--resume msg_question --timeout-ms 30000'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects ambiguous ask create/resume input before RPC', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
await expect(
|
||||
|
||||
@@ -2,12 +2,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
const originalExitCode = process.exitCode
|
||||
const originalCliCommand = process.env.ORCA_CLI_COMMAND
|
||||
|
||||
type RecoveryWorkerStartResult = {
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
state: string
|
||||
effects: unknown[]
|
||||
residualResources: unknown[]
|
||||
nextCommands: string[]
|
||||
}
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
import { printResult } from '../format'
|
||||
import { BOOLEAN_FLAGS, parseArgs } from '../args'
|
||||
import { formatCommandHelp } from '../help'
|
||||
import { ORCHESTRATION_WORKER_COMMAND_SPECS } from '../specs/orchestration-worker-specs'
|
||||
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
|
||||
describe('orchestration worker-start CLI contract', () => {
|
||||
@@ -15,18 +28,24 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
callMock.mockReset()
|
||||
vi.mocked(printResult).mockReset()
|
||||
process.exitCode = undefined
|
||||
delete process.env.ORCA_CLI_COMMAND
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode
|
||||
if (originalCliCommand === undefined) {
|
||||
delete process.env.ORCA_CLI_COMMAND
|
||||
} else {
|
||||
process.env.ORCA_CLI_COMMAND = originalCliCommand
|
||||
}
|
||||
})
|
||||
|
||||
const invokeWorkerStart = (flags: Map<string, string | boolean>) =>
|
||||
const invokeWorkerStart = (flags: Map<string, string | boolean>, json = true) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration worker-start']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
json
|
||||
} as never)
|
||||
|
||||
it('passes the complete supported creation contract and retry receipt', async () => {
|
||||
@@ -56,7 +75,7 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
['timeout-ms', '90000'],
|
||||
['run', 'run_1'],
|
||||
['from', 'term_coord'],
|
||||
['retry-request', 'request_1']
|
||||
['retry-request', '44444444-4444-4444-8444-444444444444']
|
||||
])
|
||||
)
|
||||
|
||||
@@ -80,7 +99,7 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
from: 'term_coord',
|
||||
devMode: false
|
||||
},
|
||||
{ orchestrationRequestId: 'request_1' }
|
||||
{ orchestrationRequestId: '44444444-4444-4444-8444-444444444444' }
|
||||
)
|
||||
expect(process.exitCode).toBeUndefined()
|
||||
})
|
||||
@@ -125,6 +144,23 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards --spec without a task for atomic creation', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: { runId: 'run_1', taskId: 'task_new', dispatchId: 'ctx_1', state: 'ready' }
|
||||
})
|
||||
await invokeWorkerStart(
|
||||
new Map<string, string | boolean>([
|
||||
['spec', 'Implement atomic start'],
|
||||
['agent', 'codex'],
|
||||
['from', 'term_coord']
|
||||
])
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.workerStart',
|
||||
expect.objectContaining({ task: undefined, spec: 'Implement atomic start' })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails before worker-start when the runtime would strip launch preferences', async () => {
|
||||
callMock.mockResolvedValueOnce({ result: { capabilities: [] } })
|
||||
|
||||
@@ -164,6 +200,53 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['JSON', 'orca-dev', true],
|
||||
['plain', 'orca-ide', false]
|
||||
] as const)(
|
||||
'renders %s recovery commands through the resolved %s executable',
|
||||
async (_format, executable, json) => {
|
||||
process.env.ORCA_CLI_COMMAND = executable
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
taskId: 'task_1',
|
||||
dispatchId: 'ctx_unknown',
|
||||
state: 'outcome_unknown',
|
||||
effects: [],
|
||||
residualResources: [],
|
||||
nextCommands: [
|
||||
'orca orchestration worker-show --dispatch ctx_unknown --json',
|
||||
'orca orchestration worker-abandon --dispatch ctx_unknown --json'
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
await invokeWorkerStart(
|
||||
new Map<string, string | boolean>([
|
||||
['task', 'task_1'],
|
||||
['agent', 'codex'],
|
||||
['from', 'term_coord']
|
||||
]),
|
||||
json
|
||||
)
|
||||
|
||||
const [response, , formatter] = vi.mocked(printResult).mock.calls[0] as [
|
||||
{ result: RecoveryWorkerStartResult },
|
||||
boolean,
|
||||
(result: RecoveryWorkerStartResult) => string
|
||||
]
|
||||
expect(response.result.nextCommands).toEqual([
|
||||
`${executable} orchestration worker-show --dispatch ctx_unknown --json`,
|
||||
`${executable} orchestration worker-abandon --dispatch ctx_unknown --json`
|
||||
])
|
||||
if (!json) {
|
||||
expect(formatter(response.result)).toContain(
|
||||
`Next command: ${executable} orchestration worker-show --dispatch ctx_unknown --json`
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('prints the Structured Chat recovery action for a refused worker start', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
@@ -342,4 +425,327 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
source: 'transcript'
|
||||
})
|
||||
})
|
||||
|
||||
it('formats a legacy worker-list response without projection or page fields', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
workers: [
|
||||
{
|
||||
dispatchId: 'ctx_legacy',
|
||||
taskId: 'task_legacy',
|
||||
runId: 'run_legacy',
|
||||
workerState: 'ready',
|
||||
dispatchStatus: 'dispatched',
|
||||
agentTerminalHandle: 'term_legacy',
|
||||
terminalState: 'active',
|
||||
resource: null
|
||||
}
|
||||
],
|
||||
counts: { active: 1 }
|
||||
}
|
||||
})
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map(),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as
|
||||
| ((result: { workers: unknown[]; counts: Record<string, number> }) => string)
|
||||
| undefined
|
||||
expect(
|
||||
formatter?.({
|
||||
workers: [
|
||||
{
|
||||
dispatchId: 'ctx_legacy',
|
||||
taskId: 'task_legacy',
|
||||
runId: 'run_legacy',
|
||||
workerState: 'ready',
|
||||
dispatchStatus: 'dispatched',
|
||||
agentTerminalHandle: 'term_legacy',
|
||||
terminalState: 'active',
|
||||
resource: null
|
||||
}
|
||||
],
|
||||
counts: { active: 1 }
|
||||
})
|
||||
).toContain('ctx_legacy task=task_legacy [ready] terminal=active')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['--include-remote', new Map<string, string | boolean>([['include-remote', true]])],
|
||||
['--limit', new Map<string, string | boolean>([['limit', '10']])],
|
||||
['--cursor', new Map<string, string | boolean>([['cursor', 'legacy_cursor']])]
|
||||
])('fails closed when an older runtime strips explicit %s semantics', async (_flag, flags) => {
|
||||
callMock.mockResolvedValue({ result: { workers: [], counts: {} } })
|
||||
|
||||
await expect(
|
||||
ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
).rejects.toMatchObject({ code: 'incompatible_runtime' })
|
||||
|
||||
// The extra call is the bound-Run lookup; the enumeration itself must not be retried.
|
||||
expect(
|
||||
callMock.mock.calls.filter(([method]) => method === 'orchestration.workerList')
|
||||
).toHaveLength(1)
|
||||
expect(printResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prints each projected row with its literal next-action argv', async () => {
|
||||
const response = {
|
||||
result: {
|
||||
workers: [
|
||||
{
|
||||
dispatchId: 'ctx_live',
|
||||
taskId: 'task_live',
|
||||
runId: 'run_1',
|
||||
workerState: 'running',
|
||||
dispatchStatus: 'dispatched',
|
||||
agentTerminalHandle: 'term_live',
|
||||
terminalState: 'active',
|
||||
resource: null,
|
||||
projection: {
|
||||
provider: { id: 'claude', model: 'opus' },
|
||||
host: { id: 'local' },
|
||||
workspace: { id: 'ws_1' },
|
||||
stage: { activity: 'working' },
|
||||
liveness: { verdict: 'live' },
|
||||
nextAction: {
|
||||
argv: ['orchestration', 'worker-release', '--dispatch', 'ctx_live']
|
||||
},
|
||||
attention: { categories: ['settled'] }
|
||||
}
|
||||
},
|
||||
{
|
||||
dispatchId: 'ctx_done',
|
||||
taskId: 'task_done',
|
||||
runId: 'run_1',
|
||||
workerState: 'released',
|
||||
dispatchStatus: 'completed',
|
||||
agentTerminalHandle: null,
|
||||
terminalState: null,
|
||||
resource: null,
|
||||
projection: {
|
||||
provider: null,
|
||||
host: { id: 'local' },
|
||||
workspace: null,
|
||||
stage: { activity: 'released' },
|
||||
liveness: { verdict: 'exited' },
|
||||
nextAction: { argv: [] },
|
||||
attention: { categories: [] }
|
||||
}
|
||||
}
|
||||
],
|
||||
counts: { active: 1 },
|
||||
page: { total: 2, hasMore: false, nextCursor: null }
|
||||
}
|
||||
}
|
||||
callMock.mockResolvedValue(response)
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map<string, string | boolean>(),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as
|
||||
| ((result: (typeof response)['result']) => string)
|
||||
| undefined
|
||||
const output = formatter?.(response.result)
|
||||
expect(output).toContain(
|
||||
'ctx_live task=task_live [running/working] attention=settled liveness=live provider=claude/opus host=local workspace=ws_1 terminal=active next=orchestration worker-release --dispatch ctx_live'
|
||||
)
|
||||
expect(output).toContain(
|
||||
'ctx_done task=task_done [released/released] attention=none liveness=exited provider=unknown host=local workspace=unknown terminal=none next=none'
|
||||
)
|
||||
})
|
||||
|
||||
it('prints partial host warnings alongside worker rows', async () => {
|
||||
const response = {
|
||||
result: {
|
||||
workers: [
|
||||
{
|
||||
dispatchId: 'ctx_remote',
|
||||
taskId: 'task_remote',
|
||||
runId: 'run_1',
|
||||
workerState: 'running',
|
||||
dispatchStatus: 'dispatched',
|
||||
agentTerminalHandle: 'term_remote',
|
||||
terminalState: 'active',
|
||||
resource: null
|
||||
}
|
||||
],
|
||||
counts: { active: 1 },
|
||||
page: { total: 1, hasMore: false, nextCursor: null },
|
||||
partialHostErrors: [
|
||||
{
|
||||
environmentId: 'environment_windows',
|
||||
name: 'Windows host',
|
||||
code: 'host_unavailable',
|
||||
dispatchIds: ['ctx_remote']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
callMock.mockResolvedValue(response)
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map<string, string | boolean>([['include-remote', true]]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as
|
||||
| ((result: (typeof response)['result']) => string)
|
||||
| undefined
|
||||
const output = formatter?.(response.result)
|
||||
expect(output).toContain('ctx_remote task=task_remote [running] terminal=active')
|
||||
expect(output).toContain(
|
||||
'Warning: worker observations from Windows host (environment_windows) are incomplete: host_unavailable; dispatches=ctx_remote'
|
||||
)
|
||||
})
|
||||
|
||||
it('prints partial host warnings when no worker rows are available', async () => {
|
||||
const response = {
|
||||
result: {
|
||||
workers: [],
|
||||
counts: {},
|
||||
page: { total: 0, hasMore: false, nextCursor: null },
|
||||
partialHostErrors: [
|
||||
{
|
||||
environmentId: 'environment_linux',
|
||||
name: 'Linux host',
|
||||
code: 'capability_unsupported',
|
||||
dispatchIds: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
callMock.mockResolvedValue(response)
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map<string, string | boolean>([['include-remote', true]]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as
|
||||
| ((result: (typeof response)['result']) => string)
|
||||
| undefined
|
||||
expect(formatter?.(response.result)).toBe(
|
||||
'No workers found.\nScope: all Runs (no Run is bound to this terminal; pass --run to narrow)' +
|
||||
'\nWarning: worker observations from Linux host (environment_linux) are incomplete: capability_unsupported; dispatches=none'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves partial host errors in JSON output', async () => {
|
||||
const response = {
|
||||
result: {
|
||||
workers: [],
|
||||
counts: {},
|
||||
page: { total: 0, hasMore: false, nextCursor: null },
|
||||
partialHostErrors: [
|
||||
{
|
||||
environmentId: 'environment_windows',
|
||||
name: 'Windows host',
|
||||
code: 'host_unavailable',
|
||||
dispatchIds: ['ctx_remote']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
callMock.mockResolvedValue(response)
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map<string, string | boolean>([['include-remote', true]]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(printResult).toHaveBeenCalledWith(
|
||||
{ ...response, result: { ...response.result, scope: { source: 'all' } } },
|
||||
true,
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('parses, forwards, and documents the remote fleet opt-in', async () => {
|
||||
const listSpec = ORCHESTRATION_WORKER_COMMAND_SPECS.find(
|
||||
(spec) => spec.path.join(' ') === 'orchestration worker-list'
|
||||
)
|
||||
expect(BOOLEAN_FLAGS).toContain('include-remote')
|
||||
expect(
|
||||
parseArgs(['orchestration', 'worker-list', '--include-remote']).flags.get('include-remote')
|
||||
).toBe(true)
|
||||
expect(listSpec?.allowedFlags).toContain('include-remote')
|
||||
expect(formatCommandHelp(listSpec!)).toContain(
|
||||
'--include-remote Include connected-server worker observations'
|
||||
)
|
||||
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
workers: [],
|
||||
counts: {},
|
||||
page: { total: 0, hasMore: false, nextCursor: null }
|
||||
}
|
||||
})
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map<string, string | boolean>([['include-remote', true]]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.workerList',
|
||||
expect.objectContaining({ includeRemote: true, paginate: true })
|
||||
)
|
||||
|
||||
callMock.mockClear()
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map(),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
const listParams = callMock.mock.calls.find(
|
||||
([method]) => method === 'orchestration.workerList'
|
||||
)?.[1]
|
||||
expect(listParams).toHaveProperty('paginate', true)
|
||||
expect(listParams).not.toHaveProperty('includeRemote')
|
||||
})
|
||||
|
||||
it('keeps cleanup and retention TTL controls off the public CLI surface', async () => {
|
||||
const retainSpec = ORCHESTRATION_WORKER_COMMAND_SPECS.find(
|
||||
(spec) => spec.path.join(' ') === 'orchestration worker-retain'
|
||||
)
|
||||
expect(
|
||||
ORCHESTRATION_WORKER_COMMAND_SPECS.some(
|
||||
(spec) => spec.path.join(' ') === 'orchestration worker-cleanup'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(retainSpec?.allowedFlags).not.toContain('until')
|
||||
expect(retainSpec?.allowedFlags).not.toContain('policy')
|
||||
expect(ORCHESTRATION_HANDLERS['orchestration worker-cleanup']).toBeUndefined()
|
||||
|
||||
callMock.mockResolvedValue({
|
||||
result: { dispatchId: 'ctx_1', state: 'retained', processAction: 'none' }
|
||||
})
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-retain']({
|
||||
flags: new Map<string, string | boolean>([['dispatch', 'ctx_1']]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.workerRetain', { dispatch: 'ctx_1' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function requireWorkerDoneSettlement(
|
||||
tasks: { id: string; status: string; result: string | null }[]
|
||||
}>('orchestration.taskList', { status: target.expectedStatus, run: receipt.runId })
|
||||
]).catch(() => {
|
||||
throw workerDoneSettlementUnknown()
|
||||
throw workerDoneSettlementUnknown(result)
|
||||
})
|
||||
const task = taskVerification.result.tasks.find((candidate) => candidate.id === target.taskId)
|
||||
if (
|
||||
@@ -34,16 +34,32 @@ export async function requireWorkerDoneSettlement(
|
||||
return
|
||||
}
|
||||
}
|
||||
throw workerDoneSettlementUnknown()
|
||||
throw workerDoneSettlementUnknown(result)
|
||||
}
|
||||
|
||||
function workerDoneSettlementUnknown(): RuntimeClientError {
|
||||
function workerDoneSettlementUnknown(result: unknown): RuntimeClientError {
|
||||
const requestId = parseMutationRequestId(result)
|
||||
return new RuntimeClientError(
|
||||
'operation_unknown',
|
||||
'The runtime accepted worker_done but did not confirm that the exact report settled its Task and Dispatch. Retry from the assigned worker after verifying its active Dispatch.'
|
||||
requestId
|
||||
? `The runtime accepted worker_done under mutation request ${requestId}, but the post-verification read did not confirm settlement. Do not send a new completion; inspect the active Dispatch, then re-run the exact same worker_done command with --retry-request ${requestId}.`
|
||||
: 'The runtime accepted worker_done but the post-verification read did not confirm settlement. Do not send a new completion; inspect the active Dispatch and preserve the original mutation identity.',
|
||||
requestId ? { orchestrationRequestId: requestId } : undefined
|
||||
)
|
||||
}
|
||||
|
||||
function parseMutationRequestId(result: unknown): string | undefined {
|
||||
if (!result || typeof result !== 'object' || !('mutation' in result)) {
|
||||
return undefined
|
||||
}
|
||||
const mutation = (result as { mutation?: unknown }).mutation
|
||||
if (!mutation || typeof mutation !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const requestId = (mutation as { requestId?: unknown }).requestId
|
||||
return typeof requestId === 'string' && requestId.length > 0 ? requestId : undefined
|
||||
}
|
||||
|
||||
function parseWorkerDoneReceipt(
|
||||
result: unknown
|
||||
): { messageId: string; runId: string; fromHandle?: string } | undefined {
|
||||
|
||||
@@ -122,14 +122,17 @@ describe('orchestration send structured payload flags', () => {
|
||||
['type', 'heartbeat'],
|
||||
['dispatch-id', 'ctx_1'],
|
||||
['dispatch-capability', 'dcap_secret'],
|
||||
['retry-request', 'mutation_1']
|
||||
['retry-request', '33333333-3333-4333-8333-333333333333']
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.send',
|
||||
expect.not.objectContaining({ dispatchCapability: expect.anything() }),
|
||||
{ orchestrationCapability: 'dcap_secret', orchestrationRequestId: 'mutation_1' }
|
||||
{
|
||||
orchestrationCapability: 'dcap_secret',
|
||||
orchestrationRequestId: '33333333-3333-4333-8333-333333333333'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -831,6 +834,29 @@ describe('orchestration timeout flag validation', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('envelopes ask --json through the shared result printer', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
const response = {
|
||||
id: 'req_ask',
|
||||
ok: true,
|
||||
result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false },
|
||||
_meta: { runtimeId: 'runtime_1' }
|
||||
}
|
||||
callMock.mockResolvedValue(response)
|
||||
vi.mocked(printResult).mockClear()
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await invokeAsk(
|
||||
new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
['question', 'Proceed?']
|
||||
])
|
||||
)
|
||||
|
||||
expect(printResult).toHaveBeenCalledWith(response, true, expect.any(Function))
|
||||
expect(logSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes an ask resume without creating a new question payload', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({
|
||||
@@ -875,53 +901,3 @@ describe('orchestration timeout flag validation', () => {
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration task-list brief output', () => {
|
||||
it('requests server-side brief and falls back client-side for older runtimes', async () => {
|
||||
callMock.mockReset().mockResolvedValue({
|
||||
result: {
|
||||
// No spec_truncated field — the pre-brief-runtime signature.
|
||||
tasks: [{ id: 'task_1', spec: `First line\n${'detail '.repeat(40)}`, status: 'ready' }],
|
||||
count: 1
|
||||
}
|
||||
})
|
||||
vi.mocked(printResult).mockClear()
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration task-list']({
|
||||
flags: new Map([['brief', true]]),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.taskList',
|
||||
expect.objectContaining({ brief: true })
|
||||
)
|
||||
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
|
||||
result: { tasks: { spec: string; spec_truncated: boolean }[] }
|
||||
}
|
||||
expect(response.result.tasks[0].spec).toHaveLength(160)
|
||||
expect(response.result.tasks[0].spec_truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('passes server-abbreviated rows through untouched', async () => {
|
||||
const serverTasks = [
|
||||
{ id: 'task_1', spec: 'already brief…', status: 'ready', spec_truncated: true }
|
||||
]
|
||||
callMock.mockReset().mockResolvedValue({ result: { tasks: serverTasks, count: 1 } })
|
||||
vi.mocked(printResult).mockClear()
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration task-list']({
|
||||
flags: new Map([['brief', true]]),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
|
||||
result: { tasks: { spec: string; spec_truncated: boolean }[] }
|
||||
}
|
||||
// Why: re-abbreviating a server-truncated spec would flip spec_truncated
|
||||
// back to false (the truncated text fits the cap).
|
||||
expect(response.result.tasks).toBe(serverTasks)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RuntimeClient } from '../../runtime-client'
|
||||
import { getOptionalStringFlag } from '../../flags'
|
||||
import { readRetryRequestFlag } from '../../retry-request-flag'
|
||||
import { orchestrationMutationRecoveryError } from '../../orchestration-mutation-recovery'
|
||||
|
||||
export function callOrchestrationMutation<TResult>(
|
||||
@@ -9,7 +9,7 @@ export function callOrchestrationMutation<TResult>(
|
||||
params: unknown,
|
||||
options?: { timeoutMs?: number; orchestrationCapability?: string }
|
||||
) {
|
||||
const requestId = getOptionalStringFlag(flags, 'retry-request')
|
||||
const requestId = readRetryRequestFlag(flags)
|
||||
const result = requestId
|
||||
? client.call<TResult>(method, params, { ...options, orchestrationRequestId: requestId })
|
||||
: options
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { CommandHandler } from '../../dispatch'
|
||||
import { getOptionalStringFlag } from '../../flags'
|
||||
import { printResult } from '../../format'
|
||||
import { renderCommand } from '../../orchestration-mutation-recovery'
|
||||
import { RuntimeClientError } from '../../runtime-client'
|
||||
import { resolveOrchestrationCliExecutable } from '../../runtime/orchestration-recovery-command'
|
||||
import {
|
||||
clampOrchestrationAskTimeoutMs,
|
||||
resolveOrchestrationAskClientTimeoutMs
|
||||
@@ -65,9 +68,9 @@ export const ORCHESTRATION_QUESTION_HANDLER: Record<string, CommandHandler> = {
|
||||
orchestrationCapability: getOptionalStringFlag(flags, 'dispatch-capability')
|
||||
}
|
||||
)
|
||||
// Why: ask JSON is intentionally a bare object for `jq -r .answer`, unlike other verbs.
|
||||
// Why: same {ok, result} envelope as every sibling verb; ask used to print a bare object.
|
||||
if (json) {
|
||||
console.log(JSON.stringify(result.result))
|
||||
printResult(result, true, () => '')
|
||||
} else if (result.result.legacyCompatibility?.resumeRequired) {
|
||||
console.log(`Question ${result.result.messageId} committed.`)
|
||||
console.log(`Resume with: ${result.result.legacyCompatibility.resumeCommand}`)
|
||||
@@ -91,7 +94,28 @@ export const ORCHESTRATION_QUESTION_HANDLER: Record<string, CommandHandler> = {
|
||||
if (!json) {
|
||||
// Why: report the server's clamped effective budget rather than overstating the wait.
|
||||
const waitedMs = result.result.timeoutMs ?? timeoutMs
|
||||
console.error(`ask timeout after ${waitedMs}ms (thread ${result.result.threadId})`)
|
||||
const messageId = result.result.messageId
|
||||
const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability')
|
||||
const resumeCommand =
|
||||
messageId === null
|
||||
? undefined
|
||||
: renderCommand([
|
||||
resolveOrchestrationCliExecutable(),
|
||||
'orchestration',
|
||||
'ask',
|
||||
'--from',
|
||||
from,
|
||||
...(dispatchCapability ? ['--dispatch-capability', dispatchCapability] : []),
|
||||
'--resume',
|
||||
messageId,
|
||||
'--timeout-ms',
|
||||
String(waitedMs)
|
||||
])
|
||||
console.error(
|
||||
resumeCommand
|
||||
? `ask timeout after ${waitedMs}ms; question is still pending (messageId: ${messageId}). Resume waiting; do not ask again:\n${resumeCommand}`
|
||||
: `ask timeout after ${waitedMs}ms; question identity was not returned, so it cannot be resumed safely.`
|
||||
)
|
||||
}
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommandHandler } from '../../dispatch'
|
||||
import { printResult } from '../../format'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags'
|
||||
import { getOptionalStringFlag } from '../../flags'
|
||||
import { RuntimeClientError } from '../../runtime-client'
|
||||
import type { RuntimeStatus } from '../../../shared/runtime-types'
|
||||
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
@@ -8,6 +8,8 @@ import { callOrchestrationMutation } from './mutation-request'
|
||||
import { getOptionalPositiveIntegerValueFlag } from './numeric-flags'
|
||||
import { isDevCliInvocation } from './runtime-compatibility'
|
||||
import { resolveCoordinatorTerminalHandle } from './terminal-identity'
|
||||
import { formatWorkerStart } from './worker-output'
|
||||
import { renderResolvedOrchestrationCommand } from '../../orchestration-mutation-recovery'
|
||||
|
||||
export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record<string, CommandHandler> = {
|
||||
'orchestration worker-start': async ({ flags, client, cwd, json }) => {
|
||||
@@ -26,6 +28,11 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record<string, CommandHandler>
|
||||
)
|
||||
}
|
||||
}
|
||||
const task = getOptionalStringFlag(flags, 'task')
|
||||
const spec = getOptionalStringFlag(flags, 'spec')
|
||||
const taskTitle = getOptionalStringFlag(flags, 'task-title')
|
||||
const deps = getOptionalStringFlag(flags, 'deps')
|
||||
const parent = getOptionalStringFlag(flags, 'parent')
|
||||
const result = await callOrchestrationMutation<{
|
||||
runId: string
|
||||
taskId: string
|
||||
@@ -36,8 +43,13 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record<string, CommandHandler>
|
||||
warning?: string
|
||||
effects: unknown[]
|
||||
residualResources: unknown[]
|
||||
nextCommands?: string[]
|
||||
}>(client, flags, 'orchestration.workerStart', {
|
||||
task: getRequiredStringFlag(flags, 'task'),
|
||||
task,
|
||||
...(spec ? { spec } : {}),
|
||||
...(taskTitle ? { taskTitle } : {}),
|
||||
...(deps ? { deps } : {}),
|
||||
...(parent ? { parent } : {}),
|
||||
on: getOptionalStringFlag(flags, 'on'),
|
||||
worktree: getOptionalStringFlag(flags, 'worktree'),
|
||||
name: getOptionalStringFlag(flags, 'name'),
|
||||
@@ -59,12 +71,17 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record<string, CommandHandler>
|
||||
if (result.result.state !== 'ready') {
|
||||
process.exitCode = 1
|
||||
}
|
||||
printResult(result, json, (worker) => {
|
||||
const base = `Worker ${worker.dispatchId} [${worker.state}] for ${worker.taskId}`
|
||||
if (worker.lastError) {
|
||||
return `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}`
|
||||
}
|
||||
return worker.warning ? `${base}\nWarning: ${worker.warning}` : base
|
||||
})
|
||||
const renderedResult = result.result.nextCommands
|
||||
? {
|
||||
...result,
|
||||
result: {
|
||||
...result.result,
|
||||
nextCommands: result.result.nextCommands.map((command) =>
|
||||
renderResolvedOrchestrationCommand(command)
|
||||
)
|
||||
}
|
||||
}
|
||||
: result
|
||||
printResult(renderedResult, json, formatWorkerStart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_HANDLERS } from '../orchestration'
|
||||
|
||||
type Call = { name: string; params: Record<string, unknown> }
|
||||
|
||||
/** The runtime half of this seam (`runCurrent` from a coordinator handle, `workerList` filtered by
|
||||
* `run`) is proven in `rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts`; this
|
||||
* half proves the handler asks exactly those two questions and reports what it decided. */
|
||||
describe('orchestration worker-list Run scope (CLI handler)', () => {
|
||||
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
let calls: Call[]
|
||||
let logged: string[]
|
||||
let boundRun: string | null
|
||||
|
||||
const client = {
|
||||
call: async (name: string, params: Record<string, unknown>) => {
|
||||
calls.push({ name, params })
|
||||
if (name === 'orchestration.runCurrent') {
|
||||
return { result: { run: boundRun ? { id: boundRun } : null } }
|
||||
}
|
||||
if (name === 'orchestration.workerList') {
|
||||
return {
|
||||
result: { workers: [], counts: {}, page: { hasMore: false, nextCursor: null, total: 0 } }
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected call ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = []
|
||||
logged = []
|
||||
boundRun = null
|
||||
vi.spyOn(console, 'log').mockImplementation((line: string) => {
|
||||
logged.push(line)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
if (originalTerminalHandle === undefined) {
|
||||
delete process.env.ORCA_TERMINAL_HANDLE
|
||||
} else {
|
||||
process.env.ORCA_TERMINAL_HANDLE = originalTerminalHandle
|
||||
}
|
||||
})
|
||||
|
||||
async function list(flags = new Map<string, string | boolean>(), json = true) {
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags,
|
||||
client,
|
||||
cwd: '/tmp/repo',
|
||||
json
|
||||
} as never)
|
||||
const listCall = calls.find((call) => call.name === 'orchestration.workerList')
|
||||
return { listCall, receipt: json ? JSON.parse(logged.at(-1)!).result : null }
|
||||
}
|
||||
|
||||
it('defaults an unscoped list to the Run bound to the calling terminal', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
|
||||
boundRun = 'run_bound'
|
||||
|
||||
const { listCall, receipt } = await list()
|
||||
|
||||
expect(calls[0]).toEqual({ name: 'orchestration.runCurrent', params: { from: 'term_coord' } })
|
||||
expect(listCall?.params.run).toBe('run_bound')
|
||||
expect(receipt.scope).toEqual({ run: 'run_bound', source: 'bound' })
|
||||
})
|
||||
|
||||
it('keeps --run as the override and never asks for the binding', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
|
||||
boundRun = 'run_bound'
|
||||
|
||||
const { listCall, receipt } = await list(new Map([['run', 'run_other']]))
|
||||
|
||||
expect(calls.map((call) => call.name)).not.toContain('orchestration.runCurrent')
|
||||
expect(listCall?.params.run).toBe('run_other')
|
||||
expect(receipt.scope).toEqual({ run: 'run_other', source: 'flag' })
|
||||
})
|
||||
|
||||
it('still lists every Run when the caller has no bound Run', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_unbound_shell'
|
||||
boundRun = null
|
||||
|
||||
const { listCall, receipt } = await list()
|
||||
|
||||
expect(listCall?.params.run).toBeUndefined()
|
||||
expect(receipt.scope).toEqual({ source: 'all' })
|
||||
})
|
||||
|
||||
it('names the scope in the human-readable receipt', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
|
||||
boundRun = 'run_bound'
|
||||
|
||||
await list(new Map(), false)
|
||||
|
||||
expect(logged.at(-1)).toContain('Scope: Run run_bound (bound to this terminal)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getOptionalStringFlag } from '../../flags'
|
||||
import type { RuntimeClient } from '../../runtime-client'
|
||||
import { resolveOrchestrationTerminalHandle } from './terminal-identity'
|
||||
|
||||
/** Which Run `worker-list` enumerated, and why. Additive: old readers ignore it. */
|
||||
export type WorkerListRunScope = { run?: string; source: 'flag' | 'bound' | 'all' }
|
||||
|
||||
/**
|
||||
* Unscoped `worker-list` returned every Dispatch the database has ever held. The bound Run is
|
||||
* the same Run `check` reads from the calling terminal, so it is the default and `--run`
|
||||
* overrides it. With no binding to read, listing everything stays the answer and the receipt
|
||||
* says which of the three it was.
|
||||
*/
|
||||
export async function resolveWorkerListRunScope(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
client: RuntimeClient
|
||||
): Promise<WorkerListRunScope> {
|
||||
const explicit = getOptionalStringFlag(flags, 'run')
|
||||
if (explicit) {
|
||||
return { run: explicit, source: 'flag' }
|
||||
}
|
||||
try {
|
||||
const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal')
|
||||
const current = await client.call<{ run: { id: string } | null }>('orchestration.runCurrent', {
|
||||
from: terminal
|
||||
})
|
||||
return current.result.run ? { run: current.result.run.id, source: 'bound' } : { source: 'all' }
|
||||
} catch {
|
||||
// No live terminal, no stable pane, or a runtime that predates runCurrent: the caller asked
|
||||
// for an inventory, so answer with the whole one rather than failing the enumeration.
|
||||
return { source: 'all' }
|
||||
}
|
||||
}
|
||||
|
||||
export function formatWorkerListScope(scope: WorkerListRunScope): string {
|
||||
if (scope.source === 'all') {
|
||||
return 'Scope: all Runs (no Run is bound to this terminal; pass --run to narrow)'
|
||||
}
|
||||
return `Scope: Run ${scope.run} (${scope.source === 'bound' ? 'bound to this terminal' : '--run'})`
|
||||
}
|
||||
@@ -15,22 +15,37 @@ import { formatWorkerRead, type LegacyWorkerReadResult } from './worker-output'
|
||||
export const ORCHESTRATION_WORKER_OBSERVATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
'orchestration worker-show': async ({ flags, client, json }) => {
|
||||
const result = await client.call<{
|
||||
dispatch: { id: string; task_id: string; status: string }
|
||||
worker: { state: string; stage: string; agent_terminal_handle: string | null }
|
||||
dispatch: { id: string; taskId: string; status: string } | null
|
||||
worker: { state: string; stage: string; agentTerminalHandle: string | null }
|
||||
projection?: { liveness: { verdict: string }; nextAction: { argv: string[] } } | null
|
||||
observation?: { agentWait?: { source: string; reason?: string } | null }
|
||||
}>('orchestration.workerShow', {
|
||||
dispatch: getRequiredStringFlag(flags, 'dispatch')
|
||||
})
|
||||
printResult(result, json, (value) => {
|
||||
const base = `${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}`
|
||||
const lines = [
|
||||
`${value.dispatch?.id ?? 'unknown'} task=${value.dispatch?.taskId ?? 'unknown'} [${value.worker.state}] stage=${value.worker.stage}`
|
||||
]
|
||||
// Why: PTY status alone read `live` for an agent that died at a trust prompt, so the
|
||||
// fleet verdict and its next action print beside it rather than in another command.
|
||||
if (value.projection) {
|
||||
lines.push(
|
||||
`Agent liveness: ${value.projection.liveness.verdict}`,
|
||||
`Next action: ${value.projection.nextAction.argv.join(' ') || 'none'}`
|
||||
)
|
||||
}
|
||||
// Why: absent means unknown on older runtimes, distinct from an evaluated null wait.
|
||||
if (value.observation === undefined || !('agentWait' in value.observation)) {
|
||||
return `${base}\nInteractive wait: unknown (not evaluated)`
|
||||
lines.push('Interactive wait: unknown (not evaluated)')
|
||||
} else if (value.observation.agentWait) {
|
||||
const wait = value.observation.agentWait
|
||||
lines.push(
|
||||
`Waiting on a human: ${wait.reason ?? 'interactive prompt'} (via ${wait.source})`
|
||||
)
|
||||
} else {
|
||||
lines.push('Interactive wait: none')
|
||||
}
|
||||
const wait = value.observation.agentWait
|
||||
return wait
|
||||
? `${base}\nWaiting on a human: ${wait.reason ?? 'interactive prompt'} (via ${wait.source})`
|
||||
: `${base}\nInteractive wait: none`
|
||||
return lines.join('\n')
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { OrchestrationFleetWorker } from '../../../shared/orchestration-fleet-projection'
|
||||
import type { OrchestrationWorkerReadResult } from '../../../shared/orchestration-worker-output'
|
||||
import { formatWorkerRead, formatWorkerStart } from './worker-output'
|
||||
|
||||
function fleetProjection(verdict: 'live' | 'unverifiable' | 'exited'): OrchestrationFleetWorker {
|
||||
return {
|
||||
id: 'dispatch_1',
|
||||
dispatchId: 'dispatch_1',
|
||||
taskId: 'task_1',
|
||||
runId: 'run_1',
|
||||
role: 'worker',
|
||||
parent: null,
|
||||
provider: { id: 'codex', model: null },
|
||||
host: { kind: 'local', id: 'local' },
|
||||
workspace: { id: 'ws_1', kind: 'folder_or_worktree' },
|
||||
stage: { worker: 'ready', dispatch: 'dispatched', detail: null, activity: 'working' },
|
||||
outcome: 'in_progress',
|
||||
liveness:
|
||||
verdict === 'live'
|
||||
? { verdict, observedAt: 1, source: 'agent_status' }
|
||||
: verdict === 'exited'
|
||||
? { verdict, source: 'execution_host' }
|
||||
: { verdict, reason: 'missing_status' },
|
||||
evidence: { durable: true, liveStatus: 'fresh', lastObservedAt: 1 },
|
||||
resource: {
|
||||
state: 'owned',
|
||||
id: 'wtr_1',
|
||||
ownerDispatchId: 'dispatch_1',
|
||||
releaseState: 'active',
|
||||
terminalState: null
|
||||
},
|
||||
nextAction: { kind: 'inspect', argv: [] },
|
||||
attention: { categories: [], requiresAction: false }
|
||||
}
|
||||
}
|
||||
|
||||
describe('worker-start plain formatting', () => {
|
||||
it('renders partial effects, residual resources, and exact recovery commands for unknown starts', () => {
|
||||
const nextCommands = [
|
||||
'orca orchestration worker-show --dispatch ctx_unknown --json',
|
||||
'orca orchestration worker-abandon --dispatch ctx_unknown --json'
|
||||
]
|
||||
|
||||
expect(
|
||||
formatWorkerStart({
|
||||
taskId: 'task_1',
|
||||
dispatchId: 'ctx_unknown',
|
||||
state: 'outcome_unknown',
|
||||
failedStage: 'dispatch_input',
|
||||
lastError: 'submission could not be observed',
|
||||
effects: [{ kind: 'terminal', id: 'term_worker' }],
|
||||
residualResources: [{ kind: 'terminal', id: 'term_worker' }],
|
||||
nextCommands
|
||||
})
|
||||
).toBe(
|
||||
'Worker ctx_unknown [outcome_unknown] for task_1\n' +
|
||||
'dispatch_input: submission could not be observed\n' +
|
||||
'Effects: [{"kind":"terminal","id":"term_worker"}]\n' +
|
||||
'Residual resources: [{"kind":"terminal","id":"term_worker"}]\n' +
|
||||
`Next command: ${nextCommands[0]}\n` +
|
||||
`Next command: ${nextCommands[1]}`
|
||||
)
|
||||
})
|
||||
|
||||
it('renders nonempty effects and residual resources for failed starts', () => {
|
||||
expect(
|
||||
formatWorkerStart({
|
||||
taskId: 'task_1',
|
||||
dispatchId: 'ctx_failed',
|
||||
state: 'failed',
|
||||
failedStage: 'terminal_create',
|
||||
lastError: 'terminal creation failed',
|
||||
effects: [{ kind: 'worktree', id: 'worktree_1' }],
|
||||
residualResources: [{ kind: 'worktree', id: 'worktree_1' }]
|
||||
})
|
||||
).toBe(
|
||||
'Worker ctx_failed [failed] for task_1\n' +
|
||||
'terminal_create: terminal creation failed\n' +
|
||||
'Effects: [{"kind":"worktree","id":"worktree_1"}]\n' +
|
||||
'Residual resources: [{"kind":"worktree","id":"worktree_1"}]'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps ready receipts concise when effects describe successful setup', () => {
|
||||
expect(
|
||||
formatWorkerStart({
|
||||
taskId: 'task_1',
|
||||
dispatchId: 'ctx_ready',
|
||||
state: 'ready',
|
||||
effects: [{ kind: 'terminal', id: 'term_worker' }],
|
||||
residualResources: []
|
||||
})
|
||||
).toBe('Worker ctx_ready [ready] for task_1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('worker-read plain formatting', () => {
|
||||
it('renders transcript provenance, incomplete coverage, warnings, and opaque cursor guidance', () => {
|
||||
expect(
|
||||
formatWorkerRead(
|
||||
workerReadResult({
|
||||
source: 'transcript',
|
||||
sourceIdentity: 'private-source-identity',
|
||||
provider: 'codex',
|
||||
transcript: {
|
||||
messages: [
|
||||
{
|
||||
id: 'message_1',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'latest output' }],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
],
|
||||
nextCursor: 'owr1_transcript',
|
||||
limited: true,
|
||||
returnedMessageCount: 1
|
||||
},
|
||||
cursor: 'owr1_transcript',
|
||||
fallbackReason: null,
|
||||
sourceExact: true,
|
||||
contentComplete: false,
|
||||
clipping: ['message_limit_or_scan_window'],
|
||||
warnings: ['Older transcript records are not pageable through this cursor.']
|
||||
})
|
||||
)
|
||||
).toBe(
|
||||
'Source: transcript (provider=codex)\n' +
|
||||
'Worker: ready\n' +
|
||||
'Archived: false\n' +
|
||||
'Source exact: true\n' +
|
||||
'Content complete: false\n' +
|
||||
'Clipping: message_limit_or_scan_window\n' +
|
||||
'Continuation cursor (opaque; pass unchanged to --cursor): owr1_transcript\n' +
|
||||
'Warning: Older transcript records are not pageable through this cursor.\n\n' +
|
||||
'[assistant] latest output'
|
||||
)
|
||||
})
|
||||
|
||||
it('labels terminal fallback evidence and every warning', () => {
|
||||
expect(
|
||||
formatWorkerRead(
|
||||
workerReadResult({
|
||||
source: 'terminal',
|
||||
sourceIdentity: 'private-source-identity',
|
||||
terminal: {
|
||||
handle: 'term_worker',
|
||||
status: 'running',
|
||||
tail: ['bounded terminal evidence'],
|
||||
truncated: true,
|
||||
nextCursor: '20'
|
||||
},
|
||||
cursor: 'owr1_terminal',
|
||||
fallbackReason: 'session_not_reported',
|
||||
sourceExact: false,
|
||||
contentComplete: false,
|
||||
clipping: ['terminal_buffer', 'terminal_fallback'],
|
||||
warnings: ['A secret was redacted.', 'One line was malformed.']
|
||||
})
|
||||
)
|
||||
).toBe(
|
||||
'Source: terminal\n' +
|
||||
'Worker: ready\n' +
|
||||
'Archived: false\n' +
|
||||
'Source exact: false\n' +
|
||||
'Fallback reason: session_not_reported\n' +
|
||||
'Content complete: false\n' +
|
||||
'Clipping: terminal_buffer, terminal_fallback\n' +
|
||||
'Continuation cursor (opaque; pass unchanged to --cursor): owr1_terminal\n' +
|
||||
'Warning: A secret was redacted.\n' +
|
||||
'Warning: One line was malformed.\n\n' +
|
||||
'bounded terminal evidence'
|
||||
)
|
||||
})
|
||||
|
||||
it('truthfully labels an exact empty transcript without reading terminal evidence', () => {
|
||||
expect(
|
||||
formatWorkerRead(
|
||||
workerReadResult({
|
||||
source: 'transcript',
|
||||
sourceIdentity: 'private-source-identity',
|
||||
provider: 'codex',
|
||||
transcript: {
|
||||
messages: [],
|
||||
nextCursor: 'owr1_empty',
|
||||
limited: false,
|
||||
returnedMessageCount: 0
|
||||
},
|
||||
cursor: 'owr1_empty',
|
||||
fallbackReason: null,
|
||||
sourceExact: true,
|
||||
contentComplete: true,
|
||||
warnings: []
|
||||
})
|
||||
)
|
||||
).toBe(
|
||||
'Source: transcript (provider=codex)\n' +
|
||||
'Worker: ready\n' +
|
||||
'Archived: false\n' +
|
||||
'Source exact: true\n' +
|
||||
'Content complete: true\n' +
|
||||
'Continuation cursor (opaque; pass unchanged to --cursor): owr1_empty\n\n' +
|
||||
'No transcript messages returned. This exact transcript read did not request terminal evidence.'
|
||||
)
|
||||
})
|
||||
it('separates the PTY verdict from the fleet agent verdict', () => {
|
||||
const output = formatWorkerRead({
|
||||
dispatchId: 'dispatch_1',
|
||||
status: { worker: 'ready', terminal: 'running', liveness: 'live' },
|
||||
projection: fleetProjection('unverifiable'),
|
||||
source: 'terminal',
|
||||
sourceIdentity: 'private-source-identity',
|
||||
terminal: {
|
||||
handle: 'term_worker',
|
||||
status: 'running',
|
||||
tail: ['tail'],
|
||||
truncated: false,
|
||||
nextCursor: null
|
||||
},
|
||||
cursor: null,
|
||||
fallbackReason: null,
|
||||
warnings: []
|
||||
})
|
||||
|
||||
expect(output).toContain('Terminal liveness: live')
|
||||
expect(output).toContain('Agent liveness: unverifiable')
|
||||
expect(output).not.toMatch(/^Liveness:/mu)
|
||||
})
|
||||
|
||||
it('omits the agent verdict when the host published no projection', () => {
|
||||
const output = formatWorkerRead({
|
||||
dispatchId: 'dispatch_1',
|
||||
status: { worker: 'ready', terminal: 'running', liveness: 'live' },
|
||||
source: 'terminal',
|
||||
sourceIdentity: 'private-source-identity',
|
||||
terminal: {
|
||||
handle: 'term_worker',
|
||||
status: 'running',
|
||||
tail: ['tail'],
|
||||
truncated: false,
|
||||
nextCursor: null
|
||||
},
|
||||
cursor: null,
|
||||
fallbackReason: null,
|
||||
warnings: []
|
||||
})
|
||||
|
||||
expect(output).toContain('Terminal liveness: live')
|
||||
expect(output).not.toContain('Agent liveness:')
|
||||
})
|
||||
|
||||
it('distinguishes a released archive read from a live one', () => {
|
||||
const output = formatWorkerRead({
|
||||
dispatchId: 'dispatch_1',
|
||||
status: { worker: 'succeeded', terminal: 'released', liveness: 'unverifiable' },
|
||||
source: 'terminal',
|
||||
sourceIdentity: 'private-source-identity',
|
||||
terminal: {
|
||||
handle: 'term_worker',
|
||||
status: 'exited',
|
||||
tail: ['archived tail'],
|
||||
truncated: false,
|
||||
nextCursor: null
|
||||
},
|
||||
cursor: null,
|
||||
fallbackReason: null,
|
||||
warnings: [],
|
||||
archived: true
|
||||
} as unknown as OrchestrationWorkerReadResult)
|
||||
|
||||
expect(output).toContain('Archived: true')
|
||||
expect(output).toContain('Terminal liveness: unverifiable')
|
||||
expect(output).toContain('Worker: succeeded')
|
||||
})
|
||||
})
|
||||
|
||||
function workerReadResult(
|
||||
value: WorkerReadResultWithoutContext<OrchestrationWorkerReadResult>
|
||||
): OrchestrationWorkerReadResult {
|
||||
return {
|
||||
dispatchId: 'dispatch_1',
|
||||
status: { worker: 'ready', terminal: 'running' },
|
||||
...value
|
||||
} as OrchestrationWorkerReadResult
|
||||
}
|
||||
|
||||
type WorkerReadResultWithoutContext<T> = T extends unknown
|
||||
? Omit<T, 'dispatchId' | 'status'>
|
||||
: never
|
||||
@@ -7,13 +7,98 @@ export type LegacyWorkerReadResult = {
|
||||
terminal: RuntimeTerminalRead
|
||||
}
|
||||
|
||||
export type WorkerStartReceipt = {
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
state: string
|
||||
failedStage?: string
|
||||
lastError?: string
|
||||
warning?: string
|
||||
effects?: unknown[]
|
||||
residualResources?: unknown[]
|
||||
nextCommands?: string[]
|
||||
}
|
||||
|
||||
export function formatWorkerStart(value: WorkerStartReceipt): string {
|
||||
const lines = [`Worker ${value.dispatchId} [${value.state}] for ${value.taskId}`]
|
||||
if (value.lastError) {
|
||||
lines.push(`${value.failedStage ?? 'start'}: ${value.lastError}`)
|
||||
} else if (value.warning) {
|
||||
lines.push(`Warning: ${value.warning}`)
|
||||
}
|
||||
if (value.state !== 'ready' && (value.state === 'outcome_unknown' || value.effects?.length)) {
|
||||
lines.push(`Effects: ${JSON.stringify(value.effects ?? [])}`)
|
||||
}
|
||||
if (
|
||||
value.state !== 'ready' &&
|
||||
(value.state === 'outcome_unknown' || value.residualResources?.length)
|
||||
) {
|
||||
lines.push(`Residual resources: ${JSON.stringify(value.residualResources ?? [])}`)
|
||||
}
|
||||
if (value.state !== 'ready') {
|
||||
lines.push(...(value.nextCommands ?? []).map((command) => `Next command: ${command}`))
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function formatWorkerRead(
|
||||
value: OrchestrationWorkerReadResult | LegacyWorkerReadResult
|
||||
): string {
|
||||
if (!('source' in value) || value.source === 'terminal') {
|
||||
if (!('source' in value)) {
|
||||
return value.terminal.tail.join('\n')
|
||||
}
|
||||
return value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n')
|
||||
const details = formatWorkerReadDetails(value)
|
||||
const output =
|
||||
value.source === 'terminal'
|
||||
? value.terminal.tail.join('\n')
|
||||
: value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n')
|
||||
if (output) {
|
||||
return `${details}\n\n${output}`
|
||||
}
|
||||
const emptyMessage =
|
||||
value.source === 'transcript'
|
||||
? 'No transcript messages returned. This exact transcript read did not request terminal evidence.'
|
||||
: 'No terminal output returned.'
|
||||
return `${details}\n\n${emptyMessage}`
|
||||
}
|
||||
|
||||
function formatWorkerReadDetails(value: OrchestrationWorkerReadResult): string {
|
||||
const source =
|
||||
value.source === 'transcript'
|
||||
? `Source: transcript (provider=${value.provider})`
|
||||
: 'Source: terminal'
|
||||
const lines = [source]
|
||||
// A released archive read otherwise prints identically to a live one.
|
||||
if (value.status?.worker) {
|
||||
lines.push(`Worker: ${value.status.worker}`)
|
||||
}
|
||||
lines.push(`Archived: ${value.archived === true}`)
|
||||
// Two different verdicts: status.liveness is the PTY's, the fleet projection is the agent's.
|
||||
if (value.status?.liveness) {
|
||||
lines.push(`Terminal liveness: ${value.status.liveness}`)
|
||||
}
|
||||
if (value.projection) {
|
||||
lines.push(`Agent liveness: ${value.projection.liveness.verdict}`)
|
||||
}
|
||||
if (value.sourceExact !== undefined) {
|
||||
lines.push(`Source exact: ${value.sourceExact}`)
|
||||
}
|
||||
if (value.fallbackReason) {
|
||||
lines.push(`Fallback reason: ${value.fallbackReason}`)
|
||||
}
|
||||
if (value.contentComplete !== undefined) {
|
||||
lines.push(`Content complete: ${value.contentComplete}`)
|
||||
}
|
||||
if (value.clipping?.length) {
|
||||
lines.push(`Clipping: ${value.clipping.join(', ')}`)
|
||||
}
|
||||
lines.push(
|
||||
value.cursor
|
||||
? `Continuation cursor (opaque; pass unchanged to --cursor): ${value.cursor}`
|
||||
: 'Continuation cursor: unavailable'
|
||||
)
|
||||
lines.push(...(value.warnings ?? []).map((warning) => `Warning: ${warning}`))
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function formatWorkerTranscriptMessage(message: NativeChatMessage): string {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { CommandHandler } from '../../dispatch'
|
||||
import { printResult } from '../../format'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags'
|
||||
import {
|
||||
getOptionalPositiveIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRequiredStringFlag
|
||||
} from '../../flags'
|
||||
import { RuntimeClientError } from '../../runtime-client'
|
||||
import { callOrchestrationMutation } from './mutation-request'
|
||||
import { formatWorkerRelease, type WorkerReleaseReceipt } from './worker-output'
|
||||
import {
|
||||
formatWorkerListScope,
|
||||
resolveWorkerListRunScope,
|
||||
type WorkerListRunScope
|
||||
} from './worker-list-run-scope'
|
||||
|
||||
const WORKER_TERMINAL_LIST_STATES = [
|
||||
'active',
|
||||
@@ -78,7 +87,7 @@ export const ORCHESTRATION_WORKER_TERMINAL_HANDLERS: Record<string, CommandHandl
|
||||
printResult(result, json, formatWorkerRelease)
|
||||
},
|
||||
|
||||
'orchestration worker-list': async ({ flags, client, json }) => {
|
||||
'orchestration worker-list': async ({ flags, client, cwd, json }) => {
|
||||
const terminalState = getOptionalStringFlag(flags, 'terminal-state')
|
||||
if (
|
||||
terminalState &&
|
||||
@@ -91,6 +100,9 @@ export const ORCHESTRATION_WORKER_TERMINAL_HANDLERS: Record<string, CommandHandl
|
||||
`invalid --terminal-state '${terminalState}', expected one of: ${WORKER_TERMINAL_LIST_STATES.join(', ')}`
|
||||
)
|
||||
}
|
||||
const scope = await resolveWorkerListRunScope(flags, cwd, client)
|
||||
const requiresCurrentListSemantics =
|
||||
flags.has('include-remote') || flags.has('cursor') || flags.has('limit')
|
||||
const result = await client.call<{
|
||||
workers: {
|
||||
dispatchId: string
|
||||
@@ -101,26 +113,77 @@ export const ORCHESTRATION_WORKER_TERMINAL_HANDLERS: Record<string, CommandHandl
|
||||
agentTerminalHandle: string | null
|
||||
terminalState: string | null
|
||||
resource: unknown
|
||||
projection?: {
|
||||
provider: { id: string; model: string | null } | null
|
||||
host: { id: string }
|
||||
workspace: { id: string } | null
|
||||
stage: { activity: string }
|
||||
liveness: { verdict: string }
|
||||
nextAction: { argv: string[] }
|
||||
attention?: { categories: string[] }
|
||||
}
|
||||
}[]
|
||||
counts: Record<string, number>
|
||||
scope?: WorkerListRunScope
|
||||
page?: { hasMore: boolean; nextCursor: string | null; total: number }
|
||||
partialHostErrors?: {
|
||||
environmentId: string
|
||||
name: string
|
||||
code: string
|
||||
dispatchIds: string[]
|
||||
}[]
|
||||
}>('orchestration.workerList', {
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
terminalState
|
||||
paginate: true,
|
||||
run: scope.run,
|
||||
terminalState,
|
||||
...(flags.has('include-remote') ? { includeRemote: true } : {}),
|
||||
cursor: getOptionalStringFlag(flags, 'cursor'),
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
|
||||
})
|
||||
printResult(result, json, (value) => {
|
||||
if (value.workers.length === 0) {
|
||||
return 'No workers found.'
|
||||
}
|
||||
const rows = value.workers
|
||||
.map(
|
||||
(worker) =>
|
||||
`${worker.dispatchId} task=${worker.taskId} [${worker.workerState}] terminal=${worker.terminalState ?? 'none'}`
|
||||
)
|
||||
.join('\n')
|
||||
if (requiresCurrentListSemantics && !result.result.page) {
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'The connected Orca runtime did not prove support for the requested worker-list flags, so no inventory was printed. Update the connected Orca runtime and retry.'
|
||||
)
|
||||
}
|
||||
printResult({ ...result, result: { ...result.result, scope } }, json, (value) => {
|
||||
const rows =
|
||||
value.workers.length === 0
|
||||
? 'No workers found.'
|
||||
: value.workers
|
||||
.map((worker) => {
|
||||
const projection = worker.projection
|
||||
const provider = projection?.provider
|
||||
? `${projection.provider.id}${projection.provider.model ? `/${projection.provider.model}` : ''}`
|
||||
: 'unknown'
|
||||
const workspace = projection?.workspace?.id ?? 'unknown'
|
||||
const stage = projection?.stage.activity ?? worker.dispatchStatus
|
||||
const liveness = projection?.liveness.verdict
|
||||
const attention = projection?.attention?.categories.join(',') || 'none'
|
||||
const details = projection
|
||||
? `/${stage}] attention=${attention} liveness=${liveness} provider=${provider} host=${projection.host.id} workspace=${workspace}`
|
||||
: `]`
|
||||
// Why: the enumerating command owes the literal argv the guides tell callers to run.
|
||||
const next = projection
|
||||
? ` next=${projection.nextAction.argv.join(' ') || 'none'}`
|
||||
: ''
|
||||
return `${worker.dispatchId} task=${worker.taskId} [${worker.workerState}${details} terminal=${worker.terminalState ?? 'none'}${next}`
|
||||
})
|
||||
.join('\n')
|
||||
const counts = Object.entries(value.counts)
|
||||
.map(([state, count]) => `${state}=${count}`)
|
||||
.join(' ')
|
||||
return counts ? `${rows}\nTerminals: ${counts}` : rows
|
||||
const pagination =
|
||||
value.page?.hasMore && value.page.nextCursor
|
||||
? `\nMore: --cursor ${value.page.nextCursor}`
|
||||
: ''
|
||||
const warnings = (value.partialHostErrors ?? []).map(
|
||||
(error) =>
|
||||
`Warning: worker observations from ${error.name} (${error.environmentId}) are incomplete: ${error.code}; dispatches=${error.dispatchIds.join(',') || 'none'}`
|
||||
)
|
||||
const warningBlock = warnings.length ? `\n${warnings.join('\n')}` : ''
|
||||
const scopeLine = `\n${formatWorkerListScope(value.scope ?? scope)}`
|
||||
return `${counts ? `${rows}\nTerminals: ${counts}` : rows}${scopeLine}${pagination}${warningBlock}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { writeStdoutLine } from '../stdout-line'
|
||||
import {
|
||||
loadCanonicalGuides,
|
||||
requireTopic,
|
||||
type BundledSkillGuide,
|
||||
type BundledSkillGuideReference
|
||||
} from './bundled-skill-guide-table'
|
||||
|
||||
type GuideSelection = { full: boolean; reference: string | null; listReferences: boolean }
|
||||
|
||||
// Why: the kernel's gate table names each document as `references/<file>.md`, so that
|
||||
// exact string must resolve as well as the bare name an agent is likely to retype.
|
||||
function normalizeReferenceSelector(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^references\//, '')
|
||||
.replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
function resolveSelection(flags: Map<string, string | boolean>): GuideSelection {
|
||||
const full = flags.has('full')
|
||||
const listReferences = flags.get('references') === true
|
||||
const requested = flags.get('reference')
|
||||
const hasReference = flags.has('reference')
|
||||
if (listReferences && full) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Use either --references or --full, not both.')
|
||||
}
|
||||
if (listReferences && hasReference) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --references or --reference, not both.'
|
||||
)
|
||||
}
|
||||
if (full && hasReference) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Use either --full or --reference, not both.')
|
||||
}
|
||||
if (hasReference && (typeof requested !== 'string' || requested.trim().length === 0)) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --reference')
|
||||
}
|
||||
return {
|
||||
full,
|
||||
reference: typeof requested === 'string' ? requested : null,
|
||||
listReferences
|
||||
}
|
||||
}
|
||||
|
||||
function requireReferences(guide: BundledSkillGuide): readonly BundledSkillGuideReference[] {
|
||||
if (guide.references.length === 0) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Guide "${guide.name}" has no bundled references.`
|
||||
)
|
||||
}
|
||||
return guide.references
|
||||
}
|
||||
|
||||
function requireReference(guide: BundledSkillGuide, requested: string): BundledSkillGuideReference {
|
||||
const references = requireReferences(guide)
|
||||
const selector = normalizeReferenceSelector(requested)
|
||||
const match = references.find((reference) => reference.name === selector)
|
||||
if (!match) {
|
||||
const available = references.map((reference) => reference.name).join(', ')
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown reference "${requested}" for ${guide.name}. Available: ${available}`
|
||||
)
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
export const SKILL_GUIDE_GET_HANDLER: Record<string, CommandHandler> = {
|
||||
'skills get': async ({ flags, json }) => {
|
||||
const selection = resolveSelection(flags)
|
||||
const guides = await loadCanonicalGuides()
|
||||
const guide = requireTopic(flags, guides)
|
||||
|
||||
if (selection.listReferences) {
|
||||
const names = requireReferences(guide).map((reference) => reference.name)
|
||||
writeStdoutLine(
|
||||
json ? JSON.stringify({ name: guide.name, references: names }, null, 2) : names.join('\n')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (selection.reference !== null) {
|
||||
const reference = requireReference(guide, selection.reference)
|
||||
writeStdoutLine(
|
||||
json
|
||||
? JSON.stringify(
|
||||
{ name: guide.name, reference: reference.name, markdown: reference.markdown },
|
||||
null,
|
||||
2
|
||||
)
|
||||
: reference.markdown
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const markdown = selection.full ? guide.fullMarkdown : guide.markdown
|
||||
writeStdoutLine(
|
||||
json
|
||||
? JSON.stringify({ name: guide.name, full: selection.full, markdown }, null, 2)
|
||||
: markdown
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { spawn } from 'node:child_process'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { getRepeatedStringFlag } from '../flags'
|
||||
import { writeStdoutLine } from '../stdout-line'
|
||||
import { loadCanonicalGuides, type BundledSkillGuide } from './bundled-skill-guide-table'
|
||||
import { SKILL_GUIDE_GET_HANDLER } from './skill-guide-get'
|
||||
import { resolveCliCommand, withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
|
||||
import { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection'
|
||||
import {
|
||||
@@ -20,51 +23,6 @@ import {
|
||||
buildAgentFeatureSkillUpdateArgs
|
||||
} from '../../shared/agent-feature-install-commands'
|
||||
|
||||
type BundledSkillGuide = {
|
||||
name: string
|
||||
description: string
|
||||
markdown: string
|
||||
fullMarkdown: string
|
||||
aliases: readonly string[]
|
||||
}
|
||||
|
||||
function canonicalGuides(guides: readonly BundledSkillGuide[]): BundledSkillGuide[] {
|
||||
return [...guides].sort((left, right) =>
|
||||
left.name < right.name ? -1 : left.name > right.name ? 1 : 0
|
||||
)
|
||||
}
|
||||
|
||||
function requireTopic(
|
||||
flags: Map<string, string | boolean>,
|
||||
guides: BundledSkillGuide[]
|
||||
): BundledSkillGuide {
|
||||
const availableTopics = guides.map((guide) => guide.name).join(', ')
|
||||
const topic = flags.get('topic')
|
||||
if (typeof topic !== 'string' || topic.length === 0) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Missing skill topic. Available topics: ${availableTopics}`
|
||||
)
|
||||
}
|
||||
// Why: installed stubs may retain an old topic forever, so aliases and canonical
|
||||
// names share one lookup table instead of being treated as transient CLI aliases.
|
||||
const guideByTopic = new Map<string, BundledSkillGuide>(
|
||||
guides.flatMap((guide) => [guide.name, ...guide.aliases].map((name) => [name, guide]))
|
||||
)
|
||||
const guide = guideByTopic.get(topic)
|
||||
if (!guide) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown skill topic "${topic}". Available topics: ${availableTopics}`
|
||||
)
|
||||
}
|
||||
return guide
|
||||
}
|
||||
|
||||
function writeStdout(value: string): void {
|
||||
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`)
|
||||
}
|
||||
|
||||
function resolveSelectedSkillNames(
|
||||
flags: Map<string, string | boolean>,
|
||||
guides: BundledSkillGuide[]
|
||||
@@ -251,14 +209,12 @@ function formatSkillSelectionHelp(verb: SkillMutationVerb, skillNames: string[])
|
||||
|
||||
function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler {
|
||||
return async ({ flags, json }) => {
|
||||
// Why: keep the large generated table off the eager handler registry path.
|
||||
const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js')
|
||||
const guides = canonicalGuides(BUNDLED_SKILL_GUIDES)
|
||||
const guides = await loadCanonicalGuides()
|
||||
const skillNames = resolveSelectedSkillNames(flags, guides)
|
||||
|
||||
if (skillNames.length === 0) {
|
||||
const names = guides.map((guide) => guide.name)
|
||||
writeStdout(
|
||||
writeStdoutLine(
|
||||
json
|
||||
? JSON.stringify({ availableSkills: names }, null, 2)
|
||||
: formatSkillSelectionHelp(verb, names)
|
||||
@@ -286,7 +242,7 @@ function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler {
|
||||
const dryRun = flags.get('dry-run') === true
|
||||
|
||||
if (dryRun) {
|
||||
writeStdout(
|
||||
writeStdoutLine(
|
||||
json
|
||||
? JSON.stringify({ command, skills: skillNames, global, executed: false }, null, 2)
|
||||
: `${command}\n\nRerun without --dry-run to ${verb} now.`
|
||||
@@ -313,31 +269,19 @@ function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler {
|
||||
|
||||
export const SKILL_HANDLERS: Record<string, CommandHandler> = {
|
||||
'skills list': async ({ json }) => {
|
||||
// Why: the embedded guide table is large, so unrelated CLI commands must not
|
||||
// pay its module-load and parse cost during startup.
|
||||
const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js')
|
||||
const guides = canonicalGuides(BUNDLED_SKILL_GUIDES)
|
||||
// Why: generated registry order is not a user-facing contract, while stable
|
||||
// canonical sorting keeps agent-visible output reproducible across builds.
|
||||
const topics = guides.map((guide) => ({
|
||||
const topics = (await loadCanonicalGuides()).map((guide) => ({
|
||||
name: guide.name,
|
||||
description: guide.description.replace(/\s+/g, ' ').trim()
|
||||
}))
|
||||
writeStdout(
|
||||
writeStdoutLine(
|
||||
json
|
||||
? JSON.stringify({ topics }, null, 2)
|
||||
: topics.map((topic) => `${topic.name}: ${topic.description}`).join('\n')
|
||||
)
|
||||
},
|
||||
'skills get': async ({ flags, json }) => {
|
||||
// Why: keep the large generated table off the eager handler registry path.
|
||||
const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js')
|
||||
const guides = canonicalGuides(BUNDLED_SKILL_GUIDES)
|
||||
const guide = requireTopic(flags, guides)
|
||||
const full = flags.has('full')
|
||||
const markdown = full ? guide.fullMarkdown : guide.markdown
|
||||
writeStdout(json ? JSON.stringify({ name: guide.name, full, markdown }, null, 2) : markdown)
|
||||
},
|
||||
...SKILL_GUIDE_GET_HANDLER,
|
||||
'skills install': createSkillMutationHandler('install'),
|
||||
'skills update': createSkillMutationHandler('update')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type {
|
||||
RuntimeTerminalClose,
|
||||
RuntimeWorktreeTerminalCloseResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatTerminalClose, reportCliError, printResult } from '../format'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { getRequiredWorktreeSelector, getTerminalHandle } from '../selectors'
|
||||
|
||||
/** A false stop receipt is an error only when the host supplied a liveness verdict. */
|
||||
function terminalCloseFailure(close: RuntimeTerminalClose): RuntimeClientError | null {
|
||||
if (close.ptyKilled || close.ptyStopVerdict === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const verdict = close.ptyStopVerdict
|
||||
const detail =
|
||||
verdict === 'live'
|
||||
? 'The PTY is live.'
|
||||
: `The PTY was not confirmed stopped: ${close.ptyStopReason ?? 'its host could not be reached'}.`
|
||||
return new RuntimeClientError(
|
||||
verdict === 'live' ? 'terminal_stop_live' : 'terminal_stop_unverifiable',
|
||||
`Terminal ${close.handle} close failed to confirm the PTY stopped (${verdict}). ${detail}`,
|
||||
{ close }
|
||||
)
|
||||
}
|
||||
|
||||
function terminalCloseAllFailure(
|
||||
close: RuntimeWorktreeTerminalCloseResult
|
||||
): RuntimeClientError | null {
|
||||
if (!close.ptyStopVerdict) {
|
||||
return null
|
||||
}
|
||||
const detail =
|
||||
close.ptyStopVerdict === 'live'
|
||||
? 'At least one PTY is live.'
|
||||
: `At least one PTY was not confirmed stopped: ${close.ptyStopReason ?? 'its owning host could not be reached'}.`
|
||||
return new RuntimeClientError(
|
||||
close.ptyStopVerdict === 'live' ? 'terminal_stop_live' : 'terminal_stop_unverifiable',
|
||||
`Workspace terminal close did not confirm every PTY stopped (${close.ptyStopVerdict}). ${detail}`,
|
||||
{ close }
|
||||
)
|
||||
}
|
||||
|
||||
export const terminalCloseHandler: CommandHandler = async ({ flags, client, cwd, json }) => {
|
||||
if (flags.get('all') === true) {
|
||||
if (flags.has('terminal') || flags.get('tab') === true) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--all uses --worktree and cannot be combined with --terminal or --tab'
|
||||
)
|
||||
}
|
||||
try {
|
||||
const result = await client.call<RuntimeWorktreeTerminalCloseResult>('terminal.closeAll', {
|
||||
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client)
|
||||
})
|
||||
const failure = terminalCloseAllFailure(result.result)
|
||||
if (failure) {
|
||||
reportCliError(failure, json)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
printResult(
|
||||
result,
|
||||
json,
|
||||
(value) =>
|
||||
`Closed ${value.closed} terminal tabs and stopped ${value.stopped} terminal processes.`
|
||||
)
|
||||
return
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeClientError && error.code === 'method_not_found') {
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'This Orca host does not support closing every terminal in a workspace yet. Update Orca on the host and try again.'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (flags.has('worktree')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Closing a workspace requires --all: terminal close --worktree <selector> --all'
|
||||
)
|
||||
}
|
||||
const method = flags.get('tab') === true ? 'terminal.closeTab' : 'terminal.close'
|
||||
const result = await client.call<{ close: RuntimeTerminalClose }>(method, {
|
||||
terminal: await getTerminalHandle(flags, cwd, client)
|
||||
})
|
||||
// Why: a transport-level success must not hide a live or unverifiable PTY. Keep the receipt in
|
||||
// error.data so JSON callers retain the host's exact evidence while receiving a failing outcome.
|
||||
const failure = terminalCloseFailure(result.result.close)
|
||||
if (failure) {
|
||||
// Keep the established human receipt (including its liveness warning); JSON needs the
|
||||
// standard failure envelope so callers do not mistake transport success for a stopped PTY.
|
||||
if (json) {
|
||||
reportCliError(failure, true)
|
||||
} else {
|
||||
printResult(result, false, formatTerminalClose)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
printResult(result, json, formatTerminalClose)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { RuntimeTerminalSend } from '../../shared/runtime-types'
|
||||
import { TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatTerminalSend, printResult, terminalSendWarnings } from '../format'
|
||||
import { getOptionalPositiveIntegerFlag, getOptionalStringFlag } from '../flags'
|
||||
import { readRetryRequestFlag } from '../retry-request-flag'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { attachUnverifiedTerminalPromptRecovery } from '../runtime/terminal-prompt-mutation-recovery'
|
||||
import { getTerminalHandle } from '../selectors'
|
||||
|
||||
type TerminalSendResult = { send: RuntimeTerminalSend; warnings?: string[] }
|
||||
|
||||
export const terminalSendHandler: CommandHandler = async ({ flags, client, cwd, json }) => {
|
||||
const text = getOptionalStringFlag(flags, 'text')
|
||||
const enter = flags.get('enter') === true
|
||||
const interrupt = flags.get('interrupt') === true
|
||||
const promptCandidate = !!text && enter && !interrupt
|
||||
const retryRequest = readRetryRequestFlag(flags)
|
||||
const waitSubmitSeconds = getOptionalPositiveIntegerFlag(flags, 'wait-submit')
|
||||
if ((retryRequest || waitSubmitSeconds) && !promptCandidate) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--retry-request and --wait-submit require --text with --enter and without --interrupt.'
|
||||
)
|
||||
}
|
||||
if (waitSubmitSeconds && waitSubmitSeconds > 3600) {
|
||||
throw new RuntimeClientError('invalid_argument', '--wait-submit must be at most 3600 seconds.')
|
||||
}
|
||||
const waitSubmitMs = waitSubmitSeconds ? waitSubmitSeconds * 1000 : undefined
|
||||
let promptDeliverySupported = false
|
||||
let promptDeliveryRuntimeId: string | null = null
|
||||
if (promptCandidate) {
|
||||
const status = await client.getCliStatus()
|
||||
if (!status.result.runtime.reachable) {
|
||||
throw new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'Orca could not verify prompt-delivery support, so no input was sent. Wait for the execution host to become reachable and retry.'
|
||||
)
|
||||
}
|
||||
promptDeliverySupported =
|
||||
status.result.runtime.capabilities?.includes(TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY) ===
|
||||
true
|
||||
promptDeliveryRuntimeId = status.result.runtime.runtimeId
|
||||
}
|
||||
if (retryRequest && !promptDeliverySupported) {
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'This Orca host cannot honor --retry-request and never recorded this request ID. This attempt sent no input, but an earlier prompt may have been delivered; inspect the terminal and do not resend unless you independently prove it was not delivered, because updating the host cannot make this specific retry idempotent.'
|
||||
)
|
||||
}
|
||||
if (waitSubmitMs && !promptDeliverySupported) {
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'This Orca host does not support --wait-submit. No input was sent; update Orca on the execution host, or omit only --wait-submit for a legacy prompt whose delivery cannot be observed or retried safely.'
|
||||
)
|
||||
}
|
||||
const params = {
|
||||
terminal: await getTerminalHandle(flags, cwd, client),
|
||||
text,
|
||||
enter,
|
||||
interrupt,
|
||||
...(promptCandidate
|
||||
? {
|
||||
agentPrompt: true as const,
|
||||
...(waitSubmitMs ? { waitSubmitMs } : {})
|
||||
}
|
||||
: {}),
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
}
|
||||
const options = promptDeliverySupported
|
||||
? {
|
||||
terminalPromptPreflight: { runtimeId: promptDeliveryRuntimeId },
|
||||
...(retryRequest ? { orchestrationRequestId: retryRequest } : {}),
|
||||
...(waitSubmitMs ? { timeoutMs: waitSubmitMs + 10_000 } : {})
|
||||
}
|
||||
: promptCandidate
|
||||
? { legacyTerminalPrompt: true as const }
|
||||
: undefined
|
||||
const result = options
|
||||
? await client.call<TerminalSendResult>('terminal.send', params, options)
|
||||
: await client.call<TerminalSendResult>('terminal.send', params)
|
||||
const missingPromptReceipt =
|
||||
promptCandidate && result.result.send.accepted && !result.result.send.prompt
|
||||
if (missingPromptReceipt && promptDeliverySupported) {
|
||||
throw attachUnverifiedTerminalPromptRecovery(
|
||||
new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'The Orca host changed after prompt-delivery support was verified and accepted input without returning a durable prompt receipt.'
|
||||
)
|
||||
)
|
||||
}
|
||||
if (missingPromptReceipt) {
|
||||
result.result.send.prompt = {
|
||||
requestId: 'unsupported-old-host',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'old-host',
|
||||
observation: 'unsupported',
|
||||
processIncarnation: 'unknown',
|
||||
generation: 0,
|
||||
baselineWorkingSequence: 0
|
||||
}
|
||||
}
|
||||
// Why: the delivery warnings only existed in the text formatter, so --json callers never saw them.
|
||||
const warnings = terminalSendWarnings(result.result.send)
|
||||
printResult(
|
||||
warnings.length > 0 ? { ...result, result: { ...result.result, warnings } } : result,
|
||||
json,
|
||||
formatTerminalSend
|
||||
)
|
||||
if (!result.result.send.accepted) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RuntimeClientError, type RuntimeClient } from '../runtime-client'
|
||||
import { TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import { parseArgs } from '../args'
|
||||
import { printHelp } from '../help'
|
||||
import { COMMAND_SPECS } from '../specs'
|
||||
@@ -250,6 +251,20 @@ describe('terminal close CLI', () => {
|
||||
})
|
||||
|
||||
describe('terminal send CLI', () => {
|
||||
const promptClient = (call: ReturnType<typeof vi.fn>, supported: boolean) =>
|
||||
({
|
||||
call,
|
||||
getCliStatus: vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
runtime: {
|
||||
reachable: true,
|
||||
runtimeId: 'runtime-current',
|
||||
capabilities: supported ? [TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY] : []
|
||||
}
|
||||
}
|
||||
})
|
||||
}) as unknown as RuntimeClient
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
process.exitCode = ORIGINAL_EXIT_CODE
|
||||
@@ -257,7 +272,22 @@ describe('terminal send CLI', () => {
|
||||
|
||||
it('marks combined text and Enter as an agent prompt candidate', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: { send: { handle: 'term-1', accepted: true, bytesWritten: 7 } }
|
||||
result: {
|
||||
send: {
|
||||
handle: 'term-1',
|
||||
accepted: true,
|
||||
bytesWritten: 7,
|
||||
prompt: {
|
||||
requestId: '11111111-1111-4111-8111-111111111111',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'codex',
|
||||
observation: 'supported',
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
@@ -267,19 +297,60 @@ describe('terminal send CLI', () => {
|
||||
['text', 'review'],
|
||||
['enter', true]
|
||||
]),
|
||||
client: { call } as unknown as RuntimeClient,
|
||||
client: promptClient(call, true),
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledWith('terminal.send', {
|
||||
terminal: 'term-1',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-1',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
},
|
||||
{ terminalPromptPreflight: { runtimeId: 'runtime-current' } }
|
||||
)
|
||||
})
|
||||
|
||||
it('carries the swallowed-Enter warning into the --json receipt', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
send: {
|
||||
handle: 'term-1',
|
||||
accepted: true,
|
||||
bytesWritten: 7,
|
||||
prompt: {
|
||||
requestId: 'prompt-swallowed',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'claude',
|
||||
observation: 'supported',
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'review'],
|
||||
['enter', true]
|
||||
]),
|
||||
client: promptClient(call, true),
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0])).result.warnings).toEqual([
|
||||
expect.stringContaining('no turn start was observed')
|
||||
])
|
||||
})
|
||||
|
||||
it('explains that Structured Chat blocked a refused send and how to recover', async () => {
|
||||
@@ -302,6 +373,7 @@ describe('terminal send CLI', () => {
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
process.exitCode = undefined
|
||||
const client = promptClient(call, true)
|
||||
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
@@ -309,11 +381,12 @@ describe('terminal send CLI', () => {
|
||||
['text', 'review'],
|
||||
['enter', true]
|
||||
]),
|
||||
client: { call } as unknown as RuntimeClient,
|
||||
client,
|
||||
cwd: '/tmp/worktree',
|
||||
json: false
|
||||
})
|
||||
|
||||
expect(client.getCliStatus).toHaveBeenCalledOnce()
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Structured Chat.*Switch it to Terminal.*orca terminal send/s)
|
||||
)
|
||||
@@ -360,4 +433,255 @@ describe('terminal send CLI', () => {
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
})
|
||||
})
|
||||
|
||||
it('passes retry identity and observation wait only for agent prompts', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
send: {
|
||||
handle: 'term-1',
|
||||
accepted: true,
|
||||
bytesWritten: 8,
|
||||
prompt: {
|
||||
requestId: '11111111-1111-4111-8111-111111111111',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'codex',
|
||||
observation: 'supported',
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'continue'],
|
||||
['enter', true],
|
||||
['retry-request', '11111111-1111-4111-8111-111111111111'],
|
||||
['wait-submit', '3']
|
||||
]),
|
||||
client: promptClient(call, true),
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
expect.objectContaining({ agentPrompt: true, waitSubmitMs: 3_000 }),
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-current' },
|
||||
orchestrationRequestId: '11111111-1111-4111-8111-111111111111',
|
||||
timeoutMs: 13_000
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when the host downgrades after the prompt capability preflight', async () => {
|
||||
const response = {
|
||||
result: { send: { handle: 'term-1', accepted: true, bytesWritten: 8 } },
|
||||
_meta: { runtimeId: 'old-runtime-after-restart' }
|
||||
}
|
||||
const call = vi.fn().mockResolvedValue(response)
|
||||
const client = {
|
||||
call,
|
||||
getCliStatus: vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
runtime: {
|
||||
reachable: true,
|
||||
runtimeId: 'new-runtime-before-restart',
|
||||
capabilities: [TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY]
|
||||
}
|
||||
}
|
||||
})
|
||||
} as unknown as RuntimeClient
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
const error = await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'continue'],
|
||||
['enter', true],
|
||||
['retry-request', '11111111-1111-4111-8111-111111111111'],
|
||||
['wait-submit', '3']
|
||||
]),
|
||||
client,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
expect.objectContaining({ agentPrompt: true, waitSubmitMs: 3_000 }),
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'new-runtime-before-restart' },
|
||||
orchestrationRequestId: '11111111-1111-4111-8111-111111111111',
|
||||
timeoutMs: 13_000
|
||||
}
|
||||
)
|
||||
expect(error).toMatchObject({
|
||||
code: 'incompatible_runtime',
|
||||
data: {
|
||||
deliveryOutcome: 'unknown',
|
||||
retrySafe: false,
|
||||
nextSteps: expect.arrayContaining([expect.stringContaining('Inspect the terminal output')])
|
||||
}
|
||||
})
|
||||
expect((error as Error).message).toContain('cannot prove whether the prompt was delivered')
|
||||
expect((response.result.send as { prompt?: unknown }).prompt).toBeUndefined()
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('labels an old-host response as non-idempotent without claiming submission', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: { send: { handle: 'term-1', accepted: true, bytesWritten: 7 } }
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'review'],
|
||||
['enter', true]
|
||||
]),
|
||||
client: promptClient(call, false),
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
expect.objectContaining({ agentPrompt: true }),
|
||||
{ legacyTerminalPrompt: true }
|
||||
)
|
||||
expect(call.mock.results[0]?.value).toBeDefined()
|
||||
const response = await call.mock.results[0]?.value
|
||||
expect(response.result.send.prompt).toEqual({
|
||||
requestId: 'unsupported-old-host',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'old-host',
|
||||
observation: 'unsupported',
|
||||
processIncarnation: 'unknown',
|
||||
generation: 0,
|
||||
baselineWorkingSequence: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fabricate an accepted prompt receipt for an old-host refusal', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
send: {
|
||||
handle: 'term-1',
|
||||
accepted: false,
|
||||
bytesWritten: 0,
|
||||
refusedReason: 'permission'
|
||||
}
|
||||
}
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'review'],
|
||||
['enter', true]
|
||||
]),
|
||||
client: promptClient(call, false),
|
||||
cwd: '/tmp/worktree',
|
||||
json: false
|
||||
})
|
||||
|
||||
const response = await call.mock.results[0]?.value
|
||||
expect(response.result.send.prompt).toBeUndefined()
|
||||
expect(String(log.mock.calls[0]?.[0])).toBe('Input refused by term-1: permission.')
|
||||
})
|
||||
|
||||
it('refuses old-host retry before sending any input', async () => {
|
||||
const call = vi.fn()
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
const error = await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'review'],
|
||||
['enter', true],
|
||||
['retry-request', '11111111-1111-4111-8111-111111111111']
|
||||
]),
|
||||
client: {
|
||||
call,
|
||||
getCliStatus: vi.fn().mockResolvedValue({
|
||||
result: { runtime: { reachable: true, capabilities: [] } }
|
||||
})
|
||||
} as unknown as RuntimeClient,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(error).toMatchObject({ code: 'incompatible_runtime' })
|
||||
expect((error as Error).message).toContain(
|
||||
'updating the host cannot make this specific retry idempotent'
|
||||
)
|
||||
expect((error as Error).message).not.toContain('omit --retry-request')
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves retry identity after a pre-write host failure', async () => {
|
||||
const call = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new RuntimeClientError('internal_error', 'terminal_not_writable'))
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
send: {
|
||||
handle: 'term-1',
|
||||
accepted: true,
|
||||
bytesWritten: 13,
|
||||
prompt: {
|
||||
requestId: '22222222-2222-4222-8222-222222222222',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'codex',
|
||||
observation: 'supported',
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const client = promptClient(call, true)
|
||||
const flags = new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'retry safely'],
|
||||
['enter', true],
|
||||
['retry-request', '22222222-2222-4222-8222-222222222222']
|
||||
])
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await expect(
|
||||
TERMINAL_HANDLERS['terminal send']({ flags, client, cwd: '/tmp/worktree', json: true })
|
||||
).rejects.toMatchObject({ message: 'terminal_not_writable' })
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags,
|
||||
client,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledTimes(2)
|
||||
expect(call.mock.calls.map((args) => args[2])).toEqual([
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-current' },
|
||||
orchestrationRequestId: '22222222-2222-4222-8222-222222222222'
|
||||
},
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-current' },
|
||||
orchestrationRequestId: '22222222-2222-4222-8222-222222222222'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
import type {
|
||||
RuntimeTerminalClose,
|
||||
RuntimeTerminalCreate,
|
||||
RuntimeTerminalFocus,
|
||||
RuntimeTerminalListResult,
|
||||
RuntimeTerminalRead,
|
||||
RuntimeTerminalRename,
|
||||
RuntimeTerminalSend,
|
||||
RuntimeTerminalShow,
|
||||
RuntimeTerminalSplit,
|
||||
RuntimeTerminalWait,
|
||||
RuntimeWorktreeTerminalCloseResult
|
||||
RuntimeTerminalWait
|
||||
} from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { shouldUseRendererBackedInteractiveTerminal } from '../codex-command-classification'
|
||||
import {
|
||||
formatTerminalClose,
|
||||
formatTerminalCreate,
|
||||
formatTerminalFocus,
|
||||
formatTerminalList,
|
||||
formatTerminalRead,
|
||||
formatTerminalRename,
|
||||
formatTerminalSend,
|
||||
formatTerminalShow,
|
||||
formatTerminalSplit,
|
||||
formatTerminalWait,
|
||||
reportCliError,
|
||||
printResult
|
||||
} from '../format'
|
||||
import {
|
||||
@@ -43,47 +37,14 @@ import {
|
||||
getRequiredWorktreeSelector,
|
||||
getTerminalHandle
|
||||
} from '../selectors'
|
||||
import { terminalCloseHandler } from './terminal-close'
|
||||
import { terminalSendHandler } from './terminal-send'
|
||||
|
||||
// Why: terminal wait legitimately needs to outlive the CLI's default RPC
|
||||
// timeout. Even without an explicit server timeout, the client must allow
|
||||
// long waits instead of failing at the generic 15s transport cap.
|
||||
const DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
/** A false stop receipt is an error only when the host supplied a liveness verdict. */
|
||||
function terminalCloseFailure(close: RuntimeTerminalClose): RuntimeClientError | null {
|
||||
if (close.ptyKilled || close.ptyStopVerdict === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const verdict = close.ptyStopVerdict
|
||||
const detail =
|
||||
verdict === 'live'
|
||||
? 'The PTY is live.'
|
||||
: `The PTY was not confirmed stopped: ${close.ptyStopReason ?? 'its host could not be reached'}.`
|
||||
return new RuntimeClientError(
|
||||
verdict === 'live' ? 'terminal_stop_live' : 'terminal_stop_unverifiable',
|
||||
`Terminal ${close.handle} close failed to confirm the PTY stopped (${verdict}). ${detail}`,
|
||||
{ close }
|
||||
)
|
||||
}
|
||||
|
||||
function terminalCloseAllFailure(
|
||||
close: RuntimeWorktreeTerminalCloseResult
|
||||
): RuntimeClientError | null {
|
||||
if (!close.ptyStopVerdict) {
|
||||
return null
|
||||
}
|
||||
const detail =
|
||||
close.ptyStopVerdict === 'live'
|
||||
? 'At least one PTY is live.'
|
||||
: `At least one PTY was not confirmed stopped: ${close.ptyStopReason ?? 'its owning host could not be reached'}.`
|
||||
return new RuntimeClientError(
|
||||
close.ptyStopVerdict === 'live' ? 'terminal_stop_live' : 'terminal_stop_unverifiable',
|
||||
`Workspace terminal close did not confirm every PTY stopped (${close.ptyStopVerdict}). ${detail}`,
|
||||
{ close }
|
||||
)
|
||||
}
|
||||
|
||||
const terminalFocusHandler: CommandHandler = async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<{ focus: RuntimeTerminalFocus }>('terminal.focus', {
|
||||
terminal: await getTerminalHandle(flags, cwd, client),
|
||||
@@ -147,23 +108,7 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
||||
}
|
||||
printResult(result, json, formatTerminalRead)
|
||||
},
|
||||
'terminal send': async ({ flags, client, cwd, json }) => {
|
||||
const text = getOptionalStringFlag(flags, 'text')
|
||||
const enter = flags.get('enter') === true
|
||||
const interrupt = flags.get('interrupt') === true
|
||||
const result = await client.call<{ send: RuntimeTerminalSend }>('terminal.send', {
|
||||
terminal: await getTerminalHandle(flags, cwd, client),
|
||||
text,
|
||||
enter,
|
||||
interrupt,
|
||||
...(text && enter && !interrupt ? { agentPrompt: true } : {}),
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
})
|
||||
printResult(result, json, formatTerminalSend)
|
||||
if (!result.result.send.accepted) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
},
|
||||
'terminal send': terminalSendHandler,
|
||||
'terminal wait': async ({ flags, client, cwd, json }) => {
|
||||
const timeoutMs = getOptionalPositiveIntegerFlag(flags, 'timeout-ms')
|
||||
const result = await client.call<{ wait: RuntimeTerminalWait }>(
|
||||
@@ -223,67 +168,7 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
// `focus` resolves to this canonical path via CommandSpec.aliases before dispatch.
|
||||
'terminal switch': terminalFocusHandler,
|
||||
'terminal close': async ({ flags, client, cwd, json }) => {
|
||||
if (flags.get('all') === true) {
|
||||
if (flags.has('terminal') || flags.get('tab') === true) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--all uses --worktree and cannot be combined with --terminal or --tab'
|
||||
)
|
||||
}
|
||||
try {
|
||||
const result = await client.call<RuntimeWorktreeTerminalCloseResult>('terminal.closeAll', {
|
||||
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client)
|
||||
})
|
||||
const failure = terminalCloseAllFailure(result.result)
|
||||
if (failure) {
|
||||
reportCliError(failure, json)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
printResult(
|
||||
result,
|
||||
json,
|
||||
(value) =>
|
||||
`Closed ${value.closed} terminal tabs and stopped ${value.stopped} terminal processes.`
|
||||
)
|
||||
return
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeClientError && error.code === 'method_not_found') {
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'This Orca host does not support closing every terminal in a workspace yet. Update Orca on the host and try again.'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (flags.has('worktree')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Closing a workspace requires --all: terminal close --worktree <selector> --all'
|
||||
)
|
||||
}
|
||||
const method = flags.get('tab') === true ? 'terminal.closeTab' : 'terminal.close'
|
||||
const result = await client.call<{ close: RuntimeTerminalClose }>(method, {
|
||||
terminal: await getTerminalHandle(flags, cwd, client)
|
||||
})
|
||||
// Why: a transport-level success must not hide a live or unverifiable PTY. Keep the receipt in
|
||||
// error.data so JSON callers retain the host's exact evidence while receiving a failing outcome.
|
||||
const failure = terminalCloseFailure(result.result.close)
|
||||
if (failure) {
|
||||
// Keep the established human receipt (including its liveness warning); JSON needs the
|
||||
// standard failure envelope so callers do not mistake transport success for a stopped PTY.
|
||||
if (json) {
|
||||
reportCliError(failure, true)
|
||||
} else {
|
||||
printResult(result, false, formatTerminalClose)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
printResult(result, json, formatTerminalClose)
|
||||
},
|
||||
'terminal close': terminalCloseHandler,
|
||||
'terminal split': async ({ flags, client, cwd, json }) => {
|
||||
const directionFlag = getOptionalStringFlag(flags, 'direction')
|
||||
if (
|
||||
|
||||
+11
-6
@@ -2,6 +2,7 @@ import { formatSearchFlagHelp } from './agent-session-search-help'
|
||||
import type { CommandSpec } from './args'
|
||||
import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args'
|
||||
import { unknownCommandData } from './command-suggestion'
|
||||
import { formatSkillsCommandFlagHelp } from './skills-command-flag-help'
|
||||
import { ROOT_HELP_TEXT_PRIMARY } from './root-help-text-primary'
|
||||
import { ROOT_HELP_TEXT_SECONDARY } from './root-help-text-secondary'
|
||||
|
||||
@@ -73,12 +74,10 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string {
|
||||
|
||||
function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
|
||||
const command = commandPath.join(' ')
|
||||
const searchHelp = formatSearchFlagHelp(command, flag)
|
||||
if (searchHelp) {
|
||||
return searchHelp
|
||||
}
|
||||
if (command === 'skills install' && flag === 'agent') {
|
||||
return '--agent <names> Comma-separated install targets; default is detected agents'
|
||||
const tableHelp =
|
||||
formatSearchFlagHelp(command, flag) ?? formatSkillsCommandFlagHelp(command, flag)
|
||||
if (tableHelp) {
|
||||
return tableHelp
|
||||
}
|
||||
if (command === 'terminal close' && flag === 'tab') {
|
||||
return '--tab Close the whole tab and wait for durable persistence'
|
||||
@@ -110,9 +109,15 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
|
||||
if (command === 'orchestration worker-read' && flag === 'cursor') {
|
||||
return '--cursor <cursor> Opaque cursor returned by a previous worker-read page'
|
||||
}
|
||||
if (command === 'orchestration worker-list' && flag === 'cursor') {
|
||||
return '--cursor <cursor> Opaque page cursor copied from page.nextCursor'
|
||||
}
|
||||
if (command === 'orchestration worker-list' && flag === 'terminal-state') {
|
||||
return '--terminal-state <state> Terminal accounting filter: active, reclaimable, retained, release_pending, release_unknown, or released'
|
||||
}
|
||||
if (command === 'orchestration worker-list' && flag === 'include-remote') {
|
||||
return '--include-remote Include connected-server worker observations'
|
||||
}
|
||||
if (command === 'linear list-issues' && flag === 'workspace') {
|
||||
return '--workspace <id|all> Connected Linear workspace id, or all'
|
||||
}
|
||||
|
||||
@@ -228,6 +228,29 @@ describe('unknown command surfaces a suggestion', () => {
|
||||
expect(stderr).toContain('--json')
|
||||
})
|
||||
|
||||
it('names the offending --worktree value and the valid forms on selector_not_found', async () => {
|
||||
const { RuntimeRpcFailureError } = await import('./runtime/types.js')
|
||||
callMock.mockRejectedValue(
|
||||
new RuntimeRpcFailureError({
|
||||
id: 'req_selector',
|
||||
ok: false,
|
||||
error: { code: 'selector_not_found', message: 'selector_not_found' },
|
||||
_meta: { runtimeId: 'runtime_local' }
|
||||
})
|
||||
)
|
||||
|
||||
await main(
|
||||
['orchestration', 'worker-start', '--task', 't1', '--worktree', 'repo-1', '--agent', 'codex'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
||||
expect(stderr).toContain('No Orca workspace matched the worktree selector "repo-1"')
|
||||
expect(stderr).toContain('id:repo-1::<absolute-path>')
|
||||
expect(stderr).toContain('Valid selector forms:')
|
||||
})
|
||||
|
||||
it('reports a pre-command flag that belongs to another command', async () => {
|
||||
await main(['--workspace', 'worktree', 'list'], '/tmp/repo')
|
||||
|
||||
@@ -305,6 +328,23 @@ describe('orca root help', () => {
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('labels retired coordinator scheduler commands at the root', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['--help'], '/tmp/repo')
|
||||
|
||||
const output = String(logSpy.mock.calls[0]?.[0])
|
||||
expect(output).toContain(
|
||||
'orchestration coordinator-start Retired: load the current orchestration skill'
|
||||
)
|
||||
expect(output).toContain(
|
||||
'orchestration coordinator-stop Retired: load the current orchestration skill'
|
||||
)
|
||||
expect(output).not.toContain('Start the legacy automatic coordinator loop')
|
||||
expect(output).not.toContain('Stop the legacy automatic coordinator loop')
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('advertises computer-use capabilities discovery', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
@@ -356,6 +396,7 @@ describe('orca root help', () => {
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'orchestration worker-list Report worker terminal resource accounting'
|
||||
)
|
||||
expect(logSpy.mock.calls[0][0]).not.toContain('orchestration worker-cleanup')
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -442,6 +483,21 @@ describe('orca root help', () => {
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('describes worker-list cursors as opaque page cursors', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
logSpy.mockClear()
|
||||
|
||||
await main(['orchestration', 'worker-list', '--help'], '/tmp/repo')
|
||||
|
||||
const help = String(logSpy.mock.calls[0][0])
|
||||
expect(help).toContain('[--cursor <cursor>]')
|
||||
expect(help).toContain('--cursor <cursor> Opaque page cursor copied from page.nextCursor')
|
||||
expect(help).toContain('Continue with the opaque page.nextCursor value unchanged.')
|
||||
expect(help).not.toContain('--cursor <dispatch_id>')
|
||||
expect(help).not.toContain('Line cursor from a previous read')
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('advertises Linear issue linking on worktree create and set help', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
logSpy.mockClear()
|
||||
|
||||
+5
-1
@@ -176,7 +176,11 @@ export async function main(
|
||||
json
|
||||
})
|
||||
} catch (error) {
|
||||
reportCliError(error, json, { commandPath: parsed.commandPath })
|
||||
const worktreeSelector = parsed.flags.get('worktree')
|
||||
reportCliError(error, json, {
|
||||
commandPath: parsed.commandPath,
|
||||
...(typeof worktreeSelector === 'string' ? { worktreeSelector } : {})
|
||||
})
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '../shared/child-process/run-process'
|
||||
import {
|
||||
orchestrationMutationRecoveryError,
|
||||
renderCommand
|
||||
renderCommand,
|
||||
renderResolvedOrchestrationCommand
|
||||
} from './orchestration-mutation-recovery'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
@@ -212,6 +213,19 @@ describe('orchestration mutation recovery', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('shell-quotes a configured Windows executable when resolving portable recovery commands', () => {
|
||||
expect(
|
||||
renderResolvedOrchestrationCommand(
|
||||
'orca orchestration worker-show --dispatch ctx_1 --json',
|
||||
'C:\\Program Files\\Orca\\orca-ide.cmd',
|
||||
'win32',
|
||||
{ ComSpec: 'C:\\Windows\\System32\\cmd.exe' }
|
||||
)
|
||||
).toBe(
|
||||
'"C:\\Program Files\\Orca\\orca-ide.cmd" "orchestration" "worker-show" "--dispatch" "ctx_1" "--json"'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps PowerShell and POSIX recovery guidance literal', () => {
|
||||
expect(
|
||||
renderCommand(['orca', 'literal "quoted" $HOME'], 'win32', {
|
||||
|
||||
@@ -167,6 +167,19 @@ export function renderCommand(
|
||||
return shell === 'powershell' && rendered ? `& ${rendered}` : rendered
|
||||
}
|
||||
|
||||
export function renderResolvedOrchestrationCommand(
|
||||
command: string,
|
||||
executable = resolveOrchestrationCliExecutable(),
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): string {
|
||||
const parts = parseCommandLine(command)
|
||||
if (parts?.[0] !== 'orca') {
|
||||
return command
|
||||
}
|
||||
return renderCommand([executable, ...parts.slice(1)], platform, env)
|
||||
}
|
||||
|
||||
function resolveRecoveryShell(
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { parseArgs } from './args'
|
||||
import { COMMAND_SPECS } from './specs'
|
||||
import { TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY } from '../shared/protocol-version'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { TERMINAL_HANDLERS } from './handlers/terminal'
|
||||
import { ORCHESTRATION_HANDLERS } from './handlers/orchestration'
|
||||
import { readRetryRequestFlag } from './retry-request-flag'
|
||||
|
||||
const PATHS = COMMAND_SPECS.map((spec) => spec.path)
|
||||
|
||||
function promptClient() {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
send: {
|
||||
handle: 'term-1',
|
||||
accepted: true,
|
||||
bytesWritten: 2,
|
||||
prompt: {
|
||||
requestId: '11111111-1111-4111-8111-111111111111',
|
||||
stages: ['input_accepted', 'turn_started'],
|
||||
provider: 'claude',
|
||||
observation: 'supported',
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 3
|
||||
}
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
const client = {
|
||||
call,
|
||||
getCliStatus: vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
runtime: {
|
||||
reachable: true,
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: [TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY]
|
||||
}
|
||||
}
|
||||
})
|
||||
} as unknown as RuntimeClient
|
||||
return { client, call }
|
||||
}
|
||||
|
||||
async function sendWith(
|
||||
argv: string[]
|
||||
): Promise<{ error: unknown; call: ReturnType<typeof vi.fn> }> {
|
||||
const { client, call } = promptClient()
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const error = await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: parseArgs(argv, PATHS).flags,
|
||||
client,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
return { error, call }
|
||||
}
|
||||
|
||||
describe('--retry-request and --wait-submit value damage', () => {
|
||||
it('parses a value-less flag as boolean true', () => {
|
||||
const parsed = parseArgs(
|
||||
['terminal', 'send', '--terminal', 'term-1', '--text', 'hi', '--enter', '--retry-request'],
|
||||
PATHS
|
||||
)
|
||||
expect(parsed.flags.get('retry-request')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a value-less --retry-request instead of minting a fresh identity', async () => {
|
||||
const { error, call } = await sendWith([
|
||||
'terminal',
|
||||
'send',
|
||||
'--terminal',
|
||||
'term-1',
|
||||
'--text',
|
||||
'hi',
|
||||
'--enter',
|
||||
'--retry-request'
|
||||
])
|
||||
expect(error).toMatchObject({ code: 'invalid_argument' })
|
||||
expect((error as Error).message).toContain('--retry-request requires a value')
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an empty --retry-request= value', async () => {
|
||||
const { error, call } = await sendWith([
|
||||
'terminal',
|
||||
'send',
|
||||
'--terminal',
|
||||
'term-1',
|
||||
'--text',
|
||||
'hi',
|
||||
'--enter',
|
||||
'--retry-request='
|
||||
])
|
||||
expect(error).toMatchObject({ code: 'invalid_argument' })
|
||||
expect((error as Error).message).toContain('--retry-request must be the UUID')
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a non-UUID --retry-request value', () => {
|
||||
expect(() => readRetryRequestFlag(new Map([['retry-request', 'prompt-1']]))).toThrow(
|
||||
'--retry-request must be the UUID'
|
||||
)
|
||||
expect(
|
||||
readRetryRequestFlag(new Map([['retry-request', '11111111-1111-4111-8111-111111111111']]))
|
||||
).toBe('11111111-1111-4111-8111-111111111111')
|
||||
})
|
||||
|
||||
it('rejects a value-less --wait-submit instead of silently not waiting', async () => {
|
||||
const { error, call } = await sendWith([
|
||||
'terminal',
|
||||
'send',
|
||||
'--terminal',
|
||||
'term-1',
|
||||
'--text',
|
||||
'hi',
|
||||
'--enter',
|
||||
'--wait-submit'
|
||||
])
|
||||
expect(error).toMatchObject({ code: 'invalid_argument' })
|
||||
expect((error as Error).message).toContain('--wait-submit requires a value')
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a damaged --retry-request on an orchestration verb', async () => {
|
||||
const call = vi.fn()
|
||||
const client = { call } as unknown as RuntimeClient
|
||||
for (const value of [true as const, 'worker-stop-1']) {
|
||||
const error = await ORCHESTRATION_HANDLERS['orchestration worker-stop']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['dispatch', 'ctx_1'],
|
||||
['retry-request', value]
|
||||
]),
|
||||
client,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
expect(error).toMatchObject({ code: 'invalid_argument' })
|
||||
}
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { rejectValuelessFlag } from './flags'
|
||||
import { RuntimeClientError } from './runtime/types'
|
||||
import {
|
||||
isOrchestrationRetryRequestId,
|
||||
RETRY_REQUEST_ID_GUIDANCE
|
||||
} from '../shared/orchestration-retry-request-id'
|
||||
|
||||
/**
|
||||
* `--retry-request` carries the mutation identity that makes a replay idempotent. A damaged value
|
||||
* must never fall through to `undefined`, because the client would then mint a fresh identity and
|
||||
* re-apply a mutation that may already have taken effect (#15180).
|
||||
*/
|
||||
export function readRetryRequestFlag(flags: Map<string, string | boolean>): string | undefined {
|
||||
const value = flags.get('retry-request')
|
||||
rejectValuelessFlag(value, 'retry-request')
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (!isOrchestrationRetryRequestId(value)) {
|
||||
throw new RuntimeClientError('invalid_argument', RETRY_REQUEST_ID_GUIDANCE)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -114,8 +114,8 @@ export const ROOT_HELP_TEXT_PRIMARY = [
|
||||
" orchestration worker-release Release a settled worker's terminal after archiving its output",
|
||||
' orchestration worker-retain Keep a worker terminal live for debugging',
|
||||
' orchestration worker-list Report worker terminal resource accounting',
|
||||
' orchestration coordinator-start Start the legacy automatic coordinator loop',
|
||||
' orchestration coordinator-stop Stop the legacy automatic coordinator loop',
|
||||
' orchestration coordinator-start Retired: load the current orchestration skill',
|
||||
' orchestration coordinator-stop Retired: load the current orchestration skill',
|
||||
' orchestration gate-create Create a decision gate blocking a task',
|
||||
' orchestration gate-resolve Resolve a pending decision gate',
|
||||
' orchestration gate-list List decision gates',
|
||||
|
||||
@@ -61,7 +61,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
|
||||
' orca terminal list [--worktree <selector>] [--limit <n>] [--include-visual-layouts] [--json]',
|
||||
' orca terminal show [--terminal <handle>] [--json]',
|
||||
' orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]',
|
||||
' orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]',
|
||||
' orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--wait-submit <seconds>] [--retry-request <id>] [--json]',
|
||||
' orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]',
|
||||
' orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]',
|
||||
' orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]',
|
||||
@@ -91,6 +91,8 @@ export const ROOT_HELP_TEXT_SECONDARY = [
|
||||
' --text <text> Text to send to the terminal',
|
||||
' --enter Append Enter after sending text',
|
||||
' --interrupt Send as an interrupt-style input when supported',
|
||||
' --wait-submit <seconds> Observe this accepted prompt without resending it',
|
||||
' --retry-request <id> Resume the same durable prompt request after an ambiguous transport failure',
|
||||
'',
|
||||
'Terminal List Options:',
|
||||
' --include-visual-layouts Include tab and pane topology in JSON output',
|
||||
|
||||
@@ -84,11 +84,14 @@ describe('RuntimeClient module-graph deferral', () => {
|
||||
process.exitCode = 0
|
||||
})
|
||||
|
||||
// These eager modules must not pull the RuntimeClient dependency graph into help.
|
||||
// Why: the whole point of the change. These modules load on EVERY
|
||||
// invocation, so a value-import of the barrel from any of them drags the
|
||||
// RuntimeClient graph (zod, ws, tweetnacl) back onto the --help path.
|
||||
it.each([
|
||||
'args.ts',
|
||||
'flags.ts',
|
||||
'dispatch.ts',
|
||||
'format.ts',
|
||||
'cli-error.ts',
|
||||
'selectors.ts',
|
||||
'execution-host-flag.ts'
|
||||
@@ -100,7 +103,10 @@ describe('RuntimeClient module-graph deferral', () => {
|
||||
for (const line of valueImports) {
|
||||
expect(line, `${file}: "${line}" must be type-only`).toMatch(/^import type /)
|
||||
}
|
||||
expect(source).toContain("} from './runtime/types'")
|
||||
// Why: format.ts re-exports its error formatters; the guarded import lives in cli-error.ts.
|
||||
if (file !== 'format.ts') {
|
||||
expect(source).toContain("} from './runtime/types'")
|
||||
}
|
||||
})
|
||||
|
||||
it('index.ts has no eager value-import of the runtime client', () => {
|
||||
|
||||
@@ -2,16 +2,18 @@ import { createServer, type Server } from 'node:net'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import { ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS } from '../../shared/orchestration-timing-budgets'
|
||||
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
|
||||
import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery'
|
||||
import { RuntimeClient, RuntimeRpcFailureError } from '../runtime-client'
|
||||
import { reportCliError } from '../format'
|
||||
import { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError } from '../runtime-client'
|
||||
|
||||
const servers = new Set<Server>()
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await Promise.all(
|
||||
[...servers].map(
|
||||
(server) =>
|
||||
@@ -23,6 +25,37 @@ afterEach(async () => {
|
||||
servers.clear()
|
||||
})
|
||||
|
||||
function writeRuntimeConnection(userDataPath: string, endpoint: string, runtimeId: string): void {
|
||||
writeFileSync(
|
||||
join(userDataPath, 'orca-runtime.json'),
|
||||
JSON.stringify({
|
||||
runtimeId,
|
||||
pid: 1,
|
||||
transports: [{ kind: 'unix', endpoint }],
|
||||
authToken: 'token',
|
||||
startedAt: 1
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function expectPromptRetryBlockedJson(error: unknown, requestId: string): void {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
reportCliError(error, true)
|
||||
const output = JSON.parse(String(log.mock.calls[0]?.[0])) as {
|
||||
error: { data?: Record<string, unknown> }
|
||||
}
|
||||
expect(output.error.data).toMatchObject({
|
||||
deliveryOutcome: 'unknown',
|
||||
retrySafe: false,
|
||||
nextSteps: expect.arrayContaining([
|
||||
'Inspect the terminal output and agent state without sending input.'
|
||||
])
|
||||
})
|
||||
expect(JSON.stringify(output)).not.toContain('--retry-request')
|
||||
expect(JSON.stringify(output)).not.toContain(requestId)
|
||||
expect(output.error.data).not.toHaveProperty('orchestrationRequestId')
|
||||
}
|
||||
|
||||
describe('RuntimeClient orchestration recovery identity', () => {
|
||||
it('rejects a worker-start timeout whose client grace would overflow timers', () => {
|
||||
const client = new RuntimeClient(undefined, 60_000, null, null, 'orca')
|
||||
@@ -74,16 +107,7 @@ describe('RuntimeClient orchestration recovery identity', () => {
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeFileSync(
|
||||
join(userDataPath, 'orca-runtime.json'),
|
||||
JSON.stringify({
|
||||
runtimeId: 'runtime-1',
|
||||
pid: 1,
|
||||
transports: [{ kind: 'unix', endpoint }],
|
||||
authToken: 'token',
|
||||
startedAt: 1
|
||||
})
|
||||
)
|
||||
writeRuntimeConnection(userDataPath, endpoint, 'runtime-1')
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500, null, null, 'orca')
|
||||
try {
|
||||
@@ -127,4 +151,233 @@ describe('RuntimeClient orchestration recovery identity', () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps durable prompt retry when failure metadata proves the preflight runtime', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-current-prompt-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as { id: string }
|
||||
socket.end(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: { code: 'runtime_timeout', message: 'request timed out' },
|
||||
_meta: { runtimeId: 'runtime-current' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeRuntimeConnection(userDataPath, endpoint, 'runtime-current')
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500, null, null, 'orca')
|
||||
const error = await client
|
||||
.call(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-current',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
},
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-current' },
|
||||
orchestrationRequestId: 'prompt-current'
|
||||
}
|
||||
)
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(error).toBeInstanceOf(RuntimeRpcFailureError)
|
||||
expect(error).toMatchObject({
|
||||
data: { orchestrationRequestId: 'prompt-current' }
|
||||
})
|
||||
expect((error as Error).message).toContain('--retry-request prompt-current')
|
||||
})
|
||||
|
||||
it('keeps the prompt retry ID when the attested runtime times out in transport', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-rt-timeout-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
let receivedRequest: Record<string, unknown> | undefined
|
||||
const server = createServer((socket) => {
|
||||
socket.once('data', (data) => {
|
||||
receivedRequest = JSON.parse(String(data).trim()) as Record<string, unknown>
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeRuntimeConnection(userDataPath, endpoint, 'runtime-current')
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 200, null, null, 'orca')
|
||||
const error = await client
|
||||
.call(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-current',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
},
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-current' },
|
||||
orchestrationRequestId: 'prompt-transport-timeout'
|
||||
}
|
||||
)
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(receivedRequest?.orchestrationRequestId).toBe('prompt-transport-timeout')
|
||||
expect(error).toBeInstanceOf(RuntimeClientError)
|
||||
expect(error).not.toBeInstanceOf(RuntimeRpcFailureError)
|
||||
expect((error as RuntimeClientError).code).toBe('runtime_timeout')
|
||||
expect(error).toMatchObject({ data: { orchestrationRequestId: 'prompt-transport-timeout' } })
|
||||
expect((error as Error).message).toContain(
|
||||
'--retry-request prompt-transport-timeout --wait-submit <seconds>'
|
||||
)
|
||||
expect((error as RuntimeClientError).data).not.toHaveProperty('retrySafe')
|
||||
})
|
||||
|
||||
it('blocks retry when a downgraded runtime rejects after capability preflight', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-downgraded-prompt-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
let receivedRequest: Record<string, unknown> | undefined
|
||||
const server = createServer((socket) => {
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as Record<string, unknown>
|
||||
receivedRequest = request
|
||||
socket.end(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: { code: 'runtime_timeout', message: 'request timed out' },
|
||||
_meta: { runtimeId: 'runtime-after-downgrade' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeRuntimeConnection(userDataPath, endpoint, 'runtime-after-downgrade')
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500, null, null, 'orca')
|
||||
const error = await client
|
||||
.call(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-downgraded',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
},
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-before-downgrade' },
|
||||
orchestrationRequestId: 'prompt-downgraded-rejection'
|
||||
}
|
||||
)
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(receivedRequest?.orchestrationRequestId).toBe('prompt-downgraded-rejection')
|
||||
expect(error).toBeInstanceOf(RuntimeRpcFailureError)
|
||||
expectPromptRetryBlockedJson(error, 'prompt-downgraded-rejection')
|
||||
})
|
||||
|
||||
it('blocks retry when a downgraded runtime loses the prompt reply', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-lost-prompt-reply-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
let receivedRequest: Record<string, unknown> | undefined
|
||||
const server = createServer((socket) => {
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as Record<string, unknown>
|
||||
receivedRequest = request
|
||||
socket.destroy()
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeRuntimeConnection(userDataPath, endpoint, 'runtime-after-downgrade')
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500, null, null, 'orca')
|
||||
const error = await client
|
||||
.call(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-downgraded',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
},
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-before-downgrade' },
|
||||
orchestrationRequestId: 'prompt-downgraded-lost-reply'
|
||||
}
|
||||
)
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(receivedRequest?.orchestrationRequestId).toBe('prompt-downgraded-lost-reply')
|
||||
expect(error).toBeInstanceOf(RuntimeClientError)
|
||||
expect(error).not.toBeInstanceOf(RuntimeRpcFailureError)
|
||||
expectPromptRetryBlockedJson(error, 'prompt-downgraded-lost-reply')
|
||||
expect(JSON.stringify((error as RuntimeClientError).data)).not.toContain('Update Orca')
|
||||
})
|
||||
|
||||
it('reports an unknown legacy prompt outcome without advertising an unsafe retry', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-legacy-prompt-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
let receivedRequest: Record<string, unknown> | undefined
|
||||
const server = createServer((socket) => {
|
||||
socket.once('data', (data) => {
|
||||
receivedRequest = JSON.parse(String(data).trim()) as Record<string, unknown>
|
||||
socket.destroy()
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeRuntimeConnection(userDataPath, endpoint, 'runtime-legacy')
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500, null, null, 'orca')
|
||||
const error = await client
|
||||
.call(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-legacy',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
},
|
||||
{ legacyTerminalPrompt: true }
|
||||
)
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(error).toBeInstanceOf(RuntimeClientError)
|
||||
expect(error).not.toBeInstanceOf(RuntimeRpcFailureError)
|
||||
expect(error).toMatchObject({
|
||||
data: {
|
||||
deliveryOutcome: 'unknown',
|
||||
retrySafe: false,
|
||||
nextSteps: expect.arrayContaining([
|
||||
'Inspect the terminal output and agent state without sending input.',
|
||||
'Update Orca on the execution host before future prompt sends that need durable retry.'
|
||||
])
|
||||
}
|
||||
})
|
||||
expect((error as RuntimeClientError).data).not.toHaveProperty('orchestrationRequestId')
|
||||
expect(receivedRequest).not.toHaveProperty('orchestrationRequestId')
|
||||
expect((error as Error).message).not.toContain('--retry-request')
|
||||
expect((error as Error).message).toContain('do not resend automatically')
|
||||
})
|
||||
})
|
||||
|
||||
+44
-60
@@ -3,17 +3,25 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
isDurableMutation,
|
||||
isOrchestrationMutation,
|
||||
isTerminalPromptMutation,
|
||||
orchestrationMigrationData
|
||||
} from '../../shared/orchestration-rpc-contract'
|
||||
import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
|
||||
import type { PairingOffer } from '../../shared/pairing'
|
||||
import { launchOrcaApp } from './launch'
|
||||
import { getDefaultUserDataPath, readMetadata } from './metadata'
|
||||
import { getCliStatus, projectRemoteAppStatus } from './status'
|
||||
import { sendRequest } from './transport'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
|
||||
import { attachMutationRecovery } from './client-error-recovery'
|
||||
import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments'
|
||||
import {
|
||||
attachDurableMutationRecovery,
|
||||
attachLegacyTerminalPromptRecovery,
|
||||
attachUnverifiedTerminalPromptRecovery,
|
||||
didAnotherRuntimeHandleTerminalPrompt
|
||||
} from './terminal-prompt-mutation-recovery'
|
||||
import { markEnvironmentUsed } from './environments'
|
||||
import { resolveRemotePairing } from './runtime-remote-pairing'
|
||||
import {
|
||||
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
|
||||
ORCHESTRATION_CONTRACT_VERSION
|
||||
@@ -32,20 +40,9 @@ import {
|
||||
resolveOrchestrationCliExecutable
|
||||
} from './orchestration-recovery-command'
|
||||
|
||||
// Why: for long-poll methods the caller's method-level
|
||||
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
|
||||
// socket timeout to `timeoutMs + GRACE_MS` so the client's own idle timer
|
||||
// never fires before the server-side waiter has had a chance to resolve and
|
||||
// emit its terminal frame. The 10 s grace absorbs round-trip + one final
|
||||
// keepalive window. See design doc §3.1.
|
||||
const LONG_POLL_CLIENT_GRACE_MS = 10_000
|
||||
|
||||
// Why: ws + tweetnacl + the remote-runtime frame stack only matter once a
|
||||
// request actually goes over a pairing offer, which local CLI calls never do.
|
||||
// Both call sites already await this, so deferring the load changes no ordering.
|
||||
async function loadWebSocketTransport() {
|
||||
return await import('./websocket-transport.js')
|
||||
}
|
||||
const loadWebSocketTransport = async () => await import('./websocket-transport.js')
|
||||
|
||||
export class RuntimeClient {
|
||||
private readonly userDataPath: string
|
||||
@@ -87,19 +84,43 @@ export class RuntimeClient {
|
||||
async call<TResult>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: { timeoutMs?: number } & RuntimeOrchestrationEnvelope
|
||||
options?: {
|
||||
timeoutMs?: number
|
||||
legacyTerminalPrompt?: true
|
||||
terminalPromptPreflight?: { runtimeId: string | null }
|
||||
} & RuntimeOrchestrationEnvelope
|
||||
): Promise<RuntimeRpcSuccess<TResult>> {
|
||||
const effectiveTimeoutMs = options?.timeoutMs ?? this.resolveMethodTimeoutMs(method, params)
|
||||
const orchestrationMutation = isOrchestrationMutation(method, params)
|
||||
const terminalPromptMutation = isTerminalPromptMutation(method, params)
|
||||
const legacyTerminalPrompt = options?.legacyTerminalPrompt === true && terminalPromptMutation
|
||||
const durableMutation = !legacyTerminalPrompt && isDurableMutation(method, params)
|
||||
if (orchestrationMutation) {
|
||||
await this.ensureOrchestrationContractCompatible(effectiveTimeoutMs)
|
||||
}
|
||||
const orchestrationRequestId = orchestrationMutation
|
||||
const orchestrationRequestId = durableMutation
|
||||
? (options?.orchestrationRequestId ?? randomUUID())
|
||||
: undefined
|
||||
const originalCommand = orchestrationMutation
|
||||
const originalCommand = durableMutation
|
||||
? buildOrchestrationRecoveryCommand(method, params, this.cliExecutable, this.originalArgs)
|
||||
: undefined
|
||||
const recover = (error: unknown, targetRuntimeId: string | null) => {
|
||||
if (legacyTerminalPrompt) {
|
||||
return attachLegacyTerminalPromptRecovery(error)
|
||||
}
|
||||
if (
|
||||
terminalPromptMutation &&
|
||||
options?.terminalPromptPreflight &&
|
||||
didAnotherRuntimeHandleTerminalPrompt(
|
||||
error,
|
||||
options.terminalPromptPreflight.runtimeId,
|
||||
targetRuntimeId
|
||||
)
|
||||
) {
|
||||
return attachUnverifiedTerminalPromptRecovery(error)
|
||||
}
|
||||
return attachDurableMutationRecovery(error, orchestrationRequestId, originalCommand, method)
|
||||
}
|
||||
const compatibilityEnvelope = method.startsWith('orchestration.')
|
||||
? {
|
||||
...this.orchestrationCompatibility,
|
||||
@@ -128,14 +149,10 @@ export class RuntimeClient {
|
||||
envelope
|
||||
})
|
||||
} catch (error) {
|
||||
throw attachMutationRecovery(error, orchestrationRequestId, originalCommand)
|
||||
throw recover(error, null)
|
||||
}
|
||||
if (response.ok === false) {
|
||||
throw attachMutationRecovery(
|
||||
new RuntimeRpcFailureError(response),
|
||||
orchestrationRequestId,
|
||||
originalCommand
|
||||
)
|
||||
throw recover(new RuntimeRpcFailureError(response), null)
|
||||
}
|
||||
if (this.environmentSelector) {
|
||||
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
|
||||
@@ -149,14 +166,10 @@ export class RuntimeClient {
|
||||
try {
|
||||
response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs, envelope)
|
||||
} catch (error) {
|
||||
throw attachMutationRecovery(error, orchestrationRequestId, originalCommand)
|
||||
throw recover(error, metadata.runtimeId ?? null)
|
||||
}
|
||||
if (response.ok === false) {
|
||||
throw attachMutationRecovery(
|
||||
new RuntimeRpcFailureError(response),
|
||||
orchestrationRequestId,
|
||||
originalCommand
|
||||
)
|
||||
throw recover(new RuntimeRpcFailureError(response), metadata.runtimeId ?? null)
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -293,33 +306,4 @@ function throwDesktopActivationBlocked(): never {
|
||||
)
|
||||
}
|
||||
|
||||
function resolveRemotePairing(
|
||||
userDataPath: string,
|
||||
pairingCode: string | null,
|
||||
environmentSelector: string | null
|
||||
): PairingOffer | null {
|
||||
if (pairingCode && environmentSelector) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --pairing-code or --environment, not both.'
|
||||
)
|
||||
}
|
||||
if (environmentSelector) {
|
||||
return resolveEnvironmentPairingOffer(userDataPath, environmentSelector)
|
||||
}
|
||||
if (!pairingCode) {
|
||||
return null
|
||||
}
|
||||
const pairing = parsePairingCode(pairingCode)
|
||||
if (!pairing) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Invalid remote pairing code. Expected an orca://pair?... URL or bare pairing payload.'
|
||||
)
|
||||
}
|
||||
return pairing
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
|
||||
import { resolveEnvironmentPairingOffer } from './environments'
|
||||
import { RuntimeClientError } from './types'
|
||||
|
||||
export function resolveRemotePairing(
|
||||
userDataPath: string,
|
||||
pairingCode: string | null,
|
||||
environmentSelector: string | null
|
||||
): PairingOffer | null {
|
||||
if (pairingCode && environmentSelector) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --pairing-code or --environment, not both.'
|
||||
)
|
||||
}
|
||||
if (environmentSelector) {
|
||||
return resolveEnvironmentPairingOffer(userDataPath, environmentSelector)
|
||||
}
|
||||
if (!pairingCode) {
|
||||
return null
|
||||
}
|
||||
const pairing = parsePairingCode(pairingCode)
|
||||
if (!pairing) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Invalid remote pairing code. Expected an orca://pair?... URL or bare pairing payload.'
|
||||
)
|
||||
}
|
||||
return pairing
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { attachMutationRecovery } from './client-error-recovery'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './types'
|
||||
|
||||
const INSPECT_STEP = 'Inspect the terminal output and agent state without sending input.'
|
||||
|
||||
export function attachDurableMutationRecovery(
|
||||
error: unknown,
|
||||
requestId: string | undefined,
|
||||
originalCommand: string[] | undefined,
|
||||
method: string
|
||||
): unknown {
|
||||
if (method !== 'terminal.send' || !requestId || !(error instanceof RuntimeClientError)) {
|
||||
return attachMutationRecovery(error, requestId, originalCommand)
|
||||
}
|
||||
const message = `${error.message} Terminal prompt request ID: ${requestId}. Re-issue the exact command with --retry-request ${requestId} --wait-submit <seconds>; do not retry it without that ID.`
|
||||
const data = {
|
||||
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
||||
orchestrationRequestId: requestId,
|
||||
...(originalCommand ? { originalCommand } : {})
|
||||
}
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return new RuntimeRpcFailureError({
|
||||
...error.response,
|
||||
error: { ...error.response.error, message, data }
|
||||
})
|
||||
}
|
||||
return new RuntimeClientError(error.code, message, data)
|
||||
}
|
||||
|
||||
export function attachLegacyTerminalPromptRecovery(error: unknown): unknown {
|
||||
if (!(error instanceof RuntimeClientError)) {
|
||||
return error
|
||||
}
|
||||
return attachUnknownTerminalPromptRecovery(
|
||||
error,
|
||||
'The legacy host cannot prove whether the prompt was delivered',
|
||||
[
|
||||
INSPECT_STEP,
|
||||
'Update Orca on the execution host before future prompt sends that need durable retry.'
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
export function attachUnverifiedTerminalPromptRecovery(error: unknown): RuntimeClientError {
|
||||
const normalized =
|
||||
error instanceof RuntimeClientError
|
||||
? error
|
||||
: new RuntimeClientError(
|
||||
'runtime_error',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
return attachUnknownTerminalPromptRecovery(
|
||||
normalized,
|
||||
'Orca cannot prove whether the prompt was delivered by the prompt-delivery-capable runtime from the preflight',
|
||||
[
|
||||
INSPECT_STEP,
|
||||
'A different Orca runtime answered than the one whose prompt-delivery support was verified; confirm which runtime serves this host before sending again.'
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The prompt request ID survives a failed send unless a runtime other than the preflight's
|
||||
* prompt-delivery host handled it; a transport failure alone means nobody else answered, and the
|
||||
* attested host still holds the durable pending receipt that makes `--retry-request` idempotent.
|
||||
*/
|
||||
export function didAnotherRuntimeHandleTerminalPrompt(
|
||||
error: unknown,
|
||||
preflightRuntimeId: string | null,
|
||||
targetRuntimeId: string | null
|
||||
): boolean {
|
||||
const handledBy =
|
||||
error instanceof RuntimeRpcFailureError
|
||||
? (error.response._meta?.runtimeId ?? null)
|
||||
: targetRuntimeId
|
||||
if (handledBy === null) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
typeof preflightRuntimeId !== 'string' ||
|
||||
preflightRuntimeId.length === 0 ||
|
||||
handledBy !== preflightRuntimeId
|
||||
)
|
||||
}
|
||||
|
||||
function attachUnknownTerminalPromptRecovery(
|
||||
error: RuntimeClientError,
|
||||
reason: string,
|
||||
nextSteps: string[]
|
||||
): RuntimeClientError {
|
||||
const message = `${error.message} ${reason}; inspect the terminal before deciding what to do, and do not resend automatically.`
|
||||
const data: Record<string, unknown> = {
|
||||
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
||||
deliveryOutcome: 'unknown',
|
||||
retrySafe: false,
|
||||
nextSteps
|
||||
}
|
||||
delete data.orchestrationRequestId
|
||||
delete data.originalCommand
|
||||
delete data.recovery
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return new RuntimeRpcFailureError({
|
||||
...error.response,
|
||||
error: { ...error.response.error, message, data }
|
||||
})
|
||||
}
|
||||
return new RuntimeClientError(error.code, message, data)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Per-flag help for the skills commands, kept out of the shared help chain it would crowd. */
|
||||
const SKILLS_FLAG_HELP: Record<string, Record<string, string>> = {
|
||||
'skills get': {
|
||||
full: '--full Print the full guide with bundled references',
|
||||
reference: '--reference <name> Print one bundled reference by name',
|
||||
references: '--references List the bundled reference names for a topic'
|
||||
},
|
||||
'skills install': {
|
||||
agent: '--agent <names> Comma-separated install targets; default is detected agents'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSkillsCommandFlagHelp(command: string, flag: string): string | undefined {
|
||||
return SKILLS_FLAG_HELP[command]?.[flag]
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
|
||||
vi.mock('./bundled-skill-guides.js', () => ({
|
||||
BUNDLED_SKILL_GUIDES: [
|
||||
{
|
||||
name: 'alpha',
|
||||
description: 'Use when alpha work is needed.',
|
||||
markdown: '# Alpha\n\nShort.\n',
|
||||
fullMarkdown: '# Alpha\n\nShort.\n\n## References\n\nFull.\n',
|
||||
aliases: ['legacy-alpha'],
|
||||
references: [
|
||||
{ name: 'first-gate', markdown: '# First gate\n\nDo the first thing.\n' },
|
||||
{ name: 'second-gate', markdown: '# Second gate\n\nDo the second thing.\n' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'zeta',
|
||||
description: 'Use when zeta work is needed.',
|
||||
markdown: '# Zeta\n',
|
||||
fullMarkdown: '# Zeta\n',
|
||||
aliases: [],
|
||||
references: []
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-client', async () => {
|
||||
const { RuntimeClientError, RuntimeRpcFailureError } = await import('./runtime/types.js')
|
||||
class RuntimeClient {
|
||||
constructor() {
|
||||
throw new Error('skills get constructed a RuntimeClient')
|
||||
}
|
||||
}
|
||||
return {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError,
|
||||
serveOrcaApp: vi.fn(),
|
||||
getDefaultUserDataPath: vi.fn(() => '/tmp/orca-user-data')
|
||||
}
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
|
||||
function stdoutText(spy: ReturnType<typeof vi.spyOn>): string {
|
||||
return spy.mock.calls.map((call) => String(call[0])).join('')
|
||||
}
|
||||
|
||||
describe('orca skills get --reference', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
it('prints only the named reference, with no kernel and no header', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--reference', 'second-gate'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe('# Second gate\n\nDo the second thing.\n')
|
||||
})
|
||||
|
||||
it('accepts the references/<file>.md spelling the gate table prints', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--reference', 'references/first-gate.md'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe('# First gate\n\nDo the first thing.\n')
|
||||
})
|
||||
|
||||
it('resolves a reference through a topic alias', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'get', 'legacy-alpha', '--reference', 'first-gate.md'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe('# First gate\n\nDo the first thing.\n')
|
||||
})
|
||||
|
||||
it('gives --reference --json the canonical topic, reference name, and Markdown', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(
|
||||
['skills', 'get', 'legacy-alpha', '--reference', 'references/first-gate.md', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: 'alpha',
|
||||
reference: 'first-gate',
|
||||
markdown: '# First gate\n\nDo the first thing.\n'
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
})
|
||||
|
||||
it('lists reference names for --references', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--references'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe('first-gate\nsecond-gate\n')
|
||||
})
|
||||
|
||||
it('gives --references --json a stable schema', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--references', '--json'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
`${JSON.stringify({ name: 'alpha', references: ['first-gate', 'second-gate'] }, null, 2)}\n`
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a topic that ships no references', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'get', 'zeta', '--references'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith('Guide "zeta" has no bundled references.')
|
||||
})
|
||||
|
||||
it('names the available references for an unknown one', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--reference', 'nope'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Unknown reference "nope" for alpha. Available: first-gate, second-gate'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects --reference on a topic with no references', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'get', 'zeta', '--reference', 'first-gate'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith('Guide "zeta" has no bundled references.')
|
||||
})
|
||||
|
||||
it('rejects --reference without a value', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--reference'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith('Missing required --reference')
|
||||
})
|
||||
|
||||
it('rejects combining --full with --reference', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--full', '--reference', 'first-gate'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith('Use either --full or --reference, not both.')
|
||||
})
|
||||
|
||||
it('rejects combining --references with --full or --reference', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--references', '--full'], '/tmp/repo')
|
||||
await main(['skills', 'get', 'alpha', '--references', '--reference', 'first-gate'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenNthCalledWith(1, 'Use either --references or --full, not both.')
|
||||
expect(errorSpy).toHaveBeenNthCalledWith(2, 'Use either --references or --reference, not both.')
|
||||
})
|
||||
|
||||
it('still serves the kernel and the full package unchanged', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'get', 'alpha'], '/tmp/repo')
|
||||
await main(['skills', 'get', 'alpha', '--full'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
'# Alpha\n\nShort.\n# Alpha\n\nShort.\n\n## References\n\nFull.\n'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -213,7 +213,7 @@ describe('orca skills CLI', () => {
|
||||
await main(['--help'], '/tmp/repo')
|
||||
|
||||
expect(String(logSpy.mock.calls[0]?.[0])).toContain(
|
||||
'Usage: orca skills get <topic> [--full] [--json]'
|
||||
'Usage: orca skills get <topic> [--full | --reference <name>] [--json]'
|
||||
)
|
||||
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
|
||||
'Commands:\n installed List installed skill selectors'
|
||||
@@ -303,7 +303,7 @@ describe('orca skills CLI', () => {
|
||||
await main(['skills', 'install', '--skill'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith('Missing required --skill')
|
||||
expect(errorSpy).toHaveBeenCalledWith('--skill requires a value; it was passed with none.')
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
import { WORKTREE_LISTING_SCOPE_NOTES } from './worktree-listing-scope-notes'
|
||||
import { SERVE_COMMAND_SPECS } from './serve'
|
||||
import { TERMINAL_SEND_COMMAND_SPEC } from './terminal-send'
|
||||
import { TERMINAL_CLOSE_COMMAND_SPEC } from './terminal-close'
|
||||
|
||||
export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
@@ -224,13 +225,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
'orca terminal read --terminal term_abc123 --screen --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'send'],
|
||||
summary: 'Send input to a live terminal',
|
||||
usage:
|
||||
'orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'text', 'enter', 'interrupt']
|
||||
},
|
||||
TERMINAL_SEND_COMMAND_SPEC,
|
||||
{
|
||||
path: ['terminal', 'wait'],
|
||||
summary: 'Wait for a terminal condition',
|
||||
|
||||
@@ -5,10 +5,14 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'worker-start'],
|
||||
summary: 'Start one supervised worker on the Run home or a connected Orca server',
|
||||
usage:
|
||||
'orca orchestration worker-start --task <task_id> [--on <saved-environment>] [--worktree <current|selector|new-child|new-top-level>] (--agent <agent> | --terminal <handle>) [--model <id>] [--effort <level>] [--name <name>] [--repo <selector>] [--base-branch <ref>] [--display-name <text>] [--comment <text>] [--setup <run|skip|inherit>] [--retry-of <dispatch_id>] [--timeout-ms <n>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
'orca orchestration worker-start (--task <task_id> | --spec <text>) [--on <saved-environment>] [--worktree <current|selector|new-child|new-top-level>] (--agent <agent> | --terminal <handle>) [--task-title <text>] [--deps <json_array>] [--parent <task_id>] [--model <id>] [--effort <level>] [--name <name>] [--repo <selector>] [--base-branch <ref>] [--display-name <text>] [--comment <text>] [--setup <run|skip|inherit>] [--retry-of <dispatch_id>] [--timeout-ms <n>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'task',
|
||||
'spec',
|
||||
'task-title',
|
||||
'deps',
|
||||
'parent',
|
||||
'on',
|
||||
'worktree',
|
||||
'name',
|
||||
@@ -35,7 +39,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.',
|
||||
'--on selects only the worker server; the Run and this command remain on the current Orca server.',
|
||||
'Remote current and new-child are invalid; discover an exact remote selector or use new-top-level.',
|
||||
'--retry-of links the replacement attempt but does not inherit placement; repeat the intended --on/worktree and --agent/terminal choices.',
|
||||
'--retry-of needs --task naming the failed Task (--spec creates a new one) and does not inherit placement; repeat the intended --on/worktree and --agent/terminal choices.',
|
||||
'The call exits 0 only for ready. Failed or outcome_unknown exits 1 and JSON includes stage/failedStage, setup, effects, residualResources, and recovery commands when needed.'
|
||||
]
|
||||
},
|
||||
@@ -110,11 +114,13 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'worker-list'],
|
||||
summary: 'List supervised worker terminal resource accounting',
|
||||
usage:
|
||||
'orca orchestration worker-list [--run <run_id>] [--terminal-state <active|reclaimable|retained|release_pending|release_unknown|released>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'run', 'terminal-state'],
|
||||
'orca orchestration worker-list [--run <run_id>] [--terminal-state <active|reclaimable|retained|release_pending|release_unknown|released>] [--include-remote] [--cursor <cursor>] [--limit <1-100>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'run', 'terminal-state', 'include-remote', 'cursor', 'limit'],
|
||||
notes: [
|
||||
'Terminal state is process accounting and is reported separately from Task status; a completed Task can still own a live terminal.',
|
||||
'Context-only Dispatches created by orchestration dispatch are included as unsupervised with terminal state retained.'
|
||||
'Context-only Dispatches created by orchestration dispatch are included as unsupervised with terminal state retained.',
|
||||
'Returns at most 100 local rows by default; --include-remote adds connected-server observations when the host supports fleet listing. Continue with the opaque page.nextCursor value unchanged.',
|
||||
'Without --run the list is scoped to the Run bound to the calling terminal, and to every Run when there is no binding; the receipt reports which in scope.source (flag, bound, or all).'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -15,3 +15,17 @@ describe('orchestration send command spec', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration check command spec', () => {
|
||||
it('documents --types as a wake condition rather than a batch filter', () => {
|
||||
const checkSpec = ORCHESTRATION_COMMAND_SPECS.find(
|
||||
(spec) => spec.path.join(' ') === 'orchestration check'
|
||||
)
|
||||
|
||||
expect(checkSpec?.notes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--types is the wake condition for --wait; a returned Delivery is always the whole FIFO batch, so it is never filtered by type. Only --peek and --all filter their rows.'
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,6 +109,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
],
|
||||
notes: [
|
||||
'On Windows PowerShell, quote comma-separated type filters, e.g. --types "worker_done,escalation".',
|
||||
'--types is the wake condition for --wait; a returned Delivery is always the whole FIFO batch, so it is never filtered by type. Only --peek and --all filter their rows.',
|
||||
'--format renders the returned rows as local text only; it never writes to another terminal.',
|
||||
'A bound Run replays the same Delivery until --ack; process every message before acknowledging.'
|
||||
]
|
||||
|
||||
@@ -12,6 +12,26 @@ function spec(path: string): (typeof SKILL_COMMAND_SPECS)[number] {
|
||||
}
|
||||
|
||||
describe('skill command specs', () => {
|
||||
it('describes compact retrieval as the default and --full as the full guide', () => {
|
||||
const help = formatCommandHelp(spec('skills get'))
|
||||
|
||||
expect(help).toContain('Prints the compact guide by default')
|
||||
expect(help).toContain('--full Print the full guide with bundled references')
|
||||
expect(help).not.toContain('--full Include all supported V1 issue context')
|
||||
})
|
||||
|
||||
it('documents the per-reference selector beside --full', () => {
|
||||
const help = formatCommandHelp(spec('skills get'))
|
||||
|
||||
expect(help).toContain('Usage: orca skills get <topic> [--full | --reference <name>] [--json]')
|
||||
expect(help).toContain('--reference <name> Print one bundled reference by name')
|
||||
expect(help).toContain('--references List the bundled reference names for a topic')
|
||||
expect(help).toContain('orca skills get orchestration --reference recovery-and-cleanup')
|
||||
expect(effectiveAllowedFlags(spec('skills get'))).toEqual(
|
||||
expect.arrayContaining(['reference', 'references'])
|
||||
)
|
||||
})
|
||||
|
||||
it('requires explicit selectors for sharing and exposes no bulk or path flag', () => {
|
||||
const flags = effectiveAllowedFlags(spec('skills share'))
|
||||
|
||||
|
||||
+12
-5
@@ -40,22 +40,29 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [
|
||||
notes: [
|
||||
'Reads bundled guide metadata locally without contacting the Orca runtime.',
|
||||
'With --json, prints a topics array of canonical names and one-line descriptions.',
|
||||
'Use `orca skills get <name>` for the full guide, or `orca skills install` to install skills.'
|
||||
'Use `orca skills get <name>` for the compact guide, `--full` for its full reference package, or `orca skills install` to install skills.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['skills', 'get'],
|
||||
aliases: [['skills', 'show']],
|
||||
summary: 'Print a version-matched skill guide as Markdown',
|
||||
usage: 'orca skills get <topic> [--full] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'topic', 'full'],
|
||||
usage: 'orca skills get <topic> [--full | --reference <name>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'topic', 'full', 'reference', 'references'],
|
||||
positionalArgs: ['topic'],
|
||||
notes: [
|
||||
'Reads bundled guide content locally without contacting the Orca runtime.',
|
||||
'Use --full to include bundled reference documents when the guide provides them.',
|
||||
'Prints the compact guide by default. Use --full to print the full guide with bundled references when provided.',
|
||||
'Use --reference <name> to print one bundled reference alone, which is what an action gate in the compact guide needs; --references lists the available names.',
|
||||
'A reference name may be given bare (recovery-and-cleanup) or as the guide spells it (references/recovery-and-cleanup.md).',
|
||||
'Use --json for a deterministic object containing canonical topic metadata and content.'
|
||||
],
|
||||
examples: ['orca skills get orca-cli', 'orca skills get orchestration --full']
|
||||
examples: [
|
||||
'orca skills get orca-cli',
|
||||
'orca skills get orchestration --full',
|
||||
'orca skills get orchestration --references',
|
||||
'orca skills get orchestration --reference recovery-and-cleanup'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['skills', 'install'],
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const TERMINAL_SEND_COMMAND_SPEC: CommandSpec = {
|
||||
path: ['terminal', 'send'],
|
||||
summary: 'Send input to a live terminal',
|
||||
usage:
|
||||
'orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--wait-submit <seconds>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'terminal',
|
||||
'text',
|
||||
'enter',
|
||||
'interrupt',
|
||||
'wait-submit',
|
||||
'retry-request'
|
||||
],
|
||||
notes: [
|
||||
'For a text-plus-Enter agent prompt, the result separates input acceptance from observed submission and turn start.',
|
||||
'--wait-submit only observes the accepted prompt for the requested duration; timeout returns the queued/input-accepted receipt and never resends.',
|
||||
'After an ambiguous transport failure, reissue the exact command with the reported --retry-request ID. The ID is bound to the prompt payload and exact terminal process incarnation.',
|
||||
'Older hosts accept the legacy raw input but report provider old-host and do not offer idempotent retry or submission observation.'
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Write one newline-terminated payload to stdout without doubling an existing newline. */
|
||||
export function writeStdoutLine(value: string): void {
|
||||
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user