mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge reviewed application prerequisites from main
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
|
||||
|
||||
@@ -98,12 +98,17 @@ jobs:
|
||||
$env:SKIP_BUILD = '1'
|
||||
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
|
||||
pnpm run --if-present test:e2e:workspace-session-golden
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
pnpm run --if-present test:e2e:windows-fresh-startup-golden
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) {
|
||||
pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
}
|
||||
pnpm run --if-present test:e2e:source-control-golden
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
|
||||
@@ -104,11 +104,47 @@ jobs:
|
||||
--clobber \
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
else
|
||||
# Why: release tags live on side branches, so GitHub's automatic
|
||||
# previous-tag detection reaches back several releases; that body
|
||||
# already exceeds the 125000-character API limit and grows each
|
||||
# release. Pin the comparison base and cap the size.
|
||||
notes_file="$RUNNER_TEMP/android-release-notes.md"
|
||||
previous_tag="$(
|
||||
gh release list --repo "$GITHUB_REPOSITORY" --limit 200 --json tagName --jq '.[].tagName' \
|
||||
| grep '^mobile-android-v' | grep -Fxv "$tag" | sort -V | tail -1 || true
|
||||
)"
|
||||
|
||||
if [ -n "$previous_tag" ]; then
|
||||
# Why: gh writes the JSON error body to stdout on an HTTP error, so a
|
||||
# non-empty file is not proof of success — gate on exit status.
|
||||
if ! gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" -X POST \
|
||||
-f tag_name="$tag" \
|
||||
-f target_commitish="$GITHUB_SHA" \
|
||||
-f previous_tag_name="$previous_tag" \
|
||||
--jq .body > "$notes_file"; then
|
||||
: > "$notes_file"
|
||||
fi
|
||||
fi
|
||||
if [ ! -s "$notes_file" ]; then
|
||||
printf 'Orca Mobile Android %s\n' "$tag" > "$notes_file"
|
||||
fi
|
||||
# Why: reuse the desktop release path's character-safe truncation so a
|
||||
# multi-byte character cannot be split at the cap.
|
||||
NOTES_FILE="$notes_file" \
|
||||
NOTES_MODULE="$GITHUB_WORKSPACE/config/scripts/create-draft-release.mjs" \
|
||||
node --input-type=module -e '
|
||||
const { readFileSync, writeFileSync } = await import("node:fs")
|
||||
const { pathToFileURL } = await import("node:url")
|
||||
const { truncateReleaseBody } = await import(pathToFileURL(process.env.NOTES_MODULE).href)
|
||||
const file = process.env.NOTES_FILE
|
||||
writeFileSync(file, truncateReleaseBody(readFileSync(file, "utf8")))
|
||||
'
|
||||
|
||||
gh release create "$tag" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "Orca Mobile Android $tag" \
|
||||
--prerelease \
|
||||
--latest=false \
|
||||
--generate-notes \
|
||||
--notes-file "$notes_file" \
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Packaged browser compatibility
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Commit SHA or ref to validate (defaults to the selected revision)
|
||||
type: string
|
||||
required: false
|
||||
schedule:
|
||||
- cron: '20 8 * * 1'
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
type: string
|
||||
required: false
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
- name: Install headless tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 ripgrep xvfb zsh openbox x11-utils
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: electron
|
||||
- name: Download pinned old release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release download v1.4.188 --repo stablyai/orca --pattern orca-ide_1.4.188_amd64.deb --dir "$RUNNER_TEMP/old-orca"
|
||||
python3 - <<'PYVERIFY'
|
||||
import base64,hashlib,os,pathlib,subprocess
|
||||
root=pathlib.Path(os.environ['RUNNER_TEMP'])/'old-orca'
|
||||
package=root/'orca-ide_1.4.188_amd64.deb'
|
||||
expected='uGONFUDfinYggxcT9ac72wnnlofLQaqasDDeP0HWOSqarBwTi1Ax3khmzKUY3vUnvuYOpSCEmsH4InzLZ2vg6g=='
|
||||
assert base64.b64encode(hashlib.sha512(package.read_bytes()).digest()).decode()==expected
|
||||
extracted=root/'extracted'
|
||||
subprocess.run(['dpkg-deb','-x',str(package),str(extracted)],check=True)
|
||||
executable=extracted/'opt'/'Orca'/'orca-ide'
|
||||
assert executable.is_file() and os.access(executable,os.X_OK)
|
||||
with open(os.environ['GITHUB_ENV'],'a') as env: env.write('ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE='+str(executable)+'\n')
|
||||
print('Verified old package:',executable)
|
||||
PYVERIFY
|
||||
- name: Build current Electron app
|
||||
env:
|
||||
VITE_EXPOSE_STORE: 'true'
|
||||
run: |
|
||||
pnpm run build:relay
|
||||
pnpm exec electron-vite build --mode e2e
|
||||
pnpm run build:web-from-renderer
|
||||
- name: Run both mixed-version directions
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/packaged-browser-results.json
|
||||
run: >-
|
||||
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
|
||||
pnpm exec playwright test --config tests/playwright.config.ts
|
||||
tests/e2e/packaged-mixed-version-browser-placement.spec.ts
|
||||
--project=electron-headless --workers=1 --retries=0 --repeat-each=3 --reporter=list,json
|
||||
- name: Require all six compatibility executions
|
||||
if: always()
|
||||
run: node config/scripts/verify-packaged-browser-participation.mjs test-results/packaged-browser-results.json
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: packaged-mixed-version-audit
|
||||
path: test-results/
|
||||
retention-days: 3
|
||||
@@ -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
|
||||
@@ -854,6 +858,7 @@ jobs:
|
||||
src/main/windows/windows-pty-job.win32.test.ts
|
||||
src/main/windows/windows-host-job.win32.test.ts
|
||||
src/main/windows/windows-process-tree-command-line-patch.test.ts
|
||||
src/main/windows/windows-process-table-native-addon.win32.test.ts
|
||||
src/main/windows-live-tree-kill.win32.test.ts
|
||||
src/main/wsl/wsl-runner.test.ts
|
||||
src/main/wsl/wsl-guest-environment.test.ts
|
||||
@@ -942,6 +947,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
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
- **Android:** [Download APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"app-store-performance/require-selector": "warn",
|
||||
"app-store-performance/no-identity-selector": "warn",
|
||||
"app-store-performance/no-fresh-selector-result": "warn",
|
||||
"app-store-performance/no-nested-fresh-under-shallow": "warn",
|
||||
"quadratic-buffer-concat/no-loop-carried-concat": "warn",
|
||||
"sort-comparator-performance/no-repeated-collator": "warn"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,19 @@ const ALLOCATING_METHODS = new Set([
|
||||
'toSpliced',
|
||||
'with'
|
||||
])
|
||||
const ALLOCATING_OBJECT_STATICS = new Set([
|
||||
'assign',
|
||||
'create',
|
||||
'entries',
|
||||
'fromEntries',
|
||||
'keys',
|
||||
'values'
|
||||
])
|
||||
const FUNCTION_NODES = new Set([
|
||||
'ArrowFunctionExpression',
|
||||
'FunctionDeclaration',
|
||||
'FunctionExpression'
|
||||
])
|
||||
|
||||
function identifierName(node) {
|
||||
return node?.type === 'Identifier' ? node.name : null
|
||||
@@ -25,8 +38,12 @@ function propertyName(node) {
|
||||
: null
|
||||
}
|
||||
|
||||
function functionNode(node) {
|
||||
return FUNCTION_NODES.has(node?.type) ? node : null
|
||||
}
|
||||
|
||||
function returnedExpressions(selector) {
|
||||
if (selector?.type !== 'ArrowFunctionExpression' && selector?.type !== 'FunctionExpression') {
|
||||
if (!functionNode(selector)) {
|
||||
return []
|
||||
}
|
||||
if (selector.body.type !== 'BlockStatement') {
|
||||
@@ -37,10 +54,7 @@ function returnedExpressions(selector) {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return
|
||||
}
|
||||
if (
|
||||
node !== selector.body &&
|
||||
['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression'].includes(node.type)
|
||||
) {
|
||||
if (node !== selector.body && FUNCTION_NODES.has(node.type)) {
|
||||
return
|
||||
}
|
||||
if (node.type === 'ReturnStatement') {
|
||||
@@ -76,10 +90,7 @@ function unwrapShallowSelector(selector, shallowHooks) {
|
||||
}
|
||||
|
||||
function isIdentitySelector(selector) {
|
||||
if (selector?.type !== 'ArrowFunctionExpression' && selector?.type !== 'FunctionExpression') {
|
||||
return false
|
||||
}
|
||||
const parameter = selector.params[0]
|
||||
const parameter = functionNode(selector)?.params[0]
|
||||
if (parameter?.type !== 'Identifier') {
|
||||
return false
|
||||
}
|
||||
@@ -88,14 +99,23 @@ function isIdentitySelector(selector) {
|
||||
)
|
||||
}
|
||||
|
||||
function isAllocatingExpression(expression) {
|
||||
if (expression?.type === 'ConditionalExpression') {
|
||||
return (
|
||||
isAllocatingExpression(expression.consequent) || isAllocatingExpression(expression.alternate)
|
||||
)
|
||||
}
|
||||
if (expression?.type === 'LogicalExpression') {
|
||||
return isAllocatingExpression(expression.left) || isAllocatingExpression(expression.right)
|
||||
/**
|
||||
* `everyBranch` decides how a conditional counts. An inline selector is flagged
|
||||
* when ANY branch allocates; a helper the selector delegates to must allocate on
|
||||
* EVERY branch, so the `cache.get(k) ?? build(state)` identity-caching shape is
|
||||
* not a false positive.
|
||||
*/
|
||||
function allocates(expression, everyBranch) {
|
||||
const branches =
|
||||
expression?.type === 'ConditionalExpression'
|
||||
? [expression.consequent, expression.alternate]
|
||||
: expression?.type === 'LogicalExpression'
|
||||
? [expression.left, expression.right]
|
||||
: null
|
||||
if (branches) {
|
||||
return everyBranch
|
||||
? branches.every((branch) => allocates(branch, true))
|
||||
: branches.some((branch) => allocates(branch, false))
|
||||
}
|
||||
if (
|
||||
expression?.type === 'ArrayExpression' ||
|
||||
@@ -107,44 +127,104 @@ function isAllocatingExpression(expression) {
|
||||
if (expression?.type !== 'CallExpression') {
|
||||
return false
|
||||
}
|
||||
const method = propertyName(expression.callee)
|
||||
if (method && ALLOCATING_METHODS.has(method)) {
|
||||
return true
|
||||
}
|
||||
const callee = expression.callee
|
||||
const method = propertyName(callee)
|
||||
return (
|
||||
callee.type === 'MemberExpression' &&
|
||||
identifierName(callee.object) === 'Object' &&
|
||||
['assign', 'create', 'entries', 'fromEntries', 'keys', 'values'].includes(propertyName(callee))
|
||||
ALLOCATING_METHODS.has(method) ||
|
||||
(identifierName(callee.object) === 'Object' && ALLOCATING_OBJECT_STATICS.has(method))
|
||||
)
|
||||
}
|
||||
|
||||
function importedLocalName(specifier, importedName) {
|
||||
if (specifier.type !== 'ImportSpecifier' || identifierName(specifier.imported) !== importedName) {
|
||||
return null
|
||||
function isAllocatingExpression(expression) {
|
||||
return allocates(expression, false)
|
||||
}
|
||||
|
||||
// Project-local zustand hooks follow the use<Name>Store convention; React's
|
||||
// useSyncExternalStore matches that shape but is not a store subscription.
|
||||
const STORE_HOOK_NAME = /^use[A-Z][A-Za-z0-9]*Store$/
|
||||
const NON_STORE_HOOKS = new Set(['useSyncExternalStore'])
|
||||
|
||||
function isLocalModuleSource(source) {
|
||||
return typeof source === 'string' && (source.startsWith('.') || source.startsWith('@/'))
|
||||
}
|
||||
|
||||
/** Module scope only: a component-local helper must not shadow a same-named import. */
|
||||
function isModuleScope(node) {
|
||||
const parent = node.parent
|
||||
return (
|
||||
parent?.type === 'Program' ||
|
||||
(parent?.type === 'ExportNamedDeclaration' && parent.parent?.type === 'Program')
|
||||
)
|
||||
}
|
||||
|
||||
/** Records module-scope `const selectX = (state) => ...` so identifier selectors resolve. */
|
||||
function recordNamedSelector(node, state) {
|
||||
if (!isModuleScope(node)) {
|
||||
return
|
||||
}
|
||||
return identifierName(specifier.local)
|
||||
const declared =
|
||||
node.type === 'FunctionDeclaration'
|
||||
? [[node.id, node]]
|
||||
: node.declarations.map((declarator) => [declarator.id, declarator.init])
|
||||
for (const [id, initializer] of declared) {
|
||||
const name = identifierName(id)
|
||||
if (name && functionNode(initializer)) {
|
||||
state.namedSelectors.set(name, initializer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline function, or a module-scope selector referenced by name. */
|
||||
function resolveSelector(argument, state) {
|
||||
return functionNode(argument) ?? state.namedSelectors.get(identifierName(argument)) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* One hop: a selector that delegates to a module-scope helper is the idiomatic
|
||||
* shape here, and neither the inline-body check nor a reviewer reading the call
|
||||
* site can see what that helper returns. An unresolvable helper is left alone.
|
||||
*/
|
||||
function expandThroughNamedHelper(expression, state) {
|
||||
const helper =
|
||||
expression?.type === 'CallExpression'
|
||||
? state.namedSelectors.get(identifierName(expression.callee))
|
||||
: undefined
|
||||
const returned = helper ? returnedExpressions(helper) : []
|
||||
return returned.length > 0 && returned.every((entry) => allocates(entry, true))
|
||||
? returned
|
||||
: [expression]
|
||||
}
|
||||
|
||||
function createRuleState() {
|
||||
return {
|
||||
appStoreHooks: new Set(),
|
||||
shallowHooks: new Set()
|
||||
shallowHooks: new Set(),
|
||||
namedSelectors: new Map(),
|
||||
deferredCalls: []
|
||||
}
|
||||
}
|
||||
|
||||
function recordImports(node, state) {
|
||||
if (node.source?.value === 'zustand/react/shallow') {
|
||||
for (const specifier of node.specifiers) {
|
||||
const localName = importedLocalName(specifier, 'useShallow')
|
||||
if (localName) {
|
||||
state.shallowHooks.add(localName)
|
||||
}
|
||||
}
|
||||
}
|
||||
const source = node.source?.value
|
||||
for (const specifier of node.specifiers) {
|
||||
const localName = importedLocalName(specifier, 'useAppStore')
|
||||
if (localName) {
|
||||
if (specifier.type !== 'ImportSpecifier') {
|
||||
continue
|
||||
}
|
||||
const imported = identifierName(specifier.imported)
|
||||
const localName = identifierName(specifier.local)
|
||||
if (!imported || !localName) {
|
||||
continue
|
||||
}
|
||||
if (source === 'zustand/react/shallow' && imported === 'useShallow') {
|
||||
state.shallowHooks.add(localName)
|
||||
}
|
||||
// useAppStore is the app store wherever it is re-exported from; sibling
|
||||
// stores are trusted by naming convention only when they come from this codebase.
|
||||
if (
|
||||
STORE_HOOK_NAME.test(imported) &&
|
||||
!NON_STORE_HOOKS.has(imported) &&
|
||||
(imported === 'useAppStore' || isLocalModuleSource(source))
|
||||
) {
|
||||
state.appStoreHooks.add(localName)
|
||||
}
|
||||
}
|
||||
@@ -176,52 +256,107 @@ function requireSelectorRule() {
|
||||
}
|
||||
}
|
||||
|
||||
function noIdentitySelectorRule() {
|
||||
/**
|
||||
* Selector arguments are collected during traversal and judged at Program:exit so a
|
||||
* selector hoisted below its call site still resolves.
|
||||
*/
|
||||
function deferredSelectorRule(inspect) {
|
||||
const state = createRuleState()
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
recordImports(node, state)
|
||||
},
|
||||
FunctionDeclaration(node) {
|
||||
recordNamedSelector(node, state)
|
||||
},
|
||||
VariableDeclaration(node) {
|
||||
recordNamedSelector(node, state)
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (!isAppStoreCall(node, state)) {
|
||||
return
|
||||
if (isAppStoreCall(node, state)) {
|
||||
state.deferredCalls.push(node)
|
||||
}
|
||||
const { selector } = unwrapShallowSelector(node.arguments[0], state.shallowHooks)
|
||||
if (isIdentitySelector(selector)) {
|
||||
this.report({
|
||||
node: selector,
|
||||
message:
|
||||
'Select the smallest required fields instead of subscribing to the entire app store.'
|
||||
},
|
||||
'Program:exit'() {
|
||||
for (const node of state.deferredCalls) {
|
||||
const { selector: argument, shallow } = unwrapShallowSelector(
|
||||
node.arguments[0],
|
||||
state.shallowHooks
|
||||
)
|
||||
const report = inspect({
|
||||
selector: resolveSelector(argument, state),
|
||||
shallow,
|
||||
state
|
||||
})
|
||||
if (report) {
|
||||
this.report(report)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function noIdentitySelectorRule() {
|
||||
return deferredSelectorRule(({ selector }) =>
|
||||
isIdentitySelector(selector)
|
||||
? {
|
||||
node: selector,
|
||||
message:
|
||||
'Select the smallest required fields instead of subscribing to the entire app store.'
|
||||
}
|
||||
: null
|
||||
)
|
||||
}
|
||||
|
||||
function noFreshSelectorResultRule() {
|
||||
const state = createRuleState()
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
recordImports(node, state)
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (!isAppStoreCall(node, state)) {
|
||||
return
|
||||
}
|
||||
const { selector, shallow } = unwrapShallowSelector(node.arguments[0], state.shallowHooks)
|
||||
if (shallow) {
|
||||
return
|
||||
}
|
||||
const freshResult = returnedExpressions(selector).find(isAllocatingExpression)
|
||||
if (freshResult) {
|
||||
this.report({
|
||||
return deferredSelectorRule(({ selector, shallow, state }) => {
|
||||
if (shallow || !selector) {
|
||||
return null
|
||||
}
|
||||
const freshResult = returnedExpressions(selector)
|
||||
.flatMap((expression) => expandThroughNamedHelper(expression, state))
|
||||
.find(isAllocatingExpression)
|
||||
return freshResult
|
||||
? {
|
||||
node: freshResult,
|
||||
message:
|
||||
'This selector returns a fresh reference on every store write; select a stable field, cache the result, or use useShallow.'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
: null
|
||||
})
|
||||
}
|
||||
|
||||
/** useShallow compares one level deep, so a fresh reference nested inside its result never matches. */
|
||||
function nestedFreshValues(expression) {
|
||||
if (expression?.type === 'ObjectExpression') {
|
||||
return expression.properties
|
||||
.map((property) => (property.type === 'Property' ? property.value : null))
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (expression?.type === 'ArrayExpression') {
|
||||
return expression.elements.filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function noNestedFreshUnderShallowRule() {
|
||||
return deferredSelectorRule(({ selector, shallow, state }) => {
|
||||
if (!shallow || !selector) {
|
||||
return null
|
||||
}
|
||||
const nestedFresh = returnedExpressions(selector)
|
||||
.flatMap((expression) => expandThroughNamedHelper(expression, state))
|
||||
.flatMap(nestedFreshValues)
|
||||
.flatMap((expression) => expandThroughNamedHelper(expression, state))
|
||||
.find(isAllocatingExpression)
|
||||
return nestedFresh
|
||||
? {
|
||||
node: nestedFresh,
|
||||
message:
|
||||
'useShallow compares only one level deep, so this nested fresh reference changes on every store write and defeats the memo; project the primitives the component actually renders.'
|
||||
}
|
||||
: null
|
||||
})
|
||||
}
|
||||
|
||||
function bindContext(createVisitors) {
|
||||
@@ -239,6 +374,7 @@ export default {
|
||||
rules: {
|
||||
'require-selector': { create: bindContext(requireSelectorRule) },
|
||||
'no-identity-selector': { create: bindContext(noIdentitySelectorRule) },
|
||||
'no-fresh-selector-result': { create: bindContext(noFreshSelectorResultRule) }
|
||||
'no-fresh-selector-result': { create: bindContext(noFreshSelectorResultRule) },
|
||||
'no-nested-fresh-under-shallow': { create: bindContext(noNestedFreshUnderShallowRule) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/binding.gyp b/binding.gyp
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e773638bf4 100644
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..0bb2af7923b6e6f1f0da40cae8067304cd1fea14 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -3,7 +3,6 @@
|
||||
@@ -10,7 +10,8 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e7
|
||||
],
|
||||
"conditions": [
|
||||
['OS=="win"', {
|
||||
@@ -15,12 +14,11 @@
|
||||
@@ -14,13 +13,12 @@
|
||||
"src/process_worker.cc",
|
||||
"src/process_commandline.cc"
|
||||
],
|
||||
- "include_dirs": [],
|
||||
@@ -26,314 +27,207 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e7
|
||||
"AdditionalOptions": [
|
||||
"/guard:cf",
|
||||
"/sdl",
|
||||
diff --git a/lib/index.js b/lib/index.js
|
||||
index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba20906615 100644
|
||||
--- a/lib/index.js
|
||||
+++ b/lib/index.js
|
||||
@@ -7,11 +7,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getAllProcesses = exports.getProcessTree = exports.getProcessCpuUsage = exports.getProcessList = exports.filterProcessList = exports.buildProcessTree = exports.ProcessDataFlag = void 0;
|
||||
const util_1 = require("util");
|
||||
const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined;
|
||||
+exports.supportedProcessDataFlags = native === undefined ? undefined : native.supportedProcessDataFlags;
|
||||
var ProcessDataFlag;
|
||||
(function (ProcessDataFlag) {
|
||||
ProcessDataFlag[ProcessDataFlag["None"] = 0] = "None";
|
||||
ProcessDataFlag[ProcessDataFlag["Memory"] = 1] = "Memory";
|
||||
ProcessDataFlag[ProcessDataFlag["CommandLine"] = 2] = "CommandLine";
|
||||
+ ProcessDataFlag[ProcessDataFlag["CreationTime"] = 4] = "CreationTime";
|
||||
})(ProcessDataFlag = exports.ProcessDataFlag || (exports.ProcessDataFlag = {}));
|
||||
// requestInProgress is used for any function that uses CreateToolhelp32Snapshot, as multiple calls
|
||||
// to this cannot be done at the same time.
|
||||
@@ -66,11 +68,12 @@ function buildProcessTree(rootPid, processList, maxDepth = MAX_FILTER_DEPTH) {
|
||||
// • the properties are inlined/splatted
|
||||
// • the 'ppid' field is omitted
|
||||
// • the depth of the tree is limited by `maxDepth`
|
||||
- const buildNode = ({ info: { pid, name, memory, commandLine }, children }, depth) => ({
|
||||
+ const buildNode = ({ info: { pid, name, memory, commandLine, creationTimeMs }, children }, depth) => ({
|
||||
pid,
|
||||
name,
|
||||
memory,
|
||||
commandLine,
|
||||
+ creationTimeMs,
|
||||
children: depth > 0 ? children.map(c => buildNode(c, depth - 1)) : [],
|
||||
});
|
||||
return buildNode(root, maxDepth);
|
||||
diff --git a/lib/index.ts b/lib/index.ts
|
||||
index f9aa005d9ced9e42885b8a976de5eb5bd61899ee..1b509af0b9065918bcb5cb75f2d7f23821d4a56a 100644
|
||||
--- a/lib/index.ts
|
||||
+++ b/lib/index.ts
|
||||
@@ -6,12 +6,15 @@
|
||||
import { promisify } from 'util';
|
||||
|
||||
const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined;
|
||||
+/** The flag bits this compiled addon reports; undefined off win32. */
|
||||
+export const supportedProcessDataFlags: number | undefined = native?.supportedProcessDataFlags;
|
||||
import { IProcessInfo, IProcessTreeNode, IProcessCpuInfo } from '@vscode/windows-process-tree';
|
||||
|
||||
export enum ProcessDataFlag {
|
||||
None = 0,
|
||||
Memory = 1,
|
||||
- CommandLine = 2
|
||||
+ CommandLine = 2,
|
||||
+ CreationTime = 4
|
||||
}
|
||||
|
||||
type RequestCallback = (processList: IProcessInfo[]) => void;
|
||||
@@ -81,11 +84,12 @@ export function buildProcessTree(rootPid: number, processList: Iterable<IProcess
|
||||
// • the properties are inlined/splatted
|
||||
// • the 'ppid' field is omitted
|
||||
// • the depth of the tree is limited by `maxDepth`
|
||||
- const buildNode = ({ info: { pid, name, memory, commandLine }, children }: IProcessInfoNode, depth: number): IProcessTreeNode => ({
|
||||
+ const buildNode = ({ info: { pid, name, memory, commandLine, creationTimeMs }, children }: IProcessInfoNode, depth: number): IProcessTreeNode => ({
|
||||
pid,
|
||||
name,
|
||||
memory,
|
||||
commandLine,
|
||||
+ creationTimeMs,
|
||||
children: depth > 0 ? children.map(c => buildNode(c, depth - 1)) : [],
|
||||
});
|
||||
|
||||
diff --git a/src/addon.cc b/src/addon.cc
|
||||
index 9214aff281251e797a70ecb9f6e0b52932a0503f..722edd42ddb4740296bfc47582a181bd6d00c464 100644
|
||||
--- a/src/addon.cc
|
||||
+++ b/src/addon.cc
|
||||
@@ -53,6 +53,10 @@ void GetProcessCpuUsage(const Napi::CallbackInfo& args) {
|
||||
Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
||||
exports.Set("getProcessList", Napi::Function::New(env, GetProcessList));
|
||||
exports.Set("getProcessCpuUsage", Napi::Function::New(env, GetProcessCpuUsage));
|
||||
+ // Lets a caller prove THIS BINARY understands CREATIONTIME. The JS enum is
|
||||
+ // patched source and says nothing about what the .node was compiled from.
|
||||
+ exports.Set("supportedProcessDataFlags",
|
||||
+ Napi::Number::New(env, MEMORY | COMMANDLINE | CREATIONTIME));
|
||||
return exports;
|
||||
}
|
||||
|
||||
diff --git a/src/process.cc b/src/process.cc
|
||||
index 3eea92077c4d1d433119361d5c432881859131e9..738775f6fcdfb676054386fe34c0380327ed1863 100644
|
||||
index 3eea92077c4d1d433119361d5c432881859131e9..22a47421da919c76e2194280974d39c2287b098d 100644
|
||||
--- a/src/process.cc
|
||||
+++ b/src/process.cc
|
||||
@@ -1,108 +1,112 @@
|
||||
-/*---------------------------------------------------------------------------------------------
|
||||
- * Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
- * Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
- *--------------------------------------------------------------------------------------------*/
|
||||
-
|
||||
-#include "process.h"
|
||||
-#include "process_commandline.h"
|
||||
-
|
||||
-#include <tlhelp32.h>
|
||||
-#include <psapi.h>
|
||||
-#include <limits>
|
||||
-
|
||||
-uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
|
||||
- DWORD process_data_flags) {
|
||||
- // Fetch the PID and PPIDs
|
||||
- PROCESSENTRY32 process_entry = { 0 };
|
||||
- DWORD parent_pid = 0;
|
||||
- uint32_t process_count = 0;
|
||||
- HANDLE snapshot_handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
- process_entry.dwSize = sizeof(PROCESSENTRY32);
|
||||
- if (Process32First(snapshot_handle, &process_entry)) {
|
||||
- do {
|
||||
- if (process_entry.th32ProcessID != 0) {
|
||||
@@ -21,7 +21,8 @@ uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
|
||||
if (Process32First(snapshot_handle, &process_entry)) {
|
||||
do {
|
||||
if (process_entry.th32ProcessID != 0) {
|
||||
- ProcessInfo pinfo;
|
||||
- pinfo.pid = process_entry.th32ProcessID;
|
||||
- pinfo.ppid = process_entry.th32ParentProcessID;
|
||||
-
|
||||
- if (MEMORY & process_data_flags) {
|
||||
- GetProcessMemoryUsage(pinfo);
|
||||
- }
|
||||
-
|
||||
- if (COMMANDLINE & process_data_flags) {
|
||||
- GetProcessCommandLine(pinfo);
|
||||
- }
|
||||
-
|
||||
- strcpy(pinfo.name, process_entry.szExeFile);
|
||||
- process_info.push_back(std::move(pinfo));
|
||||
- process_count++;
|
||||
- }
|
||||
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
|
||||
- }
|
||||
-
|
||||
- CloseHandle(snapshot_handle);
|
||||
- return process_count;
|
||||
-}
|
||||
-
|
||||
-void GetProcessMemoryUsage(ProcessInfo& process_info) {
|
||||
- DWORD pid = process_info.pid;
|
||||
- HANDLE hProcess;
|
||||
- PROCESS_MEMORY_COUNTERS pmc;
|
||||
-
|
||||
- hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid);
|
||||
-
|
||||
- if (hProcess == NULL) {
|
||||
- return;
|
||||
- }
|
||||
-
|
||||
- if (GetProcessMemoryInfo(hProcess, &pmc, sizeof(pmc))) {
|
||||
- process_info.memory = (DWORD)pmc.WorkingSetSize;
|
||||
- }
|
||||
-
|
||||
- CloseHandle(hProcess);
|
||||
-}
|
||||
-
|
||||
-// Per documentation, it is not recommended to add or subtract values from the FILETIME
|
||||
-// structure, or to cast it to ULARGE_INTEGER as this can cause alignment faults on 64-bit Windows.
|
||||
-// Copy the high and low part to a ULARGE_INTEGER and peform arithmetic on that instead.
|
||||
-// See https://msdn.microsoft.com/en-us/library/windows/desktop/ms724284(v=vs.85).aspx
|
||||
-ULONGLONG GetTotalTime(const FILETIME* kernelTime, const FILETIME* userTime) {
|
||||
- ULARGE_INTEGER kt, ut;
|
||||
- kt.LowPart = (*kernelTime).dwLowDateTime;
|
||||
- kt.HighPart = (*kernelTime).dwHighDateTime;
|
||||
-
|
||||
- ut.LowPart = (*userTime).dwLowDateTime;
|
||||
- ut.HighPart = (*userTime).dwHighDateTime;
|
||||
-
|
||||
- return kt.QuadPart + ut.QuadPart;
|
||||
-}
|
||||
-
|
||||
-void GetCpuUsage(Cpu& cpu_info, bool first_pass) {
|
||||
- DWORD pid = cpu_info.pid;
|
||||
- HANDLE hProcess;
|
||||
-
|
||||
- hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid);
|
||||
-
|
||||
- if (hProcess == NULL) {
|
||||
- return;
|
||||
- }
|
||||
-
|
||||
- FILETIME creationTime, exitTime, kernelTime, userTime;
|
||||
- FILETIME sysIdleTime, sysKernelTime, sysUserTime;
|
||||
- if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime)
|
||||
- && GetSystemTimes(&sysIdleTime, &sysKernelTime, &sysUserTime)) {
|
||||
- if (first_pass) {
|
||||
- cpu_info.initialProcRunTime = GetTotalTime(&kernelTime, &userTime);
|
||||
- cpu_info.initialSystemTime = GetTotalTime(&sysKernelTime, &sysUserTime);
|
||||
- } else {
|
||||
- ULONGLONG endProcTime = GetTotalTime(&kernelTime, &userTime);
|
||||
- ULONGLONG endSysTime = GetTotalTime(&sysKernelTime, &sysUserTime);
|
||||
-
|
||||
- cpu_info.cpu = 100.0 * (endProcTime - cpu_info.initialProcRunTime) / (endSysTime - cpu_info.initialSystemTime);
|
||||
- }
|
||||
- } else {
|
||||
- cpu_info.cpu = std::numeric_limits<double>::quiet_NaN();
|
||||
- }
|
||||
-
|
||||
- CloseHandle(hProcess);
|
||||
+/*---------------------------------------------------------------------------------------------
|
||||
+ * Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
+ * Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
+ *--------------------------------------------------------------------------------------------*/
|
||||
+
|
||||
+#include "process.h"
|
||||
+#include "process_commandline.h"
|
||||
+
|
||||
+#include <tlhelp32.h>
|
||||
+#include <psapi.h>
|
||||
+#include <limits>
|
||||
+
|
||||
+uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
|
||||
+ DWORD process_data_flags) {
|
||||
+ // Fetch the PID and PPIDs
|
||||
+ PROCESSENTRY32 process_entry = { 0 };
|
||||
+ DWORD parent_pid = 0;
|
||||
+ uint32_t process_count = 0;
|
||||
+ HANDLE snapshot_handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
+ process_entry.dwSize = sizeof(PROCESSENTRY32);
|
||||
+ if (Process32First(snapshot_handle, &process_entry)) {
|
||||
+ do {
|
||||
+ if (process_entry.th32ProcessID != 0) {
|
||||
+ // Value-initialize: `memory` is otherwise stack garbage when the flag is unset.
|
||||
+ ProcessInfo pinfo{};
|
||||
+ pinfo.pid = process_entry.th32ProcessID;
|
||||
+ pinfo.ppid = process_entry.th32ParentProcessID;
|
||||
+
|
||||
+ if (MEMORY & process_data_flags) {
|
||||
+ GetProcessMemoryUsage(pinfo);
|
||||
pinfo.pid = process_entry.th32ProcessID;
|
||||
pinfo.ppid = process_entry.th32ParentProcessID;
|
||||
|
||||
@@ -33,23 +34,51 @@ uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
|
||||
GetProcessCommandLine(pinfo);
|
||||
}
|
||||
|
||||
+ if (CREATIONTIME & process_data_flags) {
|
||||
+ GetProcessCreationTime(pinfo);
|
||||
+ }
|
||||
+
|
||||
+ if (COMMANDLINE & process_data_flags) {
|
||||
+ GetProcessCommandLine(pinfo);
|
||||
+ }
|
||||
+
|
||||
+ strcpy(pinfo.name, process_entry.szExeFile);
|
||||
+ process_info.push_back(std::move(pinfo));
|
||||
+ process_count++;
|
||||
+ }
|
||||
strcpy(pinfo.name, process_entry.szExeFile);
|
||||
process_info.push_back(std::move(pinfo));
|
||||
process_count++;
|
||||
}
|
||||
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
|
||||
+ } while (Process32Next(snapshot_handle, &process_entry));
|
||||
+ }
|
||||
+
|
||||
+ CloseHandle(snapshot_handle);
|
||||
+ return process_count;
|
||||
+}
|
||||
+
|
||||
+void GetProcessMemoryUsage(ProcessInfo& process_info) {
|
||||
+ DWORD pid = process_info.pid;
|
||||
+ HANDLE hProcess;
|
||||
+ PROCESS_MEMORY_COUNTERS pmc;
|
||||
+
|
||||
+ // PROCESS_VM_READ is never used here -- GetProcessMemoryInfo reads counters the
|
||||
+ // kernel keeps, not the address space -- and acquiring it is what EDR scores.
|
||||
+ hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
||||
+
|
||||
+ if (hProcess == NULL) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ if (GetProcessMemoryInfo(hProcess, &pmc, sizeof(pmc))) {
|
||||
+ process_info.memory = (DWORD)pmc.WorkingSetSize;
|
||||
+ }
|
||||
+
|
||||
+ CloseHandle(hProcess);
|
||||
+}
|
||||
+
|
||||
+// Per documentation, it is not recommended to add or subtract values from the FILETIME
|
||||
+// structure, or to cast it to ULARGE_INTEGER as this can cause alignment faults on 64-bit Windows.
|
||||
+// Copy the high and low part to a ULARGE_INTEGER and peform arithmetic on that instead.
|
||||
+// See https://msdn.microsoft.com/en-us/library/windows/desktop/ms724284(v=vs.85).aspx
|
||||
+ULONGLONG GetTotalTime(const FILETIME* kernelTime, const FILETIME* userTime) {
|
||||
+ ULARGE_INTEGER kt, ut;
|
||||
+ kt.LowPart = (*kernelTime).dwLowDateTime;
|
||||
+ kt.HighPart = (*kernelTime).dwHighDateTime;
|
||||
+
|
||||
+ ut.LowPart = (*userTime).dwLowDateTime;
|
||||
+ ut.HighPart = (*userTime).dwHighDateTime;
|
||||
+
|
||||
+ return kt.QuadPart + ut.QuadPart;
|
||||
+}
|
||||
+
|
||||
+void GetCpuUsage(Cpu& cpu_info, bool first_pass) {
|
||||
+ DWORD pid = cpu_info.pid;
|
||||
+ HANDLE hProcess;
|
||||
+
|
||||
+ // GetProcessTimes needs no more than PROCESS_QUERY_LIMITED_INFORMATION.
|
||||
+ hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
||||
+
|
||||
}
|
||||
|
||||
CloseHandle(snapshot_handle);
|
||||
return process_count;
|
||||
}
|
||||
|
||||
+void GetProcessCreationTime(ProcessInfo& process_info) {
|
||||
+ HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_info.pid);
|
||||
+ if (hProcess == NULL) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ FILETIME creationTime, exitTime, kernelTime, userTime;
|
||||
+ FILETIME sysIdleTime, sysKernelTime, sysUserTime;
|
||||
+ if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime)
|
||||
+ && GetSystemTimes(&sysIdleTime, &sysKernelTime, &sysUserTime)) {
|
||||
+ if (first_pass) {
|
||||
+ cpu_info.initialProcRunTime = GetTotalTime(&kernelTime, &userTime);
|
||||
+ cpu_info.initialSystemTime = GetTotalTime(&sysKernelTime, &sysUserTime);
|
||||
+ } else {
|
||||
+ ULONGLONG endProcTime = GetTotalTime(&kernelTime, &userTime);
|
||||
+ ULONGLONG endSysTime = GetTotalTime(&sysKernelTime, &sysUserTime);
|
||||
+
|
||||
+ cpu_info.cpu = 100.0 * (endProcTime - cpu_info.initialProcRunTime) / (endSysTime - cpu_info.initialSystemTime);
|
||||
+ if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime)) {
|
||||
+ ULARGE_INTEGER timestamp;
|
||||
+ timestamp.LowPart = creationTime.dwLowDateTime;
|
||||
+ timestamp.HighPart = creationTime.dwHighDateTime;
|
||||
+ constexpr ULONGLONG WINDOWS_EPOCH_OFFSET_100NS = 116444736000000000ULL;
|
||||
+ constexpr ULONGLONG HUNDRED_NS_PER_MILLISECOND = 10000ULL;
|
||||
+ if (timestamp.QuadPart >= WINDOWS_EPOCH_OFFSET_100NS) {
|
||||
+ process_info.creationTimeMs =
|
||||
+ (timestamp.QuadPart - WINDOWS_EPOCH_OFFSET_100NS) / HUNDRED_NS_PER_MILLISECOND;
|
||||
+ }
|
||||
+ } else {
|
||||
+ cpu_info.cpu = std::numeric_limits<double>::quiet_NaN();
|
||||
+ }
|
||||
+
|
||||
+ CloseHandle(hProcess);
|
||||
}
|
||||
\ No newline at end of file
|
||||
+}
|
||||
+
|
||||
void GetProcessMemoryUsage(ProcessInfo& process_info) {
|
||||
DWORD pid = process_info.pid;
|
||||
HANDLE hProcess;
|
||||
PROCESS_MEMORY_COUNTERS pmc;
|
||||
|
||||
- hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid);
|
||||
+ // PROCESS_VM_READ is never used here -- GetProcessMemoryInfo reads counters the
|
||||
+ // kernel keeps, not the address space -- and acquiring it is what EDR scores.
|
||||
+ hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
||||
|
||||
if (hProcess == NULL) {
|
||||
return;
|
||||
@@ -81,7 +110,8 @@ void GetCpuUsage(Cpu& cpu_info, bool first_pass) {
|
||||
DWORD pid = cpu_info.pid;
|
||||
HANDLE hProcess;
|
||||
|
||||
- hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid);
|
||||
+ // GetProcessTimes needs no more than PROCESS_QUERY_LIMITED_INFORMATION.
|
||||
+ hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
||||
|
||||
if (hProcess == NULL) {
|
||||
return;
|
||||
diff --git a/src/process.h b/src/process.h
|
||||
index 82f8e4bcfa742551e5d874a7632736a7611d7aa7..78d1d2c3b2360ed06fd624b4cb2f5042510f7a77 100644
|
||||
--- a/src/process.h
|
||||
+++ b/src/process.h
|
||||
@@ -22,18 +22,22 @@ struct ProcessInfo {
|
||||
DWORD ppid;
|
||||
DWORD memory; // Reported in bytes
|
||||
std::string commandLine;
|
||||
+ ULONGLONG creationTimeMs;
|
||||
};
|
||||
|
||||
enum ProcessDataFlags {
|
||||
NONE = 0,
|
||||
MEMORY = 1,
|
||||
- COMMANDLINE = 2
|
||||
+ COMMANDLINE = 2,
|
||||
+ CREATIONTIME = 4
|
||||
};
|
||||
|
||||
uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info, DWORD flags);
|
||||
|
||||
void GetProcessMemoryUsage(ProcessInfo& process_info);
|
||||
|
||||
+void GetProcessCreationTime(ProcessInfo& process_info);
|
||||
+
|
||||
void GetCpuUsage(Cpu& cpu_info, bool first_run);
|
||||
|
||||
#endif // SRC_PROCESS_H_
|
||||
diff --git a/src/process_commandline.cc b/src/process_commandline.cc
|
||||
index ea822b120e8038a4803e34647042f08f4aaf5ca1..25907c0bf542bed6c72b1b462b19bcf3210c3cfd 100644
|
||||
--- a/src/process_commandline.cc
|
||||
+++ b/src/process_commandline.cc
|
||||
@@ -1,67 +1,125 @@
|
||||
-/*---------------------------------------------------------------------------------------------
|
||||
- * Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
- * Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
- *--------------------------------------------------------------------------------------------*/
|
||||
-
|
||||
-#include "process.h"
|
||||
-#include "process_commandline.h"
|
||||
-#include <windows.h>
|
||||
-#include <winternl.h>
|
||||
@@ -7,61 +7,119 @@
|
||||
#include "process_commandline.h"
|
||||
#include <windows.h>
|
||||
#include <winternl.h>
|
||||
-#include <iostream>
|
||||
-
|
||||
+#include <vector>
|
||||
|
||||
-bool GetProcessCommandLine(ProcessInfo& process_info) {
|
||||
- HINSTANCE ntdll = GetModuleHandleW(L"ntdll.dll");
|
||||
- if (!ntdll) {
|
||||
- return false;
|
||||
- }
|
||||
-
|
||||
- decltype(NtQueryInformationProcess)* nt_query_information_process =
|
||||
- reinterpret_cast<decltype(NtQueryInformationProcess)*>(
|
||||
- GetProcAddress(ntdll, "NtQueryInformationProcess"));
|
||||
-
|
||||
- if (!nt_query_information_process) {
|
||||
- return false;
|
||||
- }
|
||||
-
|
||||
- PROCESS_BASIC_INFORMATION pbi{};
|
||||
- PEB peb = {NULL};
|
||||
- RTL_USER_PROCESS_PARAMETERS process_parameters = {NULL};
|
||||
-
|
||||
- // Get process handle
|
||||
- DWORD pid = process_info.pid;
|
||||
- HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid);
|
||||
- if (hProcess == INVALID_HANDLE_VALUE) {
|
||||
- return false;
|
||||
- }
|
||||
-
|
||||
- // Get Process Environment Block (PEB)
|
||||
- NTSTATUS status = nt_query_information_process(hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), nullptr);
|
||||
- if (NT_SUCCESS(status) && pbi.PebBaseAddress) {
|
||||
- // Read PEB
|
||||
- if (ReadProcessMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), nullptr)) {
|
||||
- // Read the processs parameters
|
||||
- if (ReadProcessMemory(hProcess, peb.ProcessParameters, &process_parameters, sizeof(RTL_USER_PROCESS_PARAMETERS), nullptr)) {
|
||||
- if (process_parameters.CommandLine.Length > 0) {
|
||||
- std::wstring buffer;
|
||||
- buffer.resize(process_parameters.CommandLine.Length / sizeof(wchar_t));
|
||||
- if (ReadProcessMemory(hProcess, process_parameters.CommandLine.Buffer, &buffer[0], process_parameters.CommandLine.Length, nullptr)) {
|
||||
- int wide_length = static_cast<int>(buffer.length());
|
||||
- int charcount = WideCharToMultiByte(CP_UTF8, 0, buffer.data(), wide_length,
|
||||
- NULL, 0, NULL, NULL);
|
||||
- if (charcount) {
|
||||
- process_info.commandLine.resize(static_cast<size_t>(charcount));
|
||||
- WideCharToMultiByte(CP_UTF8, 0, buffer.data(), wide_length,
|
||||
- &process_info.commandLine[0], charcount,
|
||||
- NULL, NULL);
|
||||
- }
|
||||
- CloseHandle(hProcess);
|
||||
- return true;
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
-
|
||||
- CloseHandle(hProcess);
|
||||
- return false;
|
||||
-}
|
||||
+/*---------------------------------------------------------------------------------------------
|
||||
+ * Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
+ * Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
+ *--------------------------------------------------------------------------------------------*/
|
||||
+
|
||||
+#include "process.h"
|
||||
+#include "process_commandline.h"
|
||||
+#include <windows.h>
|
||||
+#include <winternl.h>
|
||||
+#include <vector>
|
||||
+
|
||||
+namespace {
|
||||
+
|
||||
+// Windows 8.1 and later hand back a process's command line as a UNICODE_STRING
|
||||
@@ -366,7 +260,7 @@ index ea822b120e8038a4803e34647042f08f4aaf5ca1..25907c0bf542bed6c72b1b462b19bcf3
|
||||
+// ntdll ships no import library for this entry point; it has to be resolved.
|
||||
+NtQueryInformationProcessFn ResolveNtQueryInformationProcess() {
|
||||
+ HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
|
||||
+ if (!ntdll) {
|
||||
if (!ntdll) {
|
||||
+ return nullptr;
|
||||
+ }
|
||||
+ return reinterpret_cast<NtQueryInformationProcessFn>(
|
||||
@@ -385,8 +279,8 @@ index ea822b120e8038a4803e34647042f08f4aaf5ca1..25907c0bf542bed6c72b1b462b19bcf3
|
||||
+ int length = static_cast<int>(wide_length);
|
||||
+ int charcount = WideCharToMultiByte(CP_UTF8, 0, data, length, NULL, 0, NULL, NULL);
|
||||
+ if (!charcount) {
|
||||
+ return false;
|
||||
+ }
|
||||
return false;
|
||||
}
|
||||
+ process_info.commandLine.resize(static_cast<size_t>(charcount));
|
||||
+ WideCharToMultiByte(CP_UTF8, 0, data, length, &process_info.commandLine[0], charcount, NULL,
|
||||
+ NULL);
|
||||
@@ -394,18 +288,25 @@ index ea822b120e8038a4803e34647042f08f4aaf5ca1..25907c0bf542bed6c72b1b462b19bcf3
|
||||
+}
|
||||
+
|
||||
+} // namespace
|
||||
+
|
||||
|
||||
- decltype(NtQueryInformationProcess)* nt_query_information_process =
|
||||
- reinterpret_cast<decltype(NtQueryInformationProcess)*>(
|
||||
- GetProcAddress(ntdll, "NtQueryInformationProcess"));
|
||||
+bool GetProcessCommandLine(ProcessInfo& process_info) {
|
||||
+ NtQueryInformationProcessFn query = NtQueryInformationProcessEntry();
|
||||
+ if (!query) {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
|
||||
- if (!nt_query_information_process) {
|
||||
+ HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, process_info.pid);
|
||||
+ if (process == NULL) {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
return false;
|
||||
}
|
||||
|
||||
- PROCESS_BASIC_INFORMATION pbi{};
|
||||
- PEB peb = {NULL};
|
||||
- RTL_USER_PROCESS_PARAMETERS process_parameters = {NULL};
|
||||
+ ULONG size = 0;
|
||||
+ NTSTATUS status = query(process, kProcessCommandLineInformation, nullptr, 0, &size);
|
||||
+ if (NT_SUCCESS(status)) {
|
||||
@@ -421,14 +322,44 @@ index ea822b120e8038a4803e34647042f08f4aaf5ca1..25907c0bf542bed6c72b1b462b19bcf3
|
||||
+ CloseHandle(process);
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
|
||||
- // Get process handle
|
||||
- DWORD pid = process_info.pid;
|
||||
- HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid);
|
||||
- if (hProcess == INVALID_HANDLE_VALUE) {
|
||||
+ std::vector<unsigned char> buffer(size);
|
||||
+ status = query(process, kProcessCommandLineInformation, &buffer[0], size, &size);
|
||||
+ CloseHandle(process);
|
||||
+ if (!NT_SUCCESS(status)) {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
return false;
|
||||
}
|
||||
|
||||
- // Get Process Environment Block (PEB)
|
||||
- NTSTATUS status = nt_query_information_process(hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), nullptr);
|
||||
- if (NT_SUCCESS(status) && pbi.PebBaseAddress) {
|
||||
- // Read PEB
|
||||
- if (ReadProcessMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), nullptr)) {
|
||||
- // Read the processs parameters
|
||||
- if (ReadProcessMemory(hProcess, peb.ProcessParameters, &process_parameters, sizeof(RTL_USER_PROCESS_PARAMETERS), nullptr)) {
|
||||
- if (process_parameters.CommandLine.Length > 0) {
|
||||
- std::wstring buffer;
|
||||
- buffer.resize(process_parameters.CommandLine.Length / sizeof(wchar_t));
|
||||
- if (ReadProcessMemory(hProcess, process_parameters.CommandLine.Buffer, &buffer[0], process_parameters.CommandLine.Length, nullptr)) {
|
||||
- int wide_length = static_cast<int>(buffer.length());
|
||||
- int charcount = WideCharToMultiByte(CP_UTF8, 0, buffer.data(), wide_length,
|
||||
- NULL, 0, NULL, NULL);
|
||||
- if (charcount) {
|
||||
- process_info.commandLine.resize(static_cast<size_t>(charcount));
|
||||
- WideCharToMultiByte(CP_UTF8, 0, buffer.data(), wide_length,
|
||||
- &process_info.commandLine[0], charcount,
|
||||
- NULL, NULL);
|
||||
- }
|
||||
- CloseHandle(hProcess);
|
||||
- return true;
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
+ // Header and characters arrive in one allocation, but treat the header as
|
||||
+ // untrusted: a hooked ntdll is the case this reader is written for, and an
|
||||
+ // unchecked Buffer/Length here would be an over-read encoded straight into JS.
|
||||
@@ -440,11 +371,70 @@ index ea822b120e8038a4803e34647042f08f4aaf5ca1..25907c0bf542bed6c72b1b462b19bcf3
|
||||
+ if (chars == nullptr || chars < begin + sizeof(UNICODE_STRING) || chars > end ||
|
||||
+ command_line->Length > static_cast<ULONG>(end - chars)) {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
}
|
||||
|
||||
- CloseHandle(hProcess);
|
||||
- return false;
|
||||
+ // True only when a command line was actually stored, so "empty" and "not
|
||||
+ // recovered" stay the same answer they were before this reader replaced the
|
||||
+ // PEB read. `src/process.cc` discards the result either way.
|
||||
+ return StoreCommandLineUtf8(process_info, command_line->Buffer,
|
||||
+ command_line->Length / sizeof(wchar_t));
|
||||
+}
|
||||
}
|
||||
diff --git a/src/process_worker.cc b/src/process_worker.cc
|
||||
index c9e3457a759c1acaa2644231a4917d45aed951f8..3f26a354477f062b34bd31fbd17be529e6a2fd7a 100644
|
||||
--- a/src/process_worker.cc
|
||||
+++ b/src/process_worker.cc
|
||||
@@ -43,6 +43,11 @@ void GetProcessesWorker::OnOK() {
|
||||
Napi::String::New(env, pinfo.commandLine));
|
||||
}
|
||||
|
||||
+ if ((CREATIONTIME & process_data_flags_) && pinfo.creationTimeMs != 0) {
|
||||
+ object.Set("creationTimeMs",
|
||||
+ Napi::Number::New(env, static_cast<double>(pinfo.creationTimeMs)));
|
||||
+ }
|
||||
+
|
||||
result.Set(i, object);
|
||||
}
|
||||
|
||||
diff --git a/typings/windows-process-tree.d.ts b/typings/windows-process-tree.d.ts
|
||||
index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e3791ec1b629 100644
|
||||
--- a/typings/windows-process-tree.d.ts
|
||||
+++ b/typings/windows-process-tree.d.ts
|
||||
@@ -7,9 +7,17 @@ declare module '@vscode/windows-process-tree' {
|
||||
export enum ProcessDataFlag {
|
||||
None = 0,
|
||||
Memory = 1,
|
||||
- CommandLine = 2
|
||||
+ CommandLine = 2,
|
||||
+ CreationTime = 4
|
||||
}
|
||||
|
||||
+ /**
|
||||
+ * The flag bits the compiled addon actually understands, or undefined off
|
||||
+ * win32. `ProcessDataFlag` above is source; this is what the binary reports,
|
||||
+ * so it is the only way to tell a patched build from a stale prebuilt.
|
||||
+ */
|
||||
+ export const supportedProcessDataFlags: number | undefined;
|
||||
+
|
||||
export interface IProcessInfo {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
@@ -24,6 +32,9 @@ declare module '@vscode/windows-process-tree' {
|
||||
* The string returned is at most 512 chars, strings exceeding this length are truncated.
|
||||
*/
|
||||
commandLine?: string;
|
||||
+
|
||||
+ /** Process creation time in Unix milliseconds. */
|
||||
+ creationTimeMs?: number;
|
||||
}
|
||||
|
||||
export interface IProcessCpuInfo extends IProcessInfo {
|
||||
@@ -35,6 +46,7 @@ declare module '@vscode/windows-process-tree' {
|
||||
name: string;
|
||||
memory?: number;
|
||||
commandLine?: string;
|
||||
+ creationTimeMs?: number;
|
||||
children: IProcessTreeNode[];
|
||||
}
|
||||
|
||||
|
||||
+518
-96
File diff suppressed because one or more lines are too long
@@ -12,7 +12,8 @@ function lintSource(source) {
|
||||
rules: {
|
||||
'app-store-performance/require-selector': 'warn',
|
||||
'app-store-performance/no-identity-selector': 'warn',
|
||||
'app-store-performance/no-fresh-selector-result': 'warn'
|
||||
'app-store-performance/no-fresh-selector-result': 'warn',
|
||||
'app-store-performance/no-nested-fresh-under-shallow': 'warn'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -52,4 +53,92 @@ describe('app store performance Oxlint plugin', () => {
|
||||
|
||||
expect(diagnostics).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves selectors referenced by name, including ones hoisted below the call', () => {
|
||||
const diagnostics = lintSource(`
|
||||
import { useAppStore } from '@/store'
|
||||
const EarlyFresh = () => useAppStore(selectFreshRows)
|
||||
const selectFreshRows = (state) => state.rows.filter(Boolean)
|
||||
const Stable = () => useAppStore(selectActiveId)
|
||||
const selectActiveId = (state) => state.activeId
|
||||
`)
|
||||
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
|
||||
'app-store-performance(no-fresh-selector-result)'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let a component-local helper resolve a same-named imported selector', () => {
|
||||
const diagnostics = lintSource(`
|
||||
import { useAppStore } from '@/store'
|
||||
import { selectRows } from './selectors'
|
||||
const Other = () => {
|
||||
const selectRows = (state) => state.rows.map((row) => row.id)
|
||||
return selectRows
|
||||
}
|
||||
const Imported = () => useAppStore(selectRows)
|
||||
`)
|
||||
|
||||
expect(diagnostics).toEqual([])
|
||||
})
|
||||
|
||||
it('covers sibling store hooks but not useSyncExternalStore', () => {
|
||||
const diagnostics = lintSource(`
|
||||
import { usePluginPanelsStore } from '@/store/plugin-panels'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
const WholePanels = () => usePluginPanelsStore()
|
||||
const FreshPanels = () => usePluginPanelsStore((state) => ({ open: state.open }))
|
||||
const External = () => useSyncExternalStore(subscribe, () => ({ open: true }))
|
||||
`)
|
||||
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
|
||||
'app-store-performance(require-selector)',
|
||||
'app-store-performance(no-fresh-selector-result)'
|
||||
])
|
||||
})
|
||||
|
||||
it('reports fresh references nested inside a useShallow projection', () => {
|
||||
const diagnostics = lintSource(`
|
||||
import { useAppStore } from '@/store'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
const NestedObject = () => useAppStore(useShallow((state) => ({ ids: state.rows.map((row) => row.id) })))
|
||||
const NestedArray = () => useAppStore(useShallow((state) => [state.activeId, state.rows.filter(Boolean)]))
|
||||
const Flat = () => useAppStore(useShallow((state) => ({ activeId: state.activeId, rows: state.rows })))
|
||||
`)
|
||||
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
|
||||
'app-store-performance(no-nested-fresh-under-shallow)',
|
||||
'app-store-performance(no-nested-fresh-under-shallow)'
|
||||
])
|
||||
})
|
||||
|
||||
it('follows a selector one hop into a module-scope helper', () => {
|
||||
const diagnostics = lintSource(`
|
||||
import { useAppStore } from '@/store'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
const buildRows = (state) => state.rows.map((row) => row.id)
|
||||
const Delegating = () => useAppStore((state) => buildRows(state))
|
||||
const NestedDelegating = () => useAppStore(useShallow((state) => ({ ids: buildRows(state) })))
|
||||
`)
|
||||
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
|
||||
'app-store-performance(no-fresh-selector-result)',
|
||||
'app-store-performance(no-nested-fresh-under-shallow)'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not flag a helper that returns a cached reference on some branch', () => {
|
||||
const diagnostics = lintSource(`
|
||||
import { useAppStore } from '@/store'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
// The identity-caching shape: fresh only on a miss, cached otherwise.
|
||||
const selectCachedRows = (state) => cache.get(state.key) ?? state.rows.filter(Boolean)
|
||||
const Cached = () => useAppStore((state) => selectCachedRows(state))
|
||||
const CachedNested = () => useAppStore(useShallow((state) => ({ rows: selectCachedRows(state) })))
|
||||
// An unknown helper cannot be resolved, so it must not be guessed at.
|
||||
const External = () => useAppStore((state) => externalBuild(state))
|
||||
`)
|
||||
|
||||
expect(diagnostics).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -98,6 +98,210 @@ function assertPatchApplied() {
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
// Every string the repair below can write, so a repaired tree cannot be
|
||||
// declared patched while one of the pieces is silently missing.
|
||||
const requiredCreationTimeSources = [
|
||||
['src/process.h', 'CREATIONTIME = 4'],
|
||||
['src/process.h', 'ULONGLONG creationTimeMs'],
|
||||
['src/process.cc', 'GetProcessCreationTime(pinfo)'],
|
||||
['src/process.cc', 'GetProcessTimes(hProcess, &creationTime'],
|
||||
['src/process_worker.cc', 'object.Set("creationTimeMs"'],
|
||||
['src/addon.cc', 'exports.Set("supportedProcessDataFlags"'],
|
||||
['lib/index.js', '["CreationTime"] = 4'],
|
||||
['lib/index.js', 'exports.supportedProcessDataFlags'],
|
||||
['lib/index.js', 'creationTimeMs,'],
|
||||
['lib/index.ts', 'CreationTime = 4'],
|
||||
['lib/index.ts', 'export const supportedProcessDataFlags'],
|
||||
['lib/index.ts', 'creationTimeMs,'],
|
||||
['typings/windows-process-tree.d.ts', 'creationTimeMs?: number'],
|
||||
// A regex because IProcessInfo declares the same field: only the tree node
|
||||
// is followed by `children`, and that is the one buildNode fills.
|
||||
['typings/windows-process-tree.d.ts', /creationTimeMs\?: number;\r?\n\s*children:/],
|
||||
['typings/windows-process-tree.d.ts', 'export const supportedProcessDataFlags']
|
||||
]
|
||||
for (const [relativePath, expected] of requiredCreationTimeSources) {
|
||||
const source = readFileSync(join(PACKAGE_DIR, relativePath), 'utf8')
|
||||
const present = typeof expected === 'string' ? source.includes(expected) : expected.test(source)
|
||||
if (!present) {
|
||||
throw new Error(
|
||||
`${relativePath} does not contain the process creation-time patch (${expected}). ` +
|
||||
'Run pnpm install before building the relay addon.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function repairCreationTimeSources() {
|
||||
let repaired = false
|
||||
const rewrite = (relativePath, transform) => {
|
||||
const filePath = join(PACKAGE_DIR, relativePath)
|
||||
const source = readFileSync(filePath, 'utf8')
|
||||
const next = transform(source, source.includes('\r\n') ? '\r\n' : '\n')
|
||||
if (next !== source) {
|
||||
writeFileSync(filePath, next)
|
||||
repaired = true
|
||||
}
|
||||
}
|
||||
|
||||
rewrite('src/process.h', (source, eol) => {
|
||||
let next = source
|
||||
if (!next.includes('ULONGLONG creationTimeMs')) {
|
||||
next = next.replace(
|
||||
/ std::string commandLine;\r?\n/,
|
||||
` std::string commandLine;${eol} ULONGLONG creationTimeMs;${eol}`
|
||||
)
|
||||
}
|
||||
if (!next.includes('CREATIONTIME = 4')) {
|
||||
next = next.replace(
|
||||
/ COMMANDLINE = 2\r?\n/,
|
||||
` COMMANDLINE = 2,${eol} CREATIONTIME = 4${eol}`
|
||||
)
|
||||
}
|
||||
if (!next.includes('void GetProcessCreationTime')) {
|
||||
next = next.replace(
|
||||
/void GetProcessMemoryUsage\(ProcessInfo& process_info\);\r?\n/,
|
||||
`void GetProcessMemoryUsage(ProcessInfo& process_info);${eol}${eol}` +
|
||||
`void GetProcessCreationTime(ProcessInfo& process_info);${eol}`
|
||||
)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
rewrite('src/process.cc', (source, eol) => {
|
||||
let next = source.replace('ProcessInfo pinfo;', 'ProcessInfo pinfo{};')
|
||||
if (!next.includes('GetProcessCreationTime(pinfo)')) {
|
||||
next = next.replace(
|
||||
/( if \(COMMANDLINE & process_data_flags\) \{\r?\n GetProcessCommandLine\(pinfo\);\r?\n \})/,
|
||||
`$1${eol}${eol} if (CREATIONTIME & process_data_flags) {${eol}` +
|
||||
` GetProcessCreationTime(pinfo);${eol} }`
|
||||
)
|
||||
}
|
||||
if (!next.includes('void GetProcessCreationTime(ProcessInfo& process_info) {')) {
|
||||
const producer = [
|
||||
'void GetProcessCreationTime(ProcessInfo& process_info) {',
|
||||
' HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_info.pid);',
|
||||
' if (hProcess == NULL) {',
|
||||
' return;',
|
||||
' }',
|
||||
'',
|
||||
' FILETIME creationTime, exitTime, kernelTime, userTime;',
|
||||
' if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime)) {',
|
||||
' ULARGE_INTEGER timestamp;',
|
||||
' timestamp.LowPart = creationTime.dwLowDateTime;',
|
||||
' timestamp.HighPart = creationTime.dwHighDateTime;',
|
||||
' constexpr ULONGLONG WINDOWS_EPOCH_OFFSET_100NS = 116444736000000000ULL;',
|
||||
' constexpr ULONGLONG HUNDRED_NS_PER_MILLISECOND = 10000ULL;',
|
||||
' if (timestamp.QuadPart >= WINDOWS_EPOCH_OFFSET_100NS) {',
|
||||
' process_info.creationTimeMs =',
|
||||
' (timestamp.QuadPart - WINDOWS_EPOCH_OFFSET_100NS) / HUNDRED_NS_PER_MILLISECOND;',
|
||||
' }',
|
||||
' }',
|
||||
'',
|
||||
' CloseHandle(hProcess);',
|
||||
'}',
|
||||
''
|
||||
].join(eol)
|
||||
next = next.replace(
|
||||
'void GetProcessMemoryUsage',
|
||||
`${producer}${eol}void GetProcessMemoryUsage`
|
||||
)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
rewrite('src/process_worker.cc', (source, eol) => {
|
||||
if (source.includes('object.Set("creationTimeMs"')) {
|
||||
return source
|
||||
}
|
||||
const emission = [
|
||||
' if ((CREATIONTIME & process_data_flags_) && pinfo.creationTimeMs != 0) {',
|
||||
' object.Set("creationTimeMs",',
|
||||
' Napi::Number::New(env, static_cast<double>(pinfo.creationTimeMs)));',
|
||||
' }',
|
||||
''
|
||||
].join(eol)
|
||||
return source.replace(
|
||||
' result.Set(i, object);',
|
||||
`${emission}${eol} result.Set(i, object);`
|
||||
)
|
||||
})
|
||||
|
||||
rewrite('src/addon.cc', (source, eol) => {
|
||||
if (source.includes('exports.Set("supportedProcessDataFlags"')) {
|
||||
return source
|
||||
}
|
||||
return source.replace(
|
||||
/( exports\.Set\("getProcessCpuUsage", Napi::Function::New\(env, GetProcessCpuUsage\)\);\r?\n)/,
|
||||
`$1 exports.Set("supportedProcessDataFlags",${eol}` +
|
||||
` Napi::Number::New(env, MEMORY | COMMANDLINE | CREATIONTIME));${eol}`
|
||||
)
|
||||
})
|
||||
|
||||
// Each piece is guarded on its own: an early-out on the enum alone would let a
|
||||
// tree with the enum but no buildNode splat pass as repaired.
|
||||
const NATIVE_CONST =
|
||||
"const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined;"
|
||||
for (const relativePath of ['lib/index.ts', 'lib/index.js']) {
|
||||
const isTs = relativePath.endsWith('.ts')
|
||||
rewrite(relativePath, (source, eol) => {
|
||||
let next = source
|
||||
if (!next.includes('CreationTime')) {
|
||||
next = isTs
|
||||
? next.replace(' CommandLine = 2', ` CommandLine = 2,${eol} CreationTime = 4`)
|
||||
: next.replace(
|
||||
' ProcessDataFlag[ProcessDataFlag["CommandLine"] = 2] = "CommandLine";',
|
||||
' ProcessDataFlag[ProcessDataFlag["CommandLine"] = 2] = "CommandLine";' +
|
||||
`${eol} ProcessDataFlag[ProcessDataFlag["CreationTime"] = 4] = "CreationTime";`
|
||||
)
|
||||
}
|
||||
if (!next.includes('supportedProcessDataFlags')) {
|
||||
const reExport = isTs
|
||||
? `/** The flag bits this compiled addon reports; undefined off win32. */${eol}` +
|
||||
'export const supportedProcessDataFlags: number | undefined = native?.supportedProcessDataFlags;'
|
||||
: 'exports.supportedProcessDataFlags = native === undefined ? undefined : native.supportedProcessDataFlags;'
|
||||
next = next.replace(NATIVE_CONST, `${NATIVE_CONST}${eol}${reExport}`)
|
||||
}
|
||||
// buildNode drops any field it does not name, so the destructure and the
|
||||
// splat have to move together.
|
||||
next = next.replace(/(memory, commandLine)( \}, children \})/, '$1, creationTimeMs$2')
|
||||
if (!/\bcreationTimeMs,/.test(next)) {
|
||||
next = next.replace(
|
||||
/(\r?\n)(\s*)commandLine,(\r?\n\s*children:)/,
|
||||
`$1$2commandLine,$1$2creationTimeMs,$3`
|
||||
)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
rewrite('typings/windows-process-tree.d.ts', (source, eol) => {
|
||||
let next = source
|
||||
if (!next.includes('CreationTime = 4')) {
|
||||
next = next.replace(' CommandLine = 2', ` CommandLine = 2,${eol} CreationTime = 4`)
|
||||
}
|
||||
if (!next.includes('supportedProcessDataFlags')) {
|
||||
next = next.replace(
|
||||
/( CreationTime = 4\r?\n \}\r?\n)/,
|
||||
`$1${eol} /** The flag bits the compiled addon reports; undefined off win32. */${eol}` +
|
||||
` export const supportedProcessDataFlags: number | undefined;${eol}`
|
||||
)
|
||||
}
|
||||
if (!next.includes('creationTimeMs?: number')) {
|
||||
next = next.replace(
|
||||
/ commandLine\?: string;\r?\n/,
|
||||
` commandLine?: string;${eol}${eol}` +
|
||||
` /** Process creation time in Unix milliseconds. */${eol}` +
|
||||
` creationTimeMs?: number;${eol}`
|
||||
)
|
||||
}
|
||||
// IProcessTreeNode is the second declaration; only it is followed by children.
|
||||
next = next.replace(
|
||||
/( commandLine\?: string;\r?\n)( children:)/,
|
||||
`$1 creationTimeMs?: number;${eol}$2`
|
||||
)
|
||||
return next
|
||||
})
|
||||
return repaired
|
||||
}
|
||||
|
||||
// pnpm can materialize this CRLF package without applying its patch. Repair the
|
||||
@@ -146,9 +350,15 @@ function applyWindowsProcessTreeBuildFixes() {
|
||||
if (processCc !== originalProcess) {
|
||||
writeFileSync(processPath, processCc)
|
||||
}
|
||||
const repairedCreationTime = repairCreationTimeSources()
|
||||
stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR)
|
||||
const repairedCommandLine = ensureWindowsProcessTreeCommandLinePatch(PACKAGE_DIR)
|
||||
if (bindingGyp !== originalBinding || processCc !== originalProcess || repairedCommandLine) {
|
||||
if (
|
||||
bindingGyp !== originalBinding ||
|
||||
processCc !== originalProcess ||
|
||||
repairedCommandLine ||
|
||||
repairedCreationTime
|
||||
) {
|
||||
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { release } from 'node:os'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
const { assertWindowsProcessTreeCreationTime } = require('./windows-process-tree-creation-time.cjs')
|
||||
const scriptPath = import.meta.filename
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const runtime = readRuntimeArg()
|
||||
@@ -262,9 +263,10 @@ function loadNativeModule(moduleName) {
|
||||
// A bare require loads the .node addon on win32, so it catches an ABI
|
||||
// mismatch on its own. What it cannot catch is *which* addon loaded: the
|
||||
// published tarball ships a prebuilt built from unpatched source that is
|
||||
// node-addon-api, so it requires cleanly and then reads every process's
|
||||
// command line out of its address space. Check the binary, not the load.
|
||||
require(moduleName)
|
||||
// node-addon-api, so it requires cleanly, reads every process's command
|
||||
// line out of its address space, and ignores the CreationTime flag. Check
|
||||
// the binary on both counts, not the load.
|
||||
assertWindowsProcessTreeCreationTime({ module: require(moduleName) })
|
||||
if (inspectWindowsProcessTreeAddon(windowsProcessTreeAddonPath()) === 'unpatched') {
|
||||
throw new Error(
|
||||
'the loaded addon still calls ReadProcessMemory, so it was not built from the patched ' +
|
||||
@@ -380,14 +382,18 @@ function getWindowsBuildNumber() {
|
||||
|
||||
function rebuildNodeRuntimeModules(moduleNames) {
|
||||
for (const moduleName of moduleNames) {
|
||||
const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
|
||||
let moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
|
||||
if (moduleName === '@vscode/windows-process-tree') {
|
||||
// Why before node-gyp: this module is rebuilt precisely because the
|
||||
// binary was the unpatched one, and pnpm materializes it unpatched often
|
||||
// enough that compiling the source as-is would just rebuild the same
|
||||
// reader and fail the verify pass.
|
||||
// reader and fail the verify pass. The patched binding.gyp then includes
|
||||
// deps/node-addon-api, which the tarball does not ship, and node-gyp must
|
||||
// run from the physical dir -- both reasons live in
|
||||
// windows-process-tree-gyp-rebuild.mjs.
|
||||
ensureWindowsProcessTreeCommandLinePatch(moduleDir)
|
||||
stageWindowsProcessTreeNodeAddonApiHeaders(moduleDir)
|
||||
moduleDir = realpathSync(moduleDir)
|
||||
}
|
||||
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
|
||||
runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
|
||||
|
||||
@@ -15,9 +15,12 @@ import { describe, expect, it } from 'vitest'
|
||||
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
|
||||
|
||||
const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url))
|
||||
const sourceNodePtyJobOwnershipPath = fileURLToPath(
|
||||
new URL('./node-pty-job-ownership.cjs', import.meta.url)
|
||||
)
|
||||
// The import walk sees `from './x.mjs'` only, so the createRequire'd CJS
|
||||
// siblings have to be named. Without them the temp project cannot even load.
|
||||
const REQUIRED_CJS_SIBLINGS = [
|
||||
'node-pty-job-ownership.cjs',
|
||||
'windows-process-tree-creation-time.cjs'
|
||||
]
|
||||
|
||||
describe('ensure-native-runtime', () => {
|
||||
it('rechecks Node native modules in fresh child processes after rebuilding', () => {
|
||||
@@ -197,10 +200,12 @@ function mkTempProject() {
|
||||
// Walked, not listed: the script imports windows-process-tree-gyp-rebuild.mjs, and a fixture
|
||||
// missing it fails every case with a module-resolution error instead of the defect under test.
|
||||
copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts'))
|
||||
copyFileSync(
|
||||
sourceNodePtyJobOwnershipPath,
|
||||
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
|
||||
)
|
||||
for (const name of REQUIRED_CJS_SIBLINGS) {
|
||||
copyFileSync(
|
||||
fileURLToPath(new URL(`./${name}`, import.meta.url)),
|
||||
join(projectDir, 'config', 'scripts', name)
|
||||
)
|
||||
}
|
||||
return projectDir
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const workflow = parse(
|
||||
readFileSync(new URL('../../.github/workflows/packaged-browser-e2e.yml', import.meta.url), 'utf8')
|
||||
)
|
||||
const steps = workflow.jobs.compatibility.steps
|
||||
|
||||
describe('packaged browser compatibility lane', () => {
|
||||
it('runs weekly and supports immutable manual or reusable revisions', () => {
|
||||
expect(workflow.on.schedule).toHaveLength(1)
|
||||
for (const trigger of ['workflow_dispatch', 'workflow_call']) {
|
||||
expect(workflow.on[trigger].inputs.ref).toMatchObject({ type: 'string', required: false })
|
||||
}
|
||||
expect(steps[0].with.ref).toBe('${{ inputs.ref || github.sha }}')
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
})
|
||||
|
||||
it('verifies the pinned package before selecting the desktop executable', () => {
|
||||
const download = steps.find((step) => step.name === 'Download pinned old release').run
|
||||
expect(download).toContain('gh release download v1.4.188')
|
||||
expect(download).toContain('hashlib.sha512(package.read_bytes())')
|
||||
expect(download).toContain("extracted/'opt'/'Orca'/'orca-ide'")
|
||||
expect(download).toContain('assert base64.')
|
||||
expect(download).toContain('decode()==expected')
|
||||
expect(download).toContain("['dpkg-deb'")
|
||||
expect(download.indexOf('assert base64.')).toBeLessThan(download.indexOf("['dpkg-deb'"))
|
||||
})
|
||||
|
||||
it('requires both directions three times and rejects silent skips', () => {
|
||||
const run = steps.find((step) => step.name === 'Run both mixed-version directions')
|
||||
expect(run.run).toContain('tests/e2e/packaged-mixed-version-browser-placement.spec.ts')
|
||||
expect(run.run).toContain('--repeat-each=3')
|
||||
expect(run.run).toContain('--retries=0')
|
||||
expect(run.run).toContain('--reporter=list,json')
|
||||
const verify = steps.find((step) => step.name === 'Require all six compatibility executions')
|
||||
expect(verify.if).toBe('always()')
|
||||
expect(verify.run).toBe(
|
||||
`node config/scripts/verify-packaged-browser-participation.mjs ${run.env.PLAYWRIGHT_JSON_OUTPUT_FILE}`
|
||||
)
|
||||
expect(steps.at(-1).if).toBe('always()')
|
||||
expect(steps.at(-1).with.path).toBe('test-results/')
|
||||
})
|
||||
})
|
||||
@@ -140,6 +140,8 @@ const NATIVE_RUNTIME_PREFIXES = [
|
||||
'config/scripts/ensure-native-runtime',
|
||||
'config/scripts/rebuild-native-deps',
|
||||
'config/scripts/node-pty-job-ownership',
|
||||
'config/scripts/windows-process-tree-creation-time',
|
||||
'config/scripts/windows-process-tree-gyp-rebuild',
|
||||
'config/scripts/electron-builder-native-rebuild',
|
||||
'config/patches/node-pty@',
|
||||
'config/patches/@vscode__windows-process-tree'
|
||||
@@ -224,6 +226,7 @@ const WINDOWS_PACKAGE_TESTS = [
|
||||
'src/main/windows/windows-pty-job.win32.test.ts',
|
||||
'src/main/windows/windows-host-job.win32.test.ts',
|
||||
'src/main/windows/windows-process-tree-command-line-patch.test.ts',
|
||||
'src/main/windows/windows-process-table-native-addon.win32.test.ts',
|
||||
'src/main/windows-live-tree-kill.win32.test.ts',
|
||||
'src/main/wsl/wsl-runner.test.ts',
|
||||
'src/main/wsl/wsl-guest-environment.test.ts',
|
||||
|
||||
@@ -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|verify-playwright-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 {
|
||||
|
||||
@@ -567,6 +567,15 @@ function loadNativeModule(moduleName) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (moduleName === '@vscode/windows-process-tree') {
|
||||
// The tarball prebuilt loads under Electron too -- the addon is N-API, so
|
||||
// a bare require proves nothing about which source it was built from.
|
||||
const { assertWindowsProcessTreeCreationTime } = projectRequire(
|
||||
'./config/scripts/windows-process-tree-creation-time.cjs'
|
||||
)
|
||||
assertWindowsProcessTreeCreationTime({ module: projectRequire(moduleName) })
|
||||
return
|
||||
}
|
||||
projectRequire(moduleName)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ function eolAttributes(paths) {
|
||||
function trackedManifestSources() {
|
||||
const paths = new Set()
|
||||
for (const { filename } of RELAY_ARTIFACTS) {
|
||||
const hits = git(['ls-files', '-z', '--', `*/${filename}`]).split('\0').filter(Boolean)
|
||||
const hits = git(['ls-files', '-z', '--', `*/${filename}`])
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
for (const path of hits) {
|
||||
paths.add(path)
|
||||
}
|
||||
|
||||
@@ -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,20 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { verifyPlaywrightParticipation } from './verify-playwright-participation.mjs'
|
||||
|
||||
export const PACKAGED_BROWSER_TEST_TITLES = [
|
||||
'keeps an old packaged client on the current server-hosted path',
|
||||
'keeps a current client on an old packaged server-hosted path'
|
||||
]
|
||||
|
||||
export function verifyPackagedBrowserParticipation(report) {
|
||||
verifyPlaywrightParticipation(report, {
|
||||
titles: PACKAGED_BROWSER_TEST_TITLES,
|
||||
label: 'Packaged browser'
|
||||
})
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
verifyPackagedBrowserParticipation(JSON.parse(readFileSync(process.argv[2], 'utf8')))
|
||||
console.log('Both packaged browser directions passed three times without skips or retries.')
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
verifyPackagedBrowserParticipation,
|
||||
PACKAGED_BROWSER_TEST_TITLES
|
||||
} from './verify-packaged-browser-participation.mjs'
|
||||
|
||||
function report() {
|
||||
return {
|
||||
stats: { expected: 6, skipped: 0, unexpected: 0, flaky: 0 },
|
||||
suites: [
|
||||
{
|
||||
suites: [
|
||||
{
|
||||
specs: PACKAGED_BROWSER_TEST_TITLES.map((title) => ({
|
||||
title,
|
||||
tests: Array.from({ length: 3 }, () => ({
|
||||
expectedStatus: 'passed',
|
||||
results: [{ status: 'passed' }]
|
||||
}))
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
describe('Packaged browser participation', () => {
|
||||
it('accepts both named scenarios executed three times', () => {
|
||||
expect(() => verifyPackagedBrowserParticipation(report())).not.toThrow()
|
||||
})
|
||||
it.each(['skipped', 'unexpected', 'flaky'])('rejects a nonzero %s result', (key) => {
|
||||
const value = report()
|
||||
value.stats[key] = 1
|
||||
expect(() => verifyPackagedBrowserParticipation(value)).toThrow('participation failed')
|
||||
})
|
||||
it('rejects missing scenarios even when aggregate counts claim six passes', () => {
|
||||
const value = report()
|
||||
value.suites[0].suites[0].specs.pop()
|
||||
expect(() => verifyPackagedBrowserParticipation(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(() => verifyPackagedBrowserParticipation(value)).toThrow(
|
||||
'Unexpected Packaged browser 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(() => verifyPackagedBrowserParticipation(value)).toThrow('without retries')
|
||||
})
|
||||
it('rejects missing report content', () => {
|
||||
expect(() => verifyPackagedBrowserParticipation({})).toThrow('participation failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
export function verifyPlaywrightParticipation(report, { titles, label, repetitions = 3 }) {
|
||||
const stats = report?.stats
|
||||
if (
|
||||
!stats ||
|
||||
stats.expected !== titles.length * repetitions ||
|
||||
stats.skipped !== 0 ||
|
||||
stats.unexpected !== 0 ||
|
||||
stats.flaky !== 0 ||
|
||||
report.errors?.length
|
||||
) {
|
||||
throw new Error(`${label} participation failed: ${JSON.stringify(stats)}`)
|
||||
}
|
||||
const counts = new Map(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 ${label} 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(`${label} 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 !== repetitions) {
|
||||
throw new Error(
|
||||
`${label} scenario requires ${repetitions === 3 ? 'three' : repetitions} executions: ${title} (${count})`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { verifyPlaywrightParticipation } from './verify-playwright-participation.mjs'
|
||||
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) {
|
||||
verifyPlaywrightParticipation(report, { titles: WSL_TEST_TITLES, label: 'WSL' })
|
||||
}
|
||||
|
||||
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,42 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* Prove the COMPILED addon understands `CREATIONTIME`, not just the patched JS.
|
||||
*
|
||||
* Unlike node-pty, this package ships a prebuilt `.node` at the same
|
||||
* `build/Release/` path node-gyp writes to, so neither a load nor a path check
|
||||
* can tell a stale prebuilt from a source build. pnpm patches the source tree
|
||||
* and leaves that prebuilt in place, which is how `ProcessDataFlag.CreationTime`
|
||||
* came to exist in `lib/index.js` on a binary that ignores flag 4 -- the gate
|
||||
* read true and every row came back without `creationTimeMs`.
|
||||
*
|
||||
* `supportedProcessDataFlags` is exported by the patched `addon.cc`, so its
|
||||
* presence is the binary's own answer. Shared by the Node and Electron probes
|
||||
* the way `node-pty-job-ownership.cjs` is.
|
||||
*/
|
||||
|
||||
/** `ProcessDataFlags::CREATIONTIME` in src/process.h. */
|
||||
const CREATION_TIME_FLAG = 4
|
||||
|
||||
function assertWindowsProcessTreeCreationTime({ module, platform = process.platform }) {
|
||||
if (platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
const supported = module?.supportedProcessDataFlags
|
||||
if (typeof supported === 'number' && (supported & CREATION_TIME_FLAG) !== 0) {
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
'@vscode/windows-process-tree does not report CreationTime support',
|
||||
`(supportedProcessDataFlags=${String(supported)}).`,
|
||||
'That is the tarball prebuilt, not a build of the patched source, so every',
|
||||
'process row comes back without creationTimeMs: Windows descendant exit',
|
||||
'verification cannot identify a PID and structured Claude/Codex chat runs',
|
||||
'with an unprovable child-tree reaper.',
|
||||
'Rebuild it from source so config/patches/@vscode__windows-process-tree@0.8.0.patch applies.'
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { assertWindowsProcessTreeCreationTime, CREATION_TIME_FLAG }
|
||||
@@ -0,0 +1,70 @@
|
||||
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',
|
||||
'config/scripts/verify-playwright-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",
|
||||
|
||||
@@ -11,6 +11,7 @@ const contracts = [
|
||||
'src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts',
|
||||
'src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts',
|
||||
'src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-queue-retention.test.ts',
|
||||
'src/renderer/src/store/store-identity-churn-probe.test.ts',
|
||||
'config/scripts/app-store-performance-plugin.test.mjs',
|
||||
'config/scripts/quadratic-buffer-concat-plugin.test.mjs',
|
||||
'config/scripts/sort-comparator-performance-plugin.test.mjs'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 41m">
|
||||
<title>downloads: 41m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 42m">
|
||||
<title>downloads: 42m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">41m</text>
|
||||
<text x="90" y="14">41m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">42m</text>
|
||||
<text x="90" y="14">42m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
|
||||
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
|
||||
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
|
||||
|
||||
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android :** [Télécharger l'APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
|
||||
- **Android :** [Télécharger l'APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [APK 0.0.47 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
|
||||
- **Android:** [APK 0.0.48 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
|
||||
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
|
||||
|
||||
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Baixar APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
|
||||
- **Android:** [Baixar APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ on any other OS keeps using the scan.
|
||||
|
||||
## Why the package is patched
|
||||
|
||||
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries four hunks.
|
||||
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries five changes.
|
||||
|
||||
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
|
||||
libraries, which Orca's Windows build agents do not install. `node-pty` is
|
||||
@@ -360,6 +360,32 @@ on any other OS keeps using the scan.
|
||||
`node_addon_api.gyp` resolves outside the repo and hourly Windows builds
|
||||
die at configure. `node-pty` is patched the same way for the same reason.
|
||||
4. **No PEB reads, no `PROCESS_VM_READ`.** See below.
|
||||
5. **The `CreationTime` flag (4).** Upstream exposes no process start time, and
|
||||
`isWindowsProcessStartTimeAvailable()` gates structured Claude and Codex
|
||||
chat on it, so without this change win32 silently fell back to the legacy
|
||||
transcript path. `GetProcessCreationTime` opens
|
||||
`PROCESS_QUERY_LIMITED_INFORMATION` and converts `GetProcessTimes`' FILETIME
|
||||
to Unix ms; a process that denies the handle is emitted with the field
|
||||
absent, never zero, because callers must be able to tell "cannot identify"
|
||||
from a timestamp.
|
||||
5. **`supportedProcessDataFlags`.** `addon.cc` exports the flag bits the
|
||||
compiled binary understands, and `lib/index.js` re-exports it.
|
||||
|
||||
Why a fifth hunk and not just the enum: unlike `node-pty`, this package
|
||||
publishes a prebuilt `.node` at the same `build/Release/` path node-gyp
|
||||
writes to. pnpm patches the source tree and leaves that prebuilt alone, so a
|
||||
host can hold a patched `lib/index.js` — `ProcessDataFlag.CreationTime` and
|
||||
all — over a binary that ignores flag 4. CI produced exactly that: the gate
|
||||
read available and every row came back without `creationTimeMs`. Neither a
|
||||
load check nor a path check can see the difference, so the binary has to say
|
||||
so itself.
|
||||
|
||||
Two readers depend on it. `isWindowsProcessStartTimeAvailable()` returns
|
||||
false unless this bit is set, because claiming otherwise leaves
|
||||
`captureWindowsDescendantSnapshot` returning null forever while structured
|
||||
chat believes it has a reaper. And `windows-process-tree-creation-time.cjs`
|
||||
asserts it during install, which is what forces a from-source rebuild —
|
||||
the same role `node-pty-job-ownership.cjs` plays for node-pty's job exports.
|
||||
|
||||
The typings claim `commandLine` is truncated at 512 characters. Measured, it is
|
||||
not: the longest observed on a real host was 26,059.
|
||||
@@ -494,10 +520,10 @@ already has, which is why the addon is checked again at load.
|
||||
|
||||
## What the snapshot does not provide
|
||||
|
||||
`CreationDate` (process start time) has no equivalent. Anything using a start
|
||||
time to prove a PID has not been recycled — daemon identity, managed-hook
|
||||
ownership, and CPU accounting in the memory collector — still reads it through
|
||||
its own query. Those callers are not migrated.
|
||||
`CreationDate` (process start time) now has an equivalent — `creationTimeMs`,
|
||||
above — but only inside this module. Daemon identity, managed-hook ownership and
|
||||
CPU accounting in the memory collector still read a start time through their own
|
||||
queries; those callers are not migrated.
|
||||
|
||||
Committed private bytes have no equivalent either, and the one memory value the
|
||||
addon can produce is unusable for the sizes Orca now sees: `process.cc` stores
|
||||
@@ -509,10 +535,12 @@ counters in the same pass. Migrating it to the native table would cost both, and
|
||||
it is why this module no longer sets the `Memory` flag at all: the field had no
|
||||
reader, and asking for it opened a handle per process on every snapshot.
|
||||
|
||||
Start time is a proxy for identity, not identity. The durable answer for the
|
||||
process trees Orca itself spawns is an inherited handle: a job object names the
|
||||
tree Orca created, so no start-time comparison is needed. Those readers should
|
||||
be resolved that way rather than by adding a start time to this module.
|
||||
Start time is a proxy for identity, not identity. For the process trees Orca
|
||||
itself spawns the durable answer is still an inherited handle: a job object
|
||||
names the tree Orca created, so no start-time comparison is needed. The
|
||||
`creationTimeMs` this snapshot now carries is for the trees Orca did **not**
|
||||
create the handle for — a recovered agent session, a descendant walked out of
|
||||
the table — where a bare PID is all there is to re-identify.
|
||||
|
||||
Do not adopt `getProcessCpuUsage()` from the package. It takes both CPU samples
|
||||
inside one call with a blocking `Sleep(1000)` in the middle, which would hold a
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@ The Orca mobile companion is an iOS/Android app that pairs with your desktop Orc
|
||||
The mobile companion is in beta. Install iOS from the [App
|
||||
Store](https://apps.apple.com/us/app/orca-ide/id6766130217), join the [TestFlight preview
|
||||
channel](https://testflight.apple.com/join/YjeGMQBA), or install Android from the [current APK
|
||||
0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk).
|
||||
0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk).
|
||||
</Callout>
|
||||
|
||||
## What you can do from mobile
|
||||
|
||||
@@ -19,9 +19,9 @@ Type a web search instead of a path or URL to open it in the worktree browser wi
|
||||
|
||||
## Worktree Jump Palette (Cmd-J)
|
||||
|
||||
Jump across every worktree and every tab in one search. The placeholder in the empty input reads _repo/worktree_ — type either half and Orca filters accordingly. Once you start typing, search includes non-archived worktrees even if they are hidden by the sidebar's current filters. Slack-style emoji shortcodes (`:rocket:`) use the same suggestion popover as workspace naming.
|
||||
Jump across worktrees and tabs in one search. The palette opens with the sidebar's current host and project scope, including individual repository selections. The placeholder in the empty input reads _repo/worktree_ — type either half and Orca filters accordingly. Typing can still find non-archived worktrees hidden by the sidebar's other visibility toggles, but it keeps that host and repository scope. Slack-style emoji shortcodes (`:rocket:`) use the same suggestion popover as workspace naming.
|
||||
|
||||
Press **Tab** in the palette for a host and project filter menu. Selected hosts and projects narrow the result set and show as chips you can remove one at a time; closing the palette clears the filter so the next open is unscoped.
|
||||
Press **Tab** in the palette for a host and project filter menu. Project choices are repository-granular. Selected hosts and repositories narrow the result set and show as chips you can remove one at a time. Changes are temporary: closing the palette discards them, and the next open reseeds the filter from the sidebar.
|
||||
|
||||
Results include:
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ The sidebar header filter menu groups host and project scope under a shared **Sh
|
||||
- **Other-client** workspaces — **Hide other-client workspaces** appears when a shared [Remote Orca Server](/docs/remote-servers) has workspaces created from another paired client; turn it on to keep this device's list to workspaces you created here. Empty `Cmd-J` recents and numeric shortcuts follow the same filter; typing a query still finds hidden rows.
|
||||
- **Detached HEAD** workspaces — checkouts sitting on a commit rather than a branch
|
||||
|
||||
Active filter count shows on the filter control; **Clear** resets only the filters that are on. Text search and [Worktree Jump Palette](/docs/model/quick-open) (`Cmd-J`) still reach workspaces hidden only by these filters once you type a query — the jump palette also has its own host/project filters (**Tab**).
|
||||
Active filter count shows on the filter control; **Clear** resets only the filters that are on. Text search and [Worktree Jump Palette](/docs/model/quick-open) (`Cmd-J`) still reach workspaces hidden only by the hide toggles once you type a query. Cmd-J keeps the sidebar's host and project scope when it opens; press **Tab** to adjust its temporary host and individual-repository filters.
|
||||
|
||||
When you add a parent folder that contains multiple Git repos, Orca can import the selected repos separately or group them under one project group.
|
||||
|
||||
|
||||
+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",
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
TextInput: 'TextInput',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ArrowUp: 'ArrowUp',
|
||||
Check: 'Check',
|
||||
CircleHelp: 'CircleHelp'
|
||||
}))
|
||||
|
||||
describe('MobileNativeChatQuestion', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('submits the selected duplicate-label row by position', async () => {
|
||||
const onAnswer = vi.fn(async () => true)
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatQuestion, {
|
||||
question: {
|
||||
question: 'Pick regions',
|
||||
options: ['Region', 'Region'],
|
||||
multiSelect: true,
|
||||
allowOther: false,
|
||||
optionTokens: ['first-token', 'second-token']
|
||||
},
|
||||
onAnswer
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const choices = renderer.root.findAllByProps({ accessibilityRole: 'checkbox' })
|
||||
await act(async () => choices[1]!.props.onPress())
|
||||
const submit = renderer.root.findByProps({ accessibilityLabel: 'Submit selected options' })
|
||||
await act(async () => submit.props.onPress())
|
||||
|
||||
expect(onAnswer).toHaveBeenCalledWith('second-token')
|
||||
})
|
||||
|
||||
it('submits a tokenless duplicate-label row by position', async () => {
|
||||
const onAnswer = vi.fn(async () => true)
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatQuestion, {
|
||||
question: {
|
||||
question: 'Pick one',
|
||||
options: ['Choice', 'Choice'],
|
||||
multiSelect: false,
|
||||
allowOther: false,
|
||||
optionTokens: ['first-token', null]
|
||||
},
|
||||
onAnswer
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const choices = renderer.root.findAllByProps({ accessibilityRole: 'button' })
|
||||
await act(async () => choices[1]!.props.onPress())
|
||||
|
||||
expect(onAnswer).toHaveBeenCalledWith('Choice')
|
||||
})
|
||||
|
||||
it('submits structured multi-select choices together with other text', async () => {
|
||||
const onAnswer = vi.fn(async () => true)
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatQuestion, {
|
||||
question: {
|
||||
question: 'Pick regions',
|
||||
options: ['us-east', 'eu-west'],
|
||||
multiSelect: true,
|
||||
allowOther: true,
|
||||
optionTokens: ['east-token', 'west-token'],
|
||||
freeTextToken: 'other-token'
|
||||
},
|
||||
onAnswer
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const choices = renderer.root.findAllByProps({ accessibilityRole: 'checkbox' })
|
||||
await act(async () => choices[0]!.props.onPress())
|
||||
const input = renderer.root.findByType('TextInput')
|
||||
await act(async () => input.props.onChangeText('ap-south'))
|
||||
const submit = renderer.root.findByProps({ accessibilityLabel: 'Submit selected options' })
|
||||
await act(async () => submit.props.onPress())
|
||||
|
||||
expect(onAnswer).toHaveBeenCalledWith('east-token, other-token:ap-south')
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,8 @@ import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
|
||||
import { ArrowUp, Check, CircleHelp } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import {
|
||||
formatQuestionAnswer,
|
||||
formatQuestionAnswerByIndexes,
|
||||
formatQuestionAnswerWithOtherByIndexes,
|
||||
formatQuestionFreeTextAnswer,
|
||||
type MobileChatQuestion
|
||||
} from './mobile-native-chat-question'
|
||||
@@ -18,7 +19,7 @@ type Props = {
|
||||
* the user answer freely (the escape hatch) when the heuristic misreads the
|
||||
* options or none apply. */
|
||||
export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.JSX.Element {
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [selectedOptionIndexes, setSelectedOptionIndexes] = useState<number[]>([])
|
||||
const [freeText, setFreeText] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const sendingRef = useRef(false)
|
||||
@@ -27,9 +28,11 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
|
||||
const hasOptions = question.options.length > 0
|
||||
const trimmedFreeText = freeText.trim()
|
||||
|
||||
const toggle = (option: string): void => {
|
||||
setSelected((prev) =>
|
||||
prev.includes(option) ? prev.filter((o) => o !== option) : [...prev, option]
|
||||
const toggle = (optionIndex: number): void => {
|
||||
setSelectedOptionIndexes((prev) =>
|
||||
prev.includes(optionIndex)
|
||||
? prev.filter((index) => index !== optionIndex)
|
||||
: [...prev, optionIndex]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,34 +50,51 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
|
||||
}
|
||||
}
|
||||
|
||||
const answerSingle = async (option: string, optionIndex: number): Promise<void> => {
|
||||
const answerSingle = async (optionIndex: number): Promise<void> => {
|
||||
const token = question.optionTokens[optionIndex]
|
||||
await sendAnswer(token && token.length > 0 ? token : formatQuestionAnswer(question, [option]))
|
||||
await sendAnswer(
|
||||
token && token.length > 0 ? token : formatQuestionAnswerByIndexes(question, [optionIndex])
|
||||
)
|
||||
}
|
||||
|
||||
const submitMulti = async (): Promise<void> => {
|
||||
if (selected.length === 0) {
|
||||
if (selectedOptionIndexes.length === 0) {
|
||||
return
|
||||
}
|
||||
await sendAnswer(formatQuestionAnswer(question, selected))
|
||||
const answer =
|
||||
question.freeTextToken && trimmedFreeText.length > 0
|
||||
? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, trimmedFreeText)
|
||||
: formatQuestionAnswerByIndexes(question, selectedOptionIndexes)
|
||||
if (await sendAnswer(answer)) {
|
||||
setFreeText('')
|
||||
}
|
||||
}
|
||||
|
||||
const submitFreeText = async (): Promise<void> => {
|
||||
if (trimmedFreeText.length === 0) {
|
||||
return
|
||||
}
|
||||
if (await sendAnswer(formatQuestionFreeTextAnswer(question, trimmedFreeText))) {
|
||||
const answer =
|
||||
question.multiSelect && question.freeTextToken && selectedOptionIndexes.length > 0
|
||||
? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, trimmedFreeText)
|
||||
: formatQuestionFreeTextAnswer(question, trimmedFreeText)
|
||||
if (await sendAnswer(answer)) {
|
||||
setFreeText('')
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmitMulti = selected.length > 0 && !sending
|
||||
const canSubmitMulti = selectedOptionIndexes.length > 0 && !sending
|
||||
const canSendFreeText = allowOther && trimmedFreeText.length > 0 && !sending
|
||||
|
||||
// Stable keys for option rows even if an agent repeats a label.
|
||||
const optionRows = useMemo(
|
||||
() => question.options.map((label, index) => ({ label, key: `${index}:${label}` })),
|
||||
[question.options]
|
||||
() =>
|
||||
question.options.map((label, index) => ({
|
||||
label,
|
||||
description: question.optionDescriptions?.[index],
|
||||
key: `${index}:${label}`
|
||||
})),
|
||||
[question.optionDescriptions, question.options]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -86,8 +106,8 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
|
||||
|
||||
{hasOptions ? (
|
||||
<View style={styles.options}>
|
||||
{optionRows.map(({ label, key }, optIndex) => {
|
||||
const isSelected = selected.includes(label)
|
||||
{optionRows.map(({ label, description, key }, optIndex) => {
|
||||
const isSelected = selectedOptionIndexes.includes(optIndex)
|
||||
return (
|
||||
<Pressable
|
||||
key={key}
|
||||
@@ -98,16 +118,21 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
|
||||
isSelected && styles.optionSelected,
|
||||
pressed && styles.pressed
|
||||
]}
|
||||
onPress={() =>
|
||||
question.multiSelect ? toggle(label) : answerSingle(label, optIndex)
|
||||
}
|
||||
onPress={() => (question.multiSelect ? toggle(optIndex) : answerSingle(optIndex))}
|
||||
>
|
||||
{question.multiSelect ? (
|
||||
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
|
||||
{isSelected ? <Check size={13} color={colors.bgBase} strokeWidth={3} /> : null}
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={styles.optionText}>{label}</Text>
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>{label}</Text>
|
||||
{description ? (
|
||||
<Text style={styles.optionDescription} numberOfLines={2}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
@@ -126,7 +151,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
|
||||
disabled={!canSubmitMulti}
|
||||
>
|
||||
<Text style={[styles.submitText, !canSubmitMulti && styles.submitTextDisabled]}>
|
||||
Submit{selected.length > 0 ? ` (${selected.length})` : ''}
|
||||
Submit{selectedOptionIndexes.length > 0 ? ` (${selectedOptionIndexes.length})` : ''}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
@@ -207,11 +232,19 @@ const styles = StyleSheet.create({
|
||||
optionSelected: {
|
||||
borderColor: colors.accentBlue
|
||||
},
|
||||
optionText: {
|
||||
optionBody: {
|
||||
flex: 1,
|
||||
gap: 2
|
||||
},
|
||||
optionText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize + 1
|
||||
},
|
||||
optionDescription: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
lineHeight: typography.metaSize + 5
|
||||
},
|
||||
checkbox: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
|
||||
@@ -137,13 +137,27 @@ describe('resolveMobileNativeChat', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-Codex structured agent-session tabs', () => {
|
||||
it('resolves Claude structured agent-session tabs on the same journal path', () => {
|
||||
expect(
|
||||
resolveMobileNativeChat({
|
||||
type: 'agent-session',
|
||||
sessionId: 'structured-1',
|
||||
agent: 'claude'
|
||||
} as never)
|
||||
})
|
||||
).toEqual({
|
||||
agent: 'claude',
|
||||
sessionId: 'structured-1',
|
||||
transcriptPath: null
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects structured agent-session tabs whose provider the reducer cannot replay', () => {
|
||||
expect(
|
||||
resolveMobileNativeChat({
|
||||
type: 'agent-session',
|
||||
sessionId: 'structured-1',
|
||||
agent: 'grok'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host'
|
||||
import {
|
||||
@@ -48,7 +49,9 @@ export function resolveMobileNativeChat(
|
||||
return null
|
||||
}
|
||||
if (tab.type === 'agent-session') {
|
||||
return tab.sessionId && tab.agent === 'codex'
|
||||
// Structured tabs are journal-backed, so any provider the shared reducer can
|
||||
// replay renders here — there is no per-agent transcript layout to know.
|
||||
return tab.sessionId && isAgentSessionHandleProvider(tab.agent)
|
||||
? { agent: tab.agent, sessionId: tab.sessionId, transcriptPath: null }
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export type MobileChatQuestion = {
|
||||
* parallel to `options`. Null where the option was a plain bullet. Used to
|
||||
* echo the exact choice the agent listed back to the terminal. */
|
||||
optionTokens: (string | null)[]
|
||||
/** Per-option secondary text from structured prompts, parallel to `options`. */
|
||||
optionDescriptions?: (string | undefined)[]
|
||||
/** Opaque prefix used when free-text answers must target a specific prompt. */
|
||||
freeTextToken?: string
|
||||
}
|
||||
@@ -130,6 +132,48 @@ export function parseAgentQuestion(text: string): MobileChatQuestion | null {
|
||||
}
|
||||
}
|
||||
|
||||
function formatQuestionOptionAtIndex(question: MobileChatQuestion, index: number): string | null {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= question.options.length) {
|
||||
return null
|
||||
}
|
||||
const label = question.options[index]
|
||||
if (label == null || label.trim().length === 0) {
|
||||
return null
|
||||
}
|
||||
const token = question.optionTokens[index]
|
||||
return token != null && token.length > 0 ? token : label
|
||||
}
|
||||
|
||||
function formatQuestionAnswerPartsByIndexes(
|
||||
question: MobileChatQuestion,
|
||||
selectedIndexes: number[]
|
||||
): string[] {
|
||||
return selectedIndexes
|
||||
.map((index) => formatQuestionOptionAtIndex(question, index))
|
||||
.filter((part): part is string => part != null && part.trim().length > 0)
|
||||
}
|
||||
|
||||
export function formatQuestionAnswerByIndexes(
|
||||
question: MobileChatQuestion,
|
||||
selectedIndexes: number[]
|
||||
): string {
|
||||
const parts = formatQuestionAnswerPartsByIndexes(question, selectedIndexes)
|
||||
return parts.join(question.multiSelect ? ', ' : ' ')
|
||||
}
|
||||
|
||||
export function formatQuestionAnswerWithOtherByIndexes(
|
||||
question: MobileChatQuestion,
|
||||
selectedIndexes: number[],
|
||||
text: string
|
||||
): string {
|
||||
const parts = formatQuestionAnswerPartsByIndexes(question, selectedIndexes)
|
||||
const other = formatQuestionFreeTextAnswer(question, text)
|
||||
if (other.length > 0) {
|
||||
parts.push(other)
|
||||
}
|
||||
return parts.join(question.multiSelect ? ', ' : ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the text to send to the agent terminal for the selected option(s).
|
||||
* Convention: echo the option's leading marker (number/letter) when the list had
|
||||
@@ -150,8 +194,7 @@ export function formatQuestionAnswer(question: MobileChatQuestion, selected: str
|
||||
// Free-text / unknown entry: pass the user's text straight through.
|
||||
return label
|
||||
}
|
||||
const token = question.optionTokens[index]
|
||||
return token != null && token.length > 0 ? token : label
|
||||
return formatQuestionOptionAtIndex(question, index) ?? label
|
||||
})
|
||||
|
||||
return parts.join(question.multiSelect ? ', ' : ' ')
|
||||
|
||||
@@ -70,7 +70,7 @@ const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3
|
||||
const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13'
|
||||
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
|
||||
const HEAD_NESTED_FUNCTION_SHA256 =
|
||||
'6a13919ede2a8033436fb03e0ff7c426fbed97f470875a7b21b00aaada17fb73'
|
||||
'536c72b233c813bb0cea164b090bdce5406ceb965bbc5b83c1f89b89b46f3821'
|
||||
const HEAD_NATIVE_REGISTRATION_SHA256 =
|
||||
'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e'
|
||||
const HEAD_NATIVE_REMOVAL_SHA256 =
|
||||
@@ -79,7 +79,7 @@ const HEAD_TIMER_CREATION_SHA256 =
|
||||
'1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b'
|
||||
const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116'
|
||||
const HEAD_RUNTIME_STRING_SHA256 =
|
||||
'0c08a53c2cd1e182e1d7edfb7b98bd9e4a313e47c7b93f5a509a89ec3292bc1f'
|
||||
'31951b0b83be01ebfa659c4b94df9ad7eaff6404df5338fbade89eb7473a3cb4'
|
||||
const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5'
|
||||
const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016'
|
||||
const HEAD_STYLE_REFERENCE_SHA256 =
|
||||
@@ -517,7 +517,7 @@ describe('mobile session route extraction parity', () => {
|
||||
|
||||
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
|
||||
const strings = readRuntimeStrings()
|
||||
expect(strings).toHaveLength(547)
|
||||
expect(strings).toHaveLength(546)
|
||||
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
|
||||
const jsx = readJsxFacts(readDefinitions())
|
||||
expect(jsx.host).toHaveLength(124)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle'
|
||||
import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
@@ -35,7 +36,7 @@ export type MobileSessionTab =
|
||||
id: string
|
||||
title: string
|
||||
sessionId: string
|
||||
agent: 'codex'
|
||||
agent: AgentSessionHandleProvider
|
||||
isActive: boolean
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
|
||||
import {
|
||||
projectStructuredQuestion,
|
||||
type StructuredQuestionItem
|
||||
} from './mobile-structured-agent-prompts'
|
||||
|
||||
/** The shape the host emits for a Claude AskUserQuestion carrying more than one question:
|
||||
* the flat `question`/`options` pair is a placeholder and the real content is in `questions`. */
|
||||
function groupedPrompt(): StructuredQuestionItem {
|
||||
return {
|
||||
itemId: 'item-1',
|
||||
revision: 1,
|
||||
body: {
|
||||
kind: 'question',
|
||||
question: '2 grouped questions from Claude',
|
||||
options: [],
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
question: 'Which database?',
|
||||
multiSelect: false,
|
||||
options: [{ id: 'q1:choice-1', label: 'Postgres', description: 'Durable server' }],
|
||||
freeTextQuestionId: 'q1'
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
question: 'Which regions?',
|
||||
multiSelect: true,
|
||||
options: [{ id: 'q2:choice-1', label: 'us-east' }],
|
||||
freeTextQuestionId: 'q2'
|
||||
}
|
||||
],
|
||||
resolution: { state: 'pending' }
|
||||
}
|
||||
} as unknown as AgentJournalRenderItem as StructuredQuestionItem
|
||||
}
|
||||
|
||||
describe('structured question projection for grouped Claude prompts', () => {
|
||||
it('renders an answerable question instead of the empty placeholder card', () => {
|
||||
const projected = projectStructuredQuestion(groupedPrompt())
|
||||
|
||||
expect(projected?.question).not.toBe('2 grouped questions from Claude')
|
||||
expect(projected?.options).toEqual(['Postgres'])
|
||||
expect(projected?.optionDescriptions).toEqual(['Durable server'])
|
||||
expect(projected?.optionTokens.filter(Boolean)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
|
||||
import type { MobileChatPermission } from './mobile-native-chat-permission'
|
||||
import type { MobileChatQuestion } from './mobile-native-chat-question'
|
||||
import {
|
||||
groupedQuestionPromptKey,
|
||||
projectGroupedQuestion,
|
||||
type GroupedQuestionDraft
|
||||
} from './mobile-structured-grouped-question'
|
||||
|
||||
export type StructuredApprovalItem = AgentJournalRenderItem & {
|
||||
body: Extract<AgentJournalRenderItem['body'], { kind: 'approval' }>
|
||||
@@ -143,14 +148,24 @@ export function projectStructuredPermission(
|
||||
}
|
||||
|
||||
export function projectStructuredQuestion(
|
||||
prompt: StructuredQuestionItem | null
|
||||
prompt: StructuredQuestionItem | null,
|
||||
groupedDraft: GroupedQuestionDraft | null = null
|
||||
): MobileChatQuestion | null {
|
||||
if (prompt?.body.kind !== 'question') {
|
||||
return null
|
||||
}
|
||||
if (prompt.body.questions) {
|
||||
return projectGroupedQuestion(
|
||||
prompt.body.questions,
|
||||
groupedDraft,
|
||||
groupedQuestionPromptKey(prompt.itemId, prompt.revision)
|
||||
)
|
||||
}
|
||||
const optionDescriptions = prompt.body.options.map((option) => option.description)
|
||||
return {
|
||||
question: prompt.body.question,
|
||||
options: prompt.body.options.map((option) => option.label),
|
||||
...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}),
|
||||
multiSelect: false,
|
||||
allowOther: Boolean(prompt.body.freeTextQuestionId),
|
||||
optionTokens: prompt.body.options.map((option) =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { createMobileStructuredCodexSession } from './mobile-structured-agent-session-launch'
|
||||
import { createMobileStructuredAgentSession } from './mobile-structured-agent-session-launch'
|
||||
|
||||
function clientReturning(
|
||||
...responses: unknown[]
|
||||
@@ -36,11 +36,13 @@ const acceptedCreateResult = {
|
||||
}
|
||||
const acceptedCreate = { ok: true, result: acceptedCreateResult }
|
||||
|
||||
describe('mobile structured Codex launch', () => {
|
||||
describe('mobile structured agent-session launch', () => {
|
||||
it('creates through the structured agent-session intent after support is confirmed', async () => {
|
||||
const client = clientReturning({ ok: true, result: { supported: true } }, acceptedCreate)
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
|
||||
await expect(
|
||||
createMobileStructuredAgentSession(client, 'workspace-1', 'codex')
|
||||
).resolves.toMatchObject({
|
||||
kind: 'created',
|
||||
sessionId: expect.stringMatching(/^codex_[A-Za-z0-9_]{8,128}$/)
|
||||
})
|
||||
@@ -67,16 +69,94 @@ describe('mobile structured Codex launch', () => {
|
||||
expect(params.envelope.sessionId).toMatch(/^codex_[A-Za-z0-9_]{8,128}$/)
|
||||
})
|
||||
|
||||
it('creates a Claude session through the same envelope, keyed to the claude provider', async () => {
|
||||
const client = clientReturning(
|
||||
{ ok: true, result: { supported: true } },
|
||||
{
|
||||
ok: true,
|
||||
result: {
|
||||
...acceptedCreateResult,
|
||||
value: { ...acceptedCreateResult.value, sessionId: 'claude_session_1' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
createMobileStructuredAgentSession(client, 'workspace-1', 'claude')
|
||||
).resolves.toMatchObject({ kind: 'created', sessionId: 'claude_session_1' })
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'agentSession.createSupport', {
|
||||
worktree: 'id:workspace-1',
|
||||
agent: 'claude'
|
||||
})
|
||||
const params = client.sendRequest.mock.calls[1]?.[1] as {
|
||||
envelope: { sessionId: string; payloadFingerprint: string }
|
||||
agent: string
|
||||
}
|
||||
expect(params.agent).toBe('claude')
|
||||
expect(params.envelope.sessionId).toMatch(/^claude_[A-Za-z0-9_]{8,128}$/)
|
||||
expect(params.envelope.payloadFingerprint).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
it('names the refusing agent in the failure copy rather than always saying Codex', async () => {
|
||||
const client = clientReturning(
|
||||
{ ok: true, result: { supported: true } },
|
||||
// A definitive refusal is the only path that reaches the failure copy; anything else
|
||||
// stays unknown and never renders a message.
|
||||
{ ok: false, error: { code: 'method_not_found', message: '' } }
|
||||
)
|
||||
|
||||
await expect(
|
||||
createMobileStructuredAgentSession(client, 'workspace-1', 'claude')
|
||||
).resolves.toEqual({ kind: 'failed', message: 'Could not open Claude chat.' })
|
||||
})
|
||||
|
||||
it('reports unsupported without creating a terminal when the structured path is unavailable', async () => {
|
||||
const client = clientReturning({ ok: true, result: { supported: false, reason: 'remote' } })
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({
|
||||
await expect(
|
||||
createMobileStructuredAgentSession(client, 'workspace-1', 'codex')
|
||||
).resolves.toEqual({
|
||||
kind: 'unsupported',
|
||||
reason: 'remote'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries a transient unresolved worktree before deciding structured support', async () => {
|
||||
vi.useFakeTimers()
|
||||
const client = clientReturning(
|
||||
{ ok: false, error: { code: 'selector_not_found', message: 'Selector not found' } },
|
||||
{ ok: true, result: { supported: true } },
|
||||
acceptedCreate
|
||||
)
|
||||
|
||||
try {
|
||||
const result = createMobileStructuredAgentSession(client, 'workspace-1', 'claude')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(result).resolves.toMatchObject({ kind: 'created' })
|
||||
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
|
||||
'agentSession.createSupport',
|
||||
'agentSession.createSupport',
|
||||
'agentSession.create'
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not retry a support failure unrelated to worktree resolution', async () => {
|
||||
const client = clientReturning({
|
||||
ok: false,
|
||||
error: { code: 'runtime_busy', message: 'Runtime busy' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
createMobileStructuredAgentSession(client, 'workspace-1', 'claude')
|
||||
).resolves.toEqual({ kind: 'unsupported' })
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps an unknown create outcome distinct so callers do not create a duplicate terminal', async () => {
|
||||
const client = clientReturning({ ok: true, result: { supported: true } })
|
||||
client.sendRequest.mockImplementationOnce(async () => ({
|
||||
@@ -85,7 +165,9 @@ describe('mobile structured Codex launch', () => {
|
||||
}))
|
||||
client.sendRequest.mockRejectedValue(markRpcDeliveryUnknown(new Error('response lost')))
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
|
||||
await expect(
|
||||
createMobileStructuredAgentSession(client, 'workspace-1', 'codex')
|
||||
).resolves.toMatchObject({
|
||||
kind: 'unknown'
|
||||
})
|
||||
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
|
||||
@@ -105,7 +187,9 @@ describe('mobile structured Codex launch', () => {
|
||||
client.sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('response lost')))
|
||||
client.sendRequest.mockRejectedValueOnce(new Error('connection interrupted'))
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
|
||||
await expect(
|
||||
createMobileStructuredAgentSession(client, 'workspace-1', 'codex')
|
||||
).resolves.toMatchObject({
|
||||
kind: 'unknown'
|
||||
})
|
||||
})
|
||||
@@ -118,7 +202,9 @@ describe('mobile structured Codex launch', () => {
|
||||
}))
|
||||
client.sendRequest.mockRejectedValue(new Error('internal error after commit'))
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
|
||||
await expect(
|
||||
createMobileStructuredAgentSession(client, 'workspace-1', 'codex')
|
||||
).resolves.toMatchObject({
|
||||
kind: 'unknown'
|
||||
})
|
||||
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
|
||||
@@ -135,7 +221,9 @@ describe('mobile structured Codex launch', () => {
|
||||
{ ok: true, result: { ok: true, value: { sessionId: '' } } }
|
||||
)
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
|
||||
await expect(
|
||||
createMobileStructuredAgentSession(client, 'workspace-1', 'codex')
|
||||
).resolves.toMatchObject({
|
||||
kind: 'unknown'
|
||||
})
|
||||
})
|
||||
@@ -148,7 +236,9 @@ describe('mobile structured Codex launch', () => {
|
||||
{ ok: false, error: { code, message: 'structured create unavailable' } }
|
||||
)
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({
|
||||
await expect(
|
||||
createMobileStructuredAgentSession(client, 'workspace-1', 'codex')
|
||||
).resolves.toEqual({
|
||||
kind: 'failed',
|
||||
message: 'structured create unavailable'
|
||||
})
|
||||
@@ -163,7 +253,9 @@ describe('mobile structured Codex launch', () => {
|
||||
{ ok: false, error: { code, message: 'create outcome ambiguous' } }
|
||||
)
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({
|
||||
await expect(
|
||||
createMobileStructuredAgentSession(client, 'workspace-1', 'codex')
|
||||
).resolves.toEqual({
|
||||
kind: 'unknown',
|
||||
message: 'create outcome ambiguous'
|
||||
})
|
||||
@@ -185,7 +277,9 @@ describe('mobile structured Codex launch', () => {
|
||||
}
|
||||
)
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({
|
||||
await expect(
|
||||
createMobileStructuredAgentSession(client, 'workspace-1', 'codex')
|
||||
).resolves.toEqual({
|
||||
kind: 'failed',
|
||||
message: 'structured create unavailable'
|
||||
})
|
||||
@@ -205,7 +299,9 @@ describe('mobile structured Codex launch', () => {
|
||||
}
|
||||
)
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({
|
||||
await expect(
|
||||
createMobileStructuredAgentSession(client, 'workspace-1', 'codex')
|
||||
).resolves.toEqual({
|
||||
kind: 'unknown',
|
||||
message: 'create outcome ambiguous'
|
||||
})
|
||||
|
||||
@@ -1,93 +1,108 @@
|
||||
import type { AgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle'
|
||||
import type {
|
||||
AgentSessionAttachResult,
|
||||
AgentSessionMutationResult
|
||||
} from '../../../src/shared/agent-session-wire'
|
||||
import { isDefinitiveAgentSessionCreateRefusal } from '../../../src/shared/agent-session-definitive-refusal'
|
||||
import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation'
|
||||
import {
|
||||
createStructuredAgentSessionId,
|
||||
structuredAgentSessionCreateParams,
|
||||
type StructuredAgentSessionCreateParams
|
||||
} from '../../../src/shared/structured-agent-session-create'
|
||||
import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names'
|
||||
import { hasRuntimeRpcErrorCode } from '../../../src/shared/runtime-rpc-error-code'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { structuredSessionOperationId } from './mobile-structured-agent-session-rpc'
|
||||
import { structuredSessionRandomUuid } from './mobile-structured-agent-session-rpc'
|
||||
|
||||
type StructuredCreateSupport = {
|
||||
supported?: boolean
|
||||
reason?: 'agent' | 'remote' | 'wsl'
|
||||
}
|
||||
|
||||
export type MobileStructuredCodexLaunchResult =
|
||||
const SELECTOR_NOT_RESOLVABLE_CODE = 'selector_not_found'
|
||||
const CREATE_SUPPORT_RETRY_DELAYS_MS: readonly number[] = [50, 150, 300]
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export type MobileStructuredAgentLaunchResult =
|
||||
| { kind: 'created'; sessionId: string }
|
||||
| { kind: 'unsupported'; reason?: StructuredCreateSupport['reason'] }
|
||||
| { kind: 'failed'; message: string }
|
||||
| { kind: 'unknown'; message: string }
|
||||
|
||||
type StructuredCreateParams = {
|
||||
envelope: {
|
||||
sessionId: string
|
||||
clientOperationId: string
|
||||
expectedRuntimeFence: null
|
||||
payloadFingerprint: string
|
||||
}
|
||||
function createParamsFor(
|
||||
agent: AgentSessionHandleProvider,
|
||||
worktree: string
|
||||
agent: 'codex'
|
||||
): StructuredAgentSessionCreateParams {
|
||||
return structuredAgentSessionCreateParams({
|
||||
sessionId: createStructuredAgentSessionId(agent, structuredSessionRandomUuid),
|
||||
worktree,
|
||||
agent,
|
||||
randomUuid: structuredSessionRandomUuid
|
||||
})
|
||||
}
|
||||
|
||||
function createStructuredCodexSessionId(): string {
|
||||
return `codex_${createRandomUuid().replaceAll('-', '_')}`
|
||||
}
|
||||
|
||||
function createRandomUuid(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||
return globalThis.crypto.randomUUID()
|
||||
}
|
||||
return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('')
|
||||
}
|
||||
|
||||
function createStructuredCodexSessionParams(worktreeId: string): StructuredCreateParams {
|
||||
const sessionId = createStructuredCodexSessionId()
|
||||
const worktree = `id:${worktreeId}`
|
||||
const fields = { worktree, agent: 'codex' as const }
|
||||
return {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: structuredSessionOperationId(),
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.create',
|
||||
sessionId,
|
||||
fields
|
||||
})
|
||||
},
|
||||
...fields
|
||||
}
|
||||
}
|
||||
|
||||
function unknownCreateResult(error: unknown): MobileStructuredCodexLaunchResult {
|
||||
function unknownCreateResult(
|
||||
agent: AgentSessionHandleProvider,
|
||||
error: unknown
|
||||
): MobileStructuredAgentLaunchResult {
|
||||
const message = error instanceof Error ? error.message.trim() : ''
|
||||
return {
|
||||
kind: 'unknown',
|
||||
message: message || 'The Codex chat result could not be confirmed.'
|
||||
}
|
||||
return { kind: 'unknown', message: message || unconfirmedMessage(agent) }
|
||||
}
|
||||
|
||||
function classifyCreateRefusal(code: string, message: string): MobileStructuredCodexLaunchResult {
|
||||
function unconfirmedMessage(agent: AgentSessionHandleProvider): string {
|
||||
return `The ${TUI_AGENT_DISPLAY_NAMES[agent]} chat result could not be confirmed.`
|
||||
}
|
||||
|
||||
function failedMessage(agent: AgentSessionHandleProvider): string {
|
||||
return `Could not open ${TUI_AGENT_DISPLAY_NAMES[agent]} chat.`
|
||||
}
|
||||
|
||||
/** Only a refusal the host names as definitive may become `failed`; anything else keeps the
|
||||
* outcome unknown so no legacy sibling terminal is created for a session that may exist. */
|
||||
function classifyCreateRefusal(
|
||||
agent: AgentSessionHandleProvider,
|
||||
code: string,
|
||||
message: string
|
||||
): MobileStructuredAgentLaunchResult {
|
||||
if (!isDefinitiveAgentSessionCreateRefusal(code)) {
|
||||
return unknownCreateResult(new Error(message))
|
||||
return unknownCreateResult(agent, new Error(message))
|
||||
}
|
||||
return { kind: 'failed', message: message || 'Could not open Codex chat.' }
|
||||
return { kind: 'failed', message: message || failedMessage(agent) }
|
||||
}
|
||||
|
||||
export async function createMobileStructuredCodexSession(
|
||||
export async function createMobileStructuredAgentSession(
|
||||
client: RpcClient,
|
||||
worktreeId: string
|
||||
): Promise<MobileStructuredCodexLaunchResult> {
|
||||
worktreeId: string,
|
||||
agent: AgentSessionHandleProvider
|
||||
): Promise<MobileStructuredAgentLaunchResult> {
|
||||
const worktree = `id:${worktreeId}`
|
||||
let supportResponse
|
||||
try {
|
||||
supportResponse = await client.sendRequest('agentSession.createSupport', {
|
||||
worktree,
|
||||
agent: 'codex'
|
||||
})
|
||||
} catch {
|
||||
// A support probe has no side effect; an unavailable probe safely degrades to terminal chat.
|
||||
return { kind: 'unsupported' }
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
supportResponse = await client.sendRequest('agentSession.createSupport', { worktree, agent })
|
||||
} catch (error) {
|
||||
const retryDelayMs = CREATE_SUPPORT_RETRY_DELAYS_MS[attempt]
|
||||
if (
|
||||
retryDelayMs === undefined ||
|
||||
!hasRuntimeRpcErrorCode(error, SELECTOR_NOT_RESOLVABLE_CODE)
|
||||
) {
|
||||
return { kind: 'unsupported' }
|
||||
}
|
||||
await delay(retryDelayMs)
|
||||
continue
|
||||
}
|
||||
const retryDelayMs = CREATE_SUPPORT_RETRY_DELAYS_MS[attempt]
|
||||
if (
|
||||
retryDelayMs !== undefined &&
|
||||
hasRuntimeRpcErrorCode(supportResponse, SELECTOR_NOT_RESOLVABLE_CODE)
|
||||
) {
|
||||
await delay(retryDelayMs)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if (
|
||||
!supportResponse ||
|
||||
@@ -102,7 +117,7 @@ export async function createMobileStructuredCodexSession(
|
||||
return { kind: 'unsupported', reason: support?.reason }
|
||||
}
|
||||
|
||||
const params = createStructuredCodexSessionParams(worktreeId)
|
||||
const params = createParamsFor(agent, worktree)
|
||||
let response
|
||||
try {
|
||||
response = await client.sendRequest('agentSession.create', params, {
|
||||
@@ -118,12 +133,12 @@ export async function createMobileStructuredCodexSession(
|
||||
})
|
||||
} catch (retryError) {
|
||||
// A second transport error cannot disprove the first attempt committed.
|
||||
return unknownCreateResult(retryError)
|
||||
return unknownCreateResult(agent, retryError)
|
||||
}
|
||||
}
|
||||
|
||||
if (!response || typeof response !== 'object' || typeof response.ok !== 'boolean') {
|
||||
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
|
||||
return unknownCreateResult(agent, new Error(unconfirmedMessage(agent)))
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (
|
||||
@@ -131,13 +146,13 @@ export async function createMobileStructuredCodexSession(
|
||||
typeof response.error !== 'object' ||
|
||||
typeof response.error.code !== 'string'
|
||||
) {
|
||||
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
|
||||
return unknownCreateResult(agent, new Error(unconfirmedMessage(agent)))
|
||||
}
|
||||
return classifyCreateRefusal(response.error.code, response.error.message)
|
||||
return classifyCreateRefusal(agent, response.error.code, response.error.message)
|
||||
}
|
||||
const result = response.result as AgentSessionMutationResult<AgentSessionAttachResult>
|
||||
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
|
||||
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
|
||||
return unknownCreateResult(agent, new Error(unconfirmedMessage(agent)))
|
||||
}
|
||||
if (!result.ok) {
|
||||
if (
|
||||
@@ -145,16 +160,16 @@ export async function createMobileStructuredCodexSession(
|
||||
typeof result.refusal !== 'object' ||
|
||||
typeof result.refusal.code !== 'string'
|
||||
) {
|
||||
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
|
||||
return unknownCreateResult(agent, new Error(unconfirmedMessage(agent)))
|
||||
}
|
||||
return classifyCreateRefusal(result.refusal.code, result.refusal.message)
|
||||
return classifyCreateRefusal(agent, result.refusal.code, result.refusal.message)
|
||||
}
|
||||
if (
|
||||
!result.value ||
|
||||
typeof result.value.sessionId !== 'string' ||
|
||||
!result.value.sessionId.trim()
|
||||
) {
|
||||
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
|
||||
return unknownCreateResult(agent, new Error(unconfirmedMessage(agent)))
|
||||
}
|
||||
return { kind: 'created', sessionId: result.value.sessionId }
|
||||
}
|
||||
|
||||
@@ -49,16 +49,16 @@ export async function callAgentSession<TResult>(
|
||||
return response.result as TResult
|
||||
}
|
||||
|
||||
/** React Native has no guaranteed `crypto.randomUUID`; the fallback keeps the same
|
||||
* 32-hex entropy shape the durable id and fingerprint helpers validate. */
|
||||
export function structuredSessionRandomUuid(): string {
|
||||
return typeof globalThis.crypto?.randomUUID === 'function'
|
||||
? globalThis.crypto.randomUUID()
|
||||
: Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('')
|
||||
}
|
||||
|
||||
export function structuredSessionOperationId(): string {
|
||||
const randomUuid =
|
||||
typeof globalThis.crypto?.randomUUID === 'function'
|
||||
? () => globalThis.crypto.randomUUID()
|
||||
: () => {
|
||||
return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join(
|
||||
''
|
||||
)
|
||||
}
|
||||
return createStructuredAgentSessionOperationId(randomUuid)
|
||||
return createStructuredAgentSessionOperationId(structuredSessionRandomUuid)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalQuestion } from '../../../src/shared/agent-session-journal-types'
|
||||
import { decodeAgentSessionQuestionAnswers } from '../../../src/shared/agent-session-question-answer'
|
||||
import {
|
||||
formatQuestionAnswer,
|
||||
formatQuestionFreeTextAnswer,
|
||||
mobileChatQuestionKey
|
||||
} from './mobile-native-chat-question'
|
||||
import {
|
||||
advanceGroupedQuestion,
|
||||
groupedQuestionPromptKey,
|
||||
projectGroupedQuestion,
|
||||
type GroupedQuestionDraft
|
||||
} from './mobile-structured-grouped-question'
|
||||
|
||||
const PROMPT_KEY = groupedQuestionPromptKey('item-1', 3)
|
||||
|
||||
function question(overrides: Partial<AgentJournalQuestion> = {}): AgentJournalQuestion {
|
||||
return {
|
||||
id: 'q1',
|
||||
question: 'Which database?',
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ id: 'q1:choice-1', label: 'Postgres' },
|
||||
{ id: 'q1:choice-2', label: 'SQLite' }
|
||||
],
|
||||
freeTextQuestionId: 'q1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const SECOND = question({
|
||||
id: 'q2',
|
||||
question: 'Which regions?',
|
||||
multiSelect: true,
|
||||
options: [
|
||||
{ id: 'q2:choice-1', label: 'us-east' },
|
||||
{ id: 'q2:choice-2', label: 'eu-west' }
|
||||
],
|
||||
freeTextQuestionId: 'q2'
|
||||
})
|
||||
|
||||
/** Mirrors what the question card sends back for a single-select tap. */
|
||||
function tapOption(projected: NonNullable<ReturnType<typeof projectGroupedQuestion>>, at: number) {
|
||||
return projected.optionTokens[at] ?? ''
|
||||
}
|
||||
|
||||
describe('mobile structured grouped questions', () => {
|
||||
it('projects the first question with real options instead of the empty flat shape', () => {
|
||||
const projected = projectGroupedQuestion([question(), SECOND], null, PROMPT_KEY)
|
||||
|
||||
expect(projected).toMatchObject({
|
||||
question: 'Which database? (1 of 2)',
|
||||
options: ['Postgres', 'SQLite'],
|
||||
multiSelect: false,
|
||||
allowOther: true
|
||||
})
|
||||
expect(projected?.optionTokens.every((token) => Boolean(token))).toBe(true)
|
||||
expect(projected?.freeTextToken).toBeTruthy()
|
||||
})
|
||||
|
||||
it('steps to the next question once the first is answered, without sending anything', () => {
|
||||
const questions = [question(), SECOND]
|
||||
const first = projectGroupedQuestion(questions, null, PROMPT_KEY)!
|
||||
|
||||
const advance = advanceGroupedQuestion({
|
||||
response: tapOption(first, 0),
|
||||
questions,
|
||||
draft: null,
|
||||
promptKey: PROMPT_KEY
|
||||
})
|
||||
|
||||
expect(advance).toEqual({
|
||||
kind: 'advance',
|
||||
draft: { promptKey: PROMPT_KEY, answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] }
|
||||
})
|
||||
const second = projectGroupedQuestion(
|
||||
questions,
|
||||
advance!.kind === 'advance' ? advance.draft : null,
|
||||
PROMPT_KEY
|
||||
)
|
||||
expect(second).toMatchObject({ question: 'Which regions? (2 of 2)', multiSelect: true })
|
||||
})
|
||||
|
||||
it('submits the whole group as one encoded answer on the last step', () => {
|
||||
const questions = [question(), SECOND]
|
||||
const draft: GroupedQuestionDraft = {
|
||||
promptKey: PROMPT_KEY,
|
||||
answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }]
|
||||
}
|
||||
const second = projectGroupedQuestion(questions, draft, PROMPT_KEY)!
|
||||
|
||||
const result = advanceGroupedQuestion({
|
||||
// Multi-select joins its selected option tokens the way the card does.
|
||||
response: formatQuestionAnswer(second, ['us-east', 'eu-west']),
|
||||
questions,
|
||||
draft,
|
||||
promptKey: PROMPT_KEY
|
||||
})
|
||||
|
||||
expect(result?.kind).toBe('submit')
|
||||
expect(
|
||||
decodeAgentSessionQuestionAnswers(result?.kind === 'submit' ? result.optionId : '')
|
||||
).toEqual([
|
||||
{ questionId: 'q1', optionIds: ['q1:choice-1'] },
|
||||
{ questionId: 'q2', optionIds: ['q2:choice-1', 'q2:choice-2'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('carries a free-text answer as `other` for the question it was typed against', () => {
|
||||
const questions = [question()]
|
||||
const only = projectGroupedQuestion(questions, null, PROMPT_KEY)!
|
||||
|
||||
const result = advanceGroupedQuestion({
|
||||
response: formatQuestionFreeTextAnswer(only, ' DuckDB '),
|
||||
questions,
|
||||
draft: null,
|
||||
promptKey: PROMPT_KEY
|
||||
})
|
||||
|
||||
expect(
|
||||
decodeAgentSessionQuestionAnswers(result?.kind === 'submit' ? result.optionId : '')
|
||||
).toEqual([{ questionId: 'q1', optionIds: [], other: 'DuckDB' }])
|
||||
})
|
||||
|
||||
it('keeps selected options and other text for grouped multi-select answers', () => {
|
||||
const questions = [SECOND]
|
||||
const only = projectGroupedQuestion(questions, null, PROMPT_KEY)!
|
||||
|
||||
const result = advanceGroupedQuestion({
|
||||
response: `${tapOption(only, 0)}, ${formatQuestionFreeTextAnswer(only, 'ap-south')}`,
|
||||
questions,
|
||||
draft: null,
|
||||
promptKey: PROMPT_KEY
|
||||
})
|
||||
|
||||
expect(
|
||||
decodeAgentSessionQuestionAnswers(result?.kind === 'submit' ? result.optionId : '')
|
||||
).toEqual([{ questionId: 'q2', optionIds: ['q2:choice-1'], other: 'ap-south' }])
|
||||
})
|
||||
|
||||
it('gives each step a distinct card key so a selection cannot carry into the next question', () => {
|
||||
// The view keys MobileNativeChatQuestion by this value; an identical key would reuse the
|
||||
// mounted card and submit step 1's checkboxes as step 2's answer. Claude can legitimately ask
|
||||
// the SAME text twice in one group (once per file, say), so identical wording must still key
|
||||
// apart on the question id and step counter.
|
||||
const questions = [
|
||||
question({ id: 'q1', question: 'Approve?' }),
|
||||
question({ id: 'q2', question: 'Approve?' })
|
||||
]
|
||||
const first = projectGroupedQuestion(questions, null, PROMPT_KEY)!
|
||||
const second = projectGroupedQuestion(
|
||||
questions,
|
||||
{ promptKey: PROMPT_KEY, answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] },
|
||||
PROMPT_KEY
|
||||
)!
|
||||
|
||||
expect(first.question).toBe('Approve? (1 of 2)')
|
||||
expect(second.question).toBe('Approve? (2 of 2)')
|
||||
expect(mobileChatQuestionKey(first)).not.toBe(mobileChatQuestionKey(second))
|
||||
})
|
||||
|
||||
it('discards a draft collected against a superseded prompt revision', () => {
|
||||
const questions = [question(), SECOND]
|
||||
const stale: GroupedQuestionDraft = {
|
||||
promptKey: groupedQuestionPromptKey('item-1', 2),
|
||||
answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }]
|
||||
}
|
||||
|
||||
expect(projectGroupedQuestion(questions, stale, PROMPT_KEY)).toMatchObject({
|
||||
question: 'Which database? (1 of 2)'
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a response that does not answer the current step', () => {
|
||||
const questions = [question(), SECOND]
|
||||
|
||||
expect(
|
||||
advanceGroupedQuestion({
|
||||
response: 'Postgres',
|
||||
questions,
|
||||
draft: null,
|
||||
promptKey: PROMPT_KEY
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses an option token rendered for a superseded prompt revision', () => {
|
||||
const stale = projectGroupedQuestion([question()], null, groupedQuestionPromptKey('item-1', 2))!
|
||||
|
||||
expect(
|
||||
advanceGroupedQuestion({
|
||||
response: tapOption(stale, 0),
|
||||
questions: [question()],
|
||||
draft: null,
|
||||
promptKey: PROMPT_KEY
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses free text rendered for a superseded prompt revision', () => {
|
||||
const stale = projectGroupedQuestion([question()], null, groupedQuestionPromptKey('item-1', 2))!
|
||||
|
||||
expect(
|
||||
advanceGroupedQuestion({
|
||||
response: formatQuestionFreeTextAnswer(stale, 'stale answer'),
|
||||
questions: [question()],
|
||||
draft: null,
|
||||
promptKey: PROMPT_KEY
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a multi-select response when one selected token is malformed', () => {
|
||||
const questions = [SECOND]
|
||||
const only = projectGroupedQuestion(questions, null, PROMPT_KEY)!
|
||||
|
||||
expect(
|
||||
advanceGroupedQuestion({
|
||||
response: `${tapOption(only, 0)}, not-a-grouped-token`,
|
||||
questions,
|
||||
draft: null,
|
||||
promptKey: PROMPT_KEY
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a multi-select response when one selected token belongs to another prompt', () => {
|
||||
const questions = [SECOND]
|
||||
const current = projectGroupedQuestion(questions, null, PROMPT_KEY)!
|
||||
const stale = projectGroupedQuestion(questions, null, groupedQuestionPromptKey('item-1', 2))!
|
||||
|
||||
expect(
|
||||
advanceGroupedQuestion({
|
||||
response: `${tapOption(current, 0)}, ${tapOption(stale, 1)}`,
|
||||
questions,
|
||||
draft: null,
|
||||
promptKey: PROMPT_KEY
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses an empty multi-select rather than sending a group the host would reject', () => {
|
||||
const questions = [SECOND]
|
||||
const only = projectGroupedQuestion(questions, null, PROMPT_KEY)!
|
||||
|
||||
expect(
|
||||
advanceGroupedQuestion({
|
||||
response: formatQuestionAnswer(only, []),
|
||||
questions,
|
||||
draft: null,
|
||||
promptKey: PROMPT_KEY
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { AgentJournalQuestion } from '../../../src/shared/agent-session-journal-types'
|
||||
import {
|
||||
encodeAgentSessionQuestionAnswers,
|
||||
isValidAgentSessionQuestionAnswers,
|
||||
type AgentSessionQuestionAnswer
|
||||
} from '../../../src/shared/agent-session-question-answer'
|
||||
import type { MobileChatQuestion } from './mobile-native-chat-question'
|
||||
|
||||
/**
|
||||
* Claude's AskUserQuestion can carry several questions, or one multi-select question, in a single
|
||||
* prompt. The host then leaves the flat `question.options` EMPTY and puts the real content in
|
||||
* `questions`, so a client that reads only the flat shape renders an unanswerable card and the turn
|
||||
* stalls. The phone has room for one question at a time, so the group is answered as steps and
|
||||
* submitted once — the host accepts the whole group as one encoded option id.
|
||||
*/
|
||||
export type GroupedQuestionDraft = {
|
||||
/** Identifies the exact prompt revision these answers belong to; a revised prompt discards them. */
|
||||
promptKey: string
|
||||
answers: AgentSessionQuestionAnswer[]
|
||||
}
|
||||
|
||||
export type GroupedQuestionAdvance =
|
||||
| { kind: 'advance'; draft: GroupedQuestionDraft }
|
||||
| { kind: 'submit'; optionId: string }
|
||||
|
||||
const GROUPED_TOKEN_PREFIX = 'structured-grouped-question:'
|
||||
|
||||
type GroupedTokenPayload =
|
||||
| { kind: 'option'; promptKey: string; questionId: string; optionId: string }
|
||||
| { kind: 'free-text'; promptKey: string; questionId: string }
|
||||
|
||||
export function groupedQuestionPromptKey(itemId: string, revision: number): string {
|
||||
return `${itemId}:${revision}`
|
||||
}
|
||||
|
||||
function encodeGroupedToken(payload: GroupedTokenPayload): string {
|
||||
return `${GROUPED_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`
|
||||
}
|
||||
|
||||
function decodeGroupedToken(value: string): GroupedTokenPayload | null {
|
||||
if (!value.startsWith(GROUPED_TOKEN_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const decoded = JSON.parse(
|
||||
decodeURIComponent(value.slice(GROUPED_TOKEN_PREFIX.length))
|
||||
) as Record<string, unknown>
|
||||
if (typeof decoded.promptKey !== 'string' || typeof decoded.questionId !== 'string') {
|
||||
return null
|
||||
}
|
||||
if (decoded.kind === 'option' && typeof decoded.optionId === 'string') {
|
||||
return {
|
||||
kind: 'option',
|
||||
promptKey: decoded.promptKey,
|
||||
questionId: decoded.questionId,
|
||||
optionId: decoded.optionId
|
||||
}
|
||||
}
|
||||
if (decoded.kind === 'free-text') {
|
||||
return { kind: 'free-text', promptKey: decoded.promptKey, questionId: decoded.questionId }
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function decodeGroupedFreeTextAnswer(value: string): {
|
||||
promptKey: string
|
||||
questionId: string
|
||||
answer: string
|
||||
} | null {
|
||||
if (!value.startsWith(GROUPED_TOKEN_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
// The payload is percent-encoded, so the first `:` after the prefix is the answer separator.
|
||||
const separator = value.indexOf(':', GROUPED_TOKEN_PREFIX.length)
|
||||
if (separator === -1) {
|
||||
return null
|
||||
}
|
||||
const payload = decodeGroupedToken(value.slice(0, separator))
|
||||
if (payload?.kind !== 'free-text') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return {
|
||||
promptKey: payload.promptKey,
|
||||
questionId: payload.questionId,
|
||||
answer: decodeURIComponent(value.slice(separator + 1))
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers already collected for this exact prompt revision; a stale draft counts as none. */
|
||||
function answersFor(
|
||||
draft: GroupedQuestionDraft | null,
|
||||
promptKey: string
|
||||
): AgentSessionQuestionAnswer[] {
|
||||
return draft && draft.promptKey === promptKey ? draft.answers : []
|
||||
}
|
||||
|
||||
/** The step to show now, or null once every question has an answer. */
|
||||
export function projectGroupedQuestion(
|
||||
questions: readonly AgentJournalQuestion[],
|
||||
draft: GroupedQuestionDraft | null,
|
||||
promptKey: string
|
||||
): MobileChatQuestion | null {
|
||||
const answered = answersFor(draft, promptKey).length
|
||||
const question = questions[answered]
|
||||
if (!question) {
|
||||
return null
|
||||
}
|
||||
const heading = question.header ? `${question.header}: ${question.question}` : question.question
|
||||
const optionDescriptions = question.options.map((option) => option.description)
|
||||
return {
|
||||
question:
|
||||
questions.length > 1 ? `${heading} (${answered + 1} of ${questions.length})` : heading,
|
||||
options: question.options.map((option) => option.label),
|
||||
...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}),
|
||||
multiSelect: question.multiSelect,
|
||||
allowOther: Boolean(question.freeTextQuestionId),
|
||||
optionTokens: question.options.map((option) =>
|
||||
encodeGroupedToken({
|
||||
kind: 'option',
|
||||
promptKey,
|
||||
questionId: question.id,
|
||||
optionId: option.id
|
||||
})
|
||||
),
|
||||
...(question.freeTextQuestionId
|
||||
? {
|
||||
freeTextToken: encodeGroupedToken({
|
||||
kind: 'free-text',
|
||||
promptKey,
|
||||
questionId: question.id
|
||||
})
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one step's answer out of what the question card sent back. */
|
||||
function answerFromResponse(
|
||||
response: string,
|
||||
question: AgentJournalQuestion,
|
||||
promptKey: string
|
||||
): AgentSessionQuestionAnswer | null {
|
||||
// Multi-select submits comma-joined parts; tokens and free text are encoded, so the separator is stable.
|
||||
const optionIds: string[] = []
|
||||
let other: string | undefined
|
||||
for (const part of response.split(', ')) {
|
||||
const trimmed = part.trim()
|
||||
const freeText = decodeGroupedFreeTextAnswer(trimmed)
|
||||
if (freeText) {
|
||||
const answer = freeText.answer.trim()
|
||||
if (
|
||||
freeText.promptKey !== promptKey ||
|
||||
freeText.questionId !== question.id ||
|
||||
answer.length === 0 ||
|
||||
other !== undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
other = answer
|
||||
continue
|
||||
}
|
||||
|
||||
const payload = decodeGroupedToken(trimmed)
|
||||
if (
|
||||
payload?.kind !== 'option' ||
|
||||
payload.promptKey !== promptKey ||
|
||||
payload.questionId !== question.id
|
||||
) {
|
||||
return null
|
||||
}
|
||||
optionIds.push(payload.optionId)
|
||||
}
|
||||
const offered = new Set(question.options.map((option) => option.id))
|
||||
if (optionIds.some((optionId) => !offered.has(optionId))) {
|
||||
return null
|
||||
}
|
||||
if (other && !question.freeTextQuestionId) {
|
||||
return null
|
||||
}
|
||||
const answerCount = optionIds.length + (other ? 1 : 0)
|
||||
if (answerCount === 0 || (!question.multiSelect && answerCount !== 1)) {
|
||||
return null
|
||||
}
|
||||
return { questionId: question.id, optionIds, ...(other ? { other } : {}) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one answer into the draft. Returns `advance` while questions remain and `submit` with the
|
||||
* encoded group once the last one lands; null when the response does not answer this prompt step.
|
||||
*/
|
||||
export function advanceGroupedQuestion(args: {
|
||||
response: string
|
||||
questions: readonly AgentJournalQuestion[]
|
||||
draft: GroupedQuestionDraft | null
|
||||
promptKey: string
|
||||
}): GroupedQuestionAdvance | null {
|
||||
const collected = answersFor(args.draft, args.promptKey)
|
||||
const question = args.questions[collected.length]
|
||||
if (!question) {
|
||||
return null
|
||||
}
|
||||
const answer = answerFromResponse(args.response, question, args.promptKey)
|
||||
if (!answer) {
|
||||
return null
|
||||
}
|
||||
const answers = [...collected, answer]
|
||||
if (answers.length < args.questions.length) {
|
||||
return { kind: 'advance', draft: { promptKey: args.promptKey, answers } }
|
||||
}
|
||||
// Never send a group the host would refuse — the user would see a silent failure with no way back.
|
||||
return isValidAgentSessionQuestionAnswers(args.questions, answers)
|
||||
? { kind: 'submit', optionId: encodeAgentSessionQuestionAnswers(answers) }
|
||||
: null
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import type { MobileNewTabAgentOption } from './mobile-new-tab-agent-options'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
|
||||
import type { Terminal, TerminalCreateResult } from './mobile-session-route-types'
|
||||
import type { MobileSessionAttachmentsModel } from './use-mobile-session-attachments'
|
||||
import { createMobileStructuredCodexSession } from './mobile-structured-agent-session-launch'
|
||||
import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle'
|
||||
import { createMobileStructuredAgentSession } from './mobile-structured-agent-session-launch'
|
||||
|
||||
export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttachmentsModel) {
|
||||
const {
|
||||
@@ -63,9 +64,9 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach
|
||||
.slice(2, 10)}`
|
||||
|
||||
try {
|
||||
// Bare Codex launches follow structured support; prompted launches keep their startup semantics.
|
||||
if (agent === 'codex' && options === undefined) {
|
||||
const structured = await createMobileStructuredCodexSession(client, worktreeId)
|
||||
// Bare structured-provider launches follow host createSupport; prompted launches keep their startup semantics.
|
||||
if (isAgentSessionHandleProvider(agent) && options === undefined) {
|
||||
const structured = await createMobileStructuredAgentSession(client, worktreeId, agent)
|
||||
if (structured.kind === 'created') {
|
||||
const previous = activeHandleRef.current
|
||||
if (previous) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import type {
|
||||
AgentSessionCancelResult,
|
||||
AgentSessionPromptResult,
|
||||
AgentSessionSendResult
|
||||
} from '../../../src/shared/agent-session-wire'
|
||||
import type {
|
||||
@@ -21,9 +20,7 @@ import {
|
||||
pendingStructuredApproval,
|
||||
pendingStructuredQuestion,
|
||||
projectStructuredPermission,
|
||||
projectStructuredQuestion,
|
||||
structuredApprovalResponseTarget,
|
||||
structuredQuestionResponseTarget
|
||||
projectStructuredQuestion
|
||||
} from './mobile-structured-agent-prompts'
|
||||
import {
|
||||
requestStructuredAgentSessionMutation,
|
||||
@@ -36,6 +33,7 @@ import type { MobileChatPermission } from './mobile-native-chat-permission'
|
||||
import type { MobileChatQuestion } from './mobile-native-chat-question'
|
||||
import type { MobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state'
|
||||
import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses'
|
||||
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
|
||||
|
||||
type StructuredMobileAttachment = StructuredAgentSessionAttachment & { id?: string }
|
||||
@@ -196,51 +194,12 @@ export function useMobileStructuredAgentSession(args: {
|
||||
[client, enabled, onSendError, sessionId, sessionKey]
|
||||
)
|
||||
|
||||
const respondPermission = useCallback(
|
||||
async (optionId: string): Promise<boolean> => {
|
||||
const target = structuredApprovalResponseTarget(
|
||||
optionId,
|
||||
stateRef.current.items.find(pendingStructuredApproval) ?? null
|
||||
)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
const result = await mutate<AgentSessionPromptResult>(
|
||||
'agentSession.respondToApproval',
|
||||
'agentSession.respondTo:approval',
|
||||
target
|
||||
)
|
||||
if (result.status === 'unknown') {
|
||||
onSendError('Response unconfirmed — check chat before retrying')
|
||||
return false
|
||||
}
|
||||
return result.status === 'accepted'
|
||||
},
|
||||
[mutate, onSendError]
|
||||
)
|
||||
|
||||
const respondQuestion = useCallback(
|
||||
async (answer: string): Promise<boolean> => {
|
||||
const target = structuredQuestionResponseTarget(
|
||||
answer,
|
||||
stateRef.current.items.find(pendingStructuredQuestion) ?? null
|
||||
)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
const result = await mutate<AgentSessionPromptResult>(
|
||||
'agentSession.respondToQuestion',
|
||||
'agentSession.respondTo:question',
|
||||
target
|
||||
)
|
||||
if (result.status === 'unknown') {
|
||||
onSendError('Answer unconfirmed — check chat before retrying')
|
||||
return false
|
||||
}
|
||||
return result.status === 'accepted'
|
||||
},
|
||||
[mutate, onSendError]
|
||||
)
|
||||
const { groupedDraft, respondPermission, respondQuestion } = useMobileStructuredPromptResponses({
|
||||
stateRef,
|
||||
sessionKey,
|
||||
mutate,
|
||||
onSendError
|
||||
})
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
const current = stateRef.current
|
||||
@@ -303,7 +262,7 @@ export function useMobileStructuredAgentSession(args: {
|
||||
sendWithOutcome,
|
||||
cancel,
|
||||
permission: projectStructuredPermission(approvalPrompt),
|
||||
question: projectStructuredQuestion(questionPrompt),
|
||||
question: projectStructuredQuestion(questionPrompt, groupedDraft),
|
||||
optionSnapshot,
|
||||
optionSurface,
|
||||
pendingOptionId,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { createElement, useRef } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionPromptResult } from '../../../src/shared/agent-session-wire'
|
||||
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
|
||||
import {
|
||||
EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
type StructuredAgentSessionState
|
||||
} from '../../../src/shared/structured-agent-session-reducer'
|
||||
import { projectStructuredQuestion } from './mobile-structured-agent-prompts'
|
||||
import type {
|
||||
StructuredAgentSessionMutate,
|
||||
StructuredAgentSessionMutationResult
|
||||
} from './mobile-structured-agent-session-rpc'
|
||||
import { groupedQuestionPromptKey } from './mobile-structured-grouped-question'
|
||||
import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses'
|
||||
|
||||
type PromptResponses = ReturnType<typeof useMobileStructuredPromptResponses>
|
||||
|
||||
let currentHook: PromptResponses | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function groupedPrompt(itemId: string, revision: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId,
|
||||
revision,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: {
|
||||
kind: 'question',
|
||||
question: '2 grouped questions from Claude',
|
||||
options: [],
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
question: 'First?',
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ id: 'q1:choice-1', label: 'One' },
|
||||
{ id: 'q1:choice-2', label: 'Another one' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
question: 'Second?',
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ id: 'q2:choice-1', label: 'Two' },
|
||||
{ id: 'q2:choice-2', label: 'Another two' }
|
||||
]
|
||||
}
|
||||
],
|
||||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sessionState(prompt: AgentJournalRenderItem): StructuredAgentSessionState {
|
||||
return { ...EMPTY_STRUCTURED_AGENT_SESSION, status: 'ready', items: [prompt] }
|
||||
}
|
||||
|
||||
function projectedResponse(prompt: AgentJournalRenderItem, draft: PromptResponses['groupedDraft']) {
|
||||
const projected = projectStructuredQuestion(prompt, draft)
|
||||
const response = projected?.optionTokens[0]
|
||||
if (!response) {
|
||||
throw new Error('Grouped question did not project an option response')
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
function Probe(props: {
|
||||
sessionKey: string
|
||||
state: StructuredAgentSessionState
|
||||
mutate: StructuredAgentSessionMutate
|
||||
}) {
|
||||
const stateRef = useRef(props.state)
|
||||
stateRef.current = props.state
|
||||
currentHook = useMobileStructuredPromptResponses({
|
||||
stateRef,
|
||||
sessionKey: props.sessionKey,
|
||||
mutate: props.mutate,
|
||||
onSendError: vi.fn()
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function hook(): PromptResponses {
|
||||
if (!currentHook) {
|
||||
throw new Error('Hook probe is not mounted')
|
||||
}
|
||||
return currentHook
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
currentHook = null
|
||||
renderer = null
|
||||
})
|
||||
|
||||
describe('useMobileStructuredPromptResponses', () => {
|
||||
it.each([
|
||||
['another session', 'session-b', groupedPrompt('item-b', 1)],
|
||||
['a newer prompt revision', 'session-a', groupedPrompt('item-a', 2)]
|
||||
])(
|
||||
'does not let a completed grouped response clear %s draft',
|
||||
async (_, nextSession, nextPrompt) => {
|
||||
const firstPrompt = groupedPrompt('item-a', 1)
|
||||
let resolveMutation!: (
|
||||
value: StructuredAgentSessionMutationResult<AgentSessionPromptResult>
|
||||
) => void
|
||||
const pendingMutation = new Promise<
|
||||
StructuredAgentSessionMutationResult<AgentSessionPromptResult>
|
||||
>((resolve) => {
|
||||
resolveMutation = resolve
|
||||
})
|
||||
const mutate = vi.fn(() => pendingMutation) as unknown as StructuredAgentSessionMutate
|
||||
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(Probe, {
|
||||
sessionKey: 'session-a',
|
||||
state: sessionState(firstPrompt),
|
||||
mutate
|
||||
})
|
||||
)
|
||||
})
|
||||
await act(async () => {
|
||||
await hook().respondQuestion(projectedResponse(firstPrompt, null))
|
||||
})
|
||||
let firstSubmission!: Promise<boolean>
|
||||
act(() => {
|
||||
firstSubmission = hook().respondQuestion(
|
||||
projectedResponse(firstPrompt, hook().groupedDraft)
|
||||
)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
renderer?.update(
|
||||
createElement(Probe, {
|
||||
sessionKey: nextSession,
|
||||
state: sessionState(nextPrompt),
|
||||
mutate
|
||||
})
|
||||
)
|
||||
})
|
||||
await act(async () => {
|
||||
await hook().respondQuestion(projectedResponse(nextPrompt, null))
|
||||
})
|
||||
expect(hook().groupedDraft?.answers).toHaveLength(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveMutation({
|
||||
status: 'accepted',
|
||||
value: {
|
||||
itemId: firstPrompt.itemId,
|
||||
revision: firstPrompt.revision,
|
||||
resolution: {
|
||||
state: 'resolved',
|
||||
selectedOptionId: 'q2:choice-1',
|
||||
resolvedBy: 'mobile',
|
||||
resolvedAt: 2
|
||||
}
|
||||
},
|
||||
sameFence: true
|
||||
})
|
||||
await firstSubmission
|
||||
})
|
||||
|
||||
expect(hook().groupedDraft?.promptKey).toBe(
|
||||
groupedQuestionPromptKey(nextPrompt.itemId, nextPrompt.revision)
|
||||
)
|
||||
expect(hook().groupedDraft?.answers).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { AgentSessionPromptResult } from '../../../src/shared/agent-session-wire'
|
||||
import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer'
|
||||
import {
|
||||
pendingStructuredApproval,
|
||||
pendingStructuredQuestion,
|
||||
structuredApprovalResponseTarget,
|
||||
structuredQuestionResponseTarget
|
||||
} from './mobile-structured-agent-prompts'
|
||||
import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc'
|
||||
import {
|
||||
advanceGroupedQuestion,
|
||||
groupedQuestionPromptKey,
|
||||
type GroupedQuestionDraft
|
||||
} from './mobile-structured-grouped-question'
|
||||
|
||||
/**
|
||||
* Answering the two durable prompt kinds. Kept beside the session hook rather than inside it
|
||||
* because grouped questions carry their own multi-step draft, which is state the rest of the
|
||||
* session does not touch.
|
||||
*/
|
||||
export function useMobileStructuredPromptResponses(args: {
|
||||
stateRef: { readonly current: StructuredAgentSessionState }
|
||||
sessionKey: string
|
||||
mutate: StructuredAgentSessionMutate
|
||||
onSendError: (message: string) => void
|
||||
}): {
|
||||
groupedDraft: GroupedQuestionDraft | null
|
||||
respondPermission: (optionId: string) => Promise<boolean>
|
||||
respondQuestion: (answer: string) => Promise<boolean>
|
||||
} {
|
||||
const { mutate, onSendError, sessionKey, stateRef } = args
|
||||
// Partially answered grouped question, held only until its last step is submitted. The session it
|
||||
// was collected in is stored with it and checked on read, so switching sessions drops the draft
|
||||
// without an effect that would render the stale one for a frame first.
|
||||
const [collected, setCollected] = useState<{
|
||||
sessionKey: string
|
||||
draft: GroupedQuestionDraft
|
||||
} | null>(null)
|
||||
const groupedDraft = collected?.sessionKey === sessionKey ? collected.draft : null
|
||||
|
||||
const respondPermission = useCallback(
|
||||
async (optionId: string): Promise<boolean> => {
|
||||
const target = structuredApprovalResponseTarget(
|
||||
optionId,
|
||||
stateRef.current.items.find(pendingStructuredApproval) ?? null
|
||||
)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
const result = await mutate<AgentSessionPromptResult>(
|
||||
'agentSession.respondToApproval',
|
||||
'agentSession.respondTo:approval',
|
||||
target
|
||||
)
|
||||
if (result.status === 'unknown') {
|
||||
onSendError('Response unconfirmed — check chat before retrying')
|
||||
return false
|
||||
}
|
||||
return result.status === 'accepted'
|
||||
},
|
||||
[mutate, onSendError, stateRef]
|
||||
)
|
||||
|
||||
const respondQuestion = useCallback(
|
||||
async (answer: string): Promise<boolean> => {
|
||||
const prompt = stateRef.current.items.find(pendingStructuredQuestion) ?? null
|
||||
if (prompt?.body.questions) {
|
||||
const promptKey = groupedQuestionPromptKey(prompt.itemId, prompt.revision)
|
||||
const grouped = advanceGroupedQuestion({
|
||||
response: answer,
|
||||
questions: prompt.body.questions,
|
||||
draft: groupedDraft,
|
||||
promptKey
|
||||
})
|
||||
if (!grouped) {
|
||||
return false
|
||||
}
|
||||
if (grouped.kind === 'advance') {
|
||||
setCollected({ sessionKey, draft: grouped.draft })
|
||||
return true
|
||||
}
|
||||
const result = await mutate<AgentSessionPromptResult>(
|
||||
'agentSession.respondToQuestion',
|
||||
'agentSession.respondTo:question',
|
||||
{ itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: grouped.optionId }
|
||||
)
|
||||
if (result.status !== 'rejected') {
|
||||
// The group left the phone; a retry must start from the first question, not a stale tail.
|
||||
setCollected((current) =>
|
||||
current?.sessionKey === sessionKey && current.draft.promptKey === promptKey
|
||||
? null
|
||||
: current
|
||||
)
|
||||
}
|
||||
if (result.status === 'unknown') {
|
||||
onSendError('Answer unconfirmed — check chat before retrying')
|
||||
return false
|
||||
}
|
||||
return result.status === 'accepted'
|
||||
}
|
||||
const target = structuredQuestionResponseTarget(answer, prompt)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
const result = await mutate<AgentSessionPromptResult>(
|
||||
'agentSession.respondToQuestion',
|
||||
'agentSession.respondTo:question',
|
||||
target
|
||||
)
|
||||
if (result.status === 'unknown') {
|
||||
onSendError('Answer unconfirmed — check chat before retrying')
|
||||
return false
|
||||
}
|
||||
return result.status === 'accepted'
|
||||
},
|
||||
[groupedDraft, mutate, onSendError, sessionKey, stateRef]
|
||||
)
|
||||
|
||||
return { groupedDraft, respondPermission, respondQuestion }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
} from '../../../src/shared/protocol-version'
|
||||
import { MOBILE_RUNTIME_CLIENT_CAPABILITIES } from './mobile-runtime-client-capabilities'
|
||||
|
||||
/** Mirrors the host's `parseRuntimeClientCapabilities`, which returns an EMPTY list — silently
|
||||
* dropping every capability, not just the excess — when the array is longer than this or any
|
||||
* entry is longer than 128 chars. Growing past it would look exactly like an old client. */
|
||||
const HOST_CAPABILITY_LIMIT = 64
|
||||
const HOST_CAPABILITY_NAME_LIMIT = 128
|
||||
|
||||
describe('mobile runtime client capabilities', () => {
|
||||
it('advertises structured agent sessions including the Claude lane', () => {
|
||||
expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES).toEqual(
|
||||
expect.arrayContaining([
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('stays inside the bounds the host parses, which fail closed to no capabilities at all', () => {
|
||||
expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES.length).toBeLessThanOrEqual(HOST_CAPABILITY_LIMIT)
|
||||
for (const capability of MOBILE_RUNTIME_CLIENT_CAPABILITIES) {
|
||||
expect(capability.length).toBeGreaterThan(0)
|
||||
expect(capability.length).toBeLessThanOrEqual(HOST_CAPABILITY_NAME_LIMIT)
|
||||
}
|
||||
})
|
||||
|
||||
it('advertises each capability once so duplicates cannot consume the budget', () => {
|
||||
expect(new Set(MOBILE_RUNTIME_CLIENT_CAPABILITIES).size).toBe(
|
||||
MOBILE_RUNTIME_CLIENT_CAPABILITIES.length
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
} from '../../../src/shared/protocol-version'
|
||||
@@ -6,7 +7,8 @@ import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runt
|
||||
|
||||
export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY
|
||||
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
])
|
||||
|
||||
export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD =
|
||||
|
||||
@@ -90,7 +90,10 @@ describe('mobile rpc-client capabilities', () => {
|
||||
|
||||
const capabilityRequest = sentRequest(socket, 'runtime.clientCapabilities.update')
|
||||
expect(capabilityRequest.params).toMatchObject({
|
||||
clientCapabilities: expect.arrayContaining(['agent-session.structured.v1'])
|
||||
clientCapabilities: expect.arrayContaining([
|
||||
'agent-session.structured.v1',
|
||||
'agent-session.structured.claude.v1'
|
||||
])
|
||||
})
|
||||
expect(socket.sent.some((payload) => payload.includes('session.tabs.subscribe'))).toBe(false)
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -109,7 +109,7 @@ overrides:
|
||||
monaco-editor>dompurify: 3.4.13
|
||||
|
||||
patchedDependencies:
|
||||
'@vscode/windows-process-tree@0.8.0': f8ea245391c94da5770045aeea01fa6de466c2199c6ef46b5b769b398aa9823e
|
||||
'@vscode/windows-process-tree@0.8.0': e66202cc623996d02040c93449eb9ae353fddadf426cb53202a59ee710ee6fe7
|
||||
'@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
|
||||
'@xterm/addon-search@0.17.0-beta.300': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0
|
||||
'@xterm/addon-serialize@0.15.0-beta.300': 851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294
|
||||
@@ -510,7 +510,7 @@ importers:
|
||||
optionalDependencies:
|
||||
'@vscode/windows-process-tree':
|
||||
specifier: 0.8.0
|
||||
version: 0.8.0(patch_hash=f8ea245391c94da5770045aeea01fa6de466c2199c6ef46b5b769b398aa9823e)
|
||||
version: 0.8.0(patch_hash=e66202cc623996d02040c93449eb9ae353fddadf426cb53202a59ee710ee6fe7)
|
||||
sherpa-onnx-darwin-arm64:
|
||||
specifier: 1.12.37
|
||||
version: 1.12.37
|
||||
@@ -9821,7 +9821,7 @@ snapshots:
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vscode/windows-process-tree@0.8.0(patch_hash=f8ea245391c94da5770045aeea01fa6de466c2199c6ef46b5b769b398aa9823e)':
|
||||
'@vscode/windows-process-tree@0.8.0(patch_hash=e66202cc623996d02040c93449eb9ae353fddadf426cb53202a59ee710ee6fe7)':
|
||||
dependencies:
|
||||
node-addon-api: 7.1.0
|
||||
optional: true
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user